@grinev/opencode-telegram-bot 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/dist/agent/manager.js +92 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +26 -0
  7. package/dist/bot/commands/agent.js +16 -0
  8. package/dist/bot/commands/definitions.js +20 -0
  9. package/dist/bot/commands/help.js +7 -0
  10. package/dist/bot/commands/model.js +16 -0
  11. package/dist/bot/commands/models.js +37 -0
  12. package/dist/bot/commands/new.js +58 -0
  13. package/dist/bot/commands/opencode-start.js +87 -0
  14. package/dist/bot/commands/opencode-stop.js +46 -0
  15. package/dist/bot/commands/projects.js +104 -0
  16. package/dist/bot/commands/server-restart.js +23 -0
  17. package/dist/bot/commands/server-start.js +23 -0
  18. package/dist/bot/commands/sessions.js +240 -0
  19. package/dist/bot/commands/start.js +40 -0
  20. package/dist/bot/commands/status.js +63 -0
  21. package/dist/bot/commands/stop.js +92 -0
  22. package/dist/bot/handlers/agent.js +96 -0
  23. package/dist/bot/handlers/context.js +112 -0
  24. package/dist/bot/handlers/model.js +115 -0
  25. package/dist/bot/handlers/permission.js +158 -0
  26. package/dist/bot/handlers/question.js +294 -0
  27. package/dist/bot/handlers/variant.js +126 -0
  28. package/dist/bot/index.js +573 -0
  29. package/dist/bot/middleware/auth.js +30 -0
  30. package/dist/bot/utils/keyboard.js +66 -0
  31. package/dist/cli/args.js +97 -0
  32. package/dist/cli.js +90 -0
  33. package/dist/config.js +46 -0
  34. package/dist/index.js +26 -0
  35. package/dist/keyboard/manager.js +171 -0
  36. package/dist/keyboard/types.js +1 -0
  37. package/dist/model/manager.js +123 -0
  38. package/dist/model/types.js +26 -0
  39. package/dist/opencode/client.js +13 -0
  40. package/dist/opencode/events.js +79 -0
  41. package/dist/opencode/server.js +104 -0
  42. package/dist/permission/manager.js +78 -0
  43. package/dist/permission/types.js +1 -0
  44. package/dist/pinned/manager.js +610 -0
  45. package/dist/pinned/types.js +1 -0
  46. package/dist/pinned-message/service.js +54 -0
  47. package/dist/process/manager.js +273 -0
  48. package/dist/process/types.js +1 -0
  49. package/dist/project/manager.js +28 -0
  50. package/dist/question/manager.js +143 -0
  51. package/dist/question/types.js +1 -0
  52. package/dist/runtime/bootstrap.js +278 -0
  53. package/dist/runtime/mode.js +74 -0
  54. package/dist/runtime/paths.js +37 -0
  55. package/dist/session/manager.js +10 -0
  56. package/dist/session/state.js +24 -0
  57. package/dist/settings/manager.js +99 -0
  58. package/dist/status/formatter.js +44 -0
  59. package/dist/summary/aggregator.js +427 -0
  60. package/dist/summary/formatter.js +226 -0
  61. package/dist/utils/formatting.js +237 -0
  62. package/dist/utils/logger.js +59 -0
  63. package/dist/utils/safe-background-task.js +33 -0
  64. package/dist/variant/manager.js +103 -0
  65. package/dist/variant/types.js +1 -0
  66. package/package.json +63 -0
