@grinev/opencode-telegram-bot 0.21.0 → 0.21.2

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 (182) hide show
  1. package/.env.example +18 -2
  2. package/README.md +21 -8
  3. package/dist/app/{start-bot-app.js → bootstrap/start-bot-app.js} +16 -15
  4. package/dist/{bot/utils/assistant-run-footer.js → app/formatters/assistant-run-footer-formatter.js} +1 -1
  5. package/dist/{summary → app/formatters}/subagent-formatter.js +8 -3
  6. package/dist/{summary/formatter.js → app/formatters/summary-formatter.js} +4 -132
  7. package/dist/{summary → app/formatters}/tool-message-batcher.js +1 -1
  8. package/dist/{bot/assistant-run-state.js → app/managers/assistant-run-state-manager.js} +1 -1
  9. package/dist/{attach/manager.js → app/managers/attach-manager.js} +1 -1
  10. package/dist/{background-session/tracker.js → app/managers/background-session-manager.js} +2 -2
  11. package/dist/{scheduled-task/foreground-state.js → app/managers/foreground-session-state-manager.js} +1 -1
  12. package/dist/{interaction/manager.js → app/managers/interaction-manager.js} +31 -1
  13. package/dist/{permission/manager.js → app/managers/permission-manager.js} +1 -1
  14. package/dist/{question/manager.js → app/managers/question-manager.js} +1 -1
  15. package/dist/{rename/manager.js → app/managers/rename-manager.js} +1 -1
  16. package/dist/{scheduled-task/creation-manager.js → app/managers/scheduled-task-creation-manager.js} +2 -2
  17. package/dist/{summary/aggregator.js → app/managers/summary-aggregation-manager.js} +3 -3
  18. package/dist/{agent/manager.js → app/services/agent-selection-service.js} +4 -5
  19. package/dist/{attach/service.js → app/services/attach-service.js} +23 -44
  20. package/dist/{bot/utils/busy-reconciliation.js → app/services/busy-reconciliation-service.js} +12 -7
  21. package/dist/app/services/command-catalog-service.js +21 -0
  22. package/dist/{bot/utils/external-user-input.js → app/services/external-user-input-service.js} +4 -19
  23. package/dist/app/services/file-browser-service.js +265 -0
  24. package/dist/{bot/utils/file-download.js → app/services/file-download-service.js} +14 -28
  25. package/dist/app/services/mcp-catalog-service.js +79 -0
  26. package/dist/app/services/message-history-service.js +81 -0
  27. package/dist/{model/capabilities.js → app/services/model-capabilities-service.js} +2 -2
  28. package/dist/{model/context-limit.js → app/services/model-context-limit-service.js} +4 -4
  29. package/dist/{model/manager.js → app/services/model-selection-service.js} +4 -4
  30. package/dist/{project/manager.js → app/services/project-service.js} +3 -3
  31. package/dist/app/services/project-switch-service.js +39 -0
  32. package/dist/{bot/utils/busy-guard.js → app/services/run-control-service.js} +3 -14
  33. package/dist/{scheduled-task/executor.js → app/services/scheduled-task-executor-service.js} +5 -5
  34. package/dist/{scheduled-task/runtime.js → app/services/scheduled-task-runtime-service.js} +17 -56
  35. package/dist/{scheduled-task/schedule-parser.js → app/services/scheduled-task-schedule-parser-service.js} +3 -3
  36. package/dist/{scheduled-task/session-ignore.js → app/services/scheduled-task-session-ignore-service.js} +1 -1
  37. package/dist/{session/cache-manager.js → app/services/session-cache-service.js} +3 -3
  38. package/dist/{session/manager.js → app/services/session-service.js} +1 -1
  39. package/dist/app/services/skills-catalog-service.js +21 -0
  40. package/dist/{stt/client.js → app/services/stt-service.js} +2 -2
  41. package/dist/{tts/client.js → app/services/tts-service.js} +68 -5
  42. package/dist/{variant/manager.js → app/services/variant-selection-service.js} +3 -3
  43. package/dist/{scheduled-task/store.js → app/stores/scheduled-task-store.js} +3 -3
  44. package/dist/{settings/manager.js → app/stores/settings-store.js} +3 -3
  45. package/dist/app/types/worktree.js +1 -0
  46. package/dist/bot/{handlers/agent.js → callbacks/agent-selection-callback-handler.js} +8 -57
  47. package/dist/bot/callbacks/callback-router.js +110 -0
  48. package/dist/bot/{commands/commands.js → callbacks/command-catalog-callback-handler.js} +17 -197
  49. package/dist/bot/{handlers/context.js → callbacks/context-control-callback-handler.js} +3 -32
  50. package/dist/bot/callbacks/file-browser-callback-handler.js +219 -0
  51. package/dist/bot/callbacks/inline-menu-cancel-callback-handler.js +32 -0
  52. package/dist/bot/{commands/mcps.js → callbacks/mcp-catalog-callback-handler.js} +7 -172
  53. package/dist/bot/{commands/messages.js → callbacks/message-history-callback-handler.js} +17 -248
  54. package/dist/bot/{handlers/model.js → callbacks/model-selection-callback-handler.js} +10 -71
  55. package/dist/bot/callbacks/permission-callback-handler.js +114 -0
  56. package/dist/bot/callbacks/project-callback-handler.js +79 -0
  57. package/dist/bot/callbacks/question-callback-handler.js +159 -0
  58. package/dist/bot/{commands/rename.js → callbacks/rename-callback-handler.js} +6 -34
  59. package/dist/bot/{commands/tasklist.js → callbacks/scheduled-task-callback-handler.js} +83 -68
  60. package/dist/bot/{commands/sessions.js → callbacks/session-callback-handler.js} +19 -158
  61. package/dist/bot/{commands/skills.js → callbacks/skills-catalog-callback-handler.js} +8 -188
  62. package/dist/bot/{handlers/variant.js → callbacks/variant-selection-callback-handler.js} +7 -69
  63. package/dist/bot/callbacks/worktree-callback-handler.js +97 -0
  64. package/dist/bot/commands/{abort.js → abort-command.js} +6 -6
  65. package/dist/bot/commands/command-catalog-command.js +42 -0
  66. package/dist/bot/commands/{detach.js → detach-command.js} +8 -8
  67. package/dist/bot/commands/ls-command.js +48 -0
  68. package/dist/bot/commands/mcp-catalog-command.js +39 -0
  69. package/dist/bot/commands/messages-command.js +59 -0
  70. package/dist/bot/commands/{new.js → new-command.js} +12 -11
  71. package/dist/bot/commands/open-command.js +45 -0
  72. package/dist/bot/commands/{opencode-start.js → opencode-start-command.js} +1 -1
  73. package/dist/bot/commands/{opencode-stop.js → opencode-stop-command.js} +1 -1
  74. package/dist/bot/commands/projects-command.js +32 -0
  75. package/dist/bot/commands/rename-command.js +33 -0
  76. package/dist/bot/commands/sessions-command.js +42 -0
  77. package/dist/bot/commands/skills-catalog-command.js +42 -0
  78. package/dist/bot/commands/{start.js → start-command.js} +12 -12
  79. package/dist/bot/commands/{status.js → status-command.js} +9 -9
  80. package/dist/bot/commands/{task.js → task-command.js} +9 -81
  81. package/dist/bot/commands/tasklist-command.js +40 -0
  82. package/dist/bot/commands/{tts.js → tts-command.js} +2 -2
  83. package/dist/bot/commands/worktree-command.js +47 -0
  84. package/dist/bot/handlers/{document.js → document-handler.js} +3 -3
  85. package/dist/bot/handlers/{media-group.js → media-group-handler.js} +3 -3
  86. package/dist/bot/handlers/photo-handler.js +44 -0
  87. package/dist/bot/handlers/prompt.js +16 -16
  88. package/dist/bot/handlers/text-message-handler.js +61 -0
  89. package/dist/bot/handlers/tts-response-handler.js +29 -0
  90. package/dist/bot/handlers/{voice.js → voice-handler.js} +2 -2
  91. package/dist/bot/index.js +27 -1130
  92. package/dist/{keyboard/manager.js → bot/keyboards/keyboard-manager.js} +6 -6
  93. package/dist/bot/keyboards/keyboard-types.js +1 -0
  94. package/dist/bot/{utils/keyboard.js → keyboards/main-reply-keyboard.js} +2 -2
  95. package/dist/bot/menus/agent-selection-menu.js +54 -0
  96. package/dist/bot/menus/command-catalog-menu.js +101 -0
  97. package/dist/bot/menus/context-control-menu.js +33 -0
  98. package/dist/bot/menus/file-browser-menu.js +265 -0
  99. package/dist/bot/{handlers → menus}/inline-menu.js +4 -33
  100. package/dist/bot/menus/mcp-catalog-menu.js +97 -0
  101. package/dist/bot/menus/message-history-menu.js +107 -0
  102. package/dist/bot/menus/model-selection-menu.js +66 -0
  103. package/dist/bot/menus/permission-menu.js +116 -0
  104. package/dist/bot/{commands/projects.js → menus/project-selection-menu.js} +5 -105
  105. package/dist/bot/{handlers/question.js → menus/question-menu.js} +10 -166
  106. package/dist/bot/menus/rename-menu.js +6 -0
  107. package/dist/bot/menus/scheduled-task-menu.js +42 -0
  108. package/dist/bot/menus/session-selection-menu.js +115 -0
  109. package/dist/bot/menus/skills-catalog-menu.js +99 -0
  110. package/dist/bot/menus/variant-selection-menu.js +67 -0
  111. package/dist/bot/menus/worktree-selection-menu.js +76 -0
  112. package/dist/bot/{utils → messages}/assistant-rendering.js +2 -2
  113. package/dist/bot/messages/busy-blocked-renderer.js +11 -0
  114. package/dist/bot/messages/external-user-input-notification.js +22 -0
  115. package/dist/bot/messages/scheduled-task-delivery.js +63 -0
  116. package/dist/bot/{utils → messages}/send-downloaded-file.js +1 -1
  117. package/dist/bot/messages/summary-message-formatter.js +130 -0
  118. package/dist/bot/{utils → messages}/telegram-text.js +60 -0
  119. package/dist/{interaction/guard.js → bot/middleware/interaction-guard-decision.js} +11 -4
  120. package/dist/bot/middleware/interaction-guard.js +2 -2
  121. package/dist/bot/middleware/unknown-command.js +1 -1
  122. package/dist/{pinned/format.js → bot/pinned/pinned-message-format.js} +1 -2
  123. package/dist/{pinned/manager.js → bot/pinned/pinned-message-manager.js} +10 -10
  124. package/dist/bot/pinned/pinned-message-types.js +1 -0
  125. package/dist/{summary → bot/render}/markdown-to-telegram-v2.js +2 -2
  126. package/dist/bot/render/types.js +1 -0
  127. package/dist/bot/routers/command-router.js +73 -0
  128. package/dist/bot/routers/message-router.js +150 -0
  129. package/dist/bot/services/attach-presentation.js +40 -0
  130. package/dist/bot/services/event-subscription-service.js +842 -0
  131. package/dist/bot/services/project-switch-presentation.js +26 -0
  132. package/dist/bot/streaming/response-streamer.js +18 -2
  133. package/dist/cli.js +4 -4
  134. package/dist/config.js +21 -3
  135. package/dist/i18n/ar.js +491 -0
  136. package/dist/i18n/index.js +7 -0
  137. package/dist/index.js +1 -1
  138. package/dist/opencode/ready-refresh.js +2 -2
  139. package/dist/{service → runtime/service}/manager.js +2 -2
  140. package/dist/runtime/service/types.js +1 -0
  141. package/package.json +1 -1
  142. package/dist/bot/commands/ls.js +0 -445
  143. package/dist/bot/commands/open.js +0 -319
  144. package/dist/bot/commands/worktree.js +0 -201
  145. package/dist/bot/handlers/permission.js +0 -235
  146. package/dist/bot/utils/browser-roots.js +0 -140
  147. package/dist/bot/utils/file-tree.js +0 -92
  148. package/dist/bot/utils/send-tts-response.js +0 -37
  149. package/dist/bot/utils/switch-project.js +0 -45
  150. package/dist/bot/utils/telegram-file-url.js +0 -11
  151. package/dist/interaction/busy.js +0 -8
  152. package/dist/interaction/cleanup.js +0 -32
  153. /package/dist/{scheduled-task/display.js → app/formatters/scheduled-task-display-formatter.js} +0 -0
  154. /package/dist/{bot/utils/abort-error-suppression.js → app/managers/abort-suppression-manager.js} +0 -0
  155. /package/dist/{external-input/suppression.js → app/managers/external-input-suppression-manager.js} +0 -0
  156. /package/dist/{scheduled-task/next-run.js → app/services/scheduled-task-next-run-service.js} +0 -0
  157. /package/dist/{git/worktree.js → app/services/worktree-service.js} +0 -0
  158. /package/dist/{agent/types.js → app/types/agent.js} +0 -0
  159. /package/dist/{interaction/types.js → app/types/interaction.js} +0 -0
  160. /package/dist/{model/types.js → app/types/model.js} +0 -0
  161. /package/dist/{keyboard/types.js → app/types/permission.js} +0 -0
  162. /package/dist/{permission/types.js → app/types/project.js} +0 -0
  163. /package/dist/{pinned/types.js → app/types/question.js} +0 -0
  164. /package/dist/{scheduled-task/types.js → app/types/scheduled-task.js} +0 -0
  165. /package/dist/{question/types.js → app/types/session.js} +0 -0
  166. /package/dist/{service/types.js → app/types/settings.js} +0 -0
  167. /package/dist/{telegram/render/types.js → app/types/summary.js} +0 -0
  168. /package/dist/{variant/types.js → app/types/variant.js} +0 -0
  169. /package/dist/bot/commands/{help.js → help-command.js} +0 -0
  170. /package/dist/bot/{utils → messages}/send-with-markdown-fallback.js +0 -0
  171. /package/dist/bot/{utils → messages}/thinking-message.js +0 -0
  172. /package/dist/{telegram → bot}/render/block-fallback.js +0 -0
  173. /package/dist/{telegram → bot}/render/block-parser.js +0 -0
  174. /package/dist/{telegram → bot}/render/block-renderer.js +0 -0
  175. /package/dist/{telegram → bot}/render/chunker.js +0 -0
  176. /package/dist/{telegram → bot}/render/inline-renderer.js +0 -0
  177. /package/dist/{telegram → bot}/render/markdown-normalizer.js +0 -0
  178. /package/dist/{telegram → bot}/render/pipeline.js +0 -0
  179. /package/dist/{telegram → bot}/render/validator.js +0 -0
  180. /package/dist/bot/{utils/commands.js → routers/command-utils.js} +0 -0
  181. /package/dist/bot/{utils → streaming}/finalize-assistant-response.js +0 -0
  182. /package/dist/{service/runtime.js → runtime/service/env.js} +0 -0
