@grinev/opencode-telegram-bot 0.6.1 → 0.7.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
 
@@ -53,3 +57,12 @@ OPENCODE_MODEL_ID=big-pickle
53
57
  # Code File Settings (optional)
54
58
  # Maximum file size in KB to send as document (default: 100)
55
59
  # CODE_FILE_MAX_SIZE_KB=100
60
+
61
+ # Speech-to-Text / Voice Recognition (optional)
62
+ # Enable voice message transcription by setting a Whisper-compatible API URL.
63
+ # Works with OpenAI, Groq, or any Whisper-compatible endpoint.
64
+ # If STT_API_URL is not set, voice messages will get a "not configured" reply.
65
+ # STT_API_URL=
66
+ # STT_API_KEY=
67
+ # STT_MODEL=
68
+ # STT_LANGUAGE=
package/README.md CHANGED
@@ -25,7 +25,9 @@ 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
29
31
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
30
32
  - **Localization** — English and Russian UI (`BOT_LOCALE=en|ru`)
31
33
 
@@ -102,15 +104,7 @@ opencode-telegram config
102
104
  | `/opencode_stop` | Stop the OpenCode server remotely |
103
105
  | `/help` | Show available commands |
104
106
 
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)
107
+ 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
108
 
115
109
  > `/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
110
 
@@ -124,26 +118,54 @@ When installed via npm, the configuration wizard handles the initial setup. The
124
118
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
125
119
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
126
120
 
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` |
121
+ | Variable | Description | Required | Default |
122
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
123
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
124
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
125
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
126
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
127
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
128
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
129
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
130
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
131
+ | `BOT_LOCALE` | Bot UI language (`en` or `ru`) | No | `en` |
132
+ | `SESSIONS_LIST_LIMIT` | Max sessions shown in `/sessions` | No | `10` |
133
+ | `PROJECTS_LIST_LIMIT` | Max projects shown in `/projects` | No | `10` |
134
+ | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
135
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
136
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
137
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
138
+ | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | — |
139
+ | `STT_API_KEY` | API key for your STT provider | No | — |
140
+ | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
141
+ | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
142
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
144
143
 
145
144
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
146
145
 
146
+ ### Voice and Audio Transcription (Optional)
147
+
148
+ If `STT_API_URL` and `STT_API_KEY` are set, the bot will:
149
+
150
+ 1. Accept `voice` and `audio` Telegram messages
151
+ 2. Transcribe them via `POST {STT_API_URL}/audio/transcriptions`
152
+ 3. Show recognized text in chat
153
+ 4. Send the recognized text to OpenCode as a normal prompt
154
+
155
+ Supported provider examples (Whisper-compatible):
156
+
157
+ - **OpenAI**
158
+ - `STT_API_URL=https://api.openai.com/v1`
159
+ - `STT_MODEL=whisper-1`
160
+ - **Groq**
161
+ - `STT_API_URL=https://api.groq.com/openai/v1`
162
+ - `STT_MODEL=whisper-large-v3-turbo`
163
+ - **Together**
164
+ - `STT_API_URL=https://api.together.xyz/v1`
165
+ - `STT_MODEL=openai/whisper-large-v3`
166
+
167
+ If STT variables are not set, voice/audio transcription is disabled and the bot will ask you to configure STT.
168
+
147
169
  ### Model Configuration
148
170
 
149
171
  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;
