@grinev/opencode-telegram-bot 0.15.0 → 0.16.1

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 (50) hide show
  1. package/.env.example +16 -0
  2. package/README.md +22 -1
  3. package/dist/app/start-bot-app.js +82 -7
  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 +187 -100
  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/args.js +36 -7
  24. package/dist/cli.js +133 -15
  25. package/dist/config.js +4 -0
  26. package/dist/i18n/de.js +15 -10
  27. package/dist/i18n/en.js +15 -10
  28. package/dist/i18n/es.js +15 -10
  29. package/dist/i18n/fr.js +15 -10
  30. package/dist/i18n/ru.js +15 -10
  31. package/dist/i18n/zh.js +15 -10
  32. package/dist/index.js +2 -0
  33. package/dist/project/manager.js +2 -1
  34. package/dist/scheduled-task/runtime.js +8 -0
  35. package/dist/service/manager.js +244 -0
  36. package/dist/service/runtime.js +19 -0
  37. package/dist/service/types.js +1 -0
  38. package/dist/summary/aggregator.js +17 -1
  39. package/dist/summary/formatter.js +2 -88
  40. package/dist/telegram/render/block-fallback.js +28 -0
  41. package/dist/telegram/render/block-parser.js +295 -0
  42. package/dist/telegram/render/block-renderer.js +457 -0
  43. package/dist/telegram/render/chunker.js +281 -0
  44. package/dist/telegram/render/inline-renderer.js +128 -0
  45. package/dist/telegram/render/markdown-normalizer.js +94 -0
  46. package/dist/telegram/render/pipeline.js +9 -0
  47. package/dist/telegram/render/types.js +1 -0
  48. package/dist/telegram/render/validator.js +160 -0
  49. package/dist/utils/logger.js +200 -73
  50. package/package.json +6 -2
@@ -1,17 +1,10 @@
1
1
  import { InlineKeyboard } from "grammy";
2
- import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
2
+ import { getCurrentProject } from "../../settings/manager.js";
3
3
  import { getProjects } from "../../project/manager.js";
4
4
  import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
5
- import { clearSession } from "../../session/manager.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { pinnedMessageManager } from "../../pinned/manager.js";
8
- import { keyboardManager } from "../../keyboard/manager.js";
9
- import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
- import { getStoredModel } from "../../model/manager.js";
11
- import { formatVariantForButton } from "../../variant/manager.js";
12
- import { clearAllInteractionState } from "../../interaction/cleanup.js";
13
- import { createMainKeyboard } from "../utils/keyboard.js";
14
5
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
6
+ import { switchToProject } from "../utils/switch-project.js";
7
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
15
8
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
16
9
  import { logger } from "../../utils/logger.js";
17
10
  import { t } from "../../i18n/index.js";
