@grinev/opencode-telegram-bot 0.2.0 → 0.2.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.
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
2
2
  import { permissionManager } from "../../permission/manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getCurrentSession } from "../../session/manager.js";
5
6
  import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
@@ -69,8 +70,10 @@ export async function handlePermissionCallback(ctx) {
69
70
  async function handlePermissionReply(ctx, reply) {
70
71
  const requestID = permissionManager.getRequestID();
71
72
  const currentProject = getCurrentProject();
73
+ const currentSession = getCurrentSession();
72
74
  const chatId = ctx.chat?.id;
73
- if (!requestID || !currentProject || !chatId) {
75
+ const directory = currentSession?.directory ?? currentProject?.worktree;
76
+ if (!requestID || !directory || !chatId) {
74
77
  await ctx.answerCallbackQuery({
75
78
  text: t("permission.no_active_request_callback"),
76
79
  show_alert: true,
@@ -94,7 +97,7 @@ async function handlePermissionReply(ctx, reply) {
94
97
  taskName: "permission.reply",
95
98
  task: () => opencodeClient.permission.reply({
96
99
  requestID,
97
- directory: currentProject.worktree,
100
+ directory,
98
101
  reply,
99
102
  }),
100
103
  onSuccess: ({ error }) => {
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
2
2
  import { questionManager } from "../../question/manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getCurrentSession } from "../../session/manager.js";
5
6
  import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
@@ -194,9 +195,11 @@ async function showPollSummary(bot, chatId) {
194
195
  }
195
196
  async function sendAllAnswersToAgent(bot, chatId) {
196
197
  const currentProject = getCurrentProject();
198
+ const currentSession = getCurrentSession();
197
199
  const requestID = questionManager.getRequestID();
198
200
  const totalQuestions = questionManager.getTotalQuestions();
199
- if (!currentProject) {
201
+ const directory = currentSession?.directory ?? currentProject?.worktree;
202
+ if (!directory) {
200
203
  logger.error("[QuestionHandler] No project for sending answers");
201
204
  await bot.sendMessage(chatId, t("question.no_active_project"));
202
205
  return;
@@ -233,7 +236,7 @@ async function sendAllAnswersToAgent(bot, chatId) {
233
236
  taskName: "question.reply",
234
237
  task: () => opencodeClient.question.reply({
235
238
  requestID,
236
- directory: currentProject.worktree,
239
+ directory,
237
240
  answers: allAnswers,
238
241
  }),
239
242
  onSuccess: ({ error }) => {
package/dist/bot/index.js CHANGED
@@ -24,12 +24,13 @@ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
24
24
  import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
25
25
  import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
26
26
  import { questionManager } from "../question/manager.js";
27
+ import { permissionManager } from "../permission/manager.js";
27
28
  import { keyboardManager } from "../keyboard/manager.js";
28
- import { subscribeToEvents } from "../opencode/events.js";
29
+ import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
29
30
  import { summaryAggregator } from "../summary/aggregator.js";
30
31
  import { formatSummary, formatToolInfo } from "../summary/formatter.js";
31
32
  import { opencodeClient } from "../opencode/client.js";
32
- import { getCurrentSession, setCurrentSession } from "../session/manager.js";
33
+ import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
33
34
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
34
35
  import { getCurrentProject } from "../settings/manager.js";
35
36
  import { getStoredAgent } from "../agent/manager.js";
@@ -38,6 +39,7 @@ import { formatVariantForButton } from "../variant/manager.js";
38
39
  import { createMainKeyboard } from "./utils/keyboard.js";
39
40
  import { logger } from "../utils/logger.js";
40
41
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
42
+ import { formatErrorDetails } from "../utils/error-format.js";
41
43
  import { pinnedMessageManager } from "../pinned/manager.js";
42
44
  import { t } from "../i18n/index.js";
43
45
  let botInstance = null;
@@ -68,10 +70,9 @@ async function ensureCommandsInitialized(ctx, next) {
68
70
  }
69
71
  await next();
70
72
  }
71
- async function ensureEventSubscription() {
72
- const currentProject = getCurrentProject();
73
- if (!currentProject) {
74
- logger.error("No current project found for event subscription");
73
+ async function ensureEventSubscription(directory) {
74
+ if (!directory) {
75
+ logger.error("No directory found for event subscription");
75
76
  return;
76
77
  }
77
78
  summaryAggregator.setOnComplete(async (sessionId, messageText) => {
@@ -256,8 +257,8 @@ async function ensureEventSubscription() {
256
257
  logger.error("[Bot] Error updating keyboard context:", err);
257
258
  }
258
259
  });
259
- logger.info(`[Bot] Subscribing to OpenCode events for project: ${currentProject.worktree}`);
260
- subscribeToEvents(currentProject.worktree, (event) => {
260
+ logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
261
+ subscribeToEvents(directory, (event) => {
261
262
  if (event.type === "session.created" || event.type === "session.updated") {
262
263
  const info = event.properties.info;
263
264
  if (info?.directory) {
@@ -291,6 +292,23 @@ async function isSessionBusy(sessionId, directory) {
291
292
  return false;
292
293
  }
293
294
  }
295
+ async function resetMismatchedSessionContext() {
296
+ stopEventListening();
297
+ summaryAggregator.clear();
298
+ questionManager.clear();
299
+ permissionManager.clear();
300
+ clearSession();
301
+ keyboardManager.clearContext();
302
+ if (!pinnedMessageManager.isInitialized()) {
303
+ return;
304
+ }
305
+ try {
306
+ await pinnedMessageManager.clear();
307
+ }
308
+ catch (err) {
309
+ logger.error("[Bot] Failed to clear pinned message during session reset:", err);
310
+ }
311
+ }
294
312
  export function createBot() {
295
313
  const bot = new Bot(config.telegram.token);
296
314
  // Heartbeat for diagnostics: verify the event loop is not blocked
@@ -462,7 +480,6 @@ export function createBot() {
462
480
  await ctx.reply(t("bot.project_not_selected"));
463
481
  return;
464
482
  }
465
- await ensureEventSubscription();
466
483
  botInstance = bot;
467
484
  chatIdInstance = ctx.chat.id;
468
485
  // Initialize pinned message manager if not already
@@ -472,6 +489,12 @@ export function createBot() {
472
489
  // Initialize keyboard manager if not already
473
490
  keyboardManager.initialize(bot.api, ctx.chat.id);
474
491
  let currentSession = getCurrentSession();
492
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
493
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
494
+ await resetMismatchedSessionContext();
495
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
496
+ return;
497
+ }
475
498
  if (!currentSession) {
476
499
  await ctx.reply(t("bot.creating_session"));
477
500
  const { data: session, error } = await opencodeClient.session.create({
@@ -517,6 +540,7 @@ export function createBot() {
517
540
  }
518
541
  }
519
542
  }
543
+ await ensureEventSubscription(currentSession.directory);
520
544
  summaryAggregator.setSession(currentSession.id);
521
545
  summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
522
546
  const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
@@ -530,7 +554,7 @@ export function createBot() {
530
554
  const storedModel = getStoredModel();
531
555
  const promptOptions = {
532
556
  sessionID: currentSession.id,
533
- directory: currentProject.worktree,
557
+ directory: currentSession.directory,
534
558
  parts: [{ type: "text", text }],
535
559
  agent: currentAgent,
536
560
  };
@@ -555,18 +579,20 @@ export function createBot() {
555
579
  task: () => opencodeClient.session.prompt(promptOptions),
556
580
  onSuccess: ({ error }) => {
557
581
  if (error) {
558
- logger.error("OpenCode API error:", JSON.stringify(error, null, 2));
582
+ const details = formatErrorDetails(error);
583
+ logger.error("OpenCode API error:", error);
559
584
  // Send the error via API directly because ctx is no longer available
560
585
  void bot.api
561
586
  .sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
562
- details: JSON.stringify(error),
587
+ details,
563
588
  }))
564
589
  .catch(() => { });
565
590
  return;
566
591
  }
567
592
  logger.info("[Bot] session.prompt completed");
568
593
  },
569
- onError: () => {
594
+ onError: (error) => {
595
+ logger.error("[Bot] session.prompt background task failed:", error);
570
596
  void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
571
597
  },
572
598
  });
package/dist/i18n/en.js CHANGED
@@ -26,6 +26,7 @@ export const en = {
26
26
  "bot.create_session_error": "🔴 Failed to create session. Try /new or check server status with /status.",
27
27
  "bot.session_created": "✅ Session created: {title}",
28
28
  "bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
29
+ "bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
29
30
  "bot.prompt_send_error_detailed": "🔴 Failed to send request.\n\nDetails: {details}",
30
31
  "bot.prompt_send_error": "🔴 An error occurred while sending request to OpenCode.",
31
32
  "status.header_running": "🟢 **OpenCode Server is running**",
package/dist/i18n/ru.js CHANGED
@@ -26,6 +26,7 @@ export const ru = {
26
26
  "bot.create_session_error": "🔴 Не удалось создать сессию. Попробуйте команду /new или проверьте статус сервера /status.",
27
27
  "bot.session_created": "✅ Сессия создана: {title}",
28
28
  "bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
29
+ "bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
29
30
  "bot.prompt_send_error_detailed": "🔴 Ошибка при отправке запроса.\n\nДетали: {details}",
30
31
  "bot.prompt_send_error": "🔴 Произошла ошибка при отправке запроса в OpenCode.",
31
32
  "status.header_running": "🟢 **OpenCode Server запущен**",
@@ -1,12 +1,38 @@
1
1
  import { opencodeClient } from "./client.js";
2
2
  import { logger } from "../utils/logger.js";
3
+ const RECONNECT_BASE_DELAY_MS = 1000;
4
+ const RECONNECT_MAX_DELAY_MS = 15000;
5
+ const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
3
6
  let eventStream = null;
4
7
  let eventCallback = null;
5
8
  let isListening = false;
6
9
  let activeDirectory = null;
7
10
  let streamAbortController = null;
11
+ function getReconnectDelayMs(attempt) {
12
+ const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
13
+ return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
14
+ }
15
+ function waitWithAbort(ms, signal) {
16
+ return new Promise((resolve) => {
17
+ if (signal.aborted) {
18
+ resolve(false);
19
+ return;
20
+ }
21
+ const onAbort = () => {
22
+ clearTimeout(timeout);
23
+ signal.removeEventListener("abort", onAbort);
24
+ resolve(false);
25
+ };
26
+ const timeout = setTimeout(() => {
27
+ signal.removeEventListener("abort", onAbort);
28
+ resolve(true);
29
+ }, ms);
30
+ signal.addEventListener("abort", onAbort, { once: true });
31
+ });
32
+ }
8
33
  export async function subscribeToEvents(directory, callback) {
9
34
  if (isListening && activeDirectory === directory) {
35
+ eventCallback = callback;
10
36
  logger.debug(`Event listener already running for ${directory}`);
11
37
  return;
12
38
  }
@@ -23,24 +49,59 @@ export async function subscribeToEvents(directory, callback) {
23
49
  isListening = true;
24
50
  streamAbortController = controller;
25
51
  try {
26
- const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
27
- if (!result.stream) {
28
- throw new Error("No stream returned from event subscription");
29
- }
30
- eventStream = result.stream;
31
- for await (const event of eventStream) {
32
- if (!isListening || activeDirectory !== directory) {
33
- logger.debug(`Event listener stopped or changed directory, breaking loop`);
34
- break;
52
+ let reconnectAttempt = 0;
53
+ while (isListening && activeDirectory === directory && !controller.signal.aborted) {
54
+ try {
55
+ const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
56
+ if (!result.stream) {
57
+ throw new Error(FATAL_NO_STREAM_ERROR);
58
+ }
59
+ reconnectAttempt = 0;
60
+ eventStream = result.stream;
61
+ for await (const event of eventStream) {
62
+ if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
63
+ logger.debug(`Event listener stopped or changed directory, breaking loop`);
64
+ break;
65
+ }
66
+ // CRITICAL: Explicitly yield to the event loop BEFORE processing the event
67
+ // This allows grammY to handle getUpdates between SSE events
68
+ await new Promise((resolve) => setImmediate(resolve));
69
+ if (eventCallback) {
70
+ // Use setImmediate to avoid blocking the event loop
71
+ // and let grammY process incoming Telegram updates
72
+ const callbackSnapshot = eventCallback;
73
+ setImmediate(() => callbackSnapshot(event));
74
+ }
75
+ }
76
+ eventStream = null;
77
+ if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
78
+ break;
79
+ }
80
+ reconnectAttempt++;
81
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
82
+ logger.warn(`Event stream ended for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
83
+ const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
84
+ if (!shouldContinue) {
85
+ break;
86
+ }
35
87
  }
36
- // CRITICAL: Explicitly yield to the event loop BEFORE processing the event
37
- // This allows grammY to handle getUpdates between SSE events
38
- await new Promise((resolve) => setImmediate(resolve));
39
- if (eventCallback) {
40
- // Use setImmediate to avoid blocking the event loop
41
- // and let grammY process incoming Telegram updates
42
- const callback = eventCallback;
43
- setImmediate(() => callback(event));
88
+ catch (error) {
89
+ eventStream = null;
90
+ if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
91
+ logger.info("Event listener aborted");
92
+ return;
93
+ }
94
+ if (error instanceof Error && error.message === FATAL_NO_STREAM_ERROR) {
95
+ logger.error("Event stream fatal error:", error);
96
+ throw error;
97
+ }
98
+ reconnectAttempt++;
99
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
100
+ logger.error(`Event stream error for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`, error);
101
+ const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
102
+ if (!shouldContinue) {
103
+ break;
104
+ }
44
105
  }
45
106
  }
46
107
  }
@@ -0,0 +1,29 @@
1
+ const DEFAULT_MAX_ERROR_DETAILS_LENGTH = 1500;
2
+ function clipText(value, maxLength) {
3
+ if (value.length <= maxLength) {
4
+ return value;
5
+ }
6
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
7
+ }
8
+ export function formatErrorDetails(error, maxLength = DEFAULT_MAX_ERROR_DETAILS_LENGTH) {
9
+ let details = "";
10
+ if (error instanceof Error) {
11
+ details = error.stack ?? `${error.name}: ${error.message}`;
12
+ }
13
+ else if (typeof error === "string") {
14
+ details = error;
15
+ }
16
+ else {
17
+ try {
18
+ details = JSON.stringify(error, null, 2);
19
+ }
20
+ catch {
21
+ details = String(error);
22
+ }
23
+ }
24
+ const normalized = details.trim();
25
+ if (!normalized || normalized === "{}" || normalized === "[object Object]") {
26
+ return "unknown error";
27
+ }
28
+ return clipText(normalized, maxLength);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",