grok-telegram-bot 2.4.0 → 2.6.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 (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -1,28 +1,41 @@
1
1
  /**
2
- * Control commands: /start /help /status /new /cancel /btw /flush.
2
+ * Control commands: /start /help /status /new /cancel /stop /btw /flush /menu.
3
+ *
4
+ * Slash messages are deleted instantly by bot.ts middleware; handlers post
5
+ * bot status messages so the user always sees the bot is alive (CLI can be slow).
3
6
  */
4
- import type { Bot } from "grammy";
7
+ import type { Bot, Context } from "grammy";
5
8
  import { basename } from "node:path";
6
9
  import { textPrompt } from "../../app/types.js";
7
10
  import type { BotDeps } from "../deps.js";
8
11
  import { HELP_TEXT } from "../commands.js";
9
12
  import { compactKeyboard } from "../menu/keyboard.js";
10
13
  import { refreshMenu } from "../menu/refresh.js";
14
+ import { adoptUserPrompt } from "../prompt-anchor.js";
11
15
  import { extractReplyContext } from "../reply-context.js";
16
+ import { resolveScope } from "../scope.js";
12
17
  import { openMainMenu } from "./menu.js";
13
18
 
14
19
  export function registerControl(bot: Bot, deps: BotDeps): void {
15
20
  bot.command("start", async (ctx) => {
16
- const rt = deps.registry.get(ctx.chat.id);
21
+ const scope = resolveScope(ctx, deps);
17
22
  const agent = deps.acp.agentInfo;
23
+ const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
18
24
  const lines = [
19
25
  "\u{1F44B} Welcome! I bridge Telegram to Grok Build over ACP.",
20
26
  agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
21
27
  "",
22
- "Tap \u2630 Menu for everything. A live status panel appears while I work",
23
- "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
28
+ isGroup || scope.isForum
29
+ ? "In groups / topics: /menu for controls, /cancel or /stop to halt a turn."
30
+ : "Bar: \u2630 Menu \u00B7 \u{1F195} New session \u00B7 \u{1F9ED} Running \u00B7 \u23F9 Stop. Live status panel while I work",
31
+ isGroup || scope.isForum
32
+ ? "Just send a message in this topic to start."
33
+ : "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
24
34
  ].filter(Boolean);
25
- await ctx.reply(lines.join("\n"), { reply_markup: compactKeyboard() });
35
+ await ctx.reply(lines.join("\n"), {
36
+ reply_markup: compactKeyboard(),
37
+ ...scope.threadExtra,
38
+ });
26
39
  await deps.statusPanel.refresh(ctx.chat.id);
27
40
  });
28
41
 
@@ -32,71 +45,111 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
32
45
  });
33
46
 
34
47
  bot.command("help", async (ctx) => {
35
- await ctx.reply(HELP_TEXT);
48
+ const scope = resolveScope(ctx, deps);
49
+ await ctx.reply(HELP_TEXT, scope.threadExtra);
36
50
  });
37
51
 
38
52
  bot.command("status", async (ctx) => {
39
- const rt = deps.registry.get(ctx.chat.id);
53
+ const scope = resolveScope(ctx, deps);
54
+ const rt = scope.rt;
40
55
  const lines = [
41
56
  "\u{1F4CA} Status",
57
+ scope.isForum ? `Topic: ${scope.projectName ?? "topic"}` : "",
42
58
  `Project: ${rt.projectName ?? (basename(rt.cwd) || rt.cwd)}`,
43
59
  `Folder: ${rt.cwd}`,
44
60
  `Session: ${rt.sessionId ?? "(none yet)"}`,
61
+ `Model: ${rt.model || "default"}`,
62
+ `Reasoning: ${rt.reasoning}`,
45
63
  `State: ${rt.isBusy ? "\u23F3 working" : "\u2705 idle"}`,
46
64
  `Queued follow-ups: ${rt.queueLength}`,
47
- ];
65
+ ].filter(Boolean);
48
66
  const subagents = deps.registry.subagentSummaryForChat(ctx.chat.id);
49
67
  if (subagents) lines.push(`Subagents: ${subagents}`);
50
- await ctx.reply(lines.join("\n"));
68
+ await ctx.reply(lines.join("\n"), scope.threadExtra);
51
69
  });
