@grinev/opencode-telegram-bot 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -36,6 +36,10 @@ OPENCODE_MODEL_ID=big-pickle
36
36
  # Bot Configuration (optional)
37
37
  # Maximum number of sessions shown in /sessions (default: 10)
38
38
  # SESSIONS_LIST_LIMIT=10
39
+
40
+ # Maximum number of projects shown in /projects (default: 10)
41
+ # PROJECTS_LIST_LIMIT=10
42
+
39
43
  # Bot locale: en or ru (default: en)
40
44
  # BOT_LOCALE=en
41
45
 
@@ -50,6 +54,20 @@ OPENCODE_MODEL_ID=big-pickle
50
54
  # Hide tool call service messages (default: false)
51
55
  # HIDE_TOOL_CALL_MESSAGES=false
52
56
 
57
+ # Assistant message formatting mode (default: markdown)
58
+ # markdown = convert assistant replies to Telegram MarkdownV2
59
+ # raw = show assistant replies as plain text
60
+ # MESSAGE_FORMAT_MODE=markdown
61
+
53
62
  # Code File Settings (optional)
54
63
  # Maximum file size in KB to send as document (default: 100)
55
64
  # CODE_FILE_MAX_SIZE_KB=100
65
+
66
+ # Speech-to-Text / Voice Recognition (optional)
67
+ # Enable voice message transcription by setting a Whisper-compatible API URL.
68
+ # Works with OpenAI, Groq, or any Whisper-compatible endpoint.
69
+ # If STT_API_URL is not set, voice messages will get a "not configured" reply.
70
+ # STT_API_URL=
71
+ # STT_API_KEY=
72
+ # STT_MODEL=
73
+ # STT_LANGUAGE=
package/README.md CHANGED
@@ -25,7 +25,10 @@ Quick start: `npx @grinev/opencode-telegram-bot`
25
25
  - **Model switching** — pick any model from your OpenCode favorites directly in the chat
26
26
  - **Agent modes** — switch between Plan and Build modes on the fly
27
27
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
28
+ - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
28
29
  - **Context control** — compact context when it gets too large, right from the chat
30
+ - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
31
+ - **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
29
32
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
30
33
  - **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
31
34
 
@@ -102,15 +105,7 @@ opencode-telegram config
102
105
  | `/opencode_stop` | Stop the OpenCode server remotely |
103
106
  | `/help` | Show available commands |
104
107
 
105
- Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active.
106
-
107
- ### Interaction Rules
108
-
109
- - Only one interactive flow can be active at a time (inline menus, permission request, question flow, rename)
110
- - While an interaction is active, the bot accepts only relevant input for that flow and blocks unrelated actions
111
- - Allowed utility commands remain available during active interactions: `/help`, `/status`, `/stop`
112
- - Unknown slash commands return an explicit fallback message instead of being silently ignored
113
- - Interaction flows do not expire automatically and wait until explicit completion (`answer`, `cancel`, `/stop`, or reset/cleanup)
108
+ Any regular text message is sent as a prompt to the coding agent only when no blocking interaction is active. Voice/audio messages are transcribed and then sent as prompts when STT is configured.
114
109
 
115
110
  > `/opencode_start` and `/opencode_stop` are intended as emergency commands — for example, if you need to restart a stuck server while away from your computer. Under normal usage, start `opencode serve` yourself before launching the bot.
116
111
 
@@ -124,26 +119,55 @@ When installed via npm, the configuration wizard handles the initial setup. The
124
119
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
125
120
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
126
121
 
127
- | Variable | Description | Required | Default |
128
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ----------------------- |
129
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
130
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
131
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
132
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
133
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
134
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
135
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
136
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
137
- | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
138
- | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
139
- | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
140
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
141
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
142
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
143
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
122
+ | Variable | Description | Required | Default |
123
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
124
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
125
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
126
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
127
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
128
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
129
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
130
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
131
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
132
+ | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
133
+ | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
134
+ | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
135
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
136
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
137
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
138
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
139
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
140
+ | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
141
+ | `STT_API_KEY` | API key for your STT provider | No | — |
142
+ | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
143
+ | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
144
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
144
145
 
145
146
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
146
147
 