@@ -0,0 +1,212 @@
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 both text and voice message handlers.
66
+ *
67
+ * @returns true if the prompt was dispatched, false if it was blocked/failed early.
68
+ */
69
+ export async function processUserPrompt(ctx, text, deps) {
70
+ const { bot, ensureEventSubscription } = deps;
71
+ const currentProject = getCurrentProject();
72
+ if (!currentProject) {
73
+ await ctx.reply(t("bot.project_not_selected"));
74
+ return false;
75
+ }
76
+ botInstance = bot;
77
+ chatIdInstance = ctx.chat.id;
78
+ // Initialize pinned message manager if not already
79
+ if (!pinnedMessageManager.isInitialized()) {
80
+ pinnedMessageManager.initialize(bot.api, ctx.chat.id);
81
+ }
82
+ // Initialize keyboard manager if not already
83
+ keyboardManager.initialize(bot.api, ctx.chat.id);
84
+ let currentSession = getCurrentSession();
85
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
86
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
87
+ await resetMismatchedSessionContext();
88
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
89
+ return false;
90
+ }
91
+ if (!currentSession) {
92
+ await ctx.reply(t("bot.creating_session"));
93
+ const { data: session, error } = await opencodeClient.session.create({
94
+ directory: currentProject.worktree,
95
+ });
96
+ if (error || !session) {
97
+ await ctx.reply(t("bot.create_session_error"));
98
+ return false;
99
+ }
100
+ logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
101
+ currentSession = {
102
+ id: session.id,
103
+ title: session.title,
104
+ directory: currentProject.worktree,
105
+ };
106
+ setCurrentSession(currentSession);
107
+ await ingestSessionInfoForCache(session);
108
+ // Create pinned message for new session
109
+ try {
110
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
111
+ }
112
+ catch (err) {
113
+ logger.error("[Bot] Error creating pinned message for new session:", err);
114
+ }
115
+ const currentAgent = getStoredAgent();
116
+ const currentModel = getStoredModel();
117
+ const contextInfo = pinnedMessageManager.getContextInfo();
118
+ const variantName = formatVariantForButton(currentModel.variant || "default");
119
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
120
+ await ctx.reply(t("bot.session_created", { title: session.title }), {
121
+ reply_markup: keyboard,
122
+ });
123
+ }
124
+ else {
125
+ logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
126
+ // Ensure pinned message exists for existing session
127
+ if (!pinnedMessageManager.getState().messageId) {
128
+ try {
129
+ await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
130
+ }
131
+ catch (err) {
132
+ logger.error("[Bot] Error creating pinned message for existing session:", err);
133
+ }
134
+ }
135
+ }
136
+ await ensureEventSubscription(currentSession.directory);
137
+ summaryAggregator.setSession(currentSession.id);
138
+ summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
139
+ const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
140
+ if (sessionIsBusy) {
141
+ logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
142
+ await ctx.reply(t("bot.session_busy"));
143
+ return false;
144
+ }
145
+ try {
146
+ const currentAgent = getStoredAgent();
147
+ const storedModel = getStoredModel();
148
+ const promptOptions = {
149
+ sessionID: currentSession.id,
150
+ directory: currentSession.directory,
151
+ parts: [{ type: "text", text }],
152
+ agent: currentAgent,
153
+ };
154
+ // Use stored model (from settings or config)
155
+ if (storedModel.providerID && storedModel.modelID) {
156
+ promptOptions.model = {
157
+ providerID: storedModel.providerID,
158
+ modelID: storedModel.modelID,
159
+ };
160
+ // Add variant if specified
161
+ if (storedModel.variant) {
162
+ promptOptions.variant = storedModel.variant;
163
+ }
164
+ }
165
+ const promptErrorLogContext = {
166
+ sessionId: currentSession.id,
167
+ directory: currentSession.directory,
168
+ agent: currentAgent || "default",
169
+ modelProvider: storedModel.providerID || "default",
170
+ modelId: storedModel.modelID || "default",
171
+ variant: storedModel.variant || "default",
172
+ promptLength: text.length,
173
+ };
174
+ logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
175
+ // CRITICAL: DO NOT wait for session.prompt to complete.
176
+ // If we wait, the handler will not finish and grammY will not call getUpdates,
177
+ // which blocks receiving button callback_query updates.
178
+ // The processing result will arrive via SSE events.
179
+ safeBackgroundTask({
180
+ taskName: "session.prompt",
181
+ task: () => opencodeClient.session.prompt(promptOptions),
182
+ onSuccess: ({ error }) => {
183
+ if (error) {
184
+ const details = formatErrorDetails(error, 6000);
185
+ logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
186
+ logger.error("[Bot] session.prompt error details:", details);
187
+ logger.error("[Bot] session.prompt raw API error object:", error);
188
+ // Send user-friendly error via API directly because ctx is no longer available
189
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
190
+ return;
191
+ }
192
+ logger.info("[Bot] session.prompt completed");
193
+ },
194
+ onError: (error) => {
195
+ const details = formatErrorDetails(error, 6000);
196
+ logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
197
+ logger.error("[Bot] session.prompt background failure details:", details);
198
+ logger.error("[Bot] session.prompt raw background error object:", error);
199
+ void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
200
+ },
201
+ });
202
+ return true;
203
+ }
204
+ catch (err) {
205
+ logger.error("Error in prompt handler:", err);
206
+ if (interactionManager.getSnapshot()) {
207
+ clearAllInteractionState("message_handler_error");
208
+ }
209
+ await ctx.reply(t("error.generic"));
210
+ return false;
211
+ }
212
+ }
@@ -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
+ }
package/dist/bot/index.js CHANGED
@@ -33,23 +33,18 @@ import { questionManager } from "../question/manager.js";
33
33
  import { interactionManager } from "../interaction/manager.js";