52
70
 
53
71
  bot.command("new", async (ctx) => {
54
- const rt = deps.registry.get(ctx.chat.id);
72
+ const scope = resolveScope(ctx, deps);
73
+ // Instant feedback before ACP session/new (can be slow on cold CLI).
74
+ // Same copy as bar 🆕 New and inline m:new.
75
+ await ctx.reply("\u2728 Creating new session\u2026", scope.threadExtra).catch(() => {});
55
76
  try {
56
- await deps.registry.controller(ctx.chat.id).addNew(rt.cwd, rt.projectName);
57
- await refreshMenu(ctx, deps, `\u2728 New session started in ${rt.projectName ?? rt.cwd}`);
77
+ await scope.controller.addNew(scope.rt.cwd, scope.rt.projectName);
78
+ await refreshMenu(ctx, deps, `\u2728 New session in ${scope.rt.projectName ?? scope.rt.cwd}`);
58
79
  } catch (err) {
59
- await ctx.reply(`\u274C Could not start session: ${(err as Error).message}`);
80
+ await ctx.reply(`\u274C ${(err as Error).message}`, scope.threadExtra);
60
81
  }
61
82
  });
62
83
 
63
- bot.command("cancel", async (ctx) => {
64
- const rt = deps.registry.get(ctx.chat.id);
65
- const cancelled = await rt.cancel();
66
- await ctx.reply(cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.");
67
- });
84
+ const cancelTurn = async (ctx: Context): Promise<void> => {
85
+ const scope = resolveScope(ctx, deps);
86
+ const cancelled = await scope.rt.cancel();
87
+ await ctx.reply(
88
+ cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
89
+ scope.threadExtra,
90
+ );
91
+ };
92
+
93
+ bot.command("cancel", cancelTurn);
94
+ bot.command("stop", cancelTurn);
68
95
 
69
96
  bot.command("btw", async (ctx) => {
70
97
  const text = (ctx.match || "").toString().trim();
71
98
  if (!text) {
72
- await ctx.reply("Usage: /btw <something for the agent to do — now if idle, otherwise next>");
99
+ const scope = resolveScope(ctx, deps);
100
+ await ctx.reply(
101
+ "Usage: /btw <something for the agent to do — now if idle, otherwise next>",
102
+ scope.threadExtra,
103
+ );
73
104
  return;
74
105
  }
75
- const rt = deps.registry.get(ctx.chat.id);
76
- // Run it right away when idle; otherwise queue it to run automatically the
77
- // moment the current turn finishes (can't interrupt an in-flight agent turn).
78
- const outcome = await rt.submit(textPrompt(text, undefined, extractReplyContext(ctx)));
106
+ const scope = resolveScope(ctx, deps);
107
+ const userMsgId = ctx.message?.message_id;
108
+ const anchor = await adoptUserPrompt(deps.api, {
109
+ chatId: ctx.chat.id,
110
+ text,
111
+ // Command middleware already deleted the slash message; re-delete is a no-op.
112
+ userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
113
+ messageThreadId: scope.threadExtra.message_thread_id,
114
+ projectName: scope.rt.projectName,
115
+ prefix: "\u{1F4DD} /btw",
116
+ });
117
+ // Fall back to userMsgId if anchor send failed (message may already be gone).
118
+ const outcome = await scope.rt.submit(
119
+ textPrompt(text, anchor?.replyTo ?? userMsgId, extractReplyContext(ctx), {
120
+ promptId: anchor?.promptId,
121
+ }),
122
+ );
79
123
  if (outcome === "queued") {
124
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
125
+ if (anchor?.replyTo !== undefined) {
126
+ extra.reply_parameters = {
127
+ message_id: anchor.replyTo,
128
+ allow_sending_without_reply: true,
129
+ };
130
+ }
80
131
  await ctx.reply(
81
- `\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
132
+ `\u{1F4E5} Queued (position ${scope.rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
133
+ extra,
82
134
  );
83
- } else {
84
- await ctx.reply("\u25B6\uFE0F On it\u2026");
85
135
  }
86
136
  });
87
137
 
88
138
  bot.command("flush", async (ctx) => {
89
- const rt = deps.registry.get(ctx.chat.id);
139
+ const scope = resolveScope(ctx, deps);
140
+ const rt = scope.rt;
90
141
  if (rt.queueLength === 0) {
91
- await ctx.reply("Queue is empty.");
142
+ await ctx.reply("Queue is empty.", scope.threadExtra);
92
143
  return;
93
144
  }
94
145
  if (rt.isBusy) {
95
- await ctx.reply(`\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`);
146
+ await ctx.reply(
147
+ `\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`,
148
+ scope.threadExtra,
149
+ );
96
150
  return;
97
151
  }
98
- // Idle: drain the queue by submitting an empty trigger that flushes.
99
- await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026");
152
+ await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026", scope.threadExtra);
100
153
  const drained = rt.drainQueueToPrompt();
101
154
  if (drained) await rt.submit(drained);
102
155
  });
@@ -57,7 +57,7 @@ export function registerDocuments(bot: Bot, deps: BotDeps): void {
57
57
 
58
58
  const caption = ctx.message.caption ?? "";
59
59
  const quoted = extractReplyContext(ctx);
60
- const replyTo = ctx.message.message_id;
60
+ const userMsgId = ctx.message.message_id;
61
61
 
62
62
  let promptText: string;
63
63
  if (looksLikeText(buf, doc.mime_type, name)) {
@@ -75,10 +75,37 @@ export function registerDocuments(bot: Bot, deps: BotDeps): void {
75
75
  }
76
76
 
77
77
  try {
78
- const rt = deps.registry.get(chatId);
79
- const outcome = await rt.submit(textPrompt(promptText, replyTo, quoted));
78
+ const { resolveScope } = await import("../scope.js");
79
+ const { adoptUserPrompt } = await import("../prompt-anchor.js");
80
+ const scope = resolveScope(ctx, deps);
81
+ const anchorPreview = caption.trim() || `File: ${name}`;
82
+ const anchor = await adoptUserPrompt(deps.api, {
83
+ chatId,
84
+ text: anchorPreview,
85
+ userMessageIds: [userMsgId],
86
+ messageThreadId: scope.threadExtra.message_thread_id,
87
+ projectName: scope.rt.projectName,
88
+ prefix: `\u{1F4CE} ${name}`,
89
+ // Re-post the file so it stays in chat after the user message is deleted.
90
+ media: [{ type: "document", fileId: doc.file_id, fileName: name }],
91
+ });
92
+ const outcome = await scope.rt.submit(
93
+ textPrompt(promptText, anchor?.replyTo ?? userMsgId, quoted, {
94
+ promptId: anchor?.promptId,
95
+ }),
96
+ );
80
97
  if (outcome === "queued") {
81
- await ctx.reply(`\u{1F4E5} Queued "${name}" \u2014 will run after the current task.`);
98
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
99
+ if (anchor?.replyTo !== undefined) {
100
+ extra.reply_parameters = {
101
+ message_id: anchor.replyTo,
102
+ allow_sending_without_reply: true,
103
+ };
104
+ }
105
+ await ctx.reply(
106
+ `\u{1F4E5} Queued "${name}" \u2014 will run after the current task.`,
107
+ extra,
108
+ );
82
109
  }
83
110
  } catch (e) {
84
111
  log.warn(`submit failed for "${name}":`, (e as Error).message);
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Forum topic group: auto-setup, user-created topic binding, and path prompts.
3
+ */
4
+ import type { Bot } from "grammy";
5
+ import { createLogger } from "../../logger.js";
6
+ import type { ForumManager } from "../../forum/manager.js";
7
+ import type { BotDeps } from "../deps.js";
8
+ import {
9
+ FORUM_GENERAL_THREAD_ID,
10
+ forumThreadId,
11
+ outboundThreadExtra,
12
+ } from "../../forum/thread.js";
13
+
14
+ const log = createLogger("forum-handler");
15
+
16
+ const BIND_HINT =
17
+ `Send the **absolute project path** or an **exact** catalog project name to bind this topic.\n` +
18
+ `Example: \`H:\\\\Lucru\\\\Domains\\\\MyApp\``;
19
+
20
+ export function registerForum(bot: Bot, deps: BotDeps, forum: ForumManager): void {
21
+ const groupId = forum.groupId;
22
+
23
+ // Service message: user (or another admin) created a topic.
24
+ bot.on("message:forum_topic_created", async (ctx) => {
25
+ if (ctx.chat?.id !== groupId) return;
26
+ // Group ignored (not admin / no Topics / probe failed).
27
+ if (!forum.isReady) {
28
+ log.debug(`ignore forum_topic_created — forum not ready (${forum.getStatusText()})`);
29
+ return;
30
+ }
31
+ const created = ctx.message.forum_topic_created;
32
+ const threadId = ctx.message.message_thread_id;
33
+ if (!created || threadId === undefined) return;
34
+ // Skip General — always workspace-bound.
35
+ if (threadId === FORUM_GENERAL_THREAD_ID) return;
36
+
37
+ forum.noteUserTopic(threadId, created.name);
38
+ log.info(`user topic created: ${created.name} (#${threadId})`);
39
+
40
+ // Exact catalog name → bind immediately (no message required).
41
+ const auto = await forum.tryAutoBindByTopicName(threadId, created.name);
42
+ if (auto.status === "bound") {
43
+ await ctx
44
+ .reply(
45
+ `\u2705 Topic **${created.name}** auto-bound to project:\n\`${auto.binding.projectPath}\`\n\nYou can chat here now.`,
46
+ { parse_mode: "Markdown", message_thread_id: threadId },
47
+ )
48
+ .catch(() => {});
49
+ return;
50
+ }
51
+
52
+ if (auto.status === "already_bound") {
53
+ await ctx
54
+ .reply(
55
+ `\u{1F4CC} New topic **${created.name}**.\n\n` +
56
+ `Exact catalog match \`${auto.projectPath}\` is already bound to topic #${auto.otherThreadId}.\n` +
57
+ BIND_HINT,
58
+ { parse_mode: "Markdown", message_thread_id: threadId },
59
+ )
60
+ .catch(() => {});
61
+ return;
62
+ }
63
+
64
+ await ctx
65
+ .reply(
66
+ `\u{1F4CC} New topic **${created.name}**.\n\n` +
67
+ `No exact catalog project matched this name.\n` +
68
+ BIND_HINT,
69
+ { parse_mode: "Markdown", message_thread_id: threadId },
70
+ )
71
+ .catch(() => {});
72
+ });
73
+
74
+ // Re-probe when the bot is promoted/demoted in the configured group.
75
+ bot.on("my_chat_member", async (ctx) => {
76
+ if (ctx.chat?.id !== groupId) return;
77
+ const status = ctx.myChatMember.new_chat_member.status;
78
+ if (status === "administrator" || status === "creator") {
79
+ log.info(`bot became ${status} in topic group — re-running forum setup`);
80
+ void forum.ensureSetup().catch((e) => {
81
+ log.warn(`forum re-setup after promotion failed: ${(e as Error).message}`);
82
+ });
83
+ return;
84
+ }
85
+ if (status === "member" || status === "restricted" || status === "left" || status === "kicked") {
86
+ forum.markDisabled(
87
+ "not_admin",
88
+ `bot status is now "${status}" — forum topic management ignored until the bot is admin again`,
89
+ );
90
+ }
91
+ });
92
+
93
+ // Optional: re-run setup command for admins in the group.
94
+ bot.command("forum_setup", async (ctx) => {
95
+ const threadId = ctx.message?.message_thread_id;
96
+ const replyOpts = outboundThreadExtra(threadId);
97
+ if (ctx.chat?.id !== groupId) {
98
+ await ctx.reply("Use this command inside the configured forum group.", replyOpts).catch(() => {});
99
+ return;
100
+ }
101
+ await ctx
102
+ .reply(
103
+ "Setting up forum topics (this can take a while for large catalogs)…",
104
+ replyOpts,
105
+ )
106
+ .catch(() => {});
107
+ try {
108
+ await forum.ensureSetup();
109
+ if (!forum.isReady) {
110
+ await ctx
111
+ .reply(
112
+ `\u26A0\uFE0F Forum setup skipped / group ignored.\n${forum.getStatusText()}`,
113
+ replyOpts,
114
+ )
115
+ .catch(() => {});
116
+ return;
117
+ }
118
+ const n = forum.store.all().length;
119
+ await ctx
120
+ .reply(`\u2705 Forum setup done. ${n} topic(s) mapped.\n${forum.getStatusText()}`, replyOpts)
121
+ .catch(() => {});
122
+ } catch (e) {
123
+ await ctx.reply(`Setup failed: ${(e as Error).message}`, replyOpts).catch(() => {});
124
+ }
125
+ });
126
+
127
+ void deps;
128
+ }
129
+
130
+ /**
131
+ * Resolve a forum-group text message to a SessionRuntime, or handle bind flow.
132
+ * Returns "handled" when the message was consumed (bind ask / bind result).
133
+ */
134
+ export async function resolveForumRuntime(
135
+ deps: BotDeps,
136
+ forum: ForumManager,
137
+ chatId: number,
138
+ threadId: number | undefined,
139
+ text: string,
140
+ messageId: number,
141
+ ): Promise<{ rt: import("../session-runtime.js").SessionRuntime } | "handled" | "ignore"> {
142
+ if (chatId !== forum.groupId) return "ignore";
143
+ // Not admin / Topics off / probe failed — do not steal group messages.
144
+ if (!forum.isReady) return "ignore";
145
+ const tid = forumThreadId(threadId);
146
+
147
+ // General topic → always workspace (no path prompt).
148
+ if (tid === FORUM_GENERAL_THREAD_ID) {
149
+ const cwd = deps.cfg.workspace;
150
+ if (!forum.store.get(tid)?.projectPath) {
151
+ forum.store.bindProject(tid, cwd, "General", "general");
152
+ }
153
+ const rt = deps.registry.getForumTopic(chatId, tid, cwd, "General");
154
+ return { rt };
155
+ }
156
+
157
+ // Ensure we know about this thread.
158
+ let binding = forum.store.get(tid);
159
+ if (!binding) {
160
+ binding = forum.noteUserTopic(tid, `Topic ${tid}`);
161
+ }
162
+
163
+ // Unbound only: try to interpret text as path / exact catalog name (do not
164
+ // steal normal prompts when already bound — even if pending flag is stale).
165
+ if (!binding.projectPath) {
166
+ const result = forum.tryBindPath(tid, text);
167
+ if (result.ok) {
168
+ const iconNote = result.binding.iconPath ? `\nIcon: ${result.binding.iconPath}` : "";
169
+ await deps.api
170
+ .sendMessage(
171
+ chatId,
172
+ `\u2705 Bound to project:\n\`${result.binding.projectPath}\`${iconNote}\n\nYou can chat here now.`,
173
+ {
174
+ ...outboundThreadExtra(tid),
175
+ parse_mode: "Markdown",
176
+ reply_parameters: { message_id: messageId },
177
+ },
178
+ )
179
+ .catch(() => {});
180
+ // Warm runtime; path-only message is not submitted as an agent prompt.
181
+ const resolved = forum.resolveCwd(tid);
182
+ if (resolved) {
183
+ deps.registry.getForumTopic(chatId, tid, resolved.cwd, resolved.projectName);
184
+ }
185
+ return "handled";
186
+ }
187
+ await deps.api
188
+ .sendMessage(
189
+ chatId,
190
+ `\u2753 ${result.error}\n\n${BIND_HINT.replace(/\*\*/g, "")}`,
191
+ {
192
+ ...outboundThreadExtra(tid),
193
+ reply_parameters: { message_id: messageId },
194
+ },
195
+ )
196
+ .catch(() => {});
197
+ return "handled";
198
+ }
199
+
200
+ const resolved = forum.resolveCwd(tid);
201
+ if (!resolved) {
202
+ forum.store.markPending(tid);
203
+ await deps.api
204
+ .sendMessage(
205
+ chatId,
206
+ `\u2753 This topic is not linked to a project yet.\n${BIND_HINT.replace(/\*\*/g, "")}`,
207
+ outboundThreadExtra(tid),
208
+ )
209
+ .catch(() => {});
210
+ return "handled";
211
+ }
212
+
213
+ // Multi-session controller for this topic (own model/reasoning/running list).
214
+ // First message with no sessionId creates one via ensureSession on submit.
215
+ const controller = deps.registry.forumController(chatId, tid, resolved.cwd, resolved.projectName);
216
+ return { rt: controller.foreground() };
217
+ }
@@ -11,8 +11,17 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
11
11
  import { reasoningLabel } from "../../app/reasoning.js";
12
12
  import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
13
13
  import type { BotDeps } from "../deps.js";
14
- 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";
15
23
  import { refreshMenu } from "../menu/refresh.js";
24
+ import { resolveScope } from "../scope.js";
16
25
  import { showImportSources } from "./import-session.js";
17
26
  import { showKillConfirm } from "./kill.js";
18
27
  import { showMcp } from "./mcp.js";
@@ -23,14 +32,25 @@ import { showSessions } from "./sessions.js";
23
32
  import { showTasks } from "./tasks.js";
24
33
  import { showUsage } from "./usage.js";
25
34
 
26
- /** Open the full inline menu, showing the current model/reasoning. */
35
+ /** Open the full inline menu, showing the current model/reasoning (topic-aware). */
27
36
  export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
28
37
  await deps.ephemeral.open(ctx);
29
- const rt = deps.registry.get(ctx.chat!.id);
30
- 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, {
31
48
  reply_markup: mainMenuInline({
32
- model: rt.model || "default",
33
- 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,
34
54
  }),
35
55
  });
36
56
  }
@@ -39,14 +59,27 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
39
59
  // Compact persistent bar.
40
60
  bot.hears(BAR_LABELS, async (ctx) => {
41
61
  deps.wizard.abort(ctx.chat.id);
62
+ const scope = resolveScope(ctx, deps);
42
63
  switch (ctx.message?.text) {
43
64
  case MENU_BTN:
44
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
+ }
45
75
  case RUNNING_BTN:
46
76
  return showRunning(ctx, deps);
47
77
  case STOP_BTN: {
48
- const rt = deps.registry.get(ctx.chat.id);
49
- 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
+ );
50
83
  }
51
84
  }
52
85
  });
@@ -57,7 +90,7 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
57
90
  // ── Reasoning ──────────────────────────────────────────────────────────────
58
91
  bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
59
92
  const level = ctx.match![1] as ReasoningEffort;
60
- deps.registry.get(ctx.chat!.id).setReasoningPref(level);
93
+ resolveScope(ctx, deps).rt.setReasoningPref(level);
61
94
  await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
62
95
  });
