diff --git a/src/app/stores/settings-store.ts b/src/app/stores/settings-store.ts index db313e5a..77bd398b 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..940eea2b 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..c652196f --- /dev/null +++ b/src/bot/callbacks/tts-callback-handler.ts @@ -0,0 +1,47 @@ +import { Context, InlineKeyboard } from "grammy"; +import { isTtsConfigured } from "../../app/services/tts-service.js"; +import { getTtsMode, 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 (!isTtsConfigured()) { + await ctx.answerCallbackQuery({ text: t("tts.not_configured"), show_alert: true }); + return true; + } + + setTtsMode(mode); + + const current = getTtsMode(); + + const keyboard = new InlineKeyboard() + .text(`${current === 'off' ? '✅ ' : ''}🔇 ${t("status.tts.off")}`, `${TTS_CALLBACK_PREFIX}off`) + .text(`${current === 'all' ? '✅ ' : ''}🔊 ${t("status.tts.all")}`, `${TTS_CALLBACK_PREFIX}all`) + .text(`${current === 'auto' ? '✅ ' : ''}🎤 ${t("status.tts.auto")}`, `${TTS_CALLBACK_PREFIX}auto`); + + try { + await ctx.editMessageReplyMarkup({ reply_markup: keyboard }); + } catch (err) { + logger.warn("[TTS] Failed to update inline keyboard:", err); + } + + const messageKey = mode === 'off' ? "tts.off" : mode === 'all' ? "tts.all" : "tts.auto"; + await ctx.answerCallbackQuery({ text: t(messageKey) }); + + return true; +} diff --git a/src/bot/commands/status-command.ts b/src/bot/commands/status-command.ts index 2a963566..abf3747a 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,9 @@ 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..7f7b2f26 100644 --- a/src/bot/commands/tts-command.ts +++ b/src/bot/commands/tts-command.ts @@ -1,19 +1,22 @@ -import { CommandContext, Context } from "grammy"; +import { CommandContext, Context, InlineKeyboard } from "grammy"; import { isTtsConfigured } from "../../app/services/tts-service.js"; -import { isTtsEnabled, setTtsEnabled } from "../../app/stores/settings-store.js"; +import { getTtsMode } from "../../app/stores/settings-store.js"; import { t } from "../../i18n/index.js"; -export async function ttsCommand(ctx: CommandContext): Promise { - const enabled = !isTtsEnabled(); +export const TTS_CALLBACK_PREFIX = "tts:"; - if (enabled && !isTtsConfigured()) { +export async function ttsCommand(ctx: CommandContext): Promise { + if (!isTtsConfigured()) { await ctx.reply(t("tts.not_configured")); return; } - setTtsEnabled(enabled); + const current = getTtsMode(); - const message = enabled ? t("tts.enabled") : t("tts.disabled"); + const keyboard = new InlineKeyboard() + .text(`${current === 'off' ? '✅ ' : ''}🔇 ${t("status.tts.off")}`, `${TTS_CALLBACK_PREFIX}off`) + .text(`${current === 'all' ? '✅ ' : ''}🔊 ${t("status.tts.all")}`, `${TTS_CALLBACK_PREFIX}all`) + .text(`${current === 'auto' ? '✅ ' : ''}🎤 ${t("status.tts.auto")}`, `${TTS_CALLBACK_PREFIX}auto`); - 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..41fd24ee 100644 --- a/src/bot/handlers/prompt.ts +++ b/src/bot/handlers/prompt.ts @@ -3,7 +3,7 @@ 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 { 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 +127,7 @@ 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..72ff16de 100644 --- a/src/bot/handlers/voice-handler.ts +++ b/src/bot/handlers/voice-handler.ts @@ -6,6 +6,7 @@ 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 { 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"; @@ -241,7 +242,9 @@ 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" as const : "text_only" as const; + 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..91d99854 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": "Choose audio reply mode", "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": "Select audio reply mode:", + "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..ca840920 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": "Choose audio reply mode", "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": "Select audio reply mode:", + "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": diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 382e5ff8..780ff387 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": diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 39d89999..3296849b 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": "Choose audio reply mode", "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": "Select audio reply mode:", + "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": diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 37735a3b..b3756768 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": "Choose audio reply mode", "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": "Select audio reply mode:", + "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": diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index bebe2bd4..557b69cf 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": "Choose audio reply mode", "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": "Select audio reply mode:", + "tts.off": "🔇 Аудиоответы выключены.", + "tts.all": "🔊 Аудиоответы включены для всех сообщений.", + "tts.auto": "🎤 Аудиоответы включены только для голосовых сообщений.", "tts.not_configured": "⚠️ Аудиоответы недоступны. Сначала укажите `TTS_API_URL` и `TTS_API_KEY`.", - "tts.disabled": "🔇 Аудиоответы выключены глобально.", "tts.failed": "⚠️ Не удалось создать аудиоответ.", "projects.empty": diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 8a86099c..28c0e9c6 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": "Choose audio reply mode", "cmd.description.projects": "列出项目", "cmd.description.worktree": "切换 git worktree", "cmd.description.task": "创建定时任务", @@ -99,9 +99,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 +113,11 @@ export const zh: I18nDictionary = { "status.session_hint": "使用 /sessions 选择一个会话,或 /new 创建", "status.server_unavailable": "🔴 OpenCode 服务器不可用\n\n使用 /opencode_start 启动服务器。", - "tts.enabled": "🔊 已全局启用语音回复。", + "tts.prompt": "Select audio reply mode:", + "tts.off": "🔇 语音回复已关闭。", + "tts.all": "🔊 已为所有消息启用语音回复。", + "tts.auto": "🎤 仅为语音消息启用语音回复。", "tts.not_configured": "⚠️ 语音回复暂不可用。请先设置 `TTS_API_URL` 和 `TTS_API_KEY`。", - "tts.disabled": "🔇 已全局关闭语音回复。", "tts.failed": "⚠️ 生成语音回复失败。", "projects.empty": 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..5141f5d2 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,17 @@ 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[0][1].text).toContain("✅"); + expect(opts.reply_markup.inline_keyboard[0][2].text).toContain("🎤"); }); - it("does not enable audio replies when TTS is not configured", async () => { - mocked.isTtsEnabledMock.mockReturnValue(false); + it("shows not configured when TTS is not configured", async () => { 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 +58,56 @@ describe("bot/commands/tts-command", () => { await ttsCommand(ctx as never); - expect(mocked.setTtsEnabledMock).not.toHaveBeenCalled(); expect(replyMock).toHaveBeenCalledWith(t("tts.not_configured")); }); +}); + +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 updates keyboard on callback", async () => { + mocked.isTtsConfiguredMock.mockReturnValue(true); + mocked.getTtsModeMock.mockReturnValue("off"); + const editReplyMarkupMock = 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` }, + editMessageReplyMarkup: editReplyMarkupMock, + 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(editReplyMarkupMock).toHaveBeenCalledTimes(1); + expect(answerCbMock).toHaveBeenCalledWith({ text: t("tts.all") }); + }); + + 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(mocked.setTtsEnabledMock).toHaveBeenCalledWith(false); - expect(replyMock).toHaveBeenCalledWith(t("tts.disabled")); + expect(result).toBe(true); + expect(answerCbMock).toHaveBeenCalledWith({ text: t("tts.not_configured"), show_alert: true }); }); }); diff --git a/tests/bot/handlers/prompt.test.ts b/tests/bot/handlers/prompt.test.ts index 5b9553cd..d0428534 100644 --- a/tests/bot/handlers/prompt.test.ts +++ b/tests/bot/handlers/prompt.test.ts @@ -44,7 +44,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: vi.fn(() => "off"), })); vi.mock("../../../src/app/services/agent-selection-service.js", () => ({ diff --git a/tests/bot/handlers/voice.test.ts b/tests/bot/handlers/voice.test.ts index 52d87ede..b0fcb2b1 100644 --- a/tests/bot/handlers/voice.test.ts +++ b/tests/bot/handlers/voice.test.ts @@ -4,6 +4,10 @@ import type { Context } from "grammy"; import type { VoiceMessageDeps } from "../../../src/bot/handlers/voice-handler.js"; import { t } from "../../../src/i18n/index.js"; +vi.mock("../../../src/app/stores/settings-store.js", () => ({ + getTtsMode: () => "off", +})); + vi.mock("../../../src/utils/logger.js", () => ({ logger: { debug: vi.fn(), @@ -140,7 +144,7 @@ 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,7 +189,7 @@ 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}]`, ); @@ -203,7 +207,7 @@ 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 +232,7 @@ 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 () => {