34
34
  import { clearAllInteractionState } from "../interaction/cleanup.js";
35
35
  import { keyboardManager } from "../keyboard/manager.js";
36
- import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
36
+ import { subscribeToEvents } from "../opencode/events.js";
37
37
  import { summaryAggregator } from "../summary/aggregator.js";
38
38
  import { formatSummary, formatToolInfo } from "../summary/formatter.js";
39
39
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
40
- import { opencodeClient } from "../opencode/client.js";
41
- import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
40
+ import { getCurrentSession } from "../session/manager.js";
42
41
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
43
- import { getCurrentProject } from "../settings/manager.js";
44
- import { getStoredAgent } from "../agent/manager.js";
45
- import { getStoredModel } from "../model/manager.js";
46
- import { formatVariantForButton } from "../variant/manager.js";
47
- import { createMainKeyboard } from "./utils/keyboard.js";
48
42
  import { logger } from "../utils/logger.js";
49
43
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
50
- import { formatErrorDetails } from "../utils/error-format.js";
51
44
  import { pinnedMessageManager } from "../pinned/manager.js";
52
45
  import { t } from "../i18n/index.js";
46
+ import { processUserPrompt } from "./handlers/prompt.js";
47
+ import { handleVoiceMessage } from "./handlers/voice.js";
53
48
  let botInstance = null;
54
49
  let chatIdInstance = null;
55
50
  let commandsInitialized = false;
@@ -310,6 +305,25 @@ async function ensureEventSubscription(directory) {
310
305
  logger.error("[Bot] Error reloading context after compaction:", err);
311
306
  }
312
307
  });
308
+ summaryAggregator.setOnSessionError(async (sessionId, message) => {
309
+ if (!botInstance || !chatIdInstance) {
310
+ return;
311
+ }
312
+ const currentSession = getCurrentSession();
313
+ if (!currentSession || currentSession.id !== sessionId) {
314
+ return;
315
+ }
316
+ await toolMessageBatcher.flushSession(sessionId, "session_error");
317
+ const normalizedMessage = message.trim() || t("common.unknown_error");
318
+ const truncatedMessage = normalizedMessage.length > 3500
319
+ ? `${normalizedMessage.slice(0, 3497)}...`
320
+ : normalizedMessage;
321
+ await botInstance.api
322
+ .sendMessage(chatIdInstance, t("bot.session_error", { message: truncatedMessage }))
323
+ .catch((err) => {
324
+ logger.error("[Bot] Failed to send session.error message:", err);
325
+ });
326
+ });
313
327
  summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
