grok-telegram-bot 2.3.1 → 2.5.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 (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Import session — pick a sibling bot (Kiro / OpenCode / Claude / Codex), list
3
+ * its /running (controlled) sessions, and import one into this chat as a new
4
+ * Grok session primed with the full transcript (nothing lost).
5
+ *
6
+ * Flow:
7
+ * Menu → Import session → source → running session cards → Import
8
+ */
9
+ import { basename, join } from "node:path";
10
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
11
+ import { textPrompt } from "../../app/types.js";
12
+ import { buildImportPackage } from "../../import/build-import.js";
13
+ import { listRunningFromSource, type ImportableSession } from "../../import/list-running.js";
14
+ import {
15
+ getImportSource,
16
+ IMPORT_SOURCES,
17
+ sourceAvailable,
18
+ type ImportSourceId,
19
+ } from "../../import/sources.js";
20
+ import { createLogger } from "../../logger.js";
21
+ import type { BotDeps } from "../deps.js";
22
+ import { IMPORT_CONFIRM_PROMPT } from "../session-fork.js";
23
+ import { refreshMenu } from "../menu/refresh.js";
24
+
25
+ const log = createLogger("import-session");
26
+
27
+ const CARD_LIMIT = 20;
28
+
29
+ /** Open the source picker (menu entry / /import). */
30
+ export async function showImportSources(ctx: Context, deps: BotDeps): Promise<void> {
31
+ await deps.ephemeral.open(ctx);
32
+ const kb = new InlineKeyboard();
33
+ for (const src of IMPORT_SOURCES) {
34
+ const ok = sourceAvailable(src);
35
+ const mark = ok ? "" : " \u26A0";
36
+ kb.text(`${labelEmoji(src.id)} ${src.label}${mark}`, `imp:src:${src.id}`).row();
37
+ }
38
+ kb.text("\u2716 Cancel", "imp:cancel");
39
+ await deps.ephemeral.reply(
40
+ ctx,
41
+ [
42
+ "\u{1F4E5} Import session",
43
+ "",
44
+ "Choose the source tool. You will then see its /running sessions",
45
+ "and can import one into Grok with full conversation context.",
46
+ ].join("\n"),
47
+ { reply_markup: kb },
48
+ );
49
+ }
50
+
51
+ /** List /running sessions for a source bot. */
52
+ export async function showImportRunning(
53
+ ctx: Context,
54
+ deps: BotDeps,
55
+ sourceId: ImportSourceId,
56
+ ): Promise<void> {
57
+ const src = getImportSource(sourceId);
58
+ if (!src) {
59
+ await ctx.reply("Unknown source.");
60
+ return;
61
+ }
62
+ await deps.ephemeral.open(ctx);
63
+
64
+ if (!sourceAvailable(src)) {
65
+ await deps.ephemeral.reply(
66
+ ctx,
67
+ `\u26A0\uFE0F ${src.label} bot root not found:\n\`${src.botRoot}\``,
68
+ );
69
+ return;
70
+ }
71
+
72
+ const chatId = ctx.chat!.id;
73
+ let sessions = listRunningFromSource(src, chatId);
74
+ // If this Telegram chat id has no controlled sessions there, union all chats
75
+ // on that bot (same human often uses one chat id everywhere).
76
+ if (sessions.length === 0) sessions = listRunningFromSource(src);
77
+
78
+ if (sessions.length === 0) {
79
+ const kb = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
80
+ await deps.ephemeral.reply(
81
+ ctx,
82
+ [
83
+ `\u{1F4E5} ${src.label} \u2014 no /running sessions`,
84
+ "",
85
+ "That bot has no controlled sessions in its settings right now.",
86
+ "Open a session there first (Project / message), then try Import again.",
87
+ ].join("\n"),
88
+ { reply_markup: kb },
89
+ );
90
+ return;
91
+ }
92
+
93
+ deps.menuCache.setImportSessions(chatId, sourceId, sessions);
94
+ const shown = sessions.slice(0, CARD_LIMIT);
95
+ await deps.ephemeral.reply(
96
+ ctx,
97
+ `\u{1F4E5} ${src.label} /running \u2014 ${sessions.length} session(s). Tap Import on a card:`,
98
+ );
99
+
100
+ const now = Date.now();
101
+ for (let i = 0; i < shown.length; i++) {
102
+ const s = shown[i]!;
103
+ const { text, kb } = buildImportCard(s, i, now);
104
+ await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
105
+ }
106
+ if (sessions.length > shown.length) {
107
+ await deps.ephemeral.reply(ctx, `\u2026and ${sessions.length - shown.length} more (not shown).`);
108
+ }
109
+ const nav = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
110
+ await deps.ephemeral.reply(ctx, "Pick a session above, or go back.", { reply_markup: nav });
111
+ }
112
+
113
+ /** Perform the import into a new Grok /running session. */
114
+ export async function doImportSession(ctx: Context, deps: BotDeps, index: number): Promise<void> {
115
+ const chatId = ctx.chat!.id;
116
+ const cached = deps.menuCache.getImportSessions(chatId);
117
+ const session = cached?.sessions[index];
118
+ if (!session || !cached) {
119
+ await ctx.reply("That import list expired \u2014 open Import session again.");
120
+ return;
121
+ }
122
+ const src = getImportSource(cached.sourceId);
123
+ if (!src) {
124
+ await ctx.reply("Unknown source.");
125
+ return;
126
+ }
127
+
128
+ await deps.ephemeral.clear(chatId).catch(() => {});
129
+ await ctx.reply(
130
+ `\u23F3 Importing from ${src.label} \u2026\n\`${session.sessionId.slice(0, 12)}\` \u00B7 ${session.projectName ?? (basename(session.cwd || "") || "project")}`,
131
+ );
132
+
133
+ const cwd = resolveImportCwd(session, deps, chatId);
134
+ const projectName = session.projectName || (cwd ? basename(cwd) : "imported");
135
+ const importsDir = join(deps.cfg.dataDir, "imports");
136
+
137
+ let pkg;
138
+ try {
139
+ pkg = buildImportPackage(src, { ...session, cwd }, importsDir);
140
+ } catch (e) {
141
+ log.error("buildImportPackage failed:", (e as Error).message);
142
+ await ctx.reply(`\u274C Could not read source history: ${(e as Error).message}`);
143
+ return;
144
+ }
145
+
146
+ if (pkg.entryCount === 0) {
147
+ await ctx.reply(
148
+ [
149
+ `\u26A0\uFE0F No history entries found on disk for this session.`,
150
+ `Session id: \`${session.sessionId}\``,
151
+ `I will still open a Grok session in the project, but context may be empty.`,
152
+ `Transcript archive: \`${pkg.transcriptPath}\``,
153
+ ].join("\n"),
154
+ );
155
+ }
156
+
157
+ try {
158
+ const rt = await deps.registry.controller(chatId).addImport(cwd, projectName, pkg.priming);
159
+ // Flush priming into the live Grok session immediately so context is not
160
+ // waiting on the user's next free-form message.
161
+ void rt.submit(textPrompt(IMPORT_CONFIRM_PROMPT));
162
+
163
+ const lines = [
164
+ `\u2705 Imported into Grok /running`,
165
+ `\u{1F4E5} ${src.label} \u2192 Grok`,
166
+ `\u{1F4C1} ${projectName}`,
167
+ cwd ? ` ${cwd}` : "",
168
+ `\u{1F4DC} ${pkg.entryCount} history entries \u00B7 ${humanSize(pkg.transcriptChars)} transcript`,
169
+ pkg.truncatedInline
170
+ ? `\u2139\uFE0F Full transcript is on disk (inline slice was capped); Grok will use the file if needed.`
171
+ : `\u2705 Full transcript inlined into the first Grok turn.`,
172
+ `\u{1F4C4} ${pkg.transcriptPath}`,
173
+ rt.sessionId ? `\u{1F194} new Grok session ${rt.sessionId.slice(0, 8)}` : "",
174
+ ``,
175
+ `Grok is loading the context now. After the short confirmation, send your next instruction to continue.`,
176
+ ].filter(Boolean);
177
+
178
+ await refreshMenu(ctx, deps, `\u{1F4E5} Imported ${src.label} \u00B7 ${projectName}`);
179
+ await ctx.reply(lines.join("\n"));
180
+ } catch (e) {
181
+ log.error("import failed:", (e as Error).message);
182
+ await ctx.reply(
183
+ `\u274C Import failed: ${(e as Error).message}\nTranscript (if written): \`${pkg.transcriptPath}\``,
184
+ );
185
+ }
186
+ }
187
+
188
+ export function registerImportSession(bot: Bot, deps: BotDeps): void {
189
+ bot.command("import", (ctx) => showImportSources(ctx, deps));
190
+
191
+ bot.callbackQuery("imp:cancel", async (ctx) => {
192
+ await ctx.answerCallbackQuery();
193
+ await ctx.deleteMessage().catch(() => {});
194
+ });
195
+
196
+ bot.callbackQuery("imp:back", async (ctx) => {
197
+ await ctx.answerCallbackQuery();
198
+ await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
199
+ await showImportSources(ctx, deps);
200
+ });
201
+
202
+ bot.callbackQuery(/^imp:src:(kiro|opencode|claude|codex)$/, async (ctx) => {
203
+ await ctx.answerCallbackQuery();
204
+ await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
205
+ await showImportRunning(ctx, deps, ctx.match![1] as ImportSourceId);
206
+ });
207
+
208
+ bot.callbackQuery(/^imp:go:(\d+)$/, async (ctx) => {
209
+ await ctx.answerCallbackQuery({ text: "Importing\u2026" });
210
+ await doImportSession(ctx, deps, Number(ctx.match![1]));
211
+ });
212
+ }
213
+
214
+ // ── cards / helpers ──────────────────────────────────────────────────────────
215
+
216
+ function buildImportCard(
217
+ s: ImportableSession,
218
+ index: number,
219
+ now: number,
220
+ ): { text: string; kb: InlineKeyboard } {
221
+ const proj = s.projectName || (s.cwd ? basename(s.cwd) : "(no project)");
222
+ const when = s.updatedAt ? timeAgo(now - Date.parse(s.updatedAt)) : "unknown";
223
+ const title = cleanTitle(s.title);
224
+ const hist =
225
+ s.historyBytes > 0 ? humanSize(s.historyBytes) : s.historyPath ? "0 B" : "no history file";
226
+
227
+ const lines = [
228
+ `\u{1F4E5} ${proj}`,
229
+ title ? `\u{1F4AC} \u201C${trunc(title, 120)}\u201D` : "\u{1F4AC} (no title)",
230
+ s.cwd ? `\u{1F4C1} ${s.cwd}` : "\u{1F4C1} (no cwd recorded)",
231
+ `\u{1F552} ${when} \u00B7 \u{1F4DC} ${hist}`,
232
+ `\u{1F194} ${s.sessionId.length > 16 ? s.sessionId.slice(0, 12) + "\u2026" : s.sessionId}`,
233
+ ];
234
+
235
+ const kb = new InlineKeyboard().text("\u{1F4E5} Import into Grok", `imp:go:${index}`);
236
+ return { text: lines.join("\n"), kb };
237
+ }
238
+
239
+ function resolveImportCwd(session: ImportableSession, deps: BotDeps, chatId: number): string {
240
+ if (session.cwd) return session.cwd;
241
+ // No cwd on the source session — stay on this chat's current project.
242
+ try {
243
+ return deps.registry.get(chatId).cwd || deps.cfg.workspace;
244
+ } catch {
245
+ return deps.cfg.workspace;
246
+ }
247
+ }
248
+
249
+ function labelEmoji(id: ImportSourceId): string {
250
+ switch (id) {
251
+ case "kiro":
252
+ return "\u{1F3AF}";
253
+ case "opencode":
254
+ return "\u26A1";
255
+ case "claude":
256
+ return "\u{1F9E0}";
257
+ case "codex":
258
+ return "\u{1F4D6}";
259
+ }
260
+ }
261
+
262
+ function cleanTitle(raw: string): string {
263
+ let t = (raw || "").trim().replace(/^\([^)]*\)\s*/, "");
264
+ const marker = "User's new message:";
265
+ const i = t.lastIndexOf(marker);
266
+ if (i !== -1) t = t.slice(i + marker.length);
267
+ return t.replace(/\s+/g, " ").trim();
268
+ }
269
+
270
+ function trunc(s: string, n: number): string {
271
+ return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
272
+ }
273
+
274
+ function timeAgo(ms: number): string {
275
+ if (!Number.isFinite(ms) || ms < 0) return "unknown";
276
+ const s = Math.round(ms / 1000);
277
+ if (s < 45) return "just now";
278
+ const m = Math.round(s / 60);
279
+ if (m < 60) return `${m}m ago`;
280
+ const h = Math.round(m / 60);
281
+ if (h < 24) return `${h}h ago`;
282
+ return `${Math.round(h / 24)}d ago`;
283
+ }
284
+
285
+ function humanSize(bytes: number): string {
286
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
287
+ if (bytes < 1024) return `${bytes} B`;
288
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
289
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
290
+ }
@@ -1,16 +1,28 @@
1
1
  /**
2
2
  * Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
3
- * prefix for stateful ones) to actions, and provides inline submenus for Agent
4
- * (real Grok modes), Reasoning, and Model. Changing a value re-renders the
5
- * keyboard so its labels always reflect the current state.
3
+ * prefix for stateful ones) to actions, and provides inline submenus for
4
+ * Reasoning and Model. Changing a value re-renders the keyboard so its labels
5
+ * always reflect the current state.
6
+ *
7
+ * The Agent picker was removed: Grok has no useful headless agent switch, and
8
+ * plan mode is entered automatically when the agent judges a task complex.
6
9
  */
