@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.
- package/dist/bot/handlers/permission.js +5 -2
- package/dist/bot/handlers/question.js +5 -2
- package/dist/bot/index.js +39 -13
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/ru.js +1 -0
- package/dist/opencode/events.js +78 -17
- package/dist/utils/error-format.js +29 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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: ${
|
|
260
|
-
subscribeToEvents(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 запущен**",
|
package/dist/opencode/events.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|