Skill: koto9x/discord-voice-memo-upgrade (version 1.0.0)

# Discord Voice Memo Upgrades - Skill Documentation ## Overview This skill provides a core patch for Moltbot that fixes voice memo TTS auto-replies. The issue occurs when block streaming prevents the final payload from reaching the TTS synthesis pipeline. ## Type **Core Patch / Documentation** This is not a traditional plugin that extends functionality - it's a documentation package with patch files for core Clawdbot modifications. ## Use Case Use this if you're experiencing: - Voice memos not triggering TTS responses - TTS working for text messages but not audio messages - TTS auto mode = "inbound" not functioning ## Installation Methods ### Method 1: Manual Patch (Recommended for Development) ```bash # 1. Locate your clawdbot installation CLAWDBOT_PATH=$(which clawdbot) CLAWDBOT_DIR=$(dirname $(dirname $CLAWDBOT_PATH)) # 2. Backup original files cp $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/auto-reply/reply/dispatch-from-config.js \ $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/auto-reply/reply/dispatch-from-config.js.backup cp $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/tts/tts.js \ $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/tts/tts.js.backup # 3. Apply patch cp patch/dispatch-from-config.js $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/auto-reply/reply/ cp patch/tts.js $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/tts/ # 4. Restart clawdbot clawdbot restart ``` ### Method 2: Wait for Upstream If this patch gets accepted into core Clawdbot, you can simply update: ```bash npm install -g clawdbot@latest ``` ## Configuration No additional configuration needed beyond existing TTS settings. Ensure you have: ```json { "messages": { "tts": { "auto": "inbound", // or "always" "provider": "openai", // or "elevenlabs" or "edge" "elevenlabs": { "apiKey": "your-key-here" } } } } ``` ## How to Test 1. Configure TTS with `auto: "inbound"` 2. Send a voice memo to your bot 3. Check logs for debug output: ``` [TTS-DEBUG] inboundAudio=true ttsAutoResolved=inbound ttsWillFire=true [TTS-APPLY] PASSED all checks, proceeding to textToSpeech [TTS-SPEECH] ... ``` 4. Verify bot responds with audio ## Debug Logging The patch includes extensive debug logging. To view: ```bash # Logs will show in your clawdbot console clawdbot gateway start ``` Look for: - `[TTS-DEBUG]` - Shows TTS detection logic - `[TTS-APPLY]` - Shows TTS payload processing decisions - `[TTS-SPEECH]` - Shows TTS synthesis attempt ## Production Deployment **Important**: Before deploying to production, consider: 1. **Remove debug logging** - The `console.log` statements should be removed or made configurable 2. **Test thoroughly** - Ensure voice memos work correctly 3. **Monitor performance** - Disabling block streaming may impact streaming behavior To remove debug logging, edit the patched files and remove lines containing: - `console.log('[TTS-DEBUG]'` - `console.log('[TTS-APPLY]'` - `console.log('[TTS-SPEECH]'` ## Reverting If you need to revert the patch: ```bash # Restore backups CLAWDBOT_PATH=$(which clawdbot) CLAWDBOT_DIR=$(dirname $(dirname $CLAWDBOT_PATH)) cp $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/auto-reply/reply/dispatch-from-config.js.backup \ $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/auto-reply/reply/dispatch-from-config.js cp $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/tts/tts.js.backup \ $CLAWDBOT_DIR/lib/node_modules/clawdbot/dist/tts/tts.js clawdbot restart ``` ## Technical Details ### The Problem Block streaming is used to send incremental text chunks to the user as they're generated. However, TTS synthesis hooks into the "final" payload type by default. When block streaming is enabled: 1. Text chunks are sent as "block" payloads 2. The final assembled text is sent as a "final" payload 3. But block streaming optimization drops the final payload (text already sent) 4. TTS never fires because it only processes "final" payloads ### The Solution The patch adds detection logic to identify when TTS should fire: - Inbound message has audio attachment (`isInboundAudioContext()`) - TTS auto mode is "inbound" or "always" - Valid TTS provider and API key configured When these conditions are met, block streaming is temporarily disabled for that specific reply, ensuring the final payload reaches the TTS pipeline. ### Code Flow ``` dispatchReplyFromConfig() ├─ isInboundAudioContext(ctx) → detects audio ├─ resolveSessionTtsAuto(ctx, cfg) → gets TTS settings ├─ ttsWillFire = conditions met? └─ getReplyFromConfig({ disableBlockStreaming: ttsWillFire }) └─ maybeApplyTtsToPayload() receives final payload └─ textToSpeech() synthesizes audio ``` ## Compatibility - **Clawdbot**: 1.0.0+ - **Node.js**: 18+ - **Platforms**: All platforms supported by Clawdbot ## Known Issues - Debug logging is verbose (should be removed for production) - Modifies compiled dist files (not source) - May need to reapply after clawdbot updates ## Contributing To improve this patch: 1. Test with different TTS providers (OpenAI, ElevenLabs, Edge) 2. Test with different auto modes ("always", "inbound", "tagged") 3. Suggest optimizations to reduce debug logging overhead 4. Propose integration into core Clawdbot source ## Support If you encounter issues: 1. Check logs for `[TTS-DEBUG]` output 2. Verify TTS configuration is correct 3. Ensure API keys are valid 4. Check that block streaming was actually disabled (`disableBlockStreaming: true` in logs) ## License Same as Moltbot.

path: CHANGELOG.md
content: # Changelog

All notable changes to the Discord Voice Memo Upgrades will be documented in this file.

## [1.0.0] - 2026-01-28

### Added
- Initial release of TTS voice memo auto-reply fix
- Core patch for `dispatch-from-config.js` to disable block streaming when TTS will fire
- Detection logic for inbound audio context
- Session-level TTS auto mode resolution
- Debug logging for TTS pipeline tracking in `tts.js`
- Comprehensive documentation of the fix

### Fixed
- Voice memo TTS auto-replies not working due to block streaming dropping final payloads
- TTS synthesis pipeline not receiving complete response payload
- Inbound audio messages not triggering TTS when auto mode = "inbound"

### Technical Details
- Added `ttsWillFire` detection based on inbound audio and TTS auto mode
- Added `disableBlockStreaming: ttsWillFire` to ensure final payload reaches TTS
- Added `[TTS-DEBUG]`, `[TTS-APPLY]`, and `[TTS-SPEECH]` debug logging

### Notes
- Debug logging should be removed or made configurable before production deployment
- This is a core modification, not a plugin - manual patching required
- Compatible with latest Moltbot versions


path: index.js
content: /**
 * Discord Voice Memo Upgrades
 *
 * This is a documentation-only package that contains patch files
 * for core Clawdbot modifications to enable TTS auto-replies for
 * voice memo messages.
 *
 * This file exists to satisfy Node.js package requirements but
 * does not export any functionality since this is a core patch,
 * not a plugin.
 */

module.exports = {
  name: 'discord-voice-memo-upgrades',
  version: '1.0.0',
  type: 'core-patch',
  description: 'Fixes voice memo TTS auto-replies by disabling block streaming when TTS will fire',

  // Metadata for documentation
  metadata: {
    filesModified: [
      'dist/auto-reply/reply/dispatch-from-config.js',
      'dist/tts/tts.js'
    ],
    keyChange: 'Added disableBlockStreaming: ttsWillFire to ensure final payload reaches TTS pipeline',
    debugLogging: 'Added [TTS-DEBUG], [TTS-APPLY], and [TTS-SPEECH] console logging',
    productionNote: 'Debug logging should be removed before production deployment'
  }
};


path: README.md
content: # Discord Voice Memo Upgrades for Moltbot/Clawdbot

Fixes voice memo TTS auto-replies by ensuring the final payload reaches the TTS synthesis pipeline when block streaming is enabled.

## Problem

When block streaming is enabled, intermediate text chunks are sent but the final assembled payload (which TTS hooks into) gets dropped. This prevents TTS from synthesizing audio responses for voice memo messages, even when TTS auto-reply is configured correctly.

## Solution

The fix disables block streaming temporarily when TTS will fire, ensuring the complete response payload reaches the TTS synthesis pipeline.

## Changes Made

### 1. `dispatch-from-config.js` (auto-reply/reply/)

**Core Fix**: Added logic to detect when TTS will fire and disable block streaming for that reply.

**Key Changes**:
- Added `disableBlockStreaming: ttsWillFire` — When TTS is going to fire (inbound audio message, TTS auto mode = "inbound", valid provider+apiKey), block streaming is disabled for that reply so the final payload reaches the TTS pipeline.
- Added detection logic for inbound audio context
- Added TTS pipeline configuration resolution
- Added debug logging (should be cleaned up for production)

**Detection Logic** (around line 101-111):
```javascript
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
const ttsAutoResolved = normalizeTtsAutoMode(sessionTtsAuto) ?? normalizeTtsAutoMode(cfg.messages?.tts?.auto);
const ttsWillFire = ttsAutoResolved === "always" || (ttsAutoResolved === "inbound" && inboundAudio);
```

**Applied at line 232**:
```javascript
disableBlockStreaming: ttsWillFire || params.replyOptions?.disableBlockStreaming,
```

### 2. `tts.js` (tts/)

**Debug Enhancements**: Added comprehensive logging to track TTS pipeline decisions.