7
10
  import { type Bot, type Context, InlineKeyboard } from "grammy";
8
11
  import { reasoningLabel } from "../../app/reasoning.js";
9
- import { listAgents } from "../../agents/catalog.js";
10
12
  import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
11
13
  import type { BotDeps } from "../deps.js";
12
- import { BAR_LABELS, compactKeyboard, mainMenuInline, MENU_BTN, RUNNING_BTN, STOP_BTN } from "../menu/keyboard.js";
14
+ import {
15
+ BAR_LABELS,
16
+ compactKeyboard,
17
+ mainMenuInline,
18
+ MENU_BTN,
19
+ NEW_BTN,
20
+ RUNNING_BTN,
21
+ STOP_BTN,
22
+ } from "../menu/keyboard.js";
13
23
  import { refreshMenu } from "../menu/refresh.js";
24
+ import { resolveScope } from "../scope.js";
25
+ import { showImportSources } from "./import-session.js";
14
26
  import { showKillConfirm } from "./kill.js";
15
27
  import { showMcp } from "./mcp.js";
16
28
  import { showProjects } from "./projects.js";
@@ -20,15 +32,25 @@ import { showSessions } from "./sessions.js";
20
32
  import { showTasks } from "./tasks.js";
21
33
  import { showUsage } from "./usage.js";