@@ -179,35 +172,9 @@ export async function handleProjectSelect(ctx) {
179
172
  if (!selectedProject) {
180
173
  throw new Error(`Project with id ${projectId} not found`);
181
174
  }
182
- logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
183
- setCurrentProject(selectedProject);
184
- clearSession();
185
- summaryAggregator.clear();
186
- clearAllInteractionState("project_switched");
187
- // Clear pinned message when switching projects
188
- try {
189
- await pinnedMessageManager.clear();
190
- }
191
- catch (err) {
192
- logger.error("[Bot] Error clearing pinned message:", err);
193
- }
194
- // Initialize keyboard manager if not already
195
- if (ctx.chat) {
196
- keyboardManager.initialize(ctx.api, ctx.chat.id);
197
- }
198
- // Refresh context limit for current model
199
- await pinnedMessageManager.refreshContextLimit();
200
- const contextLimit = pinnedMessageManager.getContextLimit();
201
- // Reset context to 0 (no session selected) with current model's limit
202
- keyboardManager.updateContext(0, contextLimit);
203
- // Get current state for keyboard (with context = 0)
204
- const currentAgent = await resolveProjectAgent(getStoredAgent());
205
- const currentModel = getStoredModel();
206
- const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
207
- const variantName = formatVariantForButton(currentModel.variant || "default");
208
- keyboardManager.updateAgent(currentAgent);
209
- const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
210
175
  const projectName = selectedProject.name || selectedProject.worktree;
176
+ logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
177
+ const keyboard = await switchToProject(ctx, selectedProject, "project_switched");
211
178
  await ctx.answerCallbackQuery();
212
179
  await ctx.reply(t("projects.selected", { project: projectName }), {
213
180
  reply_markup: keyboard,
@@ -9,6 +9,7 @@ import { clearProject } from "../../settings/manager.js";
9
9
  import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
10
10
  import { abortCurrentOperation } from "./abort.js";
11
11
  import { t } from "../../i18n/index.js";
12
+ import { assistantRunState } from "../assistant-run-state.js";
12
13
  export async function startCommand(ctx) {
13
14
  if (ctx.chat) {
14
15
  if (!pinnedMessageManager.isInitialized()) {
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
18
19
  }
19
20
  await abortCurrentOperation(ctx, { notifyUser: false });
20
21
  foregroundSessionState.clearAll("start_command_reset");
22
+ assistantRunState.clearAll("start_command_reset");
21
23
  clearSession();
22
24
  clearProject();
23
25
  keyboardManager.clearContext();
@@ -3,7 +3,15 @@ import { logger } from "../../utils/logger.js";
3
3
  import { t } from "../../i18n/index.js";
4
4
  const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
5
5
  const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
6
- const INLINE_MENU_KINDS = ["project", "session", "model", "agent", "variant", "context"];
6
+ const INLINE_MENU_KINDS = [
7
+ "project",
8
+ "session",
9
+ "model",
10
+ "agent",
11
+ "variant",
12
+ "context",
13
+ "open",
14
+ ];
7
15
  function isInlineMenuKind(value) {
8
16
  return INLINE_MENU_KINDS.includes(value);
9
17
  }
@@ -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";
@@ -35,9 +36,9 @@ import { questionManager } from "../question/manager.js";
35
36
  import { interactionManager } from "../interaction/manager.js";
36
37
  import { clearAllInteractionState } from "../interaction/cleanup.js";
37
38
  import { keyboardManager } from "../keyboard/manager.js";
38
- import { subscribeToEvents } from "../opencode/events.js";
39
+ import { stopEventListening, 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,17 +55,20 @@ 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;
71
+ let heartbeatTimer = null;
68
72
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
69
73
  const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
70
74
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
@@ -73,6 +77,7 @@ const SUBAGENT_STREAM_PREFIX = "🧩";
73
77
  const __filename = fileURLToPath(import.meta.url);
74
78
  const __dirname = path.dirname(__filename);
75
79
  const TEMP_DIR = path.join(__dirname, "..", ".tmp");
80
+ const sessionCompletionTasks = new Map();
76
81
  function getCurrentReplyKeyboard() {
77
82
  if (!keyboardManager.isInitialized()) {
78
83
  return undefined;
@@ -90,14 +95,23 @@ function prepareDocumentCaption(caption) {
90
95
  return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
91
96
  }
92
97
  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
- };
98
+ return prepareAssistantStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
99
+ }
100
+ function prepareFinalStreamingPayload(messageText) {
101
+ return prepareAssistantFinalStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
102
+ }
103
+ function enqueueSessionCompletionTask(sessionId, task) {
104
+ const previousTask = sessionCompletionTasks.get(sessionId) ?? Promise.resolve();
105
+ const nextTask = previousTask
106
+ .catch(() => undefined)
107
+ .then(task)
108
+ .finally(() => {
109
+ if (sessionCompletionTasks.get(sessionId) === nextTask) {
110
+ sessionCompletionTasks.delete(sessionId);
111
+ }
112
+ });
113
+ sessionCompletionTasks.set(sessionId, nextTask);
114
+ return nextTask;
101
115
  }
102
116
  const toolMessageBatcher = new ToolMessageBatcher({
103
117
  sendText: async (sessionId, text) => {
@@ -141,39 +155,36 @@ const toolMessageBatcher = new ToolMessageBatcher({
141
155
  });
142
156
  const responseStreamer = new ResponseStreamer({
143
157
  throttleMs: RESPONSE_STREAM_THROTTLE_MS,
144
- sendText: async (text, format, options) => {
158
+ sendPart: async (part, options) => {
145
159
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
146
160
  throw new Error("Bot context missing for streamed send");
147
161
  }
148
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
149
- const sentMessage = await sendMessageWithMarkdownFallback({
162
+ return sendRenderedBotPart({
150
163
  api: botInstance.api,
151
164
  chatId: chatIdInstance,
152
- text,
165
+ part,
153
166
  options,
154
- parseMode,
155
167
  });
156
- return sentMessage.message_id;
157
168
  },
158
- editText: async (messageId, text, format, options) => {
169
+ editPart: async (messageId, part, options) => {
159
170
  if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
160
171
  throw new Error("Bot context missing for streamed edit");
161
172
  }
162
- const parseMode = format === "markdown_v2" ? "MarkdownV2" : undefined;
163
173
  try {
164
- await editMessageWithMarkdownFallback({
174
+ return await editRenderedBotPart({
165
175
  api: botInstance.api,
166
176
  chatId: chatIdInstance,
167
177
  messageId,
168
- text,
178
+ part,
169
179
  options,
170
- parseMode,
171
180
  });
172
181
  }
173
182
  catch (error) {
174
183
  const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
175
184
  if (errorMessage.includes("message is not modified")) {
176
- return;
185
+ return {
186
+ deliveredSignature: getTelegramRenderedPartSignature(part),
187
+ };
177
188
  }
178
189
  throw error;
179
190
  }
@@ -298,84 +309,82 @@ async function ensureEventSubscription(directory) {
298
309
  if (!preparedStreamPayload) {
299
310
  return;
300
311
  }
312
+ // Reply keyboards make the first streamed message non-editable in Telegram,
313
+ // so partial chunks must be sent without reply_markup and finalized later.
301
314
  preparedStreamPayload.sendOptions = { disable_notification: true };
302
315
  preparedStreamPayload.editOptions = undefined;
303
316
  responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
304
317
  });
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
- }
318
+ summaryAggregator.setOnComplete((sessionId, messageId, messageText, completionInfo) => {
319
+ void enqueueSessionCompletionTask(sessionId, async () => {
320
+ if (!botInstance || !chatIdInstance) {
321
+ logger.error("Bot or chat ID not available for sending message");
322
+ clearPromptResponseMode(sessionId);
323
+ responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
324
+ toolCallStreamer.clearSession(sessionId, "bot_context_missing");
325
+ assistantRunState.clearRun(sessionId, "bot_context_missing");
326
+ foregroundSessionState.markIdle(sessionId);
327
+ return;
328
+ }
329
+ const currentSession = getCurrentSession();
330
+ if (currentSession?.id !== sessionId) {
331
+ clearPromptResponseMode(sessionId);
332
+ responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
333
+ toolCallStreamer.clearSession(sessionId, "session_mismatch");
334
+ assistantRunState.clearRun(sessionId, "session_mismatch");
335
+ foregroundSessionState.markIdle(sessionId);
336
+ await scheduledTaskRuntime.flushDeferredDeliveries();
337
+ return;
338
+ }
339
+ const botApi = botInstance.api;
340
+ const chatId = chatIdInstance;
341
+ try {
342
+ assistantRunState.markResponseCompleted(sessionId, {
343
+ agent: completionInfo.agent,
344
+ providerID: completionInfo.providerID,
345
+ modelID: completionInfo.modelID,
346
+ });
347
+ await finalizeAssistantResponse({
348
+ sessionId,
349
+ messageId,
350
+ messageText,
351
+ responseStreamer,
352
+ flushPendingServiceMessages: () => Promise.all([
353
+ toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
354
+ toolCallStreamer.breakSession(sessionId, "assistant_message_completed"),
355
+ ]).then(() => undefined),
356
+ prepareStreamingPayload: prepareFinalStreamingPayload,
357
+ renderFinalParts: (text) => renderAssistantFinalPartsSafe(text),
358
+ getReplyKeyboard: getCurrentReplyKeyboard,
359
+ sendRenderedPart: async (part, options) => {
360
+ await sendRenderedBotPart({
361
+ api: botApi,
362
+ chatId,
363
+ part,
364
+ options: options,
365
+ });
366
+ },
367
+ });
368
+ await sendTtsResponseForSession({
369
+ api: botApi,
370
+ sessionId,
371
+ chatId,
372
+ text: messageText,
373
+ });
374
+ }
375
+ catch (err) {
376
+ clearPromptResponseMode(sessionId);
377
+ assistantRunState.clearRun(sessionId, "assistant_finalize_failed");
378
+ logger.error("Failed to send message to Telegram:", err);
379
+ // Stop processing events after critical error to prevent infinite loop
380
+ logger.error("[Bot] CRITICAL: Stopping event processing due to error");
381
+ summaryAggregator.clear();
382
+ foregroundSessionState.markIdle(sessionId);
383
+ }
384
+ finally {
385
+ await scheduledTaskRuntime.flushDeferredDeliveries();
386
+ }
387
+ });
379
388
  });
380
389
  summaryAggregator.setOnTool(async (toolInfo) => {
381
390
  if (!botInstance || !chatIdInstance) {
@@ -434,6 +443,9 @@ async function ensureEventSubscription(directory) {
434
443
  if (!currentSession || currentSession.id !== fileInfo.sessionId) {
435
444
  return;
436
445
  }
446
+ if (config.bot.hideToolFileMessages) {
447
+ return;
448
+ }
437
449
  try {
438
450
  await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
439
451
  const toolMessage = formatToolInfo(fileInfo);
@@ -568,9 +580,54 @@ async function ensureEventSubscription(directory) {
568
580
  logger.error("[Bot] Error reloading context after compaction:", err);
569
581
  }
570
582
  });
583
+ summaryAggregator.setOnSessionIdle(async (sessionId) => {
584
+ await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
585
+ const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
586
+ clearPromptResponseMode(sessionId);
587
+ if (!botInstance || !chatIdInstance) {
588
+ foregroundSessionState.markIdle(sessionId);
589
+ return;
590
+ }
591
+ const currentSession = getCurrentSession();
592
+ if (!currentSession || currentSession.id !== sessionId) {
593
+ foregroundSessionState.markIdle(sessionId);
594
+ await scheduledTaskRuntime.flushDeferredDeliveries();
595
+ return;
596
+ }
597
+ try {
598
+ await Promise.all([
599
+ toolMessageBatcher.flushSession(sessionId, "session_idle"),
600
+ toolCallStreamer.flushSession(sessionId, "session_idle"),
601
+ ]);
602
+ if (completedRun?.hasCompletedResponse) {
603
+ const agent = completedRun.actualAgent || completedRun.configuredAgent;
604
+ const providerID = completedRun.actualProviderID || completedRun.configuredProviderID;
605
+ const modelID = completedRun.actualModelID || completedRun.configuredModelID;
606
+ if (agent && providerID && modelID) {
607
+ const keyboard = getCurrentReplyKeyboard();
608
+ await botInstance.api.sendMessage(chatIdInstance, formatAssistantRunFooter({
609
+ agent,
610
+ providerID,
611
+ modelID,
612
+ elapsedMs: Date.now() - completedRun.startedAt,
613
+ }), {
614
+ ...(keyboard ? { reply_markup: keyboard } : {}),
615
+ });
616
+ }
617
+ }
618
+ }
619
+ catch (err) {
620
+ logger.error("[Bot] Failed to send session idle footer:", err);
621
+ }
622
+ finally {
623
+ foregroundSessionState.markIdle(sessionId);
624
+ await scheduledTaskRuntime.flushDeferredDeliveries();
625
+ }
626
+ });
571
627
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
572
628
  if (!botInstance || !chatIdInstance) {
573
629
  clearPromptResponseMode(sessionId);
630
+ assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
574
631
  foregroundSessionState.markIdle(sessionId);
575
632
  return;
576
633
  }
@@ -579,12 +636,14 @@ async function ensureEventSubscription(directory) {
579
636
  clearPromptResponseMode(sessionId);
580
637
  responseStreamer.clearSession(sessionId, "session_error_not_current");
581
638
  toolCallStreamer.clearSession(sessionId, "session_error_not_current");
639
+ assistantRunState.clearRun(sessionId, "session_error_not_current");
582
640
  foregroundSessionState.markIdle(sessionId);
583
641
  await scheduledTaskRuntime.flushDeferredDeliveries();
584
642
  return;
585
643
  }
586
644
  responseStreamer.clearSession(sessionId, "session_error");
587
645
  clearPromptResponseMode(sessionId);
646
+ assistantRunState.clearRun(sessionId, "session_error");
588
647
  await Promise.all([
589
648
  toolMessageBatcher.flushSession(sessionId, "session_error"),
590
649
  toolCallStreamer.flushSession(sessionId, "session_error"),
@@ -661,6 +720,12 @@ async function ensureEventSubscription(directory) {
661
720
  }
662
721
  export function createBot() {
663
722
  clearAllInteractionState("bot_startup");
723
+ sessionCompletionTasks.clear();
724
+ assistantRunState.clearAll("bot_startup");
725
+ if (heartbeatTimer) {
726
+ clearInterval(heartbeatTimer);
727
+ heartbeatTimer = null;
728
+ }
664
729
  const botOptions = {};
665
730
  if (config.telegram.proxyUrl) {
666
731
  const proxyUrl = config.telegram.proxyUrl;
@@ -683,7 +748,7 @@ export function createBot() {
683
748
  const bot = new Bot(config.telegram.token, botOptions);
684
749
  // Heartbeat for diagnostics: verify the event loop is not blocked
685
750
  let heartbeatCounter = 0;
686
- setInterval(() => {
751
+ heartbeatTimer = setInterval(() => {
687
752
  heartbeatCounter++;
688
753
  if (heartbeatCounter % 6 === 0) {
689
754
  // Log every 30 seconds (5 sec * 6)
@@ -736,6 +801,7 @@ export function createBot() {
736
801
  bot.command("opencode_start", opencodeStartCommand);
737
802
  bot.command("opencode_stop", opencodeStopCommand);
738
803
  bot.command("projects", projectsCommand);
804
+ bot.command("open", openCommand);
739
805
  bot.command("sessions", sessionsCommand);
740
806
  bot.command("new", newCommand);
741
807
  bot.command("abort", abortCommand);
@@ -753,8 +819,13 @@ export function createBot() {
753
819
  }
754
820
  try {
755
821
  const handledInlineCancel = await handleInlineMenuCancel(ctx);
822
+ if (handledInlineCancel) {
823
+ // Clean up path index when the open-directory menu is cancelled
824
+ clearOpenPathIndex();
825
+ }
756
826
  const handledSession = await handleSessionSelect(ctx);
757
827
  const handledProject = await handleProjectSelect(ctx);
828
+ const handledOpen = await handleOpenCallback(ctx);
758
829
  const handledQuestion = await handleQuestionCallback(ctx);
759
830
  const handledPermission = await handlePermissionCallback(ctx);
760
831
  const handledAgent = await handleAgentSelect(ctx);
@@ -765,10 +836,11 @@ export function createBot() {
765
836
  const handledTaskList = await handleTaskListCallback(ctx);
766
837
  const handledRenameCancel = await handleRenameCancel(ctx);
767
838
  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}`);
839
+ 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
840
  if (!handledInlineCancel &&
770
841
  !handledSession &&
771
842
  !handledProject &&
843
+ !handledOpen &&
772
844
  !handledQuestion &&
773
845
  !handledPermission &&
774
846
  !handledAgent &&
@@ -989,3 +1061,18 @@ export function createBot() {
989
1061
  });
990
1062
  return bot;
991
1063
  }
1064
+ export function cleanupBotRuntime(reason) {
1065
+ stopEventListening();
1066
+ summaryAggregator.clear();
1067
+ responseStreamer.clearAll(reason);
1068
+ toolCallStreamer.clearAll(reason);
1069
+ toolMessageBatcher.clearAll(reason);
1070
+ sessionCompletionTasks.clear();
1071
+ assistantRunState.clearAll(reason);
1072
+ if (heartbeatTimer) {
1073
+ clearInterval(heartbeatTimer);
1074
+ heartbeatTimer = null;
1075
+ }
1076
+ botInstance = null;
1077
+ chatIdInstance = null;
1078
+ }