grok-telegram-bot 2.4.0 → 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 (78) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +119 -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/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +12 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +96 -15
  12. package/src/bot/bot.ts +122 -15
  13. package/src/bot/chat-controller.ts +52 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +159 -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 +207 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +101 -21
  23. package/src/bot/handlers/photo.ts +123 -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 +8 -5
  29. package/src/bot/menu/ephemeral.ts +13 -3
  30. package/src/bot/menu/keyboard.ts +53 -14
  31. package/src/bot/menu/refresh.ts +3 -1
  32. package/src/bot/menu/status-panel.ts +12 -6
  33. package/src/bot/permission-service.ts +19 -0
  34. package/src/bot/prompt-anchor.ts +300 -0
  35. package/src/bot/prompt-content.ts +3 -0
  36. package/src/bot/registry.ts +94 -1
  37. package/src/bot/scope.ts +94 -0
  38. package/src/bot/session-runtime.ts +647 -158
  39. package/src/bot/suggestions.ts +91 -31
  40. package/src/bot/telegram-actions.ts +440 -0
  41. package/src/bot/telegram-bots.ts +495 -0
  42. package/src/bot/telegram-io.ts +94 -10
  43. package/src/cli.ts +2 -0
  44. package/src/config.ts +201 -2
  45. package/src/forum/bind-path.ts +146 -0
  46. package/src/forum/manager.ts +651 -0
  47. package/src/forum/project-icon.ts +142 -0
  48. package/src/forum/thread.ts +16 -0
  49. package/src/forum/topic-store.ts +114 -0
  50. package/src/forum/types.ts +29 -0
  51. package/src/grok/client.ts +130 -28
  52. package/src/index.ts +205 -75
  53. package/src/projects/manager.ts +16 -3
  54. package/src/render/chunk.ts +17 -10
  55. package/src/render/hashtags.ts +5 -1
  56. package/src/render/session-comment.ts +64 -7
  57. package/src/render/telegram-bridge.ts +360 -0
  58. package/src/render/tool-call.ts +56 -37
  59. package/src/service/platform.ts +44 -7
  60. package/src/service/windows.ts +16 -4
  61. package/src/sessions/history.ts +50 -9
  62. package/src/sessions/process.ts +7 -0
  63. package/src/sessions/types.ts +2 -2
  64. package/src/stream/streamer.ts +17 -6
  65. package/scripts/analyze-jsonl.ts +0 -33
  66. package/scripts/delayed-restart.ps1 +0 -29
  67. package/scripts/probe-exit-response-shape.py +0 -77
  68. package/scripts/probe-plan-exit.py +0 -60
  69. package/scripts/probe-plan-exit2.py +0 -48
  70. package/scripts/probe-plan-fields.py +0 -41
  71. package/scripts/probe-plan-fields2.py +0 -58
  72. package/scripts/probe-plan-response-path.py +0 -48
  73. package/scripts/sample-claude-tooluse.ts +0 -21
  74. package/scripts/sample-kiro-events.ts +0 -31
  75. package/scripts/smoke-exit-plan.ts +0 -274
  76. package/scripts/smoke-exit-shapes.ts +0 -252
  77. package/scripts/smoke-import.mjs +0 -82
  78. package/scripts/smoke-import.ts +0 -73
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Per-chat settings persistence (project, agent, model, reasoning, pinned
3
- * status message id). Backed by a single JSON file so state survives restarts.
2
+ * Per-chat / per-forum-topic settings persistence (project, agent, model,
3
+ * reasoning, pinned status message id, controlled sessions).
4
+ * Keys: `"12345"` for private chats, `"12345:t7"` for forum topic thread 7.
5
+ * Backed by a single JSON file so state survives restarts.
4
6
  */
5
7
  import { join } from "node:path";
6
8
  import { JsonStore } from "./json-store.js";
@@ -15,24 +17,61 @@ export class SettingsStore {
15
17
  this.store = new JsonStore<SettingsMap>(join(dataDir, "settings.json"), {});
16
18
  }