@@ -0,0 +1,573 @@
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 { config } from "../config.js";
6
+ import { authMiddleware } from "./middleware/auth.js";
7
+ import { BOT_COMMANDS } from "./commands/definitions.js";
8
+ import { startCommand } from "./commands/start.js";
9
+ import { helpCommand } from "./commands/help.js";
10
+ import { statusCommand } from "./commands/status.js";
11
+ import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
12
+ import { newCommand } from "./commands/new.js";
13
+ import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
14
+ import { stopCommand } from "./commands/stop.js";
15
+ import { opencodeStartCommand } from "./commands/opencode-start.js";
16
+ import { opencodeStopCommand } from "./commands/opencode-stop.js";
17
+ import { handleAgentCommand } from "./commands/agent.js";
18
+ import { handleModelCommand } from "./commands/model.js";
19
+ import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
20
+ import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
21
+ import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
22
+ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
23
+ import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
24
+ import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
25
+ import { questionManager } from "../question/manager.js";
26
+ import { keyboardManager } from "../keyboard/manager.js";
27
+ import { subscribeToEvents } from "../opencode/events.js";
28
+ import { summaryAggregator } from "../summary/aggregator.js";
29
+ import { formatSummary, formatToolInfo } from "../summary/formatter.js";
30
+ import { opencodeClient } from "../opencode/client.js";
31
+ import { getCurrentSession, setCurrentSession } from "../session/manager.js";
32
+ import { getCurrentProject } from "../settings/manager.js";
33
+ import { getStoredAgent } from "../agent/manager.js";
34
+ import { getStoredModel } from "../model/manager.js";
35
+ import { formatVariantForButton } from "../variant/manager.js";
36
+ import { createMainKeyboard } from "./utils/keyboard.js";
37
+ import { logger } from "../utils/logger.js";
38
+ import { safeBackgroundTask } from "../utils/safe-background-task.js";
39
+ import { pinnedMessageManager } from "../pinned/manager.js";
40
+ let botInstance = null;
41
+ let chatIdInstance = null;
42
+ let commandsInitialized = false;
43
+ async function ensureCommandsInitialized(ctx, next) {
44
+ if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
45
+ await next();
46
+ return;
47
+ }
48
+ if (!ctx.chat) {
49
+ logger.warn("[Bot] Cannot initialize commands: chat context is missing");
50
+ await next();
51
+ return;
52
+ }
53
+ try {
54
+ await ctx.api.setMyCommands(BOT_COMMANDS, {
55
+ scope: {
56
+ type: "chat",
57
+ chat_id: ctx.chat.id,
58
+ },
59
+ });
60
+ commandsInitialized = true;
61
+ logger.info(`[Bot] Commands initialized for authorized user (chat_id=${ctx.chat.id})`);
62
+ }
63
+ catch (err) {
64
+ logger.error("[Bot] Failed to set commands:", err);
65
+ }
66
+ await next();
67
+ }
68
+ async function ensureEventSubscription() {
69
+ const currentProject = getCurrentProject();
70
+ if (!currentProject) {
71
+ logger.error("No current project found for event subscription");
72
+ return;
73
+ }
74
+ summaryAggregator.setOnComplete(async (sessionId, messageText) => {
75
+ if (!botInstance || !chatIdInstance) {
76
+ logger.error("Bot or chat ID not available for sending message");
77
+ return;
78
+ }
79
+ const currentSession = getCurrentSession();
80
+ if (currentSession?.id !== sessionId) {
81
+ return;
82
+ }
83
+ try {
84
+ const parts = formatSummary(messageText);
85
+ logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
86
+ for (let i = 0; i < parts.length; i++) {
87
+ const isLastPart = i === parts.length - 1;
88
+ if (isLastPart && keyboardManager.isInitialized()) {
89
+ // Attach updated keyboard to the last message part (only if initialized)
90
+ const keyboard = keyboardManager.getKeyboard();
91
+ if (keyboard) {
92
+ await botInstance.api.sendMessage(chatIdInstance, parts[i], {
93
+ reply_markup: keyboard,
94
+ });
95
+ }
96
+ else {
97
+ await botInstance.api.sendMessage(chatIdInstance, parts[i]);
98
+ }
99
+ }
100
+ else {
101
+ await botInstance.api.sendMessage(chatIdInstance, parts[i]);
102
+ }
103
+ }
104
+ }
105
+ catch (err) {
106
+ logger.error("Failed to send message to Telegram:", err);
107
+ // Stop processing events after critical error to prevent infinite loop
108
+ logger.error("[Bot] CRITICAL: Stopping event processing due to error");
109
+ summaryAggregator.clear();
110
+ }
111
+ });
112
+ summaryAggregator.setOnTool(async (toolInfo) => {
113
+ if (!botInstance || !chatIdInstance) {
114
+ logger.error("Bot or chat ID not available for sending tool notification");
115
+ return;
116
+ }
117
+ const currentSession = getCurrentSession();
118
+ const sessionId = summaryAggregator["currentSessionId"];
119
+ if (!currentSession || currentSession.id !== sessionId) {
120
+ return;
121
+ }
122
+ try {
123
+ const message = formatToolInfo(toolInfo);
124
+ if (message) {
125
+ await botInstance.api.sendMessage(chatIdInstance, message);
126
+ }
127
+ }
128
+ catch (err) {
129
+ logger.error("Failed to send tool notification to Telegram:", err);
130
+ }
131
+ });
132
+ summaryAggregator.setOnToolFile(async (fileData) => {
133
+ if (!botInstance || !chatIdInstance) {
134
+ logger.error("Bot or chat ID not available for sending file");
135
+ return;
136
+ }
137
+ const currentSession = getCurrentSession();
138
+ if (!currentSession) {
139
+ return;
140
+ }
141
+ const __filename = fileURLToPath(import.meta.url);
142
+ const __dirname = path.dirname(__filename);
143
+ const tempDir = path.join(__dirname, "..", ".tmp");
144
+ try {
145
+ logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
146
+ await fs.mkdir(tempDir, { recursive: true });
147
+ const tempFilePath = path.join(tempDir, fileData.filename);
148
+ await fs.writeFile(tempFilePath, fileData.buffer);
149
+ await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
150
+ caption: fileData.caption,
151
+ });
152
+ await fs.unlink(tempFilePath);
153
+ logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
154
+ }
155
+ catch (err) {
156
+ logger.error("Failed to send file to Telegram:", err);
157
+ }
158
+ });
159
+ summaryAggregator.setOnQuestion(async (questions, requestID) => {
160
+ if (!botInstance || !chatIdInstance) {
161
+ logger.error("Bot or chat ID not available for showing questions");
162
+ return;
163
+ }
164
+ logger.info(`[Bot] Received ${questions.length} questions from agent, requestID=${requestID}`);
165
+ questionManager.startQuestions(questions, requestID);
166
+ await showCurrentQuestion(botInstance.api, chatIdInstance);
167
+ });
168
+ summaryAggregator.setOnQuestionError(async () => {
169
+ logger.info(`[Bot] Question tool failed, clearing active poll and deleting messages`);
170
+ // Удаляем все сообщения с невалидным опросом
171
+ const messageIds = questionManager.getMessageIds();
172
+ for (const messageId of messageIds) {
173
+ if (chatIdInstance) {
174
+ await botInstance?.api.deleteMessage(chatIdInstance, messageId).catch((err) => {
175
+ logger.error(`[Bot] Failed to delete question message ${messageId}:`, err);
176
+ });
177
+ }
178
+ }
179
+ questionManager.clear();
180
+ });
181
+ summaryAggregator.setOnPermission(async (request) => {
182
+ if (!botInstance || !chatIdInstance) {
183
+ logger.error("Bot or chat ID not available for showing permission request");
184
+ return;
185
+ }
186
+ logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
187
+ await showPermissionRequest(botInstance.api, chatIdInstance, request);
188
+ });
189
+ summaryAggregator.setOnThinking(async () => {
190
+ if (!botInstance || !chatIdInstance) {
191
+ return;
192
+ }
193
+ logger.debug("[Bot] Agent started thinking");
194
+ await botInstance.api.sendMessage(chatIdInstance, "💭 Думаю...").catch((err) => {
195
+ logger.error("[Bot] Failed to send thinking message:", err);
196
+ });
197
+ });
198
+ summaryAggregator.setOnTokens(async (tokens) => {
199
+ if (!pinnedMessageManager.isInitialized()) {
200
+ return;
201
+ }
202
+ try {
203
+ logger.debug(`[Bot] Received tokens: input=${tokens.input}, output=${tokens.output}`);
204
+ // Update keyboardManager SYNCHRONOUSLY before any await
205
+ // This ensures keyboard has correct context when onComplete sends the reply
206
+ const contextSize = tokens.input + tokens.cacheRead;
207
+ const contextLimit = pinnedMessageManager.getContextLimit();
208
+ if (contextLimit > 0) {
209
+ keyboardManager.updateContext(contextSize, contextLimit);
210
+ }
211
+ await pinnedMessageManager.onMessageComplete(tokens);
212
+ }
213
+ catch (err) {
214
+ logger.error("[Bot] Error updating pinned message with tokens:", err);
215
+ }
216
+ });
217
+ summaryAggregator.setOnSessionCompacted(async (sessionId, directory) => {
218
+ if (!pinnedMessageManager.isInitialized()) {
219
+ return;
220
+ }
221
+ try {
222
+ logger.info(`[Bot] Session compacted, reloading context: ${sessionId}`);
223
+ await pinnedMessageManager.onSessionCompacted(sessionId, directory);
224
+ }
225
+ catch (err) {
226
+ logger.error("[Bot] Error reloading context after compaction:", err);
227
+ }
228
+ });
229
+ summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
230
+ if (!pinnedMessageManager.isInitialized()) {
231
+ return;
232
+ }
233
+ try {
234
+ await pinnedMessageManager.onSessionDiff(diffs);
235
+ }
236
+ catch (err) {
237
+ logger.error("[Bot] Error updating session diff:", err);
238
+ }
239
+ });
240
+ summaryAggregator.setOnFileChange((change) => {
241
+ if (!pinnedMessageManager.isInitialized()) {
242
+ return;
243
+ }
244
+ pinnedMessageManager.addFileChange(change);
245
+ });
246
+ pinnedMessageManager.setOnKeyboardUpdate(async (tokensUsed, tokensLimit) => {
247
+ try {
248
+ logger.debug(`[Bot] Updating keyboard with context: ${tokensUsed}/${tokensLimit}`);
249
+ keyboardManager.updateContext(tokensUsed, tokensLimit);
250
+ // Don't send automatic keyboard updates - keyboard will update naturally with user messages
251
+ }
252
+ catch (err) {
253
+ logger.error("[Bot] Error updating keyboard context:", err);
254
+ }
255
+ });
256
+ logger.info(`[Bot] Subscribing to OpenCode events for project: ${currentProject.worktree}`);
257
+ subscribeToEvents(currentProject.worktree, (event) => {
258
+ summaryAggregator.processEvent(event);
259
+ }).catch((err) => {
260
+ logger.error("Failed to subscribe to events:", err);
261
+ });
262
+ }
263
+ async function isSessionBusy(sessionId, directory) {
264
+ try {
265
+ const { data, error } = await opencodeClient.session.status({ directory });
266
+ if (error || !data) {
267
+ logger.warn("[Bot] Failed to check session status before prompt:", error);
268
+ return false;
269
+ }
270
+ const sessionStatus = data[sessionId];
271
+ if (!sessionStatus) {
272
+ return false;
273
+ }
274
+ logger.debug(`[Bot] Current session status before prompt: ${sessionStatus.type || "unknown"}`);
275
+ return sessionStatus.type === "busy";
276
+ }
277
+ catch (err) {
278
+ logger.warn("[Bot] Error checking session status before prompt:", err);
279
+ return false;
280
+ }
281
+ }
282
+ export function createBot() {
283
+ const bot = new Bot(config.telegram.token);
284
+ // Heartbeat для диагностики - проверяем что event loop не заблокирован
285
+ let heartbeatCounter = 0;
286
+ setInterval(() => {
287
+ heartbeatCounter++;
288
+ if (heartbeatCounter % 6 === 0) {
289
+ // Логируем каждые 30 секунд (5 сек * 6)
290
+ logger.debug(`[Bot] Heartbeat #${heartbeatCounter} - event loop alive`);
291
+ }
292
+ }, 5000);
293
+ // Логируем все API вызовы для диагностики
294
+ let lastGetUpdatesTime = Date.now();
295
+ bot.api.config.use(async (prev, method, payload, signal) => {
296
+ if (method === "getUpdates") {
297
+ const now = Date.now();
298
+ const timeSinceLast = now - lastGetUpdatesTime;
299
+ logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
300
+ lastGetUpdatesTime = now;
301
+ }
302
+ else if (method === "sendMessage") {
303
+ logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
304
+ }
305
+ return prev(method, payload, signal);
306
+ });
307
+ bot.use((ctx, next) => {
308
+ const hasCallbackQuery = !!ctx.callbackQuery;
309
+ const hasMessage = !!ctx.message;
310
+ const callbackData = ctx.callbackQuery?.data || "N/A";
311
+ logger.debug(`[DEBUG] Incoming update: hasCallbackQuery=${hasCallbackQuery}, hasMessage=${hasMessage}, callbackData=${callbackData}`);
312
+ return next();
313
+ });
314
+ bot.use(authMiddleware);
315
+ bot.use(ensureCommandsInitialized);
316
+ bot.command("start", startCommand);
317
+ bot.command("help", helpCommand);
318
+ bot.command("status", statusCommand);
319
+ bot.command("opencode_start", opencodeStartCommand);
320
+ bot.command("opencode_stop", opencodeStopCommand);
321
+ bot.command("projects", projectsCommand);
322
+ bot.command("sessions", sessionsCommand);
323
+ bot.command("new", newCommand);
324
+ bot.command("agent", handleAgentCommand);
325
+ bot.command("model", handleModelCommand);
326
+ bot.command("stop", stopCommand);
327
+ bot.on("callback_query:data", async (ctx) => {
328
+ logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
329
+ logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
330
+ try {
331
+ const handledSession = await handleSessionSelect(ctx);
332
+ const handledProject = await handleProjectSelect(ctx);
333
+ const handledQuestion = await handleQuestionCallback(ctx);
334
+ const handledPermission = await handlePermissionCallback(ctx);
335
+ const handledAgent = await handleAgentSelect(ctx);
336
+ const handledModel = await handleModelSelect(ctx);
337
+ const handledVariant = await handleVariantSelect(ctx);
338
+ const handledCompactConfirm = await handleCompactConfirm(ctx);
339
+ const handledCompactCancel = await handleCompactCancel(ctx);
340
+ logger.debug(`[Bot] Callback handled: session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compact=${handledCompactConfirm || handledCompactCancel}`);
341
+ if (!handledSession &&
342
+ !handledProject &&
343
+ !handledQuestion &&
344
+ !handledPermission &&
345
+ !handledAgent &&
346
+ !handledModel &&
347
+ !handledVariant &&
348
+ !handledCompactConfirm &&
349
+ !handledCompactCancel) {
350
+ logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
351
+ await ctx.answerCallbackQuery({ text: "Неизвестная команда" });
352
+ }
353
+ }
354
+ catch (err) {
355
+ logger.error("[Bot] Error handling callback:", err);
356
+ await ctx.answerCallbackQuery({ text: "Ошибка обработки" }).catch(() => { });
357
+ }
358
+ });
359
+ // Handle Reply Keyboard button press (agent mode indicator)
360
+ bot.hears(/^(📋|🛠️|💬|🔍|📝|📄|📦|🤖) \w+ Mode$/, async (ctx) => {
361
+ logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
362
+ try {
363
+ await showAgentSelectionMenu(ctx);
364
+ }
365
+ catch (err) {
366
+ logger.error("[Bot] Error showing agent menu:", err);
367
+ await ctx.reply("❌ Ошибка при загрузке списка агентов");
368
+ }
369
+ });
370
+ // Handle Reply Keyboard button press (model selector)
371
+ // Pattern: "ProviderName/ModelName" (may contain spaces, dots, parentheses, etc.)
372
+ bot.hears(/^[A-Za-z0-9\-\. ]+\/[A-Za-z0-9\-\. ()]+\.{0,3}$/, async (ctx) => {
373
+ logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
374
+ try {
375
+ await showModelSelectionMenu(ctx);
376
+ }
377
+ catch (err) {
378
+ logger.error("[Bot] Error showing model menu:", err);
379
+ await ctx.reply("❌ Ошибка при загрузке списка моделей");
380
+ }
381
+ });
382
+ // Handle Reply Keyboard button press (context button)
383
+ bot.hears(/^📊(?:\s|$)/, async (ctx) => {
384
+ logger.debug(`[Bot] Context button pressed: ${ctx.message?.text}`);
385
+ try {
386
+ await handleContextButtonPress(ctx);
387
+ }
388
+ catch (err) {
389
+ logger.error("[Bot] Error handling context button:", err);
390
+ await ctx.reply("❌ Ошибка при обработке кнопки контекста");
391
+ }
392
+ });
393
+ // Handle Reply Keyboard button press (variant selector)
394
+ bot.hears(/^💭 \w+$/, async (ctx) => {
395
+ logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
396
+ try {
397
+ await showVariantSelectionMenu(ctx);
398
+ }
399
+ catch (err) {
400
+ logger.error("[Bot] Error showing variant menu:", err);
401
+ await ctx.reply("❌ Ошибка при загрузке списка вариантов");
402
+ }
403
+ });
404
+ bot.on("message:text", async (ctx, next) => {
405
+ const text = ctx.message?.text;
406
+ if (text) {
407
+ const isCommand = text.startsWith("/");
408
+ logger.debug(`[Bot] Received text message: ${isCommand ? `command="${text}"` : `prompt (length=${text.length})`}, chatId=${ctx.chat.id}`);
409
+ }
410
+ await next();
411
+ });
412
+ // Remove any previously set global commands to prevent unauthorized users from seeing them
413
+ safeBackgroundTask({
414
+ taskName: "bot.clearGlobalCommands",
415
+ task: async () => {
416
+ try {
417
+ await Promise.all([
418
+ bot.api.setMyCommands([], { scope: { type: "default" } }),
419
+ bot.api.setMyCommands([], { scope: { type: "all_private_chats" } }),
420
+ ]);
421
+ return { success: true };
422
+ }
423
+ catch (error) {
424
+ return { success: false, error };
425
+ }
426
+ },
427
+ onSuccess: (result) => {
428
+ if (result.success) {
429
+ logger.info("[Bot] Cleared global commands (default and all_private_chats scopes)");
430
+ return;
431
+ }
432
+ logger.warn("[Bot] Could not clear global commands:", result.error);
433
+ },
434
+ });
435
+ bot.on("message:text", async (ctx) => {
436
+ const text = ctx.message?.text;
437
+ if (!text) {
438
+ return;
439
+ }
440
+ if (text.startsWith("/")) {
441
+ return;
442
+ }
443
+ if (questionManager.isActive()) {
444
+ await handleQuestionTextAnswer(ctx);
445
+ return;
446
+ }
447
+ const currentProject = getCurrentProject();
448
+ if (!currentProject) {
449
+ await ctx.reply("🏗 Проект не выбран.\n\nСначала выберите проект командой /projects.");
450
+ return;
451
+ }
452
+ await ensureEventSubscription();
453
+ botInstance = bot;
454
+ chatIdInstance = ctx.chat.id;
455
+ // Initialize pinned message manager if not already
456
+ if (!pinnedMessageManager.isInitialized()) {
457
+ pinnedMessageManager.initialize(bot.api, ctx.chat.id);
458
+ }
459
+ // Initialize keyboard manager if not already
460
+ keyboardManager.initialize(bot.api, ctx.chat.id);
461
+ let currentSession = getCurrentSession();
462
+ if (!currentSession) {
463
+ await ctx.reply("🔄 Создаю новую сессию...");
464
+ const { data: session, error } = await opencodeClient.session.create({
465
+ directory: currentProject.worktree,
466
+ });
467
+ if (error || !session) {
468
+ await ctx.reply("🔴 Не удалось создать сессию. Попробуйте команду /new или проверьте статус сервера /status.");
469
+ return;
470
+ }
471
+ logger.info(`[Bot] Created new session: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
472
+ currentSession = {
473
+ id: session.id,
474
+ title: session.title,
475
+ directory: currentProject.worktree,
476
+ };
477
+ setCurrentSession(currentSession);
478
+ // Create pinned message for new session
479
+ try {
480
+ await pinnedMessageManager.onSessionChange(session.id, session.title);
481
+ }
482
+ catch (err) {
483
+ logger.error("[Bot] Error creating pinned message for new session:", err);
484
+ }
485
+ const currentAgent = getStoredAgent();
486
+ const currentModel = getStoredModel();
487
+ const contextInfo = pinnedMessageManager.getContextInfo();
488
+ const variantName = formatVariantForButton(currentModel.variant || "default");
489
+ const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
490
+ await ctx.reply(`✅ Сессия создана: ${session.title}`, {
491
+ reply_markup: keyboard,
492
+ });
493
+ }
494
+ else {
495
+ logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
496
+ // Ensure pinned message exists for existing session
497
+ if (!pinnedMessageManager.getState().messageId) {
498
+ try {
499
+ await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
500
+ }
501
+ catch (err) {
502
+ logger.error("[Bot] Error creating pinned message for existing session:", err);
503
+ }
504
+ }
505
+ }
506
+ summaryAggregator.setSession(currentSession.id);
507
+ summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
508
+ const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
509
+ if (sessionIsBusy) {
510
+ logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
511
+ await ctx.reply("⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.");
512
+ return;
513
+ }
514
+ try {
515
+ const currentAgent = getStoredAgent();
516
+ const storedModel = getStoredModel();
517
+ const promptOptions = {
518
+ sessionID: currentSession.id,
519
+ directory: currentProject.worktree,
520
+ parts: [{ type: "text", text }],
521
+ agent: currentAgent,
522
+ };
523
+ // Use stored model (from settings or config)
524
+ if (storedModel.providerID && storedModel.modelID) {
525
+ promptOptions.model = {
526
+ providerID: storedModel.providerID,
527
+ modelID: storedModel.modelID,
528
+ };
529
+ // Add variant if specified
530
+ if (storedModel.variant) {
531
+ promptOptions.variant = storedModel.variant;
532
+ }
533
+ }
534
+ logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}...`);
535
+ // КРИТИЧНО: НЕ ждём завершения session.prompt!
536
+ // Если ждать, handler не завершится и grammY не будет делать getUpdates,
537
+ // что заблокирует получение callback_query от кнопок.
538
+ // Результат обработки придёт через SSE события.
539
+ safeBackgroundTask({
540
+ taskName: "session.prompt",
541
+ task: () => opencodeClient.session.prompt(promptOptions),
542
+ onSuccess: ({ error }) => {
543
+ if (error) {
544
+ logger.error("OpenCode API error:", JSON.stringify(error, null, 2));
545
+ // Отправляем ошибку через API напрямую, т.к. ctx уже недоступен
546
+ void bot.api
547
+ .sendMessage(ctx.chat.id, `🔴 Ошибка при отправке запроса.\n\nДетали: ${JSON.stringify(error)}`)
548
+ .catch(() => { });
549
+ return;
550
+ }
551
+ logger.info("[Bot] session.prompt completed");
552
+ },
553
+ onError: () => {
554
+ void bot.api
555
+ .sendMessage(ctx.chat.id, "🔴 Произошла ошибка при отправке запроса в OpenCode.")
556
+ .catch(() => { });
557
+ },
558
+ });
559
+ }
560
+ catch (err) {
561
+ logger.error("Error in prompt handler:", err);
562
+ await ctx.reply("🔴 Произошла ошибка.");
563
+ }
564
+ logger.debug("[Bot] message:text handler completed (prompt sent in background)");
565
+ });
566
+ bot.catch((err) => {
567
+ logger.error("[Bot] Unhandled error in bot:", err);
568
+ if (err.ctx) {
569
+ logger.error("[Bot] Error context - update type:", err.ctx.update ? Object.keys(err.ctx.update) : "unknown");
570
+ }
571
+ });
572
+ return bot;
573
+ }
@@ -0,0 +1,30 @@
1
+ import { config } from "../../config.js";
2
+ import { logger } from "../../utils/logger.js";
3
+ export async function authMiddleware(ctx, next) {
4
+ const userId = ctx.from?.id;
5
+ logger.debug(`[Auth] Checking access: userId=${userId}, allowedUserId=${config.telegram.allowedUserId}, hasCallbackQuery=${!!ctx.callbackQuery}, hasMessage=${!!ctx.message}`);
6
+ if (userId && userId === config.telegram.allowedUserId) {
7
+ logger.debug(`[Auth] Access granted for userId=${userId}`);
8
+ await next();
9
+ }
10
+ else {
11
+ // Silently ignore unauthorized users
12
+ logger.warn(`Unauthorized access attempt from user ID: ${userId}`);
13
+ // Actively hide commands for unauthorized users by setting empty command list
14
+ // Only do this if the chat is NOT the authorized user's chat
15
+ // (to avoid resetting commands when forwarded messages are received)
16
+ if (ctx.chat?.id && ctx.chat.id !== config.telegram.allowedUserId) {
17
+ try {
18
+ // Set empty commands for this specific chat (more reliable than deleteMyCommands)
19
+ await ctx.api.setMyCommands([], {
20
+ scope: { type: "chat", chat_id: ctx.chat.id },
21
+ });
22
+ logger.debug(`[Auth] Set empty commands for unauthorized chat_id=${ctx.chat.id}`);
23
+ }
24
+ catch (err) {
25
+ // Ignore errors
26
+ logger.debug(`[Auth] Could not set empty commands: ${err}`);
27
+ }
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,66 @@
1
+ import { Keyboard } from "grammy";
2
+ import { getAgentDisplayName } from "../../agent/types.js";
3
+ import { formatModelForButton } from "../../model/types.js";
4
+ /**
5
+ * Format token count for display (e.g., 150000 -> "150K", 1500000 -> "1.5M")
6
+ */
7
+ function formatTokenCount(count) {
8
+ if (count >= 1000000) {
9
+ return `${(count / 1000000).toFixed(1)}M`;
10
+ }
11
+ else if (count >= 1000) {
12
+ return `${Math.round(count / 1000)}K`;
13
+ }
14
+ return count.toString();
15
+ }
16
+ /**
17
+ * Format context information for button
18
+ */
19
+ function formatContextForButton(contextInfo) {
20
+ const used = formatTokenCount(contextInfo.tokensUsed);
21
+ const limit = formatTokenCount(contextInfo.tokensLimit);
22
+ const percent = Math.round((contextInfo.tokensUsed / contextInfo.tokensLimit) * 100);
23
+ return `📊 ${used} / ${limit} (${percent}%)`;
24
+ }
25
+ /**
26
+ * Create Reply Keyboard with agent, model, variant, and context indicators
27
+ * @param currentAgent Current agent name (e.g., "build", "plan")
28
+ * @param currentModel Current model info
29
+ * @param contextInfo Optional context information (tokens used/limit)
30
+ * @param variantName Optional variant display name (e.g., "💭 Default")
31
+ * @returns Reply Keyboard with agent and context in row 1, model and variant in row 2
32
+ */
33
+ export function createMainKeyboard(currentAgent, currentModel, contextInfo, variantName) {
34
+ const keyboard = new Keyboard();
35
+ const agentText = getAgentDisplayName(currentAgent);
36
+ // Format model: "providerID/modelID" (no emoji!)
37
+ const modelText = formatModelForButton(currentModel.providerID, currentModel.modelID);
38
+ // Context text - show "0" if no data available
39
+ const contextText = contextInfo ? formatContextForButton(contextInfo) : "📊 0";
40
+ // Variant text - default to "💭 Default" if not provided
41
+ const variantText = variantName || "💭 Default";
42
+ // Row 1: agent and context buttons
43
+ keyboard.text(agentText).text(contextText).row();
44
+ // Row 2: model and variant buttons
45
+ keyboard.text(modelText).text(variantText).row();
46
+ return keyboard.resized().persistent();
47
+ }
48
+ /**
49
+ * Create Reply Keyboard with agent mode indicator
50
+ * @param currentAgent Current agent name (e.g., "build", "plan")
51
+ * @returns Reply Keyboard with single button showing current mode
52
+ * @deprecated Use createMainKeyboard instead
53
+ */
54
+ export function createAgentKeyboard(currentAgent) {
55
+ const keyboard = new Keyboard();
56
+ const displayName = getAgentDisplayName(currentAgent);
57
+ // Single button with current agent mode
58
+ keyboard.text(displayName).row();
59
+ return keyboard.resized().persistent();
60
+ }
61
+ /**
62
+ * Remove Reply Keyboard (for cleanup)
63
+ */
64
+ export function removeKeyboard() {
65
+ return { remove_keyboard: true };
66
+ }