22
34
 
23
- /** Open the full inline menu, showing the current agent/model/reasoning. */
35
+ /** Open the full inline menu, showing the current model/reasoning (topic-aware). */
24
36
  export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
25
37
  await deps.ephemeral.open(ctx);
26
- const rt = deps.registry.get(ctx.chat!.id);
27
- await deps.ephemeral.reply(ctx, "\u2699\uFE0F Menu", {
38
+ const scope = resolveScope(ctx, deps);
39
+ const preferred = scope.rt.preferredAccountId;
40
+ const saved = preferred
41
+ ? deps.accounts.list().find((a) => a.id === preferred || a.loginId === preferred)
42
+ : undefined;
43
+ const accountLabel = saved?.label || preferred?.slice(0, 12) || undefined;
44
+ const title = scope.isForum
45
+ ? `\u2699\uFE0F Topic menu \u00B7 ${scope.projectName ?? "topic"}`
46
+ : "\u2699\uFE0F Menu";
47
+ await deps.ephemeral.reply(ctx, title, {
28
48
  reply_markup: mainMenuInline({
29
- agent: rt.agent || "default",
30
- model: rt.model || "default",
31
- reasoning: reasoningLabel(rt.reasoning),
49
+ model: scope.rt.model || "default",
50
+ reasoning: reasoningLabel(scope.rt.reasoning),
51
+ forumTopic: scope.isForum
52
+ ? { name: scope.projectName ?? "Topic", account: accountLabel }
53
+ : undefined,
32
54
  }),
33
55
  });
34
56
  }
@@ -37,14 +59,27 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
37
59
  // Compact persistent bar.
38
60
  bot.hears(BAR_LABELS, async (ctx) => {
39
61
  deps.wizard.abort(ctx.chat.id);
62
+ const scope = resolveScope(ctx, deps);
40
63
  switch (ctx.message?.text) {
41
64
  case MENU_BTN:
42
65
  return openMainMenu(ctx, deps);
66
+ case NEW_BTN: {
67
+ await ctx.reply("\u2728 Creating new session\u2026", scope.threadExtra).catch(() => {});
68
+ try {
69
+ await scope.controller.addNew(scope.rt.cwd, scope.rt.projectName);
70
+ return refreshMenu(ctx, deps, `\u2728 New session in ${scope.rt.projectName ?? scope.rt.cwd}`);
71
+ } catch (e) {
72
+ return void ctx.reply(`\u274C ${(e as Error).message}`, scope.threadExtra);
73
+ }
74
+ }
43
75
  case RUNNING_BTN:
44
76
  return showRunning(ctx, deps);
45
77
  case STOP_BTN: {
46
- const rt = deps.registry.get(ctx.chat.id);
47
- return void ctx.reply((await rt.cancel()) ? "\u23F9 Cancelling\u2026" : "Nothing is running.");
78
+ const cancelled = await scope.rt.cancel();
79
+ return void ctx.reply(
80
+ cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
81
+ scope.threadExtra,
82
+ );
48
83
  }
49
84
  }
50
85
  });
@@ -52,20 +87,10 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
52
87
  // Inline menu actions.
53
88
  bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
54
89
 
55
- // ── Agent (real Grok modes) ─────────────────────────────────────────────
56
- bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
57
- const mode = deps.acp.availableModes[Number(ctx.match![1])];
58
- if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
59
- // Answer before ACP so a slow setMode never expires the callback query.
60
- await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
61
- await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
62
- await confirmUi(ctx, deps);
63
- });
64
-
65
90
  // ── Reasoning ──────────────────────────────────────────────────────────────
