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
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
@@ -11,6 +11,7 @@ import { SettingsStore } from "../app/settings-store.js";
11
11
  import { SttService } from "../app/stt.js";
12
12
  import { Updater } from "../app/updater.js";
13
13
  import { UsageService } from "../app/usage.js";
14
+ import { textPrompt } from "../app/types.js";
14
15
  import type { AppConfig } from "../config.js";
15
16
  import { INSTANCE_DIR } from "../config.js";
16
17
  import { createLogger } from "../logger.js";
@@ -21,11 +22,12 @@ import { Scheduler } from "../tasks/scheduler.js";
21
22
  import { TaskStore } from "../tasks/store.js";
22
23
  import { createAuthMiddleware } from "./auth.js";
23
24
  import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
24
- import { COMMANDS } from "./commands.js";
25
+ import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
25
26
  import { type BotDeps, MenuCache } from "./deps.js";
26
27
  import { registerControl } from "./handlers/control.js";
27
28
  import { registerDocuments } from "./handlers/document.js";
28
29
  import { registerHistory } from "./handlers/history.js";
30
+ import { registerImportSession } from "./handlers/import-session.js";
29
31
  import { registerKill } from "./handlers/kill.js";
30
32
  import { registerMcp } from "./handlers/mcp.js";
31
33
  import { registerMenu } from "./handlers/menu.js";
@@ -41,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
41
43
  import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
42
44
  import { registerUsage } from "./handlers/usage.js";
43
45
  import { registerVoice } from "./handlers/voice.js";
46
+ import { registerForum } from "./handlers/forum.js";
44
47
  import { StatusPanel } from "./menu/status-panel.js";
45
48
  import { sendMarkdownDoc } from "./telegram-io.js";
46
49
  import { Ephemeral } from "./menu/ephemeral.js";
@@ -48,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
48
51
  import { PermissionService } from "./permission-service.js";
49
52
  import { RuntimeRegistry } from "./registry.js";
50
53
  import { TaskWizard } from "./wizard/task-wizard.js";
54
+ import { ForumManager } from "../forum/manager.js";
55
+ import { TelegramBotService } from "./telegram-bots.js";
51
56
 
52
57
  const log = createLogger("bot");
53
58
 
@@ -97,13 +102,44 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
97
102
  const statusPanel = new StatusPanel(bot.api, settings, registry);
98
103
  registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
99
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
+
100
136
  const deps: BotDeps = {
101
137
  api: bot.api,
102
138
  cfg,
103
139
  acp,
104
140
  registry,
105
141
  store,
106
- projects: new ProjectManager(cfg.projectRoots),
142
+ projects,
107
143
  menuCache: new MenuCache(),
108
144
  settings,
109
145
  statusPanel,
@@ -119,6 +155,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
119
155
  }),
120
156
  usage: new UsageService(cfg.grokCliPath),
121
157
  accounts: new AccountManager(cfg.dataDir),
158
+ forum,
122
159
  };
123
160
 
124
161
  // Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
@@ -133,6 +170,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
133
170
  onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
134
171
  });
135
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
+ };
136
178
 
137
179
  // The bot pins/unpins the status panel, and Telegram emits a "pinned a
138
180
  // message" service message for each pin. Delete those so the chat stays clean
@@ -144,14 +186,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
144
186
  // handler forgets (prevents the loading spinner + unhandled 400 noise).
145
187
  bot.use(safeCallbackMiddleware());
146
188
 
147
- // Keep history clean: after handling, delete the user's command (/…) and
148
- // 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).
149
193
  bot.on("message:text", async (ctx, next) => {
150
- await next();
151
194
  const text = ctx.message?.text ?? "";
152
195
  if (text.startsWith("/") || BAR_LABELS.includes(text)) {
153
- await ctx.deleteMessage().catch(() => {});
196
+ void ctx.deleteMessage().catch(() => {});
154
197
  }
198
+ await next();
155
199
  });
156
200
 
157
201
  bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
@@ -171,11 +215,76 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
171
215
  if (sid) await switchAndShow(ctx, deps, sid);
172
216
  });
173
217
 
218
+ // Legacy complexity buttons (removed — agent decides; auto-plan if complex).
219
+ bot.callbackQuery(/^cplx:(simple|complex)$/, async (ctx) => {
220
+ await ctx.answerCallbackQuery({ text: "Complexity is automatic now" });
221
+ await ctx
222
+ .editMessageText("\u2705 Complexity is decided by the agent automatically \u2014 just send your task.", {
223
+ reply_markup: { inline_keyboard: [] },
224
+ })
225
+ .catch(() => {});
226
+ });
227
+
228
+ // Post-turn suggestion buttons on the Done message.
229
+ bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
230
+ const batchId = Number(ctx.match![1]);
231
+ const index = Number(ctx.match![2]);
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;
236
+ const text = rt.takeSuggestion(batchId, index);
237
+ if (!text) {
238
+ await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
239
+ return;
240
+ }
241
+ await ctx.answerCallbackQuery({ text: "Sending\u2026" });
242
+ // Dim the keyboard so double-taps don't re-fire.
243
+ await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
244
+ try {
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
+ );
262
+ if (outcome === "queued") {
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(() => {});
273
+ }
274
+ } catch (e) {
275
+ await ctx
276
+ .reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
277
+ .catch(() => {});
278
+ }
279
+ });
280
+
174
281
  registerMenu(bot, deps); // persistent-keyboard buttons (hears)