314
328
  if (!pinnedMessageManager.isInitialized()) {
315
329
  return;
@@ -353,41 +367,6 @@ async function ensureEventSubscription(directory) {
353
367
  logger.error("Failed to subscribe to events:", err);
354
368
  });
355
369
  }
356
- async function isSessionBusy(sessionId, directory) {
357
- try {
358
- const { data, error } = await opencodeClient.session.status({ directory });
359
- if (error || !data) {
360
- logger.warn("[Bot] Failed to check session status before prompt:", error);
361
- return false;
362
- }
363
- const sessionStatus = data[sessionId];
364
- if (!sessionStatus) {
365
- return false;
366
- }
367
- logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
368
- return sessionStatus.type === "busy";
369
- }
370
- catch (err) {
371
- logger.warn("[Bot] Error checking session status before prompt:", err);
372
- return false;
373
- }
374
- }
375
- async function resetMismatchedSessionContext() {
376
- stopEventListening();
377
- summaryAggregator.clear();
378
- clearAllInteractionState("session_mismatch_reset");
379
- clearSession();
380
- keyboardManager.clearContext();
381
- if (!pinnedMessageManager.isInitialized()) {
382
- return;
383
- }
384
- try {
385
- await pinnedMessageManager.clear();
386
- }
387
- catch (err) {
388
- logger.error("[Bot] Failed to clear pinned message during session reset:", err);
389
- }
390
- }
391
370
  export function createBot() {
392
371
  clearAllInteractionState("bot_startup");
393
372
  toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
@@ -591,6 +570,20 @@ export function createBot() {
591
570
  logger.warn("[Bot] Could not clear global commands:", result.error);
592
571
  },
593
572
  });
