Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/app/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -193,6 +195,14 @@ export async function loadSettings(): Promise<void> {
requiresRewrite = true;
}

// Migrate old ttsEnabled boolean to new ttsMode
if ("ttsEnabled" in loadedSettings) {
const oldEnabled = (loadedSettings as Record<string, unknown>).ttsEnabled;
loadedSettings.ttsMode = oldEnabled === true ? 'all' : 'off';
delete (loadedSettings as Record<string, unknown>).ttsEnabled;
requiresRewrite = true;
}

currentSettings = loadedSettings;
currentSettings.scheduledTasks = cloneScheduledTasks(loadedSettings.scheduledTasks) ?? [];
currentSettings.scheduledTaskSessionIgnores =
Expand Down
2 changes: 1 addition & 1 deletion src/app/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
5 changes: 4 additions & 1 deletion src/bot/callbacks/callback-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,6 +77,7 @@ export function registerCallbackRouter(bot: Bot<Context>, 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);
Expand All @@ -95,7 +97,7 @@ export function registerCallbackRouter(bot: Bot<Context>, 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 (
Expand All @@ -113,6 +115,7 @@ export function registerCallbackRouter(bot: Bot<Context>, deps: CallbackRouterDe
!handledModelSearchResults &&
!handledModel &&
!handledVariant &&
!handledTts &&
!handledCompactConfirm &&
!handledTask &&
!handledTaskList &&
Expand Down
47 changes: 47 additions & 0 deletions src/bot/callbacks/tts-callback-handler.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
5 changes: 3 additions & 2 deletions src/bot/commands/status-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,8 +26,9 @@ export async function statusCommand(ctx: CommandContext<Context>) {
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
Expand Down
19 changes: 11 additions & 8 deletions src/bot/commands/tts-command.ts
Original file line number Diff line number Diff line change
@@ -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<Context>): Promise<void> {
const enabled = !isTtsEnabled();
export const TTS_CALLBACK_PREFIX = "tts:";

if (enabled && !isTtsConfigured()) {
export async function ttsCommand(ctx: CommandContext<Context>): Promise<void> {
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 });
}
4 changes: 2 additions & 2 deletions src/bot/handlers/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -127,7 +127,7 @@ export async function processUserPrompt(
options: ProcessPromptOptions = {},
): Promise<boolean> {
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) {
Expand Down
5 changes: 4 additions & 1 deletion src/bot/handlers/voice-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions src/i18n/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "إنشاء مهمة مجدولة",
Expand Down Expand Up @@ -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}",
Expand All @@ -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 وأنشئ جلسة واحدة على الأقل، ثم سيظهر المشروع هنا.",
Expand Down
13 changes: 8 additions & 5 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand All @@ -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":
Expand Down
13 changes: 8 additions & 5 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand All @@ -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":
Expand Down
13 changes: 8 additions & 5 deletions src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand All @@ -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":
Expand Down
Loading
Loading