175
282
  registerWizardInput(bot, deps); // wizard text input (before commands)
283
+ if (forum) registerForum(bot, deps, forum);
176
284
  registerControl(bot, deps);
177
285
  registerProjects(bot, deps);
178
286
  registerSessions(bot, deps);
287
+ registerImportSession(bot, deps);
179
288
  registerSessionKill(bot, deps);
180
289
  registerRunning(bot, deps);
181
290
  registerHistory(bot, deps);
@@ -198,13 +307,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
198
307
  log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
199
308
  return;
200
309
  }
201
- 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.
202
320
  });
203
321
 
204
- try {
205
- await bot.api.setMyCommands(COMMANDS);
206
- } catch (e) {
207
- 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
+ );
208
344
  }
209
345
 
210
346
  const updater = new Updater({
@@ -242,5 +378,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
242
378
  // Remove any navigation surface left over from before a restart.
243
379
  void deps.ephemeral.cleanupAll().catch(() => {});
244
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
+
245
388
  return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
246
389
  }
@@ -23,6 +23,8 @@ export interface RunningSession {
23
23
  unread: number;
24
24
  /** Latest task-completion % (0–100) for this session, if known. */
25
25
  progress?: number;
26
+ /** Card comment: last user prompt (+ last AI thinking while busy). */
27
+ comment?: string;
26
28
  }
27
29
 
28
30
  export interface SwitchResult {
@@ -35,11 +37,31 @@ export interface SwitchResult {
35
37
  alreadyForeground: boolean;
36
38
  }
37
39
 
40
+ export interface ChatControllerOpts {
41
+ /** Forum topic thread — all runtimes post into this topic. */
42
+ messageThreadId?: number;
43
+ /** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
44
+ settingsKey?: string;
45
+ /** Fixed project path (forum topics): never switch away via project picker. */
46
+ fixedCwd?: string;
47
+ fixedProjectName?: string;
48
+ }
49
+
50
+ /** Optional Telegram bridge services attached to every runtime in this chat. */
51
+ export type ChatBridgeServices = SessionRuntime["bridge"];
52
+
38
53
  export class ChatController {
39
54
  private readonly runtimes: SessionRuntime[] = [];
40
55
  private fg: SessionRuntime | undefined;
41
56
  private readonly lastRead = new Map<string, number>();
42
57
  private restored = false;
58
+ readonly settingsKey: string;
59
+ readonly messageThreadId: number | undefined;
60
+ readonly fixedCwd: string | undefined;
61
+ readonly fixedProjectName: string | undefined;
62
+
63
+ /** Telegram bridge (forum / memory / sibling bots); set by the registry. */
64
+ bridge?: ChatBridgeServices;
43
65
 
44
66
  constructor(
45
67
  private readonly api: Api,
@@ -51,14 +73,22 @@ export class ChatController {
51
73
  private readonly refresh: (chatId: number) => void,
52
74
  private readonly notifyActivity: (busy: boolean) => void,
53
75
  private readonly getRotator?: () => AccountRotator | undefined,
54
- ) {}
76
+ opts?: ChatControllerOpts,
77
+ ) {
78
+ this.messageThreadId = opts?.messageThreadId;
79
+ this.settingsKey = opts?.settingsKey ?? String(chatId);
80
+ this.fixedCwd = opts?.fixedCwd;
81
+ this.fixedProjectName = opts?.fixedProjectName;
82
+ }
55
83
 
56
84
  /** The current foreground runtime (created/restored lazily). */
57
85
  foreground(): SessionRuntime {
58
86
  this.ensureRestored();
59
87
  if (!this.fg) {
60
- const s = this.settings.get(this.chatId);
61
- const rt = this.create({ cwd: s.projectPath ?? this.cfg.workspace, projectName: s.projectName, sessionId: s.sessionId });
88
+ const s = this.settings.getKey(this.settingsKey);
89
+ const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
90
+ const name = this.fixedProjectName ?? s.projectName;
91
+ const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
62
92
  this.runtimes.push(rt);
63
93
  this.fg = rt;
64
94
  }
@@ -76,6 +106,7 @@ export class ChatController {
76
106
  foreground: rt.isForeground,
77
107
  unread: this.unreadCount(rt),
78
108
  progress: rt.taskProgress,
109
+ comment: rt.cardComment,
79
110
  }));
80
111
  }
81
112
 
@@ -142,6 +173,28 @@ export class ChatController {
142
173
  return rt;
143
174
  }
144
175
 
176
+ /**
177
+ * Import a foreign session (Kiro / OpenCode / Claude / Codex) as a new
178
+ * controlled Grok session in the same project, primed with the full transcript.
179
+ * The new session becomes the foreground /running entry.
180
+ */
181
+ async addImport(
182
+ cwd: string,
183
+ projectName: string | undefined,
184
+ priming: string,
185
+ ): Promise<SessionRuntime> {
186
+ this.ensureRestored();
187
+ const prevFg = this.fg;
188
+ const rt = this.create({ cwd, projectName });
189
+ this.runtimes.push(rt);
190
+ this.fg = rt;
191
+ void this.background(prevFg);
192
+ await rt.startImportedSession(cwd, projectName, priming);
193
+ this.markSeen(rt);
194
+ this.persist();
195
+ return rt;
196
+ }
197
+
145
198
  /**
146
199
  * Connect to a session with resume-or-fork semantics (used by /sessions),
147
200
  * adding it as a controlled session and bringing it to the foreground.
@@ -251,6 +304,13 @@ export class ChatController {
251
304
  return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
252
305
  }
253
306
 
307
+ /** Last user prompt (+ thinking when busy) for a controlled session id. */
308
+ commentFor(sessionId?: string): string | undefined {
309
+ if (!sessionId) return undefined;
310
+ this.ensureRestored();
311
+ return this.runtimes.find((r) => r.sessionId === sessionId)?.cardComment;
312
+ }
313
+
254
314
  findBySession(sessionId: string): boolean {
255
315
  return this.runtimes.some((r) => r.sessionId === sessionId);
256
316
  }
@@ -266,27 +326,31 @@ export class ChatController {
266
326
  private ensureRestored(): void {
267
327
  if (this.restored) return;
268
328
  this.restored = true;
269
- const s = this.settings.get(this.chatId);
329
+ const s = this.settings.getKey(this.settingsKey);
270
330
  const seen = new Set<string>();
271
331
  for (const cs of s.controlledSessions ?? []) {
272
332
  if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
273
333
  seen.add(cs.sessionId);
334
+ // Forum topics only restore sessions for the fixed project path.
335
+ if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
274
336
  this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
275
337
  }
276
338
  // Lazy project switches persist projectPath without a sessionId. If the
277
339
  // saved project is not among controlled sessions, recreate an unbound FG
278
340
  // so a restart lands on the project the user last chose.
279
- if (s.projectPath) {
280
- const key = normPath(s.projectPath);
341
+ const homePath = this.fixedCwd ?? s.projectPath;
342
+ const homeName = this.fixedProjectName ?? s.projectName;
343
+ if (homePath) {
344
+ const key = normPath(homePath);
281
345
  const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
282
346
  if (!hasProject) {
283
- this.runtimes.push(this.create({ cwd: s.projectPath, projectName: s.projectName }));
347
+ this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
284
348
  }
285
349
  }
286
350
  if (this.runtimes.length > 0) {
287
351
  let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
288
- if (!fg && s.projectPath) {
289
- const key = normPath(s.projectPath);
352
+ if (!fg && homePath) {
353
+ const key = normPath(homePath);
290
354
  fg = this.runtimes.find((r) => normPath(r.cwd) === key);
291
355
  }
292
356
  fg = fg ?? this.runtimes[0]!;
@@ -330,10 +394,15 @@ export class ChatController {
330
394
  }
331
395
 
332
396
  private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
333
- const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
397
+ const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
398
+ ...init,
399
+ messageThreadId: this.messageThreadId,
400
+ settingsKey: this.settingsKey,
401
+ });
334
402
  rt.onStateChange = () => this.refresh(this.chatId);
335
403
  rt.onActivity = (busy) => this.notifyActivity(busy);
336
404
  rt.accountRotator = this.getRotator?.();
405
+ rt.bridge = this.bridge;
337
406
  // A logical fork (auto-fork-on-error / lost-session recovery) swaps the
338
407
  // runtime's session id in place — re-persist the controlled list with the
339
408
  // new id and treat the fresh session as already-seen.
@@ -380,15 +449,15 @@ export class ChatController {
380
449
  seen.add(r.sessionId);
381
450
  controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
382
451
  }
383
- this.settings.update(this.chatId, {
452
+ this.settings.updateKey(this.settingsKey, {
384
453
  controlledSessions: controlled,
385
454
  foregroundSessionId: this.fg?.sessionId,
386
455
  // Keep the single-session restore fields aligned with the foreground so
387
456
  // the pinned status panel and a fresh restore never show a project that
388
457
  // belongs to a different (previously-foreground) session.
389
458
  sessionId: this.fg?.sessionId,
390
- projectPath: this.fg?.cwd,
391
- projectName: this.fg?.projectName,
459
+ projectPath: this.fixedCwd ?? this.fg?.cwd,
460
+ projectName: this.fixedProjectName ?? this.fg?.projectName,
392
461
  });
393
462
  }
394
463
  }