66
91
  bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
67
92
  const level = ctx.match![1] as ReasoningEffort;
68
- deps.registry.get(ctx.chat!.id).setReasoningPref(level);
93
+ resolveScope(ctx, deps).rt.setReasoningPref(level);
69
94
  await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
70
95
  });
71
96
 
@@ -74,37 +99,52 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
74
99
  const entry = deps.acp.availableModels[Number(ctx.match![1])];
75
100
  if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
76
101
  await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
77
- const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
102
+ const res = await resolveScope(ctx, deps).rt.setModelPref(entry.modelId);
78
103
  if (!res.ok) {
79
- await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`).catch(() => {});
104
+ await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`, resolveScope(ctx, deps).threadExtra).catch(() => {});
80
105
  }
81
106
  await confirmUi(ctx, deps);
82
107
  });
83
108
  bot.callbackQuery("model:clear", async (ctx) => {
84
109
  await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
85
- await deps.registry.get(ctx.chat!.id).setModelPref("");
110
+ await resolveScope(ctx, deps).rt.setModelPref("");
86
111
  await confirmUi(ctx, deps);
87
112
  });
88
113
  }
89
114
 
90
115
  /** Dispatch an inline-menu action (`m:<action>`). */
91
116
  async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
92
- const chatId = ctx.chat!.id;
93
- const rt = deps.registry.get(chatId);
117
+ const scope = resolveScope(ctx, deps);
118
+ const { chatId, rt, controller, threadExtra, isForum, projectName, projectPath } = scope;
94
119
  switch (action) {
95
120
  case "close":
96
121
  await ctx.answerCallbackQuery();
97
122
  return void ctx.deleteMessage().catch(() => {});
123
+ case "topicinfo":
124
+ await ctx.answerCallbackQuery();
125
+ return void deps.ephemeral.reply(
126
+ ctx,
127
+ `\u{1F4C1} **Topic project**\n${projectName ?? "?"}\n\`${projectPath ?? rt.cwd}\`\n\n` +
128
+ `Model / reasoning / running sessions on this menu are for **this topic only**.`,
129
+ );
98
130
  case "hidebar":
99
131
  await ctx.answerCallbackQuery();
100
132
  await ctx.deleteMessage().catch(() => {});
101
133
  return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
102
134
  reply_markup: { remove_keyboard: true },
135
+ ...threadExtra,
103
136
  });
