opencode-telegram-group-topics-bot 0.11.2
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 +74 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/agent/manager.js +60 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +47 -0
- package/dist/bot/commands/abort.js +116 -0
- package/dist/bot/commands/commands.js +389 -0
- package/dist/bot/commands/constants.js +20 -0
- package/dist/bot/commands/definitions.js +25 -0
- package/dist/bot/commands/help.js +27 -0
- package/dist/bot/commands/models.js +38 -0
- package/dist/bot/commands/new.js +247 -0
- package/dist/bot/commands/opencode-start.js +85 -0
- package/dist/bot/commands/opencode-stop.js +44 -0
- package/dist/bot/commands/projects.js +304 -0
- package/dist/bot/commands/rename.js +173 -0
- package/dist/bot/commands/sessions.js +491 -0
- package/dist/bot/commands/start.js +67 -0
- package/dist/bot/commands/status.js +138 -0
- package/dist/bot/constants.js +49 -0
- package/dist/bot/handlers/agent.js +127 -0
- package/dist/bot/handlers/context.js +125 -0
- package/dist/bot/handlers/document.js +65 -0
- package/dist/bot/handlers/inline-menu.js +124 -0
- package/dist/bot/handlers/model.js +152 -0
- package/dist/bot/handlers/permission.js +281 -0
- package/dist/bot/handlers/prompt.js +263 -0
- package/dist/bot/handlers/question.js +285 -0
- package/dist/bot/handlers/variant.js +147 -0
- package/dist/bot/handlers/voice.js +173 -0
- package/dist/bot/index.js +945 -0
- package/dist/bot/message-patterns.js +4 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/middleware/interaction-guard.js +80 -0
- package/dist/bot/middleware/unknown-command.js +22 -0
- package/dist/bot/scope.js +222 -0
- package/dist/bot/telegram-constants.js +3 -0
- package/dist/bot/telegram-rate-limiter.js +263 -0
- package/dist/bot/utils/commands.js +21 -0
- package/dist/bot/utils/file-download.js +91 -0
- package/dist/bot/utils/keyboard.js +85 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +57 -0
- package/dist/bot/utils/session-error-filter.js +34 -0
- package/dist/bot/utils/topic-link.js +29 -0
- package/dist/cli/args.js +98 -0
- package/dist/cli.js +80 -0
- package/dist/config.js +103 -0
- package/dist/i18n/de.js +330 -0
- package/dist/i18n/en.js +330 -0
- package/dist/i18n/es.js +330 -0
- package/dist/i18n/index.js +102 -0
- package/dist/i18n/ru.js +330 -0
- package/dist/i18n/zh.js +330 -0
- package/dist/index.js +28 -0
- package/dist/interaction/cleanup.js +24 -0
- package/dist/interaction/constants.js +25 -0
- package/dist/interaction/guard.js +100 -0
- package/dist/interaction/manager.js +113 -0
- package/dist/interaction/types.js +1 -0
- package/dist/keyboard/manager.js +115 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/capabilities.js +62 -0
- package/dist/model/manager.js +257 -0
- package/dist/model/types.js +24 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +159 -0
- package/dist/opencode/prompt-submit-error.js +101 -0
- package/dist/permission/manager.js +92 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +405 -0
- package/dist/pinned/types.js +1 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +88 -0
- package/dist/question/manager.js +186 -0
- package/dist/question/types.js +1 -0
- package/dist/rename/manager.js +64 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/runtime/process-error-handlers.js +24 -0
- package/dist/session/cache-manager.js +455 -0
- package/dist/session/manager.js +87 -0
- package/dist/settings/manager.js +283 -0
- package/dist/stt/client.js +64 -0
- package/dist/summary/aggregator.js +625 -0
- package/dist/summary/formatter.js +417 -0
- package/dist/summary/tool-message-batcher.js +277 -0
- package/dist/topic/colors.js +8 -0
- package/dist/topic/constants.js +10 -0
- package/dist/topic/manager.js +161 -0
- package/dist/topic/title-constants.js +2 -0
- package/dist/topic/title-format.js +10 -0
- package/dist/topic/title-sync.js +17 -0
- package/dist/utils/error-format.js +29 -0
- package/dist/utils/logger.js +175 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
3
|
+
import { getCurrentSession, setCurrentSession } from "../../session/manager.js";
|
|
4
|
+
import { renameManager } from "../../rename/manager.js";
|
|
5
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
6
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
7
|
+
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
8
|
+
import { logger } from "../../utils/logger.js";
|
|
9
|
+
import { t } from "../../i18n/index.js";
|
|
10
|
+
import { getScopeKeyFromContext } from "../scope.js";
|
|
11
|
+
import { syncTopicTitleForSession } from "../../topic/title-sync.js";
|
|
12
|
+
function getCallbackMessageId(ctx) {
|
|
13
|
+
const message = ctx.callbackQuery?.message;
|
|
14
|
+
if (!message || !("message_id" in message)) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const messageId = message.message_id;
|
|
18
|
+
return typeof messageId === "number" ? messageId : null;
|
|
19
|
+
}
|
|
20
|
+
export async function renameCommand(ctx) {
|
|
21
|
+
try {
|
|
22
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
23
|
+
const currentSession = getCurrentSession(scopeKey);
|
|
24
|
+
if (!currentSession) {
|
|
25
|
+
await ctx.reply(t("rename.no_session"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
let displayedTitle = currentSession.title;
|
|
29
|
+
try {
|
|
30
|
+
const { data: latestSession, error: latestSessionError } = await opencodeClient.session.get({
|
|
31
|
+
sessionID: currentSession.id,
|
|
32
|
+
directory: currentSession.directory,
|
|
33
|
+
});
|
|
34
|
+
if (!latestSessionError && latestSession?.title) {
|
|
35
|
+
displayedTitle = latestSession.title;
|
|
36
|
+
setCurrentSession({
|
|
37
|
+
id: currentSession.id,
|
|
38
|
+
title: latestSession.title,
|
|
39
|
+
directory: currentSession.directory,
|
|
40
|
+
}, scopeKey);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (latestTitleError) {
|
|
44
|
+
logger.debug("[RenameCommand] Failed to fetch latest session title before prompt", {
|
|
45
|
+
sessionId: currentSession.id,
|
|
46
|
+
latestTitleError,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const keyboard = new InlineKeyboard().text(t("rename.button.cancel"), "rename:cancel");
|
|
50
|
+
const message = await ctx.reply(t("rename.prompt", { title: displayedTitle }), {
|
|
51
|
+
reply_markup: keyboard,
|
|
52
|
+
});
|
|
53
|
+
renameManager.startWaiting(currentSession.id, currentSession.directory, displayedTitle, scopeKey);
|
|
54
|
+
renameManager.setMessageId(message.message_id, scopeKey);
|
|
55
|
+
interactionManager.start({
|
|
56
|
+
kind: "rename",
|
|
57
|
+
expectedInput: "text",
|
|
58
|
+
metadata: {
|
|
59
|
+
sessionId: currentSession.id,
|
|
60
|
+
messageId: message.message_id,
|
|
61
|
+
},
|
|
62
|
+
}, scopeKey);
|
|
63
|
+
logger.info(`[RenameCommand] Waiting for new title for session: ${currentSession.id}`);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
logger.error("[RenameCommand] Error starting rename flow:", error);
|
|
67
|
+
await ctx.reply(t("rename.error"));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function handleRenameCancel(ctx) {
|
|
71
|
+
const data = ctx.callbackQuery?.data;
|
|
72
|
+
if (!data || data !== "rename:cancel") {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
76
|
+
logger.debug("[RenameHandler] Cancel callback received");
|
|
77
|
+
if (!renameManager.isWaitingForName(scopeKey)) {
|
|
78
|
+
const state = interactionManager.getSnapshot(scopeKey);
|
|
79
|
+
if (state?.kind === "rename") {
|
|
80
|
+
interactionManager.clear(INTERACTION_CLEAR_REASON.RENAME_CANCEL_INACTIVE, scopeKey);
|
|
81
|
+
}
|
|
82
|
+
await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const interactionState = interactionManager.getSnapshot(scopeKey);
|
|
86
|
+
if (interactionState?.kind !== "rename") {
|
|
87
|
+
renameManager.clear(scopeKey);
|
|
88
|
+
await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
92
|
+
if (!renameManager.isActiveMessage(callbackMessageId, scopeKey)) {
|
|
93
|
+
await ctx.answerCallbackQuery({ text: t("rename.inactive_callback"), show_alert: true });
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
renameManager.clear(scopeKey);
|
|
97
|
+
interactionManager.clear(INTERACTION_CLEAR_REASON.RENAME_CANCELLED, scopeKey);
|
|
98
|
+
await ctx.answerCallbackQuery();
|
|
99
|
+
await ctx.editMessageText(t("rename.cancelled")).catch(() => { });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
export async function handleRenameTextAnswer(ctx) {
|
|
103
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
104
|
+
if (!renameManager.isWaitingForName(scopeKey)) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const text = ctx.message?.text;
|
|
108
|
+
if (!text) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (text.startsWith("/")) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const interactionState = interactionManager.getSnapshot(scopeKey);
|
|
115
|
+
if (interactionState?.kind !== "rename") {
|
|
116
|
+
renameManager.clear(scopeKey);
|
|
117
|
+
await ctx.reply(t("rename.inactive"));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const sessionInfo = renameManager.getSessionInfo(scopeKey);
|
|
121
|
+
if (!sessionInfo) {
|
|
122
|
+
renameManager.clear(scopeKey);
|
|
123
|
+
interactionManager.clear(INTERACTION_CLEAR_REASON.RENAME_MISSING_SESSION_INFO, scopeKey);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const newTitle = text.trim();
|
|
127
|
+
if (!newTitle) {
|
|
128
|
+
await ctx.reply(t("rename.empty_title"));
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
logger.info(`[RenameHandler] Renaming session ${sessionInfo.sessionId} to: ${newTitle}`);
|
|
132
|
+
try {
|
|
133
|
+
const { data: updatedSession, error } = await opencodeClient.session.update({
|
|
134
|
+
sessionID: sessionInfo.sessionId,
|
|
135
|
+
directory: sessionInfo.directory,
|
|
136
|
+
title: newTitle,
|
|
137
|
+
});
|
|
138
|
+
if (error || !updatedSession) {
|
|
139
|
+
throw error || new Error("Failed to update session");
|
|
140
|
+
}
|
|
141
|
+
const nextSession = {
|
|
142
|
+
id: sessionInfo.sessionId,
|
|
143
|
+
title: newTitle,
|
|
144
|
+
directory: sessionInfo.directory,
|
|
145
|
+
};
|
|
146
|
+
setCurrentSession(nextSession, scopeKey);
|
|
147
|
+
if (ctx.chat?.type !== "private" && pinnedMessageManager.isInitialized(scopeKey)) {
|
|
148
|
+
await pinnedMessageManager.onSessionTitleUpdate(newTitle, scopeKey);
|
|
149
|
+
try {
|
|
150
|
+
await syncTopicTitleForSession(ctx.api, sessionInfo.sessionId, newTitle);
|
|
151
|
+
}
|
|
152
|
+
catch (syncError) {
|
|
153
|
+
logger.debug("[RenameHandler] Failed to sync forum topic title", {
|
|
154
|
+
sessionId: sessionInfo.sessionId,
|
|
155
|
+
syncError,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const messageId = renameManager.getMessageId(scopeKey);
|
|
160
|
+
if (messageId && ctx.chat) {
|
|
161
|
+
await ctx.api.deleteMessage(ctx.chat.id, messageId).catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
await ctx.reply(t("rename.success", { title: newTitle }));
|
|
164
|
+
logger.info(`[RenameHandler] Session renamed successfully: ${newTitle}`);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
logger.error("[RenameHandler] Error renaming session:", error);
|
|
168
|
+
await ctx.reply(t("rename.error"));
|
|
169
|
+
}
|
|
170
|
+
renameManager.clear(scopeKey);
|
|
171
|
+
interactionManager.clear(INTERACTION_CLEAR_REASON.RENAME_COMPLETED, scopeKey);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
3
|
+
import { setCurrentSession } from "../../session/manager.js";
|
|
4
|
+
import { TOPIC_SESSION_STATUS, getCurrentProject, setCurrentAgent, setCurrentModel, setCurrentProject, } from "../../settings/manager.js";
|
|
5
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
6
|
+
import { INTERACTION_CLEAR_REASON } from "../../interaction/constants.js";
|
|
7
|
+
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
8
|
+
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
|
+
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
13
|
+
import { config } from "../../config.js";
|
|
14
|
+
import { getDateLocale, t } from "../../i18n/index.js";
|
|
15
|
+
import { getStoredAgent } from "../../agent/manager.js";
|
|
16
|
+
import { getStoredModel } from "../../model/manager.js";
|
|
17
|
+
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
18
|
+
import { formatVariantForButton } from "../../variant/manager.js";
|
|
19
|
+
import { createScopeKeyFromParams, GENERAL_TOPIC_THREAD_ID, GLOBAL_SCOPE_KEY, SCOPE_CONTEXT, getScopeFromContext, getScopeKeyFromContext, getThreadSendOptions, isTopicScope, } from "../scope.js";
|
|
20
|
+
import { BOT_I18N_KEY, CHAT_TYPE, TELEGRAM_CHAT_FIELD, TELEGRAM_ERROR_MARKER, } from "../constants.js";
|
|
21
|
+
import { getTopicBindingBySessionId, registerTopicSessionBinding } from "../../topic/manager.js";
|
|
22
|
+
import { TOPIC_COLORS } from "../../topic/colors.js";
|
|
23
|
+
import { formatTopicTitle } from "../../topic/title-format.js";
|
|
24
|
+
import { buildTopicThreadLink } from "../utils/topic-link.js";
|
|
25
|
+
const SESSION_CALLBACK_PREFIX = "session:";
|
|
26
|
+
const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
|
|
27
|
+
const SESSION_FETCH_EXTRA_COUNT = 1;
|
|
28
|
+
function buildSessionPageCallback(page) {
|
|
29
|
+
return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
|
|
30
|
+
}
|
|
31
|
+
function parseSessionPageCallback(data) {
|
|
32
|
+
if (!data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const rawPage = data.slice(SESSION_PAGE_CALLBACK_PREFIX.length);
|
|
36
|
+
const page = Number(rawPage);
|
|
37
|
+
if (!Number.isInteger(page) || page < 0) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return page;
|
|
41
|
+
}
|
|
42
|
+
function parseSessionIdCallback(data) {
|
|
43
|
+
if (!data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (data.startsWith(SESSION_PAGE_CALLBACK_PREFIX)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
|
|
50
|
+
return sessionId.length > 0 ? sessionId : null;
|
|
51
|
+
}
|
|
52
|
+
function formatSessionsSelectText(page) {
|
|
53
|
+
if (page === 0) {
|
|
54
|
+
return t("sessions.select");
|
|
55
|
+
}
|
|
56
|
+
return t("sessions.select_page", { page: page + 1 });
|
|
57
|
+
}
|
|
58
|
+
function isGeneralForumScope(ctx) {
|
|
59
|
+
const scope = getScopeFromContext(ctx);
|
|
60
|
+
const isForumEnabled = ctx.chat?.type === CHAT_TYPE.SUPERGROUP &&
|
|
61
|
+
Reflect.get(ctx.chat, TELEGRAM_CHAT_FIELD.IS_FORUM) === true;
|
|
62
|
+
return Boolean(isForumEnabled &&
|
|
63
|
+
scope?.context === SCOPE_CONTEXT.GROUP_GENERAL &&
|
|
64
|
+
(scope.threadId === null || scope.threadId === GENERAL_TOPIC_THREAD_ID));
|
|
65
|
+
}
|
|
66
|
+
function getErrorText(error) {
|
|
67
|
+
if (error instanceof Error) {
|
|
68
|
+
return error.message.toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
const description = typeof error === "object" && error !== null ? Reflect.get(error, "description") : null;
|
|
71
|
+
if (typeof description === "string") {
|
|
72
|
+
return description.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
return String(error).toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
function clearInteractionWithScope(reason, scopeKey) {
|
|
77
|
+
if (scopeKey === GLOBAL_SCOPE_KEY) {
|
|
78
|
+
clearAllInteractionState(reason);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
clearAllInteractionState(reason, scopeKey);
|
|
82
|
+
}
|
|
83
|
+
async function loadSessionPage(directory, page, pageSize) {
|
|
84
|
+
const startIndex = page * pageSize;
|
|
85
|
+
const endExclusive = startIndex + pageSize;
|
|
86
|
+
const { data: sessions, error } = await opencodeClient.session.list({
|
|
87
|
+
directory,
|
|
88
|
+
limit: endExclusive + SESSION_FETCH_EXTRA_COUNT,
|
|
89
|
+
});
|
|
90
|
+
if (error || !sessions) {
|
|
91
|
+
throw error || new Error("No data received from server");
|
|
92
|
+
}
|
|
93
|
+
const hasNext = sessions.length > endExclusive;
|
|
94
|
+
const pagedSessions = sessions.slice(startIndex, endExclusive);
|
|
95
|
+
logger.debug(`[Sessions] Loaded page=${page + 1}, startIndex=${startIndex}, endExclusive=${endExclusive}, pageSize=${pageSize}, items=${pagedSessions.length}, hasNext=${hasNext}`);
|
|
96
|
+
return {
|
|
97
|
+
sessions: pagedSessions,
|
|
98
|
+
hasNext,
|
|
99
|
+
page,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function buildSessionsKeyboard(pageData, pageSize) {
|
|
103
|
+
const keyboard = new InlineKeyboard();
|
|
104
|
+
const localeForDate = getDateLocale();
|
|
105
|
+
const pageStartIndex = pageData.page * pageSize;
|
|
106
|
+
pageData.sessions.forEach((session, index) => {
|
|
107
|
+
const date = new Date(session.time.created).toLocaleDateString(localeForDate);
|
|
108
|
+
const label = `${pageStartIndex + index + 1}. ${session.title} (${date})`;
|
|
109
|
+
keyboard.text(label, `${SESSION_CALLBACK_PREFIX}${session.id}`).row();
|
|
110
|
+
});
|
|
111
|
+
if (pageData.page > 0) {
|
|
112
|
+
keyboard.text(t("sessions.button.prev_page"), buildSessionPageCallback(pageData.page - 1));
|
|
113
|
+
}
|
|
114
|
+
if (pageData.hasNext) {
|
|
115
|
+
keyboard.text(t("sessions.button.next_page"), buildSessionPageCallback(pageData.page + 1));
|
|
116
|
+
}
|
|
117
|
+
if (pageData.page > 0 || pageData.hasNext) {
|
|
118
|
+
keyboard.row();
|
|
119
|
+
}
|
|
120
|
+
return keyboard;
|
|
121
|
+
}
|
|
122
|
+
export async function sessionsCommand(ctx) {
|
|
123
|
+
try {
|
|
124
|
+
const scope = getScopeFromContext(ctx);
|
|
125
|
+
const scopeKey = scope?.key ?? GLOBAL_SCOPE_KEY;
|
|
126
|
+
if (isTopicScope(scope)) {
|
|
127
|
+
await ctx.reply(t(BOT_I18N_KEY.SESSIONS_TOPIC_LOCKED), getThreadSendOptions(scope?.threadId ?? null));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const pageSize = config.bot.sessionsListLimit;
|
|
131
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
132
|
+
if (!currentProject) {
|
|
133
|
+
await ctx.reply(t("sessions.project_not_selected"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
logger.debug(`[Sessions] Fetching sessions for directory: ${currentProject.worktree}`);
|
|
137
|
+
const firstPage = await loadSessionPage(currentProject.worktree, 0, pageSize);
|
|
138
|
+
logger.debug(`[Sessions] Found ${firstPage.sessions.length} sessions on page 1`);
|
|
139
|
+
firstPage.sessions.forEach((session) => {
|
|
140
|
+
logger.debug(`[Sessions] Session: ${session.title} | ${session.directory}`);
|
|
141
|
+
});
|
|
142
|
+
if (firstPage.sessions.length === 0) {
|
|
143
|
+
await ctx.reply(t("sessions.empty"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const keyboard = buildSessionsKeyboard(firstPage, pageSize);
|
|
147
|
+
await replyWithInlineMenu(ctx, {
|
|
148
|
+
menuKind: "session",
|
|
149
|
+
text: formatSessionsSelectText(firstPage.page),
|
|
150
|
+
keyboard,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
logger.error("[Sessions] Error fetching sessions:", error);
|
|
155
|
+
await ctx.reply(t("sessions.fetch_error"));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export async function handleSessionSelect(ctx) {
|
|
159
|
+
const scopeKey = getScopeKeyFromContext(ctx);
|
|
160
|
+
const scope = getScopeFromContext(ctx);
|
|
161
|
+
const usePinned = ctx.chat?.type !== CHAT_TYPE.PRIVATE;
|
|
162
|
+
const callbackQuery = ctx.callbackQuery;
|
|
163
|
+
if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const page = parseSessionPageCallback(callbackQuery.data);
|
|
167
|
+
const sessionId = parseSessionIdCallback(callbackQuery.data);
|
|
168
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "session");
|
|
169
|
+
if (!isActiveMenu) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const currentProject = getCurrentProject(scopeKey);
|
|
174
|
+
if (!currentProject) {
|
|
175
|
+
clearInteractionWithScope("session_select_project_missing", scopeKey);
|
|
176
|
+
await ctx.answerCallbackQuery();
|
|
177
|
+
await ctx.reply(t("sessions.select_project_first"));
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (page !== null) {
|
|
181
|
+
try {
|
|
182
|
+
const pageSize = config.bot.sessionsListLimit;
|
|
183
|
+
const pageData = await loadSessionPage(currentProject.worktree, page, pageSize);
|
|
184
|
+
if (pageData.sessions.length === 0) {
|
|
185
|
+
await ctx.answerCallbackQuery({ text: t("sessions.page_empty_callback") });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const keyboard = buildSessionsKeyboard(pageData, pageSize);
|
|
189
|
+
appendInlineMenuCancelButton(keyboard, "session");
|
|
190
|
+
await ctx.editMessageText(formatSessionsSelectText(pageData.page), {
|
|
191
|
+
reply_markup: keyboard,
|
|
192
|
+
});
|
|
193
|
+
await ctx.answerCallbackQuery();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
logger.error("[Sessions] Error loading sessions page:", error);
|
|
197
|
+
await ctx.answerCallbackQuery({ text: t("sessions.page_load_error_callback") });
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (!sessionId) {
|
|
202
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const { data: session, error } = await opencodeClient.session.get({
|
|
206
|
+
sessionID: sessionId,
|
|
207
|
+
directory: currentProject.worktree,
|
|
208
|
+
});
|
|
209
|
+
if (error || !session) {
|
|
210
|
+
throw error || new Error("Failed to get session details");
|
|
211
|
+
}
|
|
212
|
+
const inGeneralForum = Boolean(ctx.chat && isGeneralForumScope(ctx));
|
|
213
|
+
if (inGeneralForum && ctx.chat) {
|
|
214
|
+
const existingBinding = getTopicBindingBySessionId(session.id);
|
|
215
|
+
if (existingBinding && existingBinding.chatId === ctx.chat.id) {
|
|
216
|
+
const existingLink = buildTopicThreadLink(ctx.chat, existingBinding.threadId);
|
|
217
|
+
if (existingLink) {
|
|
218
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.SESSION_SWITCHED, scopeKey);
|
|
219
|
+
await ctx.answerCallbackQuery();
|
|
220
|
+
await ctx.reply(t(BOT_I18N_KEY.SESSIONS_BOUND_TOPIC_LINK, {
|
|
221
|
+
title: session.title,
|
|
222
|
+
topic: existingBinding.topicName ?? String(existingBinding.threadId),
|
|
223
|
+
url: existingLink,
|
|
224
|
+
}), getThreadSendOptions(scope?.threadId ?? null));
|
|
225
|
+
await ctx.deleteMessage().catch(() => { });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const createdTopic = await ctx.api.createForumTopic(ctx.chat.id, formatTopicTitle(session.title, session.title), {
|
|
230
|
+
icon_color: TOPIC_COLORS.BLUE,
|
|
231
|
+
});
|
|
232
|
+
const topicThreadId = createdTopic.message_thread_id;
|
|
233
|
+
const topicScopeKey = createScopeKeyFromParams({
|
|
234
|
+
chatId: ctx.chat.id,
|
|
235
|
+
threadId: topicThreadId,
|
|
236
|
+
context: SCOPE_CONTEXT.GROUP_TOPIC,
|
|
237
|
+
});
|
|
238
|
+
const sessionInfo = {
|
|
239
|
+
id: session.id,
|
|
240
|
+
title: session.title,
|
|
241
|
+
directory: currentProject.worktree,
|
|
242
|
+
};
|
|
243
|
+
setCurrentProject(currentProject, topicScopeKey);
|
|
244
|
+
setCurrentSession(sessionInfo, topicScopeKey);
|
|
245
|
+
setCurrentAgent(getStoredAgent(scopeKey), topicScopeKey);
|
|
246
|
+
setCurrentModel(getStoredModel(scopeKey), topicScopeKey);
|
|
247
|
+
registerTopicSessionBinding({
|
|
248
|
+
scopeKey: topicScopeKey,
|
|
249
|
+
chatId: ctx.chat.id,
|
|
250
|
+
threadId: topicThreadId,
|
|
251
|
+
sessionId: session.id,
|
|
252
|
+
projectId: currentProject.id,
|
|
253
|
+
projectWorktree: currentProject.worktree,
|
|
254
|
+
topicName: formatTopicTitle(session.title, session.title),
|
|
255
|
+
status: TOPIC_SESSION_STATUS.ACTIVE,
|
|
256
|
+
});
|
|
257
|
+
summaryAggregator.setSession(session.id);
|
|
258
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.SESSION_SWITCHED, scopeKey);
|
|
259
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.SESSION_SWITCHED, topicScopeKey);
|
|
260
|
+
if (!pinnedMessageManager.isInitialized(topicScopeKey)) {
|
|
261
|
+
pinnedMessageManager.initialize(ctx.api, ctx.chat.id, topicScopeKey, topicThreadId);
|
|
262
|
+
}
|
|
263
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id, topicScopeKey);
|
|
264
|
+
try {
|
|
265
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title, topicScopeKey);
|
|
266
|
+
await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree, topicScopeKey);
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
logger.error("[Sessions] Error preparing topic pinned message", err);
|
|
270
|
+
}
|
|
271
|
+
const topicContextInfo = pinnedMessageManager.getContextInfo(topicScopeKey) ??
|
|
272
|
+
keyboardManager.getContextInfo(topicScopeKey) ??
|
|
273
|
+
(pinnedMessageManager.getContextLimit(topicScopeKey) > 0
|
|
274
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit(topicScopeKey) }
|
|
275
|
+
: null);
|
|
276
|
+
const topicModel = getStoredModel(topicScopeKey);
|
|
277
|
+
const topicAgent = getStoredAgent(topicScopeKey);
|
|
278
|
+
const variantName = formatVariantForButton(topicModel.variant || "default");
|
|
279
|
+
const topicKeyboard = createMainKeyboard(topicAgent, topicModel, topicContextInfo ?? undefined, variantName);
|
|
280
|
+
await ctx.api.sendMessage(ctx.chat.id, t(BOT_I18N_KEY.NEW_TOPIC_CREATED, { title: session.title }), {
|
|
281
|
+
...getThreadSendOptions(topicThreadId),
|
|
282
|
+
reply_markup: topicKeyboard,
|
|
283
|
+
});
|
|
284
|
+
const topicLink = buildTopicThreadLink(ctx.chat, topicThreadId);
|
|
285
|
+
if (!topicLink) {
|
|
286
|
+
throw new Error("Unable to build topic link");
|
|
287
|
+
}
|
|
288
|
+
await ctx.answerCallbackQuery();
|
|
289
|
+
await ctx.reply(t(BOT_I18N_KEY.SESSIONS_CREATED_TOPIC_LINK, {
|
|
290
|
+
title: session.title,
|
|
291
|
+
topic: formatTopicTitle(session.title, session.title),
|
|
292
|
+
url: topicLink,
|
|
293
|
+
}), getThreadSendOptions(scope?.threadId ?? null));
|
|
294
|
+
await ctx.deleteMessage().catch(() => { });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
|
|
298
|
+
const sessionInfo = {
|
|
299
|
+
id: session.id,
|
|
300
|
+
title: session.title,
|
|
301
|
+
directory: currentProject.worktree,
|
|
302
|
+
};
|
|
303
|
+
setCurrentSession(sessionInfo, scopeKey);
|
|
304
|
+
summaryAggregator.setSession(session.id);
|
|
305
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.SESSION_SWITCHED, scopeKey);
|
|
306
|
+
await ctx.answerCallbackQuery();
|
|
307
|
+
let loadingMessageId = null;
|
|
308
|
+
if (ctx.chat) {
|
|
309
|
+
try {
|
|
310
|
+
const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, t("sessions.loading_context"), getThreadSendOptions(scope?.threadId ?? null));
|
|
311
|
+
loadingMessageId = loadingMessage.message_id;
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
logger.error("[Sessions] Failed to send loading message:", err);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Initialize pinned message manager if not already
|
|
318
|
+
if (usePinned && !pinnedMessageManager.isInitialized(scopeKey) && ctx.chat) {
|
|
319
|
+
pinnedMessageManager.initialize(ctx.api, ctx.chat.id, scopeKey, scope?.threadId ?? null);
|
|
320
|
+
}
|
|
321
|
+
// Initialize keyboard manager if not already
|
|
322
|
+
if (ctx.chat) {
|
|
323
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id, scopeKey);
|
|
324
|
+
}
|
|
325
|
+
if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) === 0) {
|
|
326
|
+
await pinnedMessageManager.refreshContextLimit(scopeKey);
|
|
327
|
+
}
|
|
328
|
+
if (usePinned) {
|
|
329
|
+
try {
|
|
330
|
+
// Create new pinned message for this session
|
|
331
|
+
await pinnedMessageManager.onSessionChange(session.id, session.title, scopeKey);
|
|
332
|
+
// Load context from session history (for existing sessions)
|
|
333
|
+
// Wait for it to complete so keyboard has correct context
|
|
334
|
+
await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree, scopeKey);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
logger.error("[Bot] Error initializing pinned message:", err);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (ctx.chat) {
|
|
341
|
+
const chatId = ctx.chat.id;
|
|
342
|
+
// Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
|
|
343
|
+
const contextInfo = (usePinned ? pinnedMessageManager.getContextInfo(scopeKey) : null) ??
|
|
344
|
+
keyboardManager.getContextInfo(scopeKey);
|
|
345
|
+
if (contextInfo) {
|
|
346
|
+
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit, scopeKey);
|
|
347
|
+
}
|
|
348
|
+
else if (usePinned && pinnedMessageManager.getContextLimit(scopeKey) > 0) {
|
|
349
|
+
keyboardManager.updateContext(0, pinnedMessageManager.getContextLimit(scopeKey), scopeKey);
|
|
350
|
+
}
|
|
351
|
+
// Delete loading message
|
|
352
|
+
if (loadingMessageId) {
|
|
353
|
+
try {
|
|
354
|
+
await ctx.api.deleteMessage(chatId, loadingMessageId);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
logger.debug("[Sessions] Failed to delete loading message:", err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Send session selection confirmation with updated keyboard
|
|
361
|
+
const keyboard = keyboardManager.getKeyboard(scopeKey);
|
|
362
|
+
try {
|
|
363
|
+
await ctx.api.sendMessage(chatId, t("sessions.selected", { title: session.title }), {
|
|
364
|
+
reply_markup: keyboard,
|
|
365
|
+
...getThreadSendOptions(scope?.threadId ?? null),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
logger.error("[Sessions] Failed to send selection message:", err);
|
|
370
|
+
}
|
|
371
|
+
// Send preview asynchronously
|
|
372
|
+
safeBackgroundTask({
|
|
373
|
+
taskName: "sessions.sendPreview",
|
|
374
|
+
task: () => sendSessionPreview(ctx.api, chatId, scope?.threadId ?? null, null, session.title, session.id, currentProject.worktree),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
await ctx.deleteMessage();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
clearInteractionWithScope(INTERACTION_CLEAR_REASON.SESSION_SELECT_ERROR, scopeKey);
|
|
381
|
+
logger.error("[Sessions] Error selecting session:", error);
|
|
382
|
+
const errorText = getErrorText(error);
|
|
383
|
+
await ctx.answerCallbackQuery();
|
|
384
|
+
if (errorText.includes(TELEGRAM_ERROR_MARKER.NOT_ENOUGH_RIGHTS_CREATE_TOPIC)) {
|
|
385
|
+
await ctx.reply(t(BOT_I18N_KEY.NEW_TOPIC_CREATE_NO_RIGHTS));
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
if (scope?.threadId != null) {
|
|
389
|
+
await ctx.reply(t("sessions.select_error"), getThreadSendOptions(scope.threadId));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
await ctx.reply(t("sessions.select_error"));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
const PREVIEW_MESSAGES_LIMIT = 6;
|
|
398
|
+
const PREVIEW_ITEM_MAX_LENGTH = 420;
|
|
399
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
400
|
+
function extractTextParts(parts) {
|
|
401
|
+
const textParts = parts
|
|
402
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
403
|
+
.map((part) => part.text);
|
|
404
|
+
if (textParts.length === 0) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
const text = textParts.join("").trim();
|
|
408
|
+
return text.length > 0 ? text : null;
|
|
409
|
+
}
|
|
410
|
+
function truncateText(text, maxLength) {
|
|
411
|
+
if (text.length <= maxLength) {
|
|
412
|
+
return text;
|
|
413
|
+
}
|
|
414
|
+
const clipped = text.slice(0, Math.max(0, maxLength - 3)).trimEnd();
|
|
415
|
+
return `${clipped}...`;
|
|
416
|
+
}
|
|
417
|
+
async function loadSessionPreview(sessionId, directory) {
|
|
418
|
+
try {
|
|
419
|
+
const { data: messages, error } = await opencodeClient.session.messages({
|
|
420
|
+
sessionID: sessionId,
|
|
421
|
+
directory,
|
|
422
|
+
limit: PREVIEW_MESSAGES_LIMIT,
|
|
423
|
+
});
|
|
424
|
+
if (error || !messages) {
|
|
425
|
+
logger.warn("[Sessions] Failed to fetch session messages:", error);
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
const items = messages
|
|
429
|
+
.map(({ info, parts }) => {
|
|
430
|
+
const role = info.role;
|
|
431
|
+
if (role !== "user" && role !== "assistant") {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
if (role === "assistant" && info.summary) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const text = extractTextParts(parts);
|
|
438
|
+
if (!text) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const created = info.time?.created ?? 0;
|
|
442
|
+
return {
|
|
443
|
+
role,
|
|
444
|
+
text: truncateText(text, PREVIEW_ITEM_MAX_LENGTH),
|
|
445
|
+
created,
|
|
446
|
+
};
|
|
447
|
+
})
|
|
448
|
+
.filter((item) => Boolean(item));
|
|
449
|
+
return items.sort((a, b) => a.created - b.created);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
logger.error("[Sessions] Error loading session preview:", err);
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function formatSessionPreview(_sessionTitle, items) {
|
|
457
|
+
const lines = [];
|
|
458
|
+
if (items.length === 0) {
|
|
459
|
+
lines.push(t("sessions.preview.empty"));
|
|
460
|
+
return lines.join("\n");
|
|
461
|
+
}
|
|
462
|
+
lines.push(t("sessions.preview.title"));
|
|
463
|
+
items.forEach((item, index) => {
|
|
464
|
+
const label = item.role === "user" ? t("sessions.preview.you") : t("sessions.preview.agent");
|
|
465
|
+
lines.push(`${label} ${item.text}`);
|
|
466
|
+
if (index < items.length - 1) {
|
|
467
|
+
lines.push("");
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
const rawMessage = lines.join("\n");
|
|
471
|
+
return truncateText(rawMessage, TELEGRAM_MESSAGE_LIMIT);
|
|
472
|
+
}
|
|
473
|
+
async function sendSessionPreview(api, chatId, threadId, messageId, sessionTitle, sessionId, directory) {
|
|
474
|
+
const previewItems = await loadSessionPreview(sessionId, directory);
|
|
475
|
+
const finalText = formatSessionPreview(sessionTitle, previewItems);
|
|
476
|
+
if (messageId) {
|
|
477
|
+
try {
|
|
478
|
+
await api.editMessageText(chatId, messageId, finalText);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
logger.warn("[Sessions] Failed to edit preview message, sending new one:", err);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
await api.sendMessage(chatId, finalText, getThreadSendOptions(threadId));
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
logger.error("[Sessions] Failed to send session preview message:", err);
|
|
490
|
+
}
|
|
491
|
+
}
|