@grinev/opencode-telegram-bot 0.13.1 → 0.14.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
@@ -50,20 +50,12 @@ OPENCODE_MODEL_ID=big-pickle
50
50
  # Supported locales: en, de, es, fr, ru, zh
51
51
  # BOT_LOCALE=en
52
52
 
53
- # Service message batching interval in seconds (thinking + tool calls, default: 5)
54
- # Recommended: keep >=2 to reduce risk of hitting Telegram rate limits (about 1 message/sec)
55
- # 0 = send immediately (can hit rate limits if there are many tool calls)
56
- # SERVICE_MESSAGES_INTERVAL_SEC=5
57
-
58
53
  # Hide thinking indicator messages (default: false)
59
54
  # HIDE_THINKING_MESSAGES=false
60
55
 
61
56
  # Hide tool call service messages (default: false)
62
57
  # HIDE_TOOL_CALL_MESSAGES=false
63
58
 
64
- # Stream assistant responses while they are being generated (default: true)
65
- # RESPONSE_STREAMING=true
66
-
67
59
  # Assistant message formatting mode (default: markdown)
68
60
  # markdown = convert assistant replies to Telegram MarkdownV2
69
61
  # raw = show assistant replies as plain text
package/README.md CHANGED
@@ -28,6 +28,7 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Français (`fr`), Р
28
28
  - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
29
29
  - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
30
30
  - **Agent modes** — switch between Plan and Build modes on the fly
31
+ - **Subagent activity** — watch live subagent progress in chat, including the current task, agent, model, and active tool step
31
32
  - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
32
33
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
33
34
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
@@ -146,32 +147,30 @@ When installed via npm, the configuration wizard handles the initial setup. The
146
147
  - **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
147
148
  - **Linux:** `~/.config/opencode-telegram-bot/.env`
148
149
 
