@grinev/opencode-telegram-bot 0.14.1 → 0.16.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.
Files changed (61) hide show
  1. package/.env.example +25 -0
  2. package/README.md +50 -26
  3. package/dist/agent/manager.js +34 -6
  4. package/dist/agent/types.js +7 -1
  5. package/dist/app/start-bot-app.js +6 -1
  6. package/dist/bot/assistant-run-state.js +60 -0
  7. package/dist/bot/commands/abort.js +2 -0
  8. package/dist/bot/commands/commands.js +12 -2
  9. package/dist/bot/commands/definitions.js +2 -0
  10. package/dist/bot/commands/new.js +3 -2
  11. package/dist/bot/commands/open.js +314 -0
  12. package/dist/bot/commands/projects.js +5 -37
  13. package/dist/bot/commands/sessions.js +3 -0
  14. package/dist/bot/commands/start.js +2 -0
  15. package/dist/bot/commands/status.js +5 -2
  16. package/dist/bot/commands/tts.js +13 -0
  17. package/dist/bot/handlers/agent.js +3 -4
  18. package/dist/bot/handlers/inline-menu.js +9 -1
  19. package/dist/bot/handlers/model.js +3 -2
  20. package/dist/bot/handlers/prompt.js +33 -5
  21. package/dist/bot/handlers/variant.js +3 -2
  22. package/dist/bot/index.js +179 -94
  23. package/dist/bot/message-patterns.js +2 -2
  24. package/dist/bot/streaming/response-streamer.js +26 -17
  25. package/dist/bot/streaming/tool-call-streamer.js +31 -24
  26. package/dist/bot/utils/assistant-rendering.js +117 -0
  27. package/dist/bot/utils/assistant-run-footer.js +9 -0
  28. package/dist/bot/utils/browser-roots.js +140 -0
  29. package/dist/bot/utils/file-tree.js +92 -0
  30. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  31. package/dist/bot/utils/keyboard.js +6 -6
  32. package/dist/bot/utils/send-tts-response.js +37 -0
  33. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  34. package/dist/bot/utils/switch-project.js +48 -0
  35. package/dist/bot/utils/telegram-text.js +95 -1
  36. package/dist/cli.js +4 -0
  37. package/dist/config.js +10 -0
  38. package/dist/i18n/de.js +32 -9
  39. package/dist/i18n/en.js +32 -9
  40. package/dist/i18n/es.js +32 -9
  41. package/dist/i18n/fr.js +32 -9
  42. package/dist/i18n/ru.js +32 -9
  43. package/dist/i18n/zh.js +32 -9
  44. package/dist/index.js +2 -0
  45. package/dist/pinned/manager.js +66 -4
  46. package/dist/project/manager.js +2 -1
  47. package/dist/settings/manager.js +7 -0
  48. package/dist/summary/aggregator.js +17 -1
  49. package/dist/summary/formatter.js +12 -89
  50. package/dist/telegram/render/block-fallback.js +28 -0
  51. package/dist/telegram/render/block-parser.js +295 -0
  52. package/dist/telegram/render/block-renderer.js +457 -0
  53. package/dist/telegram/render/chunker.js +281 -0
  54. package/dist/telegram/render/inline-renderer.js +128 -0
  55. package/dist/telegram/render/markdown-normalizer.js +94 -0
  56. package/dist/telegram/render/pipeline.js +9 -0
  57. package/dist/telegram/render/types.js +1 -0
  58. package/dist/telegram/render/validator.js +160 -0
  59. package/dist/tts/client.js +59 -0
  60. package/dist/utils/logger.js +200 -73
  61. package/package.json +6 -2
package/dist/bot/index.js CHANGED
@@ -16,6 +16,7 @@ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTT
16
16
  import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
17
17
  import { newCommand } from "./commands/new.js";
18
18
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
+ import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
19
20
  import { abortCommand } from "./commands/abort.js";
20
21
  import { opencodeStartCommand } from "./commands/opencode-start.js";
21
22
  import { opencodeStopCommand } from "./commands/opencode-stop.js";
@@ -23,6 +24,7 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
23
24
  import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
