Skip to content
Merged
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
6 changes: 3 additions & 3 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:

Expand Down
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
46 changes: 46 additions & 0 deletions src/bot/callbacks/tts-callback-handler.ts
Original file line number Diff line number Diff line change
@@ -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<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 (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;
}
10 changes: 8 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,14 @@ 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
31 changes: 18 additions & 13 deletions src/bot/commands/tts-command.ts
Original file line number Diff line number Diff line change
@@ -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<Context>): Promise<void> {
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<Context>): Promise<void> {
const current = getTtsMode();
const keyboard = buildTtsModeKeyboard(current);

await ctx.reply(message);
await ctx.reply(t("tts.prompt"), { reply_markup: keyboard });
}
11 changes: 8 additions & 3 deletions src/bot/handlers/prompt.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -127,7 +131,8 @@ 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
12 changes: 10 additions & 2 deletions src/bot/handlers/voice-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
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": "اختيار وضع الردود الصوتية",
"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": "اختر وضع الردود الصوتية:",
"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
20 changes: 12 additions & 8 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": "Audioantwort-Modus wählen",
"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": "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":
Expand Down Expand Up @@ -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.",

Expand Down Expand Up @@ -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",

Expand Down
Loading
Loading