diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index 788e9a63a167..432ea83fa70b 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -249,6 +249,22 @@ describe("handleTtsCommands status fallback reporting", () => { expect(reply.text).toContain("TTS status"); }); + it("keeps base status fields in display order", async () => { + const reply = expectReply(await handleTtsCommands(buildTtsParams("/tts status"), true)); + + expect(reply.text).toBe( + [ + "📊 TTS status", + "State: ✅ enabled", + "Chat override: default", + `Provider: ${PRIMARY_TTS_PROVIDER} (✅ configured)`, + "Persona: none", + "Text limit: 1500 chars", + "Auto-summary: on", + ].join("\n"), + ); + }); + it("resolves status config for the active agent", async () => { const cfg = { agents: { list: [{ id: "reader", tts: { provider: "elevenlabs" } }] }, @@ -364,7 +380,7 @@ describe("handleTtsCommands status fallback reporting", () => { const beforeTtsRead = Date.now(); const result = await handleTtsCommands( - buildTtsParams("/tts latest", {}, undefined, { sessionEntry, sessionStore }), + buildTtsParams("/tts read latest", {}, undefined, { sessionEntry, sessionStore }), true, ); diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index ceecd9cbbe85..3276681941b3 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -37,7 +37,7 @@ import { persistSessionEntry, sessionEntryPersistenceConflictReply, } from "./commands-session-store.js"; -import type { CommandHandler } from "./commands-types.js"; +import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; type ParsedTtsCommand = { action: string; @@ -48,6 +48,8 @@ type TtsAttemptDetail = NonNullable< NonNullable>["attempts"] >[number]; +type TtsCommandParams = Parameters[0]; + function parseTtsCommand(normalized: string): ParsedTtsCommand | null { // Accept `/tts` and `/tts [args]` as a single control surface. if (normalized === "/tts") { @@ -84,6 +86,10 @@ function formatAttemptDetails(attempts: TtsAttemptDetail[] | undefined): string .join(", "); } +function stopWithText(text: string): CommandHandlerResult { + return { shouldContinue: false, reply: { text } }; +} + function ttsUsage(): ReplyPayload { // Keep usage in one place so help/validation stays consistent. return { @@ -126,7 +132,7 @@ async function buildTtsAudioReply(params: { accountId?: string; prefsPath: string; agentId?: string; -}): Promise<{ reply: ReplyPayload; provider?: string; hash?: string } | { error: string }> { +}): Promise<{ reply: ReplyPayload } | { error: string }> { const start = Date.now(); const result = await textToSpeech({ text: params.text, @@ -151,7 +157,6 @@ async function buildTtsAudioReply(params: { latencyMs: result.latencyMs, }); return { - provider: result.provider, reply: { mediaUrl: result.audioPath, audioAsVoice: result.audioAsVoice === true || result.voiceCompatible === true, @@ -175,6 +180,139 @@ async function buildTtsAudioReply(params: { return { error: result.error ?? "unknown error" }; } +async function handleTtsChatAction( + params: TtsCommandParams, + args: string, +): Promise { + const requested = args.toLowerCase(); + if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) { + return stopWithText("🔇 No active chat session is available for a chat-scoped TTS override."); + } + if (!requested || requested === "status") { + return stopWithText(`🔊 Chat TTS override: ${params.sessionEntry.ttsAuto ?? "default"}.`); + } + + let replyText: string; + if (requested === "on") { + params.sessionEntry.ttsAuto = "always"; + replyText = "🔊 TTS enabled for this chat."; + } else if (requested === "off") { + params.sessionEntry.ttsAuto = "off"; + replyText = "🔇 TTS disabled for this chat."; + } else if (requested === "default" || requested === "inherit" || requested === "clear") { + delete params.sessionEntry.ttsAuto; + replyText = "🔊 TTS chat override cleared."; + } else { + return { shouldContinue: false, reply: ttsUsage() }; + } + + if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) { + return sessionEntryPersistenceConflictReply(); + } + return stopWithText(replyText); +} + +async function handleTtsLatestAction( + params: TtsCommandParams, + accountId: string | undefined, + prefsPath: string, +): Promise { + if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) { + return stopWithText("🎤 No active chat session is available for `/tts latest`."); + } + const latest = await readLatestAssistantTextFromSessionTranscript( + params.sessionEntry.sessionFile, + ); + const latestText = latest?.text.trim(); + if (!latestText || isSilentReplyPayloadText(latestText)) { + return stopWithText("🎤 No readable assistant reply was found in this chat yet."); + } + const hash = hashTtsReadLatestText(latestText); + if (params.sessionEntry.lastTtsReadLatestHash === hash) { + return stopWithText("🔊 Latest assistant reply was already sent as audio."); + } + + const audio = await buildTtsAudioReply({ + text: latestText, + cfg: params.cfg, + channel: params.command.channel, + accountId, + prefsPath, + agentId: params.agentId, + }); + if ("error" in audio) { + return stopWithText(`❌ Error generating audio: ${audio.error}`); + } + + params.sessionEntry.lastTtsReadLatestHash = hash; + params.sessionEntry.lastTtsReadLatestAt = Date.now(); + if ( + !(await persistSessionEntry({ + ...params, + touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"], + })) + ) { + return sessionEntryPersistenceConflictReply(); + } + return { shouldContinue: false, reply: audio.reply }; +} + +function handleTtsStatusAction( + params: TtsCommandParams, + config: ReturnType, + prefsPath: string, +): CommandHandlerResult { + const enabled = isTtsEnabled(config, prefsPath); + const provider = getTtsProvider(config, prefsPath); + const persona = getTtsPersona(config, prefsPath); + const hasKey = isTtsProviderConfigured(config, provider, params.cfg); + const maxLength = getTtsMaxLength(prefsPath); + const summarize = isSummarizationEnabled(prefsPath); + const last = getLastTtsAttempt(); + const lines = [ + "📊 TTS status", + `State: ${enabled ? "✅ enabled" : "❌ disabled"}`, + `Chat override: ${params.sessionEntry?.ttsAuto ?? "default"}`, + `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`, + `Persona: ${persona?.id ?? "none"}`, + `Text limit: ${maxLength} chars`, + `Auto-summary: ${summarize ? "on" : "off"}`, + ]; + if (last) { + const timeAgo = Math.round((Date.now() - last.timestamp) / 1000); + lines.push(""); + lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`); + lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`); + if (last.success) { + lines.push(`Provider: ${last.provider ?? "unknown"}`); + if (last.persona) { + lines.push(`Persona: ${last.persona}`); + } + if (last.fallbackFrom && last.provider && last.fallbackFrom !== last.provider) { + lines.push(`Fallback: ${last.fallbackFrom} -> ${last.provider}`); + } + if (last.attemptedProviders && last.attemptedProviders.length > 1) { + lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`); + } + const details = formatAttemptDetails(last.attempts); + if (details) { + lines.push(`Attempt details: ${details}`); + } + lines.push(`Latency: ${last.latencyMs ?? 0}ms`); + } else if (last.error) { + lines.push(`Error: ${last.error}`); + if (last.attemptedProviders && last.attemptedProviders.length > 0) { + lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`); + } + const details = formatAttemptDetails(last.attempts); + if (details) { + lines.push(`Attempt details: ${details}`); + } + } + } + return stopWithText(lines.join("\n")); +} + export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -207,119 +345,29 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "on") { setTtsEnabled(prefsPath, true); - return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } }; + return stopWithText("🔊 TTS enabled."); } if (action === "off") { setTtsEnabled(prefsPath, false); - return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } }; + return stopWithText("🔇 TTS disabled."); } if (action === "chat") { - const requested = normalizeOptionalLowercaseString(args) ?? ""; - if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) { - return { - shouldContinue: false, - reply: { text: "🔇 No active chat session is available for a chat-scoped TTS override." }, - }; - } - if (!requested || requested === "status") { - return { - shouldContinue: false, - reply: { text: `🔊 Chat TTS override: ${params.sessionEntry.ttsAuto ?? "default"}.` }, - }; - } - if (requested === "on") { - params.sessionEntry.ttsAuto = "always"; - if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) { - return sessionEntryPersistenceConflictReply(); - } - return { shouldContinue: false, reply: { text: "🔊 TTS enabled for this chat." } }; - } - if (requested === "off") { - params.sessionEntry.ttsAuto = "off"; - if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) { - return sessionEntryPersistenceConflictReply(); - } - return { shouldContinue: false, reply: { text: "🔇 TTS disabled for this chat." } }; - } - if (requested === "default" || requested === "inherit" || requested === "clear") { - delete params.sessionEntry.ttsAuto; - if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) { - return sessionEntryPersistenceConflictReply(); - } - return { shouldContinue: false, reply: { text: "🔊 TTS chat override cleared." } }; - } - return { shouldContinue: false, reply: ttsUsage() }; + return handleTtsChatAction(params, args); } - if ( - action === "latest" || - (action === "read" && normalizeOptionalLowercaseString(args) === "latest") - ) { - if (!params.sessionEntry || !params.sessionStore || !params.sessionKey) { - return { - shouldContinue: false, - reply: { text: "🎤 No active chat session is available for `/tts latest`." }, - }; - } - const latest = await readLatestAssistantTextFromSessionTranscript( - params.sessionEntry.sessionFile, - ); - const latestText = latest?.text.trim(); - if (!latestText || isSilentReplyPayloadText(latestText)) { - return { - shouldContinue: false, - reply: { text: "🎤 No readable assistant reply was found in this chat yet." }, - }; - } - const hash = hashTtsReadLatestText(latestText); - if (params.sessionEntry.lastTtsReadLatestHash === hash) { - return { - shouldContinue: false, - reply: { text: "🔊 Latest assistant reply was already sent as audio." }, - }; - } - - const audio = await buildTtsAudioReply({ - text: latestText, - cfg: params.cfg, - channel: params.command.channel, - accountId, - prefsPath, - agentId: params.agentId, - }); - if ("error" in audio) { - return { - shouldContinue: false, - reply: { text: `❌ Error generating audio: ${audio.error}` }, - }; - } - - params.sessionEntry.lastTtsReadLatestHash = hash; - params.sessionEntry.lastTtsReadLatestAt = Date.now(); - if ( - !(await persistSessionEntry({ - ...params, - touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"], - })) - ) { - return sessionEntryPersistenceConflictReply(); - } - return { shouldContinue: false, reply: audio.reply }; + if (action === "latest" || (action === "read" && args.toLowerCase() === "latest")) { + return handleTtsLatestAction(params, accountId, prefsPath); } if (action === "audio") { - if (!args.trim()) { - return { - shouldContinue: false, - reply: { - text: - `🎤 Generate audio from text.\n\n` + - `Usage: /tts audio \n` + - `Example: /tts audio Hello, this is a test!`, - }, - }; + if (!args) { + return stopWithText( + `🎤 Generate audio from text.\n\n` + + `Usage: /tts audio \n` + + `Example: /tts audio Hello, this is a test!`, + ); } const audio = await buildTtsAudioReply({ @@ -333,46 +381,39 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (!("error" in audio)) { return { shouldContinue: false, reply: audio.reply }; } - return { - shouldContinue: false, - reply: { text: `❌ Error generating audio: ${audio.error}` }, - }; + return stopWithText(`❌ Error generating audio: ${audio.error}`); } if (action === "provider") { const currentProvider = getTtsProvider(config, prefsPath); - if (!args.trim()) { + if (!args) { const providers = listSpeechProviders(params.cfg); - return { - shouldContinue: false, - reply: { - text: - `🎙️ TTS provider\n` + - `Primary: ${currentProvider}\n` + - providers - .map( - (provider) => - `${provider.label}: ${ - provider.isConfigured({ - cfg: params.cfg, - providerConfig: getResolvedSpeechProviderConfig( - config, - provider.id, - params.cfg, - ), - timeoutMs: config.timeoutMs, - }) - ? "✅" - : "❌" - }`, - ) - .join("\n") + - `\nUsage: /tts provider `, - }, - }; + return stopWithText( + `🎙️ TTS provider\n` + + `Primary: ${currentProvider}\n` + + providers + .map( + (provider) => + `${provider.label}: ${ + provider.isConfigured({ + cfg: params.cfg, + providerConfig: getResolvedSpeechProviderConfig( + config, + provider.id, + params.cfg, + ), + timeoutMs: config.timeoutMs, + }) + ? "✅" + : "❌" + }`, + ) + .join("\n") + + `\nUsage: /tts provider `, + ); } - const requested = normalizeOptionalLowercaseString(args) ?? ""; + const requested = args.toLowerCase(); const resolvedProvider = getSpeechProvider(requested, params.cfg); if (!resolvedProvider) { return { shouldContinue: false, reply: ttsUsage() }; @@ -380,16 +421,13 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand const nextProvider = canonicalizeSpeechProviderId(requested, params.cfg) ?? resolvedProvider.id; setTtsProvider(prefsPath, nextProvider); - return { - shouldContinue: false, - reply: { text: `✅ TTS provider set to ${nextProvider}.` }, - }; + return stopWithText(`✅ TTS provider set to ${nextProvider}.`); } if (action === "persona") { const personas = listTtsPersonas(config); const activePersona = getTtsPersona(config, prefsPath); - if (!args.trim()) { + if (!args) { const lines = [ "🎭 TTS persona", `Active: ${activePersona?.id ?? "none"}`, @@ -404,141 +442,68 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand : "No personas configured.", "Usage: /tts persona | off", ]; - return { shouldContinue: false, reply: { text: lines.join("\n") } }; + return stopWithText(lines.join("\n")); } - const requested = normalizeOptionalLowercaseString(args) ?? ""; + const requested = args.toLowerCase(); if (requested === "off" || requested === "none" || requested === "default") { setTtsPersona(prefsPath, null); - return { shouldContinue: false, reply: { text: "✅ TTS persona disabled." } }; + return stopWithText("✅ TTS persona disabled."); } const persona = personas.find((entry) => entry.id === requested); if (!persona) { - return { - shouldContinue: false, - reply: { - text: - `❌ Unknown TTS persona: ${requested || args}.\n` + - `Use /tts persona to list configured personas.`, - }, - }; + return stopWithText( + `❌ Unknown TTS persona: ${requested || args}.\n` + + `Use /tts persona to list configured personas.`, + ); } setTtsPersona(prefsPath, persona.id); - return { - shouldContinue: false, - reply: { text: `✅ TTS persona set to ${persona.id}.` }, - }; + return stopWithText(`✅ TTS persona set to ${persona.id}.`); } if (action === "limit") { - if (!args.trim()) { + if (!args) { const currentLimit = getTtsMaxLength(prefsPath); - return { - shouldContinue: false, - reply: { - text: - `📏 TTS limit: ${currentLimit} characters.\n\n` + - `Text longer than this triggers summary (if enabled).\n` + - `Range: 100-4096 chars (Telegram max).\n\n` + - `To change: /tts limit \n` + - `Example: /tts limit 2000`, - }, - }; + return stopWithText( + `📏 TTS limit: ${currentLimit} characters.\n\n` + + `Text longer than this triggers summary (if enabled).\n` + + `Range: 100-4096 chars (Telegram max).\n\n` + + `To change: /tts limit \n` + + `Example: /tts limit 2000`, + ); } - const trimmedLimit = args.trim(); - const next = /^\d+$/.test(trimmedLimit) ? Number(trimmedLimit) : Number.NaN; + const next = /^\d+$/.test(args) ? Number(args) : Number.NaN; if (!Number.isSafeInteger(next) || next < 100 || next > 4096) { - return { - shouldContinue: false, - reply: { text: "❌ Limit must be between 100 and 4096 characters." }, - }; + return stopWithText("❌ Limit must be between 100 and 4096 characters."); } setTtsMaxLength(prefsPath, next); - return { - shouldContinue: false, - reply: { text: `✅ TTS limit set to ${next} characters.` }, - }; + return stopWithText(`✅ TTS limit set to ${next} characters.`); } if (action === "summary") { - if (!args.trim()) { + if (!args) { const enabled = isSummarizationEnabled(prefsPath); const maxLen = getTtsMaxLength(prefsPath); - return { - shouldContinue: false, - reply: { - text: - `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` + - `When text exceeds ${maxLen} chars:\n` + - `• ON: summarizes text, then generates audio\n` + - `• OFF: truncates text, then generates audio\n\n` + - `To change: /tts summary on | off`, - }, - }; + return stopWithText( + `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` + + `When text exceeds ${maxLen} chars:\n` + + `• ON: summarizes text, then generates audio\n` + + `• OFF: truncates text, then generates audio\n\n` + + `To change: /tts summary on | off`, + ); } - const requested = normalizeOptionalLowercaseString(args) ?? ""; + const requested = args.toLowerCase(); if (requested !== "on" && requested !== "off") { return { shouldContinue: false, reply: ttsUsage() }; } setSummarizationEnabled(prefsPath, requested === "on"); - return { - shouldContinue: false, - reply: { - text: requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.", - }, - }; + return stopWithText( + requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.", + ); } if (action === "status") { - const enabled = isTtsEnabled(config, prefsPath); - const provider = getTtsProvider(config, prefsPath); - const persona = getTtsPersona(config, prefsPath); - const hasKey = isTtsProviderConfigured(config, provider, params.cfg); - const maxLength = getTtsMaxLength(prefsPath); - const summarize = isSummarizationEnabled(prefsPath); - const last = getLastTtsAttempt(); - const lines = [ - "📊 TTS status", - `State: ${enabled ? "✅ enabled" : "❌ disabled"}`, - `Chat override: ${params.sessionEntry?.ttsAuto ?? "default"}`, - `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`, - `Persona: ${persona?.id ?? "none"}`, - `Text limit: ${maxLength} chars`, - `Auto-summary: ${summarize ? "on" : "off"}`, - ]; - if (last) { - const timeAgo = Math.round((Date.now() - last.timestamp) / 1000); - lines.push(""); - lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`); - lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`); - if (last.success) { - lines.push(`Provider: ${last.provider ?? "unknown"}`); - if (last.persona) { - lines.push(`Persona: ${last.persona}`); - } - if (last.fallbackFrom && last.provider && last.fallbackFrom !== last.provider) { - lines.push(`Fallback: ${last.fallbackFrom} -> ${last.provider}`); - } - if (last.attemptedProviders && last.attemptedProviders.length > 1) { - lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`); - } - const details = formatAttemptDetails(last.attempts); - if (details) { - lines.push(`Attempt details: ${details}`); - } - lines.push(`Latency: ${last.latencyMs ?? 0}ms`); - } else if (last.error) { - lines.push(`Error: ${last.error}`); - if (last.attemptedProviders && last.attemptedProviders.length > 0) { - lines.push(`Attempts: ${last.attemptedProviders.join(" -> ")}`); - } - const details = formatAttemptDetails(last.attempts); - if (details) { - lines.push(`Attempt details: ${details}`); - } - } - } - return { shouldContinue: false, reply: { text: lines.join("\n") } }; + return handleTtsStatusAction(params, config, prefsPath); } return { shouldContinue: false, reply: ttsUsage() };