573
+ // Voice and audio message handlers (STT transcription -> prompt)
574
+ const voicePromptDeps = { bot, ensureEventSubscription };
575
+ bot.on("message:voice", async (ctx) => {
576
+ logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
577
+ botInstance = bot;
578
+ chatIdInstance = ctx.chat.id;
579
+ await handleVoiceMessage(ctx, voicePromptDeps);
580
+ });
581
+ bot.on("message:audio", async (ctx) => {
582
+ logger.debug(`[Bot] Received audio message, chatId=${ctx.chat.id}`);
583
+ botInstance = bot;
584
+ chatIdInstance = ctx.chat.id;
585
+ await handleVoiceMessage(ctx, voicePromptDeps);
586
+ });
594
587
  bot.on("message:text", async (ctx) => {
595
588
  const text = ctx.message?.text;
596
589
  if (!text) {
@@ -607,145 +600,10 @@ export function createBot() {
607
600
  if (handledRename) {
608
601
  return;
609
602
  }
610
- const currentProject = getCurrentProject();
611
- if (!currentProject) {
612
- await ctx.reply(t("bot.project_not_selected"));
613
- return;
614
- }
615
603
  botInstance = bot;
616
604
  chatIdInstance = ctx.chat.id;
617
- // Initialize pinned message manager if not already
618
- if (!pinnedMessageManager.isInitialized()) {
619
- pinnedMessageManager.initialize(bot.api, ctx.chat.id);
620
- }
621
- // Initialize keyboard manager if not already
622
- keyboardManager.initialize(bot.api, ctx.chat.id);
623
- let currentSession = getCurrentSession();
624
- if (currentSession && currentSession.directory !== currentProject.worktree) {
625
- logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
626
- await resetMismatchedSessionContext();
627
- await ctx.reply(t("bot.session_reset_project_mismatch"));
628
- return;
629
- }
630
- if (!currentSession) {
631
- await ctx.reply(t("bot.creating_session"));
632
- const { data: session, error } = await opencodeClient.session.create({
633
- directory: currentProject.worktree,
634
- });
635
- if (error || !session) {
636
- await ctx.reply(t("bot.create_session_error"));
637
- return;
638
- }
639
- logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
640
- currentSession = {
641
- id: session.id,
642
- title: session.title,
643
- directory: currentProject.worktree,
644
- };
645
- setCurrentSession(currentSession);
646
- await ingestSessionInfoForCache(session);
647
- // Create pinned message for new session
648
- try {
649
- await pinnedMessageManager.onSessionChange(session.id, session.title);
650
- }
651
- catch (err) {
652
- logger.error("[Bot] Error creating pinned message for new session:", err);
653
- }
654
- const currentAgent = getStoredAgent();
655
- const currentModel = getStoredModel();
656
- const contextInfo = pinnedMessageManager.getContextInfo();
657
- const variantName = formatVariantForButton(currentModel.variant || "default");
658
- const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
659
- await ctx.reply(t("bot.session_created", { title: session.title }), {
660
- reply_markup: keyboard,
661
- });
662
- }
663
- else {
664
- logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
665
- // Ensure pinned message exists for existing session
666
- if (!pinnedMessageManager.getState().messageId) {
667
- try {
668
- await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
669
- }
670
- catch (err) {
671
- logger.error("[Bot] Error creating pinned message for existing session:", err);
672
- }
673
- }
674
- }
675
- await ensureEventSubscription(currentSession.directory);
676
- summaryAggregator.setSession(currentSession.id);
677
- summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
678
- const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
679
- if (sessionIsBusy) {
680
- logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
681
- await ctx.reply(t("bot.session_busy"));
682
- return;
683
- }
684
- try {
685
- const currentAgent = getStoredAgent();
686
- const storedModel = getStoredModel();
687
- const promptOptions = {
688
- sessionID: currentSession.id,
689
- directory: currentSession.directory,
690
- parts: [{ type: "text", text }],
691
- agent: currentAgent,
692
- };
693
- // Use stored model (from settings or config)
694
- if (storedModel.providerID && storedModel.modelID) {
695
- promptOptions.model = {
696
- providerID: storedModel.providerID,
697
- modelID: storedModel.modelID,
698
- };
699
- // Add variant if specified
700
- if (storedModel.variant) {
701
- promptOptions.variant = storedModel.variant;
702
- }
703
- }
704
- const promptErrorLogContext = {
705
- sessionId: currentSession.id,
706
- directory: currentSession.directory,
707
- agent: currentAgent || "default",
708
- modelProvider: storedModel.providerID || "default",
709
- modelId: storedModel.modelID || "default",
710
- variant: storedModel.variant || "default",
711
- promptLength: text.length,
712
- };
713
- logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
714
- // CRITICAL: DO NOT wait for session.prompt to complete.
715
- // If we wait, the handler will not finish and grammY will not call getUpdates,
716
- // which blocks receiving button callback_query updates.
717
- // The processing result will arrive via SSE events.
718
- safeBackgroundTask({
719
- taskName: "session.prompt",
720
- task: () => opencodeClient.session.prompt(promptOptions),
721
- onSuccess: ({ error }) => {
722
- if (error) {
723
- const details = formatErrorDetails(error, 6000);
724
- logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
725
- logger.error("[Bot] session.prompt error details:", details);
726
- logger.error("[Bot] session.prompt raw API error object:", error);
727
- // Send user-friendly error via API directly because ctx is no longer available
728
- void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
729
- return;
730
- }
731
- logger.info("[Bot] session.prompt completed");
732
- },
733
- onError: (error) => {
734
- const details = formatErrorDetails(error, 6000);
735
- logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
736
- logger.error("[Bot] session.prompt background failure details:", details);
737
- logger.error("[Bot] session.prompt raw background error object:", error);
738
- void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
739
- },
740
- });
741
- }
742
- catch (err) {
743
- logger.error("Error in prompt handler:", err);
744
- if (interactionManager.getSnapshot()) {
745
- clearAllInteractionState("message_handler_error");
746
- }
747
- await ctx.reply(t("error.generic"));
748
- }
605
+ const promptDeps = { bot, ensureEventSubscription };
606
+ await processUserPrompt(ctx, text, promptDeps);
749
607
  logger.debug("[Bot] message:text handler completed (prompt sent in background)");
750
608
  });
751
609
  bot.catch((err) => {
package/dist/config.js CHANGED
@@ -82,6 +82,7 @@ export const config = {
82
82
  },
83
83
  bot: {
84
84
  sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
85
+ projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
85
86
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
86
87
  serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
87
88
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
@@ -90,4 +91,10 @@ export const config = {
90
91
  files: {
91
92
  maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
92
93
  },
94
+ stt: {
95
+ apiUrl: getEnvVar("STT_API_URL", false),
96
+ apiKey: getEnvVar("STT_API_KEY", false),
97
+ model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
98
+ language: getEnvVar("STT_LANGUAGE", false),
99
+ },
93
100
  };
package/dist/i18n/en.js CHANGED
@@ -41,6 +41,7 @@ export const en = {
41
41
  "bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
42
42
  "bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
43
43
  "bot.prompt_send_error": "Failed to send request to OpenCode.",
44
+ "bot.session_error": "🔴 OpenCode returned an error: {message}",
44
45
  "bot.unknown_command": "⚠️ Unknown command: {command}. Use /help to see available commands.",
45
46
  "status.header_running": "🟢 **OpenCode Server is running**",
46
47
  "status.health.healthy": "Healthy",
@@ -229,4 +230,9 @@ export const en = {
229
230
  "legacy.models.no_provider_models": " ⚠️ No available models\n",
230
231
  "legacy.models.env_hint": "💡 To use model in .env:\n",
231
232
  "legacy.models.error": "🔴 An error occurred while loading models list.",
233
+ "stt.recognizing": "🎤 Recognizing audio...",
234
+ "stt.recognized": "🎤 Recognized:\n{text}",
235
+ "stt.not_configured": "🎤 Voice recognition is not configured.\n\nSet STT_API_URL and STT_API_KEY in .env to enable it.",
236
+ "stt.error": "🔴 Failed to recognize audio: {error}",
237
+ "stt.empty_result": "🎤 No speech detected in the audio message.",
232
238
  };
package/dist/i18n/ru.js CHANGED
@@ -41,6 +41,7 @@ export const ru = {
41
41
  "bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
42
42
  "bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
43
43
  "bot.prompt_send_error": "Не удалось отправить запрос в OpenCode.",
44
+ "bot.session_error": "🔴 OpenCode вернул ошибку: {message}",
44
45
  "bot.unknown_command": "⚠️ Неизвестная команда: {command}. Используйте /help для списка команд.",
45
46
  "status.header_running": "🟢 **OpenCode Server запущен**",
46
47
  "status.health.healthy": "Healthy",
@@ -229,4 +230,9 @@ export const ru = {
229
230
  "legacy.models.no_provider_models": " ⚠️ Нет доступных моделей\n",
230
231
  "legacy.models.env_hint": "💡 Для использования модели в .env:\n",
231
232
  "legacy.models.error": "🔴 Произошла ошибка при получении списка моделей.",
233
+ "stt.recognizing": "🎤 Распознаю аудио...",
234
+ "stt.recognized": "🎤 Распознано:\n{text}",
235
+ "stt.not_configured": "🎤 Распознавание голоса не настроено.\n\nУстановите STT_API_URL и STT_API_KEY в .env для включения.",
236
+ "stt.error": "🔴 Не удалось распознать аудио: {error}",
237
+ "stt.empty_result": "🎤 В аудиосообщении не обнаружена речь.",
232
238
  };
@@ -75,7 +75,10 @@ export function resolveInteractionGuardDecision(ctx) {
75
75
  return createBlockDecision(inputType, state, "command_not_allowed", command);
76
76
  }
77
77
  if (state.expectedInput === "mixed") {
78
- return createAllowDecision(inputType, state, command);
78
+ if (inputType === "callback" || inputType === "text") {
79
+ return createAllowDecision(inputType, state, command);
80
+ }
81
+ return createBlockDecision(inputType, state, "expected_text", command);
79
82
  }
80
83
  if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
81
84
  return createAllowDecision(inputType, state, command);
@@ -0,0 +1,64 @@
1
+ import { config } from "../config.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const STT_REQUEST_TIMEOUT_MS = 60_000;
4
+ /**
5
+ * Returns true if STT is configured (API URL and API key are set).
6
+ */
7
+ export function isSttConfigured() {
8
+ return Boolean(config.stt.apiUrl && config.stt.apiKey);
9
+ }
10
+ /**
11
+ * Transcribes an audio buffer using a Whisper-compatible API (OpenAI / Groq / etc.).
12
+ *
13
+ * Sends a multipart/form-data POST to `{STT_API_URL}/audio/transcriptions`.
14
+ *
15
+ * @param audioBuffer - Raw audio file bytes (ogg, mp3, wav, m4a, webm, etc.)
16
+ * @param filename - Original filename with extension (used by the API to detect format)
17
+ * @returns Transcribed text
18
+ * @throws Error if STT is not configured, the request fails, or the response is invalid
19
+ */
20
+ export async function transcribeAudio(audioBuffer, filename) {
21
+ if (!isSttConfigured()) {
22
+ throw new Error("STT is not configured: STT_API_URL and STT_API_KEY are required");
23
+ }
24
+ const url = `${config.stt.apiUrl}/audio/transcriptions`;
25
+ const formData = new FormData();
26
+ formData.append("file", new Blob([new Uint8Array(audioBuffer)]), filename);
27
+ formData.append("model", config.stt.model);
28
+ formData.append("response_format", "json");
29
+ if (config.stt.language) {
30
+ formData.append("language", config.stt.language);
31
+ }
32
+ logger.debug(`[STT] Sending transcription request: url=${url}, model=${config.stt.model}, filename=${filename}, size=${audioBuffer.length} bytes`);
33
+ const controller = new AbortController();
34
+ const timeout = setTimeout(() => controller.abort(), STT_REQUEST_TIMEOUT_MS);
35
+ try {
36
+ const response = await fetch(url, {
37
+ method: "POST",
38
+ headers: {
39
+ Authorization: `Bearer ${config.stt.apiKey}`,
40
+ },
41
+ body: formData,
42
+ signal: controller.signal,
43
+ });
44
+ if (!response.ok) {
45
+ const errorBody = await response.text().catch(() => "");
46
+ throw new Error(`STT API returned HTTP ${response.status}: ${errorBody || response.statusText}`);
47
+ }
48
+ const data = (await response.json());
49
+ if (typeof data.text !== "string") {
50
+ throw new Error("STT API response does not contain a text field");
51
+ }
52
+ logger.debug(`[STT] Transcription result: ${data.text.length} chars`);
53
+ return { text: data.text };
54
+ }
55
+ catch (err) {
56
+ if (err instanceof DOMException && err.name === "AbortError") {
57
+ throw new Error(`STT request timed out after ${STT_REQUEST_TIMEOUT_MS}ms`);
58
+ }
59
+ throw err;
60
+ }
61
+ finally {
62
+ clearTimeout(timeout);
63
+ }
64
+ }
@@ -39,11 +39,13 @@ class SummaryAggregator {
39
39
  onThinkingCallback = null;
40
40
  onTokensCallback = null;
41
41
  onSessionCompactedCallback = null;
42
+ onSessionErrorCallback = null;
42
43
  onPermissionCallback = null;
43
44
  onSessionDiffCallback = null;
44
45
  onFileChangeCallback = null;
45
46
  onClearedCallback = null;
46
47
  processedToolStates = new Set();
48
+ thinkingFiredForMessages = new Set();
47
49
  bot = null;
48
50
  chatId = null;
49
51
  typingTimer = null;
@@ -76,6 +78,9 @@ class SummaryAggregator {
76
78
  setOnSessionCompacted(callback) {
77
79
  this.onSessionCompactedCallback = callback;
78
80
  }
81
+ setOnSessionError(callback) {
82
+ this.onSessionErrorCallback = callback;
83
+ }
79
84
  setOnPermission(callback) {
80
85
  this.onPermissionCallback = callback;
81
86
  }
@@ -133,6 +138,9 @@ class SummaryAggregator {
133
138
  case "session.compacted":
134
139
  this.handleSessionCompacted(event);
135
140
  break;
141
+ case "session.error":
142
+ this.handleSessionError(event);
143
+ break;
136
144
  case "question.asked":
137
145
  this.handleQuestionAsked(event);
138
146
  break;
@@ -170,6 +178,7 @@ class SummaryAggregator {
170
178
  this.messages.clear();
171
179
  this.partHashes.clear();
172
180
  this.processedToolStates.clear();
181
+ this.thinkingFiredForMessages.clear();
173
182
  this.messageCount = 0;
174
183
  this.lastUpdated = 0;
175
184
  if (this.onClearedCallback) {
@@ -193,16 +202,6 @@ class SummaryAggregator {
193
202
  this.currentMessageParts.set(messageID, []);
194
203
  this.messageCount++;
195
204
  this.startTypingIndicator();
196
- const isSummaryMessage = info.summary === true;
197
- // Notify that agent started thinking
198
- if (!isSummaryMessage && this.onThinkingCallback) {
199
- const callback = this.onThinkingCallback;
200
- setImmediate(() => {
201
- if (typeof callback === "function") {
202
- callback(info.sessionID);
203
- }
204
- });
205
- }
206
205
  }
207
206
  const pending = this.pendingParts.get(messageID) || [];
208
207
  const current = this.currentMessageParts.get(messageID) || [];
@@ -250,7 +249,21 @@ class SummaryAggregator {
250
249
  }
251
250
  const messageID = part.messageID;
252
251
  const messageInfo = this.messages.get(messageID);
253
- if (part.type === "text" && "text" in part && part.text) {
252
+ if (part.type === "reasoning") {
253
+ // Fire the thinking callback once per message on the first reasoning part.
254
+ // This is the signal that the model is actually doing extended thinking.
255
+ if (!this.thinkingFiredForMessages.has(messageID) && this.onThinkingCallback) {
256
+ this.thinkingFiredForMessages.add(messageID);
257
+ const callback = this.onThinkingCallback;
258
+ const sessionID = part.sessionID;
259
+ setImmediate(() => {
260
+ if (typeof callback === "function") {
261
+ callback(sessionID);
262
+ }
263
+ });
264
+ }
265
+ }
266
+ else if (part.type === "text" && "text" in part && part.text) {
254
267
  const partHash = this.hashString(part.text);
255
268
  if (!this.partHashes.has(messageID)) {
256
269
  this.partHashes.set(messageID, new Set());
@@ -452,6 +465,21 @@ class SummaryAggregator {
452
465
  });
453
466
  }
454
467
  }
468
+ handleSessionError(event) {
469
+ const { sessionID, error } = event.properties;
470
+ if (sessionID !== this.currentSessionId) {
471
+ return;
472
+ }
473
+ const message = error?.data?.message || error?.message || error?.name || "Unknown session error";
474
+ logger.warn(`[Aggregator] Session error: ${sessionID}: ${message}`);
475
+ this.stopTypingIndicator();
476
+ if (this.onSessionErrorCallback) {
477
+ const callback = this.onSessionErrorCallback;
478
+ setImmediate(() => {
479
+ callback(sessionID, message);
480
+ });
481
+ }
482
+ }
455
483
  handleQuestionAsked(event) {
456
484
  const { id, sessionID, questions } = event.properties;
457
485
  if (sessionID !== this.currentSessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",