package/dist/bot/index.js CHANGED
@@ -1,864 +1,42 @@
1
- import { Bot, InputFile } from "grammy";
2
- import { promises as fs } from "fs";
3
- import * as path from "path";
4
- import { fileURLToPath } from "url";
1
+ import { Bot } from "grammy";
5
2
  import { config } from "../config.js";
6
- import { authMiddleware } from "./middleware/auth.js";
7
- import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
8
- import { unknownCommandMiddleware } from "./middleware/unknown-command.js";
9
- import { BOT_COMMANDS } from "./commands/definitions.js";
10
- import { startCommand } from "./commands/start.js";
11
- import { helpCommand } from "./commands/help.js";
12
- import { statusCommand } from "./commands/status.js";
13
- import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN, } from "./message-patterns.js";
14
- import { buildBackgroundSessionOpenKeyboard, handleBackgroundSessionOpen, handleSessionSelect, sessionsCommand, } from "./commands/sessions.js";
15
- import { newCommand } from "./commands/new.js";
16
- import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
17
- import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
18
- import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
19
- import { clearLsPathIndex, handleLsCallback, lsCommand } from "./commands/ls.js";
20
- import { abortCommand } from "./commands/abort.js";
21
- import { detachCommand } from "./commands/detach.js";
22
- import { opencodeStartCommand } from "./commands/opencode-start.js";
23
- import { opencodeStopCommand } from "./commands/opencode-stop.js";
24
- import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
25
- import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
26
- import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
27
- import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
28
- import { handleMessagesCallback, messagesCommand } from "./commands/messages.js";
29
- import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
30
- import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
31
- import { ttsCommand } from "./commands/tts.js";
32
- import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
33
- import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
34
- import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
35
- import { handleModelSelect, handleModelSearchCallback, handleModelSearchResults, handleModelSearchTextInput, showModelSelectionMenu, } from "./handlers/model.js";
36
- import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
37
- import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
38
- import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
39
- import { questionManager } from "../question/manager.js";
40
- import { interactionManager } from "../interaction/manager.js";
41
- import { clearAllInteractionState } from "../interaction/cleanup.js";
42
- import { keyboardManager } from "../keyboard/manager.js";
43
- import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
3
+ import { getCurrentProject } from "../app/stores/settings-store.js";
4
+ import { attachManager } from "../app/managers/attach-manager.js";
5
+ import { clearAllInteractionState } from "../app/managers/interaction-manager.js";
6
+ import { configureAttachPresentation, restoreAttachedCurrentSession, } from "../app/services/attach-service.js";
44
7
  import { opencodeReadyLifecycle } from "../opencode/ready-lifecycle.js";
