@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.
Files changed (61) hide show
  1. package/.env.example +25 -0
  2. package/README.md +50 -26
  3. package/dist/agent/manager.js +34 -6
  4. package/dist/agent/types.js +7 -1
  5. package/dist/app/start-bot-app.js +6 -1
  6. package/dist/bot/assistant-run-state.js +60 -0
  7. package/dist/bot/commands/abort.js +2 -0
  8. package/dist/bot/commands/commands.js +12 -2
  9. package/dist/bot/commands/definitions.js +2 -0
  10. package/dist/bot/commands/new.js +3 -2
  11. package/dist/bot/commands/open.js +314 -0
  12. package/dist/bot/commands/projects.js +5 -37
  13. package/dist/bot/commands/sessions.js +3 -0
  14. package/dist/bot/commands/start.js +2 -0
  15. package/dist/bot/commands/status.js +5 -2
  16. package/dist/bot/commands/tts.js +13 -0
  17. package/dist/bot/handlers/agent.js +3 -4
  18. package/dist/bot/handlers/inline-menu.js +9 -1
  19. package/dist/bot/handlers/model.js +3 -2
  20. package/dist/bot/handlers/prompt.js +33 -5
  21. package/dist/bot/handlers/variant.js +3 -2
  22. package/dist/bot/index.js +179 -94
  23. package/dist/bot/message-patterns.js +2 -2
  24. package/dist/bot/streaming/response-streamer.js +26 -17
  25. package/dist/bot/streaming/tool-call-streamer.js +31 -24
  26. package/dist/bot/utils/assistant-rendering.js +117 -0
  27. package/dist/bot/utils/assistant-run-footer.js +9 -0
  28. package/dist/bot/utils/browser-roots.js +140 -0
  29. package/dist/bot/utils/file-tree.js +92 -0
  30. package/dist/bot/utils/finalize-assistant-response.js +18 -24
  31. package/dist/bot/utils/keyboard.js +6 -6
  32. package/dist/bot/utils/send-tts-response.js +37 -0
  33. package/dist/bot/utils/send-with-markdown-fallback.js +3 -3
  34. package/dist/bot/utils/switch-project.js +48 -0
  35. package/dist/bot/utils/telegram-text.js +95 -1
  36. package/dist/cli.js +4 -0
  37. package/dist/config.js +10 -0
  38. package/dist/i18n/de.js +32 -9
  39. package/dist/i18n/en.js +32 -9
  40. package/dist/i18n/es.js +32 -9
  41. package/dist/i18n/fr.js +32 -9
  42. package/dist/i18n/ru.js +32 -9
  43. package/dist/i18n/zh.js +32 -9
  44. package/dist/index.js +2 -0
  45. package/dist/pinned/manager.js +66 -4
  46. package/dist/project/manager.js +2 -1
  47. package/dist/settings/manager.js +7 -0
  48. package/dist/summary/aggregator.js +17 -1
  49. package/dist/summary/formatter.js +12 -89
  50. package/dist/telegram/render/block-fallback.js +28 -0
  51. package/dist/telegram/render/block-parser.js +295 -0
  52. package/dist/telegram/render/block-renderer.js +457 -0
  53. package/dist/telegram/render/chunker.js +281 -0
  54. package/dist/telegram/render/inline-renderer.js +128 -0
  55. package/dist/telegram/render/markdown-normalizer.js +94 -0
  56. package/dist/telegram/render/pipeline.js +9 -0
  57. package/dist/telegram/render/types.js +1 -0
  58. package/dist/telegram/render/validator.js +160 -0
  59. package/dist/tts/client.js +59 -0
  60. package/dist/utils/logger.js +200 -73
  61. 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 { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
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 mode information
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, getAgentEmoji } from "../../agent/types.js";
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
- ? `✅ ${emoji} ${agent.name.toUpperCase()}`
88
- : `${emoji} ${agent.name.charAt(0).toUpperCase() + agent.name.slice(1)}`;
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 = ["project", "session", "model", "agent", "variant", "context"];
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
  }