149
- | Variable | Description | Required | Default |
150
- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ | :------: | ------------------------ |
151
- | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
152
- | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
153
- | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
154
- | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
155
- | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
156
- | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
157
- | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
158
- | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
159
- | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
160
- | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
161
- | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
162
- | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
163
- | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
164
- | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
165
- | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
166
- | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167
- | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
168
- | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
169
- | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
170
- | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
171
- | `STT_API_KEY` | API key for your STT provider | No | — |
172
- | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
173
- | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
174
- | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
150
+ | Variable | Description | Required | Default |
151
+ | -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
153
+ | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
154
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
155
+ | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156
+ | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157
+ | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
158
+ | `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159
+ | `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160
+ | `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161
+ | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162
+ | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163
+ | `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164
+ | `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165
+ | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
166
+ | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167
+ | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
168
+ | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
169
+ | `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No | |
170
+ | `STT_API_KEY` | API key for your STT provider | No | |
171
+ | `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
172
+ | `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No | — |
173
+ | `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
175
174
 
176
175
  > **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
177
176
 
@@ -51,6 +51,7 @@ async function loadSessionPage(directory, page, pageSize) {
51
51
  const { data: sessions, error } = await opencodeClient.session.list({
52
52
  directory,
53
53
  limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
54
+ roots: true,
54
55
  });
55
56
  if (error || !sessions) {
56
57
  throw error || new Error("No data received from server");
package/dist/bot/index.js CHANGED
@@ -37,6 +37,7 @@ import { keyboardManager } from "../keyboard/manager.js";
37
37
  import { subscribeToEvents } from "../opencode/events.js";
38
38
  import { summaryAggregator } from "../summary/aggregator.js";
39
39
  import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
40
+ import { renderSubagentCards } from "../summary/subagent-formatter.js";
40
41
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
41
42
  import { getCurrentSession } from "../session/manager.js";
42
43
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
@@ -56,6 +57,7 @@ import { getStoredModel } from "../model/manager.js";
56
57
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
57
58
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
58
59
  import { ResponseStreamer } from "./streaming/response-streamer.js";
60
+ import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
59
61
  import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./utils/send-with-markdown-fallback.js";
60
62
  let botInstance = null;
61
63
  let chatIdInstance = null;
@@ -64,6 +66,7 @@ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
64
66
  const RESPONSE_STREAM_THROTTLE_MS = 200;
65
67
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
66
68
  const SESSION_RETRY_PREFIX = "🔁";
69
+ const SUBAGENT_STREAM_PREFIX = "🧩";
67
70
  const __filename = fileURLToPath(import.meta.url);
68
71
  const __dirname = path.dirname(__filename);
69
72
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
@@ -94,7 +97,6 @@ function prepareStreamingPayload(messageText) {
94
97
  };
95
98
  }
96
99
  const toolMessageBatcher = new ToolMessageBatcher({
97
- intervalSeconds: 5,
98
100
  sendText: async (sessionId, text) => {
99
101
  if (!botInstance || !chatIdInstance) {
100
102
  return;
@@ -187,6 +189,58 @@ const responseStreamer = new ResponseStreamer({
187
189
  });
188
190
  },
189
191
  });
192
+ const toolCallStreamer = new ToolCallStreamer({
193
+ throttleMs: RESPONSE_STREAM_THROTTLE_MS,
194
+ sendText: async (sessionId, text) => {
195
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
196
+ throw new Error("Bot context missing for tool stream send");
197
+ }
198
+ const currentSession = getCurrentSession();
199
+ if (!currentSession || currentSession.id !== sessionId) {
200
+ throw new Error(`Tool stream session mismatch for send: ${sessionId}`);
201
+ }
202
+ const sentMessage = await botInstance.api.sendMessage(chatIdInstance, text, {
203
+ disable_notification: true,
204
+ });
205
+ return sentMessage.message_id;
206
+ },
207
+ editText: async (sessionId, messageId, text) => {
208
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
209
+ throw new Error("Bot context missing for tool stream edit");
210
+ }
211
+ const currentSession = getCurrentSession();
212
+ if (!currentSession || currentSession.id !== sessionId) {
213
+ throw new Error(`Tool stream session mismatch for edit: ${sessionId}`);
214
+ }
215
+ try {
216
+ await botInstance.api.editMessageText(chatIdInstance, messageId, text);
217
+ }
218
+ catch (error) {
219
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
220
+ if (errorMessage.includes("message is not modified")) {
221
+ return;
222
+ }
223
+ throw error;
224
+ }
225
+ },
226
+ deleteText: async (sessionId, messageId) => {
227
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
228
+ throw new Error("Bot context missing for tool stream delete");
229
+ }
230
+ const currentSession = getCurrentSession();
231
+ if (!currentSession || currentSession.id !== sessionId) {
232
+ throw new Error(`Tool stream session mismatch for delete: ${sessionId}`);
233
+ }
234
+ await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
235
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
236
+ if (errorMessage.includes("message to delete not found") ||
237
+ errorMessage.includes("message identifier is not specified")) {
238
+ return;
239
+ }
240
+ throw error;
241
+ });
242
+ },
243
+ });
190
244
  async function ensureCommandsInitialized(ctx, next) {
191
245
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
192
246
  await next();
@@ -217,16 +271,13 @@ async function ensureEventSubscription(directory) {
217
271
  logger.error("No directory found for event subscription");
218
272
  return;
219
273
  }
220
- toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
221
274
  summaryAggregator.setTypingIndicatorEnabled(true);
222
275
  summaryAggregator.setOnCleared(() => {
223
276
  toolMessageBatcher.clearAll("summary_aggregator_clear");
277
+ toolCallStreamer.clearAll("summary_aggregator_clear");
224
278
  responseStreamer.clearAll("summary_aggregator_clear");
225
279
  });
226
280
  summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
227
- if (!config.bot.responseStreaming) {
228
- return;
229
- }
230
281
  if (!botInstance || !chatIdInstance) {
231
282
  return;
232
283
  }
@@ -238,7 +289,7 @@ async function ensureEventSubscription(directory) {
238
289
  if (!preparedStreamPayload) {
239
290
  return;
240
291
  }
241
- preparedStreamPayload.sendOptions = undefined;
292
+ preparedStreamPayload.sendOptions = { disable_notification: true };
242
293
  preparedStreamPayload.editOptions = undefined;
243
294
  responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
244
295
  });
@@ -246,12 +297,14 @@ async function ensureEventSubscription(directory) {
246
297
  if (!botInstance || !chatIdInstance) {
247
298
  logger.error("Bot or chat ID not available for sending message");
248
299
  responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
300
+ toolCallStreamer.clearSession(sessionId, "bot_context_missing");
249
301
  foregroundSessionState.markIdle(sessionId);
250
302
  return;
251
303
  }
252
304
  const currentSession = getCurrentSession();
253
305
  if (currentSession?.id !== sessionId) {
254
306
  responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
307
+ toolCallStreamer.clearSession(sessionId, "session_mismatch");
255
308
  foregroundSessionState.markIdle(sessionId);
256
309
  await scheduledTaskRuntime.flushDeferredDeliveries();
257
310
  return;
@@ -259,13 +312,15 @@ async function ensureEventSubscription(directory) {
259
312
  const botApi = botInstance.api;
260
313
  const chatId = chatIdInstance;
261
314
  try {
262
- const streamedViaMessages = await finalizeAssistantResponse({
263
- responseStreaming: config.bot.responseStreaming,
315
+ await finalizeAssistantResponse({
264
316
  sessionId,
265
317
  messageId,
266
318
  messageText,
267
319
  responseStreamer,
268
- flushPendingServiceMessages: () => toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
320
+ flushPendingServiceMessages: () => Promise.all([
321
+ toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
322
+ toolCallStreamer.flushSession(sessionId, "assistant_message_completed"),
323
+ ]).then(() => undefined),
269
324
  prepareStreamingPayload,
270
325
  formatSummary,
271
326
  resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
@@ -279,13 +334,17 @@ async function ensureEventSubscription(directory) {
279
334
  format,
280
335
  });
281
336
  },
337
+ deleteMessages: async (messageIds) => {
338
+ for (const msgId of messageIds) {
339
+ try {
340
+ await botApi.deleteMessage(chatId, msgId);
341
+ }
342
+ catch (err) {
343
+ logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
344
+ }
345
+ }
346
+ },
282
347
  });
283
- if (streamedViaMessages) {
284
- logger.debug(`[Bot] Final assistant message already streamed (session=${sessionId}, message=${messageId})`);
285
- foregroundSessionState.markIdle(sessionId);
286
- await scheduledTaskRuntime.flushDeferredDeliveries();
287
- return;
288
- }
289
348
  }
290
349
  catch (err) {
291
350
  logger.error("Failed to send message to Telegram:", err);
@@ -309,19 +368,43 @@ async function ensureEventSubscription(directory) {
309
368
  }
310
369
  const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
311
370
  (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
312
- if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
371
+ if (config.bot.hideToolCallMessages ||
372
+ shouldIncludeToolInfoInFileCaption ||
373
+ toolInfo.tool === "task") {
313
374
  return;
314
375
  }
315
376
  try {
316
377
  const message = formatToolInfo(toolInfo);
317
378
  if (message) {
318
- toolMessageBatcher.enqueue(toolInfo.sessionId, message);
379
+ toolCallStreamer.append(toolInfo.sessionId, message);
319
380
  }
320
381
  }
321
382
  catch (err) {
322
383
  logger.error("Failed to send tool notification to Telegram:", err);
323
384
  }
324
385
  });
386
+ summaryAggregator.setOnSubagent(async (sessionId, subagents) => {
387
+ if (!botInstance || !chatIdInstance) {
388
+ return;
389
+ }
390
+ if (config.bot.hideToolCallMessages) {
391
+ return;
392
+ }
393
+ const currentSession = getCurrentSession();
394
+ if (!currentSession || currentSession.id !== sessionId) {
395
+ return;
396
+ }
397
+ try {
398
+ const renderedCards = await renderSubagentCards(subagents);
399
+ if (!renderedCards) {
400
+ return;
401
+ }
402
+ toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
403
+ }
404
+ catch (err) {
405
+ logger.error("Failed to render subagent activity for Telegram:", err);
406
+ }
407
+ });
325
408
  summaryAggregator.setOnToolFile(async (fileInfo) => {
326
409
  if (!botInstance || !chatIdInstance) {
327
410
  logger.error("Bot or chat ID not available for sending file");
@@ -332,6 +415,7 @@ async function ensureEventSubscription(directory) {
332
415
  return;
333
416
  }
334
417
  try {
418
+ await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
335
419
  const toolMessage = formatToolInfo(fileInfo);
336
420
  const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
337
421
  toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
@@ -350,7 +434,10 @@ async function ensureEventSubscription(directory) {
350
434
  }
351
435
  const currentSession = getCurrentSession();
352
436
  if (currentSession) {
353
- await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
437
+ await Promise.all([
438
+ toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
439
+ toolCallStreamer.flushSession(currentSession.id, "question_asked"),
440
+ ]);
354
441
  }
355
442
  if (questionManager.isActive()) {
356
443
  logger.warn("[Bot] Replacing active poll with a new one");
@@ -382,7 +469,10 @@ async function ensureEventSubscription(directory) {
382
469
  logger.error("Bot or chat ID not available for showing permission request");
383
470
  return;
384
471
  }
385
- await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
472
+ await Promise.all([
473
+ toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
474
+ toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
475
+ ]);
386
476
  logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
387
477
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
388
478
  });
@@ -395,25 +485,40 @@ async function ensureEventSubscription(directory) {
395
485
  return;
396
486
  }
397
487
  logger.debug("[Bot] Agent started thinking");
488
+ await toolCallStreamer.breakSession(sessionId, "thinking_started");
398
489
  deliverThinkingMessage(sessionId, toolMessageBatcher, {
399
- responseStreaming: config.bot.responseStreaming,
400
490
  hideThinkingMessages: config.bot.hideThinkingMessages,
401
491
  });
492
+ // Refresh pinned message so it shows the latest in-memory context
493
+ // (accumulated from silent token updates). 1 API call per thinking event.
494
+ if (pinnedMessageManager.isInitialized()) {
495
+ await pinnedMessageManager.refresh();
496
+ }
402
497
  });
403
- summaryAggregator.setOnTokens(async (tokens) => {
498
+ summaryAggregator.setOnTokens(async (tokens, isCompleted) => {
404
499
  if (!pinnedMessageManager.isInitialized()) {
405
500
  return;
406
501
  }
407
502
  try {
408
- logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
409
- // Update keyboardManager SYNCHRONOUSLY before any await
410
- // This ensures keyboard has correct context when onComplete sends the reply
503
+ logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`);
411
504
  const contextSize = tokens.input + tokens.cacheRead;
