@grinev/opencode-telegram-bot 0.2.0 → 0.3.0

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.
package/.env.example CHANGED
@@ -4,6 +4,14 @@ TELEGRAM_BOT_TOKEN=
4
4
  # Allowed Telegram User ID (from @userinfobot)
5
5
  TELEGRAM_ALLOWED_USER_ID=
6
6
 
7
+ # Telegram Proxy URL (optional)
8
+ # Supports socks5://, socks4://, http://, https:// protocols
9
+ # Examples:
10
+ # TELEGRAM_PROXY_URL=socks5://proxy.example.com:1080
11
+ # TELEGRAM_PROXY_URL=socks5://user:password@proxy.example.com:1080
12
+ # TELEGRAM_PROXY_URL=http://proxy.example.com:8080
13
+ # TELEGRAM_PROXY_URL=
14
+
7
15
  # OpenCode API URL (optional, default: http://localhost:4096)
8
16
  # OPENCODE_API_URL=http://localhost:4096
9
17
 
package/README.md CHANGED
@@ -117,6 +117,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
117
117
  | -------------------------- | -------------------------------------------- | :------: | ----------------------- |
118
118
  | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes | — |
119
119
  | `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes | — |
120
+ | `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No | — |
120
121
  | `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
121
122
  | `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
122
123
  | `OPENCODE_SERVER_PASSWORD` | Server auth password | No | — |
@@ -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
@@ -2,6 +2,8 @@ import { Bot, InputFile } from "grammy";
2
2
  import { promises as fs } from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { SocksProxyAgent } from "socks-proxy-agent";
6
+ import { HttpsProxyAgent } from "https-proxy-agent";
5
7
  import { config } from "../config.js";
6
8
  import { authMiddleware } from "./middleware/auth.js";
7
9
  import { BOT_COMMANDS } from "./commands/definitions.js";
@@ -24,12 +26,13 @@ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
24
26
  import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
25
27
  import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
26
28
  import { questionManager } from "../question/manager.js";
29
+ import { permissionManager } from "../permission/manager.js";
27
30
  import { keyboardManager } from "../keyboard/manager.js";
28
- import { subscribeToEvents } from "../opencode/events.js";
31
+ import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
29
32
  import { summaryAggregator } from "../summary/aggregator.js";
30
33
  import { formatSummary, formatToolInfo } from "../summary/formatter.js";
31
34
  import { opencodeClient } from "../opencode/client.js";
32
- import { getCurrentSession, setCurrentSession } from "../session/manager.js";
35
+ import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
33
36
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
34
37
  import { getCurrentProject } from "../settings/manager.js";
35
38
  import { getStoredAgent } from "../agent/manager.js";
@@ -38,6 +41,7 @@ import { formatVariantForButton } from "../variant/manager.js";
38
41
  import { createMainKeyboard } from "./utils/keyboard.js";
39
42
  import { logger } from "../utils/logger.js";
40
43
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
44
+ import { formatErrorDetails } from "../utils/error-format.js";
41
45
  import { pinnedMessageManager } from "../pinned/manager.js";
42
46
  import { t } from "../i18n/index.js";
43
47
  let botInstance = null;
@@ -68,10 +72,9 @@ async function ensureCommandsInitialized(ctx, next) {
68
72
  }
69
73
  await next();
70
74
  }
