opencode-telegram-group-topics-bot 0.11.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 (101) hide show
  1. package/.env.example +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/agent/manager.js +60 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +47 -0
  7. package/dist/bot/commands/abort.js +116 -0
  8. package/dist/bot/commands/commands.js +389 -0
  9. package/dist/bot/commands/constants.js +20 -0
  10. package/dist/bot/commands/definitions.js +25 -0
  11. package/dist/bot/commands/help.js +27 -0
  12. package/dist/bot/commands/models.js +38 -0
  13. package/dist/bot/commands/new.js +247 -0
  14. package/dist/bot/commands/opencode-start.js +85 -0
  15. package/dist/bot/commands/opencode-stop.js +44 -0
  16. package/dist/bot/commands/projects.js +304 -0
  17. package/dist/bot/commands/rename.js +173 -0
  18. package/dist/bot/commands/sessions.js +491 -0
  19. package/dist/bot/commands/start.js +67 -0
  20. package/dist/bot/commands/status.js +138 -0
  21. package/dist/bot/constants.js +49 -0
  22. package/dist/bot/handlers/agent.js +127 -0
  23. package/dist/bot/handlers/context.js +125 -0
  24. package/dist/bot/handlers/document.js +65 -0
  25. package/dist/bot/handlers/inline-menu.js +124 -0
  26. package/dist/bot/handlers/model.js +152 -0
  27. package/dist/bot/handlers/permission.js +281 -0
  28. package/dist/bot/handlers/prompt.js +263 -0
  29. package/dist/bot/handlers/question.js +285 -0
  30. package/dist/bot/handlers/variant.js +147 -0
  31. package/dist/bot/handlers/voice.js +173 -0
  32. package/dist/bot/index.js +945 -0
  33. package/dist/bot/message-patterns.js +4 -0
  34. package/dist/bot/middleware/auth.js +30 -0
  35. package/dist/bot/middleware/interaction-guard.js +80 -0
  36. package/dist/bot/middleware/unknown-command.js +22 -0
  37. package/dist/bot/scope.js +222 -0
  38. package/dist/bot/telegram-constants.js +3 -0
  39. package/dist/bot/telegram-rate-limiter.js +263 -0
  40. package/dist/bot/utils/commands.js +21 -0
  41. package/dist/bot/utils/file-download.js +91 -0
  42. package/dist/bot/utils/keyboard.js +85 -0
  43. package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
  44. package/dist/bot/utils/session-error-filter.js +34 -0
  45. package/dist/bot/utils/topic-link.js +29 -0
  46. package/dist/cli/args.js +98 -0
  47. package/dist/cli.js +80 -0
  48. package/dist/config.js +103 -0
  49. package/dist/i18n/de.js +330 -0
  50. package/dist/i18n/en.js +330 -0
  51. package/dist/i18n/es.js +330 -0
  52. package/dist/i18n/index.js +102 -0
  53. package/dist/i18n/ru.js +330 -0
  54. package/dist/i18n/zh.js +330 -0
  55. package/dist/index.js +28 -0
  56. package/dist/interaction/cleanup.js +24 -0
  57. package/dist/interaction/constants.js +25 -0
  58. package/dist/interaction/guard.js +100 -0
  59. package/dist/interaction/manager.js +113 -0
  60. package/dist/interaction/types.js +1 -0
  61. package/dist/keyboard/manager.js +115 -0
  62. package/dist/keyboard/types.js +1 -0
  63. package/dist/model/capabilities.js +62 -0
  64. package/dist/model/manager.js +257 -0
  65. package/dist/model/types.js +24 -0
  66. package/dist/opencode/client.js +13 -0
  67. package/dist/opencode/events.js +159 -0
  68. package/dist/opencode/prompt-submit-error.js +101 -0
  69. package/dist/permission/manager.js +92 -0
  70. package/dist/permission/types.js +1 -0
  71. package/dist/pinned/manager.js +405 -0
  72. package/dist/pinned/types.js +1 -0
  73. package/dist/process/manager.js +273 -0
  74. package/dist/process/types.js +1 -0
  75. package/dist/project/manager.js +88 -0
  76. package/dist/question/manager.js +186 -0
  77. package/dist/question/types.js +1 -0
  78. package/dist/rename/manager.js +64 -0
  79. package/dist/runtime/bootstrap.js +350 -0
  80. package/dist/runtime/mode.js +74 -0
  81. package/dist/runtime/paths.js +37 -0
  82. package/dist/runtime/process-error-handlers.js +24 -0
  83. package/dist/session/cache-manager.js +455 -0
  84. package/dist/session/manager.js +87 -0
  85. package/dist/settings/manager.js +283 -0
  86. package/dist/stt/client.js +64 -0
  87. package/dist/summary/aggregator.js +625 -0
  88. package/dist/summary/formatter.js +417 -0
  89. package/dist/summary/tool-message-batcher.js +277 -0
  90. package/dist/topic/colors.js +8 -0
  91. package/dist/topic/constants.js +10 -0
  92. package/dist/topic/manager.js +161 -0
  93. package/dist/topic/title-constants.js +2 -0
  94. package/dist/topic/title-format.js +10 -0
  95. package/dist/topic/title-sync.js +17 -0
  96. package/dist/utils/error-format.js +29 -0
  97. package/dist/utils/logger.js +175 -0
  98. package/dist/utils/safe-background-task.js +33 -0
  99. package/dist/variant/manager.js +103 -0
  100. package/dist/variant/types.js +1 -0
  101. package/package.json +76 -0