412
505
  const contextLimit = pinnedMessageManager.getContextLimit();
506
+ // Skip non-completed messages with zero context: a new assistant message
507
+ // starts with tokens={input:0, ...} which would overwrite valid context
508
+ // from the previous step. Only accept zeros from completed messages.
509
+ if (!isCompleted && contextSize === 0) {
510
+ logger.debug("[Bot] Skipping zero-token intermediate update");
511
+ return;
512
+ }
513
+ // Update both keyboard and pinned state in memory (keeps them in sync)
413
514
  if (contextLimit > 0) {
414
515
  keyboardManager.updateContext(contextSize, contextLimit);
415
516
  }
416
- await pinnedMessageManager.onMessageComplete(tokens);
517
+ pinnedMessageManager.updateTokensSilent(tokens);
518
+ // Full pinned message update (API call) only on completed messages
519
+ if (isCompleted) {
520
+ await pinnedMessageManager.onMessageComplete(tokens);
521
+ }
417
522
  }
418
523
  catch (err) {
419
524
  logger.error("[Bot] Error updating pinned message with tokens:", err);
@@ -451,12 +556,16 @@ async function ensureEventSubscription(directory) {
451
556
  const currentSession = getCurrentSession();
452
557
  if (!currentSession || currentSession.id !== sessionId) {
453
558
  responseStreamer.clearSession(sessionId, "session_error_not_current");
559
+ toolCallStreamer.clearSession(sessionId, "session_error_not_current");
454
560
  foregroundSessionState.markIdle(sessionId);
455
561
  await scheduledTaskRuntime.flushDeferredDeliveries();
456
562
  return;
457
563
  }
458
564
  responseStreamer.clearSession(sessionId, "session_error");
459
- await toolMessageBatcher.flushSession(sessionId, "session_error");
565
+ await Promise.all([
566
+ toolMessageBatcher.flushSession(sessionId, "session_error"),
567
+ toolCallStreamer.flushSession(sessionId, "session_error"),
568
+ ]);
460
569
  const normalizedMessage = message.trim() || t("common.unknown_error");
461
570
  const truncatedMessage = normalizedMessage.length > 3500
462
571
  ? `${normalizedMessage.slice(0, 3497)}...`
@@ -482,7 +591,7 @@ async function ensureEventSubscription(directory) {
482
591
  ? `${normalizedMessage.slice(0, 3497)}...`
483
592
  : normalizedMessage;
484
593
  const retryMessage = t("bot.session_retry", { message: truncatedMessage });
485
- toolMessageBatcher.enqueueUniqueByPrefix(sessionId, retryMessage, SESSION_RETRY_PREFIX);
594
+ toolCallStreamer.replaceByPrefix(sessionId, SESSION_RETRY_PREFIX, retryMessage);
486
595
  });
487
596
  summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
488
597
  if (!pinnedMessageManager.isInitialized()) {
@@ -529,8 +638,6 @@ async function ensureEventSubscription(directory) {
529
638
  }
530
639
  export function createBot() {
531
640
  clearAllInteractionState("bot_startup");
532
- toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
533
- logger.debug(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
534
641
  const botOptions = {};
535
642
  if (config.telegram.proxyUrl) {
536
643
  const proxyUrl = config.telegram.proxyUrl;
@@ -70,9 +70,10 @@ export class ResponseStreamer {
70
70
  this.ensureTimer(state);
71
71
  }
72
72
  async complete(sessionId, messageId, payload, options) {
73
+ const notStreamed = { streamed: false, telegramMessageIds: [] };
73
74
  const state = this.states.get(buildStateKey(sessionId, messageId));
74
75
  if (!state) {
75
- return false;
76
+ return notStreamed;
76
77
  }
77
78
  if (payload) {
78
79
  const normalizedPayload = normalizePayload(payload);
@@ -86,12 +87,12 @@ export class ResponseStreamer {
86
87
  await this.cleanupBrokenStream(state, "complete_broken_stream");
87
88
  this.cancelState(state);
88
89
  this.states.delete(state.key);
89
- return false;
90
+ return notStreamed;
90
91
  }
91
92
  if (state.telegramMessageIds.length === 0) {
92
93
  this.cancelState(state);
93
94
  this.states.delete(state.key);
94
- return false;
95
+ return notStreamed;
95
96
  }
96
97
  let synced = true;
97
98
  if (options?.flushFinal !== false) {
@@ -100,9 +101,10 @@ export class ResponseStreamer {
100
101
  await this.cleanupBrokenStream(state, "final_sync_failed_cleanup");
101
102
  }
102
103
  }
104
+ const messageIds = [...state.telegramMessageIds];
103
105
  this.cancelState(state);
104
106
  this.states.delete(state.key);
105
- return synced;
107
+ return { streamed: synced, telegramMessageIds: messageIds };
106
108
  }
107
109
  clearMessage(sessionId, messageId, reason) {
108
110
  const key = buildStateKey(sessionId, messageId);