104
137
  case "showbar":
105
138
  await ctx.answerCallbackQuery();
106
- return void ctx.reply("\u2328\uFE0F Bar restored.", { reply_markup: compactKeyboard() });
139
+ return void ctx.reply("\u2328\uFE0F Bar restored.", {
140
+ reply_markup: compactKeyboard(),
141
+ ...threadExtra,
142
+ });
107
143
  case "project":
144
+ if (isForum) {
145
+ await ctx.answerCallbackQuery({ text: "Project is fixed to this topic", show_alert: true });
146
+ return;
147
+ }
108
148
  await ctx.answerCallbackQuery();
109
149
  return showProjects(ctx, deps);
110
150
  case "running":
@@ -113,12 +153,19 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
113
153
  case "sessions":
114
154
  await ctx.answerCallbackQuery();
115
155
  return showSessions(ctx, deps);
156
+ case "import":
157
+ await ctx.answerCallbackQuery();
158
+ return showImportSources(ctx, deps);
116
159
  case "tasks":
117
160
  await ctx.answerCallbackQuery();
118
161
  return showTasks(ctx, deps);
119
162
  case "agent":
120
- await ctx.answerCallbackQuery();
121
- return showAgentMenu(ctx, deps);
163
+ // Legacy callback from old keyboards.
164
+ await ctx.answerCallbackQuery({
165
+ text: "Agent menu removed — Grok picks sub-agents automatically",
166
+ show_alert: true,
167
+ });
168
+ return openMainMenu(ctx, deps);
122
169
  case "model":
