diff --git a/PRODUCT.md b/PRODUCT.md index e2c5c174..211b1d5e 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -105,7 +105,7 @@ Current command set: - `/messages` - browse user messages in the current session - `/projects` - show and switch projects - `/worktree` - show and switch existing git worktrees for the current repository -- `/tts` - toggle global audio replies +- `/tts` - choose audio reply mode (`off`, `all`, or `auto`) - `/task` - create a scheduled task - `/tasklist` - browse and delete scheduled tasks - `/rename` - rename current session @@ -118,7 +118,7 @@ Current command set: Model, agent, variant, and context actions are available from the persistent bottom keyboard. -Text messages (non-commands) are treated as prompts for OpenCode only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured. When `/tts` is enabled globally, completed assistant replies also include a generated audio file if TTS is configured. +Text messages (non-commands) are treated as prompts for OpenCode only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured. When `/tts` is set to `all`, completed assistant replies include a generated audio file if TTS is configured. When it is set to `auto`, audio replies are sent only after voice/audio prompts. Interaction routing rules: @@ -160,7 +160,7 @@ Model picker behavior: - [x] PDF attachments support (send documents from Telegram to OpenCode) - [x] Text file attachments support (send code/config/log files from Telegram to OpenCode) - [x] Voice/audio transcription via Whisper-compatible APIs (OpenAI/Groq/Together and compatible providers) -- [x] Optional global audio replies with `/tts` via OpenAI-compatible APIs +- [x] Optional audio replies with `/tts` modes via OpenAI-compatible APIs - [x] Dynamic subagent activity display during task execution - [x] Git worktree switching and main-project status display for git repositories (`/worktree`) - [x] Create new OpenCode projects directly from Telegram diff --git a/README.md b/README.md index fda557fa..add6f492 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ opencode-telegram config | `/worktree` | Switch between existing git worktrees | | `/open` | Add a project by browsing directories | | `/ls` | List directory contents, then tap to open or download | -| `/tts` | Toggle audio replies | +| `/tts` | Choose audio reply mode (`off`, `all`, or `auto`) | | `/rename` | Rename the current session | | `/commands` | Browse and run custom commands | | `/skills` | Browse and run OpenCode skills | @@ -314,7 +314,7 @@ If `STT_API_URL` and `STT_API_KEY` are set, the bot will: If `STT_NOTE_PROMPT` is set to a non-empty value other than `false` or `0`, the bot prepends `[Note: ...]` to the transcription before sending it to the LLM. The recognized text shown in Telegram stays unchanged. -If TTS credentials are configured, you can toggle spoken replies globally with `/tts`. The preference is stored in `settings.json` and persists across restarts. +If TTS credentials are configured, you can choose spoken reply behavior with `/tts`: `off` disables audio replies, `all` sends audio for every assistant reply, and `auto` sends audio only after voice/audio prompts. The preference is stored in `settings.json` and persists across restarts. OpenAI-compatible TTS configuration example: diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index db313e5a..260a2847 100644 --- a/src/app/stores/settings-store.ts +++ b/src/app/stores/settings-store.ts @@ -85,12 +85,14 @@ export function clearSession(): void { void writeSettingsFile(currentSettings); } -export function isTtsEnabled(): boolean { - return currentSettings.ttsEnabled === true; +export type TtsMode = "off" | "all" | "auto"; + +export function getTtsMode(): TtsMode { + return currentSettings.ttsMode ?? "off"; } -export function setTtsEnabled(enabled: boolean): void { - currentSettings.ttsEnabled = enabled; +export function setTtsMode(mode: TtsMode): void { + currentSettings.ttsMode = mode; void writeSettingsFile(currentSettings); } @@ -193,6 +195,14 @@ export async function loadSettings(): Promise { requiresRewrite = true; } + // Migrate old ttsEnabled boolean to new ttsMode + if ("ttsEnabled" in loadedSettings) { + const oldEnabled = (loadedSettings as Record).ttsEnabled; + loadedSettings.ttsMode = oldEnabled === true ? "all" : "off"; + delete (loadedSettings as Record).ttsEnabled; + requiresRewrite = true; + } + currentSettings = loadedSettings; currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? []; currentSettings.scheduledTaskSessionIgnores = diff --git a/src/app/types/settings.ts b/src/app/types/settings.ts index 2630d686..be47b703 100644 --- a/src/app/types/settings.ts +++ b/src/app/types/settings.ts @@ -14,7 +14,7 @@ export interface Settings { currentAgent?: string; currentModel?: ModelInfo; pinnedMessageId?: number; - ttsEnabled?: boolean; + ttsMode?: "off" | "all" | "auto"; sessionDirectoryCache?: SessionDirectoryCacheInfo; scheduledTasks?: ScheduledTask[]; scheduledTaskSessionIgnores?: ScheduledTaskSessionIgnoreInfo[]; diff --git a/src/bot/callbacks/callback-router.ts b/src/bot/callbacks/callback-router.ts index 395075bf..09089437 100644 --- a/src/bot/callbacks/callback-router.ts +++ b/src/bot/callbacks/callback-router.ts @@ -27,6 +27,7 @@ import { handleTaskCallback, handleTaskListCallback, } from "./scheduled-task-callback-handler.js"; +import { handleTtsCallback } from "./tts-callback-handler.js"; import { handleVariantSelect } from "./variant-selection-callback-handler.js"; import { handleWorktreeCallback } from "./worktree-callback-handler.js"; import { clearLsPathIndex, clearOpenPathIndex } from "../menus/file-browser-menu.js"; @@ -76,6 +77,7 @@ export function registerCallbackRouter(bot: Bot, deps: CallbackRouterDe const handledModelSearchResults = await handleModelSearchResults(ctx); const handledModel = await handleModelSelect(ctx); const handledVariant = await handleVariantSelect(ctx); + const handledTts = await handleTtsCallback(ctx); const handledCompactConfirm = await handleCompactConfirm(ctx); const handledTask = await handleTaskCallback(ctx); const handledTaskList = await handleTaskListCallback(ctx); @@ -95,7 +97,7 @@ export function registerCallbackRouter(bot: Bot, deps: CallbackRouterDe const handledMcps = await handleMcpsCallback(ctx); logger.debug( - `[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`, + `[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, tts=${handledTts}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`, ); if ( @@ -113,6 +115,7 @@ export function registerCallbackRouter(bot: Bot, deps: CallbackRouterDe !handledModelSearchResults && !handledModel && !handledVariant && + !handledTts && !handledCompactConfirm && !handledTask && !handledTaskList && diff --git a/src/bot/callbacks/tts-callback-handler.ts b/src/bot/callbacks/tts-callback-handler.ts new file mode 100644 index 00000000..3193a74c --- /dev/null +++ b/src/bot/callbacks/tts-callback-handler.ts @@ -0,0 +1,46 @@ +import { Context } from "grammy"; +import { isTtsConfigured } from "../../app/services/tts-service.js"; +import { setTtsMode, type TtsMode } from "../../app/stores/settings-store.js"; +import { TTS_CALLBACK_PREFIX } from "../commands/tts-command.js"; +import { t } from "../../i18n/index.js"; +import { logger } from "../../utils/logger.js"; + +const TTS_MODES: TtsMode[] = ["off", "all", "auto"]; + +export async function handleTtsCallback(ctx: Context): Promise { + const callbackQuery = ctx.callbackQuery; + + if (!callbackQuery?.data || !callbackQuery.data.startsWith(TTS_CALLBACK_PREFIX)) { + return false; + } + + const mode = callbackQuery.data.slice(TTS_CALLBACK_PREFIX.length) as TtsMode; + + if (!TTS_MODES.includes(mode)) { + return false; + } + + if (mode !== "off" && !isTtsConfigured()) { + await ctx.answerCallbackQuery({ text: t("tts.not_configured"), show_alert: true }); + return true; + } + + setTtsMode(mode); + + const messageKey = mode === "off" ? "tts.off" : mode === "all" ? "tts.all" : "tts.auto"; + await ctx.answerCallbackQuery({ text: t(messageKey) }); + + try { + await ctx.deleteMessage(); + } catch (deleteError) { + logger.warn("[TTS] Failed to delete mode selection message:", deleteError); + + try { + await ctx.editMessageReplyMarkup(); + } catch (editError) { + logger.warn("[TTS] Failed to remove mode selection keyboard:", editError); + } + } + + return true; +} diff --git a/src/bot/commands/status-command.ts b/src/bot/commands/status-command.ts index 2a963566..2e034770 100644 --- a/src/bot/commands/status-command.ts +++ b/src/bot/commands/status-command.ts @@ -2,7 +2,7 @@ import { CommandContext, Context } from "grammy"; import { opencodeClient } from "../../opencode/client.js"; import { getGitWorktreeContext } from "../../app/services/worktree-service.js"; import { getCurrentSession } from "../../app/services/session-service.js"; -import { getCurrentProject, isTtsEnabled } from "../../app/stores/settings-store.js"; +import { getCurrentProject, getTtsMode } from "../../app/stores/settings-store.js"; import { fetchCurrentAgent } from "../../app/services/agent-selection-service.js"; import { fetchCurrentModel } from "../../app/services/model-selection-service.js"; import { getAgentDisplayName } from "../../app/types/agent.js"; @@ -26,8 +26,14 @@ export async function statusCommand(ctx: CommandContext) { if (data.version) { message += `${t("status.line.version", { version: data.version })}\n`; } + const ttsMode = getTtsMode(); message += `${t("status.line.tts", { - tts: isTtsEnabled() ? t("status.tts.on") : t("status.tts.off"), + tts: + ttsMode === "off" + ? t("status.tts.off") + : ttsMode === "all" + ? t("status.tts.all") + : t("status.tts.auto"), })}\n`; // Add agent information diff --git a/src/bot/commands/tts-command.ts b/src/bot/commands/tts-command.ts index e6584c27..71b03a7c 100644 --- a/src/bot/commands/tts-command.ts +++ b/src/bot/commands/tts-command.ts @@ -1,19 +1,24 @@ -import { CommandContext, Context } from "grammy"; -import { isTtsConfigured } from "../../app/services/tts-service.js"; -import { isTtsEnabled, setTtsEnabled } from "../../app/stores/settings-store.js"; +import { CommandContext, Context, InlineKeyboard } from "grammy"; +import { getTtsMode, type TtsMode } from "../../app/stores/settings-store.js"; import { t } from "../../i18n/index.js"; -export async function ttsCommand(ctx: CommandContext): Promise { - const enabled = !isTtsEnabled(); - - if (enabled && !isTtsConfigured()) { - await ctx.reply(t("tts.not_configured")); - return; - } +export const TTS_CALLBACK_PREFIX = "tts:"; - setTtsEnabled(enabled); +export function buildTtsModeKeyboard(current: TtsMode): InlineKeyboard { + return new InlineKeyboard() + .text(`${current === "off" ? "✅ " : ""}🔇 ${t("status.tts.off")}`, `${TTS_CALLBACK_PREFIX}off`) + .row() + .text(`${current === "all" ? "✅ " : ""}🔊 ${t("status.tts.all")}`, `${TTS_CALLBACK_PREFIX}all`) + .row() + .text( + `${current === "auto" ? "✅ " : ""}🎤 ${t("status.tts.auto")}`, + `${TTS_CALLBACK_PREFIX}auto`, + ); +} - const message = enabled ? t("tts.enabled") : t("tts.disabled"); +export async function ttsCommand(ctx: CommandContext): Promise { + const current = getTtsMode(); + const keyboard = buildTtsModeKeyboard(current); - await ctx.reply(message); + await ctx.reply(t("tts.prompt"), { reply_markup: keyboard }); } diff --git a/src/bot/handlers/prompt.ts b/src/bot/handlers/prompt.ts index ba17492a..950ae84b 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -1,9 +1,13 @@ import { Bot, Context } from "grammy"; import type { FilePartInput, TextPartInput } from "@opencode-ai/sdk/v2"; import { opencodeClient } from "../../opencode/client.js"; -import { clearSession, getCurrentSession, setCurrentSession } from "../../app/services/session-service.js"; +import { + clearSession, + getCurrentSession, + setCurrentSession, +} from "../../app/services/session-service.js"; import { ingestSessionInfoForCache } from "../../app/services/session-cache-service.js"; -import { getCurrentProject, isTtsEnabled } from "../../app/stores/settings-store.js"; +import { getCurrentProject, getTtsMode } from "../../app/stores/settings-store.js"; import { getStoredAgent, resolveProjectAgent } from "../../app/services/agent-selection-service.js"; import { getStoredModel } from "../../app/services/model-selection-service.js"; import { formatVariantForButton } from "../../app/services/variant-selection-service.js"; @@ -127,7 +131,8 @@ export async function processUserPrompt( options: ProcessPromptOptions = {}, ): Promise { const { bot, ensureEventSubscription } = deps; - const responseMode = options.responseMode ?? (isTtsEnabled() ? "text_and_tts" : "text_only"); + const responseMode = + options.responseMode ?? (getTtsMode() === "all" ? "text_and_tts" : "text_only"); const currentProject = getCurrentProject(); if (!currentProject) { diff --git a/src/bot/handlers/voice-handler.ts b/src/bot/handlers/voice-handler.ts index 48adf38d..720aca0f 100644 --- a/src/bot/handlers/voice-handler.ts +++ b/src/bot/handlers/voice-handler.ts @@ -6,7 +6,12 @@ import type { FilePartInput } from "@opencode-ai/sdk/v2"; import { HttpsProxyAgent } from "https-proxy-agent"; import { SocksProxyAgent } from "socks-proxy-agent"; import { config } from "../../config.js"; -import { isSttConfigured, transcribeAudio, type SttResult } from "../../app/services/stt-service.js"; +import { getTtsMode } from "../../app/stores/settings-store.js"; +import { + isSttConfigured, + transcribeAudio, + type SttResult, +} from "../../app/services/stt-service.js"; import { processUserPrompt, type ProcessPromptDeps } from "./prompt.js"; import { logger } from "../../utils/logger.js"; import { t } from "../../i18n/index.js"; @@ -241,7 +246,10 @@ export async function handleVoiceMessage(ctx: Context, deps: VoiceMessageDeps): } // Process the recognized text as a prompt - await processPrompt(ctx, textForLLM, deps); + const currentTtsMode = getTtsMode(); + const responseMode = + currentTtsMode === "all" || currentTtsMode === "auto" ? "text_and_tts" : "text_only"; + await processPrompt(ctx, textForLLM, deps, [], { responseMode }); } catch (err) { const errorMessage = err instanceof Error ? err.message : "unknown error"; logger.error("[Voice] Error processing voice message:", err); diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index ccf81eeb..3f9bcfcb 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -14,7 +14,7 @@ export const ar: I18nDictionary = { "cmd.description.detach": "الخروج من الجلسة دون إيقافها", "cmd.description.sessions": "عرض الجلسات السابقة", "cmd.description.messages": "استعراض رسائل الجلسة", - "cmd.description.tts": "تشغيل أو إيقاف الردود الصوتية", + "cmd.description.tts": "اختيار وضع الردود الصوتية", "cmd.description.projects": "عرض المشاريع", "cmd.description.worktree": "التبديل بين نسخ العمل في Git", "cmd.description.task": "إنشاء مهمة مجدولة", @@ -102,8 +102,9 @@ export const ar: I18nDictionary = { "status.line.mode": "الوكيل: {mode}", "status.line.model": "النموذج: {model}", "status.line.tts": "الردود الصوتية: {tts}", - "status.tts.on": "مفعّلة", "status.tts.off": "معطّلة", + "status.tts.all": "الكل", + "status.tts.auto": "تلقائي", "status.agent_not_set": "غير محدد", "status.project_selected": "المشروع: {project}", "status.worktree_selected": "نسخة العمل: {worktree}", @@ -114,9 +115,11 @@ export const ar: I18nDictionary = { "status.session_hint": "استخدم /sessions لاختيار جلسة أو /new لبدء جلسة جديدة", "status.server_unavailable": "🔴 خادم OpenCode غير متاح\n\nاستخدم /opencode_start لتشغيل الخادم.", - "tts.enabled": "🔊 تم تفعيل الردود الصوتية.", + "tts.prompt": "اختر وضع الردود الصوتية:", + "tts.off": "🔇 تم تعطيل الردود الصوتية.", + "tts.all": "🔊 تم تفعيل الردود الصوتية لجميع الرسائل.", + "tts.auto": "🎤 تم تفعيل الردود الصوتية للرسائل الصوتية فقط.", "tts.not_configured": "⚠️ الردود الصوتية غير متاحة حاليًا. اضبط `TTS_API_URL` و`TTS_API_KEY` أولًا.", - "tts.disabled": "🔇 تم تعطيل الردود الصوتية.", "tts.failed": "⚠️ تعذر إنشاء الرد الصوتي.", "projects.empty": "📭 لم يتم العثور على مشاريع.\n\nافتح مجلدًا في OpenCode وأنشئ جلسة واحدة على الأقل، ثم سيظهر المشروع هنا.", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ea6487f0..45026b24 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -7,7 +7,7 @@ export const de: I18nDictionary = { "cmd.description.detach": "Von aktueller Sitzung trennen", "cmd.description.sessions": "Sitzungen auflisten", "cmd.description.messages": "Sitzungsnachrichten durchsuchen", - "cmd.description.tts": "Audioantworten umschalten", + "cmd.description.tts": "Audioantwort-Modus wählen", "cmd.description.projects": "Projekte auflisten", "cmd.description.worktree": "Git-Worktrees wechseln", "cmd.description.task": "Geplante Aufgabe erstellen", @@ -118,9 +118,10 @@ export const de: I18nDictionary = { "status.line.uptime_sec": "Betriebszeit: {seconds} s", "status.line.mode": "Agent: {mode}", "status.line.model": "Modell: {model}", - "status.line.tts": "TTS-Antworten: {tts}", - "status.tts.on": "Ein", + "status.line.tts": "Audioantworten: {tts}", "status.tts.off": "Aus", + "status.tts.all": "Alle", + "status.tts.auto": "Auto", "status.agent_not_set": "nicht gesetzt", "status.project_selected": "Projekt: {project}", "status.worktree_selected": "Worktree: {worktree}", @@ -132,10 +133,12 @@ export const de: I18nDictionary = { "status.server_unavailable": "🔴 OpenCode-Server ist nicht verfügbar\n\nNutze /opencode_start, um den Server zu starten.", - "tts.enabled": "🔊 Audioantworten global aktiviert.", + "tts.prompt": "Audioantwort-Modus auswählen:", + "tts.off": "🔇 Audioantworten deaktiviert.", + "tts.all": "🔊 Audioantworten für alle Nachrichten aktiviert.", + "tts.auto": "🎤 Audioantworten nur für Sprachnachrichten aktiviert.", "tts.not_configured": "⚠️ Audioantworten sind nicht verfugbar. Setze zuerst `TTS_API_URL` und `TTS_API_KEY`.", - "tts.disabled": "🔇 Audioantworten global deaktiviert.", "tts.failed": "⚠️ Audioreply konnte nicht erzeugt werden.", "projects.empty": @@ -194,7 +197,8 @@ export const de: I18nDictionary = { "messages.button.back": "⬅️ Zurück", "messages.button.cancel": "❌ Abbrechen", "messages.revert_success": "✅ Zurück zur Nachricht:\n\n{text}", - "messages.revert_error": "❌ Nachricht konnte nicht zurückgesetzt werden. Bitte versuche es erneut.", + "messages.revert_error": + "❌ Nachricht konnte nicht zurückgesetzt werden. Bitte versuche es erneut.", "messages.fork_success": "🔀 Fork erstellt von Nachricht:\n\n{text}", "messages.fork_error": "❌ Fork konnte nicht erstellt werden. Bitte versuche es erneut.", @@ -300,8 +304,8 @@ export const de: I18nDictionary = { "model.menu.error": "🔴 Modellliste konnte nicht geladen werden", "model.search.button": "🔍 Suche", "model.search.prompt": "🔍 Modellnamen zum Suchen eingeben:", - "model.search.results_title": "Suchergebnisse für \"{query}\":", - "model.search.no_results": "Keine Modelle gefunden für \"{query}\"", + "model.search.results_title": 'Suchergebnisse für "{query}":', + "model.search.no_results": 'Keine Modelle gefunden für "{query}"', "model.search.search_again": "↩ Erneut suchen", "model.search.error": "Suche fehlgeschlagen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 382e5ff8..87f12f66 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -5,7 +5,7 @@ export const en = { "cmd.description.detach": "Detach from current session", "cmd.description.sessions": "List sessions", "cmd.description.messages": "Browse session messages", - "cmd.description.tts": "Toggle audio replies", + "cmd.description.tts": "Choose audio reply mode", "cmd.description.projects": "List projects", "cmd.description.worktree": "Switch git worktrees", "cmd.description.task": "Create a scheduled task", @@ -111,9 +111,10 @@ export const en = { "status.line.uptime_sec": "Uptime: {seconds} sec", "status.line.mode": "Agent: {mode}", "status.line.model": "Model: {model}", - "status.line.tts": "TTS replies: {tts}", - "status.tts.on": "On", + "status.line.tts": "Audio replies: {tts}", "status.tts.off": "Off", + "status.tts.all": "All", + "status.tts.auto": "Auto", "status.agent_not_set": "not set", "status.project_selected": "Project: {project}", "status.worktree_selected": "Worktree: {worktree}", @@ -125,10 +126,12 @@ export const en = { "status.server_unavailable": "🔴 OpenCode Server is unavailable\n\nUse /opencode_start to start the server.", - "tts.enabled": "🔊 Audio replies enabled globally.", + "tts.prompt": "Select audio reply mode:", + "tts.off": "🔇 Audio replies disabled.", + "tts.all": "🔊 Audio replies enabled for all messages.", + "tts.auto": "🎤 Audio replies enabled for voice/audio messages only.", "tts.not_configured": "⚠️ Audio replies are unavailable. Set `TTS_API_URL` and `TTS_API_KEY` first.", - "tts.disabled": "🔇 Audio replies disabled globally.", "tts.failed": "⚠️ Failed to generate audio reply.", "projects.empty": @@ -284,8 +287,8 @@ export const en = { "model.menu.error": "🔴 Failed to get models list", "model.search.button": "🔍 Search", "model.search.prompt": "🔍 Enter model name to search:", - "model.search.results_title": "Search results for \"{query}\":", - "model.search.no_results": "No models found for \"{query}\"", + "model.search.results_title": 'Search results for "{query}":', + "model.search.no_results": 'No models found for "{query}"', "model.search.search_again": "↩ Search again", "model.search.error": "Search failed", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 39d89999..dda2e3e1 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -7,7 +7,7 @@ export const es: I18nDictionary = { "cmd.description.detach": "Desconectar de la sesión actual", "cmd.description.sessions": "Listar sesiones", "cmd.description.messages": "Ver mensajes de la sesión", - "cmd.description.tts": "Alternar respuestas de audio", + "cmd.description.tts": "Elegir modo de respuestas de audio", "cmd.description.projects": "Listar proyectos", "cmd.description.worktree": "Cambiar worktrees de git", "cmd.description.task": "Crear tarea programada", @@ -118,9 +118,10 @@ export const es: I18nDictionary = { "status.line.uptime_sec": "Tiempo activo: {seconds} s", "status.line.mode": "Agente: {mode}", "status.line.model": "Modelo: {model}", - "status.line.tts": "Respuestas TTS: {tts}", - "status.tts.on": "Activadas", + "status.line.tts": "Respuestas de audio: {tts}", "status.tts.off": "Desactivadas", + "status.tts.all": "Todo", + "status.tts.auto": "Auto", "status.agent_not_set": "no configurado", "status.project_selected": "Proyecto: {project}", "status.worktree_selected": "Worktree: {worktree}", @@ -132,10 +133,12 @@ export const es: I18nDictionary = { "status.server_unavailable": "🔴 OpenCode Server no está disponible\n\nUsa /opencode_start para iniciar el servidor.", - "tts.enabled": "🔊 Respuestas de audio activadas globalmente.", + "tts.prompt": "Selecciona el modo de respuestas de audio:", + "tts.off": "🔇 Respuestas de audio desactivadas.", + "tts.all": "🔊 Respuestas de audio activadas para todos los mensajes.", + "tts.auto": "🎤 Respuestas de audio activadas solo para mensajes de voz.", "tts.not_configured": "⚠️ Las respuestas de audio no estan disponibles. Configura primero `TTS_API_URL` y `TTS_API_KEY`.", - "tts.disabled": "🔇 Respuestas de audio desactivadas globalmente.", "tts.failed": "⚠️ No se pudo generar la respuesta de audio.", "projects.empty": @@ -298,8 +301,8 @@ export const es: I18nDictionary = { "model.menu.error": "🔴 No se pudo obtener la lista de modelos", "model.search.button": "🔍 Buscar", "model.search.prompt": "🔍 Ingrese el nombre del modelo para buscar:", - "model.search.results_title": "Resultados de búsqueda para \"{query}\":", - "model.search.no_results": "No se encontraron modelos para \"{query}\"", + "model.search.results_title": 'Resultados de búsqueda para "{query}":', + "model.search.no_results": 'No se encontraron modelos para "{query}"', "model.search.search_again": "↩ Buscar de nuevo", "model.search.error": "Búsqueda fallida", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 37735a3b..a08a56b8 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -7,7 +7,7 @@ export const fr: I18nDictionary = { "cmd.description.detach": "Se détacher de la session actuelle", "cmd.description.sessions": "Lister les sessions", "cmd.description.messages": "Parcourir les messages de session", - "cmd.description.tts": "Basculer les réponses audio", + "cmd.description.tts": "Choisir le mode des réponses audio", "cmd.description.projects": "Lister les projets", "cmd.description.worktree": "Changer de worktree git", "cmd.description.task": "Créer une tâche planifiée", @@ -120,9 +120,10 @@ export const fr: I18nDictionary = { "status.line.uptime_sec": "Temps de fonctionnement : {seconds} sec", "status.line.mode": "Agent : {mode}", "status.line.model": "Modèle : {model}", - "status.line.tts": "Réponses TTS : {tts}", - "status.tts.on": "Activées", + "status.line.tts": "Réponses audio : {tts}", "status.tts.off": "Désactivées", + "status.tts.all": "Tout", + "status.tts.auto": "Auto", "status.agent_not_set": "non défini", "status.project_selected": "Projet : {project}", "status.worktree_selected": "Worktree : {worktree}", @@ -134,10 +135,12 @@ export const fr: I18nDictionary = { "status.server_unavailable": "🔴 Le serveur OpenCode est indisponible\n\nUtilisez /opencode_start pour démarrer le serveur.", - "tts.enabled": "🔊 Réponses audio activées globalement.", + "tts.prompt": "Sélectionnez le mode des réponses audio :", + "tts.off": "🔇 Réponses audio désactivées.", + "tts.all": "🔊 Réponses audio activées pour tous les messages.", + "tts.auto": "🎤 Réponses audio activées pour les messages vocaux uniquement.", "tts.not_configured": "⚠️ Les réponses audio ne sont pas disponibles. Définissez d'abord `TTS_API_URL` et `TTS_API_KEY`.", - "tts.disabled": "🔇 Réponses audio désactivées globalement.", "tts.failed": "⚠️ Impossible de générer la réponse audio.", "projects.empty": @@ -302,8 +305,8 @@ export const fr: I18nDictionary = { "model.menu.error": "🔴 Impossible de récupérer la liste des modèles", "model.search.button": "🔍 Rechercher", "model.search.prompt": "🔍 Entrez le nom du modèle à rechercher :", - "model.search.results_title": "Résultats de recherche pour \"{query}\" :", - "model.search.no_results": "Aucun modèle trouvé pour \"{query}\"", + "model.search.results_title": 'Résultats de recherche pour "{query}" :', + "model.search.no_results": 'Aucun modèle trouvé pour "{query}"', "model.search.search_again": "↩ Rechercher à nouveau", "model.search.error": "Échec de la recherche", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index bebe2bd4..8ddc3101 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -7,7 +7,7 @@ export const ru: I18nDictionary = { "cmd.description.detach": "Отсоединиться от текущей сессии", "cmd.description.sessions": "Список сессий", "cmd.description.messages": "Сообщения текущей сессии", - "cmd.description.tts": "Переключить аудиоответы", + "cmd.description.tts": "Выбрать режим аудиоответов", "cmd.description.projects": "Список проектов", "cmd.description.worktree": "Переключить git worktree", "cmd.description.task": "Создать задачу по расписанию", @@ -111,9 +111,10 @@ export const ru: I18nDictionary = { "status.line.uptime_sec": "Uptime: {seconds} сек", "status.line.mode": "Агент: {mode}", "status.line.model": "Модель: {model}", - "status.line.tts": "TTS-ответы: {tts}", - "status.tts.on": "Вкл", + "status.line.tts": "Аудиоответы: {tts}", "status.tts.off": "Выкл", + "status.tts.all": "Все", + "status.tts.auto": "Авто", "status.agent_not_set": "не установлен", "status.project_selected": "Проект: {project}", "status.worktree_selected": "Worktree: {worktree}", @@ -125,9 +126,11 @@ export const ru: I18nDictionary = { "status.server_unavailable": "🔴 OpenCode Server недоступен\n\nИспользуйте /opencode_start для запуска сервера.", - "tts.enabled": "🔊 Аудиоответы включены глобально.", + "tts.prompt": "Выберите режим аудиоответов:", + "tts.off": "🔇 Аудиоответы выключены.", + "tts.all": "🔊 Аудиоответы включены для всех сообщений.", + "tts.auto": "🎤 Аудиоответы включены только для голосовых сообщений.", "tts.not_configured": "⚠️ Аудиоответы недоступны. Сначала укажите `TTS_API_URL` и `TTS_API_KEY`.", - "tts.disabled": "🔇 Аудиоответы выключены глобально.", "tts.failed": "⚠️ Не удалось создать аудиоответ.", "projects.empty": @@ -287,8 +290,8 @@ export const ru: I18nDictionary = { "model.menu.error": "🔴 Не удалось получить список моделей", "model.search.button": "🔍 Поиск", "model.search.prompt": "🔍 Введите название модели для поиска:", - "model.search.results_title": "Результаты поиска для \"{query}\":", - "model.search.no_results": "Модели не найдены для \"{query}\"", + "model.search.results_title": 'Результаты поиска для "{query}":', + "model.search.no_results": 'Модели не найдены для "{query}"', "model.search.search_again": "↩ Искать снова", "model.search.error": "Ошибка поиска", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8a86099c..7d59c586 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -7,7 +7,7 @@ export const zh: I18nDictionary = { "cmd.description.detach": "从当前会话分离", "cmd.description.sessions": "列出会话", "cmd.description.messages": "浏览会话消息", - "cmd.description.tts": "切换语音回复", + "cmd.description.tts": "选择语音回复模式", "cmd.description.projects": "列出项目", "cmd.description.worktree": "切换 git worktree", "cmd.description.task": "创建定时任务", @@ -83,7 +83,8 @@ export const zh: I18nDictionary = { "bot.file_too_large": "⚠️ 文件过大(最大 {maxSizeMb}MB)", "bot.file_download_error": "🔴 下载文件失败", "bot.file_type_unsupported": "⚠️ 不支持此文件类型。请发送图片、PDF 或文本/代码文件。", - "bot.media_group_not_processed": "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。", + "bot.media_group_not_processed": + "⚠️ 此相册中有一个或多个文件无法处理。未向 OpenCode 发送任何内容。", "bot.media_group_download_error": "🔴 无法下载其中一个文件。未向 OpenCode 发送任何内容。", "bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。", "bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)", @@ -99,9 +100,10 @@ export const zh: I18nDictionary = { "status.line.uptime_sec": "运行时间:{seconds} 秒", "status.line.mode": "Agent:{mode}", "status.line.model": "模型:{model}", - "status.line.tts": "TTS 回复:{tts}", - "status.tts.on": "开启", + "status.line.tts": "语音回复:{tts}", "status.tts.off": "关闭", + "status.tts.all": "全部", + "status.tts.auto": "自动", "status.agent_not_set": "未设置", "status.project_selected": "项目:{project}", "status.worktree_selected": "Worktree:{worktree}", @@ -112,9 +114,11 @@ export const zh: I18nDictionary = { "status.session_hint": "使用 /sessions 选择一个会话,或 /new 创建", "status.server_unavailable": "🔴 OpenCode 服务器不可用\n\n使用 /opencode_start 启动服务器。", - "tts.enabled": "🔊 已全局启用语音回复。", + "tts.prompt": "请选择语音回复模式:", + "tts.off": "🔇 语音回复已关闭。", + "tts.all": "🔊 已为所有消息启用语音回复。", + "tts.auto": "🎤 仅为语音消息启用语音回复。", "tts.not_configured": "⚠️ 语音回复暂不可用。请先设置 `TTS_API_URL` 和 `TTS_API_KEY`。", - "tts.disabled": "🔇 已全局关闭语音回复。", "tts.failed": "⚠️ 生成语音回复失败。", "projects.empty": @@ -251,8 +255,8 @@ export const zh: I18nDictionary = { "model.menu.error": "🔴 获取模型列表失败", "model.search.button": "🔍 搜索", "model.search.prompt": "🔍 输入模型名称进行搜索:", - "model.search.results_title": "\"{query}\" 的搜索结果:", - "model.search.no_results": "未找到 \"{query}\" 的模型", + "model.search.results_title": '"{query}" 的搜索结果:', + "model.search.no_results": '未找到 "{query}" 的模型', "model.search.search_again": "↩ 重新搜索", "model.search.error": "搜索失败", diff --git a/tests/app/stores/settings-store.test.ts b/tests/app/stores/settings-store.test.ts new file mode 100644 index 00000000..60eb3a17 --- /dev/null +++ b/tests/app/stores/settings-store.test.ts @@ -0,0 +1,44 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setRuntimeMode } from "../../../src/runtime/mode.js"; +import { + __resetSettingsForTests, + getTtsMode, + loadSettings, +} from "../../../src/app/stores/settings-store.js"; + +describe("app/stores/settings-store", () => { + let tempHome: string; + + beforeEach(async () => { + tempHome = await mkdtemp(path.join(os.tmpdir(), "opencode-telegram-settings-store-")); + process.env.OPENCODE_TELEGRAM_HOME = tempHome; + setRuntimeMode("installed"); + __resetSettingsForTests(); + }); + + afterEach(async () => { + delete process.env.OPENCODE_TELEGRAM_HOME; + __resetSettingsForTests(); + await rm(tempHome, { recursive: true, force: true }); + }); + + it.each([ + { oldValue: true, expectedMode: "all" }, + { oldValue: false, expectedMode: "off" }, + ] as const)( + "migrates ttsEnabled=$oldValue to $expectedMode mode", + async ({ oldValue, expectedMode }) => { + await writeFile( + path.join(tempHome, "settings.json"), + JSON.stringify({ ttsEnabled: oldValue }, null, 2), + ); + + await loadSettings(); + + expect(getTtsMode()).toBe(expectedMode); + }, + ); +}); diff --git a/tests/bot/commands/status.test.ts b/tests/bot/commands/status.test.ts index b6beb40e..3d0b1c47 100644 --- a/tests/bot/commands/status.test.ts +++ b/tests/bot/commands/status.test.ts @@ -6,7 +6,7 @@ const mocked = vi.hoisted(() => ({ healthMock: vi.fn(), getCurrentSessionMock: vi.fn(), getCurrentProjectMock: vi.fn(), - isTtsEnabledMock: vi.fn(), + getTtsModeMock: vi.fn(), fetchCurrentAgentMock: vi.fn(), fetchCurrentModelMock: vi.fn(), getGitWorktreeContextMock: vi.fn(), @@ -35,7 +35,7 @@ vi.mock("../../../src/app/services/session-service.js", () => ({ vi.mock("../../../src/app/stores/settings-store.js", () => ({ getCurrentProject: mocked.getCurrentProjectMock, - isTtsEnabled: mocked.isTtsEnabledMock, + getTtsMode: mocked.getTtsModeMock, })); vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ @@ -77,7 +77,7 @@ describe("bot/commands/status-command", () => { mocked.healthMock.mockReset(); mocked.getCurrentSessionMock.mockReset(); mocked.getCurrentProjectMock.mockReset(); - mocked.isTtsEnabledMock.mockReset(); + mocked.getTtsModeMock.mockReset(); mocked.fetchCurrentAgentMock.mockReset(); mocked.fetchCurrentModelMock.mockReset(); mocked.getGitWorktreeContextMock.mockReset(); @@ -94,7 +94,7 @@ describe("bot/commands/status-command", () => { mocked.healthMock.mockResolvedValue({ data: { healthy: true, version: "1.0.0" }, error: null }); mocked.getCurrentSessionMock.mockReturnValue({ id: "s1", title: "S", directory: "/repo" }); mocked.getCurrentProjectMock.mockReturnValue({ id: "p1", worktree: "/repo", name: "Repo" }); - mocked.isTtsEnabledMock.mockReturnValue(true); + mocked.getTtsModeMock.mockReturnValue("all"); mocked.fetchCurrentAgentMock.mockResolvedValue("build"); mocked.fetchCurrentModelMock.mockReturnValue({ providerID: "openai", modelID: "gpt-5" }); mocked.getGitWorktreeContextMock.mockResolvedValue(null); @@ -117,8 +117,8 @@ describe("bot/commands/status-command", () => { await statusCommand(ctx as never); const message = mocked.sendBotTextMock.mock.calls[0]?.[0]?.text as string; - expect(message).toContain("TTS replies"); - expect(message).toContain("On"); + expect(message).toContain("Audio replies"); + expect(message).toContain("All"); expect(message).not.toContain("Started by bot"); }); diff --git a/tests/bot/commands/tts.test.ts b/tests/bot/commands/tts.test.ts index ac9d07a2..8ac02d2f 100644 --- a/tests/bot/commands/tts.test.ts +++ b/tests/bot/commands/tts.test.ts @@ -1,17 +1,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Context } from "grammy"; import { ttsCommand } from "../../../src/bot/commands/tts-command.js"; +import { handleTtsCallback } from "../../../src/bot/callbacks/tts-callback-handler.js"; import { t } from "../../../src/i18n/index.js"; +import { TTS_CALLBACK_PREFIX } from "../../../src/bot/commands/tts-command.js"; const mocked = vi.hoisted(() => ({ - isTtsEnabledMock: vi.fn(), - setTtsEnabledMock: vi.fn(), + getTtsModeMock: vi.fn(), + setTtsModeMock: vi.fn(), isTtsConfiguredMock: vi.fn(), })); vi.mock("../../../src/app/stores/settings-store.js", () => ({ - isTtsEnabled: mocked.isTtsEnabledMock, - setTtsEnabled: mocked.setTtsEnabledMock, + getTtsMode: mocked.getTtsModeMock, + setTtsMode: mocked.setTtsModeMock, })); vi.mock("../../../src/app/services/tts-service.js", () => ({ @@ -20,15 +22,15 @@ vi.mock("../../../src/app/services/tts-service.js", () => ({ describe("bot/commands/tts-command", () => { beforeEach(() => { - mocked.isTtsEnabledMock.mockReset(); - mocked.setTtsEnabledMock.mockReset(); + mocked.getTtsModeMock.mockReset(); + mocked.setTtsModeMock.mockReset(); mocked.isTtsConfiguredMock.mockReset(); }); - it("enables audio replies globally", async () => { - mocked.isTtsEnabledMock.mockReturnValue(false); + it("shows inline keyboard with current mode selected", async () => { + mocked.getTtsModeMock.mockReturnValue("all"); mocked.isTtsConfiguredMock.mockReturnValue(true); - const replyMock = vi.fn().mockResolvedValue(undefined); + const replyMock = vi.fn().mockResolvedValue({ message_id: 1 }); const ctx = { chat: { id: 42, type: "private" }, message: { text: "/tts" }, @@ -37,14 +39,18 @@ describe("bot/commands/tts-command", () => { await ttsCommand(ctx as never); - expect(mocked.setTtsEnabledMock).toHaveBeenCalledWith(true); - expect(replyMock).toHaveBeenCalledWith(t("tts.enabled")); + expect(replyMock).toHaveBeenCalledTimes(1); + const [text, opts] = replyMock.mock.calls[0]; + expect(text).toBe(t("tts.prompt")); + expect(opts.reply_markup.inline_keyboard[0][0].text).toContain("🔇"); + expect(opts.reply_markup.inline_keyboard[1][0].text).toContain("✅"); + expect(opts.reply_markup.inline_keyboard[2][0].text).toContain("🎤"); }); - it("does not enable audio replies when TTS is not configured", async () => { - mocked.isTtsEnabledMock.mockReturnValue(false); + it("shows mode menu even when TTS is not configured", async () => { + mocked.getTtsModeMock.mockReturnValue("off"); mocked.isTtsConfiguredMock.mockReturnValue(false); - const replyMock = vi.fn().mockResolvedValue(undefined); + const replyMock = vi.fn().mockResolvedValue({ message_id: 1 }); const ctx = { chat: { id: 42, type: "private" }, message: { text: "/tts" }, @@ -53,22 +59,99 @@ describe("bot/commands/tts-command", () => { await ttsCommand(ctx as never); - expect(mocked.setTtsEnabledMock).not.toHaveBeenCalled(); - expect(replyMock).toHaveBeenCalledWith(t("tts.not_configured")); + expect(replyMock).toHaveBeenCalledTimes(1); + const [text, opts] = replyMock.mock.calls[0]; + expect(text).toBe(t("tts.prompt")); + expect(opts.reply_markup.inline_keyboard[0][0].text).toContain("✅"); + }); +}); + +describe("bot/callbacks/tts-callback-handler", () => { + beforeEach(() => { + mocked.getTtsModeMock.mockReset(); + mocked.setTtsModeMock.mockReset(); + mocked.isTtsConfiguredMock.mockReset(); }); - it("disables audio replies globally", async () => { - mocked.isTtsEnabledMock.mockReturnValue(true); - const replyMock = vi.fn().mockResolvedValue(undefined); + it("sets mode and deletes menu message on callback", async () => { + mocked.isTtsConfiguredMock.mockReturnValue(true); + mocked.getTtsModeMock.mockReturnValue("off"); + const deleteMessageMock = vi.fn().mockResolvedValue(undefined); + const answerCbMock = vi.fn().mockResolvedValue(undefined); const ctx = { - chat: { id: 42, type: "private" }, - message: { text: "/tts" }, - reply: replyMock, + callbackQuery: { data: `${TTS_CALLBACK_PREFIX}all` }, + deleteMessage: deleteMessageMock, + answerCallbackQuery: answerCbMock, } as unknown as Context; - await ttsCommand(ctx as never); + const result = await handleTtsCallback(ctx); + + expect(result).toBe(true); + expect(mocked.setTtsModeMock).toHaveBeenCalledWith("all"); + expect(answerCbMock).toHaveBeenCalledWith({ text: t("tts.all") }); + expect(deleteMessageMock).toHaveBeenCalledTimes(1); + }); + + it("removes keyboard when deleting menu message fails", async () => { + mocked.isTtsConfiguredMock.mockReturnValue(true); + const deleteMessageMock = vi.fn().mockRejectedValue(new Error("delete failed")); + const editReplyMarkupMock = vi.fn().mockResolvedValue(undefined); + const answerCbMock = vi.fn().mockResolvedValue(undefined); + const ctx = { + callbackQuery: { data: `${TTS_CALLBACK_PREFIX}auto` }, + deleteMessage: deleteMessageMock, + editMessageReplyMarkup: editReplyMarkupMock, + answerCallbackQuery: answerCbMock, + } as unknown as Context; + + const result = await handleTtsCallback(ctx); + + expect(result).toBe(true); + expect(mocked.setTtsModeMock).toHaveBeenCalledWith("auto"); + expect(deleteMessageMock).toHaveBeenCalledTimes(1); + expect(editReplyMarkupMock).toHaveBeenCalledTimes(1); + }); + + it("rejects unknown callback prefix", async () => { + const ctx = { + callbackQuery: { data: "unknown:data" }, + } as unknown as Context; + + const result = await handleTtsCallback(ctx); + expect(result).toBe(false); + }); + + it("shows alert when not configured", async () => { + mocked.isTtsConfiguredMock.mockReturnValue(false); + const answerCbMock = vi.fn().mockResolvedValue(undefined); + const ctx = { + callbackQuery: { data: `${TTS_CALLBACK_PREFIX}all` }, + answerCallbackQuery: answerCbMock, + } as unknown as Context; + + const result = await handleTtsCallback(ctx); + + expect(result).toBe(true); + expect(mocked.setTtsModeMock).not.toHaveBeenCalled(); + expect(answerCbMock).toHaveBeenCalledWith({ text: t("tts.not_configured"), show_alert: true }); + }); + + it("allows selecting off when TTS is not configured", async () => { + mocked.isTtsConfiguredMock.mockReturnValue(false); + mocked.getTtsModeMock.mockReturnValue("off"); + const deleteMessageMock = vi.fn().mockResolvedValue(undefined); + const answerCbMock = vi.fn().mockResolvedValue(undefined); + const ctx = { + callbackQuery: { data: `${TTS_CALLBACK_PREFIX}off` }, + deleteMessage: deleteMessageMock, + answerCallbackQuery: answerCbMock, + } as unknown as Context; + + const result = await handleTtsCallback(ctx); - expect(mocked.setTtsEnabledMock).toHaveBeenCalledWith(false); - expect(replyMock).toHaveBeenCalledWith(t("tts.disabled")); + expect(result).toBe(true); + expect(mocked.setTtsModeMock).toHaveBeenCalledWith("off"); + expect(answerCbMock).toHaveBeenCalledWith({ text: t("tts.off") }); + expect(deleteMessageMock).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/bot/handlers/prompt.test.ts b/tests/bot/handlers/prompt.test.ts index 5b9553cd..05d7587c 100644 --- a/tests/bot/handlers/prompt.test.ts +++ b/tests/bot/handlers/prompt.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Bot, Context } from "grammy"; -import { processUserPrompt, type ProcessPromptDeps } from "../../../src/bot/handlers/prompt.js"; +import { + consumePromptResponseMode, + processUserPrompt, + type ProcessPromptDeps, +} from "../../../src/bot/handlers/prompt.js"; const mocked = vi.hoisted(() => ({ currentProject: { id: "project-1", worktree: "D:\\Projects\\Repo" }, @@ -18,6 +22,7 @@ const mocked = vi.hoisted(() => ({ setSessionSummaryMock: vi.fn(), setBotAndChatIdMock: vi.fn(), attachToSessionMock: vi.fn(), + getTtsModeMock: vi.fn(), })); vi.mock("../../../src/opencode/client.js", () => ({ @@ -44,7 +49,7 @@ vi.mock("../../../src/app/services/session-cache-service.js", () => ({ vi.mock("../../../src/app/stores/settings-store.js", () => ({ getCurrentProject: vi.fn(() => mocked.currentProject), - isTtsEnabled: vi.fn(() => false), + getTtsMode: mocked.getTtsModeMock, })); vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ @@ -153,11 +158,15 @@ function getScheduledBackgroundTask(): { onSuccess?: (value: { error: unknown | null }) => void; onError?: (error: unknown) => void; } { - const [[options]] = mocked.safeBackgroundTaskMock.mock.calls as [[{ - task: () => Promise; - onSuccess?: (value: { error: unknown | null }) => void; - onError?: (error: unknown) => void; - }]]; + const [[options]] = mocked.safeBackgroundTaskMock.mock.calls as [ + [ + { + task: () => Promise; + onSuccess?: (value: { error: unknown | null }) => void; + onError?: (error: unknown) => void; + }, + ], + ]; return options; } @@ -179,6 +188,8 @@ describe("bot/handlers/prompt", () => { mocked.setSessionSummaryMock.mockReset(); mocked.setBotAndChatIdMock.mockReset(); mocked.attachToSessionMock.mockReset(); + mocked.getTtsModeMock.mockReset(); + mocked.getTtsModeMock.mockReturnValue("off"); mocked.attachToSessionMock.mockResolvedValue({ busy: false, alreadyAttached: false, @@ -287,6 +298,15 @@ describe("bot/handlers/prompt", () => { expect(mocked.suppressionRegisterMock).not.toHaveBeenCalled(); }); + it("keeps text prompts text-only when TTS mode is auto", async () => { + mocked.getTtsModeMock.mockReturnValue("auto"); + + const handled = await processUserPrompt(createContext(), "Review README", createDeps()); + + expect(handled).toBe(true); + expect(consumePromptResponseMode("session-1")).toBe("text_only"); + }); + it("uses plural placeholder text for multiple file-only prompts", async () => { const handled = await processUserPrompt(createContext(), "", createDeps(), [ { diff --git a/tests/bot/handlers/voice.test.ts b/tests/bot/handlers/voice.test.ts index 52d87ede..db428403 100644 --- a/tests/bot/handlers/voice.test.ts +++ b/tests/bot/handlers/voice.test.ts @@ -4,6 +4,14 @@ import type { Context } from "grammy"; import type { VoiceMessageDeps } from "../../../src/bot/handlers/voice-handler.js"; import { t } from "../../../src/i18n/index.js"; +const mocked = vi.hoisted(() => ({ + getTtsModeMock: vi.fn(), +})); + +vi.mock("../../../src/app/stores/settings-store.js", () => ({ + getTtsMode: mocked.getTtsModeMock, +})); + vi.mock("../../../src/utils/logger.js", () => ({ logger: { debug: vi.fn(), @@ -121,6 +129,7 @@ function mockHttpsDownload(): ReturnType { describe("bot/handlers/voice-handler", () => { beforeEach(() => { vi.clearAllMocks(); + mocked.getTtsModeMock.mockReturnValue("off"); vi.doUnmock("node:https"); vi.stubEnv("TELEGRAM_BOT_TOKEN", "test-telegram-token"); vi.stubEnv("TELEGRAM_ALLOWED_USER_ID", "123456789"); @@ -140,7 +149,9 @@ describe("bot/handlers/voice-handler", () => { await handleVoiceMessage(ctx, deps); expect(replyMock).toHaveBeenCalledWith(t("stt.recognizing")); - expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps); + expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps, [], { + responseMode: "text_only", + }); }); it("returns not-configured message and does not process prompt", async () => { @@ -185,12 +196,27 @@ describe("bot/handlers/voice-handler", () => { await handleVoiceMessage(ctx, deps); - expect(processPromptMock).toHaveBeenCalledWith(ctx, `[Note: ${note}]\nrun tests`, deps); + expect(processPromptMock).toHaveBeenCalledWith(ctx, `[Note: ${note}]\nrun tests`, deps, [], { + responseMode: "text_only", + }); expect(logger.debug).toHaveBeenCalledWith( `[Voice] Added STT note to LLM prompt: [Note: ${note}]`, ); }); + it("requests an audio reply for voice prompts when TTS mode is auto", async () => { + mocked.getTtsModeMock.mockReturnValue("auto"); + const { handleVoiceMessage } = await loadVoiceModule(); + const { ctx } = createVoiceContext(); + const { deps, processPromptMock } = createVoiceDeps(); + + await handleVoiceMessage(ctx, deps); + + expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps, [], { + responseMode: "text_and_tts", + }); + }); + it.each(["", "false", "0", " "])( "does not add STT note when STT_NOTE_PROMPT is %j", async (notePrompt) => { @@ -203,7 +229,9 @@ describe("bot/handlers/voice-handler", () => { await handleVoiceMessage(ctx, deps); - expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps); + expect(processPromptMock).toHaveBeenCalledWith(ctx, "run tests", deps, [], { + responseMode: "text_only", + }); expect(logger.debug).not.toHaveBeenCalled(); }, ); @@ -228,7 +256,9 @@ describe("bot/handlers/voice-handler", () => { expect(String(url)).toBe( "https://api.telegram.org/file/bottest-telegram-token/voice/file_123.oga", ); - expect(processPromptMock).toHaveBeenCalledWith(ctx, "hello", deps); + expect(processPromptMock).toHaveBeenCalledWith(ctx, "hello", deps, [], { + responseMode: "text_only", + }); }); it("downloads voice files from TELEGRAM_API_ROOT without a double slash", async () => {