24
25
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
25
26
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
27
+ import { ttsCommand } from "./commands/tts.js";
26
28
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
27
29
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
28
30
  import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
@@ -36,7 +38,7 @@ import { clearAllInteractionState } from "../interaction/cleanup.js";
36
38
  import { keyboardManager } from "../keyboard/manager.js";
37
39
  import { subscribeToEvents } from "../opencode/events.js";
38
40
  import { summaryAggregator } from "../summary/aggregator.js";
39
- import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
41
+ import { formatToolInfo } from "../summary/formatter.js";
40
42
  import { renderSubagentCards } from "../summary/subagent-formatter.js";
41
43
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
42
44
  import { getCurrentSession } from "../session/manager.js";
@@ -46,20 +48,23 @@ import { safeBackgroundTask } from "../utils/safe-background-task.js";
46
48
  import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
47
49
  import { pinnedMessageManager } from "../pinned/manager.js";
48
50
  import { t } from "../i18n/index.js";
49
- import { processUserPrompt } from "./handlers/prompt.js";
51
+ import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
50
52
  import { handleVoiceMessage } from "./handlers/voice.js";
51
53
  import { handleDocumentMessage } from "./handlers/document.js";
52
54
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
53
55
  import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
56
+ import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
54
57
  import { deliverThinkingMessage } from "./utils/thinking-message.js";
55
- import { sendBotText } from "./utils/telegram-text.js";
58
+ import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
59
+ import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
56
60
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
57
61
  import { getStoredModel } from "../model/manager.js";
58
62
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
59
63
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
64
+ import { assistantRunState } from "./assistant-run-state.js";
60
65
  import { ResponseStreamer } from "./streaming/response-streamer.js";
61
66
  import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
62
- import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./utils/send-with-markdown-fallback.js";
67
+ import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
63
68
  let botInstance = null;
64
69
  let chatIdInstance = null;
65
70
  let commandsInitialized = false;
@@ -71,6 +76,7 @@ const SUBAGENT_STREAM_PREFIX = "🧩";
71
76
  const __filename = fileURLToPath(import.meta.url);
72
77
  const __dirname = path.dirname(__filename);