@@ -0,0 +1,945 @@
1
+ import { Bot, InputFile } from "grammy";
2
+ import { promises as fs } from "fs";
3
+ import * as path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { SocksProxyAgent } from "socks-proxy-agent";
6
+ import { HttpsProxyAgent } from "https-proxy-agent";
7
+ import { config } from "../config.js";
8
+ import { authMiddleware } from "./middleware/auth.js";
9
+ import { interactionGuardMiddleware } from "./middleware/interaction-guard.js";
10
+ import { unknownCommandMiddleware } from "./middleware/unknown-command.js";
11
+ import { BOT_COMMANDS } from "./commands/definitions.js";
12
+ import { startCommand } from "./commands/start.js";
13
+ import { helpCommand } from "./commands/help.js";
14
+ import { statusCommand } from "./commands/status.js";
15
+ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN, } from "./message-patterns.js";
16
+ import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
17
+ import { createNewCommand } from "./commands/new.js";
18
+ import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
+ import { abortCommand } from "./commands/abort.js";
20
+ import { opencodeStartCommand } from "./commands/opencode-start.js";
21
+ import { opencodeStopCommand } from "./commands/opencode-stop.js";
22
+ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
23
+ import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
24
+ import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
25
+ import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
26
+ import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
27
+ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
28
+ import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
29
+ import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
30
+ import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
31
+ import { questionManager } from "../question/manager.js";
32
+ import { interactionManager } from "../interaction/manager.js";
33
+ import { clearAllInteractionState } from "../interaction/cleanup.js";
34
+ import { keyboardManager } from "../keyboard/manager.js";
35
+ import { subscribeToEvents } from "../opencode/events.js";
36
+ import { summaryAggregator } from "../summary/aggregator.js";
37
+ import { formatSummary, formatToolInfo, getAssistantParseMode } from "../summary/formatter.js";
38
+ import { ToolMessageBatcher } from "../summary/tool-message-batcher.js";
39
+ import { ingestSessionInfoForCache } from "../session/cache-manager.js";
40
+ import { logger } from "../utils/logger.js";
41
+ import { safeBackgroundTask } from "../utils/safe-background-task.js";
42
+ import { pinnedMessageManager } from "../pinned/manager.js";
43
+ import { t } from "../i18n/index.js";
44
+ import { processUserPrompt } from "./handlers/prompt.js";
45
+ import { handleVoiceMessage } from "./handlers/voice.js";
46
+ import { handleDocumentMessage } from "./handlers/document.js";
47
+ import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
48
+ import { sendMessageWithMarkdownFallback } from "./utils/send-with-markdown-fallback.js";
49
+ import { extractCommandName } from "./utils/commands.js";
50
+ import { isOperationAbortedSessionError, SessionErrorThrottle, } from "./utils/session-error-filter.js";
51
+ import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
52
+ import { getStoredModel } from "../model/manager.js";
53
+ import { getCurrentProject } from "../settings/manager.js";
54
+ import { GLOBAL_SCOPE_KEY, SCOPE_CONTEXT, getChatActionThreadOptions, getScopeFromContext, getThreadSendOptions, } from "./scope.js";
55
+ import { TelegramRateLimiter } from "./telegram-rate-limiter.js";
56
+ import { getSessionRouteTarget, listAllTopicBindings } from "../topic/manager.js";
57
+ import { BOT_COMMAND, DM_ALLOWED_COMMANDS } from "./commands/constants.js";
58
+ import { INTERACTION_CLEAR_REASON } from "../interaction/constants.js";
59
+ import { BOT_I18N_KEY, CHAT_TYPE, GENERAL_TOPIC, TELEGRAM_CHAT_FIELD } from "./constants.js";
60
+ import { TELEGRAM_CHAT_ACTION } from "./telegram-constants.js";
61
+ import { syncTopicTitleForSession } from "../topic/title-sync.js";
62
+ let botInstance = null;
63
+ const initializedCommandChats = new Set();
64
+ const renamedGeneralTopicChats = new Set();
65
+ const DM_ALLOWED_COMMAND_SET = new Set(DM_ALLOWED_COMMANDS);
66
+ const telegramRateLimiter = new TelegramRateLimiter();
67
+ const eventCallbackByDirectory = new Map();
68
+ const sessionDeliveryTasks = new Map();
69
+ function enqueueSessionDelivery(sessionId, task) {
70
+ const previousTask = sessionDeliveryTasks.get(sessionId) ?? Promise.resolve();
71
+ const nextTask = previousTask
72
+ .catch(() => undefined)
73
+ .then(task)
74
+ .catch((error) => {
75
+ logger.error("[Bot] Session delivery task failed", {
76
+ sessionId,
77
+ error,
78
+ });
79
+ })
80
+ .finally(() => {
81
+ if (sessionDeliveryTasks.get(sessionId) === nextTask) {
82
+ sessionDeliveryTasks.delete(sessionId);
83
+ }
84
+ });
85
+ sessionDeliveryTasks.set(sessionId, nextTask);
86
+ }
87
+ async function ensureGeneralTopicName(ctx) {
88
+ if (!ctx.chat || ctx.chat.type === CHAT_TYPE.PRIVATE) {
89
+ return;
90
+ }
91
+ if (renamedGeneralTopicChats.has(ctx.chat.id)) {
92
+ return;
93
+ }
94
+ const isForumEnabled = Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
95
+ if (!isForumEnabled) {
96
+ return;
97
+ }
98
+ try {
99
+ await ctx.api.editGeneralForumTopic(ctx.chat.id, GENERAL_TOPIC.NAME);
100
+ renamedGeneralTopicChats.add(ctx.chat.id);
101
+ logger.info(`[Bot] Renamed General topic in chat ${ctx.chat.id} to "${GENERAL_TOPIC.NAME}"`);
102
+ }
103
+ catch (error) {
104
+ logger.debug("[Bot] Failed to rename General topic", {
105
+ chatId: ctx.chat.id,
106
+ error,
107
+ });
108
+ }
109
+ }
110
+ function rememberScopeTarget(ctx) {
111
+ const scope = getScopeFromContext(ctx);
112
+ if (!scope) {
113
+ return;
114
+ }
115
+ telegramRateLimiter.setActiveScopeKey(scope.key);
116
+ }
117
+ function getTargetBySessionId(sessionId) {
118
+ const target = getSessionRouteTarget(sessionId);
119
+ if (!target) {
120
+ return null;
121
+ }
122
+ return {
123
+ chatId: target.chatId,
124
+ threadId: target.threadId,
125
+ scopeKey: target.scopeKey,
126
+ };
127
+ }
128
+ function extractSessionTitleUpdate(event) {
129
+ if (event.type !== "session.updated") {
130
+ return null;
131
+ }
132
+ const eventProperties = event.properties;
133
+ const infoSessionId = typeof eventProperties.info?.id === "string" ? eventProperties.info.id : null;
134
+ const infoTitle = typeof eventProperties.info?.title === "string" ? eventProperties.info.title : null;
135
+ if (infoSessionId && infoTitle) {
136
+ return { sessionId: infoSessionId, title: infoTitle };
137
+ }
138
+ const sessionId = typeof eventProperties.session?.id === "string"
139
+ ? eventProperties.session.id
140
+ : typeof eventProperties.sessionID === "string"
141
+ ? eventProperties.sessionID
142
+ : null;
143
+ const title = typeof eventProperties.session?.title === "string"
144
+ ? eventProperties.session.title
145
+ : typeof eventProperties.title === "string"
146
+ ? eventProperties.title
147
+ : null;
148
+ if (!sessionId || !title) {
149
+ return null;
150
+ }
151
+ return { sessionId, title };
152
+ }
153
+ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
154
+ const SESSION_RETRY_PREFIX = "🔁";
155
+ const sessionErrorThrottle = new SessionErrorThrottle(3000);
156
+ const __filename = fileURLToPath(import.meta.url);
157
+ const __dirname = path.dirname(__filename);
158
+ const TEMP_DIR = path.join(__dirname, "..", ".tmp");
159
+ function isGroupGeneralControlScope(ctx) {
160
+ const scope = getScopeFromContext(ctx);
161
+ const isForumEnabled = ctx.chat?.type === CHAT_TYPE.SUPERGROUP &&
162
+ Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
163
+ return Boolean(isForumEnabled && scope?.context === SCOPE_CONTEXT.GROUP_GENERAL && ctx.chat);
164
+ }
165
+ async function replyGeneralControlPromptRestriction(ctx) {
166
+ await ctx.reply(t(BOT_I18N_KEY.GROUP_GENERAL_PROMPTS_DISABLED), getThreadSendOptions(getScopeFromContext(ctx)?.threadId ?? null));
167
+ }
168
+ function prepareDocumentCaption(caption) {
169
+ const normalizedCaption = caption.trim();
170
+ if (!normalizedCaption) {
171
+ return "";
172
+ }
173
+ if (normalizedCaption.length <= TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH) {
174
+ return normalizedCaption;
175
+ }
176
+ return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
177
+ }
178
+ const toolMessageBatcher = new ToolMessageBatcher({
179
+ intervalSeconds: 5,
180
+ sendText: async (sessionId, text) => {
181
+ if (!botInstance) {
182
+ return;
183
+ }
184
+ const target = getTargetBySessionId(sessionId);
185
+ if (!target) {
186
+ return;
187
+ }
188
+ await botInstance.api.sendMessage(target.chatId, text, {
189
+ disable_notification: true,
190
+ ...getThreadSendOptions(target.threadId),
191
+ });
192
+ },
193
+ sendFile: async (sessionId, fileData) => {
194
+ if (!botInstance) {
195
+ return;
196
+ }
197
+ const target = getTargetBySessionId(sessionId);
198
+ if (!target) {
199
+ return;
200
+ }
201
+ const tempFilePath = path.join(TEMP_DIR, fileData.filename);
202
+ try {
203
+ logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
204
+ await fs.mkdir(TEMP_DIR, { recursive: true });
205
+ await fs.writeFile(tempFilePath, fileData.buffer);
206
+ await botInstance.api.sendDocument(target.chatId, new InputFile(tempFilePath), {
207
+ caption: fileData.caption,
208
+ disable_notification: true,
209
+ ...getThreadSendOptions(target.threadId),
210
+ });
211
+ }
212
+ finally {
213
+ await fs.unlink(tempFilePath).catch(() => { });
214
+ }
215
+ },
216
+ });
217
+ async function ensureCommandsInitialized(ctx, next) {
218
+ if (!ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
219
+ await next();
220
+ return;
221
+ }
222
+ if (!ctx.chat) {
223
+ logger.warn("[Bot] Cannot initialize commands: chat context is missing");
224
+ await next();
225
+ return;
226
+ }
227
+ if (initializedCommandChats.has(ctx.chat.id)) {
228
+ await next();
229
+ return;
230
+ }
231
+ try {
232
+ if (ctx.chat.type === CHAT_TYPE.PRIVATE) {
233
+ await ctx.api.setMyCommands(BOT_COMMANDS, {
234
+ scope: {
235
+ type: "chat",
236
+ chat_id: ctx.chat.id,
237
+ },
238
+ });
239
+ }
240
+ else {
241
+ await ctx.api.setMyCommands(BOT_COMMANDS, {
242
+ scope: {
243
+ type: "chat_member",
244
+ chat_id: ctx.chat.id,
245
+ user_id: ctx.from.id,
246
+ },
247
+ });
248
+ }
249
+ initializedCommandChats.add(ctx.chat.id);
250
+ logger.info(`[Bot] Commands initialized for authorized user in chat (chat_id=${ctx.chat.id}, user_id=${ctx.from.id})`);
251
+ }
252
+ catch (err) {
253
+ logger.error("[Bot] Failed to set commands:", err);
254
+ }
255
+ await next();
256
+ }
257
+ async function ensureEventSubscription(directory) {
258
+ if (!directory) {
259
+ logger.error("No directory found for event subscription");
260
+ return;
261
+ }
262
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
263
+ summaryAggregator.setOnCleared(() => {
264
+ toolMessageBatcher.clearAll("summary_aggregator_clear");
265
+ });
266
+ summaryAggregator.setOnComplete((sessionId, messageText) => {
267
+ enqueueSessionDelivery(sessionId, async () => {
268
+ if (!botInstance) {
269
+ logger.error("Bot not available for sending message");
270
+ return;
271
+ }
272
+ const target = getTargetBySessionId(sessionId);
273
+ if (!target) {
274
+ return;
275
+ }
276
+ await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
277
+ try {
278
+ const parts = formatSummary(messageText);
279
+ const assistantParseMode = getAssistantParseMode();
280
+ logger.debug(`[Bot] Sending completed message to Telegram (chatId=${target.chatId}, parts=${parts.length})`);
281
+ for (let i = 0; i < parts.length; i++) {
282
+ const isLastPart = i === parts.length - 1;
283
+ const keyboard = isLastPart && keyboardManager.isInitialized(target.scopeKey)
284
+ ? keyboardManager.getKeyboard(target.scopeKey)
285
+ : undefined;
286
+ const options = keyboard ? { reply_markup: keyboard } : undefined;
287
+ await sendMessageWithMarkdownFallback({
288
+ api: botInstance.api,
289
+ chatId: target.chatId,
290
+ text: parts[i],
291
+ options: {
292
+ ...(options || {}),
293
+ ...getThreadSendOptions(target.threadId),
294
+ },
295
+ parseMode: assistantParseMode,
296
+ });
297
+ }
298
+ }
299
+ catch (err) {
300
+ logger.error("Failed to send message to Telegram:", err);
301
+ logger.warn("[Bot] Assistant message delivery failed; keeping event processing active");
302
+ }
303
+ });
304
+ });
305
+ summaryAggregator.setOnTool(async (toolInfo) => {
306
+ if (!botInstance) {
307
+ logger.error("Bot or chat ID not available for sending tool notification");
308
+ return;
309
+ }
310
+ const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
311
+ (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
312
+ if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
313
+ return;
314
+ }
315
+ try {
316
+ const target = getTargetBySessionId(toolInfo.sessionId);
317
+ const projectWorktree = target ? getCurrentProject(target.scopeKey)?.worktree : undefined;
318
+ const message = formatToolInfo(toolInfo, projectWorktree);
319
+ if (message) {
320
+ toolMessageBatcher.enqueue(toolInfo.sessionId, message);
321
+ }
322
+ }
323
+ catch (err) {
324
+ logger.error("Failed to send tool notification to Telegram:", err);
325
+ }
326
+ });
327
+ summaryAggregator.setOnToolFile(async (fileInfo) => {
328
+ if (!botInstance) {
329
+ logger.error("Bot or chat ID not available for sending file");
330
+ return;
331
+ }
332
+ try {
333
+ const target = getTargetBySessionId(fileInfo.sessionId);
334
+ const projectWorktree = target ? getCurrentProject(target.scopeKey)?.worktree : undefined;
335
+ const toolMessage = formatToolInfo(fileInfo, projectWorktree);
336
+ const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
337
+ toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
338
+ ...fileInfo.fileData,
339
+ caption,
340
+ });
341
+ }
342
+ catch (err) {
343
+ logger.error("Failed to send file to Telegram:", err);
344
+ }
345
+ });
346
+ summaryAggregator.setOnQuestion((sessionId, questions, requestID) => {
347
+ enqueueSessionDelivery(sessionId, async () => {
348
+ if (!botInstance) {
349
+ logger.error("Bot or chat ID not available for showing questions");
350
+ return;
351
+ }
352
+ await toolMessageBatcher.flushSession(sessionId, "question_asked");
353
+ const target = getTargetBySessionId(sessionId);
354
+ if (!target) {
355
+ return;
356
+ }
357
+ if (questionManager.isActive(target.scopeKey)) {
358
+ logger.warn("[Bot] Replacing active poll with a new one");
359
+ const previousMessageIds = questionManager.getMessageIds(target.scopeKey);
360
+ for (const messageId of previousMessageIds) {
361
+ await botInstance.api.deleteMessage(target.chatId, messageId).catch(() => { });
362
+ }
363
+ clearAllInteractionState(INTERACTION_CLEAR_REASON.QUESTION_REPLACED_BY_NEW_POLL, target.scopeKey);
364
+ }
365
+ logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
366
+ questionManager.startQuestions(questions, requestID, target.scopeKey);
367
+ await showCurrentQuestion(botInstance.api, target.chatId, target.scopeKey, target.threadId);
368
+ });
369
+ });
370
+ summaryAggregator.setOnQuestionError(async () => {
371
+ logger.info(`[Bot] Question tool failed, clearing active poll and deleting messages`);
372
+ const bindings = listAllTopicBindings();
373
+ for (const binding of bindings) {
374
+ const messageIds = questionManager.getMessageIds(binding.scopeKey);
375
+ for (const messageId of messageIds) {
376
+ await botInstance?.api.deleteMessage(binding.chatId, messageId).catch((err) => {
377
+ logger.error(`[Bot] Failed to delete question message ${messageId}:`, err);
378
+ });
379
+ }
380
+ clearAllInteractionState(INTERACTION_CLEAR_REASON.QUESTION_ERROR, binding.scopeKey);
381
+ }
382
+ clearAllInteractionState(INTERACTION_CLEAR_REASON.QUESTION_ERROR, GLOBAL_SCOPE_KEY);
383
+ });
384
+ summaryAggregator.setOnPermission((request) => {
385
+ enqueueSessionDelivery(request.sessionID, async () => {
386
+ if (!botInstance) {
387
+ logger.error("Bot or chat ID not available for showing permission request");
388
+ return;
389
+ }
390
+ const target = getTargetBySessionId(request.sessionID);
391
+ if (!target) {
392
+ return;
393
+ }
394
+ await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
395
+ logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
396
+ await showPermissionRequest(botInstance.api, target.chatId, request, target.scopeKey, target.threadId);
397
+ });
398
+ });
399
+ summaryAggregator.setOnTypingIndicator((sessionId) => {
400
+ if (!botInstance) {
401
+ return;
402
+ }
403
+ const target = getTargetBySessionId(sessionId);
404
+ if (!target) {
405
+ return;
406
+ }
407
+ void botInstance.api
408
+ .sendChatAction(target.chatId, TELEGRAM_CHAT_ACTION.TYPING, getChatActionThreadOptions(target.threadId))
409
+ .catch((error) => {
410
+ logger.debug("[Bot] Failed to send typing indicator", {
411
+ sessionId,
412
+ chatId: target.chatId,
413
+ threadId: target.threadId,
414
+ error,
415
+ });
416
+ });
417
+ });
418
+ summaryAggregator.setOnThinking(async (sessionId) => {
419
+ if (config.bot.hideThinkingMessages) {
420
+ return;
421
+ }
422
+ if (!botInstance) {
423
+ return;
424
+ }
425
+ const target = getTargetBySessionId(sessionId);
426
+ if (!target) {
427
+ return;
428
+ }
429
+ logger.debug("[Bot] Agent started thinking");
430
+ toolMessageBatcher.enqueue(sessionId, t("bot.thinking"));
431
+ });
432
+ summaryAggregator.setOnTokens(async (sessionId, tokens) => {
433
+ const target = getTargetBySessionId(sessionId);
434
+ if (!target) {
435
+ return;
436
+ }
437
+ try {
438
+ logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
439
+ // Update keyboardManager SYNCHRONOUSLY before any await
440
+ // This ensures keyboard has correct context when onComplete sends the reply
441
+ const contextSize = tokens.input + tokens.cacheRead;
442
+ const contextLimit = pinnedMessageManager.getContextLimit(target.scopeKey);
443
+ if (contextLimit > 0) {
444
+ keyboardManager.updateContext(contextSize, contextLimit, target.scopeKey);
445
+ }
446
+ if (pinnedMessageManager.isInitialized(target.scopeKey)) {
447
+ await pinnedMessageManager.onMessageComplete(tokens, target.scopeKey);
448
+ }
449
+ }
450
+ catch (err) {
451
+ logger.error("[Bot] Error updating pinned message with tokens:", err);
452
+ }
453
+ });
454
+ summaryAggregator.setOnSessionCompacted(async (sessionId, directory) => {
455
+ const target = getTargetBySessionId(sessionId);
456
+ if (!target || !pinnedMessageManager.isInitialized(target.scopeKey)) {
457
+ return;
458
+ }
459
+ try {
460
+ logger.info(`[Bot] Session compacted, reloading context: ${sessionId}`);
461
+ await pinnedMessageManager.onSessionCompacted(sessionId, directory, target.scopeKey);
462
+ }
463
+ catch (err) {
464
+ logger.error("[Bot] Error reloading context after compaction:", err);
465
+ }
466
+ });
467
+ summaryAggregator.setOnSessionError(async (sessionId, message) => {
468
+ if (!botInstance) {
469
+ return;
470
+ }
471
+ const target = getTargetBySessionId(sessionId);
472
+ if (!target) {
473
+ return;
474
+ }
475
+ await toolMessageBatcher.flushSession(sessionId, "session_error");
476
+ const normalizedMessage = message.trim() || t("common.unknown_error");
477
+ if (isOperationAbortedSessionError(normalizedMessage)) {
478
+ logger.info(`[Bot] Suppressing session.abort error notification for ${sessionId}`);
479
+ return;
480
+ }
481
+ if (sessionErrorThrottle.shouldSuppress(sessionId, normalizedMessage)) {
482
+ logger.debug(`[Bot] Suppressing duplicate session.error notification for ${sessionId}`);
483
+ return;
484
+ }
485
+ const truncatedMessage = normalizedMessage.length > 3500
486
+ ? `${normalizedMessage.slice(0, 3497)}...`
487
+ : normalizedMessage;
488
+ await botInstance.api
489
+ .sendMessage(target.chatId, t("bot.session_error", { message: truncatedMessage }), {
490
+ ...getThreadSendOptions(target.threadId),
491
+ })
492
+ .catch((err) => {
493
+ logger.error("[Bot] Failed to send session.error message:", err);
494
+ });
495
+ });
496
+ summaryAggregator.setOnSessionRetry(async ({ sessionId, message }) => {
497
+ if (!botInstance) {
498
+ return;
499
+ }
500
+ const normalizedMessage = message.trim() || t("common.unknown_error");
501
+ const truncatedMessage = normalizedMessage.length > 3500
502
+ ? `${normalizedMessage.slice(0, 3497)}...`
503
+ : normalizedMessage;
504
+ const retryMessage = t("bot.session_retry", { message: truncatedMessage });
505
+ toolMessageBatcher.enqueueUniqueByPrefix(sessionId, retryMessage, SESSION_RETRY_PREFIX);
506
+ });
507
+ summaryAggregator.setOnSessionDiff(async (sessionId, diffs) => {
508
+ const target = getTargetBySessionId(sessionId);
509
+ if (!target || !pinnedMessageManager.isInitialized(target.scopeKey)) {
510
+ return;
511
+ }
512
+ try {
513
+ await pinnedMessageManager.onSessionDiff(diffs, target.scopeKey);
514
+ }
515
+ catch (err) {
516
+ logger.error("[Bot] Error updating session diff:", err);
517
+ }
518
+ });
519
+ summaryAggregator.setOnFileChange((change, sessionId) => {
520
+ const target = getTargetBySessionId(sessionId);
521
+ if (!target || !pinnedMessageManager.isInitialized(target.scopeKey)) {
522
+ return;
523
+ }
524
+ pinnedMessageManager.addFileChange(change, target.scopeKey);
525
+ });
526
+ pinnedMessageManager.setOnKeyboardUpdate(async (tokensUsed, tokensLimit, scopeKey) => {
527
+ try {
528
+ logger.debug(`[Bot] Updating keyboard with context: ${tokensUsed}/${tokensLimit}`);
529
+ keyboardManager.updateContext(tokensUsed, tokensLimit, scopeKey);
530
+ // Don't send automatic keyboard updates - keyboard will update naturally with user messages
531
+ }
532
+ catch (err) {
533
+ logger.error("[Bot] Error updating keyboard context:", err);
534
+ }
535
+ });
536
+ let eventCallback = eventCallbackByDirectory.get(directory);
537
+ if (!eventCallback) {
538
+ eventCallback = (event) => {
539
+ if (event.type === "session.created" || event.type === "session.updated") {
540
+ const info = event.properties.info;
541
+ if (info?.directory) {
542
+ safeBackgroundTask({
543
+ taskName: `session.cache.${event.type}`,
544
+ task: () => ingestSessionInfoForCache(info),
545
+ });
546
+ }
547
+ }
548
+ const sessionTitleUpdate = extractSessionTitleUpdate(event);
549
+ if (sessionTitleUpdate && botInstance) {
550
+ const activeBot = botInstance;
551
+ safeBackgroundTask({
552
+ taskName: "topic.title.sync_from_event",
553
+ task: () => syncTopicTitleForSession(activeBot.api, sessionTitleUpdate.sessionId, sessionTitleUpdate.title),
554
+ onError: (syncError) => {
555
+ logger.debug("[Bot] Failed to sync topic title from session.updated", {
556
+ sessionId: sessionTitleUpdate.sessionId,
557
+ syncError,
558
+ });
559
+ },
560
+ });
561
+ }
562
+ summaryAggregator.processEvent(event);
563
+ };
564
+ eventCallbackByDirectory.set(directory, eventCallback);
565
+ logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
566
+ }
567
+ subscribeToEvents(directory, eventCallback).catch((err) => {
568
+ logger.error("Failed to subscribe to events:", err);
569
+ });
570
+ }
571
+ export function createBot() {
572
+ clearAllInteractionState(INTERACTION_CLEAR_REASON.BOT_STARTUP);
573
+ toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
574
+ logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
575
+ const botOptions = {};
576
+ if (config.telegram.proxyUrl) {
577
+ const proxyUrl = config.telegram.proxyUrl;
578
+ let agent;
579
+ if (proxyUrl.startsWith("socks")) {
580
+ agent = new SocksProxyAgent(proxyUrl);
581
+ logger.info(`[Bot] Using SOCKS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
582
+ }
583
+ else {
584
+ agent = new HttpsProxyAgent(proxyUrl);
585
+ logger.info(`[Bot] Using HTTP/HTTPS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
586
+ }
587
+ botOptions.client = {
588
+ baseFetchConfig: {
589
+ agent,
590
+ compress: true,
591
+ },
592
+ };
593
+ }
594
+ const bot = new Bot(config.telegram.token, botOptions);
595
+ bot.api.config.use((prev, method, payload, signal) => {
596
+ return telegramRateLimiter.enqueue(method, payload, () => prev(method, payload, signal));
597
+ });
598
+ // Heartbeat for diagnostics: verify the event loop is not blocked
599
+ let heartbeatCounter = 0;
600
+ setInterval(() => {
601
+ heartbeatCounter++;
602
+ if (heartbeatCounter % 6 === 0) {
603
+ // Log every 30 seconds (5 sec * 6)
604
+ logger.debug(`[Bot] Heartbeat #${heartbeatCounter} - event loop alive`);
605
+ }
606
+ }, 5000);
607
+ // Log all API calls for diagnostics
608
+ let lastGetUpdatesTime = Date.now();
609
+ bot.api.config.use(async (prev, method, payload, signal) => {
610
+ if (method === "getUpdates") {
611
+ const now = Date.now();
612
+ const timeSinceLast = now - lastGetUpdatesTime;
613
+ logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
614
+ lastGetUpdatesTime = now;
615
+ }
616
+ else if (method === "sendMessage") {
617
+ logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
618
+ }
619
+ return prev(method, payload, signal);
620
+ });
621
+ bot.use((ctx, next) => {
622
+ rememberScopeTarget(ctx);
623
+ const hasCallbackQuery = !!ctx.callbackQuery;
624
+ const hasMessage = !!ctx.message;
625
+ const callbackData = ctx.callbackQuery?.data || "N/A";
626
+ logger.debug(`[DEBUG] Incoming update: hasCallbackQuery=${hasCallbackQuery}, hasMessage=${hasMessage}, callbackData=${callbackData}`);
627
+ return next();
628
+ });
629
+ bot.use(authMiddleware);
630
+ bot.use(async (ctx, next) => {
631
+ if (ctx.message && ctx.chat?.type !== CHAT_TYPE.PRIVATE) {
632
+ await ensureGeneralTopicName(ctx);
633
+ }
634
+ await next();
635
+ });
636
+ bot.use(ensureCommandsInitialized);
637
+ bot.use(interactionGuardMiddleware);
638
+ bot.use(async (ctx, next) => {
639
+ if (ctx.chat?.type !== CHAT_TYPE.PRIVATE) {
640
+ await next();
641
+ return;
642
+ }
643
+ const text = ctx.message?.text;
644
+ if (text) {
645
+ const commandName = extractCommandName(text);
646
+ if (commandName) {
647
+ if (DM_ALLOWED_COMMAND_SET.has(commandName)) {
648
+ await next();
649
+ return;
650
+ }
651
+ await ctx.reply(t("dm.restricted.command"));
652
+ return;
653
+ }
654
+ await ctx.reply(t("dm.restricted.prompt"));
655
+ return;
656
+ }
657
+ if (ctx.message?.photo || ctx.message?.document || ctx.message?.voice || ctx.message?.audio) {
658
+ await ctx.reply(t("dm.restricted.prompt"));
659
+ return;
660
+ }
661
+ await next();
662
+ });
663
+ const blockMenuWhileInteractionActive = async (ctx) => {
664
+ const activeInteraction = interactionManager.getSnapshot(getScopeFromContext(ctx)?.key ?? GLOBAL_SCOPE_KEY);
665
+ if (!activeInteraction) {
666
+ return false;
667
+ }
668
+ logger.debug(`[Bot] Blocking menu open while interaction active: kind=${activeInteraction.kind}, expectedInput=${activeInteraction.expectedInput}`);
669
+ await ctx.reply(t("interaction.blocked.finish_current"));
670
+ return true;
671
+ };
672
+ bot.command(BOT_COMMAND.START, startCommand);
673
+ bot.command(BOT_COMMAND.HELP, helpCommand);
674
+ bot.command(BOT_COMMAND.STATUS, statusCommand);
675
+ bot.command(BOT_COMMAND.OPENCODE_START, opencodeStartCommand);
676
+ bot.command(BOT_COMMAND.OPENCODE_STOP, opencodeStopCommand);
677
+ bot.command(BOT_COMMAND.PROJECTS, projectsCommand);
678
+ bot.command(BOT_COMMAND.SESSIONS, sessionsCommand);
679
+ bot.command(BOT_COMMAND.NEW, createNewCommand({ ensureEventSubscription }));
680
+ bot.command(BOT_COMMAND.ABORT, abortCommand);
681
+ bot.command(BOT_COMMAND.RENAME, renameCommand);
682
+ bot.command(BOT_COMMAND.COMMANDS, commandsCommand);
683
+ bot.on("message:text", unknownCommandMiddleware);
684
+ bot.on("callback_query:data", async (ctx) => {
685
+ logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
686
+ logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
687
+ if (ctx.chat) {
688
+ botInstance = bot;
689
+ rememberScopeTarget(ctx);
690
+ }
691
+ try {
692
+ const handledInlineCancel = await handleInlineMenuCancel(ctx);
693
+ const handledSession = await handleSessionSelect(ctx);
694
+ const handledProject = await handleProjectSelect(ctx);
695
+ const handledQuestion = await handleQuestionCallback(ctx);
696
+ const handledPermission = await handlePermissionCallback(ctx);
697
+ const handledAgent = await handleAgentSelect(ctx);
698
+ const handledModel = await handleModelSelect(ctx);
699
+ const handledVariant = await handleVariantSelect(ctx);
700
+ const handledCompactConfirm = await handleCompactConfirm(ctx);
701
+ const handledRenameCancel = await handleRenameCancel(ctx);
702
+ const handledCommands = await handleCommandsCallback(ctx, { ensureEventSubscription });
703
+ logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, rename=${handledRenameCancel}, commands=${handledCommands}`);
704
+ if (!handledInlineCancel &&
705
+ !handledSession &&
706
+ !handledProject &&
707
+ !handledQuestion &&
708
+ !handledPermission &&
709
+ !handledAgent &&
710
+ !handledModel &&
711
+ !handledVariant &&
712
+ !handledCompactConfirm &&
713
+ !handledRenameCancel &&
714
+ !handledCommands) {
715
+ logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
716
+ await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
717
+ }
718
+ }
719
+ catch (err) {
720
+ logger.error("[Bot] Error handling callback:", err);
721
+ clearAllInteractionState(INTERACTION_CLEAR_REASON.CALLBACK_HANDLER_ERROR, getScopeFromContext(ctx)?.key ?? GLOBAL_SCOPE_KEY);
722
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
723
+ }
724
+ });
725
+ // Handle Reply Keyboard button press (agent mode indicator)
726
+ bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
727
+ logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
728
+ try {
729
+ if (await blockMenuWhileInteractionActive(ctx)) {
730
+ return;
731
+ }
732
+ await showAgentSelectionMenu(ctx);
733
+ }
734
+ catch (err) {
735
+ logger.error("[Bot] Error showing agent menu:", err);
736
+ await ctx.reply(t("error.load_agents"));
737
+ }
738
+ });
739
+ // Handle Reply Keyboard button press (model selector)
740
+ // Model button text is produced by formatModelForButton() and always starts with "🤖 ".
741
+ bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
742
+ logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
743
+ try {
744
+ if (await blockMenuWhileInteractionActive(ctx)) {
745
+ return;
746
+ }
747
+ await showModelSelectionMenu(ctx);
748
+ }
749
+ catch (err) {
750
+ logger.error("[Bot] Error showing model menu:", err);
751
+ await ctx.reply(t("error.load_models"));
752
+ }
753
+ });
754
+ bot.hears(t("keyboard.general_defaults"), async (ctx) => {
755
+ await ctx.reply(t("keyboard.general_defaults_info"), getThreadSendOptions(getScopeFromContext(ctx)?.threadId ?? null));
756
+ });
757
+ // Handle Reply Keyboard button press (context button)
758
+ bot.hears(/^📊(?:\s|$)/, async (ctx) => {
759
+ logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
760
+ try {
761
+ if (await blockMenuWhileInteractionActive(ctx)) {
762
+ return;
763
+ }
764
+ await handleContextButtonPress(ctx);
765
+ }
766
+ catch (err) {
767
+ logger.error("[Bot] Error handling context button:", err);
768
+ await ctx.reply(t("error.context_button"));
769
+ }
770
+ });
771
+ // Handle Reply Keyboard button press (variant selector)
772
+ // Keep support for both legacy "💭" and current "💡" prefix.
773
+ bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
774
+ logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
775
+ try {
776
+ if (await blockMenuWhileInteractionActive(ctx)) {
777
+ return;
778
+ }
779
+ await showVariantSelectionMenu(ctx);
780
+ }
781
+ catch (err) {
782
+ logger.error("[Bot] Error showing variant menu:", err);
783
+ await ctx.reply(t("error.load_variants"));
784
+ }
785
+ });
786
+ bot.on("message:text", async (ctx, next) => {
787
+ const text = ctx.message?.text;
788
+ if (text) {
789
+ const isCommand = text.startsWith("/");
790
+ logger.debug(`[Bot] Received text message: ${isCommand ? `command="${text}"` : `prompt (length=${text.length})`}, chatId=${ctx.chat.id}`);
791
+ }
792
+ await next();
793
+ });
794
+ // Remove any previously set global commands to prevent unauthorized users from seeing them
795
+ safeBackgroundTask({
796
+ taskName: "bot.clearGlobalCommands",
797
+ task: async () => {
798
+ try {
799
+ await Promise.all([
800
+ bot.api.setMyCommands([], { scope: { type: "default" } }),
801
+ bot.api.setMyCommands([], { scope: { type: "all_private_chats" } }),
802
+ ]);
803
+ return { success: true };
804
+ }
805
+ catch (error) {
806
+ return { success: false, error };
807
+ }
808
+ },
809
+ onSuccess: (result) => {
810
+ if (result.success) {
811
+ logger.info("[Bot] Cleared global commands (default and all_private_chats scopes)");
812
+ return;
813
+ }
814
+ logger.warn("[Bot] Could not clear global commands:", result.error);
815
+ },
816
+ });
817
+ // Voice and audio message handlers (STT transcription -> prompt)
818
+ const voicePromptDeps = { bot, ensureEventSubscription };
819
+ bot.on("message:voice", async (ctx) => {
820
+ logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
821
+ botInstance = bot;
822
+ rememberScopeTarget(ctx);
823
+ if (isGroupGeneralControlScope(ctx)) {
824
+ await replyGeneralControlPromptRestriction(ctx);
825
+ return;
826
+ }
827
+ await handleVoiceMessage(ctx, voicePromptDeps);
828
+ });
829
+ bot.on("message:audio", async (ctx) => {
830
+ logger.debug(`[Bot] Received audio message, chatId=${ctx.chat.id}`);
831
+ botInstance = bot;
832
+ rememberScopeTarget(ctx);
833
+ if (isGroupGeneralControlScope(ctx)) {
834
+ await replyGeneralControlPromptRestriction(ctx);
835
+ return;
836
+ }
837
+ await handleVoiceMessage(ctx, voicePromptDeps);
838
+ });
839
+ // Photo message handler
840
+ bot.on("message:photo", async (ctx) => {
841
+ logger.debug(`[Bot] Received photo message, chatId=${ctx.chat.id}`);
842
+ if (isGroupGeneralControlScope(ctx)) {
843
+ await replyGeneralControlPromptRestriction(ctx);
844
+ return;
845
+ }
846
+ const photos = ctx.message?.photo;
847
+ if (!photos || photos.length === 0) {
848
+ return;
849
+ }
850
+ const caption = ctx.message.caption || "";
851
+ try {
852
+ // Get the largest photo (last element in array)
853
+ const largestPhoto = photos[photos.length - 1];
854
+ // Check model capabilities
855
+ const scopeKey = getScopeFromContext(ctx)?.key ?? GLOBAL_SCOPE_KEY;
856
+ const storedModel = getStoredModel(scopeKey);
857
+ const capabilities = await getModelCapabilities(storedModel.providerID, storedModel.modelID);
858
+ if (!supportsInput(capabilities, "image")) {
859
+ logger.warn(`[Bot] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support image input`);
860
+ await ctx.reply(t("bot.photo_model_no_image"));
861
+ // Fall back to caption-only if present
862
+ if (caption.trim().length > 0) {
863
+ botInstance = bot;
864
+ rememberScopeTarget(ctx);
865
+ const promptDeps = { bot, ensureEventSubscription };
866
+ await processUserPrompt(ctx, caption, promptDeps);
867
+ }
868
+ return;
869
+ }
870
+ // Download photo
871
+ await ctx.reply(t("bot.photo_downloading"));
872
+ const downloadedFile = await downloadTelegramFile(ctx.api, largestPhoto.file_id);
873
+ // Convert to data URI (Telegram always converts photos to JPEG)
874
+ const dataUri = toDataUri(downloadedFile.buffer, "image/jpeg");
875
+ // Create file part
876
+ const filePart = {
877
+ type: "file",
878
+ mime: "image/jpeg",
879
+ filename: "photo.jpg",
880
+ url: dataUri,
881
+ };
882
+ logger.info(`[Bot] Sending photo (${downloadedFile.buffer.length} bytes) with prompt`);
883
+ botInstance = bot;
884
+ rememberScopeTarget(ctx);
885
+ // Send via processUserPrompt with file part
886
+ const promptDeps = { bot, ensureEventSubscription };
887
+ await processUserPrompt(ctx, caption, promptDeps, [filePart]);
888
+ }
889
+ catch (err) {
890
+ logger.error("[Bot] Error handling photo message:", err);
891
+ await ctx.reply(t("bot.photo_download_error"));
892
+ }
893
+ });
894
+ // Document message handler (PDF and text files)
895
+ bot.on("message:document", async (ctx) => {
896
+ logger.debug(`[Bot] Received document message, chatId=${ctx.chat.id}`);
897
+ botInstance = bot;
898
+ rememberScopeTarget(ctx);
899
+ if (isGroupGeneralControlScope(ctx)) {
900
+ await replyGeneralControlPromptRestriction(ctx);
901
+ return;
902
+ }
903
+ const deps = { bot, ensureEventSubscription };
904
+ await handleDocumentMessage(ctx, deps);
905
+ });
906
+ bot.on("message:text", async (ctx) => {
907
+ const text = ctx.message?.text;
908
+ if (!text) {
909
+ return;
910
+ }
911
+ botInstance = bot;
912
+ rememberScopeTarget(ctx);
913
+ if (text.startsWith("/")) {
914
+ return;
915
+ }
916
+ const scopeKey = getScopeFromContext(ctx)?.key ?? GLOBAL_SCOPE_KEY;
917
+ if (questionManager.isActive(scopeKey)) {
918
+ await handleQuestionTextAnswer(ctx);
919
+ return;
920
+ }
921
+ const handledRename = await handleRenameTextAnswer(ctx);
922
+ if (handledRename) {
923
+ return;
924
+ }
925
+ const promptDeps = { bot, ensureEventSubscription };
926
+ const handledCommandArgs = await handleCommandTextArguments(ctx, promptDeps);
927
+ if (handledCommandArgs) {
928
+ return;
929
+ }
930
+ if (isGroupGeneralControlScope(ctx)) {
931
+ await replyGeneralControlPromptRestriction(ctx);
932
+ return;
933
+ }
934
+ await processUserPrompt(ctx, text, promptDeps);
935
+ logger.debug("[Bot] message:text handler completed (prompt sent in background)");
936
+ });
937
+ bot.catch((err) => {
938
+ logger.error("[Bot] Unhandled error in bot:", err);
939
+ clearAllInteractionState("bot_unhandled_error", err.ctx ? (getScopeFromContext(err.ctx)?.key ?? GLOBAL_SCOPE_KEY) : GLOBAL_SCOPE_KEY);
940
+ if (err.ctx) {
941
+ logger.error("[Bot] Error context - update type:", err.ctx.update ? Object.keys(err.ctx.update) : "unknown");
942
+ }
943
+ });
944
+ return bot;
945
+ }