**Changes**:
- Added `[TTS-DEBUG]` logging in `dispatchReplyFromConfig()` showing inboundAudio, ttsAutoResolved, ttsWillFire, MediaType, etc.
- Added `[TTS-APPLY]` debug logging at every early-return in `maybeApplyTtsToPayload()` — shows exactly which check causes TTS to skip
- Added `[TTS-SPEECH]` debug logging in `textToSpeech()` — shows the resolved provider chain and API key status

**Note**: Debug logging lines should be removed or converted to proper debug-level logging before production deployment.

## Files Modified

- `/path/to/clawdbot/dist/auto-reply/reply/dispatch-from-config.js`
- `/path/to/clawdbot/dist/tts/tts.js`

## What to Ship

The actual fix is just the `disableBlockStreaming: ttsWillFire` change in `dispatch-from-config.js`. The debug logging should be stripped before shipping to production.

## How It Works

1. When a voice memo arrives, `isInboundAudioContext()` detects the audio attachment
2. `resolveSessionTtsAuto()` checks the session's TTS auto mode setting
3. `ttsWillFire` is set to `true` if conditions are met (inbound audio + auto mode)
4. Block streaming is disabled for this reply only
5. The complete response payload reaches `maybeApplyTtsToPayload()`
6. TTS synthesis occurs and audio is returned

## Installation

Since these are core Clawdbot modifications, you'll need to either:

1. **Apply the patch** to your Clawdbot installation (see `patch/` directory)
2. **Wait for upstream merge** if this gets accepted into core Clawdbot
3. **Manually edit** the files in your `node_modules/clawdbot/dist/` directory

## Testing

To verify the fix works:

1. Configure TTS with `auto: "inbound"` mode
2. Send a voice memo to your bot via Discord/Telegram
3. Check logs for `[TTS-DEBUG]` and `[TTS-APPLY]` messages
4. Verify bot responds with synthesized audio

## Production Recommendations

Before deploying to production:
- Remove all `console.log` debug statements
- Consider making the logging configurable via debug levels
- Add tests for the TTS detection logic
- Document the configuration options

## Version

- **Version**: 1.0.0
- **Clawdbot Core Version**: Compatible with latest
- **Author**: k121
- **Date**: 2026-01-28

## License

Same as Moltbot.


path: UPLOAD.md
content: # Upload Instructions for ClawdHub

This document explains how to upload the TTS Voice Memo Fix skill to ClawdHub.

## What You're Submitting

**Skill Name**: Discord Voice Memo Upgrades
**Version**: 1.0.0
**Type**: Core Patch / Documentation
**Category**: Core Enhancement

## Package Contents

- `README.md` - Overview and problem description
- `SKILL.md` - Detailed skill documentation
- `PATCH.md` - Technical patch details
- `CHANGELOG.md` - Version history
- `clawdbot.plugin.json` - Skill metadata
- `package.json` - npm package metadata
- `index.js` - Package entry point
- `patch/` - Directory containing modified core files
  - `dispatch-from-config.js` - Modified auto-reply dispatcher
  - `tts.js` - Modified TTS handler with debug logging

## Pre-Upload Checklist

- [x] README.md includes problem description and solution
- [x] SKILL.md includes installation and testing instructions
- [x] PATCH.md documents exact code changes
- [x] CHANGELOG.md lists all changes
- [x] clawdbot.plugin.json has correct metadata
- [x] package.json has correct version (1.0.0)
- [x] Patch files are included in `patch/` directory
- [x] Tarball created: `discord-voice-memo-upgrades-v1.0.0.tar.gz`

## Upload Methods

### Method 1: Upload Tarball

1. Navigate to https://clawdhub.com/upload
2. Select "Upload Skill Package"
3. Choose file: `discord-voice-memo-upgrades-v1.0.0.tar.gz`
4. Fill in metadata:
   - **Title**: Discord Voice Memo Upgrades
   - **Version**: 1.0.0
   - **Category**: Core Patch
   - **Description**: Fixes voice memo TTS auto-replies by disabling block streaming when TTS will fire
   - **Tags**: tts, voice, audio, voice-memo, text-to-speech, auto-reply, clawdbot, moltbot, core-patch
5. Submit

### Method 2: Upload Directory

If ClawdHub supports directory upload:

1. Navigate to https://clawdhub.com/upload
2. Select "Upload from Directory"
3. Choose directory: [REDACTED_SECRET]
4. Fill in metadata (same as above)
5. Submit

### Method 3: Git Repository (Future)

If you create a GitHub repository for this skill:

```bash
cd /Users/k121/MEGA/DEV/local/clawd/skills/discord-voice-memo-upgrades
git init
git add .
git commit -m "Initial release: Discord Voice Memo Upgrades v1.0.0"
git remote add origin <your-repo-url>
git push -u origin main
```

Then submit the repository URL to ClawdHub.

## Metadata for Upload Form

Copy/paste these values when filling out the upload form:

**Name**: Discord Voice Memo Upgrades
**Slug**: discord-voice-memo-upgrades
**Version**: 1.0.0
**Author**: k121
**Category**: Core Patch
**Type**: Documentation

**Short Description**:
Fixes voice memo TTS auto-replies by disabling block streaming when TTS will fire

**Long Description**:
This skill provides a core patch for Moltbot that fixes voice memo TTS auto-replies. The issue occurs when block streaming prevents the final payload from reaching the TTS synthesis pipeline. The fix detects when TTS should fire and temporarily disables block streaming for that specific reply, ensuring the complete response reaches the TTS synthesis pipeline.

**Tags**:
tts, voice, audio, voice-memo, text-to-speech, auto-reply, clawdbot, moltbot, core-patch, bug-fix

**Requirements**:
- Clawdbot >= 1.0.0
- Node.js >= 18.0.0
- Manual patch application required

**Installation Type**: Manual Patch

**License**: MIT

## Notes for Reviewers

This is a **core patch**, not a traditional plugin. It modifies compiled dist files in the Clawdbot installation:

1. `dist/auto-reply/reply/dispatch-from-config.js`
2. `dist/tts/tts.js`

**Key Change**: Added `disableBlockStreaming: ttsWillFire` logic to ensure TTS receives final payloads.

**Debug Logging**: Includes extensive console.log debugging that should be removed for production.

## Post-Upload

After uploading, share the ClawdHub link:
- Discord: Share in #skills or #announcements
- Twitter/X: Tweet with #Clawdbot #Moltbot #TTS
- GitHub: Create issue/PR in clawdbot repo linking to ClawdHub skill

## Support

If users have questions or issues:
- Point them to the SKILL.md for installation instructions
- Point them to PATCH.md for technical details
- Encourage testing with different TTS providers

## Version History

- **1.0.0** (2026-01-28) - Initial release


path: package.json
content: {
  "name": "discord-voice-memo-upgrades",
  "version": "1.0.0",
  "description": "Core patch for Clawdbot/Moltbot to enable TTS auto-replies for voice memo messages",
  "main": "index.js",
  "scripts": {
    "test": "echo \"No tests yet\" && exit 0"
  },
  "keywords": [
    "clawdbot",
    "moltbot",
    "tts",
    "voice",
    "audio",
    "patch"
  ],
  "author": "k121",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": ""
  },
  "peerDependencies": {
    "clawdbot": ">=1.0.0"
  }
}


path: PATCH.md
content: # Core Patch Details

This document describes the exact changes made to core Clawdbot files.

## File 1: `dist/auto-reply/reply/dispatch-from-config.js`

### Location of Changes

Lines 101-111 (detection logic):
```javascript
const inboundAudio = isInboundAudioContext(ctx);
const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
// Disable block streaming when TTS should produce audio for this response.
// Block streaming drops final payloads (text already sent as blocks), but
// TTS only fires on "final" kind payloads (default mode), so TTS never
// triggers when block streaming is active.  Disabling block streaming for
// TTS-eligible messages ensures the final reply carries text for synthesis.
const ttsAutoResolved = normalizeTtsAutoMode(sessionTtsAuto) ?? normalizeTtsAutoMode(cfg.messages?.tts?.auto);
const ttsWillFire = ttsAutoResolved === "always" || (ttsAutoResolved === "inbound" && inboundAudio);
// DEBUG: TTS pipeline tracing
console.log(`[TTS-DEBUG] inboundAudio=${inboundAudio} sessionTtsAuto=${sessionTtsAuto} ttsAutoResolved=${ttsAutoResolved} ttsWillFire=${ttsWillFire} MediaType=${ctx.MediaType} MediaTypes=${JSON.stringify(ctx.MediaTypes)} Body=${(ctx.Body ?? "").slice(0, 80)}`);
```

Line 232 (application):
```javascript
disableBlockStreaming: ttsWillFire || params.replyOptions?.disableBlockStreaming,
```

Line 234 (skip onBlockReply when TTS will fire):
```javascript
// Don't provide onBlockReply when TTS will fire - we need the full final payload for synthesis
onBlockReply: ttsWillFire ? undefined : (payload, context) => {
```

### Key Additions

1. **`isInboundAudioContext(ctx)`** - Already existed, now used for TTS detection
2. **`resolveSessionTtsAuto(ctx, cfg)`** - Already existed, now used for TTS detection
3. **`ttsWillFire`** variable - NEW, determines if TTS should fire for this message
4. **Debug logging** - NEW, shows TTS detection logic
5. **`disableBlockStreaming: ttsWillFire`** - NEW, disables block streaming when TTS will fire

## File 2: `dist/tts/tts.js`

### Location of Changes

Line 813 (in `textToSpeech()`):
```javascript
console.log(`[TTS-SPEECH] entry: channel=${params.channel} channelId=${channelId} textLen=${params.text.length} maxLen=${config.maxTextLength}`);
```

