@grinev/opencode-telegram-bot 0.15.0 → 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 (45) hide show
  1. package/.env.example +13 -0
  2. package/README.md +6 -0
  3. package/dist/app/start-bot-app.js +6 -1
  4. package/dist/bot/assistant-run-state.js +60 -0
  5. package/dist/bot/commands/abort.js +2 -0
  6. package/dist/bot/commands/commands.js +10 -0
  7. package/dist/bot/commands/definitions.js +1 -0
  8. package/dist/bot/commands/open.js +314 -0
  9. package/dist/bot/commands/projects.js +5 -38
  10. package/dist/bot/commands/start.js +2 -0
  11. package/dist/bot/handlers/inline-menu.js +9 -1
  12. package/dist/bot/handlers/prompt.js +11 -0
  13. package/dist/bot/index.js +162 -98
  14. package/dist/bot/streaming/response-streamer.js +26 -17
  15. package/dist/bot/utils/assistant-rendering.js +117 -0
  16. package/dist/bot/utils/assistant-run-footer.js +9 -0
  17. package/dist/bot/utils/browser-roots.js +140 -0
  18. package/dist/bot/utils/file-tree.js +92 -0
  19. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  20. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  21. package/dist/bot/utils/switch-project.js +48 -0
  22. package/dist/bot/utils/telegram-text.js +95 -1
  23. package/dist/cli.js +4 -0
  24. package/dist/config.js +3 -0
  25. package/dist/i18n/de.js +15 -0
  26. package/dist/i18n/en.js +15 -0
  27. package/dist/i18n/es.js +15 -0
  28. package/dist/i18n/fr.js +15 -0
  29. package/dist/i18n/ru.js +15 -0
  30. package/dist/i18n/zh.js +15 -0
  31. package/dist/index.js +2 -0
  32. package/dist/project/manager.js +2 -1
  33. package/dist/summary/aggregator.js +17 -1
  34. package/dist/summary/formatter.js +2 -88
  35. package/dist/telegram/render/block-fallback.js +28 -0
  36. package/dist/telegram/render/block-parser.js +295 -0
  37. package/dist/telegram/render/block-renderer.js +457 -0
  38. package/dist/telegram/render/chunker.js +281 -0
  39. package/dist/telegram/render/inline-renderer.js +128 -0
  40. package/dist/telegram/render/markdown-normalizer.js +94 -0
  41. package/dist/telegram/render/pipeline.js +9 -0
  42. package/dist/telegram/render/types.js +1 -0
  43. package/dist/telegram/render/validator.js +160 -0
  44. package/dist/utils/logger.js +200 -73
  45. package/package.json +6 -2
@@ -17,6 +17,7 @@ import { formatErrorDetails } from "../../utils/error-format.js";
17
17
  import { logger } from "../../utils/logger.js";
18
18
  import { t } from "../../i18n/index.js";
19
19
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
20
+ import { assistantRunState } from "../assistant-run-state.js";
20
21
  /** Module-level references for async callbacks that don't have ctx. */
21
22
  let botInstance = null;
22
23
  let chatIdInstance = null;
@@ -61,6 +62,7 @@ async function resetMismatchedSessionContext() {
61
62
  stopEventListening();
62
63
  summaryAggregator.clear();
63
64
  foregroundSessionState.clearAll("session_mismatch_reset");
65
+ assistantRunState.clearAll("session_mismatch_reset");
64
66
  clearAllInteractionState("session_mismatch_reset");
65
67
  clearSession();
66
68
  keyboardManager.clearContext();
@@ -209,6 +211,12 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
209
211
  };
210
212
  logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
211
213
  foregroundSessionState.markBusy(currentSession.id);
214
+ assistantRunState.startRun(currentSession.id, {
215
+ startedAt: Date.now(),
216
+ configuredAgent: currentAgent,
217
+ configuredProviderID: storedModel.providerID,
218
+ configuredModelID: storedModel.modelID,
219
+ });
212
220
  setPromptResponseMode(currentSession.id, responseMode);