148
+ ### Voice and Audio Transcription (Optional)
149
+
150
+ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
151
+
152
+ 1. Accept `voice` and `audio` Telegram messages
153
+ 2. Transcribe them via `POST {STT_API_URL}/audio/transcriptions`
154
+ 3. Show recognized text in chat
155
+ 4. Send the recognized text to OpenCode as a normal prompt
156
+
157
+ Supported provider examples (Whisper-compatible):
158
+
159
+ - **OpenAI**
160
+ - `STT_API_URL=https://api.openai.com/v1`
161
+ - `STT_MODEL=whisper-1`
162
+ - **Groq**
163
+ - `STT_API_URL=https://api.groq.com/openai/v1`
164
+ - `STT_MODEL=whisper-large-v3-turbo`
165
+ - **Together**
166
+ - `STT_API_URL=https://api.together.xyz/v1`
167
+ - `STT_MODEL=openai/whisper-large-v3`
168
+
169
+ If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
170
+
147
171
  ### Model Configuration
148
172
 
149
173
  The bot picks up your **favorite models** from OpenCode. To add a model to favorites:
@@ -14,8 +14,8 @@ import { createMainKeyboard } from "../utils/keyboard.js";
14
14
  import { ensureActiveInlineMenu, replyWithInlineMenu } from "../handlers/inline-menu.js";
15
15
  import { logger } from "../../utils/logger.js";
16
16
  import { t } from "../../i18n/index.js";