Line 815:
```javascript
console.log(`[TTS-SPEECH] FAIL: text too long`);
```

Line 825:
```javascript
console.log(`[TTS-SPEECH] provider=${provider} userProvider=${userProvider} providers=${JSON.stringify(providers)} [REDACTED_SECRET] ? "SET(" + config.elevenlabs.apiKey.slice(0, 8) + "...)" : "MISSING"}`);
```

Line 1054 (in `maybeApplyTtsToPayload()`):
```javascript
console.log(`[TTS-APPLY] entry: autoMode=${autoMode} kind=${params.kind} inboundAudio=${params.inboundAudio} channel=${params.channel} provider=${config.provider} [REDACTED_SECRET] ? "SET" : "MISSING"}`);
```

Lines 1056, 1075, 1079, 1084, 1088, 1092, 1096, 1100, 1103 - Various `[TTS-APPLY] SKIP:` messages

Line 1103:
```javascript
console.log(`[TTS-APPLY] PASSED all checks, proceeding to textToSpeech. textLen=${ttsText.trim().length}`);
```

Line 1134:
```javascript
console.log(`[TTS-APPLY] calling textToSpeech with ${textForAudio.length} chars, channel=${params.channel}`);
```

Line 1142:
```javascript
console.log(`[TTS-APPLY] textToSpeech result: success=${result.success} audioPath=${result.audioPath ?? "none"} error=${result.error ?? "none"} provider=${result.provider ?? "none"} latency=${result.latencyMs ?? "?"}ms`);
```

Line 1154:
```javascript
console.log(`[TTS-APPLY] SUCCESS: audioPath=${result.audioPath} shouldVoice=${shouldVoice}`);
```

Line 1168:
```javascript
console.log(`[TTS-APPLY] FAILED: ${result.error ?? "unknown"}`);
```

### Key Additions

All additions are debug logging with prefixes:
- **`[TTS-SPEECH]`** - Shows TTS synthesis attempts and results
- **`[TTS-APPLY]`** - Shows TTS payload processing decisions

## Summary of Changes

### dispatch-from-config.js
- **Lines added**: ~15 lines
- **Lines modified**: 3 lines
- **Purpose**: Detect when TTS will fire and disable block streaming

### tts.js
- **Lines added**: ~15 lines
- **Lines modified**: 0 lines
- **Purpose**: Add debug logging to track TTS pipeline behavior

## Production Readiness

Before shipping to production:

1. **Remove or make configurable** all `console.log` statements:
   - Lines with `[TTS-DEBUG]`
   - Lines with `[TTS-APPLY]`
   - Lines with `[TTS-SPEECH]`

2. **Alternative**: Convert to proper debug logging:
   ```javascript
   if (logVerbose.enabled) {
     logVerbose(`[TTS-DEBUG] ...`);
   }
   ```

3. **Core logic to keep**:
   - The `ttsWillFire` detection (lines 101-109 in dispatch-from-config.js)
   - The `disableBlockStreaming: ttsWillFire` application (line 232)
   - The `onBlockReply: ttsWillFire ? undefined : ...` modification (line 234)

## Testing the Patch

To verify the patch works:

```bash
# 1. Apply the patch
cp patch/dispatch-from-config.js /path/to/clawdbot/dist/auto-reply/reply/
cp patch/tts.js /path/to/clawdbot/dist/tts/

# 2. Configure TTS
# In clawdbot.json:
{
  "messages": {
    "tts": {
      "auto": "inbound",
      "provider": "openai",
      "openai": { "apiKey": "sk-..." }
    }
  }
}

# 3. Send a voice memo to your bot

# 4. Check logs - you should see:
# [TTS-DEBUG] inboundAudio=true ttsAutoResolved=inbound ttsWillFire=true ...
# [TTS-APPLY] entry: autoMode=inbound kind=final inboundAudio=true ...
# [TTS-APPLY] PASSED all checks, proceeding to textToSpeech. textLen=...
# [TTS-SPEECH] entry: channel=telegram channelId=telegram textLen=...
# [TTS-APPLY] SUCCESS: audioPath=/tmp/tts-... shouldVoice=true
```

## Reverting the Patch

If you backed up the original files:
```bash
cp /path/to/clawdbot/dist/auto-reply/reply/dispatch-from-config.js.backup \
   /path/to/clawdbot/dist/auto-reply/reply/dispatch-from-config.js

cp /path/to/clawdbot/dist/tts/tts.js.backup \
   /path/to/clawdbot/dist/tts/tts.js
```

Or reinstall clawdbot:
```bash
npm install -g clawdbot@latest
```


path: clawdbot.plugin.json
content: {
  "id": "discord-voice-memo-upgrades",
  "name": "Discord Voice Memo Upgrades",
  "version": "1.0.0",
  "description": "Core patch for Moltbot to enable TTS auto-replies for Discord voice memo messages",
  "author": "k121",
  "homepage": "https://clawdhub.com",
  "repository": {
    "type": "git",
    "url": ""
  },
  "keywords": [
    "discord",
    "tts",
    "voice",
    "audio",
    "voice-memo",
    "text-to-speech",
    "auto-reply",
    "moltbot",
    "core-patch"
  ],
  "category": "discord",
  "clawdbotVersion": ">=1.0.0",
  "type": "documentation",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {}
  },
  "uiHints": {}
}