63
96
 
@@ -66,37 +99,52 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
66
99
  const entry = deps.acp.availableModels[Number(ctx.match![1])];
67
100
  if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
68
101
  await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
69
- const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
102
+ const res = await resolveScope(ctx, deps).rt.setModelPref(entry.modelId);
70
103
  if (!res.ok) {
71
- 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(() => {});
72
105
  }
73
106
  await confirmUi(ctx, deps);
74
107
  });
75
108
  bot.callbackQuery("model:clear", async (ctx) => {
76
109
  await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
77
- await deps.registry.get(ctx.chat!.id).setModelPref("");
110
+ await resolveScope(ctx, deps).rt.setModelPref("");
78
111
  await confirmUi(ctx, deps);
79
112
  });
80
113
  }
81
114
 
82
115
  /** Dispatch an inline-menu action (`m:<action>`). */
83
116
  async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
84
- const chatId = ctx.chat!.id;
85
- const rt = deps.registry.get(chatId);
117
+ const scope = resolveScope(ctx, deps);
118
+ const { chatId, rt, controller, threadExtra, isForum, projectName, projectPath } = scope;
86
119
  switch (action) {
87
120
  case "close":
88
121
  await ctx.answerCallbackQuery();
89
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
+ );
90
130
  case "hidebar":