71
- async function ensureEventSubscription() {
72
- const currentProject = getCurrentProject();
73
- if (!currentProject) {
74
- logger.error("No current project found for event subscription");
75
+ async function ensureEventSubscription(directory) {
76
+ if (!directory) {
77
+ logger.error("No directory found for event subscription");
75
78
  return;
76
79
  }
77
80
  summaryAggregator.setOnComplete(async (sessionId, messageText) => {
@@ -256,8 +259,8 @@ async function ensureEventSubscription() {
256
259
  logger.error("[Bot] Error updating keyboard context:", err);
257
260
  }
258
261
  });
259
- logger.info(`[Bot] Subscribing to OpenCode events for project: ${currentProject.worktree}`);
260
- subscribeToEvents(currentProject.worktree, (event) => {
262
+ logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
263
+ subscribeToEvents(directory, (event) => {
261
264
  if (event.type === "session.created" || event.type === "session.updated") {
262
265
  const info = event.properties.info;
263
266
  if (info?.directory) {
@@ -291,8 +294,44 @@ async function isSessionBusy(sessionId, directory) {
291
294
  return false;
292
295
  }
293
296
  }
297
+ async function resetMismatchedSessionContext() {
298
+ stopEventListening();
299
+ summaryAggregator.clear();
300
+ questionManager.clear();
301
+ permissionManager.clear();
302
+ clearSession();
303
+ keyboardManager.clearContext();
304
+ if (!pinnedMessageManager.isInitialized()) {
305
+ return;
306
+ }
307
+ try {
308
+ await pinnedMessageManager.clear();
309
+ }
310
+ catch (err) {
311
+ logger.error("[Bot] Failed to clear pinned message during session reset:", err);
312
+ }
313
+ }
294
314
  export function createBot() {
295
- const bot = new Bot(config.telegram.token);
315
+ const botOptions = {};
316
+ if (config.telegram.proxyUrl) {
317
+ const proxyUrl = config.telegram.proxyUrl;
318
+ let agent;
319
+ if (proxyUrl.startsWith("socks")) {
320
+ agent = new SocksProxyAgent(proxyUrl);
321
+ logger.info(`[Bot] Using SOCKS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
322
+ }
323
+ else {
324
+ agent = new HttpsProxyAgent(proxyUrl);
325
+ logger.info(`[Bot] Using HTTP/HTTPS proxy: ${proxyUrl.replace(/\/\/.*@/, "//***@")}`);
326
+ }
327
+ botOptions.client = {
328
+ baseFetchConfig: {
329
+ agent,
330
+ compress: true,
331
+ },
332
+ };
333
+ }
334
+ const bot = new Bot(config.telegram.token, botOptions);
296
335
  // Heartbeat for diagnostics: verify the event loop is not blocked
297
336
  let heartbeatCounter = 0;
298
337
  setInterval(() => {
@@ -462,7 +501,6 @@ export function createBot() {
462
501
  await ctx.reply(t("bot.project_not_selected"));
463
502
  return;
464
503
  }
465
- await ensureEventSubscription();
466
504
  botInstance = bot;
467
505
  chatIdInstance = ctx.chat.id;
468
506
  // Initialize pinned message manager if not already
@@ -472,6 +510,12 @@ export function createBot() {
472
510
  // Initialize keyboard manager if not already
473
511
  keyboardManager.initialize(bot.api, ctx.chat.id);
474
512
  let currentSession = getCurrentSession();
513
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
514
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
515
+ await resetMismatchedSessionContext();
516
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
517
+ return;
518
+ }
475
519
  if (!currentSession) {
476
520
  await ctx.reply(t("bot.creating_session"));
477
521
  const { data: session, error } = await opencodeClient.session.create({
@@ -517,6 +561,7 @@ export function createBot() {
517
561
  }
518
562
  }
519
563
  }
564
+ await ensureEventSubscription(currentSession.directory);
520
565
  summaryAggregator.setSession(currentSession.id);
521
566
  summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
522
567
  const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
@@ -530,7 +575,7 @@ export function createBot() {
530
575
  const storedModel = getStoredModel();
531
576
  const promptOptions = {
532
577
  sessionID: currentSession.id,
533
- directory: currentProject.worktree,
578
+ directory: currentSession.directory,
534
579
  parts: [{ type: "text", text }],
535
580
  agent: currentAgent,
536
581
  };
@@ -555,18 +600,20 @@ export function createBot() {
555
600
  task: () => opencodeClient.session.prompt(promptOptions),
556
601
  onSuccess: ({ error }) => {
557
602
  if (error) {
558
- logger.error("OpenCode API error:", JSON.stringify(error, null, 2));
603
+ const details = formatErrorDetails(error);
604
+ logger.error("OpenCode API error:", error);
559
605
  // Send the error via API directly because ctx is no longer available
560
606
  void bot.api
561
607
  .sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
562
- details: JSON.stringify(error),
608
+ details,
563
609
  }))
564
610
  .catch(() => { });
565
611
  return;
566
612
  }
567
613
  logger.info("[Bot] session.prompt completed");
568
614
  },
569
- onError: () => {
615
+ onError: (error) => {
616
+ logger.error("[Bot] session.prompt background task failed:", error);
570
617
  void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
571
618
  },
572
619
  });
package/dist/config.js CHANGED
@@ -38,6 +38,7 @@ export const config = {
38
38
  telegram: {
39
39
  token: getEnvVar("TELEGRAM_BOT_TOKEN"),
40
40
  allowedUserId: parseInt(getEnvVar("TELEGRAM_ALLOWED_USER_ID"), 10),
41
+ proxyUrl: getEnvVar("TELEGRAM_PROXY_URL", false),
41
42
  },
42
43
  opencode: {
43
44
  apiUrl: getEnvVar("OPENCODE_API_URL", false) || "http://localhost:4096",
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.3.0",
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",
@@ -55,7 +55,9 @@
55
55
  "@opencode-ai/sdk": "^1.1.21",
56
56
  "better-sqlite3": "^12.6.2",
57
57
  "dotenv": "^17.2.3",
58
- "grammy": "^1.39.2"
58
+ "grammy": "^1.39.2",
59
+ "https-proxy-agent": "^7.0.6",
60
+ "socks-proxy-agent": "^8.0.5"
59
61
  },
60
62
  "devDependencies": {
61
63
  "@types/better-sqlite3": "^7.6.13",