123
170
  await ctx.answerCallbackQuery();
124
171
  return showModelMenu(ctx, deps);
@@ -143,17 +190,26 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
143
190
  await ctx.answerCallbackQuery();
144
191
  return showKillConfirm(ctx, deps);
145
192
  case "new":
146
- await ctx.answerCallbackQuery();
193
+ await ctx.answerCallbackQuery({ text: "Creating session\u2026" });
194
+ await ctx.reply("\u2728 Creating new session\u2026", threadExtra).catch(() => {});
147
195
  try {
148
- await deps.registry.controller(chatId).addNew(rt.cwd, rt.projectName);
196
+ await controller.addNew(rt.cwd, rt.projectName);
149
197
  return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
150
198
  } catch (e) {
151
- return void ctx.reply(`\u274C ${(e as Error).message}`);
199
+ return void ctx.reply(`\u274C ${(e as Error).message}`, threadExtra);
152
200
  }
153
201
  case "stop": {
154
202
  // Answer first so a slow cancel never times out the callback query.
155
- await ctx.answerCallbackQuery({ text: rt.isBusy ? "Cancelling\u2026" : "Nothing is running" });
156
- if (rt.isBusy) await rt.cancel();
203
+ const busy = rt.isBusy;
204
+ await ctx.answerCallbackQuery({ text: busy ? "Cancelling\u2026" : "Nothing is running" });
205
+ const cancelled = busy ? await rt.cancel() : false;
206
+ // Visible in-topic feedback (callback toasts are easy to miss in groups).
207
+ await ctx
208
+ .reply(
209
+ cancelled || busy ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
210
+ threadExtra,
211
+ )
212
+ .catch(() => {});
157
213
  return;
158
214
  }
159
215
  default:
@@ -178,41 +234,26 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
178
234
  await openMainMenu(ctx, deps); // reopen so the new value is visible
179
235
  }
180
236
 
181
- async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
182
- const rt = deps.registry.get(ctx.chat!.id);
183
- await ensureReady(ctx, rt);
184
- await deps.ephemeral.open(ctx);
185
- const modes = deps.acp.availableModes.slice(0, 60);
186
- if (modes.length === 0) {
187
- // Grok has no headless agent switch; surface the sub-agents it can delegate
188
- // to (built-ins + any custom ones in ~/.grok/user-settings.json) as info.
189
- const subs = listAgents(rt.cwd).map((s) => s.name);
190
- const info = subs.length
191
- ? `Grok sub-agents available: ${subs.join(", ")}.\nGrok delegates to these automatically during a turn (via its task/delegate tools) — there's no headless agent switch. Use \u{1F9E9} Model to change the model.`
192
- : "Grok delegates to built-in sub-agents automatically in headless mode; there's no agent to select. Use \u{1F9E9} Model to change the model.";
193
- await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\n${info}`);
194
- return;
195
- }
196
- const kb = new InlineKeyboard();
197
- modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
198
- await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
199
- }
200
-
201
237
  async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
202
- const rt = deps.registry.get(ctx.chat!.id);
238
+ const rt = resolveScope(ctx, deps).rt;
203
239
  await deps.ephemeral.open(ctx);
204
240
  const kb = new InlineKeyboard();
205
241
  REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
206
- await deps.ephemeral.reply(ctx, `Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, { reply_markup: kb });
242
+ await deps.ephemeral.reply(ctx, `Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, {
243
+ reply_markup: kb,
244
+ });
207
245
  }
208
246
 
209
247
  async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
210
- const rt = deps.registry.get(ctx.chat!.id);
248
+ const rt = resolveScope(ctx, deps).rt;
211
249
  await ensureReady(ctx, rt);
212
250
  await deps.ephemeral.open(ctx);
213
251
  const models = deps.acp.availableModels;
214
252
  if (models.length === 0) {
215
- await deps.ephemeral.reply(ctx, "No selectable models reported by Grok yet \u2014 send a message first, then try again.");
253
+ await deps.ephemeral.reply(
254
+ ctx,
255
+ "No selectable models reported by Grok yet \u2014 send a message first, then try again.",
256
+ );
216
257
  return;
217
258
  }
218
259
  const current = rt.model || deps.acp.currentModelId;