path: patch/tts.js
content: import { existsSync, mkdirSync, readFileSync, writeFileSync, mkdtempSync, rmSync, renameSync, unlinkSync, } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { completeSimple } from "@mariozechner/pi-ai";
import { EdgeTTS } from "node-edge-tts";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { logVerbose } from "../globals.js";
import { isVoiceCompatibleAudio } from "../media/audio.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
import { buildModelAliasIndex, resolveDefaultModelForAgent, resolveModelRefFromString, } from "../agents/model-selection.js";
import { resolveModel } from "../agents/pi-embedded-runner/model.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4000;
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts";
const DEFAULT_OPENAI_VOICE = "alloy";
const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";
const DEFAULT_EDGE_LANG = "en-US";
const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
const DEFAULT_ELEVENLABS_VOICE_SETTINGS = {
    stability: 0.5,
    similarityBoost: 0.75,
    style: 0.0,
    useSpeakerBoost: true,
    speed: 1.0,
};
const TELEGRAM_OUTPUT = {
    openai: "opus",
    // ElevenLabs output formats use codec_sample_rate_bitrate naming.
    // Opus @ 48kHz/64kbps is a good voice-note tradeoff for Telegram.
    elevenlabs: "opus_48000_64",
    extension: ".opus",
    voiceCompatible: true,
};
const DEFAULT_OUTPUT = {
    openai: "mp3",
    elevenlabs: "mp3_44100_128",
    extension: ".mp3",
    voiceCompatible: false,
};
const TELEPHONY_OUTPUT = {
    openai: { format: "pcm", sampleRate: 24000 },
    elevenlabs: { format: "pcm_22050", sampleRate: 22050 },
};
const TTS_AUTO_MODES = new Set(["off", "always", "inbound", "tagged"]);
let lastTtsAttempt;
export function normalizeTtsAutoMode(value) {
    if (typeof value !== "string")
        return undefined;
    const normalized = value.trim().toLowerCase();
    if (TTS_AUTO_MODES.has(normalized)) {
        return normalized;
    }
    return undefined;
}
function resolveModelOverridePolicy(overrides) {
    const enabled = overrides?.enabled ?? true;
    if (!enabled) {
        return {
            enabled: false,
            allowText: false,
            allowProvider: false,
            allowVoice: false,
            allowModelId: false,
            allowVoiceSettings: false,
            allowNormalization: false,
            allowSeed: false,
        };
    }
    const allow = (value) => value ?? true;
    return {
        enabled: true,
        allowText: allow(overrides?.allowText),
        allowProvider: allow(overrides?.allowProvider),
        allowVoice: allow(overrides?.allowVoice),
        allowModelId: allow(overrides?.allowModelId),
        allowVoiceSettings: allow(overrides?.allowVoiceSettings),
        allowNormalization: allow(overrides?.allowNormalization),
        allowSeed: allow(overrides?.allowSeed),
    };
}
export function resolveTtsConfig(cfg) {
    const raw = cfg.messages?.tts ?? {};
    const providerSource = raw.provider ? "config" : "default";
    const edgeOutputFormat = raw.edge?.outputFormat?.trim();
    const auto = normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
    return {
        auto,
        mode: raw.mode ?? "final",
        provider: raw.provider ?? "edge",
        providerSource,
        summaryModel: raw.summaryModel?.trim() || undefined,
        modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
        elevenlabs: {
            [REDACTED_SECRET],
            baseUrl: raw.elevenlabs?.baseUrl?.trim() || DEFAULT_ELEVENLABS_BASE_URL,
            voiceId: raw.elevenlabs?.voiceId ?? DEFAULT_ELEVENLABS_VOICE_ID,
            modelId: raw.elevenlabs?.modelId ?? DEFAULT_ELEVENLABS_MODEL_ID,
            seed: raw.elevenlabs?.seed,
            applyTextNormalization: raw.elevenlabs?.applyTextNormalization,
            languageCode: raw.elevenlabs?.languageCode,
            voiceSettings: {
                stability: raw.elevenlabs?.voiceSettings?.stability ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.stability,
                similarityBoost: raw.elevenlabs?.voiceSettings?.similarityBoost ??
                    DEFAULT_ELEVENLABS_VOICE_SETTINGS.similarityBoost,
                style: raw.elevenlabs?.voiceSettings?.style ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.style,
                useSpeakerBoost: raw.elevenlabs?.voiceSettings?.useSpeakerBoost ??
                    DEFAULT_ELEVENLABS_VOICE_SETTINGS.useSpeakerBoost,
                speed: raw.elevenlabs?.voiceSettings?.speed ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.speed,
            },
        },
        openai: {
            [REDACTED_SECRET],
            model: raw.openai?.model ?? DEFAULT_OPENAI_MODEL,
            voice: raw.openai?.voice ?? DEFAULT_OPENAI_VOICE,
        },
        edge: {
            enabled: raw.edge?.enabled ?? true,
            voice: raw.edge?.voice?.trim() || DEFAULT_EDGE_VOICE,
            lang: raw.edge?.lang?.trim() || DEFAULT_EDGE_LANG,
            outputFormat: edgeOutputFormat || DEFAULT_EDGE_OUTPUT_FORMAT,
            outputFormatConfigured: Boolean(edgeOutputFormat),
            pitch: raw.edge?.pitch?.trim() || undefined,
            rate: raw.edge?.rate?.trim() || undefined,
            volume: raw.edge?.volume?.trim() || undefined,
            saveSubtitles: raw.edge?.saveSubtitles ?? false,
            proxy: raw.edge?.proxy?.trim() || undefined,
            timeoutMs: raw.edge?.timeoutMs,
        },
        prefsPath: raw.prefsPath,
        maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
        timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
    };
}
export function resolveTtsPrefsPath(config) {
    if (config.prefsPath?.trim())
        return resolveUserPath(config.prefsPath.trim());
    const envPath = process.env.CLAWDBOT_TTS_PREFS?.trim();
    if (envPath)
        return resolveUserPath(envPath);
    return path.join(CONFIG_DIR, "settings", "tts.json");
}
function resolveTtsAutoModeFromPrefs(prefs) {
    const auto = normalizeTtsAutoMode(prefs.tts?.auto);
    if (auto)
        return auto;
    if (typeof prefs.tts?.enabled === "boolean") {
        return prefs.tts.enabled ? "always" : "off";
    }
    return undefined;
}
export function resolveTtsAutoMode(params) {
    const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
    if (sessionAuto)
        return sessionAuto;
    const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath));
    if (prefsAuto)
        return prefsAuto;
    return params.config.auto;
}
export function buildTtsSystemPromptHint(cfg) {
    const config = resolveTtsConfig(cfg);
    const prefsPath = resolveTtsPrefsPath(config);
    const autoMode = resolveTtsAutoMode({ config, prefsPath });
    if (autoMode === "off")
        return undefined;
    const maxLength = getTtsMaxLength(prefsPath);
    const summarize = isSummarizationEnabled(prefsPath) ? "on" : "off";
    const autoHint = autoMode === "inbound"
        ? "Only use TTS when the user's last message includes audio/voice."
        : autoMode === "tagged"
            ? "Only use TTS when you include [[tts]] or [[tts:text]] tags."
            : undefined;
    return [
        "Voice (TTS) is enabled.",
        autoHint,
        `Keep spoken text ≤${maxLength} chars to avoid auto-summary (summary ${summarize}).`,
        "Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
    ]
        .filter(Boolean)
        .join("\n");
}
function readPrefs(prefsPath) {
    try {
        if (!existsSync(prefsPath))
            return {};
        return JSON.parse(readFileSync(prefsPath, "utf8"));
    }
    catch {
        return {};
    }
}
function atomicWriteFileSync(filePath, content) {
    const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
    writeFileSync(tmpPath, content);
    try {
        renameSync(tmpPath, filePath);
    }
    catch (err) {
        try {
            unlinkSync(tmpPath);
        }
        catch {
            // ignore
        }
        throw err;
    }
}
function updatePrefs(prefsPath, update) {
    const prefs = readPrefs(prefsPath);
    update(prefs);
    mkdirSync(path.dirname(prefsPath), { recursive: true });
    atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
}
export function isTtsEnabled(config, prefsPath, sessionAuto) {
    return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
}
export function setTtsAutoMode(prefsPath, mode) {
    updatePrefs(prefsPath, (prefs) => {
        const next = { ...prefs.tts };
        delete next.enabled;
        next.auto = mode;
        prefs.tts = next;
    });
}
export function setTtsEnabled(prefsPath, enabled) {
    setTtsAutoMode(prefsPath, enabled ? "always" : "off");
}
export function getTtsProvider(config, prefsPath) {
    const prefs = readPrefs(prefsPath);
    if (prefs.tts?.provider)
        return prefs.tts.provider;
    if (config.providerSource === "config")
        return config.provider;
    if (resolveTtsApiKey(config, "openai"))
        return "openai";
    if (resolveTtsApiKey(config, "elevenlabs"))
        return "elevenlabs";
    return "edge";
}
export function setTtsProvider(prefsPath, provider) {
    updatePrefs(prefsPath, (prefs) => {
        prefs.tts = { ...prefs.tts, provider };
    });
}
export function getTtsMaxLength(prefsPath) {
    const prefs = readPrefs(prefsPath);
    return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
}
export function setTtsMaxLength(prefsPath, maxLength) {
    updatePrefs(prefsPath, (prefs) => {
        prefs.tts = { ...prefs.tts, maxLength };
    });
}
export function isSummarizationEnabled(prefsPath) {
    const prefs = readPrefs(prefsPath);
    return prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
}
export function setSummarizationEnabled(prefsPath, enabled) {
    updatePrefs(prefsPath, (prefs) => {
        prefs.tts = { ...prefs.tts, summarize: enabled };
    });
}
export function getLastTtsAttempt() {
    return lastTtsAttempt;
}
export function setLastTtsAttempt(entry) {
    lastTtsAttempt = entry;
}
function resolveOutputFormat(channelId) {
    if (channelId === "telegram")
        return TELEGRAM_OUTPUT;
    return DEFAULT_OUTPUT;
}
function resolveChannelId(channel) {
    return channel ? normalizeChannelId(channel) : null;
}
function resolveEdgeOutputFormat(config) {
    return config.edge.outputFormat;
}
export function resolveTtsApiKey(config, provider) {
    if (provider === "elevenlabs") {
        return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
    }
    if (provider === "openai") {
        return config.openai.apiKey || process.env.OPENAI_API_KEY;
    }
    return undefined;
}
export const TTS_PROVIDERS = ["openai", "elevenlabs", "edge"];
export function resolveTtsProviderOrder(primary) {
    return [primary, ...TTS_PROVIDERS.filter((provider) => provider !== primary)];
}
export function isTtsProviderConfigured(config, provider) {
    if (provider === "edge")
        return config.edge.enabled;
    return Boolean(resolveTtsApiKey(config, provider));
}
function isValidVoiceId(voiceId) {
    return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
function normalizeElevenLabsBaseUrl(baseUrl) {
    const trimmed = baseUrl.trim();
    if (!trimmed)
        return DEFAULT_ELEVENLABS_BASE_URL;
    return trimmed.replace(/\/+$/, "");
}
function requireInRange(value, min, max, label) {
    if (!Number.isFinite(value) || value < min || value > max) {
        throw new Error(`${label} must be between ${min} and ${max}`);
    }
}
function assertElevenLabsVoiceSettings(settings) {
    requireInRange(settings.stability, 0, 1, "stability");
    requireInRange(settings.similarityBoost, 0, 1, "similarityBoost");
    requireInRange(settings.style, 0, 1, "style");
    requireInRange(settings.speed, 0.5, 2, "speed");
}
function normalizeLanguageCode(code) {
    const trimmed = code?.trim();
    if (!trimmed)
        return undefined;
    const normalized = trimmed.toLowerCase();
    if (!/^[a-z]{2}$/.test(normalized)) {
        throw new Error("languageCode must be a 2-letter ISO 639-1 code (e.g. en, de, fr)");
    }
    return normalized;
}
function normalizeApplyTextNormalization(mode) {
    const trimmed = mode?.trim();
    if (!trimmed)
        return undefined;
    const normalized = trimmed.toLowerCase();
    if (normalized === "auto" || normalized === "on" || normalized === "off")
        return normalized;
    throw new Error("applyTextNormalization must be one of: auto, on, off");
}
function normalizeSeed(seed) {
    if (seed == null)
        return undefined;
    const next = Math.floor(seed);
    if (!Number.isFinite(next) || next < 0 || next > 4_294_967_295) {
        throw new Error("seed must be between 0 and 4294967295");
    }
    return next;
}
function parseBooleanValue(value) {
    const normalized = value.trim().toLowerCase();
    if (["true", "1", "yes", "on"].includes(normalized))
        return true;
    if (["false", "0", "no", "off"].includes(normalized))
        return false;
    return undefined;
}
function parseNumberValue(value) {
    const parsed = Number.parseFloat(value);
    return Number.isFinite(parsed) ? parsed : undefined;
}
function parseTtsDirectives(text, policy) {
    if (!policy.enabled) {
        return { cleanedText: text, overrides: {}, warnings: [], hasDirective: false };
    }
    const overrides = {};
    const warnings = [];
    let cleanedText = text;
    let hasDirective = false;
    const blockRegex = /\[\[tts:text\]\]([\s\S]*?)\[\[\/tts:text\]\]/gi;
    cleanedText = cleanedText.replace(blockRegex, (_match, inner) => {
        hasDirective = true;
        if (policy.allowText && overrides.ttsText == null) {
            overrides.ttsText = inner.trim();
        }
        return "";
    });
    const directiveRegex = /\[\[tts:([^\]]+)\]\]/gi;
    cleanedText = cleanedText.replace(directiveRegex, (_match, body) => {
        hasDirective = true;
        const tokens = body.split(/\s+/).filter(Boolean);
        for (const token of tokens) {
            const eqIndex = token.indexOf("=");
            if (eqIndex === -1)
                continue;
            const rawKey = token.slice(0, eqIndex).trim();
            const rawValue = token.slice(eqIndex + 1).trim();
            if (!rawKey || !rawValue)
                continue;
            const key = rawKey.toLowerCase();
            try {
                switch (key) {
                    case "provider":
                        if (!policy.allowProvider)
                            break;
                        if (rawValue === "openai" || rawValue === "elevenlabs" || rawValue === "edge") {
                            overrides.provider = rawValue;
                        }
                        else {
                            warnings.push(`unsupported provider "${rawValue}"`);
                        }
                        break;
                    case "voice":
                    case "openai_voice":
                    case "openaivoice":
                        if (!policy.allowVoice)
                            break;
                        if (isValidOpenAIVoice(rawValue)) {
                            overrides.openai = { ...overrides.openai, voice: rawValue };
                        }
                        else {
                            warnings.push(`invalid OpenAI voice "${rawValue}"`);
                        }
                        break;
                    case "voiceid":
                    case "voice_id":
                    case "elevenlabs_voice":
                    case "elevenlabsvoice":
                        if (!policy.allowVoice)
                            break;
                        if (isValidVoiceId(rawValue)) {
                            overrides.elevenlabs = { ...overrides.elevenlabs, voiceId: rawValue };
                        }
                        else {
                            warnings.push(`invalid ElevenLabs voiceId "${rawValue}"`);
                        }
                        break;
                    case "model":
                    case "modelid":
                    case "model_id":
                    case "elevenlabs_model":
                    case "elevenlabsmodel":
                    case "openai_model":
                    case "openaimodel":
                        if (!policy.allowModelId)
                            break;
                        if (isValidOpenAIModel(rawValue)) {
                            overrides.openai = { ...overrides.openai, model: rawValue };
                        }
                        else {
                            overrides.elevenlabs = { ...overrides.elevenlabs, modelId: rawValue };
                        }
                        break;
                    case "stability":
                        if (!policy.allowVoiceSettings)
                            break;
                        {
                            const value = parseNumberValue(rawValue);
                            if (value == null) {
                                warnings.push("invalid stability value");
                                break;
                            }
                            requireInRange(value, 0, 1, "stability");
                            overrides.elevenlabs = {
                                ...overrides.elevenlabs,
                                voiceSettings: { ...overrides.elevenlabs?.voiceSettings, stability: value },
                            };
                        }
                        break;
                    case "similarity":
                    case "similarityboost":
                    case "similarity_boost":
                        if (!policy.allowVoiceSettings)
                            break;
                        {
                            const value = parseNumberValue(rawValue);
                            if (value == null) {
                                warnings.push("invalid similarityBoost value");
                                break;
                            }
                            requireInRange(value, 0, 1, "similarityBoost");
                            overrides.elevenlabs = {
                                ...overrides.elevenlabs,
                                voiceSettings: { ...overrides.elevenlabs?.voiceSettings, similarityBoost: value },
                            };
                        }
                        break;
                    case "style":
                        if (!policy.allowVoiceSettings)
                            break;
                        {
                            const value = parseNumberValue(rawValue);
                            if (value == null) {
                                warnings.push("invalid style value");
                                break;
                            }
                            requireInRange(value, 0, 1, "style");
                            overrides.elevenlabs = {
                                ...overrides.elevenlabs,
                                voiceSettings: { ...overrides.elevenlabs?.voiceSettings, style: value },
                            };
                        }
                        break;
                    case "speed":
                        if (!policy.allowVoiceSettings)
                            break;
                        {
                            const value = parseNumberValue(rawValue);
                            if (value == null) {
                                warnings.push("invalid speed value");
                                break;
                            }
                            requireInRange(value, 0.5, 2, "speed");
                            overrides.elevenlabs = {
                                ...overrides.elevenlabs,
                                voiceSettings: { ...overrides.elevenlabs?.voiceSettings, speed: value },
                            };
                        }
                        break;
                    case "speakerboost":
                    case "speaker_boost":
                    case "usespeakerboost":
                    case "use_speaker_boost":
                        if (!policy.allowVoiceSettings)
                            break;
                        {
                            const value = parseBooleanValue(rawValue);
                            if (value == null) {
                                warnings.push("invalid useSpeakerBoost value");
                                break;
                            }
                            overrides.elevenlabs = {
                                ...overrides.elevenlabs,
                                voiceSettings: { ...overrides.elevenlabs?.voiceSettings, useSpeakerBoost: value },
                            };
                        }
                        break;
                    case "normalize":
                    case "applytextnormalization":
                    case "apply_text_normalization":
                        if (!policy.allowNormalization)
                            break;
                        overrides.elevenlabs = {
                            ...overrides.elevenlabs,
                            applyTextNormalization: normalizeApplyTextNormalization(rawValue),
                        };
                        break;
                    case "language":
                    case "languagecode":
                    case "language_code":
                        if (!policy.allowNormalization)
                            break;
                        overrides.elevenlabs = {
                            ...overrides.elevenlabs,
                            languageCode: normalizeLanguageCode(rawValue),
                        };
                        break;
                    case "seed":
                        if (!policy.allowSeed)
                            break;
                        overrides.elevenlabs = {
                            ...overrides.elevenlabs,
                            seed: normalizeSeed(Number.parseInt(rawValue, 10)),
                        };
                        break;
                    default:
                        break;
                }
            }
            catch (err) {
                warnings.push(err.message);
            }
        }
        return "";
    });
    return {
        cleanedText,
        ttsText: overrides.ttsText,
        hasDirective,
        overrides,
        warnings,
    };
}
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts", "tts-1", "tts-1-hd"];
/**
 * Custom OpenAI-compatible TTS endpoint.
 * When set, model/voice validation is relaxed to allow non-OpenAI models.
 * Example: OPENAI_TTS_BASE_URL=http://localhost:8880/v1
 */