91
131
  await ctx.answerCallbackQuery();
92
132
  await ctx.deleteMessage().catch(() => {});
93
133
  return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
94
134
  reply_markup: { remove_keyboard: true },
135
+ ...threadExtra,
95
136
  });
96
137
  case "showbar":
97
138
  await ctx.answerCallbackQuery();
98
- 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
+ });
99
143
  case "project":
144
+ if (isForum) {
145
+ await ctx.answerCallbackQuery({ text: "Project is fixed to this topic", show_alert: true });
146
+ return;
147
+ }
100
148
  await ctx.answerCallbackQuery();
101
149
  return showProjects(ctx, deps);
102
150
  case "running":
@@ -142,17 +190,26 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
142
190
  await ctx.answerCallbackQuery();
143
191
  return showKillConfirm(ctx, deps);
144
192
  case "new":
145
- await ctx.answerCallbackQuery();
193
+ await ctx.answerCallbackQuery({ text: "Creating session\u2026" });
194
+ await ctx.reply("\u2728 Creating new session\u2026", threadExtra).catch(() => {});
146
195
  try {
147
- await deps.registry.controller(chatId).addNew(rt.cwd, rt.projectName);
196
+ await controller.addNew(rt.cwd, rt.projectName);
148
197
  return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
149
198
  } catch (e) {
150
- return void ctx.reply(`\u274C ${(e as Error).message}`);
199
+ return void ctx.reply(`\u274C ${(e as Error).message}`, threadExtra);
151
200
  }
152
201
  case "stop": {
153
202
  // Answer first so a slow cancel never times out the callback query.
154
- await ctx.answerCallbackQuery({ text: rt.isBusy ? "Cancelling\u2026" : "Nothing is running" });
155
- 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(() => {});
156
213
  return;
157
214
  }
158
215
  default:
@@ -178,20 +235,25 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
178
235
  }
179
236
 
180
237
  async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
181
- const rt = deps.registry.get(ctx.chat!.id);
238
+ const rt = resolveScope(ctx, deps).rt;
182
239
  await deps.ephemeral.open(ctx);
183
240
  const kb = new InlineKeyboard();
184
241
  REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
185
- 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
+ });
186
245
  }
187
246
 
188
247
  async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
189
- const rt = deps.registry.get(ctx.chat!.id);
248
+ const rt = resolveScope(ctx, deps).rt;
190
249
  await ensureReady(ctx, rt);
191
250
  await deps.ephemeral.open(ctx);
192
251
  const models = deps.acp.availableModels;
193
252
  if (models.length === 0) {
194
- 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
+ );
195
257
  return;
196
258
  }
197
259
  const current = rt.model || deps.acp.currentModelId;