45
- import { summaryAggregator } from "../summary/aggregator.js";
46
- import { formatToolInfo } from "../summary/formatter.js";
47
- import { renderSubagentCards } from "../summary/subagent-formatter.js";
48
- import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
49
- import { getCurrentSession } from "../session/manager.js";
50
- import { ingestSessionInfoForCache } from "../session/cache-manager.js";
51
8
  import { logger } from "../utils/logger.js";
52
9
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
53
10
  import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
54
- import { pinnedMessageManager } from "../pinned/manager.js";
55
- import { t } from "../i18n/index.js";
56
- import { getCurrentProject } from "../settings/manager.js";
11
+ import { registerCallbackRouter } from "./callbacks/callback-router.js";
12
+ import { authMiddleware } from "./middleware/auth.js";
13
+ import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
14
+ import { ensureCommandsInitialized, registerCommandRouter, } from "./routers/command-router.js";
15
+ import { registerMessageRouter } from "./routers/message-router.js";
16
+ import { createEventSubscriptionService, } from "./services/event-subscription-service.js";
17
+ import { createAttachPresentation } from "./services/attach-presentation.js";
57
18
  import { createTelegramBotOptions } from "./telegram-client-options.js";
58
- import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
59
- import { handleVoiceMessage } from "./handlers/voice.js";
60
- import { handleDocumentMessage } from "./handlers/document.js";
61
- import { createMediaGroupAttachmentMiddleware } from "./handlers/media-group.js";
62
- import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
63
- import { reconcileBusyState, setResponseStreamerForReconciliation } from "./utils/busy-reconciliation.js";
64
- import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
65
- import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
66
- import { deliverThinkingMessage } from "./utils/thinking-message.js";
67
- import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
68
- import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
69
- import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
70
- import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
71
- import { getStoredModel } from "../model/manager.js";
72
- import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
73
- import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
74
- import { assistantRunState } from "./assistant-run-state.js";
75
- import { ResponseStreamer } from "./streaming/response-streamer.js";
76
- import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
77
- import { attachManager } from "../attach/manager.js";
78
- import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurrentSession, } from "../attach/service.js";
79
- import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
80
- import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
81
- import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
82
- import { backgroundSessionTracker, } from "../background-session/tracker.js";
83
- let botInstance = null;
84
- let chatIdInstance = null;
85
- let commandsInitialized = false;
86
19
  let heartbeatTimer = null;
87
20
  let unsubscribeReadyRestore = null;