17
+ import { config } from "../../config.js";
17
18
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
18
- const MAX_PROJECTS_TO_SHOW = 10;
19
19
  function formatProjectButtonLabel(label, isActive) {
20
20
  const prefix = isActive ? "✅ " : "";
21
21
  const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
@@ -28,7 +28,7 @@ export async function projectsCommand(ctx) {
28
28
  try {
29
29
  await syncSessionDirectoryCache();
30
30
  const projects = await getProjects();
31
- const projectsToShow = projects.slice(0, MAX_PROJECTS_TO_SHOW);
31
+ const projectsToShow = projects.slice(0, config.bot.projectsListLimit);
32
32
  if (projectsToShow.length === 0) {
33
33
  await ctx.reply(t("projects.empty"));
34
34
  return;
@@ -7,8 +7,20 @@ import { createMainKeyboard } from "../utils/keyboard.js";
7
7
  import { getStoredAgent } from "../../agent/manager.js";
8
8
  import { pinnedMessageManager } from "../../pinned/manager.js";
9
9
  import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { config } from "../../config.js";
10
11
  import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
11
12
  import { t } from "../../i18n/index.js";
13
+ function isOnlyConfigDefaultModel(models) {
14
+ if (models.length !== 1) {
15
+ return false;
16
+ }
17
+ const defaultProviderID = config.opencode.model.provider;
18
+ const defaultModelID = config.opencode.model.modelId;
19
+ if (!defaultProviderID || !defaultModelID) {
20
+ return false;
21
+ }
22
+ return models[0].providerID === defaultProviderID && models[0].modelID === defaultModelID;
23
+ }
12
24
  /**
13
25
  * Handle model selection callback
14
26
  * @param ctx grammY context
@@ -83,9 +95,9 @@ export async function handleModelSelect(ctx) {
83
95
  * @param currentModel Current model for highlighting
84
96
  * @returns InlineKeyboard with model selection buttons
85
97
  */
86
- export async function buildModelSelectionMenu(currentModel) {
98
+ export async function buildModelSelectionMenu(currentModel, favoriteModels) {
87
99
  const keyboard = new InlineKeyboard();
88
- const favorites = await getFavoriteModels();
100
+ const favorites = favoriteModels ?? (await getFavoriteModels());
89
101
  if (favorites.length === 0) {
90
102
  logger.warn("[ModelHandler] No favorite models found");
91
103
  return keyboard;
@@ -109,7 +121,8 @@ export async function buildModelSelectionMenu(currentModel) {
109
121
  export async function showModelSelectionMenu(ctx) {
110
122
  try {
111
123
  const currentModel = fetchCurrentModel();
112
- const keyboard = await buildModelSelectionMenu(currentModel);
124
+ const favorites = await getFavoriteModels();
125
+ const keyboard = await buildModelSelectionMenu(currentModel, favorites);
113
126
  if (keyboard.inline_keyboard.length === 0) {
114
127
  await ctx.reply(t("model.menu.empty"));
115
128
  return;
@@ -121,6 +134,9 @@ export async function showModelSelectionMenu(ctx) {
121
134
  text,
122
135
  keyboard,
123
136
  });
137
+ if (isOnlyConfigDefaultModel(favorites)) {
138
+ await ctx.reply(t("model.menu.favorites_hint"));
139
+ }
124
140
  }
125
141
  catch (err) {
126
142
  logger.error("[ModelHandler] Error showing model menu:", err);
@@ -0,0 +1,232 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
3
+ import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
+ import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getStoredAgent } from "../../agent/manager.js";
6
+ import { getStoredModel } from "../../model/manager.js";
7
+ import { formatVariantForButton } from "../../variant/manager.js";
8
+ import { createMainKeyboard } from "../utils/keyboard.js";
9
+ import { keyboardManager } from "../../keyboard/manager.js";
10
+ import { pinnedMessageManager } from "../../pinned/manager.js";
11
+ import { summaryAggregator } from "../../summary/aggregator.js";
12
+ import { stopEventListening } from "../../opencode/events.js";
13
+ import { interactionManager } from "../../interaction/manager.js";
14
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
15
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
16
+ import { formatErrorDetails } from "../../utils/error-format.js";
17
+ import { logger } from "../../utils/logger.js";
18
+ import { t } from "../../i18n/index.js";
19
+ /** Module-level references for async callbacks that don't have ctx. */
20
+ let botInstance = null;
21
+ let chatIdInstance = null;
22
+ export function getPromptBotInstance() {
23
+ return botInstance;
24
+ }
25
+ export function getPromptChatId() {
26
+ return chatIdInstance;
27
+ }
28
+ async function isSessionBusy(sessionId, directory) {
29
+ try {
30
+ const { data, error } = await opencodeClient.session.status({ directory });
31
+ if (error || !data) {
32
+ logger.warn("[Bot] Failed to check session status before prompt:", error);
33
+ return false;
34
+ }
35
+ const sessionStatus = data[sessionId];
36
+ if (!sessionStatus) {
37
+ return false;
38
+ }
39
+ logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
40
+ return sessionStatus.type === "busy";
41
+ }
42
+ catch (err) {
43
+ logger.warn("[Bot] Error checking session status before prompt:", err);
44
+ return false;
45
+ }
46
+ }
47
+ async function resetMismatchedSessionContext() {
48
+ stopEventListening();
49
+ summaryAggregator.clear();
50
+ clearAllInteractionState("session_mismatch_reset");
51
+ clearSession();
52
+ keyboardManager.clearContext();
53
+ if (!pinnedMessageManager.isInitialized()) {
54
+ return;
55
+ }
56
+ try {
57
+ await pinnedMessageManager.clear();
58
+ }
59
+ catch (err) {
60
+ logger.error("[Bot] Failed to clear pinned message during session reset:", err);
61
+ }
62
+ }
63
+ /**
64
+ * Processes a user prompt: ensures project/session, subscribes to events, and sends
65
+ * the prompt to OpenCode. Used by text, voice, and photo message handlers.
66
+ *
67
+ * @param ctx - Grammy context
68
+ * @param text - Text content of the prompt
69
+ * @param deps - Dependencies (bot and event subscription)
70
+ * @param fileParts - Optional file parts (for photo/document attachments)
71
+ * @returns true if the prompt was dispatched, false if it was blocked/failed early.
72
+ */
73
+ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
74
+ const { bot, ensureEventSubscription } = deps;
75
+ const currentProject = getCurrentProject();
76
+ if (!currentProject) {
77
+ await ctx.reply(t("bot.project_not_selected"));
78
+ return false;
79
+ }
80
+ botInstance = bot;
81
+ chatIdInstance = ctx.chat.id;
82
+ // Initialize pinned message manager if not already
83
+ if (!pinnedMessageManager.isInitialized()) {
84
+ pinnedMessageManager.initialize(bot.api, ctx.chat.id);
85
+ }
86
+ // Initialize keyboard manager if not already
87
+ keyboardManager.initialize(bot.api, ctx.chat.id);
88
+ let currentSession = getCurrentSession();
89
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
90
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
91
+ await resetMismatchedSessionContext();
92
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
93
+ return false;
94
+ }
95
+ if (!currentSession) {
96
+ await ctx.reply(t("bot.creating_session"));
97
+ const { data: session, error } = await opencodeClient.session.create({
98
+ directory: currentProject.worktree,
99
+ });
100
+ if (error || !session) {
101
+ await ctx.reply(t("bot.create_session_error"));
102
+ return false;
103
+ }
104
+ logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
105
+ currentSession = {
106
+ id: session.id,
107
+ title: session.title,
108
+ directory: currentProject.worktree,
109
+ };
110
+ setCurrentSession(currentSession);
111
+ await ingestSessionInfoForCache(session);
112
+ // Create pinned message for new session
113
+ try {
114
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
115
+ }
116
+ catch (err) {
117
+ logger.error("[Bot] Error creating pinned message for new session:", err);
118
+ }
119
+ const currentAgent = getStoredAgent();
120
+ const currentModel = getStoredModel();
121
+ const contextInfo = pinnedMessageManager.getContextInfo();
122
+ const variantName = formatVariantForButton(currentModel.variant || "default");
123
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
124
+ await ctx.reply(t("bot.session_created", { title: session.title }), {
125
+ reply_markup: keyboard,
126
+ });
127
+ }
128
+ else {
129
+ logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
130
+ // Ensure pinned message exists for existing session
131
+ if (!pinnedMessageManager.getState().messageId) {
132
+ try {
133
+ await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
134
+ }
135
+ catch (err) {
136
+ logger.error("[Bot] Error creating pinned message for existing session:", err);
137
+ }
138
+ }
139
+ }
140
+ await ensureEventSubscription(currentSession.directory);
141
+ summaryAggregator.setSession(currentSession.id);
142
+ summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
143
+ const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
144
+ if (sessionIsBusy) {
145
+ logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
146
+ await ctx.reply(t("bot.session_busy"));
147
+ return false;
148
+ }
149
+ try {
150
+ const currentAgent = getStoredAgent();
151
+ const storedModel = getStoredModel();
152
+ // Build parts array with text and files
153
+ const parts = [];
154
+ // Add text part if present
155
+ if (text.trim().length > 0) {
156
+ parts.push({ type: "text", text });
157
+ }
158
+ // Add file parts
159
+ parts.push(...fileParts);
160
+ // If no text and files exist, use a placeholder
161
+ if (parts.length === 0 || (parts.length > 0 && parts.every((p) => p.type === "file"))) {
162
+ if (fileParts.length > 0) {
163
+ // Files without text - add a minimal system prompt
164
+ parts.unshift({ type: "text", text: "See attached file" });
165
+ }
166
+ }
167
+ const promptOptions = {
168
+ sessionID: currentSession.id,
169
+ directory: currentSession.directory,
170
+ parts,
171
+ agent: currentAgent,
172
+ };
173
+ // Use stored model (from settings or config)
174
+ if (storedModel.providerID && storedModel.modelID) {
175
+ promptOptions.model = {
176
+ providerID: storedModel.providerID,
177
+ modelID: storedModel.modelID,
178
+ };
179
+ // Add variant if specified
180
+ if (storedModel.variant) {
181
+ promptOptions.variant = storedModel.variant;
182
+ }
183
+ }
184
+ const promptErrorLogContext = {
185
+ sessionId: currentSession.id,
186
+ directory: currentSession.directory,
187
+ agent: currentAgent || "default",
188
+ modelProvider: storedModel.providerID || "default",
189
+ modelId: storedModel.modelID || "default",
190
+ variant: storedModel.variant || "default",
191
+ promptLength: text.length,
192
+ fileCount: fileParts.length,
193
+ };
194
+ logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
195
+ // CRITICAL: DO NOT wait for session.prompt to complete.
196
+ // If we wait, the handler will not finish and grammY will not call getUpdates,
197
+ // which blocks receiving button callback_query updates.
198
+ // The processing result will arrive via SSE events.
199
+ safeBackgroundTask({
200
+ taskName: "session.prompt",
201
+ task: () => opencodeClient.session.prompt(promptOptions),
202
+ onSuccess: ({ error }) => {
203
+ if (error) {
204
+ const details = formatErrorDetails(error, 6000);
205
+ logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
206
+ logger.error("[Bot] session.prompt error details:", details);
207
+ logger.error("[Bot] session.prompt raw API error object:", error);
208
+ // Send user-friendly error via API directly because ctx is no longer available
209
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
210
+ return;
211
+ }
212
+ logger.info("[Bot] session.prompt completed");
213
+ },
214
+ onError: (error) => {
215
+ const details = formatErrorDetails(error, 6000);
216
+ logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
217
+ logger.error("[Bot] session.prompt background failure details:", details);
218
+ logger.error("[Bot] session.prompt raw background error object:", error);
219
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
220
+ },
221
+ });
222
+ return true;
223
+ }
224
+ catch (err) {
225
+ logger.error("Error in prompt handler:", err);
226
+ if (interactionManager.getSnapshot()) {
227
+ clearAllInteractionState("message_handler_error");
228
+ }
229
+ await ctx.reply(t("error.generic"));
230
+ return false;
231
+ }
232
+ }
@@ -0,0 +1,170 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { URL } from "node:url";
4
+ import { HttpsProxyAgent } from "https-proxy-agent";
5
+ import { SocksProxyAgent } from "socks-proxy-agent";
6
+ import { config } from "../../config.js";
7
+ import { isSttConfigured, transcribeAudio } from "../../stt/client.js";
8
+ import { processUserPrompt } from "./prompt.js";
9
+ import { logger } from "../../utils/logger.js";
10
+ import { t } from "../../i18n/index.js";
11
+ const TELEGRAM_DOWNLOAD_TIMEOUT_MS = 30_000;
12
+ const TELEGRAM_DOWNLOAD_MAX_REDIRECTS = 3;
13
+ let telegramDownloadAgent;
14
+ function getTelegramDownloadAgent() {
15
+ if (telegramDownloadAgent !== undefined) {
16
+ return telegramDownloadAgent || undefined;
17
+ }
18
+ const proxyUrl = config.telegram.proxyUrl.trim();
19
+ if (!proxyUrl) {
20
+ telegramDownloadAgent = null;
21
+ return undefined;
22
+ }
23
+ telegramDownloadAgent = proxyUrl.startsWith("socks")
24
+ ? new SocksProxyAgent(proxyUrl)
25
+ : new HttpsProxyAgent(proxyUrl);
26
+ logger.info(`[Voice] Using Telegram download proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
27
+ return telegramDownloadAgent;
28
+ }
29
+ async function downloadTelegramFileByUrl(url, redirectDepth = 0) {
30
+ return new Promise((resolve, reject) => {
31
+ const targetUrl = new URL(url);
32
+ const requestModule = targetUrl.protocol === "http:" ? http : https;
33
+ const request = requestModule.get(targetUrl, { agent: getTelegramDownloadAgent() }, (response) => {
34
+ const statusCode = response.statusCode ?? 0;
35
+ if (statusCode >= 300 && statusCode < 400 && response.headers.location) {
36
+ response.resume();
37
+ if (redirectDepth >= TELEGRAM_DOWNLOAD_MAX_REDIRECTS) {
38
+ reject(new Error("Too many redirects while downloading Telegram file"));
39
+ return;
40
+ }
41
+ const redirectUrl = new URL(response.headers.location, targetUrl).toString();
42
+ void downloadTelegramFileByUrl(redirectUrl, redirectDepth + 1)
43
+ .then(resolve)
44
+ .catch(reject);
45
+ return;
46
+ }
47
+ if (statusCode < 200 || statusCode >= 300) {
48
+ response.resume();
49
+ reject(new Error(`Telegram file download failed with HTTP ${statusCode}`));
50
+ return;
51
+ }
52
+ const chunks = [];
53
+ response.on("data", (chunk) => {
54
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
55
+ });
56
+ response.on("end", () => {
57
+ resolve(Buffer.concat(chunks));
58
+ });
59
+ response.on("error", reject);
60
+ });
61
+ request.on("error", reject);
62
+ request.setTimeout(TELEGRAM_DOWNLOAD_TIMEOUT_MS, () => {
63
+ request.destroy(new Error(`Telegram file download timed out after ${TELEGRAM_DOWNLOAD_TIMEOUT_MS}ms`));
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * Downloads the audio file from Telegram servers.
69
+ *
70
+ * @returns Buffer with file content, or null on failure
71
+ */
72
+ async function downloadTelegramFile(ctx, fileId) {
73
+ try {
74
+ const file = await ctx.api.getFile(fileId);
75
+ if (!file.file_path) {
76
+ logger.error("[Voice] Telegram getFile returned no file_path");
77
+ return null;
78
+ }
79
+ const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`;
80
+ logger.debug(`[Voice] Downloading file: ${file.file_path} (${file.file_size ?? "?"} bytes)`);
81
+ const buffer = await downloadTelegramFileByUrl(fileUrl);
82
+ // Extract filename from file_path (e.g., "voice/file_123.oga" -> "file_123.oga")
83
+ const filename = file.file_path.split("/").pop() || "audio.ogg";
84
+ logger.debug(`[Voice] Downloaded file: ${filename} (${buffer.length} bytes)`);
85
+ return { buffer, filename };
86
+ }
87
+ catch (err) {
88
+ logger.error("[Voice] Error downloading file from Telegram:", err);
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Creates the voice message handler function.
94
+ *
95
+ * The factory pattern is used so that `bot` and `ensureEventSubscription` dependencies
96
+ * can be injected from createBot() without circular imports.
97
+ */
98
+ export function createVoiceHandler(deps) {
99
+ return async (ctx) => {
100
+ await handleVoiceMessage(ctx, deps);
101
+ };
102
+ }
103
+ /**
104
+ * Handles incoming voice and audio messages:
105
+ * 1. Checks if STT is configured
106
+ * 2. Downloads the audio file from Telegram
107
+ * 3. Sends "recognizing..." status message
108
+ * 4. Calls STT API
109
+ * 5. Shows recognized text
110
+ * 6. Passes text to processUserPrompt
111
+ */
112
+ export async function handleVoiceMessage(ctx, deps) {
113
+ const sttConfigured = deps.isSttConfigured ?? isSttConfigured;
114
+ const downloadFile = deps.downloadTelegramFile ?? downloadTelegramFile;
115
+ const transcribe = deps.transcribeAudio ?? transcribeAudio;
116
+ const processPrompt = deps.processPrompt ?? processUserPrompt;
117
+ // Determine file_id from voice or audio message
118
+ const voice = ctx.message?.voice;
119
+ const audio = ctx.message?.audio;
120
+ const fileId = voice?.file_id ?? audio?.file_id;
121
+ if (!fileId) {
122
+ logger.warn("[Voice] Received voice/audio message with no file_id");
123
+ return;
124
+ }
125
+ // Check if STT is configured
126
+ if (!sttConfigured()) {
127
+ await ctx.reply(t("stt.not_configured"));
128
+ return;
129
+ }
130
+ // Send "recognizing..." status message (will be edited later)
131
+ const statusMessage = await ctx.reply(t("stt.recognizing"));
132
+ try {
133
+ // Download the audio file from Telegram
134
+ const fileData = await downloadFile(ctx, fileId);
135
+ if (!fileData) {
136
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: "download failed" }));
137
+ return;
138
+ }
139
+ // Transcribe the audio
140
+ const result = await transcribe(fileData.buffer, fileData.filename);
141
+ const recognizedText = result.text.trim();
142
+ if (!recognizedText) {
143
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.empty_result"));
144
+ return;
145
+ }
146
+ // Show the recognized text by editing the status message.
147
+ // IMPORTANT: even if this edit fails (e.g. Telegram message length limits),
148
+ // we still send the recognized text to OpenCode as a prompt.
149
+ try {
150
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.recognized", { text: recognizedText }));
151
+ }
152
+ catch (editError) {
153
+ logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
154
+ }
155
+ logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
156
+ // Process the recognized text as a prompt
157
+ await processPrompt(ctx, recognizedText, deps);
158
+ }
159
+ catch (err) {
160
+ const errorMessage = err instanceof Error ? err.message : "unknown error";
161
+ logger.error("[Voice] Error processing voice message:", err);
162
+ try {
163
+ await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("stt.error", { error: errorMessage }));
164
+ }
165
+ catch {
166
+ // If we can't edit the status message, try sending a new one
167
+ await ctx.reply(t("stt.error", { error: errorMessage })).catch(() => { });
168
+ }
169
+ }
170
+ }