213
221
  // CRITICAL: DO NOT wait for session.prompt to complete.
214
222
  // If we wait, the handler will not finish and grammY will not call getUpdates,
@@ -220,6 +228,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
220
228
  onSuccess: ({ error }) => {
221
229
  if (error) {
222
230
  foregroundSessionState.markIdle(currentSession.id);
231
+ assistantRunState.clearRun(currentSession.id, "session_prompt_api_error");
223
232
  clearPromptResponseMode(currentSession.id);
224
233
  const details = formatErrorDetails(error, 6000);
225
234
  logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
@@ -233,6 +242,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
233
242
  },
234
243
  onError: (error) => {
235
244
  foregroundSessionState.markIdle(currentSession.id);
245
+ assistantRunState.clearRun(currentSession.id, "session_prompt_background_error");
236
246
  clearPromptResponseMode(currentSession.id);
237
247
  const details = formatErrorDetails(error, 6000);
238
248
  logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
@@ -246,6 +256,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
246
256
  catch (err) {
247
257
  if (currentSession) {
248
258
  foregroundSessionState.markIdle(currentSession.id);
259
+ assistantRunState.clearRun(currentSession.id, "session_prompt_handler_error");
249
260
  }
250
261
  logger.error("Error in prompt handler:", err);
251
262
  if (interactionManager.getSnapshot()) {
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";
@@ -37,7 +38,7 @@ import { clearAllInteractionState } from "../interaction/cleanup.js";
37
38
  import { keyboardManager } from "../keyboard/manager.js";
38
39
  import { subscribeToEvents } from "../opencode/events.js";
39
40
  import { summaryAggregator } from "../summary/aggregator.js";
40
- import { formatSummary, formatSummaryWithMode, formatToolInfo, getAssistantParseMode, } from "../summary/formatter.js";
41
+ import { formatToolInfo } from "../summary/formatter.js";
41
42
  import { renderSubagentCards } from "../summary/subagent-formatter.js";
42
43
  import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
43
44
  import { getCurrentSession } from "../session/manager.js";
@@ -54,14 +55,16 @@ import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
54
55
  import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
55
56
  import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
56
57
  import { deliverThinkingMessage } from "./utils/thinking-message.js";
57
- 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";
58
60
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
59
61
  import { getStoredModel } from "../model/manager.js";
60
62
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
61
63
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
64
+ import { assistantRunState } from "./assistant-run-state.js";
62
65
  import { ResponseStreamer } from "./streaming/response-streamer.js";
63
66
  import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
64
- import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./utils/send-with-markdown-fallback.js";
67
+ import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
65
68
  let botInstance = null;
66
69
  let chatIdInstance = null;
67
70
  let commandsInitialized = false;
@@ -73,6 +76,7 @@ const SUBAGENT_STREAM_PREFIX = "🧩";
73
76
  const __filename = fileURLToPath(import.meta.url);
74
77
  const __dirname = path.dirname(__filename);
75
78
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
79
+ const sessionCompletionTasks = new Map();
76
80
  function getCurrentReplyKeyboard() {
77
81
  if (!keyboardManager.isInitialized()) {
78
82
  return undefined;
@@ -90,14 +94,23 @@ function prepareDocumentCaption(caption) {
90
94
  return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
91
95
  }
92
96
  function prepareStreamingPayload(messageText) {
93
- const parts = formatSummaryWithMode(messageText, config.bot.messageFormatMode, RESPONSE_STREAM_TEXT_LIMIT);
94
- if (parts.length === 0) {
95
- return null;
96
- }
97
- return {
98
- parts,
99
- format: "raw",
100
- };
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;
101
114
  }
102
115
  const toolMessageBatcher = new ToolMessageBatcher({
103
116
  sendText: async (sessionId, text) => {
@@ -141,39 +154,36 @@ const toolMessageBatcher = new ToolMessageBatcher({
141
154
  });
142
155
  const responseStreamer = new ResponseStreamer({
143
156
  throttleMs: RESPONSE_STREAM_THROTTLE_MS,
144
- sendText: async (text, format, options) => {
157
+ sendPart: async (part, options) => {
145
158
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
146
159
  throw new Error("Bot context missing for streamed send");
147
160
  }
148
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
149
- const sentMessage = await sendMessageWithMarkdownFallback({
161
+ return sendRenderedBotPart({
150
162
  api: botInstance.api,
151
163
  chatId: chatIdInstance,
152
- text,
164
+ part,
153
165
  options,
154
- parseMode,
155
166
  });
156
- return sentMessage.message_id;
157
167
  },
158
- editText: async (messageId, text, format, options) => {
168
+ editPart: async (messageId, part, options) => {
159
169
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
160
170
  throw new Error("Bot context missing for streamed edit");
161
171
  }
162
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
163
172
  try {
164
- await editMessageWithMarkdownFallback({
173
+ return await editRenderedBotPart({
165
174
  api: botInstance.api,
166
175
  chatId: chatIdInstance,
167
176
  messageId,
168
- text,
177
+ part,
169
178
  options,
170
- parseMode,
171
179
  });
172
180
  }
173
181
  catch (error) {
174
182
  const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
175
183
  if (errorMessage.includes("message is not modified")) {
176
- return;
184
+ return {
185
+ deliveredSignature: getTelegramRenderedPartSignature(part),
186
+ };
177
187
  }
178
188
  throw error;
179
189
  }
@@ -298,84 +308,82 @@ async function ensureEventSubscription(directory) {
298
308
  if (!preparedStreamPayload) {
299
309
  return;
300
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.
301
313
  preparedStreamPayload.sendOptions = { disable_notification: true };
302
314
  preparedStreamPayload.editOptions = undefined;
303
315
  responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
304
316
  });
305
- summaryAggregator.setOnComplete(async (sessionId, messageId, messageText) => {
306
- if (!botInstance || !chatIdInstance) {
307
- logger.error("Bot or chat ID not available for sending message");
308
- clearPromptResponseMode(sessionId);
309
- responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
310
- toolCallStreamer.clearSession(sessionId, "bot_context_missing");
311
- foregroundSessionState.markIdle(sessionId);
312
- return;
313
- }
314
- const currentSession = getCurrentSession();
315
- if (currentSession?.id !== sessionId) {
316
- clearPromptResponseMode(sessionId);
317
- responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
318
- toolCallStreamer.clearSession(sessionId, "session_mismatch");
319
- foregroundSessionState.markIdle(sessionId);
320
- await scheduledTaskRuntime.flushDeferredDeliveries();
321
- return;
322
- }
323
- const botApi = botInstance.api;
324
- const chatId = chatIdInstance;
325
- try {
326
- await finalizeAssistantResponse({
327
- sessionId,
328
- messageId,
329
- messageText,
330
- responseStreamer,
331
- flushPendingServiceMessages: () => Promise.all([
332
- toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
333
- toolCallStreamer.flushSession(sessionId, "assistant_message_completed"),
334
- ]).then(() => undefined),
335
- prepareStreamingPayload,
336
- formatSummary,
337
- formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
338
- resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
339
- getReplyKeyboard: getCurrentReplyKeyboard,
340
- sendText: async (text, rawFallbackText, options, format) => {
341
- await sendBotText({
342
- api: botApi,
343
- chatId,
344
- text,
345
- rawFallbackText,
346
- options: options,
347
- format,
348
- });
349
- },
350
- deleteMessages: async (messageIds) => {
351
- for (const msgId of messageIds) {
352
- try {
353
- await botApi.deleteMessage(chatId, msgId);
354
- }
355
- catch (err) {
356
- logger.warn(`[Bot] Failed to delete streamed message ${msgId}:`, err);
357
- }
358
- }
359
- },
360
- });
361
- await sendTtsResponseForSession({
362
- api: botApi,
363
- sessionId,
364
- chatId,
365
- text: messageText,
366
- });
367
- }
368
- catch (err) {
369
- clearPromptResponseMode(sessionId);
370
- logger.error("Failed to send message to Telegram:", err);
371
- // Stop processing events after critical error to prevent infinite loop
372
- logger.error("[Bot] CRITICAL: Stopping event processing due to error");
373
- summaryAggregator.clear();
374
- }
375
- finally {
376
- foregroundSessionState.markIdle(sessionId);
377
- await scheduledTaskRuntime.flushDeferredDeliveries();
378
- }
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
+ });
379
387
  });
380
388
  summaryAggregator.setOnTool(async (toolInfo) => {
381
389
  if (!botInstance || !chatIdInstance) {
@@ -568,9 +576,54 @@ async function ensureEventSubscription(directory) {
568
576
  logger.error("[Bot] Error reloading context after compaction:", err);
569
577
  }
570
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
+ });
571
623
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
572
624
  if (!botInstance || !chatIdInstance) {
573
625
  clearPromptResponseMode(sessionId);
626
+ assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
574
627
  foregroundSessionState.markIdle(sessionId);
575
628
  return;
576
629
  }
@@ -579,12 +632,14 @@ async function ensureEventSubscription(directory) {
579
632
  clearPromptResponseMode(sessionId);
580
633
  responseStreamer.clearSession(sessionId, "session_error_not_current");
581
634
  toolCallStreamer.clearSession(sessionId, "session_error_not_current");
635
+ assistantRunState.clearRun(sessionId, "session_error_not_current");
582
636
  foregroundSessionState.markIdle(sessionId);
583
637
  await scheduledTaskRuntime.flushDeferredDeliveries();
584
638
  return;
585
639
  }
586
640
  responseStreamer.clearSession(sessionId, "session_error");
587
641
  clearPromptResponseMode(sessionId);
642
+ assistantRunState.clearRun(sessionId, "session_error");
588
643
  await Promise.all([
589
644
  toolMessageBatcher.flushSession(sessionId, "session_error"),
590
645
  toolCallStreamer.flushSession(sessionId, "session_error"),
@@ -661,6 +716,8 @@ async function ensureEventSubscription(directory) {
661
716
  }
662
717
  export function createBot() {
663
718
  clearAllInteractionState("bot_startup");
719
+ sessionCompletionTasks.clear();
720
+ assistantRunState.clearAll("bot_startup");
664
721
  const botOptions = {};
665
722
  if (config.telegram.proxyUrl) {
666
723
  const proxyUrl = config.telegram.proxyUrl;
@@ -736,6 +793,7 @@ export function createBot() {
736
793
  bot.command("opencode_start", opencodeStartCommand);
737
794
  bot.command("opencode_stop", opencodeStopCommand);
738
795
  bot.command("projects", projectsCommand);
796
+ bot.command("open", openCommand);
739
797
  bot.command("sessions", sessionsCommand);
740
798
  bot.command("new", newCommand);
741
799
  bot.command("abort", abortCommand);
@@ -753,8 +811,13 @@ export function createBot() {
753
811
  }
754
812
  try {
755
813
  const handledInlineCancel = await handleInlineMenuCancel(ctx);
814
+ if (handledInlineCancel) {
815
+ // Clean up path index when the open-directory menu is cancelled
816
+ clearOpenPathIndex();
817
+ }
756
818
  const handledSession = await handleSessionSelect(ctx);
757
819
  const handledProject = await handleProjectSelect(ctx);
820
+ const handledOpen = await handleOpenCallback(ctx);
758
821
  const handledQuestion = await handleQuestionCallback(ctx);
759
822
  const handledPermission = await handlePermissionCallback(ctx);
760
823
  const handledAgent = await handleAgentSelect(ctx);
@@ -765,10 +828,11 @@ export function createBot() {
765
828
  const handledTaskList = await handleTaskListCallback(ctx);
766
829
  const handledRenameCancel = await handleRenameCancel(ctx);
767
830
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
768
- 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}`);
769
832
  if (!handledInlineCancel &&
770
833
  !handledSession &&
771
834
  !handledProject &&
835
+ !handledOpen &&
772
836
  !handledQuestion &&
773
837
  !handledPermission &&
774
838
  !handledAgent &&
@@ -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];