17
19
 
20
+ /** Settings for a private chat (or legacy callers). */
18
21
  get(chatId: number): ChatSettings {
19
- const existing = this.store.get()[String(chatId)];
22
+ return this.getKey(String(chatId));
23
+ }
24
+
25
+ /** Settings by storage key (`chatId` or `chatId:t{threadId}`). */
26
+ getKey(key: string): ChatSettings {
27
+ const existing = this.store.get()[key];
20
28
  return existing ?? defaultSettings();
21
29
  }
22
30
 
23
31
  update(chatId: number, patch: Partial<ChatSettings>): ChatSettings {
24
- const key = String(chatId);
25
- const next = { ...this.get(chatId), ...patch };
32
+ return this.updateKey(String(chatId), patch);
33
+ }
34
+
35
+ updateKey(key: string, patch: Partial<ChatSettings>): ChatSettings {
36
+ const next = { ...this.getKey(key), ...patch };
26
37
  this.store.update((m) => {
27
38
  m[key] = next;
28
39
  });
29
40
  return next;
30
41
  }
31
42
 
43
+ /**
44
+ * All settings entries whose projectPath matches (for bidirectional
45
+ * bot ↔ forum session discovery).
46
+ */
47
+ entriesForProject(projectPath: string): Array<{ key: string; settings: ChatSettings }> {
48
+ const want = normPath(projectPath);
49
+ const out: Array<{ key: string; settings: ChatSettings }> = [];
50
+ for (const [key, s] of Object.entries(this.store.get())) {
51
+ if (s.projectPath && normPath(s.projectPath) === want) {
52
+ out.push({ key, settings: s });
53
+ }
54
+ for (const cs of s.controlledSessions ?? []) {
55
+ if (cs.projectPath && normPath(cs.projectPath) === want) {
56
+ out.push({ key, settings: s });
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
32
64
  /** All chat ids that have interacted (for broadcast announcements). */
33
65
  chatIds(): number[] {
34
- return Object.keys(this.store.get())
35
- .map(Number)
36
- .filter((n) => Number.isFinite(n));
66
+ const ids = new Set<number>();
67
+ for (const key of Object.keys(this.store.get())) {
68
+ const n = Number(key.split(":")[0]);
69
+ if (Number.isFinite(n)) ids.add(n);
70
+ }
71
+ return [...ids];
37
72
  }
38
73
  }
74
+
75
+ function normPath(p: string): string {
76
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
77
+ }
package/src/app/types.ts CHANGED
@@ -13,6 +13,11 @@ export interface ChatSettings {
13
13
  agent?: string;
14
14
  model?: string;
15
15
  reasoning: ReasoningEffort;
16
+ /**
17
+ * Preferred saved Grok account login id for this chat/topic (optional).
18
+ * Applied when starting turns if different from the process-active account.
19
+ */
20
+ preferredAccountId?: string;
16
21
  /** Telegram message id of the pinned status panel, if any. */
17
22
  statusMessageId?: number;
18
23
  /** Sessions this chat controls (for multi-session switching). */
@@ -57,6 +62,11 @@ export interface PromptInput {
57
62
  resourceLinks?: PromptResourceLink[];
58
63
  /** Telegram message id of the prompt, so the reply threads to it. */
59
64
  replyTo?: number;
65
+ /**
66
+ * Short id for the bot-owned prompt anchor (`#prompt_<id>`). All AI messages
67
+ * for this turn carry the same tag so the user can search related replies.
68
+ */
69
+ promptId?: string;
60
70
  /**
61
71
  * Content of the message the user was replying to (or the portion they
62
72
  * quoted). Injected as context so the agent sees what the user is responding
@@ -74,13 +84,14 @@ export function textPrompt(
74
84
  text: string,
75
85
  replyTo?: number,
76
86
  quotedText?: string,
77
- opts?: { skipSelfRecheck?: boolean },
87
+ opts?: { skipSelfRecheck?: boolean; promptId?: string },
78
88
  ): PromptInput {
79
89
  return {
80
90
  text,
81
91
  images: [],
82
92
  resourceLinks: [],
83
93
  replyTo,
94
+ promptId: opts?.promptId,
84
95
  quotedText,
85
96
  skipSelfRecheck: opts?.skipSelfRecheck,
86
97
  };
@@ -17,6 +17,7 @@ import { get } from "node:https";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { JsonStore } from "./json-store.js";
20
+ import { markIntentionalShutdown } from "./lifetime-flag.js";
20
21
  import { createLogger } from "../logger.js";
21
22
  import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
22
23
 
@@ -145,27 +146,47 @@ export class Updater {
145
146
  }
146
147
 
147
148
  private async restart(): Promise<void> {
149
+ // Signal main/beforeExit that this exit is intentional (avoid keep-alive race).
150
+ markIntentionalShutdown("updater-reexec");
148
151
  await this.opts.shutdown().catch(() => {});
149
152
  // Under systemd/launchd, a clean exit triggers a managed relaunch (no double
150
153
  // instance). On Windows / foreground there is no supervisor, so re-exec.
151
154
  if (process.env.GROK_TG_SUPERVISED === "1") {
152
155
  log.info("exiting for supervisor to relaunch the updated bot");
156
+ try {
157
+ process.stderr.write("[updater] supervised exit for relaunch\n");
158
+ } catch {
159
+ /* ignore */
160
+ }
153
161
  setTimeout(() => process.exit(0), 250);
154
162
  return;
155
163
  }
156
164
  log.info("re-executing the updated bot");
157
165
  try {
166
+ // TTY: inherit stdio so `npm start` doesn't look like a silent death.
167
+ // Non-TTY (service): detach and ignore stdio.
168
+ const inherit = Boolean(process.stdout.isTTY);
158
169
  const child = spawn(
159
170
  process.execPath,
160
171
  ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
161
- { detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
172
+ {
173
+ detached: !inherit,
174
+ stdio: inherit ? "inherit" : "ignore",
175
+ cwd: this.opts.projectRoot,
176
+ env: process.env,
177
+ },
162
178
  );
163
- child.unref();
179
+ if (!inherit) child.unref();
164
180
  if (!child.pid) {
165
181
  log.error("re-exec spawn produced no pid — staying alive");
166
182
  return;
167
183
  }
168
- log.info(`re-exec child pid ${child.pid}`);
184
+ log.info(`re-exec child pid ${child.pid} (stdio=${inherit ? "inherit" : "ignore"})`);
185
+ try {
186
+ process.stderr.write(`[updater] re-exec child pid ${child.pid}; parent exiting\n`);
187
+ } catch {
188
+ /* ignore */
189
+ }
169
190
  } catch (e) {
170
191
  // Never exit if replacement failed — silent death is worse than stale code.
171
192
  log.error(`re-exec failed: ${(e as Error).message} — staying alive`);
package/src/bot/auth.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /**
2
2
  * Authorization middleware: restricts the bot to ALLOWED_USERS when configured.
3
+ *
4
+ * Applies to **private chats and groups/forum topics alike**. User IDs are
5
+ * comma-separated in env (`ALLOWED_USERS=111,222,333`). Empty set = allow all
6
+ * (unsafe — especially with TOPIC_GROUP_ID).
7
+ *
8
+ * Unauthorized users in groups are ignored silently (no ⛔ spam). Private chats
9
+ * get one clear denial. Callback taps get a toast.
3
10
  */
4
11
  import type { Context, NextFunction } from "grammy";
5
12
  import type { AppConfig } from "../config.js";
@@ -8,31 +15,105 @@ import { createLogger } from "../logger.js";
8
15
  const log = createLogger("auth");
9
16
 
10
17
  export function createAuthMiddleware(cfg: AppConfig) {
11
- const allowAll = cfg.allowedUsers.size === 0;
12
- if (allowAll) {
18
+ if (cfg.allowAllUsers) {
13
19
  log.warn("ALLOWED_USERS is empty — the bot will respond to ANY Telegram user.");
20
+ if (cfg.topicGroupId !== undefined) {
21
+ log.warn(
22
+ "TOPIC_GROUP_ID is set with empty ALLOWED_USERS — any group member can drive sessions.",
23
+ );
24
+ }
25
+ } else {
26
+ log.info(`ALLOWED_USERS: ${cfg.allowedUsers.size} id(s) (private + groups)`);
27
+ if (cfg.allowedUsers.size === 0) {
28
+ log.warn(
29
+ "ALLOWED_USERS was set but no valid numeric ids remain — denying everyone (fail closed).",
30
+ );
31
+ }
14
32
  }
15
33
 
16
34
  return async (ctx: Context, next: NextFunction): Promise<void> => {
35
+ // Bot membership changes (promote/demote) must always reach handlers so
36
+ // forum readiness can re-probe — not gated on ALLOWED_USERS or from.is_bot.
37
+ if (ctx.myChatMember) {
38
+ await next();
39
+ return;
40
+ }
41
+
17
42
  const from = ctx.from;
18
- // Only a genuine USER action is subject to (and worth replying to) the auth
19
- // gate. Ignore everything else silently most importantly the bot's OWN
20
- // updates: the status panel being pinned/unpinned emits a service message
21
- // whose `from` is THIS bot (is_bot), and replying "⛔ Not authorized" to
22
- // that (or to any service/no-`from` update) spammed the chat with false
23
- // rejections. Real unauthorized users still get one clear reply below.
43
+ // Only a genuine USER action is subject to the auth gate. Ignore bot-authored
44
+ // updates and missing `from` (service noise) so we never ⛔-spam ourselves.
24
45
  if (!from || from.is_bot) return;
25
46
  const m = ctx.message ?? ctx.editedMessage;
26
- if (m && (m.pinned_message || m.new_chat_members || m.left_chat_member)) return;
47
+ if (
48
+ m &&
49
+ (m.pinned_message ||
50
+ m.new_chat_members ||
51
+ m.left_chat_member ||
52
+ m.forum_topic_closed ||
53
+ m.forum_topic_reopened ||
54
+ m.forum_topic_edited ||
55
+ m.general_forum_topic_hidden ||
56
+ m.general_forum_topic_unhidden)
57
+ ) {
58
+ return;
59
+ }
27
60
 
28
- const userId = String(from.id);
29
- if (allowAll || cfg.allowedUsers.has(userId)) {
30
- await next();
61
+ // forum_topic_created: only allowed users get the path-bind prompt.
62
+ if (m?.forum_topic_created) {
63
+ if (isAllowed(cfg, from.id)) {
64
+ await next();
65
+ return;
66
+ }
67
+ log.debug(`blocked unauthorized forum_topic_created from ${from.id}`);
31
68
  return;
32
69
  }
33
- log.warn(`blocked unauthorized user ${userId}`);
34
- if (ctx.chat) {
35
- await ctx.reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID.");
70
+
71
+ if (isAllowed(cfg, from.id)) {
72
+ await next();
73
+ return;
36
74
  }
75
+
76
+ log.warn(
77
+ `blocked unauthorized user ${from.id}` +
78
+ (ctx.chat ? ` in chat ${ctx.chat.id} (${ctx.chat.type})` : ""),
79
+ );
80
+ await denyUnauthorized(ctx, m);
37
81
  };
38
82
  }
83
+
84
+ /**
85
+ * True when ALLOWED_USERS was blank (open) or `userId` is listed.
86
+ * When allowAllUsers is false and the set is empty, nobody is allowed.
87
+ */
88
+ export function isAllowed(cfg: AppConfig, userId: number | string): boolean {
89
+ if (cfg.allowAllUsers) return true;
90
+ return cfg.allowedUsers.has(String(userId));
91
+ }
92
+
93
+ /** Groups, supergroups, and channels: never ⛔-reply (silent deny). */
94
+ function isGroupChat(ctx: Context): boolean {
95
+ const t = ctx.chat?.type;
96
+ return t === "group" || t === "supergroup" || t === "channel";
97
+ }
98
+
99
+ /** Private: one ⛔ reply. Group: silent (or callback toast). Never spam topics. */
100
+ async function denyUnauthorized(
101
+ ctx: Context,
102
+ m: { message_thread_id?: number } | undefined,
103
+ ): Promise<void> {
104
+ if (ctx.callbackQuery) {
105
+ await ctx
106
+ .answerCallbackQuery({
107
+ text: "\u26D4 Not authorized",
108
+ show_alert: true,
109
+ })
110
+ .catch(() => {});
111
+ return;
112
+ }
113
+ if (!ctx.chat || isGroupChat(ctx)) return; // groups: ignore quietly
114
+ const threadId = m && "message_thread_id" in m ? m.message_thread_id : undefined;
115
+ const extra = threadId !== undefined ? { message_thread_id: threadId as number } : {};
116
+ await ctx
117
+ .reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
118
+ .catch(() => {});
119
+ }
package/src/bot/bot.ts CHANGED
@@ -22,7 +22,7 @@ import { Scheduler } from "../tasks/scheduler.js";
22
22
  import { TaskStore } from "../tasks/store.js";
23
23
  import { createAuthMiddleware } from "./auth.js";
24
24
  import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
25
- import { COMMANDS } from "./commands.js";
25
+ import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
26
26
  import { type BotDeps, MenuCache } from "./deps.js";
27
27
  import { registerControl } from "./handlers/control.js";
28
28
  import { registerDocuments } from "./handlers/document.js";
@@ -43,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
43
43
  import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
44
44
  import { registerUsage } from "./handlers/usage.js";
45
45
  import { registerVoice } from "./handlers/voice.js";
46
+ import { registerForum } from "./handlers/forum.js";
46
47
  import { StatusPanel } from "./menu/status-panel.js";
47
48
  import { sendMarkdownDoc } from "./telegram-io.js";
48
49
  import { Ephemeral } from "./menu/ephemeral.js";
@@ -50,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
50
51
  import { PermissionService } from "./permission-service.js";
51
52
  import { RuntimeRegistry } from "./registry.js";
52
53
  import { TaskWizard } from "./wizard/task-wizard.js";
54
+ import { ForumManager } from "../forum/manager.js";
55
+ import { TelegramBotService } from "./telegram-bots.js";
53
56
 
54
57
  const log = createLogger("bot");
55
58
 
@@ -99,13 +102,44 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
99
102
  const statusPanel = new StatusPanel(bot.api, settings, registry);
100
103
  registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
101
104
 
105
+ const projects = new ProjectManager(cfg.projectRoots);
106
+ const forum =
107
+ cfg.topicGroupId !== undefined
108
+ ? new ForumManager(bot.api, cfg, projects)
109
+ : undefined;
110
+
111
+ // Sibling bots + memory/topic actions for the agent telegram JSON bridge.
112
+ const telegramBots = new TelegramBotService(bot.api, cfg);
113
+ telegramBots.attachToBot(bot);
114
+ registry.setBridge({
115
+ store,
116
+ forum,
117
+ bots: telegramBots,
118
+ // Cross-topic orchestration: General can create a topic and send_prompt there.
119
+ submitTopicPrompt: async ({ threadId, cwd, projectName, prompt, newSession }) => {
120
+ if (cfg.topicGroupId === undefined) {
121
+ throw new Error("TOPIC_GROUP_ID unset");
122
+ }
123
+ const groupId = cfg.topicGroupId;
124
+ const controller = registry.forumController(groupId, threadId, cwd, projectName);
125
+ if (newSession) {
126
+ const rt = await controller.addNew(cwd, projectName);
127
+ const outcome = await rt.submit(textPrompt(prompt));
128
+ return { outcome, sessionId: rt.sessionId };
129
+ }
130
+ const rt = controller.foreground();
131
+ const outcome = await rt.submit(textPrompt(prompt));
132
+ return { outcome, sessionId: rt.sessionId };
133
+ },
134
+ });
135
+
102
136
  const deps: BotDeps = {
103
137
  api: bot.api,
104
138
  cfg,
105
139
  acp,
106
140
  registry,
107
141
  store,
108
- projects: new ProjectManager(cfg.projectRoots),
142
+ projects,
109
143
  menuCache: new MenuCache(),
110
144
  settings,
111
145
  statusPanel,
@@ -121,6 +155,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
121
155
  }),
122
156
  usage: new UsageService(cfg.grokCliPath),
123
157
  accounts: new AccountManager(cfg.dataDir),
158
+ forum,
124
159
  };
125
160
 
126
161
  // Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
@@ -135,6 +170,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
135
170
  onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
136
171
  });
137
172
  acp.permissionHandler = (p) => permissions.handle(p);
173
+ // /stop and /cancel must cancel pending interactive permissions for that
174
+ // session only (ACP requires cancelled outcomes) — never kill the agent.
175
+ acp.onSessionCancel = (sessionId) => {
176
+ permissions.cancelForSession(sessionId);
177
+ };
138
178
 
139
179
  // The bot pins/unpins the status panel, and Telegram emits a "pinned a
140
180
  // message" service message for each pin. Delete those so the chat stays clean
@@ -146,14 +186,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
146
186
  // handler forgets (prevents the loading spinner + unhandled 400 noise).
147
187
  bot.use(safeCallbackMiddleware());
148
188
 
149
- // Keep history clean: after handling, delete the user's command (/…) and
150
- // persistent-bar button taps. Plain prompts and wizard input are kept.
189
+ // Keep history clean: delete the user's command (/…) and persistent-bar
190
+ // button taps INSTANTLY (before handlers) so slow ACP/CLI work never leaves
191
+ // the raw slash sitting in chat. Handlers post bot status messages instead.
192
+ // Plain prompts are adopted separately (see prompt-anchor.ts).
151
193
  bot.on("message:text", async (ctx, next) => {
152
- await next();
153
194
  const text = ctx.message?.text ?? "";
154
195
  if (text.startsWith("/") || BAR_LABELS.includes(text)) {
155
- await ctx.deleteMessage().catch(() => {});
196
+ void ctx.deleteMessage().catch(() => {});
156
197
  }
198
+ await next();
157
199
  });
158
200
 
159
201
  bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
@@ -187,7 +229,10 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
187
229
  bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
188
230
  const batchId = Number(ctx.match![1]);
189
231
  const index = Number(ctx.match![2]);
190
- const rt = deps.registry.get(ctx.chat!.id);
232
+ const { resolveScope } = await import("./scope.js");
233
+ const { adoptUserPrompt } = await import("./prompt-anchor.js");
234
+ const scope = resolveScope(ctx, deps);
235
+ const rt = scope.rt;
191
236
  const text = rt.takeSuggestion(batchId, index);
192
237
  if (!text) {
193
238
  await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
@@ -197,17 +242,45 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
197
242
  // Dim the keyboard so double-taps don't re-fire.
198
243
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
199
244
  try {
200
- const outcome = await rt.submit(textPrompt(text, ctx.callbackQuery.message?.message_id));
245
+ const chatId = ctx.chat?.id;
246
+ const anchor =
247
+ chatId !== undefined
248
+ ? await adoptUserPrompt(deps.api, {
249
+ chatId,
250
+ text,
251
+ userMessageIds: [],
252
+ messageThreadId: scope.threadExtra.message_thread_id,
253
+ projectName: rt.projectName,
254
+ prefix: "\u{1F4A1} Suggestion",
255
+ })
256
+ : undefined;
257
+ const outcome = await rt.submit(
258
+ textPrompt(text, anchor?.replyTo ?? ctx.callbackQuery.message?.message_id, undefined, {
259
+ promptId: anchor?.promptId,
260
+ }),
261
+ );
201
262
  if (outcome === "queued") {
202
- await ctx.reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`).catch(() => {});
263
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
264
+ if (anchor?.replyTo !== undefined) {
265
+ extra.reply_parameters = {
266
+ message_id: anchor.replyTo,
267
+ allow_sending_without_reply: true,
268
+ };
269
+ }
270
+ await ctx
271
+ .reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`, extra)
272
+ .catch(() => {});
203
273
  }
204
274
  } catch (e) {
205
- await ctx.reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`).catch(() => {});
275
+ await ctx
276
+ .reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
277
+ .catch(() => {});
206
278
  }
207
279
  });
208
280
 
209
281
  registerMenu(bot, deps); // persistent-keyboard buttons (hears)
210
282
  registerWizardInput(bot, deps); // wizard text input (before commands)
283
+ if (forum) registerForum(bot, deps, forum);
211
284
  registerControl(bot, deps);
212
285
  registerProjects(bot, deps);
213
286
  registerSessions(bot, deps);
@@ -234,13 +307,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
234
307
  log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
235
308
  return;
236
309
  }
237
- log.error("unhandled bot error:", err.error instanceof Error ? err.error.message : err.error);
310
+ const e = err.error;
311
+ if (e instanceof Error) {
312
+ // Include Grammy error_code when present (429 / 403 / 409, etc.) for diagnosis.
313
+ const codeNum = (e as unknown as { error_code?: number }).error_code;
314
+ const code = typeof codeNum === "number" ? ` (code ${codeNum})` : "";
315
+ log.error(`unhandled bot error${code}:`, e.stack || e.message);
316
+ } else {
317
+ log.error("unhandled bot error:", e);
318
+ }
319
+ // Never rethrow — a middleware failure must not take down long polling.
238
320
  });
239
321
 
240
- try {
241
- await bot.api.setMyCommands(COMMANDS);
242
- } catch (e) {
243
- log.warn("setMyCommands failed:", (e as Error).message);
322
+ // Scoped command menus: private = full sorted list; groups = short list with
323
+ // cancel/menu first (reply keyboard is unreliable in forum topics).
324
+ const registerCommands = async (
325
+ commands: typeof COMMANDS,
326
+ scope?: { type: string; chat_id?: number },
327
+ label = "default",
328
+ ): Promise<void> => {
329
+ try {
330
+ await bot.api.setMyCommands(commands, scope ? { scope: scope as never } : undefined);
331
+ } catch (e) {
332
+ log.warn(`setMyCommands (${label}) failed:`, (e as Error).message);
333
+ }
334
+ };
335
+ await registerCommands(COMMANDS, undefined, "default");
336
+ await registerCommands(COMMANDS, { type: "all_private_chats" }, "private");
337
+ await registerCommands(GROUP_COMMANDS, { type: "all_group_chats" }, "groups");
338
+ if (cfg.topicGroupId !== undefined) {
339
+ await registerCommands(
340
+ GROUP_COMMANDS,
341
+ { type: "chat", chat_id: cfg.topicGroupId },
342
+ `chat:${cfg.topicGroupId}`,
343
+ );
244
344
  }
245
345
 
246
346
  const updater = new Updater({
@@ -278,5 +378,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
278
378
  // Remove any navigation surface left over from before a restart.
279
379
  void deps.ephemeral.cleanupAll().catch(() => {});
280
380
 
381
+ // Forum project topics: ensure AI Chat + optional catalog topics (best-effort).
382
+ if (forum) {
383
+ void forum.ensureSetup().catch((e) => {
384
+ log.warn(`forum setup failed: ${(e as Error).message}`);
385
+ });
386
+ }
387
+
281
388
  return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
282
389
  }