88
- const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
89
- const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
90
- const RESPONSE_STREAM_TEXT_LIMIT = 3800;
91
- const SESSION_RETRY_PREFIX = "🔁";
92
- const SUBAGENT_STREAM_PREFIX = "🧩";
93
- const __filename = fileURLToPath(import.meta.url);
94
- const __dirname = path.dirname(__filename);
95
- const TEMP_DIR = path.join(__dirname, "..", ".tmp");
96
- const sessionCompletionTasks = new Map();
97
- function getCurrentReplyKeyboard() {
98
- if (!keyboardManager.isInitialized()) {
99
- return undefined;
100
- }
101
- return keyboardManager.getKeyboard();
102
- }
103
- function prepareDocumentCaption(caption) {
104
- const normalizedCaption = caption.trim();
105
- if (!normalizedCaption) {
106
- return "";
107
- }
108
- if (normalizedCaption.length <= TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH) {
109
- return normalizedCaption;
110
- }
111
- return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
112
- }
113
- function prepareStreamingPayload(messageText) {
114
- return prepareAssistantStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
115
- }
116
- function prepareFinalStreamingPayload(messageText) {
117
- return prepareAssistantFinalStreamingPayload(messageText, RESPONSE_STREAM_TEXT_LIMIT);
118
- }
119
- function enqueueSessionCompletionTask(sessionId, task) {
120
- const previousTask = sessionCompletionTasks.get(sessionId) ?? Promise.resolve();
121
- const nextTask = previousTask
122
- .catch(() => undefined)
123
- .then(task)
124
- .finally(() => {
125
- if (sessionCompletionTasks.get(sessionId) === nextTask) {
126
- sessionCompletionTasks.delete(sessionId);
127
- }
128
- });
129
- sessionCompletionTasks.set(sessionId, nextTask);
130
- return nextTask;
131
- }
132
- const toolMessageBatcher = new ToolMessageBatcher({
133
- sendText: async (sessionId, text) => {
134
- if (!botInstance || !chatIdInstance) {
135
- return;
136
- }
137
- const currentSession = getCurrentSession();
138
- if (!currentSession || currentSession.id !== sessionId) {
139
- return;
140
- }
141
- const keyboard = getCurrentReplyKeyboard();
142
- await botInstance.api.sendMessage(chatIdInstance, text, {
143
- disable_notification: true,
144
- ...(keyboard ? { reply_markup: keyboard } : {}),
145
- });
146
- },
147
- sendFile: async (sessionId, fileData) => {
148
- if (!botInstance || !chatIdInstance) {
149
- return;
150
- }
151
- const currentSession = getCurrentSession();
152
- if (!currentSession || currentSession.id !== sessionId) {
153
- return;
154
- }
155
- const tempFilePath = path.join(TEMP_DIR, fileData.filename);
156
- try {
157
- logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
158
- await fs.mkdir(TEMP_DIR, { recursive: true });
159
- await fs.writeFile(tempFilePath, fileData.buffer);
160
- const keyboard = getCurrentReplyKeyboard();
161
- await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
162
- caption: fileData.caption,
163
- disable_notification: true,
164
- ...(keyboard ? { reply_markup: keyboard } : {}),
165
- });
166
- }
167
- finally {
168
- await fs.unlink(tempFilePath).catch(() => { });
169
- }
170
- },
171
- });
172
- const responseStreamer = new ResponseStreamer({
173
- throttleMs: RESPONSE_STREAM_THROTTLE_MS,
174
- sendPart: async (part, options) => {
175
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
176
- throw new Error("Bot context missing for streamed send");
177
- }
178
- return sendRenderedBotPart({
179
- api: botInstance.api,
180
- chatId: chatIdInstance,
181
- part,
182
- options,
183
- });
184
- },
185
- editPart: async (messageId, part, options) => {
186
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
187
- throw new Error("Bot context missing for streamed edit");
188
- }
189
- try {
190
- return await editRenderedBotPart({
191
- api: botInstance.api,
192
- chatId: chatIdInstance,
193
- messageId,
194
- part,
195
- options,
196
- });
197
- }
198
- catch (error) {
199
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
200
- if (errorMessage.includes("message is not modified")) {
201
- return {
202
- deliveredSignature: getTelegramRenderedPartSignature(part),
203
- };
204
- }
205
- throw error;
206
- }
207
- },
208
- deleteText: async (messageId) => {
209
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
210
- throw new Error("Bot context missing for streamed delete");
211
- }
212
- await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
213
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
214
- if (errorMessage.includes("message to delete not found") ||
215
- errorMessage.includes("message identifier is not specified")) {
216
- return;
217
- }
218
- throw error;
219
- });
220
- },
221
- });
222
- setResponseStreamerForReconciliation(responseStreamer);
223
- const toolCallStreamer = new ToolCallStreamer({
224
- throttleMs: RESPONSE_STREAM_THROTTLE_MS,
225
- sendText: async (sessionId, text) => {
226
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
227
- throw new Error("Bot context missing for tool stream send");
228
- }
229
- const currentSession = getCurrentSession();
230
- if (!currentSession || currentSession.id !== sessionId) {
231
- throw new Error(`Tool stream session mismatch for send: ${sessionId}`);
232
- }
233
- const sentMessage = await botInstance.api.sendMessage(chatIdInstance, text, {
234
- disable_notification: true,
235
- });
236
- return sentMessage.message_id;
237
- },
238
- editText: async (sessionId, messageId, text) => {
239
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
240
- throw new Error("Bot context missing for tool stream edit");
241
- }
242
- const currentSession = getCurrentSession();
243
- if (!currentSession || currentSession.id !== sessionId) {
244
- throw new Error(`Tool stream session mismatch for edit: ${sessionId}`);
245
- }
246
- try {
247
- await botInstance.api.editMessageText(chatIdInstance, messageId, text);
248
- }
249
- catch (error) {
250
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
251
- if (errorMessage.includes("message is not modified")) {
252
- return;
253
- }
254
- throw error;
255
- }
256
- },
257
- deleteText: async (sessionId, messageId) => {
258
- if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
259
- throw new Error("Bot context missing for tool stream delete");
260
- }
261
- const currentSession = getCurrentSession();
262
- if (!currentSession || currentSession.id !== sessionId) {
263
- throw new Error(`Tool stream session mismatch for delete: ${sessionId}`);
264
- }
265
- await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
266
- const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
267
- if (errorMessage.includes("message to delete not found") ||
268
- errorMessage.includes("message identifier is not specified")) {
269
- return;
270
- }
271
- throw error;
272
- });
273
- },
274
- });
275
- function getToolStreamKey(tool) {
276
- if (tool === "todowrite") {
277
- return "todo";
278
- }
279
- return "default";
280
- }
281
- function formatShortSessionId(sessionId) {
282
- return sessionId.length <= 8 ? sessionId : sessionId.slice(0, 8);
283
- }
284
- function getBackgroundSessionLabel(notification) {
285
- const title = notification.sessionTitle?.trim();
286
- if (title) {
287
- return title;
288
- }
289
- return t("background.session_fallback", { id: formatShortSessionId(notification.sessionId) });
290
- }
291
- function formatBackgroundSessionNotification(notification) {
292
- const session = getBackgroundSessionLabel(notification);
293
- switch (notification.kind) {
294
- case "assistant_response":
295
- return t("background.assistant_response", { session });
296
- case "question_asked":
297
- return t("background.question_asked", { session });
298
- case "permission_asked":
299
- return t("background.permission_asked", { session });
300
- }
301
- }
302
- async function deliverBackgroundSessionNotification(notification) {
303
- if (!botInstance || !chatIdInstance) {
304
- return;
305
- }
306
- await botInstance.api.sendMessage(chatIdInstance, formatBackgroundSessionNotification(notification), {
307
- reply_markup: buildBackgroundSessionOpenKeyboard(notification.sessionId, notification.kind),
308
- });
309
- }
310
- function getEventSessionId(event) {
311
- const properties = event.properties;
312
- return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
313
- }
314
- function shouldMarkAttachedBusyFromEvent(event) {
315
- switch (event.type) {
316
- case "session.status":
317
- return event.properties.status?.type === "busy";
318
- case "message.updated": {
319
- const info = event.properties
320
- .info;
321
- return info?.role === "assistant" && !info.time?.completed;
322
- }
323
- case "message.part.updated":
324
- case "message.part.delta":
325
- case "question.asked":
326
- case "permission.asked":
327
- return true;
328
- default:
329
- return false;
330
- }
331
- }
332
- async function ensureCommandsInitialized(ctx, next) {
333
- if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
334
- await next();
335
- return;
336
- }
337
- if (!ctx.chat) {
338
- logger.warn("[Bot] Cannot initialize commands: chat context is missing");
339
- await next();
340
- return;
341
- }
342
- try {
343
- await ctx.api.setMyCommands(BOT_COMMANDS, {
344
- scope: {
345
- type: "chat",
346
- chat_id: ctx.chat.id,
347
- },
348
- });
349
- commandsInitialized = true;
350
- logger.debug(`[Bot] Commands initialized for authorized user (chat_id=${ctx.chat.id})`);
351
- }
352
- catch (err) {
353
- logger.error("[Bot] Failed to set commands:", err);
354
- }
355
- await next();
356
- }
357
- async function ensureEventSubscription(directory) {
358
- if (!directory) {
359
- logger.error("No directory found for event subscription");
360
- return;
361
- }
362
- summaryAggregator.setTypingIndicatorEnabled(true);
363
- backgroundSessionTracker.setDirectory(directory);
364
- backgroundSessionTracker.setOnNotification(deliverBackgroundSessionNotification);
365
- if (!config.bot.trackBackgroundSessions) {
366
- backgroundSessionTracker.clear();
367
- }
368
- summaryAggregator.setOnCleared(() => {
369
- toolMessageBatcher.clearAll("summary_aggregator_clear");
370
- toolCallStreamer.clearAll("summary_aggregator_clear");
371
- responseStreamer.clearAll("summary_aggregator_clear");
372
- });
373
- summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
374
- if (!botInstance || !chatIdInstance) {
375
- return;
376
- }
377
- const currentSession = getCurrentSession();
378
- if (!currentSession || currentSession.id !== sessionId) {
379
- return;
380
- }
381
- const preparedStreamPayload = prepareStreamingPayload(messageText);
382
- if (!preparedStreamPayload) {
383
- return;
384
- }
385
- // Reply keyboards make the first streamed message non-editable in Telegram,
386
- // so partial chunks must be sent without reply_markup and finalized later.
387
- preparedStreamPayload.sendOptions = { disable_notification: true };
388
- preparedStreamPayload.editOptions = undefined;
389
- responseStreamer.enqueue(sessionId, messageId, preparedStreamPayload);
390
- });
391
- summaryAggregator.setOnComplete((sessionId, messageId, messageText, completionInfo) => {
392
- void enqueueSessionCompletionTask(sessionId, async () => {
393
- if (!botInstance || !chatIdInstance) {
394
- logger.error("Bot or chat ID not available for sending message");
395
- clearPromptResponseMode(sessionId);
396
- responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
397
- toolCallStreamer.clearSession(sessionId, "bot_context_missing");
398
- assistantRunState.clearRun(sessionId, "bot_context_missing");
399
- foregroundSessionState.markIdle(sessionId);
400
- return;
401
- }
402
- const currentSession = getCurrentSession();
403
- if (currentSession?.id !== sessionId) {
404
- clearPromptResponseMode(sessionId);
405
- responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
406
- toolCallStreamer.clearSession(sessionId, "session_mismatch");
407
- assistantRunState.clearRun(sessionId, "session_mismatch");
408
- foregroundSessionState.markIdle(sessionId);
409
- await scheduledTaskRuntime.flushDeferredDeliveries();
410
- return;
411
- }
412
- const botApi = botInstance.api;
413
- const chatId = chatIdInstance;
414
- try {
415
- assistantRunState.markResponseCompleted(sessionId, {
416
- agent: completionInfo.agent,
417
- providerID: completionInfo.providerID,
418
- modelID: completionInfo.modelID,
419
- });
420
- await finalizeAssistantResponse({
421
- sessionId,
422
- messageId,
423
- messageText,
424
- responseStreamer,
425
- flushPendingServiceMessages: () => Promise.all([
426
- toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
427
- toolCallStreamer.breakSession(sessionId, "assistant_message_completed"),
428
- ]).then(() => undefined),
429
- prepareStreamingPayload: prepareFinalStreamingPayload,
430
- renderFinalParts: (text) => renderAssistantFinalPartsSafe(text),
431
- getReplyKeyboard: getCurrentReplyKeyboard,
432
- sendRenderedPart: async (part, options) => {
433
- await sendRenderedBotPart({
434
- api: botApi,
435
- chatId,
436
- part,
437
- options: options,
438
- });
439
- },
440
- });
441
- await sendTtsResponseForSession({
442
- api: botApi,
443
- sessionId,
444
- chatId,
445
- text: messageText,
446
- });
447
- }
448
- catch (err) {
449
- clearPromptResponseMode(sessionId);
450
- assistantRunState.clearRun(sessionId, "assistant_finalize_failed");
451
- logger.error("Failed to send message to Telegram:", err);
452
- // Stop processing events after critical error to prevent infinite loop
453
- logger.error("[Bot] CRITICAL: Stopping event processing due to error");
454
- summaryAggregator.clear();
455
- foregroundSessionState.markIdle(sessionId);
456
- }
457
- finally {
458
- await scheduledTaskRuntime.flushDeferredDeliveries();
459
- }
460
- });
461
- });
462
- summaryAggregator.setOnExternalUserInput(async (sessionId, _messageId, messageText) => {
463
- void enqueueSessionCompletionTask(sessionId, async () => {
464
- if (!botInstance || !chatIdInstance) {
465
- return;
466
- }
467
- try {
468
- await deliverExternalUserInputNotification({
469
- api: botInstance.api,
470
- chatId: chatIdInstance,
471
- currentSessionId: getCurrentSession()?.id ?? null,
472
- sessionId,
473
- text: messageText,
474
- consumeSuppressedInput: (incomingSessionId, incomingText) => externalUserInputSuppressionManager.consume(incomingSessionId, incomingText),
475
- });
476
- }
477
- catch (err) {
478
- logger.error("[Bot] Failed to deliver external user input to Telegram:", err);
479
- }
480
- });
481
- });
482
- summaryAggregator.setOnTool(async (toolInfo) => {
483
- if (!botInstance || !chatIdInstance) {
484
- logger.error("Bot or chat ID not available for sending tool notification");
485
- return;
486
- }
487
- const currentSession = getCurrentSession();
488
- if (!currentSession || currentSession.id !== toolInfo.sessionId) {
489
- return;
490
- }
491
- const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
492
- (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
493
- if (config.bot.hideToolCallMessages ||
494
- shouldIncludeToolInfoInFileCaption ||
495
- toolInfo.tool === "task") {
496
- return;
497
- }
498
- try {
499
- const message = formatToolInfo(toolInfo);
500
- if (message) {
501
- toolCallStreamer.append(toolInfo.sessionId, message, getToolStreamKey(toolInfo.tool));
502
- }
503
- }
504
- catch (err) {
505
- logger.error("Failed to send tool notification to Telegram:", err);
506
- }
507
- });
508
- summaryAggregator.setOnSubagent(async (sessionId, subagents) => {
509
- if (!botInstance || !chatIdInstance) {
510
- return;
511
- }
512
- if (config.bot.hideToolCallMessages) {
513
- return;
514
- }
515
- const currentSession = getCurrentSession();
516
- if (!currentSession || currentSession.id !== sessionId) {
517
- return;
518
- }
519
- try {
520
- const renderedCards = await renderSubagentCards(subagents);
521
- if (!renderedCards) {
522
- return;
523
- }
524
- toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards, "subagent");
525
- }
526
- catch (err) {
527
- logger.error("Failed to render subagent activity for Telegram:", err);
528
- }
529
- });
530
- summaryAggregator.setOnToolFile(async (fileInfo) => {
531
- if (!botInstance || !chatIdInstance) {
532
- logger.error("Bot or chat ID not available for sending file");
533
- return;
534
- }
535
- const currentSession = getCurrentSession();
536
- if (!currentSession || currentSession.id !== fileInfo.sessionId) {
537
- return;
538
- }
539
- if (config.bot.hideToolFileMessages) {
540
- return;
541
- }
542
- try {
543
- await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
544
- const toolMessage = formatToolInfo(fileInfo);
545
- const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
546
- toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
547
- ...fileInfo.fileData,
548
- caption,
549
- });
550
- }
551
- catch (err) {
552
- logger.error("Failed to send file to Telegram:", err);
553
- }
554
- });
555
- summaryAggregator.setOnQuestion(async (questions, requestID, sessionId) => {
556
- if (!botInstance || !chatIdInstance) {
557
- logger.error("Bot or chat ID not available for showing questions");
558
- return;
559
- }
560
- const currentSession = getCurrentSession();
561
- if (!currentSession || currentSession.id !== sessionId) {
562
- return;
563
- }
564
- await Promise.all([
565
- toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
566
- toolCallStreamer.flushSession(currentSession.id, "question_asked"),
567
- ]);
568
- if (questionManager.isActive()) {
569
- logger.warn("[Bot] Replacing active poll with a new one");
570
- const previousMessageIds = questionManager.getMessageIds();
571
- for (const messageId of previousMessageIds) {
572
- await botInstance.api.deleteMessage(chatIdInstance, messageId).catch(() => { });
573
- }
574
- clearAllInteractionState("question_replaced_by_new_poll");
575
- }
576
- logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
577
- questionManager.startQuestions(questions, requestID);
578
- await showCurrentQuestion(botInstance.api, chatIdInstance);
579
- });
580
- summaryAggregator.setOnQuestionError(async () => {
581
- logger.info(`[Bot] Question tool failed, clearing active poll and deleting messages`);
582
- // Delete all messages from the invalid poll
583
- const messageIds = questionManager.getMessageIds();
584
- for (const messageId of messageIds) {
585
- if (chatIdInstance) {
586
- await botInstance?.api.deleteMessage(chatIdInstance, messageId).catch((err) => {
587
- logger.error(`[Bot] Failed to delete question message ${messageId}:`, err);
588
- });
589
- }
590
- }
591
- clearAllInteractionState("question_error");
592
- });
593
- summaryAggregator.setOnPermission(async (request) => {
594
- if (!botInstance || !chatIdInstance) {
595
- logger.error("Bot or chat ID not available for showing permission request");
596
- return;
597
- }
598
- const currentSession = getCurrentSession();
599
- const isCurrent = currentSession?.id === request.sessionID;
600
- const isSubagent = summaryAggregator.isSubagentSession(request.sessionID);
601
- if (!currentSession || (!isCurrent && !isSubagent)) {
602
- return;
603
- }
604
- await Promise.all([
605
- toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
606
- toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
607
- ]);
608
- logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}, subagent=${isSubagent}`);
609
- await showPermissionRequest(botInstance.api, chatIdInstance, request);
610
- });
611
- summaryAggregator.setOnThinking(async (sessionId) => {
612
- if (!botInstance || !chatIdInstance) {
613
- return;
614
- }
615
- const currentSession = getCurrentSession();
616
- if (!currentSession || currentSession.id !== sessionId) {
617
- return;
618
- }
619
- logger.debug("[Bot] Agent started thinking");
620
- await toolCallStreamer.breakSession(sessionId, "thinking_started");
621
- deliverThinkingMessage(sessionId, toolMessageBatcher, {
622
- hideThinkingMessages: config.bot.hideThinkingMessages,
623
- });
624
- // Refresh pinned message so it shows the latest in-memory context
625
- // (accumulated from silent token updates). 1 API call per thinking event.
626
- if (pinnedMessageManager.isInitialized()) {
627
- await pinnedMessageManager.refresh();
628
- }
629
- });
630
- summaryAggregator.setOnTokens(async (tokens, isCompleted) => {
631
- if (!pinnedMessageManager.isInitialized()) {
632
- return;
633
- }
634
- try {
635
- logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}, completed=${isCompleted}`);
636
- const contextSize = tokens.input + tokens.cacheRead;
637
- const contextLimit = pinnedMessageManager.getContextLimit();
638
- // Skip non-completed messages with zero context: a new assistant message
639
- // starts with tokens={input:0, ...} which would overwrite valid context
640
- // from the previous step. Only accept zeros from completed messages.
641
- if (!isCompleted && contextSize === 0) {
642
- logger.debug("[Bot] Skipping zero-token intermediate update");
643
- return;
644
- }
645
- // Update both keyboard and pinned state in memory (keeps them in sync)
646
- if (contextLimit > 0) {
647
- keyboardManager.updateContext(contextSize, contextLimit);
648
- }
649
- pinnedMessageManager.updateTokensSilent(tokens);
650
- // Full pinned message update (API call) only on completed messages
651
- if (isCompleted) {
652
- await pinnedMessageManager.onMessageComplete(tokens);
653
- }
654
- }
655
- catch (err) {
656
- logger.error("[Bot] Error updating pinned message with tokens:", err);
657
- }
658
- });
659
- summaryAggregator.setOnCost(async (cost) => {
660
- if (!pinnedMessageManager.isInitialized()) {
661
- return;
662
- }
663
- try {
664
- logger.debug(`[Bot] Cost update: $${cost.toFixed(2)}`);
665
- await pinnedMessageManager.onCostUpdate(cost);
666
- }
667
- catch (err) {
668
- logger.error("[Bot] Error updating cost:", err);
669
- }
670
- });
671
- summaryAggregator.setOnSessionCompacted(async (sessionId, directory) => {
672
- if (!pinnedMessageManager.isInitialized()) {
673
- return;
674
- }
675
- try {
676
- logger.info(`[Bot] Session compacted, reloading context: ${sessionId}`);
677
- await pinnedMessageManager.onSessionCompacted(sessionId, directory);
678
- }
679
- catch (err) {
680
- logger.error("[Bot] Error reloading context after compaction:", err);
681
- }
682
- });
683
- summaryAggregator.setOnSessionIdle(async (sessionId) => {
684
- await markAttachedSessionIdle(sessionId);
685
- await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
686
- const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
687
- clearPromptResponseMode(sessionId);
688
- if (!botInstance || !chatIdInstance) {
689
- foregroundSessionState.markIdle(sessionId);
690
- return;
691
- }
692
- const currentSession = getCurrentSession();
693
- if (!currentSession || currentSession.id !== sessionId) {
694
- foregroundSessionState.markIdle(sessionId);
695
- await scheduledTaskRuntime.flushDeferredDeliveries();
696
- return;
697
- }
698
- try {
699
- await Promise.all([
700
- toolMessageBatcher.flushSession(sessionId, "session_idle"),
701
- toolCallStreamer.flushSession(sessionId, "session_idle"),
702
- ]);
703
- if (completedRun?.hasCompletedResponse) {
704
- const agent = completedRun.actualAgent || completedRun.configuredAgent;
705
- const providerID = completedRun.actualProviderID || completedRun.configuredProviderID;
706
- const modelID = completedRun.actualModelID || completedRun.configuredModelID;
707
- if (agent && providerID && modelID) {
708
- const keyboard = getCurrentReplyKeyboard();
709
- await botInstance.api.sendMessage(chatIdInstance, formatAssistantRunFooter({
710
- agent,
711
- providerID,
712
- modelID,
713
- elapsedMs: Date.now() - completedRun.startedAt,
714
- }), {
715
- ...(keyboard ? { reply_markup: keyboard } : {}),
716
- });
717
- }
718
- }
719
- }
720
- catch (err) {
721
- logger.error("[Bot] Failed to send session idle footer:", err);
722
- }
723
- finally {
724
- foregroundSessionState.markIdle(sessionId);
725
- await scheduledTaskRuntime.flushDeferredDeliveries();
726
- }
727
- });
728
- summaryAggregator.setOnSessionError(async (sessionId, message) => {
729
- await markAttachedSessionIdle(sessionId);
730
- if (!botInstance || !chatIdInstance) {
731
- clearPromptResponseMode(sessionId);
732
- assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
733
- foregroundSessionState.markIdle(sessionId);
734
- return;
735
- }
736
- const currentSession = getCurrentSession();
737
- if (!currentSession || currentSession.id !== sessionId) {
738
- clearPromptResponseMode(sessionId);
739
- responseStreamer.clearSession(sessionId, "session_error_not_current");
740
- toolCallStreamer.clearSession(sessionId, "session_error_not_current");
741
- assistantRunState.clearRun(sessionId, "session_error_not_current");
742
- foregroundSessionState.markIdle(sessionId);
743
- await scheduledTaskRuntime.flushDeferredDeliveries();
744
- return;
745
- }
746
- responseStreamer.clearSession(sessionId, "session_error");
747
- clearPromptResponseMode(sessionId);
748
- assistantRunState.clearRun(sessionId, "session_error");
749
- await Promise.all([
750
- toolMessageBatcher.flushSession(sessionId, "session_error"),
751
- toolCallStreamer.flushSession(sessionId, "session_error"),
752
- ]);
753
- const normalizedMessage = message.trim() || t("common.unknown_error");
754
- if (shouldSuppressUserAbortSessionError(sessionId, normalizedMessage)) {
755
- logger.debug(`[Bot] Suppressed user-initiated abort error: session=${sessionId}`);
756
- foregroundSessionState.markIdle(sessionId);
757
- await scheduledTaskRuntime.flushDeferredDeliveries();
758
- return;
759
- }
760
- const truncatedMessage = normalizedMessage.length > 3500
761
- ? `${normalizedMessage.slice(0, 3497)}...`
762
- : normalizedMessage;
763
- await botInstance.api
764
- .sendMessage(chatIdInstance, t("bot.session_error", { message: truncatedMessage }))
765
- .catch((err) => {
766
- logger.error("[Bot] Failed to send session.error message:", err);
767
- });
768
- foregroundSessionState.markIdle(sessionId);
769
- await scheduledTaskRuntime.flushDeferredDeliveries();
770
- });
771
- summaryAggregator.setOnSessionRetry(async ({ sessionId, message }) => {
772
- if (!botInstance || !chatIdInstance) {
773
- return;
774
- }
775
- const currentSession = getCurrentSession();
776
- if (!currentSession || currentSession.id !== sessionId) {
777
- return;
778
- }
779
- const normalizedMessage = message.trim() || t("common.unknown_error");
780
- const truncatedMessage = normalizedMessage.length > 3500
781
- ? `${normalizedMessage.slice(0, 3497)}...`
782
- : normalizedMessage;
783
- const retryMessage = t("bot.session_retry", { message: truncatedMessage });
784
- toolCallStreamer.replaceByPrefix(sessionId, SESSION_RETRY_PREFIX, retryMessage);
785
- });
786
- summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
787
- if (!pinnedMessageManager.isInitialized()) {
788
- return;
789
- }
790
- try {
791
- await pinnedMessageManager.onSessionDiff(diffs);
792
- }
793
- catch (err) {
794
- logger.error("[Bot] Error updating session diff:", err);
795
- }
796
- });
797
- summaryAggregator.setOnFileChange((change) => {
798
- if (!pinnedMessageManager.isInitialized()) {
799
- return;
800
- }
801
- pinnedMessageManager.addFileChange(change);
802
- });
803
- pinnedMessageManager.setOnKeyboardUpdate(async (tokensUsed, tokensLimit) => {
804
- try {
805
- logger.debug(`[Bot] Updating keyboard with context: ${tokensUsed}/${tokensLimit}`);
806
- keyboardManager.updateContext(tokensUsed, tokensLimit);
807
- // Don't send automatic keyboard updates - keyboard will update naturally with user messages
808
- }
809
- catch (err) {
810
- logger.error("[Bot] Error updating keyboard context:", err);
811
- }
812
- });
813
- logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
814
- subscribeToEvents(directory, (event) => {
815
- if (event.type === "server.heartbeat") {
816
- void reconcileBusyState(directory);
817
- }
818
- const attached = attachManager.getSnapshot();
819
- const eventSessionId = getEventSessionId(event);
820
- if (attached &&
821
- eventSessionId === attached.sessionId &&
822
- shouldMarkAttachedBusyFromEvent(event)) {
823
- void markAttachedSessionBusy(attached.sessionId);
824
- }
825
- if (event.type === "session.created" || event.type === "session.updated") {
826
- const info = event.properties.info;
827
- if (info?.directory) {
828
- safeBackgroundTask({
829
- taskName: `session.cache.${event.type}`,
830
- task: () => ingestSessionInfoForCache(info),
831
- });
832
- }
833
- }
834
- if (config.bot.trackBackgroundSessions) {
835
- backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null);
836
- }
837
- summaryAggregator.processEvent(event);
838
- }).catch((err) => {
839
- logger.error("Failed to subscribe to events:", err);
840
- });
841
- }
21
+ const eventSubscriptionService = createEventSubscriptionService();
842
22
  export function createBot() {
843
23
  clearAllInteractionState("bot_startup");
844
- sessionCompletionTasks.clear();
845
24
  attachManager.clear("bot_startup");
846
- assistantRunState.clearAll("bot_startup");
847
- backgroundSessionTracker.clear();
25
+ eventSubscriptionService.clearRuntimeState("bot_startup");
848
26
  if (heartbeatTimer) {
849
27
  clearInterval(heartbeatTimer);
850
28
  heartbeatTimer = null;
851
29
  }
852
30
  const botOptions = createTelegramBotOptions(config.telegram);
853
31
  const bot = new Bot(config.telegram.token, botOptions);
854
- botInstance = bot;
855
- chatIdInstance = config.telegram.allowedUserId;
32
+ configureAttachPresentation(createAttachPresentation());
33
+ eventSubscriptionService.setTelegramContext(bot, config.telegram.allowedUserId);
856
34
  unsubscribeReadyRestore?.();
857
35
  unsubscribeReadyRestore = opencodeReadyLifecycle.onReady(async (reason) => {
858
36
  const restored = await restoreAttachedCurrentSession({
859
37
  bot,
860
38
  chatId: config.telegram.allowedUserId,
861
- ensureEventSubscription,
39
+ ensureEventSubscription: eventSubscriptionService.ensureEventSubscription,
862
40
  forceFullRestore: true,
863
41
  });
864
42
  if (restored) {
@@ -867,20 +45,17 @@ export function createBot() {
867
45
  }
868
46
  const currentProject = getCurrentProject();
869
47
  if (config.bot.trackBackgroundSessions && currentProject?.worktree) {
870
- await ensureEventSubscription(currentProject.worktree);
48
+ await eventSubscriptionService.ensureEventSubscription(currentProject.worktree);
871
49
  logger.info(`[Bot] Started background session tracking after OpenCode ready: reason=${reason}, directory=${currentProject.worktree}`);
872
50
  }
873
51
  });
874
- // Heartbeat for diagnostics: verify the event loop is not blocked
875
52
  let heartbeatCounter = 0;
876
53
  heartbeatTimer = setInterval(() => {
877
54
  heartbeatCounter++;
878
55
  if (heartbeatCounter % 6 === 0) {
879
- // Log every 30 seconds (5 sec * 6)
880
56
  logger.debug(`[Bot] Heartbeat #${heartbeatCounter} - event loop alive`);
881
57
  }
882
58
  }, 5000);
883
- // Log all API calls for diagnostics
884
59
  let lastGetUpdatesTime = Date.now();
885
60
  bot.api.config.use(async (prev, method, payload, signal) => {
886
61
  if (method === "getUpdates") {
@@ -910,175 +85,17 @@ export function createBot() {
910
85
  bot.use(authMiddleware);
911
86
  bot.use(ensureCommandsInitialized);
912
87
  bot.use(interactionGuardMiddleware);
913
- const blockMenuWhileInteractionActive = async (ctx) => {
914
- const activeInteraction = interactionManager.getSnapshot();
915
- if (!activeInteraction) {
916
- return false;
917
- }
918
- logger.debug(`[Bot] Blocking menu open while interaction active: kind=${activeInteraction.kind}, expectedInput=${activeInteraction.expectedInput}`);
919
- await ctx.reply(t("interaction.blocked.finish_current"));
920
- return true;
921
- };
922
- bot.command("start", startCommand);
923
- bot.command("help", helpCommand);
924
- bot.command("status", statusCommand);
925
- bot.command("tts", ttsCommand);
926
- bot.command("opencode_start", opencodeStartCommand);
927
- bot.command("opencode_stop", opencodeStopCommand);
928
- bot.command("projects", projectsCommand);
929
- bot.command("worktree", worktreeCommand);
930
- bot.command("open", openCommand);
931
- bot.command("ls", lsCommand);
932
- bot.command("sessions", sessionsCommand);
933
- bot.command("messages", messagesCommand);
934
- bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
935
- bot.command("abort", abortCommand);
936
- bot.command("detach", detachCommand);
937
- bot.command("task", taskCommand);
938
- bot.command("tasklist", taskListCommand);
939
- bot.command("rename", renameCommand);
940
- bot.command("commands", commandsCommand);
941
- bot.command("skills", skillsCommand);
942
- bot.command("mcps", mcpsCommand);
943
- bot.on("message:text", unknownCommandMiddleware);
944
- bot.on("callback_query:data", async (ctx) => {
945
- logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
946
- logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
947
- if (ctx.chat) {
948
- botInstance = bot;
949
- chatIdInstance = ctx.chat.id;
950
- }
951
- try {
952
- const handledBackgroundSession = await handleBackgroundSessionOpen(ctx, {
953
- bot,
954
- ensureEventSubscription,
955
- });
956
- const handledInlineCancel = await handleInlineMenuCancel(ctx);
957
- if (handledInlineCancel) {
958
- // Clean up path index when the open-directory menu is cancelled
959
- clearOpenPathIndex();
960
- clearLsPathIndex();
961
- }
962
- const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
963
- const handledProject = await handleProjectSelect(ctx, { ensureEventSubscription });
964
- const handledWorktree = await handleWorktreeCallback(ctx, { ensureEventSubscription });
965
- const handledOpen = await handleOpenCallback(ctx, { ensureEventSubscription });
966
- const handledLs = await handleLsCallback(ctx);
967
- const handledQuestion = await handleQuestionCallback(ctx);
968
- const handledPermission = await handlePermissionCallback(ctx);
969
- const handledAgent = await handleAgentSelect(ctx);
970
- const handledModelSearch = await handleModelSearchCallback(ctx);
971
- const handledModelSearchResults = await handleModelSearchResults(ctx);
972
- const handledModel = await handleModelSelect(ctx);
973
- const handledVariant = await handleVariantSelect(ctx);
974
- const handledCompactConfirm = await handleCompactConfirm(ctx);
975
- const handledTask = await handleTaskCallback(ctx);
976
- const handledTaskList = await handleTaskListCallback(ctx);
977
- const handledRenameCancel = await handleRenameCancel(ctx);
978
- const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
979
- const handledMessages = await handleMessagesCallback(ctx, { bot, ensureEventSubscription });
980
- const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
981
- const handledMcps = await handleMcpsCallback(ctx);
982
- logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
983
- if (!handledBackgroundSession &&
984
- !handledInlineCancel &&
985
- !handledSession &&
986
- !handledProject &&
987
- !handledWorktree &&
988
- !handledOpen &&
989
- !handledLs &&
990
- !handledQuestion &&
991
- !handledPermission &&
992
- !handledAgent &&
993
- !handledModelSearch &&
994
- !handledModelSearchResults &&
995
- !handledModel &&
996
- !handledVariant &&
997
- !handledCompactConfirm &&
998
- !handledTask &&
999
- !handledTaskList &&
1000
- !handledRenameCancel &&
1001
- !handledCommands &&
1002
- !handledMessages &&
1003
- !handledSkills &&
1004
- !handledMcps) {
1005
- logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
1006
- await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
1007
- }
1008
- }
1009
- catch (err) {
1010
- logger.error("[Bot] Error handling callback:", err);
1011
- clearAllInteractionState("callback_handler_error");
1012
- await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
1013
- }
1014
- });
1015
- // Handle Reply Keyboard button press (agent indicator)
1016
- bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
1017
- logger.debug(`[Bot] Agent button pressed: ${ctx.message?.text}`);
1018
- try {
1019
- if (await blockMenuWhileInteractionActive(ctx)) {
1020
- return;
1021
- }
1022
- await showAgentSelectionMenu(ctx);
1023
- }
1024
- catch (err) {
1025
- logger.error("[Bot] Error showing agent menu:", err);
1026
- await ctx.reply(t("error.load_agents"));
1027
- }
88
+ registerCommandRouter(bot, {
89
+ ensureEventSubscription: eventSubscriptionService.ensureEventSubscription,
1028
90
  });
1029
- // Handle Reply Keyboard button press (model selector)
1030
- // Model button text is produced by formatModelForButton() and always starts with "🤖 ".
1031
- bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
1032
- logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
1033
- try {
1034
- if (await blockMenuWhileInteractionActive(ctx)) {
1035
- return;
1036
- }
1037
- await showModelSelectionMenu(ctx);
1038
- }
1039
- catch (err) {
1040
- logger.error("[Bot] Error showing model menu:", err);
1041
- await ctx.reply(t("error.load_models"));
1042
- }
91
+ registerCallbackRouter(bot, {
92
+ ensureEventSubscription: eventSubscriptionService.ensureEventSubscription,
93
+ setTelegramContext: eventSubscriptionService.setTelegramContext,
1043
94
  });
1044
- // Handle Reply Keyboard button press (context button)
1045
- bot.hears(/^📊(?:\s|$)/, async (ctx) => {
1046
- logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
1047
- try {
1048
- if (await blockMenuWhileInteractionActive(ctx)) {
1049
- return;
1050
- }
1051
- await handleContextButtonPress(ctx);
1052
- }
1053
- catch (err) {
1054
- logger.error("[Bot] Error handling context button:", err);
1055
- await ctx.reply(t("error.context_button"));
1056
- }
95
+ registerMessageRouter(bot, {
96
+ ensureEventSubscription: eventSubscriptionService.ensureEventSubscription,
97
+ setTelegramContext: eventSubscriptionService.setTelegramContext,
1057
98
  });
1058
- // Handle Reply Keyboard button press (variant selector)
1059
- // Keep support for both legacy "💭" and current "💡" prefix.
1060
- bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
1061
- logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
1062
- try {
1063
- if (await blockMenuWhileInteractionActive(ctx)) {
1064
- return;
1065
- }
1066
- await showVariantSelectionMenu(ctx);
1067
- }
1068
- catch (err) {
1069
- logger.error("[Bot] Error showing variant menu:", err);
1070
- await ctx.reply(t("error.load_variants"));
1071
- }
1072
- });
1073
- bot.on("message:text", async (ctx, next) => {
1074
- const text = ctx.message?.text;
1075
- if (text) {
1076
- const isCommand = text.startsWith("/");
1077
- logger.debug(`[Bot] Received text message: ${isCommand ? `command="${text}"` : `prompt (length=${text.length})`}, chatId=${ctx.chat.id}`);
1078
- }
1079
- await next();
1080
- });
1081
- // Remove any previously set global commands to prevent unauthorized users from seeing them
1082
99
  safeBackgroundTask({
1083
100
  taskName: "bot.clearGlobalCommands",
1084
101
  task: async () => {
@@ -1101,117 +118,6 @@ export function createBot() {
1101
118
  logger.warn("[Bot] Could not clear global commands:", result.error);
1102
119
  },
1103
120
  });
1104
- // Voice and audio message handlers (STT transcription -> prompt)
1105
- const voicePromptDeps = { bot, ensureEventSubscription };
1106
- bot.on("message:voice", async (ctx) => {
1107
- logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
1108
- botInstance = bot;
1109
- chatIdInstance = ctx.chat.id;
1110
- await handleVoiceMessage(ctx, voicePromptDeps);
1111
- });
1112
- bot.on("message:audio", async (ctx) => {
1113
- logger.debug(`[Bot] Received audio message, chatId=${ctx.chat.id}`);
1114
- botInstance = bot;
1115
- chatIdInstance = ctx.chat.id;
1116
- await handleVoiceMessage(ctx, voicePromptDeps);
1117
- });
1118
- bot.on("message", createMediaGroupAttachmentMiddleware({ bot, ensureEventSubscription }));
1119
- // Photo message handler
1120
- bot.on("message:photo", async (ctx) => {
1121
- logger.debug(`[Bot] Received photo message, chatId=${ctx.chat.id}`);
1122
- const photos = ctx.message?.photo;
1123
- if (!photos || photos.length === 0) {
1124
- return;
1125
- }
1126
- const caption = ctx.message.caption || "";
1127
- try {
1128
- // Get the largest photo (last element in array)
1129
- const largestPhoto = photos[photos.length - 1];
1130
- // Check model capabilities
1131
- const storedModel = getStoredModel();
1132
- const capabilities = await getModelCapabilities(storedModel.providerID, storedModel.modelID);
1133
- if (!supportsInput(capabilities, "image")) {
1134
- logger.warn(`[Bot] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support image input`);
1135
- await ctx.reply(t("bot.photo_model_no_image"));
1136
- // Fall back to caption-only if present
1137
- if (caption.trim().length > 0) {
1138
- botInstance = bot;
1139
- chatIdInstance = ctx.chat.id;
1140
- const promptDeps = { bot, ensureEventSubscription };
1141
- await processUserPrompt(ctx, caption, promptDeps);
1142
- }
1143
- return;
1144
- }
1145
- // Download photo
1146
- await ctx.reply(t("bot.photo_downloading"));
1147
- const downloadedFile = await downloadTelegramFile(ctx.api, largestPhoto.file_id);
1148
- // Convert to data URI (Telegram always converts photos to JPEG)
1149
- const dataUri = toDataUri(downloadedFile.buffer, "image/jpeg");
1150
- // Create file part
1151
- const filePart = {
1152
- type: "file",
1153
- mime: "image/jpeg",
1154
- filename: "photo.jpg",
1155
- url: dataUri,
1156
- };
1157
- logger.info(`[Bot] Sending photo (${downloadedFile.buffer.length} bytes) with prompt`);
1158
- botInstance = bot;
1159
- chatIdInstance = ctx.chat.id;
1160
- // Send via processUserPrompt with file part
1161
- const promptDeps = { bot, ensureEventSubscription };
1162
- await processUserPrompt(ctx, caption, promptDeps, [filePart]);
1163
- }
1164
- catch (err) {
1165
- logger.error("[Bot] Error handling photo message:", err);
1166
- await ctx.reply(t("bot.photo_download_error"));
1167
- }
1168
- });
1169
- // Document message handler (PDF and text files)
1170
- bot.on("message:document", async (ctx) => {
1171
- logger.debug(`[Bot] Received document message, chatId=${ctx.chat.id}`);
1172
- botInstance = bot;
1173
- chatIdInstance = ctx.chat.id;
1174
- const deps = { bot, ensureEventSubscription };
1175
- await handleDocumentMessage(ctx, deps);
1176
- });
1177
- bot.on("message:text", async (ctx) => {
1178
- const text = ctx.message?.text;
1179
- if (!text) {
1180
- return;
1181
- }
1182
- botInstance = bot;
1183
- chatIdInstance = ctx.chat.id;
1184
- if (text.startsWith("/")) {
1185
- return;
1186
- }
1187
- if (questionManager.isActive()) {
1188
- await handleQuestionTextAnswer(ctx);
1189
- return;
1190
- }
1191
- const handledTask = await handleTaskTextInput(ctx);
1192
- if (handledTask) {
1193
- return;
1194
- }
1195
- const handledModelSearchText = await handleModelSearchTextInput(ctx);
1196
- if (handledModelSearchText) {
1197
- return;
1198
- }
1199
- const handledRename = await handleRenameTextAnswer(ctx);
1200
- if (handledRename) {
1201
- return;
1202
- }
1203
- const promptDeps = { bot, ensureEventSubscription };
1204
- const handledCommandArgs = await handleCommandTextArguments(ctx, promptDeps);
1205
- if (handledCommandArgs) {
1206
- return;
1207
- }
1208
- const handledSkillArgs = await handleSkillTextArguments(ctx, promptDeps);
1209
- if (handledSkillArgs) {
1210
- return;
1211
- }
1212
- await processUserPrompt(ctx, text, promptDeps);
1213
- logger.debug("[Bot] message:text handler completed (prompt sent in background)");
1214
- });
1215
121
  bot.catch((err) => {
1216
122
  logger.error("[Bot] Unhandled error in bot:", err);
1217
123
  clearAllInteractionState("bot_unhandled_error");
@@ -1224,18 +130,9 @@ export function createBot() {
1224
130
  export function cleanupBotRuntime(reason) {
1225
131
  unsubscribeReadyRestore?.();
1226
132
  unsubscribeReadyRestore = null;
1227
- stopEventListening();
1228
- summaryAggregator.clear();
1229
- backgroundSessionTracker.clear();
1230
- responseStreamer.clearAll(reason);
1231
- toolCallStreamer.clearAll(reason);
1232
- toolMessageBatcher.clearAll(reason);
1233
- sessionCompletionTasks.clear();
1234
- assistantRunState.clearAll(reason);
133
+ eventSubscriptionService.cleanup(reason);
1235
134
  if (heartbeatTimer) {
1236
135
  clearInterval(heartbeatTimer);
1237
136
  heartbeatTimer = null;
1238
137
  }
1239
- botInstance = null;
1240
- chatIdInstance = null;
1241
138
  }