@grinev/opencode-telegram-bot 0.19.2 → 0.20.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 +5 -0
- package/README.md +5 -0
- package/dist/app/start-bot-app.js +4 -5
- package/dist/attach/service.js +34 -8
- package/dist/background-session/tracker.js +137 -0
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/detach.js +54 -0
- package/dist/bot/commands/ls.js +445 -0
- package/dist/bot/commands/open.js +9 -4
- package/dist/bot/commands/opencode-start.js +3 -3
- package/dist/bot/commands/projects.js +6 -2
- package/dist/bot/commands/sessions.js +233 -82
- package/dist/bot/commands/worktree.js +7 -2
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/index.js +86 -16
- package/dist/bot/utils/external-user-input.js +10 -2
- package/dist/bot/utils/send-downloaded-file.js +57 -0
- package/dist/bot/utils/switch-project.js +9 -14
- package/dist/config.js +1 -0
- package/dist/i18n/de.js +26 -0
- package/dist/i18n/en.js +26 -0
- package/dist/i18n/es.js +26 -0
- package/dist/i18n/fr.js +26 -0
- package/dist/i18n/ru.js +26 -0
- package/dist/i18n/zh.js +26 -0
- package/dist/interaction/busy.js +1 -1
- package/dist/interaction/manager.js +1 -1
- package/dist/model/context-limit.js +13 -2
- package/dist/opencode/auto-restart.js +4 -3
- package/dist/opencode/events.js +13 -2
- package/dist/opencode/ready-lifecycle.js +45 -0
- package/dist/opencode/ready-refresh.js +19 -1
- package/dist/pinned/manager.js +43 -7
- package/dist/scheduled-task/executor.js +3 -0
- package/dist/scheduled-task/runtime.js +2 -0
- package/dist/scheduled-task/schedule-parser.js +6 -0
- package/dist/scheduled-task/session-ignore.js +53 -0
- package/dist/settings/manager.js +12 -0
- package/dist/utils/opencode-error.js +13 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ import { resolveProjectAgent } from "../../agent/manager.js";
|
|
|
4
4
|
import { setCurrentSession } from "../../session/manager.js";
|
|
5
5
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
6
6
|
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
7
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
7
8
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
8
9
|
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
9
10
|
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
@@ -12,9 +13,22 @@ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
|
12
13
|
import { config } from "../../config.js";
|
|
13
14
|
import { getDateLocale, t } from "../../i18n/index.js";
|
|
14
15
|
import { attachToSession } from "../../attach/service.js";
|
|
16
|
+
import { renderAssistantFinalPartsSafe } from "../utils/assistant-rendering.js";
|
|
17
|
+
import { sendRenderedBotPart } from "../utils/telegram-text.js";
|
|
15
18
|
const SESSION_CALLBACK_PREFIX = "session:";
|
|
16
19
|
const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
|
|
20
|
+
const BACKGROUND_SESSION_CALLBACK_PREFIX = "background-session:";
|
|
17
21
|
const SESSION_FETCH_EXTRA_COUNT = 1;
|
|
22
|
+
const BACKGROUND_SESSION_KIND_CALLBACK_MARKERS = {
|
|
23
|
+
assistant_response: "a",
|
|
24
|
+
question_asked: "q",
|
|
25
|
+
permission_asked: "p",
|
|
26
|
+
};
|
|
27
|
+
const BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER = {
|
|
28
|
+
a: "assistant_response",
|
|
29
|
+
q: "question_asked",
|
|
30
|
+
p: "permission_asked",
|
|
31
|
+
};
|
|
18
32
|
function buildSessionPageCallback(page) {
|
|
19
33
|
return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
|
|
20
34
|
}
|
|
@@ -39,6 +53,35 @@ function parseSessionIdCallback(data) {
|
|
|
39
53
|
const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
|
|
40
54
|
return sessionId.length > 0 ? sessionId : null;
|
|
41
55
|
}
|
|
56
|
+
function parseBackgroundSessionCallback(data) {
|
|
57
|
+
if (!data.startsWith(BACKGROUND_SESSION_CALLBACK_PREFIX)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const payload = data.slice(BACKGROUND_SESSION_CALLBACK_PREFIX.length);
|
|
61
|
+
const markerSeparatorIndex = payload.indexOf(":");
|
|
62
|
+
if (markerSeparatorIndex < 0) {
|
|
63
|
+
return payload.length > 0 ? { sessionId: payload, kind: null } : null;
|
|
64
|
+
}
|
|
65
|
+
const marker = payload.slice(0, markerSeparatorIndex);
|
|
66
|
+
const sessionId = payload.slice(markerSeparatorIndex + 1);
|
|
67
|
+
const kind = BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER[marker];
|
|
68
|
+
if (!kind || sessionId.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return { sessionId, kind };
|
|
72
|
+
}
|
|
73
|
+
async function removeCallbackReplyMarkup(ctx) {
|
|
74
|
+
try {
|
|
75
|
+
await ctx.editMessageReplyMarkup();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.debug("[Sessions] Failed to remove background session button:", err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function buildBackgroundSessionOpenKeyboard(sessionId, kind) {
|
|
82
|
+
const marker = BACKGROUND_SESSION_KIND_CALLBACK_MARKERS[kind];
|
|
83
|
+
return new InlineKeyboard().text(t("background.open_session_button"), `${BACKGROUND_SESSION_CALLBACK_PREFIX}${marker}:${sessionId}`);
|
|
84
|
+
}
|
|
42
85
|
function formatSessionsSelectText(page) {
|
|
43
86
|
if (page === 0) {
|
|
44
87
|
return t("sessions.select");
|
|
@@ -119,6 +162,140 @@ export async function sessionsCommand(ctx) {
|
|
|
119
162
|
await ctx.reply(t("sessions.fetch_error"));
|
|
120
163
|
}
|
|
121
164
|
}
|
|
165
|
+
async function selectSessionById(ctx, deps, sessionId, options) {
|
|
166
|
+
const currentProject = getCurrentProject();
|
|
167
|
+
if (!currentProject) {
|
|
168
|
+
clearAllInteractionState("session_select_project_missing");
|
|
169
|
+
await ctx.answerCallbackQuery();
|
|
170
|
+
await ctx.reply(t("sessions.select_project_first"));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const { data: session, error } = await opencodeClient.session.get({
|
|
174
|
+
sessionID: sessionId,
|
|
175
|
+
directory: currentProject.worktree,
|
|
176
|
+
});
|
|
177
|
+
if (error || !session) {
|
|
178
|
+
throw error || new Error("Failed to get session details");
|
|
179
|
+
}
|
|
180
|
+
logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}, source=${options.source}`);
|
|
181
|
+
const sessionInfo = {
|
|
182
|
+
id: session.id,
|
|
183
|
+
title: session.title,
|
|
184
|
+
directory: currentProject.worktree,
|
|
185
|
+
};
|
|
186
|
+
setCurrentSession(sessionInfo);
|
|
187
|
+
clearAllInteractionState("session_switched");
|
|
188
|
+
await ctx.answerCallbackQuery();
|
|
189
|
+
let loadingMessageId = null;
|
|
190
|
+
if (ctx.chat) {
|
|
191
|
+
try {
|
|
192
|
+
const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, t("sessions.loading_context"));
|
|
193
|
+
loadingMessageId = loadingMessage.message_id;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.error("[Sessions] Failed to send loading message:", err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await attachToSession({
|
|
201
|
+
bot: deps.bot,
|
|
202
|
+
chatId: ctx.chat.id,
|
|
203
|
+
session: sessionInfo,
|
|
204
|
+
ensureEventSubscription: deps.ensureEventSubscription,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (loadingMessageId) {
|
|
209
|
+
try {
|
|
210
|
+
await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
|
|
211
|
+
}
|
|
212
|
+
catch (deleteError) {
|
|
213
|
+
logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
logger.error("[Sessions] Error following selected session:", err);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
if (ctx.chat) {
|
|
220
|
+
const chatId = ctx.chat.id;
|
|
221
|
+
const currentAgent = await resolveProjectAgent();
|
|
222
|
+
keyboardManager.updateAgent(currentAgent);
|
|
223
|
+
const contextInfo = keyboardManager.getContextInfo();
|
|
224
|
+
if (contextInfo) {
|
|
225
|
+
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
226
|
+
}
|
|
227
|
+
if (loadingMessageId) {
|
|
228
|
+
try {
|
|
229
|
+
await ctx.api.deleteMessage(chatId, loadingMessageId);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
logger.debug("[Sessions] Failed to delete loading message:", err);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const keyboard = keyboardManager.getKeyboard();
|
|
236
|
+
try {
|
|
237
|
+
await ctx.api.sendMessage(chatId, t("sessions.selected", { title: session.title }), {
|
|
238
|
+
reply_markup: keyboard,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
logger.error("[Sessions] Failed to send selection message:", err);
|
|
243
|
+
}
|
|
244
|
+
if (options.postSelectAction === "preview") {
|
|
245
|
+
safeBackgroundTask({
|
|
246
|
+
taskName: "sessions.sendPreview",
|
|
247
|
+
task: () => sendSessionPreview(ctx.api, chatId, null, session.title, session.id, currentProject.worktree),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (options.postSelectAction === "latest_assistant_response") {
|
|
251
|
+
safeBackgroundTask({
|
|
252
|
+
taskName: "sessions.sendLatestAssistantResponse",
|
|
253
|
+
task: () => sendLatestAssistantResponse(ctx.api, chatId, session.id, currentProject.worktree),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (options.removeCallbackReplyMarkup) {
|
|
258
|
+
await removeCallbackReplyMarkup(ctx);
|
|
259
|
+
}
|
|
260
|
+
if (options.deleteCallbackMessage) {
|
|
261
|
+
await ctx.deleteMessage();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function shouldBlockBackgroundSessionOpen() {
|
|
265
|
+
const activeInteraction = interactionManager.getSnapshot();
|
|
266
|
+
return activeInteraction !== null && activeInteraction.kind !== "inline";
|
|
267
|
+
}
|
|
268
|
+
export async function handleBackgroundSessionOpen(ctx, deps) {
|
|
269
|
+
const data = ctx.callbackQuery?.data;
|
|
270
|
+
if (!data) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const payload = parseBackgroundSessionCallback(data);
|
|
274
|
+
if (!payload) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
if (isForegroundBusy()) {
|
|
278
|
+
await replyBusyBlocked(ctx);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (shouldBlockBackgroundSessionOpen()) {
|
|
282
|
+
await ctx.answerCallbackQuery({ text: t("interaction.blocked.finish_current") }).catch(() => { });
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await selectSessionById(ctx, deps, payload.sessionId, {
|
|
287
|
+
source: "background_notification",
|
|
288
|
+
deleteCallbackMessage: false,
|
|
289
|
+
removeCallbackReplyMarkup: true,
|
|
290
|
+
postSelectAction: payload.kind === "assistant_response" ? "latest_assistant_response" : "none",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
logger.error("[Sessions] Error selecting background session:", error);
|
|
295
|
+
await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }).catch(() => { });
|
|
296
|
+
}
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
122
299
|
export async function handleSessionSelect(ctx, deps) {
|
|
123
300
|
const callbackQuery = ctx.callbackQuery;
|
|
124
301
|
if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
@@ -167,86 +344,12 @@ export async function handleSessionSelect(ctx, deps) {
|
|
|
167
344
|
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
168
345
|
return true;
|
|
169
346
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
347
|
+
await selectSessionById(ctx, deps, sessionId, {
|
|
348
|
+
source: "menu",
|
|
349
|
+
deleteCallbackMessage: true,
|
|
350
|
+
removeCallbackReplyMarkup: false,
|
|
351
|
+
postSelectAction: "preview",
|
|
173
352
|
});
|
|
174
|
-
if (error || !session) {
|
|
175
|
-
throw error || new Error("Failed to get session details");
|
|
176
|
-
}
|
|
177
|
-
logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
178
|
-
const sessionInfo = {
|
|
179
|
-
id: session.id,
|
|
180
|
-
title: session.title,
|
|
181
|
-
directory: currentProject.worktree,
|
|
182
|
-
};
|
|
183
|
-
setCurrentSession(sessionInfo);
|
|
184
|
-
clearAllInteractionState("session_switched");
|
|
185
|
-
await ctx.answerCallbackQuery();
|
|
186
|
-
let loadingMessageId = null;
|
|
187
|
-
if (ctx.chat) {
|
|
188
|
-
try {
|
|
189
|
-
const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, t("sessions.loading_context"));
|
|
190
|
-
loadingMessageId = loadingMessage.message_id;
|
|
191
|
-
}
|
|
192
|
-
catch (err) {
|
|
193
|
-
logger.error("[Sessions] Failed to send loading message:", err);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
await attachToSession({
|
|
198
|
-
bot: deps.bot,
|
|
199
|
-
chatId: ctx.chat.id,
|
|
200
|
-
session: sessionInfo,
|
|
201
|
-
ensureEventSubscription: deps.ensureEventSubscription,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
catch (err) {
|
|
205
|
-
if (loadingMessageId) {
|
|
206
|
-
try {
|
|
207
|
-
await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
|
|
208
|
-
}
|
|
209
|
-
catch (deleteError) {
|
|
210
|
-
logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
logger.error("[Sessions] Error following selected session:", err);
|
|
214
|
-
throw err;
|
|
215
|
-
}
|
|
216
|
-
if (ctx.chat) {
|
|
217
|
-
const chatId = ctx.chat.id;
|
|
218
|
-
const currentAgent = await resolveProjectAgent();
|
|
219
|
-
keyboardManager.updateAgent(currentAgent);
|
|
220
|
-
const contextInfo = keyboardManager.getContextInfo();
|
|
221
|
-
if (contextInfo) {
|
|
222
|
-
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
223
|
-
}
|
|
224
|
-
// Delete loading message
|
|
225
|
-
if (loadingMessageId) {
|
|
226
|
-
try {
|
|
227
|
-
await ctx.api.deleteMessage(chatId, loadingMessageId);
|
|
228
|
-
}
|
|
229
|
-
catch (err) {
|
|
230
|
-
logger.debug("[Sessions] Failed to delete loading message:", err);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
// Send session selection confirmation with updated keyboard
|
|
234
|
-
const keyboard = keyboardManager.getKeyboard();
|
|
235
|
-
try {
|
|
236
|
-
await ctx.api.sendMessage(chatId, t("sessions.selected", { title: session.title }), {
|
|
237
|
-
reply_markup: keyboard,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
logger.error("[Sessions] Failed to send selection message:", err);
|
|
242
|
-
}
|
|
243
|
-
// Send preview asynchronously
|
|
244
|
-
safeBackgroundTask({
|
|
245
|
-
taskName: "sessions.sendPreview",
|
|
246
|
-
task: () => sendSessionPreview(ctx.api, chatId, null, session.title, session.id, currentProject.worktree),
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
await ctx.deleteMessage();
|
|
250
353
|
}
|
|
251
354
|
catch (error) {
|
|
252
355
|
clearAllInteractionState("session_select_error");
|
|
@@ -257,17 +360,19 @@ export async function handleSessionSelect(ctx, deps) {
|
|
|
257
360
|
return true;
|
|
258
361
|
}
|
|
259
362
|
const PREVIEW_MESSAGES_LIMIT = 6;
|
|
363
|
+
const LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT = 20;
|
|
260
364
|
const PREVIEW_ITEM_MAX_LENGTH = 420;
|
|
261
365
|
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
262
|
-
function extractTextParts(parts) {
|
|
366
|
+
function extractTextParts(parts, options = {}) {
|
|
263
367
|
const textParts = parts
|
|
264
368
|
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
265
369
|
.map((part) => part.text);
|
|
266
370
|
if (textParts.length === 0) {
|
|
267
371
|
return null;
|
|
268
372
|
}
|
|
269
|
-
const text = textParts.join("")
|
|
270
|
-
|
|
373
|
+
const text = textParts.join("");
|
|
374
|
+
const normalizedText = options.trim === false ? text : text.trim();
|
|
375
|
+
return normalizedText.trim().length > 0 ? normalizedText : null;
|
|
271
376
|
}
|
|
272
377
|
function truncateText(text, maxLength) {
|
|
273
378
|
if (text.length <= maxLength) {
|
|
@@ -351,3 +456,49 @@ async function sendSessionPreview(api, chatId, messageId, sessionTitle, sessionI
|
|
|
351
456
|
logger.error("[Sessions] Failed to send session preview message:", err);
|
|
352
457
|
}
|
|
353
458
|
}
|
|
459
|
+
async function loadLatestAssistantResponse(sessionId, directory) {
|
|
460
|
+
try {
|
|
461
|
+
const { data: messages, error } = await opencodeClient.session.messages({
|
|
462
|
+
sessionID: sessionId,
|
|
463
|
+
directory,
|
|
464
|
+
limit: LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT,
|
|
465
|
+
});
|
|
466
|
+
if (error || !messages) {
|
|
467
|
+
logger.warn("[Sessions] Failed to fetch latest assistant response:", error);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const latestResponse = messages.reduce((latest, message) => {
|
|
471
|
+
if (message.info.role !== "assistant" || message.info.summary) {
|
|
472
|
+
return latest;
|
|
473
|
+
}
|
|
474
|
+
const text = extractTextParts(message.parts, { trim: false });
|
|
475
|
+
if (!text) {
|
|
476
|
+
return latest;
|
|
477
|
+
}
|
|
478
|
+
const created = message.info.time?.created ?? 0;
|
|
479
|
+
if (!latest || created >= latest.created) {
|
|
480
|
+
return { text, created };
|
|
481
|
+
}
|
|
482
|
+
return latest;
|
|
483
|
+
}, null);
|
|
484
|
+
return latestResponse?.text ?? null;
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
logger.error("[Sessions] Error loading latest assistant response:", err);
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function sendLatestAssistantResponse(api, chatId, sessionId, directory) {
|
|
492
|
+
const responseText = await loadLatestAssistantResponse(sessionId, directory);
|
|
493
|
+
if (!responseText) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const parts = renderAssistantFinalPartsSafe(responseText, TELEGRAM_MESSAGE_LIMIT);
|
|
497
|
+
for (const part of parts) {
|
|
498
|
+
await sendRenderedBotPart({
|
|
499
|
+
api,
|
|
500
|
+
chatId,
|
|
501
|
+
part,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -122,7 +122,7 @@ export async function worktreeCommand(ctx) {
|
|
|
122
122
|
await ctx.reply(t("worktree.fetch_error"));
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export async function handleWorktreeCallback(ctx) {
|
|
125
|
+
export async function handleWorktreeCallback(ctx, deps = {}) {
|
|
126
126
|
const callbackQuery = ctx.callbackQuery;
|
|
127
127
|
if (!callbackQuery?.data || !callbackQuery.data.startsWith(WORKTREE_CALLBACK_PREFIX)) {
|
|
128
128
|
return false;
|
|
@@ -178,7 +178,12 @@ export async function handleWorktreeCallback(ctx) {
|
|
|
178
178
|
logger.info(`[Bot] Worktree selected: ${selectedWorktree.path}`);
|
|
179
179
|
await upsertSessionDirectory(selectedWorktree.path, Date.now());
|
|
180
180
|
const projectInfo = await getProjectByWorktree(selectedWorktree.path);
|
|
181
|
-
const
|
|
181
|
+
const selectedProjectInfo = { ...projectInfo, name: selectedWorktree.path };
|
|
182
|
+
const replyKeyboard = deps.ensureEventSubscription
|
|
183
|
+
? await switchToProject(ctx, selectedProjectInfo, "worktree_switched", {
|
|
184
|
+
ensureEventSubscription: deps.ensureEventSubscription,
|
|
185
|
+
})
|
|
186
|
+
: await switchToProject(ctx, selectedProjectInfo, "worktree_switched");
|
|
182
187
|
await ctx.answerCallbackQuery();
|
|
183
188
|
await ctx.reply(t("worktree.selected", { worktree: selectedWorktree.path }), {
|
|
184
189
|
reply_markup: replyKeyboard,
|
package/dist/bot/index.js
CHANGED
|
@@ -13,12 +13,14 @@ import { startCommand } from "./commands/start.js";
|
|
|
13
13
|
import { helpCommand } from "./commands/help.js";
|
|
14
14
|
import { statusCommand } from "./commands/status.js";
|
|
15
15
|
import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN, } from "./message-patterns.js";
|
|
16
|
-
import {
|
|
16
|
+
import { buildBackgroundSessionOpenKeyboard, handleBackgroundSessionOpen, handleSessionSelect, sessionsCommand, } from "./commands/sessions.js";
|
|
17
17
|
import { newCommand } from "./commands/new.js";
|
|
18
18
|
import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
|
|
19
19
|
import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
|
|
20
20
|
import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
|
|
21
|
+
import { clearLsPathIndex, handleLsCallback, lsCommand } from "./commands/ls.js";
|
|
21
22
|
import { abortCommand } from "./commands/abort.js";
|
|
23
|
+
import { detachCommand } from "./commands/detach.js";
|
|
22
24
|
import { opencodeStartCommand } from "./commands/opencode-start.js";
|
|
23
25
|
import { opencodeStopCommand } from "./commands/opencode-stop.js";
|
|
24
26
|
import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
|
|
@@ -40,6 +42,7 @@ import { interactionManager } from "../interaction/manager.js";
|
|
|
40
42
|
import { clearAllInteractionState } from "../interaction/cleanup.js";
|
|
41
43
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
42
44
|
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
45
|
+
import { opencodeReadyLifecycle } from "../opencode/ready-lifecycle.js";
|
|
43
46
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
44
47
|
import { formatToolInfo } from "../summary/formatter.js";
|
|
45
48
|
import { renderSubagentCards } from "../summary/subagent-formatter.js";
|
|
@@ -51,6 +54,7 @@ import { safeBackgroundTask } from "../utils/safe-background-task.js";
|
|
|
51
54
|
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
|
|
52
55
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
53
56
|
import { t } from "../i18n/index.js";
|
|
57
|
+
import { getCurrentProject } from "../settings/manager.js";
|
|
54
58
|
import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
|
|
55
59
|
import { handleVoiceMessage } from "./handlers/voice.js";
|
|
56
60
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
@@ -72,10 +76,12 @@ import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurren
|
|
|
72
76
|
import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
|
|
73
77
|
import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
|
|
74
78
|
import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
|
|
79
|
+
import { backgroundSessionTracker, } from "../background-session/tracker.js";
|
|
75
80
|
let botInstance = null;
|
|
76
81
|
let chatIdInstance = null;
|
|
77
82
|
let commandsInitialized = false;
|
|
78
83
|
let heartbeatTimer = null;
|
|
84
|
+
let unsubscribeReadyRestore = null;
|
|
79
85
|
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
80
86
|
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
|
|
81
87
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
@@ -268,6 +274,35 @@ function getToolStreamKey(tool) {
|
|
|
268
274
|
}
|
|
269
275
|
return "default";
|
|
270
276
|
}
|
|
277
|
+
function formatShortSessionId(sessionId) {
|
|
278
|
+
return sessionId.length <= 8 ? sessionId : sessionId.slice(0, 8);
|
|
279
|
+
}
|
|
280
|
+
function getBackgroundSessionLabel(notification) {
|
|
281
|
+
const title = notification.sessionTitle?.trim();
|
|
282
|
+
if (title) {
|
|
283
|
+
return title;
|
|
284
|
+
}
|
|
285
|
+
return t("background.session_fallback", { id: formatShortSessionId(notification.sessionId) });
|
|
286
|
+
}
|
|
287
|
+
function formatBackgroundSessionNotification(notification) {
|
|
288
|
+
const session = getBackgroundSessionLabel(notification);
|
|
289
|
+
switch (notification.kind) {
|
|
290
|
+
case "assistant_response":
|
|
291
|
+
return t("background.assistant_response", { session });
|
|
292
|
+
case "question_asked":
|
|
293
|
+
return t("background.question_asked", { session });
|
|
294
|
+
case "permission_asked":
|
|
295
|
+
return t("background.permission_asked", { session });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function deliverBackgroundSessionNotification(notification) {
|
|
299
|
+
if (!botInstance || !chatIdInstance) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
await botInstance.api.sendMessage(chatIdInstance, formatBackgroundSessionNotification(notification), {
|
|
303
|
+
reply_markup: buildBackgroundSessionOpenKeyboard(notification.sessionId, notification.kind),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
271
306
|
function getEventSessionId(event) {
|
|
272
307
|
const properties = event.properties;
|
|
273
308
|
return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
|
|
@@ -277,7 +312,8 @@ function shouldMarkAttachedBusyFromEvent(event) {
|
|
|
277
312
|
case "session.status":
|
|
278
313
|
return event.properties.status?.type === "busy";
|
|
279
314
|
case "message.updated": {
|
|
280
|
-
const info = event.properties
|
|
315
|
+
const info = event.properties
|
|
316
|
+
.info;
|
|
281
317
|
return info?.role === "assistant" && !info.time?.completed;
|
|
282
318
|
}
|
|
283
319
|
case "message.part.updated":
|
|
@@ -320,6 +356,11 @@ async function ensureEventSubscription(directory) {
|
|
|
320
356
|
return;
|
|
321
357
|
}
|
|
322
358
|
summaryAggregator.setTypingIndicatorEnabled(true);
|
|
359
|
+
backgroundSessionTracker.setDirectory(directory);
|
|
360
|
+
backgroundSessionTracker.setOnNotification(deliverBackgroundSessionNotification);
|
|
361
|
+
if (!config.bot.trackBackgroundSessions) {
|
|
362
|
+
backgroundSessionTracker.clear();
|
|
363
|
+
}
|
|
323
364
|
summaryAggregator.setOnCleared(() => {
|
|
324
365
|
toolMessageBatcher.clearAll("summary_aggregator_clear");
|
|
325
366
|
toolCallStreamer.clearAll("summary_aggregator_clear");
|
|
@@ -761,7 +802,9 @@ async function ensureEventSubscription(directory) {
|
|
|
761
802
|
subscribeToEvents(directory, (event) => {
|
|
762
803
|
const attached = attachManager.getSnapshot();
|
|
763
804
|
const eventSessionId = getEventSessionId(event);
|
|
764
|
-
if (attached &&
|
|
805
|
+
if (attached &&
|
|
806
|
+
eventSessionId === attached.sessionId &&
|
|
807
|
+
shouldMarkAttachedBusyFromEvent(event)) {
|
|
765
808
|
void markAttachedSessionBusy(attached.sessionId);
|
|
766
809
|
}
|
|
767
810
|
if (event.type === "session.created" || event.type === "session.updated") {
|
|
@@ -773,6 +816,9 @@ async function ensureEventSubscription(directory) {
|
|
|
773
816
|
});
|
|
774
817
|
}
|
|
775
818
|
}
|
|
819
|
+
if (config.bot.trackBackgroundSessions) {
|
|
820
|
+
backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null);
|
|
821
|
+
}
|
|
776
822
|
summaryAggregator.processEvent(event);
|
|
777
823
|
}).catch((err) => {
|
|
778
824
|
logger.error("Failed to subscribe to events:", err);
|
|
@@ -783,6 +829,7 @@ export function createBot() {
|
|
|
783
829
|
sessionCompletionTasks.clear();
|
|
784
830
|
attachManager.clear("bot_startup");
|
|
785
831
|
assistantRunState.clearAll("bot_startup");
|
|
832
|
+
backgroundSessionTracker.clear();
|
|
786
833
|
if (heartbeatTimer) {
|
|
787
834
|
clearInterval(heartbeatTimer);
|
|
788
835
|
heartbeatTimer = null;
|
|
@@ -809,6 +856,24 @@ export function createBot() {
|
|
|
809
856
|
const bot = new Bot(config.telegram.token, botOptions);
|
|
810
857
|
botInstance = bot;
|
|
811
858
|
chatIdInstance = config.telegram.allowedUserId;
|
|
859
|
+
unsubscribeReadyRestore?.();
|
|
860
|
+
unsubscribeReadyRestore = opencodeReadyLifecycle.onReady(async (reason) => {
|
|
861
|
+
const restored = await restoreAttachedCurrentSession({
|
|
862
|
+
bot,
|
|
863
|
+
chatId: config.telegram.allowedUserId,
|
|
864
|
+
ensureEventSubscription,
|
|
865
|
+
forceFullRestore: true,
|
|
866
|
+
});
|
|
867
|
+
if (restored) {
|
|
868
|
+
logger.info(`[Bot] Restored followed session after OpenCode ready: reason=${reason}`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
const currentProject = getCurrentProject();
|
|
872
|
+
if (config.bot.trackBackgroundSessions && currentProject?.worktree) {
|
|
873
|
+
await ensureEventSubscription(currentProject.worktree);
|
|
874
|
+
logger.info(`[Bot] Started background session tracking after OpenCode ready: reason=${reason}, directory=${currentProject.worktree}`);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
812
877
|
// Heartbeat for diagnostics: verify the event loop is not blocked
|
|
813
878
|
let heartbeatCounter = 0;
|
|
814
879
|
heartbeatTimer = setInterval(() => {
|
|
@@ -866,9 +931,11 @@ export function createBot() {
|
|
|
866
931
|
bot.command("projects", projectsCommand);
|
|
867
932
|
bot.command("worktree", worktreeCommand);
|
|
868
933
|
bot.command("open", openCommand);
|
|
934
|
+
bot.command("ls", lsCommand);
|
|
869
935
|
bot.command("sessions", sessionsCommand);
|
|
870
936
|
bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
|
|
871
937
|
bot.command("abort", abortCommand);
|
|
938
|
+
bot.command("detach", detachCommand);
|
|
872
939
|
bot.command("task", taskCommand);
|
|
873
940
|
bot.command("tasklist", taskListCommand);
|
|
874
941
|
bot.command("rename", renameCommand);
|
|
@@ -884,15 +951,21 @@ export function createBot() {
|
|
|
884
951
|
chatIdInstance = ctx.chat.id;
|
|
885
952
|
}
|
|
886
953
|
try {
|
|
954
|
+
const handledBackgroundSession = await handleBackgroundSessionOpen(ctx, {
|
|
955
|
+
bot,
|
|
956
|
+
ensureEventSubscription,
|
|
957
|
+
});
|
|
887
958
|
const handledInlineCancel = await handleInlineMenuCancel(ctx);
|
|
888
959
|
if (handledInlineCancel) {
|
|
889
960
|
// Clean up path index when the open-directory menu is cancelled
|
|
890
961
|
clearOpenPathIndex();
|
|
962
|
+
clearLsPathIndex();
|
|
891
963
|
}
|
|
892
964
|
const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
|
|
893
|
-
const handledProject = await handleProjectSelect(ctx);
|
|
894
|
-
const handledWorktree = await handleWorktreeCallback(ctx);
|
|
895
|
-
const handledOpen = await handleOpenCallback(ctx);
|
|
965
|
+
const handledProject = await handleProjectSelect(ctx, { ensureEventSubscription });
|
|
966
|
+
const handledWorktree = await handleWorktreeCallback(ctx, { ensureEventSubscription });
|
|
967
|
+
const handledOpen = await handleOpenCallback(ctx, { ensureEventSubscription });
|
|
968
|
+
const handledLs = await handleLsCallback(ctx);
|
|
896
969
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
897
970
|
const handledPermission = await handlePermissionCallback(ctx);
|
|
898
971
|
const handledAgent = await handleAgentSelect(ctx);
|
|
@@ -905,12 +978,14 @@ export function createBot() {
|
|
|
905
978
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
906
979
|
const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
|
|
907
980
|
const handledMcps = await handleMcpsCallback(ctx);
|
|
908
|
-
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
909
|
-
if (!
|
|
981
|
+
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
982
|
+
if (!handledBackgroundSession &&
|
|
983
|
+
!handledInlineCancel &&
|
|
910
984
|
!handledSession &&
|
|
911
985
|
!handledProject &&
|
|
912
986
|
!handledWorktree &&
|
|
913
987
|
!handledOpen &&
|
|
988
|
+
!handledLs &&
|
|
914
989
|
!handledQuestion &&
|
|
915
990
|
!handledPermission &&
|
|
916
991
|
!handledAgent &&
|
|
@@ -1024,14 +1099,6 @@ export function createBot() {
|
|
|
1024
1099
|
});
|
|
1025
1100
|
// Voice and audio message handlers (STT transcription -> prompt)
|
|
1026
1101
|
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
1027
|
-
safeBackgroundTask({
|
|
1028
|
-
taskName: "bot.restoreFollowedSession",
|
|
1029
|
-
task: () => restoreAttachedCurrentSession({
|
|
1030
|
-
bot,
|
|
1031
|
-
chatId: config.telegram.allowedUserId,
|
|
1032
|
-
ensureEventSubscription,
|
|
1033
|
-
}),
|
|
1034
|
-
});
|
|
1035
1102
|
bot.on("message:voice", async (ctx) => {
|
|
1036
1103
|
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
1037
1104
|
botInstance = bot;
|
|
@@ -1146,8 +1213,11 @@ export function createBot() {
|
|
|
1146
1213
|
return bot;
|
|
1147
1214
|
}
|
|
1148
1215
|
export function cleanupBotRuntime(reason) {
|
|
1216
|
+
unsubscribeReadyRestore?.();
|
|
1217
|
+
unsubscribeReadyRestore = null;
|
|
1149
1218
|
stopEventListening();
|
|
1150
1219
|
summaryAggregator.clear();
|
|
1220
|
+
backgroundSessionTracker.clear();
|
|
1151
1221
|
responseStreamer.clearAll(reason);
|
|
1152
1222
|
toolCallStreamer.clearAll(reason);
|
|
1153
1223
|
toolMessageBatcher.clearAll(reason);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { t } from "../../i18n/index.js";
|
|
2
2
|
import { escapePlainTextForTelegramMarkdownV2 } from "../../summary/formatter.js";
|
|
3
3
|
import { sendBotText } from "./telegram-text.js";
|
|
4
|
+
const EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH = 2000;
|
|
4
5
|
function normalizeExternalUserInputText(text) {
|
|
5
6
|
return text.replace(/\r\n/g, "\n").trim();
|
|
6
7
|
}
|
|
8
|
+
function truncateExternalUserInputText(text) {
|
|
9
|
+
if (text.length <= EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH) {
|
|
10
|
+
return text;
|
|
11
|
+
}
|
|
12
|
+
return `${text.slice(0, EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH - 3)}...`;
|
|
13
|
+
}
|
|
7
14
|
function buildQuotedPlainText(text) {
|
|
8
15
|
return text
|
|
9
16
|
.split("\n")
|
|
@@ -21,10 +28,11 @@ export function buildExternalUserInputNotification(text) {
|
|
|
21
28
|
if (!normalizedText) {
|
|
22
29
|
return null;
|
|
23
30
|
}
|
|
31
|
+
const displayText = truncateExternalUserInputText(normalizedText);
|
|
24
32
|
const title = `👤 ${t("bot.external_user_input")}`;
|
|
25
33
|
return {
|
|
26
|
-
text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(
|
|
27
|
-
rawFallbackText: `${title}\n\n${buildQuotedPlainText(
|
|
34
|
+
text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(displayText)}`,
|
|
35
|
+
rawFallbackText: `${title}\n\n${buildQuotedPlainText(displayText)}`,
|
|
28
36
|
};
|
|
29
37
|
}
|
|
30
38
|
export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
|