73
78
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
79
+ const sessionCompletionTasks = new Map();
74
80
  function getCurrentReplyKeyboard() {
75
81
  if (!keyboardManager.isInitialized()) {
76
82
  return undefined;
@@ -88,14 +94,23 @@ function prepareDocumentCaption(caption) {
88
94
  return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
89
95
  }
90
96
  function prepareStreamingPayload(messageText) {
91
- const parts = formatSummaryWithMode(messageText, config.bot.messageFormatMode, RESPONSE_STREAM_TEXT_LIMIT);
92
- if (parts.length === 0) {
93
- return null;
94
- }
95
- return {
96
- parts,
97
- format: "raw",
98
- };
97
+ return prepareAssistantStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
98
+ }
99
+ function prepareFinalStreamingPayload(messageText) {
100
+ return prepareAssistantFinalStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
101
+ }
102
+ function enqueueSessionCompletionTask(sessionId, task) {
103
+ const previousTask = sessionCompletionTasks.get(sessionId) ?? Promise.resolve();
104
+ const nextTask = previousTask
105
+ .catch(() => undefined)
106
+ .then(task)
107
+ .finally(() => {
108
+ if (sessionCompletionTasks.get(sessionId) === nextTask) {
109
+ sessionCompletionTasks.delete(sessionId);
110
+ }
111
+ });
112
+ sessionCompletionTasks.set(sessionId, nextTask);
113
+ return nextTask;
99
114
  }
100
115
  const toolMessageBatcher = new ToolMessageBatcher({
101
116
  sendText: async (sessionId, text) => {
@@ -139,39 +154,36 @@ const toolMessageBatcher = new ToolMessageBatcher({
139
154
  });
140
155
  const responseStreamer = new ResponseStreamer({
141
156
  throttleMs: RESPONSE_STREAM_THROTTLE_MS,
142
- sendText: async (text, format, options) => {
157
+ sendPart: async (part, options) => {
143
158
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
144
159
  throw new Error("Bot context missing for streamed send");
145
160
  }
146
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
147
- const sentMessage = await sendMessageWithMarkdownFallback({
161
+ return sendRenderedBotPart({
148
162
  api: botInstance.api,
149
163
  chatId: chatIdInstance,
150
- text,
164
+ part,
151
165
  options,
152
- parseMode,
153
166
  });
154
- return sentMessage.message_id;
155
167
  },
156
- editText: async (messageId, text, format, options) => {
168
+ editPart: async (messageId, part, options) => {
157
169
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
158
170
  throw new Error("Bot context missing for streamed edit");
159
171
  }
160
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
161
172
  try {
162
- await editMessageWithMarkdownFallback({
173
+ return await editRenderedBotPart({
163
174
  api: botInstance.api,
164
175
  chatId: chatIdInstance,
165
176
  messageId,
166
- text,
177
+ part,
167
178
  options,
168
- parseMode,
169
179
  });
170
180
  }
171
181
  catch (error) {
172
182
  const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
173
183
  if (errorMessage.includes("message is not modified")) {
174
- return;
184
+ return {
185
+ deliveredSignature: getTelegramRenderedPartSignature(part),
186
+ };
175
187
  }
176
188
  throw error;
177
189
  }
@@ -242,6 +254,12 @@ const toolCallStreamer = new ToolCallStreamer({
242
254
  });
243
255
  },
244
256
  });
257
+ function getToolStreamKey(tool) {
258
+ if (tool === "todowrite") {
259
+ return "todo";
260
+ }
261
+ return "default";
262
+ }
245
263
  async function ensureCommandsInitialized(ctx, next) {
246
264
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
247
265
  await next();
@@ -290,75 +308,82 @@ async function ensureEventSubscription(directory) {
290
308
  if (!preparedStreamPayload) {
291
309
  return;
292
310
  }
311
+ // Reply keyboards make the first streamed message non-editable in Telegram,
312
+ // so partial chunks must be sent without reply_markup and finalized later.
293
313
  preparedStreamPayload.sendOptions = { disable_notification: true };
294
314
  preparedStreamPayload.editOptions = undefined;
295
315
  responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
296
316
  });
297
- summaryAggregator.setOnComplete(async (sessionId, messageId, messageText) => {
298
- if (!botInstance || !chatIdInstance) {
299
- logger.error("Bot or chat ID not available for sending message");
300
- responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
301
- toolCallStreamer.clearSession(sessionId, "bot_context_missing");
302
- foregroundSessionState.markIdle(sessionId);
303
- return;
304
- }
305
- const currentSession = getCurrentSession();
306
- if (currentSession?.id !== sessionId) {
307
- responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
308
- toolCallStreamer.clearSession(sessionId, "session_mismatch");
309
- foregroundSessionState.markIdle(sessionId);
310
- await scheduledTaskRuntime.flushDeferredDeliveries();
311
- return;
312
- }
313
- const botApi = botInstance.api;
314
- const chatId = chatIdInstance;
315
- try {
316
- await finalizeAssistantResponse({
317
- sessionId,
318
- messageId,
319
- messageText,
320
- responseStreamer,
321
- flushPendingServiceMessages: () => Promise.all([
322
- toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
323
- toolCallStreamer.flushSession(sessionId, "assistant_message_completed"),
324
- ]).then(() => undefined),
325
- prepareStreamingPayload,
326
- formatSummary,
327
- formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
328
- resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
329
- getReplyKeyboard: getCurrentReplyKeyboard,
330
- sendText: async (text, rawFallbackText, options, format) => {
331
- await sendBotText({
332
- api: botApi,
333
- chatId,
334
- text,
335
- rawFallbackText,
336
- options: options,
337
- format,
338
- });
339
- },
340
- deleteMessages: async (messageIds) => {
341
- for (const msgId of messageIds) {
342
- try {
343
- await botApi.deleteMessage(chatId, msgId);
344
- }
345
- catch (err) {
346
- logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
347
- }
348
- }
349
- },
350
- });
351
- }
352
- catch (err) {
353
- logger.error("Failed to send message to Telegram:", err);
354
- // Stop processing events after critical error to prevent infinite loop
355
- logger.error("[Bot] CRITICAL: Stopping event processing due to error");
356
- summaryAggregator.clear();
357
- }
358
- finally {
359
- foregroundSessionState.markIdle(sessionId);
360
- await scheduledTaskRuntime.flushDeferredDeliveries();
361
- }
317
+ summaryAggregator.setOnComplete((sessionId, messageId, messageText, completionInfo) => {
318
+ void enqueueSessionCompletionTask(sessionId, async () => {
319
+ if (!botInstance || !chatIdInstance) {
320
+ logger.error("Bot or chat ID not available for sending message");
321
+ clearPromptResponseMode(sessionId);
322
+ responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
323
+ toolCallStreamer.clearSession(sessionId, "bot_context_missing");
324
+ assistantRunState.clearRun(sessionId, "bot_context_missing");
325
+ foregroundSessionState.markIdle(sessionId);
326
+ return;
327
+ }
328
+ const currentSession = getCurrentSession();
329
+ if (currentSession?.id !== sessionId) {
330
+ clearPromptResponseMode(sessionId);
331
+ responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
332
+ toolCallStreamer.clearSession(sessionId, "session_mismatch");
333
+ assistantRunState.clearRun(sessionId, "session_mismatch");
334
+ foregroundSessionState.markIdle(sessionId);
335
+ await scheduledTaskRuntime.flushDeferredDeliveries();
336
+ return;
337
+ }
338
+ const botApi = botInstance.api;
339
+ const chatId = chatIdInstance;
340
+ try {
341
+ assistantRunState.markResponseCompleted(sessionId, {
342
+ agent: completionInfo.agent,
343
+ providerID: completionInfo.providerID,
344
+ modelID: completionInfo.modelID,
345
+ });
346
+ await finalizeAssistantResponse({
347
+ sessionId,
348
+ messageId,
349
+ messageText,
350
+ responseStreamer,
351
+ flushPendingServiceMessages: () => Promise.all([
352
+ toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
353
+ toolCallStreamer.breakSession(sessionId, "assistant_message_completed"),
354
+ ]).then(() => undefined),
355
+ prepareStreamingPayload: prepareFinalStreamingPayload,
356
+ renderFinalParts: (text) => renderAssistantFinalPartsSafe(text),
357
+ getReplyKeyboard: getCurrentReplyKeyboard,
358
+ sendRenderedPart: async (part, options) => {
359
+ await sendRenderedBotPart({
360
+ api: botApi,
361
+ chatId,
362
+ part,
363
+ options: options,
364
+ });
365
+ },
366
+ });
367
+ await sendTtsResponseForSession({
368
+ api: botApi,
369
+ sessionId,
370
+ chatId,
371
+ text: messageText,
372
+ });
373
+ }
374
+ catch (err) {
375
+ clearPromptResponseMode(sessionId);
376
+ assistantRunState.clearRun(sessionId, "assistant_finalize_failed");
377
+ logger.error("Failed to send message to Telegram:", err);
378
+ // Stop processing events after critical error to prevent infinite loop
379
+ logger.error("[Bot] CRITICAL: Stopping event processing due to error");
380
+ summaryAggregator.clear();
381
+ foregroundSessionState.markIdle(sessionId);
382
+ }
383
+ finally {
384
+ await scheduledTaskRuntime.flushDeferredDeliveries();
385
+ }
386
+ });
362
387
  });
363
388
  summaryAggregator.setOnTool(async (toolInfo) => {
364
389
  if (!botInstance || !chatIdInstance) {
@@ -379,7 +404,7 @@ async function ensureEventSubscription(directory) {
379
404
  try {
380
405
  const message = formatToolInfo(toolInfo);
381
406
  if (message) {
382
- toolCallStreamer.append(toolInfo.sessionId, message);
407
+ toolCallStreamer.append(toolInfo.sessionId, message, getToolStreamKey(toolInfo.tool));
383
408
  }
384
409
  }
385
410
  catch (err) {
@@ -402,7 +427,7 @@ async function ensureEventSubscription(directory) {
402
427
  if (!renderedCards) {
403
428
  return;
404
429
  }
405
- toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
430
+ toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards, "subagent");
406
431
  }
407
432
  catch (err) {
408
433
  logger.error("Failed to render subagent activity for Telegram:", err);
@@ -551,20 +576,70 @@ async function ensureEventSubscription(directory) {
551
576
  logger.error("[Bot] Error reloading context after compaction:", err);
552
577
  }
553
578
  });
579
+ summaryAggregator.setOnSessionIdle(async (sessionId) => {
580
+ await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
581
+ const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
582
+ clearPromptResponseMode(sessionId);
583
+ if (!botInstance || !chatIdInstance) {
584
+ foregroundSessionState.markIdle(sessionId);
585
+ return;
586
+ }
587
+ const currentSession = getCurrentSession();
588
+ if (!currentSession || currentSession.id !== sessionId) {
589
+ foregroundSessionState.markIdle(sessionId);
590
+ await scheduledTaskRuntime.flushDeferredDeliveries();
591
+ return;
592
+ }
593
+ try {
594
+ await Promise.all([
595
+ toolMessageBatcher.flushSession(sessionId, "session_idle"),
596
+ toolCallStreamer.flushSession(sessionId, "session_idle"),
597
+ ]);
598
+ if (completedRun?.hasCompletedResponse) {
599
+ const agent = completedRun.actualAgent || completedRun.configuredAgent;
600
+ const providerID = completedRun.actualProviderID || completedRun.configuredProviderID;
601
+ const modelID = completedRun.actualModelID || completedRun.configuredModelID;
602
+ if (agent && providerID && modelID) {
603
+ const keyboard = getCurrentReplyKeyboard();
604
+ await botInstance.api.sendMessage(chatIdInstance, formatAssistantRunFooter({
605
+ agent,
606
+ providerID,
607
+ modelID,
608
+ elapsedMs: Date.now() - completedRun.startedAt,
609
+ }), {
610
+ ...(keyboard ? { reply_markup: keyboard } : {}),
611
+ });
612
+ }
613
+ }
614
+ }
615
+ catch (err) {
616
+ logger.error("[Bot] Failed to send session idle footer:", err);
617
+ }
618
+ finally {
619
+ foregroundSessionState.markIdle(sessionId);
620
+ await scheduledTaskRuntime.flushDeferredDeliveries();
621
+ }
622
+ });
554
623
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
555
624
  if (!botInstance || !chatIdInstance) {
625
+ clearPromptResponseMode(sessionId);
626
+ assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
556
627
  foregroundSessionState.markIdle(sessionId);
557
628
  return;
558
629
  }
559
630
  const currentSession = getCurrentSession();
560
631
  if (!currentSession || currentSession.id !== sessionId) {
632
+ clearPromptResponseMode(sessionId);
561
633
  responseStreamer.clearSession(sessionId, "session_error_not_current");
562
634
  toolCallStreamer.clearSession(sessionId, "session_error_not_current");
635
+ assistantRunState.clearRun(sessionId, "session_error_not_current");
563
636
  foregroundSessionState.markIdle(sessionId);
564
637
  await scheduledTaskRuntime.flushDeferredDeliveries();
565
638
  return;
566
639
  }
567
640
  responseStreamer.clearSession(sessionId, "session_error");
641
+ clearPromptResponseMode(sessionId);
642
+ assistantRunState.clearRun(sessionId, "session_error");
568
643
  await Promise.all([
569
644
  toolMessageBatcher.flushSession(sessionId, "session_error"),
570
645
  toolCallStreamer.flushSession(sessionId, "session_error"),
@@ -641,6 +716,8 @@ async function ensureEventSubscription(directory) {
641
716
  }
642
717
  export function createBot() {
643
718
  clearAllInteractionState("bot_startup");
719
+ sessionCompletionTasks.clear();
720
+ assistantRunState.clearAll("bot_startup");
644
721
  const botOptions = {};
645
722
  if (config.telegram.proxyUrl) {
646
723
  const proxyUrl = config.telegram.proxyUrl;
@@ -712,9 +789,11 @@ export function createBot() {
712
789
  bot.command("start", startCommand);
713
790
  bot.command("help", helpCommand);
714
791
  bot.command("status", statusCommand);
792
+ bot.command("tts", ttsCommand);
715
793
  bot.command("opencode_start", opencodeStartCommand);
716
794
  bot.command("opencode_stop", opencodeStopCommand);
717
795
  bot.command("projects", projectsCommand);
796
+ bot.command("open", openCommand);
718
797
  bot.command("sessions", sessionsCommand);
719
798
  bot.command("new", newCommand);
720
799
  bot.command("abort", abortCommand);
@@ -732,8 +811,13 @@ export function createBot() {
732
811
  }
733
812
  try {
734
813
  const handledInlineCancel = await handleInlineMenuCancel(ctx);
814
+ if (handledInlineCancel) {
815
+ // Clean up path index when the open-directory menu is cancelled
816
+ clearOpenPathIndex();
817
+ }
735
818
  const handledSession = await handleSessionSelect(ctx);
736
819
  const handledProject = await handleProjectSelect(ctx);
820
+ const handledOpen = await handleOpenCallback(ctx);
737
821
  const handledQuestion = await handleQuestionCallback(ctx);
738
822
  const handledPermission = await handlePermissionCallback(ctx);
739
823
  const handledAgent = await handleAgentSelect(ctx);
@@ -744,10 +828,11 @@ export function createBot() {
744
828
  const handledTaskList = await handleTaskListCallback(ctx);
745
829
  const handledRenameCancel = await handleRenameCancel(ctx);
746
830
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
747
- logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}`);
831
+ logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}`);
748
832
  if (!handledInlineCancel &&
749
833
  !handledSession &&
750
834
  !handledProject &&
835
+ !handledOpen &&
751
836
  !handledQuestion &&
752
837
  !handledPermission &&
753
838
  !handledAgent &&
@@ -768,9 +853,9 @@ export function createBot() {
768
853
  await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
769
854
  }
770
855
  });
771
- // Handle Reply Keyboard button press (agent mode indicator)
856
+ // Handle Reply Keyboard button press (agent indicator)
772
857
  bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
773
- logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
858
+ logger.debug(`[Bot] Agent button pressed: ${ctx.message?.text}`);
774
859
  try {
775
860
  if (await blockMenuWhileInteractionActive(ctx)) {
776
861
  return;
@@ -1,4 +1,4 @@
1
- export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\sMode$/;
2
- export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\sMode$)[\s\S]+$/;
1
+ export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\s(?:Mode|Agent)$/;
2
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
3
3
  // Keep support for both legacy "💭" and current "💡" prefix.
4
4
  export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
@@ -2,16 +2,22 @@ import { logger } from "../../utils/logger.js";
2
2
  function buildStateKey(sessionId, messageId) {
3
3
  return `${sessionId}:${messageId}`;
4
4
  }
5
+ function clonePart(part) {
6
+ return {
7
+ text: part.text,
8
+ entities: part.entities ? [...part.entities] : undefined,
9
+ fallbackText: part.fallbackText,
10
+ source: part.source,
11
+ };
12
+ }
5
13
  function normalizePayload(payload) {
6
- const normalizedParts = payload.parts
7
- .map((part) => part.trim())
8
- .filter((part) => part.length > 0);
14
+ const normalizedParts = payload.parts.map(clonePart).filter((part) => part.text.length > 0);
9
15
  if (normalizedParts.length === 0) {
16
+ logger.debug("[ResponseStreamer] Dropped empty streaming payload after normalization");
10
17
  return null;
11
18
  }
12
19
  return {
13
20
  parts: normalizedParts,
14
- format: payload.format,
15
21
  sendOptions: payload.sendOptions,
16
22
  editOptions: payload.editOptions,
17
23
  };
@@ -37,8 +43,8 @@ function getRetryAfterMs(error) {
37
43
  }
38
44
  return seconds * 1000;
39
45
  }
40
- function createSignature(text, format) {
41
- return `${format}\n${text}`;
46
+ function createSignature(part) {
47
+ return `${part.text}\n${JSON.stringify(part.entities ?? null)}`;
42
48
  }
43
49
  function delay(ms) {
44
50
  return new Promise((resolve) => {
@@ -47,14 +53,14 @@ function delay(ms) {
47
53
  }
48
54
  export class ResponseStreamer {
49
55
  throttleMs;
50
- sendText;
51
- editText;
56
+ sendPart;
57
+ editPart;
52
58
  deleteText;
53
59
  states = new Map();
54
60
  constructor(options) {
55
61
  this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
56
- this.sendText = options.sendText;
57
- this.editText = options.editText;
62
+ this.sendPart = options.sendPart;
63
+ this.editPart = options.editPart;
58
64
  this.deleteText = options.deleteText;
59
65
  }
60
66
  enqueue(sessionId, messageId, payload) {
@@ -73,6 +79,7 @@ export class ResponseStreamer {
73
79
  const notStreamed = { streamed: false, telegramMessageIds: [] };
74
80
  const state = this.states.get(buildStateKey(sessionId, messageId));
75
81
  if (!state) {
82
+ logger.debug(`[ResponseStreamer] Complete skipped, no active stream state: session=${sessionId}, message=${messageId}`);
76
83
  return notStreamed;
77
84
  }
78
85
  if (payload) {
@@ -90,6 +97,7 @@ export class ResponseStreamer {
90
97
  return notStreamed;
91
98
  }
92
99
  if (state.telegramMessageIds.length === 0) {
100
+ logger.debug(`[ResponseStreamer] Complete returned not streamed: session=${sessionId}, message=${messageId}, reason=no_visible_partials`);
93
101
  this.cancelState(state);
94
102
  this.states.delete(state.key);
95
103
  return notStreamed;
@@ -204,10 +212,11 @@ export class ResponseStreamer {
204
212
  if (!payload) {
205
213
  return state.telegramMessageIds.length > 0;
206
214
  }
207
- const targetSignatures = payload.parts.map((part) => createSignature(part, payload.format));
215
+ const targetSignatures = payload.parts.map((part) => createSignature(part));
208
216
  const unchanged = targetSignatures.length === state.lastSentSignatures.length &&
209
217
  targetSignatures.every((signature, index) => signature === state.lastSentSignatures[index]);
210
218
  if (unchanged) {
219
+ logger.debug(`[ResponseStreamer] Skipped unchanged payload: session=${state.sessionId}, message=${state.messageId}, parts=${payload.parts.length}`);
211
220
  return state.telegramMessageIds.length > 0;
212
221
  }
213
222
  try {
@@ -259,20 +268,20 @@ export class ResponseStreamer {
259
268
  }
260
269
  async syncMessages(state, payload, targetSignatures) {
261
270
  for (let index = 0; index < payload.parts.length; index++) {
262
- const text = payload.parts[index];
271
+ const part = payload.parts[index];
263
272
  const nextSignature = targetSignatures[index];
264
273
  const currentMessageId = state.telegramMessageIds[index];
265
274
  if (currentMessageId) {
266
275
  if (state.lastSentSignatures[index] === nextSignature) {
267
276
  continue;
268
277
  }
269
- await this.editText(currentMessageId, text, payload.format, payload.editOptions);
270
- state.lastSentSignatures[index] = nextSignature;
278
+ const result = await this.editPart(currentMessageId, part, payload.editOptions);
279
+ state.lastSentSignatures[index] = result.deliveredSignature;
271
280
  continue;
272
281
  }
273
- const messageId = await this.sendText(text, payload.format, payload.sendOptions);
274
- state.telegramMessageIds[index] = messageId;
275
- state.lastSentSignatures[index] = nextSignature;
282
+ const result = await this.sendPart(part, payload.sendOptions);
283
+ state.telegramMessageIds[index] = result.messageId;
284
+ state.lastSentSignatures[index] = result.deliveredSignature;
276
285
  }
277
286
  for (let index = state.telegramMessageIds.length - 1; index >= payload.parts.length; index--) {
278
287
  const messageId = state.telegramMessageIds[index];