@grinev/opencode-telegram-bot 0.14.1 → 0.16.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 +25 -0
- package/README.md +50 -26
- package/dist/agent/manager.js +34 -6
- package/dist/agent/types.js +7 -1
- package/dist/app/start-bot-app.js +6 -1
- package/dist/bot/assistant-run-state.js +60 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +12 -2
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/new.js +3 -2
- package/dist/bot/commands/open.js +314 -0
- package/dist/bot/commands/projects.js +5 -37
- package/dist/bot/commands/sessions.js +3 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/status.js +5 -2
- package/dist/bot/commands/tts.js +13 -0
- package/dist/bot/handlers/agent.js +3 -4
- package/dist/bot/handlers/inline-menu.js +9 -1
- package/dist/bot/handlers/model.js +3 -2
- package/dist/bot/handlers/prompt.js +33 -5
- package/dist/bot/handlers/variant.js +3 -2
- package/dist/bot/index.js +179 -94
- package/dist/bot/message-patterns.js +2 -2
- package/dist/bot/streaming/response-streamer.js +26 -17
- package/dist/bot/streaming/tool-call-streamer.js +31 -24
- package/dist/bot/utils/assistant-rendering.js +117 -0
- package/dist/bot/utils/assistant-run-footer.js +9 -0
- package/dist/bot/utils/browser-roots.js +140 -0
- package/dist/bot/utils/file-tree.js +92 -0
- package/dist/bot/utils/finalize-assistant-response.js +18 -24
- package/dist/bot/utils/keyboard.js +6 -6
- package/dist/bot/utils/send-tts-response.js +37 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
- package/dist/bot/utils/switch-project.js +48 -0
- package/dist/bot/utils/telegram-text.js +95 -1
- package/dist/cli.js +4 -0
- package/dist/config.js +10 -0
- package/dist/i18n/de.js +32 -9
- package/dist/i18n/en.js +32 -9
- package/dist/i18n/es.js +32 -9
- package/dist/i18n/fr.js +32 -9
- package/dist/i18n/ru.js +32 -9
- package/dist/i18n/zh.js +32 -9
- package/dist/index.js +2 -0
- package/dist/pinned/manager.js +66 -4
- package/dist/project/manager.js +2 -1
- package/dist/settings/manager.js +7 -0
- package/dist/summary/aggregator.js +17 -1
- package/dist/summary/formatter.js +12 -89
- package/dist/telegram/render/block-fallback.js +28 -0
- package/dist/telegram/render/block-parser.js +295 -0
- package/dist/telegram/render/block-renderer.js +457 -0
- package/dist/telegram/render/chunker.js +281 -0
- package/dist/telegram/render/inline-renderer.js +128 -0
- package/dist/telegram/render/markdown-normalizer.js +94 -0
- package/dist/telegram/render/pipeline.js +9 -0
- package/dist/telegram/render/types.js +1 -0
- package/dist/telegram/render/validator.js +160 -0
- package/dist/tts/client.js +59 -0
- package/dist/utils/logger.js +200 -73
- package/package.json +6 -2
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { appendInlineMenuCancelButton, ensureActiveInlineMenu } from "../handlers/inline-menu.js";
|
|
4
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
5
|
+
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
6
|
+
import { pathToDisplayPath, scanDirectory, buildEntryLabel, buildTreeHeader, isScanError, MAX_ENTRIES_PER_PAGE, } from "../utils/file-tree.js";
|
|
7
|
+
import { getBrowserRoots, isWithinAllowedRoot, isAllowedRoot } from "../utils/browser-roots.js";
|
|
8
|
+
import { upsertSessionDirectory } from "../../session/cache-manager.js";
|
|
9
|
+
import { getProjectByWorktree } from "../../project/manager.js";
|
|
10
|
+
import { switchToProject } from "../utils/switch-project.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { t } from "../../i18n/index.js";
|
|
13
|
+
const CALLBACK_PREFIX = "open:";
|
|
14
|
+
const CALLBACK_NAV_PREFIX = "open:nav:";
|
|
15
|
+
const CALLBACK_SELECT_PREFIX = "open:sel:";
|
|
16
|
+
const CALLBACK_PAGE_PREFIX = "open:pg:";
|
|
17
|
+
const CALLBACK_ROOTS = "open:roots";
|
|
18
|
+
const MAX_BUTTON_LABEL_LENGTH = 64;
|
|
19
|
+
/**
|
|
20
|
+
* Separator used inside pagination callback data between the encoded path
|
|
21
|
+
* reference and the page number. We avoid `:` because it appears in Windows
|
|
22
|
+
* drive letters (e.g. `C:\`) and is already used as a prefix delimiter.
|
|
23
|
+
*/
|
|
24
|
+
const PAGE_SEPARATOR = "|";
|
|
25
|
+
function truncateLabel(label, maxLen = MAX_BUTTON_LABEL_LENGTH) {
|
|
26
|
+
if (label.length <= maxLen) {
|
|
27
|
+
return label;
|
|
28
|
+
}
|
|
29
|
+
return label.slice(0, Math.max(0, maxLen - 3)) + "...";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Encode a path into callback data. Telegram limits callback_data to 64 bytes.
|
|
33
|
+
* Long absolute paths can exceed this, so we encode them as a compact index
|
|
34
|
+
* when necessary. The index is stored in a module-level map that lives for the
|
|
35
|
+
* duration of the current inline menu interaction.
|
|
36
|
+
*/
|
|
37
|
+
const pathIndex = new Map();
|
|
38
|
+
let pathCounter = 0;
|
|
39
|
+
/** Clear the path index. Exported so it can be called on menu cancel/cleanup. */
|
|
40
|
+
export function clearOpenPathIndex() {
|
|
41
|
+
pathIndex.clear();
|
|
42
|
+
pathCounter = 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* @param prefix Callback-data prefix that precedes the path.
|
|
46
|
+
* @param fullPath Absolute path to encode.
|
|
47
|
+
* @param reserveBytes Extra bytes to reserve for a suffix that will be
|
|
48
|
+
* appended *after* the returned value (e.g. the page separator + digits in
|
|
49
|
+
* pagination callbacks). The total callback_data must stay ≤ 64 bytes.
|
|
50
|
+
*/
|
|
51
|
+
function encodePathForCallback(prefix, fullPath, reserveBytes = 0) {
|
|
52
|
+
const naive = `${prefix}${fullPath}`;
|
|
53
|
+
if (Buffer.byteLength(naive, "utf-8") + reserveBytes <= 64) {
|
|
54
|
+
return naive;
|
|
55
|
+
}
|
|
56
|
+
// Use a short numeric key instead
|
|
57
|
+
const key = `#${pathCounter++}`;
|
|
58
|
+
pathIndex.set(key, fullPath);
|
|
59
|
+
return `${prefix}${key}`;
|
|
60
|
+
}
|
|
61
|
+
function decodePathFromCallback(prefix, data) {
|
|
62
|
+
if (!data.startsWith(prefix)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const raw = data.slice(prefix.length);
|
|
66
|
+
if (raw.startsWith("#")) {
|
|
67
|
+
return pathIndex.get(raw) ?? null;
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Encode a pagination callback. The path part goes through the same 64-byte
|
|
73
|
+
* safe encoding used by nav/select callbacks, followed by a separator and
|
|
74
|
+
* the page number.
|
|
75
|
+
*
|
|
76
|
+
* We reserve bytes for the page suffix so the 64-byte check inside
|
|
77
|
+
* `encodePathForCallback` accounts for the complete final callback length.
|
|
78
|
+
*/
|
|
79
|
+
function encodePaginationCallback(currentPath, page) {
|
|
80
|
+
const pageSuffix = `${PAGE_SEPARATOR}${page}`;
|
|
81
|
+
const reserveBytes = Buffer.byteLength(pageSuffix, "utf-8");
|
|
82
|
+
const pathRef = encodePathForCallback(CALLBACK_PAGE_PREFIX, currentPath, reserveBytes);
|
|
83
|
+
return `${pathRef}${pageSuffix}`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Decode a pagination callback into { path, page } or null on failure.
|
|
87
|
+
*/
|
|
88
|
+
function decodePaginationCallback(data) {
|
|
89
|
+
if (!data.startsWith(CALLBACK_PAGE_PREFIX)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const payload = data.slice(CALLBACK_PAGE_PREFIX.length);
|
|
93
|
+
const sepIndex = payload.lastIndexOf(PAGE_SEPARATOR);
|
|
94
|
+
if (sepIndex < 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const pathRef = payload.slice(0, sepIndex);
|
|
98
|
+
const pageNum = Number.parseInt(payload.slice(sepIndex + 1), 10);
|
|
99
|
+
if (Number.isNaN(pageNum)) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Resolve indexed path references
|
|
103
|
+
const resolvedPath = pathRef.startsWith("#") ? (pathIndex.get(pathRef) ?? null) : pathRef;
|
|
104
|
+
if (resolvedPath === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return { path: resolvedPath, page: pageNum };
|
|
108
|
+
}
|
|
109
|
+
function buildBrowseKeyboard(entries, currentPath, hasParent, page, totalCount) {
|
|
110
|
+
const keyboard = new InlineKeyboard();
|
|
111
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
112
|
+
// Directory entries
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const label = truncateLabel(buildEntryLabel(entry));
|
|
115
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, entry.fullPath)).row();
|
|
116
|
+
}
|
|
117
|
+
// Navigation: Up + Back to roots
|
|
118
|
+
// Suppress "Up" when at an allowed root (don't let user navigate above it)
|
|
119
|
+
const atRoot = isAllowedRoot(currentPath);
|
|
120
|
+
const showUp = hasParent && !atRoot;
|
|
121
|
+
const roots = getBrowserRoots();
|
|
122
|
+
const showRoots = roots.length > 1;
|
|
123
|
+
if (showUp || showRoots) {
|
|
124
|
+
if (showUp) {
|
|
125
|
+
const parentPath = path.dirname(currentPath);
|
|
126
|
+
keyboard.text(t("open.back"), encodePathForCallback(CALLBACK_NAV_PREFIX, parentPath));
|
|
127
|
+
}
|
|
128
|
+
if (showRoots) {
|
|
129
|
+
keyboard.text(t("open.roots"), CALLBACK_ROOTS);
|
|
130
|
+
}
|
|
131
|
+
keyboard.row();
|
|
132
|
+
}
|
|
133
|
+
// Pagination
|
|
134
|
+
if (totalPages > 1) {
|
|
135
|
+
if (page > 0) {
|
|
136
|
+
keyboard.text(t("open.prev_page"), encodePaginationCallback(currentPath, page - 1));
|
|
137
|
+
}
|
|
138
|
+
if (page < totalPages - 1) {
|
|
139
|
+
keyboard.text(t("open.next_page"), encodePaginationCallback(currentPath, page + 1));
|
|
140
|
+
}
|
|
141
|
+
keyboard.row();
|
|
142
|
+
}
|
|
143
|
+
// Select current folder
|
|
144
|
+
keyboard
|
|
145
|
+
.text(t("open.select_current"), encodePathForCallback(CALLBACK_SELECT_PREFIX, currentPath))
|
|
146
|
+
.row();
|
|
147
|
+
// Cancel
|
|
148
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
149
|
+
return keyboard;
|
|
150
|
+
}
|
|
151
|
+
async function renderBrowseView(dirPath, page = 0) {
|
|
152
|
+
const result = await scanDirectory(dirPath, page);
|
|
153
|
+
if (isScanError(result)) {
|
|
154
|
+
return { error: result.error };
|
|
155
|
+
}
|
|
156
|
+
const { entries, totalCount, page: clampedPage, currentPath, displayPath, hasParent } = result;
|
|
157
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / MAX_ENTRIES_PER_PAGE));
|
|
158
|
+
const header = buildTreeHeader(displayPath, totalCount, clampedPage, totalPages);
|
|
159
|
+
const keyboard = buildBrowseKeyboard(entries, currentPath, hasParent, clampedPage, totalCount);
|
|
160
|
+
return { text: header, keyboard };
|
|
161
|
+
}
|
|
162
|
+
function buildRootsKeyboard() {
|
|
163
|
+
const keyboard = new InlineKeyboard();
|
|
164
|
+
const roots = getBrowserRoots();
|
|
165
|
+
for (const root of roots) {
|
|
166
|
+
const label = truncateLabel(`📂 ${pathToDisplayPath(root)}`);
|
|
167
|
+
keyboard.text(label, encodePathForCallback(CALLBACK_NAV_PREFIX, root)).row();
|
|
168
|
+
}
|
|
169
|
+
appendInlineMenuCancelButton(keyboard, "open");
|
|
170
|
+
return keyboard;
|
|
171
|
+
}
|
|
172
|
+
export async function openCommand(ctx) {
|
|
173
|
+
try {
|
|
174
|
+
if (isForegroundBusy()) {
|
|
175
|
+
await replyBusyBlocked(ctx);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// Reset path index for new interaction
|
|
179
|
+
clearOpenPathIndex();
|
|
180
|
+
const roots = getBrowserRoots();
|
|
181
|
+
let text;
|
|
182
|
+
let keyboard;
|
|
183
|
+
if (roots.length === 1) {
|
|
184
|
+
// Single root — navigate directly into it
|
|
185
|
+
const view = await renderBrowseView(roots[0]);
|
|
186
|
+
if ("error" in view) {
|
|
187
|
+
await ctx.reply(t("open.scan_error", { error: view.error }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
text = view.text;
|
|
191
|
+
keyboard = view.keyboard;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// Multiple roots — show root selection
|
|
195
|
+
text = t("open.select_root");
|
|
196
|
+
keyboard = buildRootsKeyboard();
|
|
197
|
+
}
|
|
198
|
+
const message = await ctx.reply(text, { reply_markup: keyboard });
|
|
199
|
+
interactionManager.start({
|
|
200
|
+
kind: "inline",
|
|
201
|
+
expectedInput: "callback",
|
|
202
|
+
metadata: {
|
|
203
|
+
menuKind: "open",
|
|
204
|
+
messageId: message.message_id,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
logger.error("[Bot] Error opening directory browser:", error);
|
|
210
|
+
await ctx.reply(t("open.open_error"));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
export async function handleOpenCallback(ctx) {
|
|
214
|
+
const data = ctx.callbackQuery?.data;
|
|
215
|
+
if (!data || !data.startsWith(CALLBACK_PREFIX)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (isForegroundBusy()) {
|
|
219
|
+
await replyBusyBlocked(ctx);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const isActiveMenu = await ensureActiveInlineMenu(ctx, "open");
|
|
223
|
+
if (!isActiveMenu) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
// Back to root selection (multi-root mode)
|
|
228
|
+
if (data === CALLBACK_ROOTS) {
|
|
229
|
+
await showRoots(ctx);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
// Navigate into a directory (including "up")
|
|
233
|
+
const navPath = decodePathFromCallback(CALLBACK_NAV_PREFIX, data);
|
|
234
|
+
if (navPath !== null) {
|
|
235
|
+
if (!isWithinAllowedRoot(navPath)) {
|
|
236
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
await navigateTo(ctx, navPath);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
// Pagination
|
|
243
|
+
const pageInfo = decodePaginationCallback(data);
|
|
244
|
+
if (pageInfo !== null) {
|
|
245
|
+
if (!isWithinAllowedRoot(pageInfo.path)) {
|
|
246
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
await navigateTo(ctx, pageInfo.path, pageInfo.page);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
// Select directory as project
|
|
253
|
+
const selectPath = decodePathFromCallback(CALLBACK_SELECT_PREFIX, data);
|
|
254
|
+
if (selectPath !== null) {
|
|
255
|
+
if (!isWithinAllowedRoot(selectPath)) {
|
|
256
|
+
await ctx.answerCallbackQuery({ text: t("open.access_denied") });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
await selectDirectory(ctx, selectPath);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
logger.error("[Bot] Error handling open callback:", error);
|
|
266
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function showRoots(ctx) {
|
|
271
|
+
const text = t("open.select_root");
|
|
272
|
+
const keyboard = buildRootsKeyboard();
|
|
273
|
+
await ctx.answerCallbackQuery();
|
|
274
|
+
await ctx.editMessageText(text, { reply_markup: keyboard });
|
|
275
|
+
}
|
|
276
|
+
async function navigateTo(ctx, dirPath, page = 0) {
|
|
277
|
+
const view = await renderBrowseView(dirPath, page);
|
|
278
|
+
if ("error" in view) {
|
|
279
|
+
await ctx.answerCallbackQuery({ text: view.error });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
await ctx.answerCallbackQuery();
|
|
283
|
+
await ctx.editMessageText(view.text, {
|
|
284
|
+
reply_markup: view.keyboard,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function selectDirectory(ctx, directory) {
|
|
288
|
+
const displayPath = pathToDisplayPath(directory);
|
|
289
|
+
try {
|
|
290
|
+
logger.info(`[Bot] Adding project directory: ${directory}`);
|
|
291
|
+
// Register directory in the session cache first — getProjectByWorktree
|
|
292
|
+
// needs the entry to exist so it can resolve the project. If the
|
|
293
|
+
// subsequent switch fails, the entry stays in the cache, which is
|
|
294
|
+
// acceptable: it's a real directory the user explicitly selected and
|
|
295
|
+
// will simply appear in /projects for retry.
|
|
296
|
+
await upsertSessionDirectory(directory, Date.now());
|
|
297
|
+
const projectInfo = await getProjectByWorktree(directory);
|
|
298
|
+
const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: displayPath }, "open_project_selected");
|
|
299
|
+
await ctx.answerCallbackQuery();
|
|
300
|
+
await ctx.reply(t("open.selected", { project: displayPath }), {
|
|
301
|
+
reply_markup: replyKeyboard,
|
|
302
|
+
});
|
|
303
|
+
// Clean up the inline menu message
|
|
304
|
+
await ctx.deleteMessage();
|
|
305
|
+
// Clear path index after selection
|
|
306
|
+
clearOpenPathIndex();
|
|
307
|
+
logger.info(`[Bot] Project added and selected: ${displayPath}`);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
logger.error("[Bot] Error selecting directory:", error);
|
|
311
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
|
|
312
|
+
await ctx.reply(t("open.select_error"));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
|
-
import {
|
|
2
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
3
3
|
import { getProjects } from "../../project/manager.js";
|
|
4
4
|
import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
|
|
5
|
-
import { clearSession } from "../../session/manager.js";
|
|
6
|
-
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
7
|
-
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
8
|
-
import { keyboardManager } from "../../keyboard/manager.js";
|
|
9
|
-
import { getStoredAgent } from "../../agent/manager.js";
|
|
10
|
-
import { getStoredModel } from "../../model/manager.js";
|
|
11
|
-
import { formatVariantForButton } from "../../variant/manager.js";
|
|
12
|
-
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
13
|
-
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
14
5
|
import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
|
|
6
|
+
import { switchToProject } from "../utils/switch-project.js";
|
|
7
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
15
8
|
import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
|
|
16
9
|
import { logger } from "../../utils/logger.js";
|
|
17
10
|
import { t } from "../../i18n/index.js";
|
|
@@ -179,34 +172,9 @@ export async function handleProjectSelect(ctx) {
|
|
|
179
172
|
if (!selectedProject) {
|
|
180
173
|
throw new Error(`Project with id ${projectId} not found`);
|
|
181
174
|
}
|
|
182
|
-
logger.info(`[Bot] Project selected: ${selectedProject.name || selectedProject.worktree} (id: ${projectId})`);
|
|
183
|
-
setCurrentProject(selectedProject);
|
|
184
|
-
clearSession();
|
|
185
|
-
summaryAggregator.clear();
|
|
186
|
-
clearAllInteractionState("project_switched");
|
|
187
|
-
// Clear pinned message when switching projects
|
|
188
|
-
try {
|
|
189
|
-
await pinnedMessageManager.clear();
|
|
190
|
-
}
|
|
191
|
-
catch (err) {
|
|
192
|
-
logger.error("[Bot] Error clearing pinned message:", err);
|
|
193
|
-
}
|
|
194
|
-
// Initialize keyboard manager if not already
|
|
195
|
-
if (ctx.chat) {
|
|
196
|
-
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
197
|
-
}
|
|
198
|
-
// Refresh context limit for current model
|
|
199
|
-
await pinnedMessageManager.refreshContextLimit();
|
|
200
|
-
const contextLimit = pinnedMessageManager.getContextLimit();
|
|
201
|
-
// Reset context to 0 (no session selected) with current model's limit
|
|
202
|
-
keyboardManager.updateContext(0, contextLimit);
|
|
203
|
-
// Get current state for keyboard (with context = 0)
|
|
204
|
-
const currentAgent = getStoredAgent();
|
|
205
|
-
const currentModel = getStoredModel();
|
|
206
|
-
const contextInfo = { tokensUsed: 0, tokensLimit: contextLimit };
|
|
207
|
-
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
208
|
-
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo, variantName);
|
|
209
175
|
const projectName = selectedProject.name || selectedProject.worktree;
|
|
176
|
+
logger.info(`[Bot] Project selected: ${projectName} (id: ${projectId})`);
|
|
177
|
+
const keyboard = await switchToProject(ctx, selectedProject, "project_switched");
|
|
210
178
|
await ctx.answerCallbackQuery();
|
|
211
179
|
await ctx.reply(t("projects.selected", { project: projectName }), {
|
|
212
180
|
reply_markup: keyboard,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
2
|
import { opencodeClient } from "../../opencode/client.js";
|
|
3
|
+
import { resolveProjectAgent } from "../../agent/manager.js";
|
|
3
4
|
import { setCurrentSession } from "../../session/manager.js";
|
|
4
5
|
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
6
|
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
@@ -214,6 +215,8 @@ export async function handleSessionSelect(ctx) {
|
|
|
214
215
|
}
|
|
215
216
|
if (ctx.chat) {
|
|
216
217
|
const chatId = ctx.chat.id;
|
|
218
|
+
const currentAgent = await resolveProjectAgent();
|
|
219
|
+
keyboardManager.updateAgent(currentAgent);
|
|
217
220
|
// Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
|
|
218
221
|
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
219
222
|
if (contextInfo) {
|
|
@@ -9,6 +9,7 @@ import { clearProject } from "../../settings/manager.js";
|
|
|
9
9
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
10
10
|
import { abortCurrentOperation } from "./abort.js";
|
|
11
11
|
import { t } from "../../i18n/index.js";
|
|
12
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
12
13
|
export async function startCommand(ctx) {
|
|
13
14
|
if (ctx.chat) {
|
|
14
15
|
if (!pinnedMessageManager.isInitialized()) {
|
|
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
|
|
|
18
19
|
}
|
|
19
20
|
await abortCurrentOperation(ctx, { notifyUser: false });
|
|
20
21
|
foregroundSessionState.clearAll("start_command_reset");
|
|
22
|
+
assistantRunState.clearAll("start_command_reset");
|
|
21
23
|
clearSession();
|
|
22
24
|
clearProject();
|
|
23
25
|
keyboardManager.clearContext();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { opencodeClient } from "../../opencode/client.js";
|
|
2
2
|
import { getCurrentSession } from "../../session/manager.js";
|
|
3
|
-
import { getCurrentProject } from "../../settings/manager.js";
|
|
3
|
+
import { getCurrentProject, isTtsEnabled } from "../../settings/manager.js";
|
|
4
4
|
import { fetchCurrentAgent } from "../../agent/manager.js";
|
|
5
5
|
import { getAgentDisplayName } from "../../agent/types.js";
|
|
6
6
|
import { fetchCurrentModel } from "../../model/manager.js";
|
|
@@ -22,6 +22,9 @@ export async function statusCommand(ctx) {
|
|
|
22
22
|
if (data.version) {
|
|
23
23
|
message += `${t("status.line.version", { version: data.version })}\n`;
|
|
24
24
|
}
|
|
25
|
+
message += `${t("status.line.tts", {
|
|
26
|
+
tts: isTtsEnabled() ? t("status.tts.on") : t("status.tts.off"),
|
|
27
|
+
})}\n`;
|
|
25
28
|
// Add process management information
|
|
26
29
|
if (processManager.isRunning()) {
|
|
27
30
|
const uptime = processManager.getUptime();
|
|
@@ -33,7 +36,7 @@ export async function statusCommand(ctx) {
|
|
|
33
36
|
else {
|
|
34
37
|
message += `${t("status.line.managed_no")}\n`;
|
|
35
38
|
}
|
|
36
|
-
// Add agent
|
|
39
|
+
// Add agent information
|
|
37
40
|
const currentAgent = await fetchCurrentAgent();
|
|
38
41
|
const agentDisplay = currentAgent
|
|
39
42
|
? getAgentDisplayName(currentAgent)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isTtsConfigured } from "../../tts/client.js";
|
|
2
|
+
import { isTtsEnabled, setTtsEnabled } from "../../settings/manager.js";
|
|
3
|
+
import { t } from "../../i18n/index.js";
|
|
4
|
+
export async function ttsCommand(ctx) {
|
|
5
|
+
const enabled = !isTtsEnabled();
|
|
6
|
+
if (enabled && !isTtsConfigured()) {
|
|
7
|
+
await ctx.reply(t("tts.not_configured"));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
setTtsEnabled(enabled);
|
|
11
|
+
const message = enabled ? t("tts.enabled") : t("tts.disabled");
|
|
12
|
+
await ctx.reply(message);
|
|
13
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
2
|
import { selectAgent, getAvailableAgents, fetchCurrentAgent } from "../../agent/manager.js";
|
|
3
|
-
import { getAgentDisplayName
|
|
3
|
+
import { getAgentDisplayName } from "../../agent/types.js";
|
|
4
4
|
import { getStoredModel } from "../../model/manager.js";
|
|
5
5
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
6
6
|
import { logger } from "../../utils/logger.js";
|
|
@@ -81,11 +81,10 @@ export async function buildAgentSelectionMenu(currentAgent) {
|
|
|
81
81
|
}
|
|
82
82
|
// Add button for each agent
|
|
83
83
|
agents.forEach((agent) => {
|
|
84
|
-
const emoji = getAgentEmoji(agent.name);
|
|
85
84
|
const isActive = agent.name === currentAgent;
|
|
86
85
|
const label = isActive
|
|
87
|
-
? `✅ ${
|
|
88
|
-
:
|
|
86
|
+
? `✅ ${getAgentDisplayName(agent.name)}`
|
|
87
|
+
: getAgentDisplayName(agent.name);
|
|
89
88
|
keyboard.text(label, `agent:${agent.name}`).row();
|
|
90
89
|
});
|
|
91
90
|
return keyboard;
|
|
@@ -3,7 +3,15 @@ import { logger } from "../../utils/logger.js";
|
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
const INLINE_MENU_CANCEL_PREFIX = "inline:cancel:";
|
|
5
5
|
const LEGACY_CONTEXT_CANCEL_CALLBACK = "compact:cancel";
|
|
6
|
-
const INLINE_MENU_KINDS = [
|
|
6
|
+
const INLINE_MENU_KINDS = [
|
|
7
|
+
"project",
|
|
8
|
+
"session",
|
|
9
|
+
"model",
|
|
10
|
+
"agent",
|
|
11
|
+
"variant",
|
|
12
|
+
"context",
|
|
13
|
+
"open",
|
|
14
|
+
];
|
|
7
15
|
function isInlineMenuKind(value) {
|
|
8
16
|
return INLINE_MENU_KINDS.includes(value);
|
|
9
17
|
}
|
|
@@ -4,7 +4,7 @@ import { formatModelForDisplay } from "../../model/types.js";
|
|
|
4
4
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
7
|
-
import { getStoredAgent } from "../../agent/manager.js";
|
|
7
|
+
import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
8
8
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
9
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
10
|
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
@@ -61,11 +61,12 @@ export async function handleModelSelect(ctx) {
|
|
|
61
61
|
// Refresh context limit for new model
|
|
62
62
|
await pinnedMessageManager.refreshContextLimit();
|
|
63
63
|
// Update Reply Keyboard with new model and context
|
|
64
|
-
const currentAgent = getStoredAgent();
|
|
64
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
65
65
|
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
66
66
|
(pinnedMessageManager.getContextLimit() > 0
|
|
67
67
|
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
68
68
|
: null);
|
|
69
|
+
keyboardManager.updateAgent(currentAgent);
|
|
69
70
|
if (contextInfo) {
|
|
70
71
|
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
71
72
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { opencodeClient } from "../../opencode/client.js";
|
|
2
2
|
import { clearSession, getCurrentSession, setCurrentSession } from "../../session/manager.js";
|
|
3
3
|
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
4
|
-
import { getCurrentProject } from "../../settings/manager.js";
|
|
5
|
-
import { getStoredAgent } from "../../agent/manager.js";
|
|
4
|
+
import { getCurrentProject, isTtsEnabled } from "../../settings/manager.js";
|
|
5
|
+
import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
6
6
|
import { getStoredModel } from "../../model/manager.js";
|
|
7
7
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
8
8
|
import { createMainKeyboard } from "../utils/keyboard.js";
|
|
@@ -17,15 +17,28 @@ import { formatErrorDetails } from "../../utils/error-format.js";
|
|
|
17
17
|
import { logger } from "../../utils/logger.js";
|
|
18
18
|
import { t } from "../../i18n/index.js";
|
|
19
19
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
20
|
+
import { assistantRunState } from "../assistant-run-state.js";
|
|
20
21
|
/** Module-level references for async callbacks that don't have ctx. */
|
|
21
22
|
let botInstance = null;
|
|
22
23
|
let chatIdInstance = null;
|
|
24
|
+
const promptResponseModes = new Map();
|
|
23
25
|
export function getPromptBotInstance() {
|
|
24
26
|
return botInstance;
|
|
25
27
|
}
|
|
26
28
|
export function getPromptChatId() {
|
|
27
29
|
return chatIdInstance;
|
|
28
30
|
}
|
|
31
|
+
export function setPromptResponseMode(sessionId, responseMode) {
|
|
32
|
+
promptResponseModes.set(sessionId, responseMode);
|
|
33
|
+
}
|
|
34
|
+
export function clearPromptResponseMode(sessionId) {
|
|
35
|
+
promptResponseModes.delete(sessionId);
|
|
36
|
+
}
|
|
37
|
+
export function consumePromptResponseMode(sessionId) {
|
|
38
|
+
const responseMode = promptResponseModes.get(sessionId) ?? null;
|
|
39
|
+
promptResponseModes.delete(sessionId);
|
|
40
|
+
return responseMode;
|
|
41
|
+
}
|
|
29
42
|
async function isSessionBusy(sessionId, directory) {
|
|
30
43
|
try {
|
|
31
44
|
const { data, error } = await opencodeClient.session.status({ directory });
|
|
@@ -49,6 +62,7 @@ async function resetMismatchedSessionContext() {
|
|
|
49
62
|
stopEventListening();
|
|
50
63
|
summaryAggregator.clear();
|
|
51
64
|
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
65
|
+
assistantRunState.clearAll("session_mismatch_reset");
|
|
52
66
|
clearAllInteractionState("session_mismatch_reset");
|
|
53
67
|
clearSession();
|
|
54
68
|
keyboardManager.clearContext();
|
|
@@ -72,8 +86,9 @@ async function resetMismatchedSessionContext() {
|
|
|
72
86
|
* @param fileParts - Optional file parts (for photo/document attachments)
|
|
73
87
|
* @returns true if the prompt was dispatched, false if it was blocked/failed early.
|
|
74
88
|
*/
|
|
75
|
-
export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
89
|
+
export async function processUserPrompt(ctx, text, deps, fileParts = [], options = {}) {
|
|
76
90
|
const { bot, ensureEventSubscription } = deps;
|
|
91
|
+
const responseMode = options.responseMode ?? (isTtsEnabled() ? "text_and_tts" : "text_only");
|
|
77
92
|
const currentProject = getCurrentProject();
|
|
78
93
|
if (!currentProject) {
|
|
79
94
|
await ctx.reply(t("bot.project_not_selected"));
|
|
@@ -118,10 +133,11 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
118
133
|
catch (err) {
|
|
119
134
|
logger.error("[Bot] Error creating pinned message for new session:", err);
|
|
120
135
|
}
|
|
121
|
-
const currentAgent = getStoredAgent();
|
|
136
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
122
137
|
const currentModel = getStoredModel();
|
|
123
138
|
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
124
139
|
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
140
|
+
keyboardManager.updateAgent(currentAgent);
|
|
125
141
|
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
126
142
|
await ctx.reply(t("bot.session_created", { title: session.title }), {
|
|
127
143
|
reply_markup: keyboard,
|
|
@@ -149,7 +165,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
149
165
|
return false;
|
|
150
166
|
}
|
|
151
167
|
try {
|
|
152
|
-
const currentAgent = getStoredAgent();
|
|
168
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
153
169
|
const storedModel = getStoredModel();
|
|
154
170
|
// Build parts array with text and files
|
|
155
171
|
const parts = [];
|
|
@@ -195,6 +211,13 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
195
211
|
};
|
|
196
212
|
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
197
213
|
foregroundSessionState.markBusy(currentSession.id);
|
|
214
|
+
assistantRunState.startRun(currentSession.id, {
|
|
215
|
+
startedAt: Date.now(),
|
|
216
|
+
configuredAgent: currentAgent,
|
|
217
|
+
configuredProviderID: storedModel.providerID,
|
|
218
|
+
configuredModelID: storedModel.modelID,
|
|
219
|
+
});
|
|
220
|
+
setPromptResponseMode(currentSession.id, responseMode);
|
|
198
221
|
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
199
222
|
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
200
223
|
// which blocks receiving button callback_query updates.
|
|
@@ -205,6 +228,8 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
205
228
|
onSuccess: ({ error }) => {
|
|
206
229
|
if (error) {
|
|
207
230
|
foregroundSessionState.markIdle(currentSession.id);
|
|
231
|
+
assistantRunState.clearRun(currentSession.id, "session_prompt_api_error");
|
|
232
|
+
clearPromptResponseMode(currentSession.id);
|
|
208
233
|
const details = formatErrorDetails(error, 6000);
|
|
209
234
|
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
210
235
|
logger.error("[Bot] session.prompt error details:", details);
|
|
@@ -217,6 +242,8 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
217
242
|
},
|
|
218
243
|
onError: (error) => {
|
|
219
244
|
foregroundSessionState.markIdle(currentSession.id);
|
|
245
|
+
assistantRunState.clearRun(currentSession.id, "session_prompt_background_error");
|
|
246
|
+
clearPromptResponseMode(currentSession.id);
|
|
220
247
|
const details = formatErrorDetails(error, 6000);
|
|
221
248
|
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
222
249
|
logger.error("[Bot] session.prompt background failure details:", details);
|
|
@@ -229,6 +256,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
229
256
|
catch (err) {
|
|
230
257
|
if (currentSession) {
|
|
231
258
|
foregroundSessionState.markIdle(currentSession.id);
|
|
259
|
+
assistantRunState.clearRun(currentSession.id, "session_prompt_handler_error");
|
|
232
260
|
}
|
|
233
261
|
logger.error("Error in prompt handler:", err);
|
|
234
262
|
if (interactionManager.getSnapshot()) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
2
|
import { getAvailableVariants, getCurrentVariant, setCurrentVariant, formatVariantForDisplay, formatVariantForButton, } from "../../variant/manager.js";
|
|
3
3
|
import { getStoredModel } from "../../model/manager.js";
|
|
4
|
-
import { getStoredAgent } from "../../agent/manager.js";
|
|
4
|
+
import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
6
6
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
7
7
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
@@ -47,11 +47,12 @@ export async function handleVariantSelect(ctx) {
|
|
|
47
47
|
keyboardManager.updateModel(updatedModel);
|
|
48
48
|
keyboardManager.updateVariant(variantId);
|
|
49
49
|
// Build keyboard with correct context info
|
|
50
|
-
const currentAgent = getStoredAgent();
|
|
50
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
51
51
|
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
52
52
|
(pinnedMessageManager.getContextLimit() > 0
|
|
53
53
|
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
54
54
|
: null);
|
|
55
|
+
keyboardManager.updateAgent(currentAgent);
|
|
55
56
|
if (contextInfo) {
|
|
56
57
|
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
57
58
|
}
|