const OPENAI_TTS_BASE_URL = (process.env.OPENAI_TTS_BASE_URL?.trim() || "https://api.openai.com/v1").replace(/\/+$/, "");
const isCustomOpenAIEndpoint = OPENAI_TTS_BASE_URL !== "https://api.openai.com/v1";
export const OPENAI_TTS_VOICES = [
    "alloy",
    "ash",
    "coral",
    "echo",
    "fable",
    "onyx",
    "nova",
    "sage",
    "shimmer",
];
function isValidOpenAIModel(model) {
    // Allow any model when using custom endpoint (e.g., Kokoro, LocalAI)
    if (isCustomOpenAIEndpoint)
        return true;
    return OPENAI_TTS_MODELS.includes(model);
}
function isValidOpenAIVoice(voice) {
    // Allow any voice when using custom endpoint (e.g., Kokoro Chinese voices)
    if (isCustomOpenAIEndpoint)
        return true;
    return OPENAI_TTS_VOICES.includes(voice);
}
function resolveSummaryModelRef(cfg, config) {
    const defaultRef = resolveDefaultModelForAgent({ cfg });
    const override = config.summaryModel?.trim();
    if (!override)
        return { ref: defaultRef, source: "default" };
    const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: defaultRef.provider });
    const resolved = resolveModelRefFromString({
        raw: override,
        defaultProvider: defaultRef.provider,
        aliasIndex,
    });
    if (!resolved)
        return { ref: defaultRef, source: "default" };
    return { ref: resolved.ref, source: "summaryModel" };
}
function isTextContentBlock(block) {
    return block.type === "text";
}
async function summarizeText(params) {
    const { text, targetLength, cfg, config, timeoutMs } = params;
    if (targetLength < 100 || targetLength > 10_000) {
        throw new Error(`Invalid targetLength: ${targetLength}`);
    }
    const startTime = Date.now();
    const { ref } = resolveSummaryModelRef(cfg, config);
    const resolved = resolveModel(ref.provider, ref.model, undefined, cfg);
    if (!resolved.model) {
        throw new Error(resolved.error ?? `Unknown summary model: ${ref.provider}/${ref.model}`);
    }
    const [REDACTED_SECRET] getApiKeyForModel({ model: resolved.model, cfg }), ref.provider);
    try {
        const controller = new AbortController();
        const timeout = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const res = await completeSimple(resolved.model, {
                messages: [
                    {
                        role: "user",
                        content: `You are an assistant that summarizes texts concisely while keeping the most important information. ` +
                            `Summarize the text to approximately ${targetLength} characters. Maintain the original tone and style. ` +
                            `Reply only with the summary, without additional explanations.\n\n` +
                            `<text_to_summarize>\n${text}\n</text_to_summarize>`,
                        timestamp: Date.now(),
                    },
                ],
            }, {
                apiKey,
                maxTokens: Math.ceil(targetLength / 2),
                temperature: 0.3,
                signal: controller.signal,
            });
            const summary = res.content
                .filter(isTextContentBlock)
                .map((block) => block.text.trim())
                .filter(Boolean)
                .join(" ")
                .trim();
            if (!summary) {
                throw new Error("No summary returned");
            }
            return {
                summary,
                latencyMs: Date.now() - startTime,
                inputLength: text.length,
                outputLength: summary.length,
            };
        }
        finally {
            clearTimeout(timeout);
        }
    }
    catch (err) {
        const error = err;
        if (error.name === "AbortError") {
            throw new Error("Summarization timed out");
        }
        throw err;
    }
}
function scheduleCleanup(tempDir, delayMs = TEMP_FILE_CLEANUP_DELAY_MS) {
    const timer = setTimeout(() => {
        try {
            rmSync(tempDir, { recursive: true, force: true });
        }
        catch {
            // ignore cleanup errors
        }
    }, delayMs);
    timer.unref();
}
async function elevenLabsTTS(params) {
    const { text, apiKey, baseUrl, voiceId, modelId, outputFormat, seed, applyTextNormalization, languageCode, voiceSettings, timeoutMs, } = params;
    if (!isValidVoiceId(voiceId)) {
        throw new Error("Invalid voiceId format");
    }
    assertElevenLabsVoiceSettings(voiceSettings);
    const normalizedLanguage = normalizeLanguageCode(languageCode);
    const normalizedNormalization = normalizeApplyTextNormalization(applyTextNormalization);
    const normalizedSeed = normalizeSeed(seed);
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), timeoutMs);
    try {
        const url = new URL(`${normalizeElevenLabsBaseUrl(baseUrl)}/v1/text-to-speech/${voiceId}`);
        if (outputFormat) {
            url.searchParams.set("output_format", outputFormat);
        }
        const response = await fetch(url.toString(), {
            method: "POST",
            headers: {
                "xi-api-key": apiKey,
                "Content-Type": "application/json",
                Accept: "audio/mpeg",
            },
            body: JSON.stringify({
                text,
                model_id: modelId,
                seed: normalizedSeed,
                apply_text_normalization: normalizedNormalization,
                language_code: normalizedLanguage,
                voice_settings: {
                    stability: voiceSettings.stability,
                    similarity_boost: voiceSettings.similarityBoost,
                    style: voiceSettings.style,
                    use_speaker_boost: voiceSettings.useSpeakerBoost,
                    speed: voiceSettings.speed,
                },
            }),
            signal: controller.signal,
        });
        if (!response.ok) {
            throw new Error(`ElevenLabs API error (${response.status})`);
        }
        return Buffer.from(await response.arrayBuffer());
    }
    finally {
        clearTimeout(timeout);
    }
}
async function openaiTTS(params) {
    const { text, apiKey, model, voice, responseFormat, timeoutMs } = params;
    if (!isValidOpenAIModel(model)) {
        throw new Error(`Invalid model: ${model}`);
    }
    if (!isValidOpenAIVoice(voice)) {
        throw new Error(`Invalid voice: ${voice}`);
    }
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), timeoutMs);
    try {
        const response = await fetch(`${OPENAI_TTS_BASE_URL}/audio/speech`, {
            method: "POST",
            headers: {
                Authorization: `Bearer ${apiKey}`,
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                model,
                input: text,
                voice,
                response_format: responseFormat,
            }),
            signal: controller.signal,
        });
        if (!response.ok) {
            throw new Error(`OpenAI TTS API error (${response.status})`);
        }
        return Buffer.from(await response.arrayBuffer());
    }
    finally {
        clearTimeout(timeout);
    }
}
function inferEdgeExtension(outputFormat) {
    const normalized = outputFormat.toLowerCase();
    if (normalized.includes("webm"))
        return ".webm";
    if (normalized.includes("ogg"))
        return ".ogg";
    if (normalized.includes("opus"))
        return ".opus";
    if (normalized.includes("wav") || normalized.includes("riff") || normalized.includes("pcm")) {
        return ".wav";
    }
    return ".mp3";
}
async function edgeTTS(params) {
    const { text, outputPath, config, timeoutMs } = params;
    const tts = new EdgeTTS({
        voice: config.voice,
        lang: config.lang,
        outputFormat: config.outputFormat,
        saveSubtitles: config.saveSubtitles,
        proxy: config.proxy,
        rate: config.rate,
        pitch: config.pitch,
        volume: config.volume,
        timeout: config.timeoutMs ?? timeoutMs,
    });
    await tts.ttsPromise(text, outputPath);
}
export async function textToSpeech(params) {
    const config = resolveTtsConfig(params.cfg);
    const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
    const channelId = resolveChannelId(params.channel);
    const output = resolveOutputFormat(channelId);
    console.log(`[TTS-SPEECH] entry: channel=${params.channel} channelId=${channelId} textLen=${params.text.length} maxLen=${config.maxTextLength}`);
    if (params.text.length > config.maxTextLength) {
        console.log(`[TTS-SPEECH] FAIL: text too long`);
        return {
            success: false,
            error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`,
        };
    }
    const userProvider = getTtsProvider(config, prefsPath);
    const overrideProvider = params.overrides?.provider;
    const provider = overrideProvider ?? userProvider;
    const providers = resolveTtsProviderOrder(provider);
    console.log(`[TTS-SPEECH] provider=${provider} userProvider=${userProvider} providers=${JSON.stringify(providers)} [REDACTED_SECRET] ? "SET(" + config.elevenlabs.apiKey.slice(0, 8) + "...)" : "MISSING"}`);
    let lastError;
    for (const provider of providers) {
        const providerStart = Date.now();
        try {
            if (provider === "edge") {
                if (!config.edge.enabled) {
                    lastError = "edge: disabled";
                    continue;
                }
                const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
                let edgeOutputFormat = resolveEdgeOutputFormat(config);
                const fallbackEdgeOutputFormat = edgeOutputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined;
                const attemptEdgeTts = async (outputFormat) => {
                    const extension = inferEdgeExtension(outputFormat);
                    const audioPath = path.join(tempDir, `voice-${Date.now()}${extension}`);
                    await edgeTTS({
                        text: params.text,
                        outputPath: audioPath,
                        config: {
                            ...config.edge,
                            outputFormat,
                        },
                        timeoutMs: config.timeoutMs,
                    });
                    return { audioPath, outputFormat };
                };
                let edgeResult;
                try {
                    edgeResult = await attemptEdgeTts(edgeOutputFormat);
                }
                catch (err) {
                    if (fallbackEdgeOutputFormat && fallbackEdgeOutputFormat !== edgeOutputFormat) {
                        logVerbose(`TTS: Edge output ${edgeOutputFormat} failed; retrying with ${fallbackEdgeOutputFormat}.`);
                        edgeOutputFormat = fallbackEdgeOutputFormat;
                        try {
                            edgeResult = await attemptEdgeTts(edgeOutputFormat);
                        }
                        catch (fallbackErr) {
                            try {
                                rmSync(tempDir, { recursive: true, force: true });
                            }
                            catch {
                                // ignore cleanup errors
                            }
                            throw fallbackErr;
                        }
                    }
                    else {
                        try {
                            rmSync(tempDir, { recursive: true, force: true });
                        }
                        catch {
                            // ignore cleanup errors
                        }
                        throw err;
                    }
                }
                scheduleCleanup(tempDir);
                const voiceCompatible = isVoiceCompatibleAudio({ fileName: edgeResult.audioPath });
                return {
                    success: true,
                    audioPath: edgeResult.audioPath,
                    latencyMs: Date.now() - providerStart,
                    provider,
                    outputFormat: edgeResult.outputFormat,
                    voiceCompatible,
                };
            }
            const [REDACTED_SECRET], provider);
            if (!apiKey) {
                lastError = `No API key for ${provider}`;
                continue;
            }
            let audioBuffer;
            if (provider === "elevenlabs") {
                const voiceIdOverride = params.overrides?.elevenlabs?.voiceId;
                const modelIdOverride = params.overrides?.elevenlabs?.modelId;
                const voiceSettings = {
                    ...config.elevenlabs.voiceSettings,
                    ...params.overrides?.elevenlabs?.voiceSettings,
                };
                const seedOverride = params.overrides?.elevenlabs?.seed;
                const normalizationOverride = params.overrides?.elevenlabs?.applyTextNormalization;
                const languageOverride = params.overrides?.elevenlabs?.languageCode;
                audioBuffer = await elevenLabsTTS({
                    text: params.text,
                    apiKey,
                    baseUrl: config.elevenlabs.baseUrl,
                    voiceId: voiceIdOverride ?? config.elevenlabs.voiceId,
                    modelId: modelIdOverride ?? config.elevenlabs.modelId,
                    outputFormat: output.elevenlabs,
                    seed: seedOverride ?? config.elevenlabs.seed,
                    applyTextNormalization: normalizationOverride ?? config.elevenlabs.applyTextNormalization,
                    languageCode: languageOverride ?? config.elevenlabs.languageCode,
                    voiceSettings,
                    timeoutMs: config.timeoutMs,
                });
            }
            else {
                const openaiModelOverride = params.overrides?.openai?.model;
                const openaiVoiceOverride = params.overrides?.openai?.voice;
                audioBuffer = await openaiTTS({
                    text: params.text,
                    apiKey,
                    model: openaiModelOverride ?? config.openai.model,
                    voice: openaiVoiceOverride ?? config.openai.voice,
                    responseFormat: output.openai,
                    timeoutMs: config.timeoutMs,
                });
            }
            const latencyMs = Date.now() - providerStart;
            const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
            const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`);
            writeFileSync(audioPath, audioBuffer);
            scheduleCleanup(tempDir);
            return {
                success: true,
                audioPath,
                latencyMs,
                provider,
                outputFormat: provider === "openai" ? output.openai : output.elevenlabs,
                voiceCompatible: output.voiceCompatible,
            };
        }
        catch (err) {
            const error = err;
            if (error.name === "AbortError") {
                lastError = `${provider}: request timed out`;
            }
            else {
                lastError = `${provider}: ${error.message}`;
            }
        }
    }
    return {
        success: false,
        error: `TTS conversion failed: ${lastError || "no providers available"}`,
    };
}
export async function textToSpeechTelephony(params) {
    const config = resolveTtsConfig(params.cfg);
    const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
    if (params.text.length > config.maxTextLength) {
        return {
            success: false,
            error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`,
        };
    }
    const userProvider = getTtsProvider(config, prefsPath);
    const providers = resolveTtsProviderOrder(userProvider);
    let lastError;
    for (const provider of providers) {
        const providerStart = Date.now();
        try {
            if (provider === "edge") {
                lastError = "edge: unsupported for telephony";
                continue;
            }
            const [REDACTED_SECRET], provider);
            if (!apiKey) {
                lastError = `No API key for ${provider}`;
                continue;
            }
            if (provider === "elevenlabs") {
                const output = TELEPHONY_OUTPUT.elevenlabs;
                const audioBuffer = await elevenLabsTTS({
                    text: params.text,
                    apiKey,
                    baseUrl: config.elevenlabs.baseUrl,
                    voiceId: config.elevenlabs.voiceId,
                    modelId: config.elevenlabs.modelId,
                    outputFormat: output.format,
                    seed: config.elevenlabs.seed,
                    applyTextNormalization: config.elevenlabs.applyTextNormalization,
                    languageCode: config.elevenlabs.languageCode,
                    voiceSettings: config.elevenlabs.voiceSettings,
                    timeoutMs: config.timeoutMs,
                });
                return {
                    success: true,
                    audioBuffer,
                    latencyMs: Date.now() - providerStart,
                    provider,
                    outputFormat: output.format,
                    sampleRate: output.sampleRate,
                };
            }
            const output = TELEPHONY_OUTPUT.openai;
            const audioBuffer = await openaiTTS({
                text: params.text,
                apiKey,
                model: config.openai.model,
                voice: config.openai.voice,
                responseFormat: output.format,
                timeoutMs: config.timeoutMs,
            });
            return {
                success: true,
                audioBuffer,
                latencyMs: Date.now() - providerStart,
                provider,
                outputFormat: output.format,
                sampleRate: output.sampleRate,
            };
        }
        catch (err) {
            const error = err;
            if (error.name === "AbortError") {
                lastError = `${provider}: request timed out`;
            }
            else {
                lastError = `${provider}: ${error.message}`;
            }
        }
    }
    return {
        success: false,
        error: `TTS conversion failed: ${lastError || "no providers available"}`,
    };
}
export async function maybeApplyTtsToPayload(params) {
    const config = resolveTtsConfig(params.cfg);
    const prefsPath = resolveTtsPrefsPath(config);
    const autoMode = resolveTtsAutoMode({
        config,
        prefsPath,
        sessionAuto: params.ttsAuto,
    });
    console.log(`[TTS-APPLY] entry: autoMode=${autoMode} kind=${params.kind} inboundAudio=${params.inboundAudio} channel=${params.channel} provider=${config.provider} [REDACTED_SECRET] ? "SET" : "MISSING"}`);
    if (autoMode === "off") {
        console.log(`[TTS-APPLY] SKIP: autoMode=off`);
        return params.payload;
    }
    const text = params.payload.text ?? "";
    const directives = parseTtsDirectives(text, config.modelOverrides);
    if (directives.warnings.length > 0) {
        logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`);
    }
    const cleanedText = directives.cleanedText;
    const trimmedCleaned = cleanedText.trim();
    const visibleText = trimmedCleaned.length > 0 ? trimmedCleaned : "";
    const ttsText = directives.ttsText?.trim() || visibleText;
    const nextPayload = visibleText === text.trim()
        ? params.payload
        : {
            ...params.payload,
            text: visibleText.length > 0 ? visibleText : undefined,
        };
    if (autoMode === "tagged" && !directives.hasDirective) {
        console.log(`[TTS-APPLY] SKIP: tagged mode, no directive`);
        return nextPayload;
    }
    if (autoMode === "inbound" && params.inboundAudio !== true) {
        console.log(`[TTS-APPLY] SKIP: inbound mode, inboundAudio=${params.inboundAudio}`);
        return nextPayload;
    }
    const mode = config.mode ?? "final";
    if (mode === "final" && params.kind && params.kind !== "final") {
        console.log(`[TTS-APPLY] SKIP: mode=final but kind=${params.kind}`);
        return nextPayload;
    }
    if (!ttsText.trim()) {
        console.log(`[TTS-APPLY] SKIP: empty ttsText`);
        return nextPayload;
    }
    if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) {
        console.log(`[TTS-APPLY] SKIP: payload already has mediaUrl`);
        return nextPayload;
    }
    if (text.includes("MEDIA:")) {
        console.log(`[TTS-APPLY] SKIP: text contains MEDIA:`);
        return nextPayload;
    }
    if (ttsText.trim().length < 10) {
        console.log(`[TTS-APPLY] SKIP: ttsText too short (${ttsText.trim().length} < 10)`);
        return nextPayload;
    }
    console.log(`[TTS-APPLY] PASSED all checks, proceeding to textToSpeech. textLen=${ttsText.trim().length}`);
    const maxLength = getTtsMaxLength(prefsPath);
    let textForAudio = ttsText.trim();
    let wasSummarized = false;
    if (textForAudio.length > maxLength) {
        if (!isSummarizationEnabled(prefsPath)) {
            logVerbose(`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`);
            return nextPayload;
        }
        try {
            const summary = await summarizeText({
                text: textForAudio,
                targetLength: maxLength,
                cfg: params.cfg,
                config,
                timeoutMs: config.timeoutMs,
            });
            textForAudio = summary.summary;
            wasSummarized = true;
            if (textForAudio.length > config.maxTextLength) {
                logVerbose(`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`);
                textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
            }
        }
        catch (err) {
            const error = err;
            logVerbose(`TTS: summarization failed: ${error.message}`);
            return nextPayload;
        }
    }
    const ttsStart = Date.now();
    console.log(`[TTS-APPLY] calling textToSpeech with ${textForAudio.length} chars, channel=${params.channel}`);
    const result = await textToSpeech({
        text: textForAudio,
        cfg: params.cfg,
        prefsPath,
        channel: params.channel,
        overrides: directives.overrides,
    });
    console.log(`[TTS-APPLY] textToSpeech result: success=${result.success} audioPath=${result.audioPath ?? "none"} error=${result.error ?? "none"} provider=${result.provider ?? "none"} latency=${result.latencyMs ?? "?"}ms`);
    if (result.success && result.audioPath) {
        lastTtsAttempt = {
            timestamp: Date.now(),
            success: true,
            textLength: text.length,
            summarized: wasSummarized,
            provider: result.provider,
            latencyMs: result.latencyMs,
        };
        const channelId = resolveChannelId(params.channel);
        const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
        console.log(`[TTS-APPLY] SUCCESS: audioPath=${result.audioPath} shouldVoice=${shouldVoice}`);
        return {
            ...nextPayload,
            mediaUrl: result.audioPath,
            audioAsVoice: shouldVoice || params.payload.audioAsVoice,
        };
    }
    lastTtsAttempt = {
        timestamp: Date.now(),
        success: false,
        textLength: text.length,
        summarized: wasSummarized,
        error: result.error,
    };
    console.log(`[TTS-APPLY] FAILED: ${result.error ?? "unknown"}`);
    const latency = Date.now() - ttsStart;
    logVerbose(`TTS: conversion failed after ${latency}ms (${result.error ?? "unknown"}).`);
    return nextPayload;
}
export const _test = {
    isValidVoiceId,
    isValidOpenAIVoice,
    isValidOpenAIModel,
    OPENAI_TTS_MODELS,
    OPENAI_TTS_VOICES,
    parseTtsDirectives,
    resolveModelOverridePolicy,
    summarizeText,
    resolveOutputFormat,
    resolveEdgeOutputFormat,
};


path: patch/dispatch-from-config.js
content: import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
import { logMessageProcessed, logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { getReplyFromConfig } from "../reply.js";
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
const normalizeMediaType = (value) => value.split(";")[0]?.trim().toLowerCase();
const isInboundAudioContext = (ctx) => {
    const rawTypes = [
        typeof ctx.MediaType === "string" ? ctx.MediaType : undefined,
        ...(Array.isArray(ctx.MediaTypes) ? ctx.MediaTypes : []),
    ].filter(Boolean);
    const types = rawTypes.map((type) => normalizeMediaType(type));
    if (types.some((type) => type === "audio" || type.startsWith("audio/")))
        return true;
    const body = typeof ctx.BodyForCommands === "string"
        ? ctx.BodyForCommands
        : typeof ctx.CommandBody === "string"
            ? ctx.CommandBody
            : typeof ctx.RawBody === "string"
                ? ctx.RawBody
                : typeof ctx.Body === "string"
                    ? ctx.Body
                    : "";
    const trimmed = body.trim();
    if (!trimmed)
        return false;
    if (AUDIO_PLACEHOLDER_RE.test(trimmed))
        return true;
    return AUDIO_HEADER_RE.test(trimmed);
};
const resolveSessionTtsAuto = (ctx, cfg) => {
    const targetSessionKey = ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
    const sessionKey = (targetSessionKey ?? ctx.SessionKey)?.trim();
    if (!sessionKey)
        return undefined;
    const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
    const storePath = resolveStorePath(cfg.session?.store, { agentId });
    try {
        const store = loadSessionStore(storePath);
        const entry = store[sessionKey.toLowerCase()] ?? store[sessionKey];
        return normalizeTtsAutoMode(entry?.ttsAuto);
    }
    catch {
        return undefined;
    }
};
export async function dispatchReplyFromConfig(params) {
    const { ctx, cfg, dispatcher } = params;
    const diagnosticsEnabled = isDiagnosticsEnabled(cfg);
    const channel = String(ctx.Surface ?? ctx.Provider ?? "unknown").toLowerCase();
    const chatId = ctx.To ?? ctx.From;
    const messageId = ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
    const sessionKey = ctx.SessionKey;
    const startTime = diagnosticsEnabled ? Date.now() : 0;
    const canTrackSession = diagnosticsEnabled && Boolean(sessionKey);
    const recordProcessed = (outcome, opts) => {
        if (!diagnosticsEnabled)
            return;
        logMessageProcessed({
            channel,
            chatId,
            messageId,
            sessionKey,
            durationMs: Date.now() - startTime,
            outcome,
            reason: opts?.reason,
            error: opts?.error,
        });
    };
    const markProcessing = () => {
        if (!canTrackSession || !sessionKey)
            return;
        logMessageQueued({ sessionKey, channel, source: "dispatch" });
        logSessionStateChange({
            sessionKey,
            state: "processing",
            reason: "message_start",
        });
    };
    const markIdle = (reason) => {
        if (!canTrackSession || !sessionKey)
            return;
        logSessionStateChange({
            sessionKey,
            state: "idle",
            reason,
        });
    };
    if (shouldSkipDuplicateInbound(ctx)) {
        recordProcessed("skipped", { reason: "duplicate" });
        return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
    }
    const inboundAudio = isInboundAudioContext(ctx);
    const sessionTtsAuto = resolveSessionTtsAuto(ctx, cfg);
    // Disable block streaming when TTS should produce audio for this response.
    // Block streaming drops final payloads (text already sent as blocks), but
    // TTS only fires on "final" kind payloads (default mode), so TTS never
    // triggers when block streaming is active.  Disabling block streaming for
    // TTS-eligible messages ensures the final reply carries text for synthesis.
    const ttsAutoResolved = normalizeTtsAutoMode(sessionTtsAuto) ?? normalizeTtsAutoMode(cfg.messages?.tts?.auto);
    const ttsWillFire = ttsAutoResolved === "always" || (ttsAutoResolved === "inbound" && inboundAudio);
    // DEBUG: TTS pipeline tracing
    console.log(`[TTS-DEBUG] inboundAudio=${inboundAudio} sessionTtsAuto=${sessionTtsAuto} ttsAutoResolved=${ttsAutoResolved} ttsWillFire=${ttsWillFire} MediaType=${ctx.MediaType} MediaTypes=${JSON.stringify(ctx.MediaTypes)} Body=${(ctx.Body ?? "").slice(0, 80)}`);
    const hookRunner = getGlobalHookRunner();
    if (hookRunner?.hasHooks("message_received")) {
        const timestamp = typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
            ? ctx.Timestamp
            : undefined;
        const messageIdForHook = ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
        const content = typeof ctx.BodyForCommands === "string"
            ? ctx.BodyForCommands
            : typeof ctx.RawBody === "string"
                ? ctx.RawBody
                : typeof ctx.Body === "string"
                    ? ctx.Body
                    : "";
        const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
        const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
        void hookRunner
            .runMessageReceived({
            from: ctx.From ?? "",
            content,
            timestamp,
            metadata: {
                to: ctx.To,
                provider: ctx.Provider,
                surface: ctx.Surface,
                threadId: ctx.MessageThreadId,
                originatingChannel: ctx.OriginatingChannel,
                originatingTo: ctx.OriginatingTo,
                messageId: messageIdForHook,
                senderId: ctx.SenderId,
                senderName: ctx.SenderName,
                senderUsername: ctx.SenderUsername,
                senderE164: ctx.SenderE164,
            },
        }, {
            channelId,
            accountId: ctx.AccountId,
            conversationId,
        })
            .catch((err) => {
            logVerbose(`dispatch-from-config: message_received hook failed: ${String(err)}`);
        });
    }
    // Check if we should route replies to originating channel instead of dispatcher.
    // Only route when the originating channel is DIFFERENT from the current surface.
    // This handles cross-provider routing (e.g., message from Telegram being processed
    // by a shared session that's currently on Slack) while preserving normal dispatcher
    // flow when the provider handles its own messages.
    //
    // Debug: `pnpm test src/auto-reply/reply/dispatch-from-config.test.ts`
    const originatingChannel = ctx.OriginatingChannel;
    const originatingTo = ctx.OriginatingTo;
    const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
    const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
    const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
    /**
     * Helper to send a payload via route-reply (async).
     * Only used when actually routing to a different provider.
     * Note: Only called when shouldRouteToOriginating is true, so
     * originatingChannel and originatingTo are guaranteed to be defined.
     */
    const sendPayloadAsync = async (payload, abortSignal, mirror) => {
        // TypeScript doesn't narrow these from the shouldRouteToOriginating check,
        // but they're guaranteed non-null when this function is called.
        if (!originatingChannel || !originatingTo)
            return;
        if (abortSignal?.aborted)
            return;
        const result = await routeReply({
            payload,
            channel: originatingChannel,
            to: originatingTo,
            sessionKey: ctx.SessionKey,
            accountId: ctx.AccountId,
            threadId: ctx.MessageThreadId,
            cfg,
            abortSignal,
            mirror,
        });
        if (!result.ok) {
            logVerbose(`dispatch-from-config: route-reply failed: ${result.error ?? "unknown error"}`);
        }
    };
    markProcessing();
    try {
        const fastAbort = await tryFastAbortFromMessage({ ctx, cfg });
        if (fastAbort.handled) {
            const payload = {
                text: formatAbortReplyText(fastAbort.stoppedSubagents),
            };
            let queuedFinal = false;
            let routedFinalCount = 0;
            if (shouldRouteToOriginating && originatingChannel && originatingTo) {
                const result = await routeReply({
                    payload,
                    channel: originatingChannel,
                    to: originatingTo,
                    sessionKey: ctx.SessionKey,
                    accountId: ctx.AccountId,
                    threadId: ctx.MessageThreadId,
                    cfg,
                });
                queuedFinal = result.ok;
                if (result.ok)
                    routedFinalCount += 1;
                if (!result.ok) {
                    logVerbose(`dispatch-from-config: route-reply (abort) failed: ${result.error ?? "unknown error"}`);
                }
            }
            else {
                queuedFinal = dispatcher.sendFinalReply(payload);
            }
            await dispatcher.waitForIdle();
            const counts = dispatcher.getQueuedCounts();
            counts.final += routedFinalCount;
            recordProcessed("completed", { reason: "fast_abort" });
            markIdle("message_completed");
            return { queuedFinal, counts };
        }
        const replyResult = await (params.replyResolver ?? getReplyFromConfig)(ctx, {
            ...params.replyOptions,
            disableBlockStreaming: ttsWillFire || params.replyOptions?.disableBlockStreaming,
            // Don't provide onBlockReply when TTS will fire - we need the full final payload for synthesis
            onBlockReply: ttsWillFire ? undefined : (payload, context) => {
                const run = async () => {
                    const ttsPayload = await maybeApplyTtsToPayload({
                        payload,
                        cfg,
                        channel: ttsChannel,
                        kind: "block",
                        inboundAudio,
                        ttsAuto: sessionTtsAuto,
                    });
                    if (shouldRouteToOriginating) {
                        await sendPayloadAsync(ttsPayload, context?.abortSignal, false);
                    }
                    else {
                        dispatcher.sendBlockReply(ttsPayload);
                    }
                };
                return run();
            },
        }, cfg);
        const replies = replyResult ? (Array.isArray(replyResult) ? replyResult : [replyResult]) : [];
        let queuedFinal = false;
        let routedFinalCount = 0;
        for (const reply of replies) {
            const ttsReply = await maybeApplyTtsToPayload({
                payload: reply,
                cfg,
                channel: ttsChannel,
                kind: "final",
                inboundAudio,
                ttsAuto: sessionTtsAuto,
            });
            if (shouldRouteToOriginating && originatingChannel && originatingTo) {
                // Route final reply to originating channel.
                const result = await routeReply({
                    payload: ttsReply,
                    channel: originatingChannel,
                    to: originatingTo,
                    sessionKey: ctx.SessionKey,
                    accountId: ctx.AccountId,
                    threadId: ctx.MessageThreadId,
                    cfg,
                });
                if (!result.ok) {
                    logVerbose(`dispatch-from-config: route-reply (final) failed: ${result.error ?? "unknown error"}`);
                }
                queuedFinal = result.ok || queuedFinal;
                if (result.ok)
                    routedFinalCount += 1;
            }
            else {
                queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
            }
        }
        await dispatcher.waitForIdle();
        const counts = dispatcher.getQueuedCounts();
        counts.final += routedFinalCount;
        recordProcessed("completed");
        markIdle("message_completed");
        return { queuedFinal, counts };
    }
    catch (err) {
        recordProcessed("error", { error: String(err) });
        markIdle("message_error");
        throw err;
    }
}


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)