grok-telegram-bot 2.5.0 → 2.7.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 (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. package/src/stream/streamer.ts +46 -10
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Interactive Grok `ask_user_question` reverse requests.
3
+ * Headless default used to skip; we now present options as Telegram buttons.
4
+ */
5
+ import type { Api } from "grammy";
6
+ import { InlineKeyboard } from "grammy";
7
+ import { outboundThreadExtra } from "../forum/thread.js";
8
+ import { createLogger } from "../logger.js";
9
+ import type { RuntimeRegistry } from "./registry.js";
10
+
11
+ const log = createLogger("ask-user");
12
+ const TIMEOUT_MS = 30 * 60 * 1000;
13
+
14
+ export interface InterviewQuestion {
15
+ id: string;
16
+ prompt: string;
17
+ options: Array<{ id: string; label: string }>;
18
+ multi: boolean;
19
+ }
20
+
21
+ /** ACP reverse-request result (externally tagged enum, smoke-verified skip). */
22
+ export type AskUserResult =
23
+ | { SkipInterview: null }
24
+ | { SubmitAnswers: { answers: Array<{ questionId: string; selected: string[] }> } };
25
+
26
+ interface Pending {
27
+ resolve: (r: AskUserResult) => void;
28
+ chatId: number;
29
+ messageId?: number;
30
+ questions: InterviewQuestion[];
31
+ picked: Map<string, Set<string>>;
32
+ index: number;
33
+ timer: NodeJS.Timeout;
34
+ }
35
+
36
+ export class AskUserService {
37
+ private readonly pending = new Map<string, Pending>();
38
+ private seq = 0;
39
+
40
+ constructor(
41
+ private readonly api: Api,
42
+ private readonly registry: RuntimeRegistry,
43
+ public autoSkip = false,
44
+ ) {}
45
+
46
+ async handle(params: Record<string, unknown>): Promise<AskUserResult> {
47
+ const questions = parseQuestions(params);
48
+ const sessionId = str(params.sessionId) || str(params.session_id) || "";
49
+ if (this.autoSkip || questions.length === 0) {
50
+ log.info(`skip ask_user_question (${questions.length} q)`);
51
+ return { SkipInterview: null };
52
+ }
53
+ const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
54
+ const chatId = desc.chatId;
55
+ if (chatId === undefined) return { SkipInterview: null };
56
+ const threadExtra = outboundThreadExtra(desc.threadId);
57
+
58
+ const reqId = String(++this.seq);
59
+ const picked = new Map<string, Set<string>>();
60
+ for (const q of questions) picked.set(q.id, new Set());
61
+
62
+ try {
63
+ const msg = await this.api.sendMessage(chatId, renderQuestion(questions, 0, picked), {
64
+ reply_markup: questionKeyboard(reqId, questions[0]!, picked.get(questions[0]!.id)!),
65
+ disable_notification: false,
66
+ ...threadExtra,
67
+ });
68
+ return new Promise<AskUserResult>((resolve) => {
69
+ const timer = setTimeout(() => {
70
+ const p = this.pending.get(reqId);
71
+ if (!p) return;
72
+ this.pending.delete(reqId);
73
+ void this.api.editMessageText(p.chatId, p.messageId ?? 0, "\u231B Question timed out \u2014 skipped.").catch(
74
+ () => {},
75
+ );
76
+ resolve({ SkipInterview: null });
77
+ }, TIMEOUT_MS);
78
+ this.pending.set(reqId, {
79
+ resolve,
80
+ chatId,
81
+ messageId: msg.message_id,
82
+ questions,
83
+ picked,
84
+ index: 0,
85
+ timer,
86
+ });
87
+ });
88
+ } catch (e) {
89
+ log.warn("send ask_user failed:", (e as Error).message);
90
+ return { SkipInterview: null };
91
+ }
92
+ }
93
+
94
+ tap(reqId: string, kind: string, value?: string): string | undefined {
95
+ const p = this.pending.get(reqId);
96
+ if (!p) return undefined;
97
+ const q = p.questions[p.index];
98
+ if (!q) return undefined;
99
+
100
+ if (kind === "skip") {
101
+ this.settle(p, reqId, { SkipInterview: null }, "\u23ED Skipped questions.");
102
+ return "Skipped";
103
+ }
104
+ if (kind === "opt" && value !== undefined) {
105
+ const opt = q.options[Number(value)];
106
+ if (!opt) return "Expired";
107
+ const set = p.picked.get(q.id)!;
108
+ if (q.multi) {
109
+ if (set.has(opt.id)) set.delete(opt.id);
110
+ else set.add(opt.id);
111
+ } else {
112
+ set.clear();
113
+ set.add(opt.id);
114
+ }
115
+ void this.redraw(reqId, p);
116
+ return q.multi ? "Toggled" : "Selected";
117
+ }
118
+ if (kind === "next") {
119
+ if (p.index < p.questions.length - 1) {
120
+ p.index += 1;
121
+ void this.redraw(reqId, p);
122
+ return "Next";
123
+ }
124
+ const answers = p.questions.map((qq) => ({
125
+ questionId: qq.id,
126
+ selected: [...(p.picked.get(qq.id) ?? [])],
127
+ }));
128
+ this.settle(p, reqId, { SubmitAnswers: { answers } }, "\u2705 Answers sent.");
129
+ return "Submitted";
130
+ }
131
+ if (kind === "prev" && p.index > 0) {
132
+ p.index -= 1;
133
+ void this.redraw(reqId, p);
134
+ return "Back";
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ private async redraw(reqId: string, p: Pending): Promise<void> {
140
+ const q = p.questions[p.index]!;
141
+ const set = p.picked.get(q.id)!;
142
+ if (p.messageId === undefined) return;
143
+ await this.api
144
+ .editMessageText(p.chatId, p.messageId, renderQuestion(p.questions, p.index, p.picked), {
145
+ reply_markup: questionKeyboard(reqId, q, set, p.index, p.questions.length),
146
+ })
147
+ .catch(() => {});
148
+ }
149
+
150
+ private settle(p: Pending, reqId: string, result: AskUserResult, text: string): void {
151
+ clearTimeout(p.timer);
152
+ this.pending.delete(reqId);
153
+ if (p.messageId !== undefined) {
154
+ void this.api.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } }).catch(
155
+ () => {},
156
+ );
157
+ }
158
+ p.resolve(result);
159
+ }
160
+ }
161
+
162
+ export function parseQuestions(params: Record<string, unknown>): InterviewQuestion[] {
163
+ const raw = params.questions ?? params.questionnaire ?? params.items;
164
+ if (!Array.isArray(raw)) return [];
165
+ const out: InterviewQuestion[] = [];
166
+ raw.forEach((item, i) => {
167
+ if (!item || typeof item !== "object") return;
168
+ const o = item as Record<string, unknown>;
169
+ const prompt = str(o.question) || str(o.prompt) || str(o.header) || str(o.text) || `Question ${i + 1}`;
170
+ const id = str(o.id) || str(o.questionId) || `q${i}`;
171
+ const multi = o.multiSelect === true || o.multi === true || o.allow_multiple === true;
172
+ const optsRaw = Array.isArray(o.options) ? o.options : [];
173
+ const options = optsRaw
174
+ .map((opt, j) => {
175
+ if (typeof opt === "string") return { id: opt, label: opt };
176
+ if (!opt || typeof opt !== "object") return undefined;
177
+ const oo = opt as Record<string, unknown>;
178
+ const label = str(oo.label) || str(oo.name) || str(oo.text) || `Option ${j + 1}`;
179
+ const oid = str(oo.id) || str(oo.value) || label;
180
+ return { id: oid, label };
181
+ })
182
+ .filter((x): x is { id: string; label: string } => Boolean(x));
183
+ if (options.length === 0) options.push({ id: "yes", label: "Yes" }, { id: "no", label: "No" });
184
+ out.push({ id, prompt, options, multi });
185
+ });
186
+ return out;
187
+ }
188
+
189
+ function renderQuestion(
190
+ questions: InterviewQuestion[],
191
+ index: number,
192
+ picked: Map<string, Set<string>>,
193
+ ): string {
194
+ const q = questions[index]!;
195
+ const set = picked.get(q.id) ?? new Set();
196
+ const lines = [
197
+ `\u2753 Grok has a question (${index + 1}/${questions.length})`,
198
+ "",
199
+ q.prompt,
200
+ q.multi ? "\n(multi-select \u2014 tap to toggle, then Next)" : "",
201
+ set.size ? `\nSelected: ${[...set].join(", ")}` : "",
202
+ ];
203
+ return lines.filter(Boolean).join("\n");
204
+ }
205
+
206
+ function questionKeyboard(
207
+ reqId: string,
208
+ q: InterviewQuestion,
209
+ selected: Set<string>,
210
+ index = 0,
211
+ total = 1,
212
+ ): InlineKeyboard {
213
+ const kb = new InlineKeyboard();
214
+ q.options.slice(0, 12).forEach((opt, j) => {
215
+ const mark = selected.has(opt.id) ? "\u2705 " : "";
216
+ kb.text(`${mark}${opt.label.slice(0, 40)}`, `asku:${reqId}:opt:${j}`).row();
217
+ });
218
+ if (index > 0) kb.text("\u25C0 Back", `asku:${reqId}:prev`);
219
+ kb.text(index < total - 1 ? "Next \u25B6" : "\u2705 Submit", `asku:${reqId}:next`);
220
+ kb.row().text("Skip", `asku:${reqId}:skip`);
221
+ return kb;
222
+ }
223
+
224
+ function str(v: unknown): string {
225
+ return typeof v === "string" ? v : "";
226
+ }
package/src/bot/auth.ts CHANGED
@@ -112,7 +112,11 @@ async function denyUnauthorized(
112
112
  }
113
113
  if (!ctx.chat || isGroupChat(ctx)) return; // groups: ignore quietly
114
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 } : {};
115
+ // Omit General (1) Bot API rejects message_thread_id=1.
116
+ const extra =
117
+ threadId !== undefined && threadId !== 1
118
+ ? { message_thread_id: threadId as number }
119
+ : {};
116
120
  await ctx
117
121
  .reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
118
122
  .catch(() => {});
package/src/bot/bot.ts CHANGED
@@ -44,6 +44,9 @@ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
44
44
  import { registerUsage } from "./handlers/usage.js";
45
45
  import { registerVoice } from "./handlers/voice.js";
46
46
  import { registerForum } from "./handlers/forum.js";
47
+ import { registerGrokSlash } from "./handlers/grok-slash.js";
48
+ import { AskUserService } from "./ask-user-service.js";
49
+ import { PlanExitService } from "./plan-exit-service.js";
47
50
  import { StatusPanel } from "./menu/status-panel.js";
48
51
  import { sendMarkdownDoc } from "./telegram-io.js";
49
52
  import { Ephemeral } from "./menu/ephemeral.js";
@@ -116,21 +119,58 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
116
119
  forum,
117
120
  bots: telegramBots,
118
121
  // Cross-topic orchestration: General can create a topic and send_prompt there.
119
- submitTopicPrompt: async ({ threadId, cwd, projectName, prompt, newSession }) => {
122
+ // sessionId resumes a specific Grok session (memory follow-up) instead of
123
+ // dumping into whatever is currently open in that topic.
124
+ submitTopicPrompt: async ({
125
+ threadId,
126
+ cwd,
127
+ projectName,
128
+ prompt,
129
+ newSession,
130
+ sessionId,
131
+ reportBack,
132
+ }) => {
120
133
  if (cfg.topicGroupId === undefined) {
121
134
  throw new Error("TOPIC_GROUP_ID unset");
122
135
  }
123
136
  const groupId = cfg.topicGroupId;
124
137
  const controller = registry.forumController(groupId, threadId, cwd, projectName);
138
+ // Explicit resume wins over newSession / foreground.
139
+ if (sessionId) {
140
+ const sw = await controller.addResume(sessionId, cwd, projectName);
141
+ if (reportBack) sw.rt.setReportBack(reportBack);
142
+ const outcome = await sw.rt.submit(textPrompt(prompt));
143
+ return { outcome, sessionId: sw.rt.sessionId ?? sessionId };
144
+ }
125
145
  if (newSession) {
126
146
  const rt = await controller.addNew(cwd, projectName);
147
+ if (reportBack) rt.setReportBack(reportBack);
127
148
  const outcome = await rt.submit(textPrompt(prompt));
128
149
  return { outcome, sessionId: rt.sessionId };
129
150
  }
130
151
  const rt = controller.foreground();
152
+ if (reportBack) rt.setReportBack(reportBack);
131
153
  const outcome = await rt.submit(textPrompt(prompt));
132
154
  return { outcome, sessionId: rt.sessionId };
133
155
  },
156
+ // Child topic Done → wake General manager with a WORK REPORT.
157
+ wakeManager: async ({ originChatId, originThreadId, prompt }) => {
158
+ if (cfg.topicGroupId === undefined) {
159
+ throw new Error("TOPIC_GROUP_ID unset");
160
+ }
161
+ const groupId = cfg.topicGroupId;
162
+ // origin is always General in manager mode; bind workspace.
163
+ const controller = registry.forumController(
164
+ originChatId || groupId,
165
+ originThreadId,
166
+ cfg.workspace,
167
+ "General",
168
+ );
169
+ const rt = controller.foreground();
170
+ await rt.submit(
171
+ textPrompt(prompt, undefined, undefined, { skipSelfRecheck: true }),
172
+ );
173
+ },
134
174
  });
135
175
 
136
176
  const deps: BotDeps = {
@@ -170,6 +210,18 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
170
210
  onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
171
211
  });
172
212
  acp.permissionHandler = (p) => permissions.handle(p);
213
+
214
+ const planExit = new PlanExitService(bot.api, registry, cfg.autoApprovePlan, (chatId) =>
215
+ statusPanel.ensurePinned(chatId),
216
+ );
217
+ acp.planExitHandler = (params) => planExit.handle(params);
218
+
219
+ const askUser = new AskUserService(
220
+ bot.api,
221
+ registry,
222
+ cfg.autoApprovePlan && cfg.autoApprovePermissions,
223
+ );
224
+ acp.askUserHandler = (params) => askUser.handle(params);
173
225
  // /stop and /cancel must cancel pending interactive permissions for that
174
226
  // session only (ACP requires cancelled outcomes) — never kill the agent.
175
227
  acp.onSessionCancel = (sessionId) => {
@@ -215,6 +267,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
215
267
  if (sid) await switchAndShow(ctx, deps, sid);
216
268
  });
217
269
 
270
+ bot.callbackQuery(/^planx:(\d+):(ok|chg|no)$/, async (ctx) => {
271
+ const toast = planExit.resolveChoice(ctx.match![1]!, ctx.match![2]!);
272
+ await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
273
+ });
274
+
275
+ bot.callbackQuery(/^asku:(\d+):(opt|next|prev|skip)(?::(.*))?$/, async (ctx) => {
276
+ const toast = askUser.tap(ctx.match![1]!, ctx.match![2]!, ctx.match![3]);
277
+ await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
278
+ });
279
+
218
280
  // Legacy complexity buttons (removed — agent decides; auto-plan if complex).
219
281
  bot.callbackQuery(/^cplx:(simple|complex)$/, async (ctx) => {
220
282
  await ctx.answerCallbackQuery({ text: "Complexity is automatic now" });
@@ -231,18 +293,50 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
231
293
  const index = Number(ctx.match![2]);
232
294
  const { resolveScope } = await import("./scope.js");
233
295
  const { adoptUserPrompt } = await import("./prompt-anchor.js");
296
+ const { isGeneralThread } = await import("../forum/thread.js");
234
297
  const scope = resolveScope(ctx, deps);
235
- const rt = scope.rt;
236
- const text = rt.takeSuggestion(batchId, index);
237
- if (!text) {
298
+ // General may have parallel sessions — find the runtime that owns this batch.
299
+ const hit =
300
+ scope.controller.takeSuggestionAnywhere(batchId, index) ??
301
+ (() => {
302
+ const t = scope.rt.takeSuggestion(batchId, index);
303
+ return t ? { rt: scope.rt, text: t } : undefined;
304
+ })();
305
+ if (!hit) {
238
306
  await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
239
307
  return;
240
308
  }
309
+ const { rt, text } = hit;
241
310
  await ctx.answerCallbackQuery({ text: "Sending\u2026" });
242
311
  // Dim the keyboard so double-taps don't re-fire.
243
312
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
244
313
  try {
245
314
  const chatId = ctx.chat?.id;
315
+ const isGeneral = isGeneralThread(scope.threadId);
316
+ const sugMsgId = ctx.callbackQuery.message?.message_id;
317
+ // General: keep chat clean — no anchor overwrite; continue same session
318
+ // and reply to the message that carried the buttons.
319
+ if (isGeneral) {
320
+ if (rt.sessionId && sugMsgId !== undefined) {
321
+ scope.controller.bindTelegramMessage(sugMsgId, rt.sessionId);
322
+ }
323
+ const outcome = await rt.submit(
324
+ textPrompt(text, sugMsgId, undefined, { promptId: undefined }),
325
+ );
326
+ if (outcome === "queued") {
327
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
328
+ if (sugMsgId !== undefined) {
329
+ extra.reply_parameters = {
330
+ message_id: sugMsgId,
331
+ allow_sending_without_reply: true,
332
+ };
333
+ }
334
+ await deps.api
335
+ .sendMessage(chatId!, "\u{1F4E5} Queued on that thread.", extra)
336
+ .catch(() => {});
337
+ }
338
+ return;
339
+ }
246
340
  const anchor =
247
341
  chatId !== undefined
248
342
  ? await adoptUserPrompt(deps.api, {
@@ -280,6 +374,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
280
374
 
281
375
  registerMenu(bot, deps); // persistent-keyboard buttons (hears)
282
376
  registerWizardInput(bot, deps); // wizard text input (before commands)
377
+ bot.on("message:text", async (ctx, next) => {
378
+ const text = ctx.message?.text ?? "";
379
+ if (!text || text.startsWith("/")) return next();
380
+ if (planExit.takeFeedback(ctx.chat.id, text)) return;
381
+ await next();
382
+ });
283
383
  if (forum) registerForum(bot, deps, forum);
284
384
  registerControl(bot, deps);
285
385
  registerProjects(bot, deps);
@@ -298,6 +398,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
298
398
  registerPhotos(bot, deps); // photos & image documents
299
399
  registerDocuments(bot, deps); // non-image files (text inlined, binaries saved)
300
400
  registerVoice(bot, deps); // voice / audio -> transcription -> prompt
401
+ registerGrokSlash(bot, deps); // Grok Build /goal /plan /compact … + catch-all
301
402
  registerMessages(bot, deps); // catch-all text prompt — keep last
302
403
 
303
404
  bot.catch((err) => {
@@ -63,6 +63,12 @@ export class ChatController {
63
63
  /** Telegram bridge (forum / memory / sibling bots); set by the registry. */
64
64
  bridge?: ChatBridgeServices;
65
65
 
66
+ /**
67
+ * General manager: map Telegram message ids (user + bot) → session id so a
68
+ * reply-to continues the same ACP session instead of spawning a new one.
69
+ */
70
+ private readonly telegramMsgSessions = new Map<number, string>();
71
+
66
72
  constructor(
67
73
  private readonly api: Api,
68
74
  private readonly chatId: number,
@@ -128,6 +134,105 @@ export class ChatController {
128
134
  return rt;
129
135
  }
130
136
 
137
+ /**
138
+ * General manager: spawn a fresh session for one user message without
139
+ * killing an in-flight sibling's Telegram stream (parallel prompts).
140
+ */
141
+ async addParallel(cwd: string, projectName?: string): Promise<SessionRuntime> {
142
+ return this.addNew(cwd, projectName);
143
+ }
144
+
145
+ /** Remember that a Telegram message belongs to a Grok session (reply routing). */
146
+ bindTelegramMessage(messageId: number, sessionId: string): void {
147
+ if (!messageId || !sessionId) return;
148
+ this.telegramMsgSessions.set(messageId, sessionId);
149
+ // Cap map growth (keep newest ~500).
150
+ if (this.telegramMsgSessions.size > 500) {
151
+ const drop = this.telegramMsgSessions.size - 500;
152
+ let i = 0;
153
+ for (const k of this.telegramMsgSessions.keys()) {
154
+ this.telegramMsgSessions.delete(k);
155
+ if (++i >= drop) break;
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Resolve a controlled runtime from a Telegram message id (reply-to). */
161
+ runtimeForTelegramMessage(messageId: number | undefined): SessionRuntime | undefined {
162
+ if (messageId === undefined) return undefined;
163
+ this.ensureRestored();
164
+ const sid = this.telegramMsgSessions.get(messageId);
165
+ if (sid) {
166
+ const byId = this.runtimes.find((r) => r.sessionId === sid);
167
+ if (byId) return byId;
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Resolve session from `#sess_xxxxxxxx` in a replied-to bot message body
174
+ * (works across process restarts when the in-memory map is cold).
175
+ */
176
+ runtimeForSessionTag(text: string | undefined): SessionRuntime | undefined {
177
+ if (!text) return undefined;
178
+ this.ensureRestored();
179
+ const tag = parseSessTag(text);
180
+ if (!tag) return undefined;
181
+ return this.runtimes.find((r) => r.sessionId && sessionMatchesTag(r.sessionId, tag));
182
+ }
183
+
184
+ /**
185
+ * Cold-start / map-miss: resolve continue target from reply message id, #sess_
186
+ * tag on controlled runtimes, or disk session list (attach into this controller).
187
+ */
188
+ async resolveContinueFromReply(opts: {
189
+ replyToMessageId?: number;
190
+ replyToText?: string;
191
+ cwd: string;
192
+ projectName?: string;
193
+ }): Promise<SessionRuntime | undefined> {
194
+ this.ensureRestored();
195
+ const byMsg = this.runtimeForTelegramMessage(opts.replyToMessageId);
196
+ if (byMsg) return byMsg;
197
+ const byTag = this.runtimeForSessionTag(opts.replyToText);
198
+ if (byTag) return byTag;
199
+
200
+ const tag = parseSessTag(opts.replyToText);
201
+ if (!tag) return undefined;
202
+ // Disk fallback: find full session id, then resume into this controller.
203
+ let metas;
204
+ try {
205
+ metas = this.store.list(80);
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ const meta = metas.find((m) => sessionMatchesTag(m.sessionId, tag));
210
+ if (!meta) return undefined;
211
+ try {
212
+ const sw = await this.addResume(
213
+ meta.sessionId,
214
+ meta.cwd || opts.cwd,
215
+ opts.projectName,
216
+ );
217
+ return sw.rt;
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ /** Find any runtime that still holds this suggestion batch (General parallel). */
224
+ takeSuggestionAnywhere(
225
+ batchId: number,
226
+ index: number,
227
+ ): { rt: SessionRuntime; text: string } | undefined {
228
+ this.ensureRestored();
229
+ for (const r of this.runtimes) {
230
+ const text = r.takeSuggestion(batchId, index);
231
+ if (text) return { rt: r, text };
232
+ }
233
+ return undefined;
234
+ }
235
+
131
236
  /**
132
237
  * Switch the chat to a project directory **without** waiting on ACP.
133
238
  * - Reuses an existing controlled runtime for the same path when possible.
@@ -410,6 +515,9 @@ export class ChatController {
410
515
  this.markSeen(rt);
411
516
  this.persist();
412
517
  };
518
+ rt.onTelegramMessageBound = (messageId, sessionId) => {
519
+ this.bindTelegramMessage(messageId, sessionId);
520
+ };
413
521
  return rt;
414
522
  }
415
523
 
@@ -466,3 +574,24 @@ export class ChatController {
466
574
  function normPath(p: string): string {
467
575
  return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
468
576
  }
577
+
578
+ /** Extract `#sess_xxxxxxxx` from bot message text/caption. */
579
+ function parseSessTag(text: string | undefined): string | undefined {
580
+ if (!text) return undefined;
581
+ const m = text.match(/#sess_([a-z0-9]{6,12})/i);
582
+ return m?.[1]?.toLowerCase();
583
+ }
584
+
585
+ /** Match full session UUID (or short form) to a #sess_ tag body. */
586
+ function sessionMatchesTag(sessionId: string, tag: string): boolean {
587
+ const compact = sessionId.replace(/-/g, "").toLowerCase();
588
+ const short = sessionId.slice(0, 8).replace(/[^a-z0-9]/gi, "").toLowerCase();
589
+ const t = tag.toLowerCase();
590
+ return (
591
+ short === t ||
592
+ short.startsWith(t) ||
593
+ t.startsWith(short) ||
594
+ compact.startsWith(t) ||
595
+ sessionId.toLowerCase().startsWith(t)
596
+ );
597
+ }
@@ -5,8 +5,10 @@
5
5
  * a shorter list with cancel/menu first — reply keyboards are unreliable there.
6
6
  */
7
7
 
8
- /** Full command menu (private chats + default). Order = Telegram "/" menu order. */
9
- export const COMMANDS: { command: string; description: string }[] = [
8
+ import { GROK_FORWARDED_COMMANDS } from "./handlers/grok-slash.js";
9
+
10
+ /** Bot-local commands (not forwarded to Grok). Order = Telegram "/" menu order. */
11
+ export const BOT_COMMANDS: { command: string; description: string }[] = [
10
12
  // Core control
11
13
  { command: "start", description: "Welcome, menu & status panel" },
12
14
  { command: "menu", description: "Open the menu" },
@@ -37,10 +39,17 @@ export const COMMANDS: { command: string; description: string }[] = [
37
39
  { command: "killall", description: "Kill all active sessions on the PC" },
38
40
  { command: "model", description: "Switch model: /model <id>" },
39
41
  { command: "restart", description: "Restart the Grok agent" },
42
+ { command: "sandbox", description: "Show / set Grok sandbox profile" },
40
43
  { command: "unwatch", description: "Stop following a live session" },
41
44
  { command: "help", description: "Show help" },
42
45
  ];
43
46
 
47
+ /** Full Telegram menu: bot-local + Grok Build shell forwards (≤100). */
48
+ export const COMMANDS: { command: string; description: string }[] = [
49
+ ...BOT_COMMANDS,
50
+ ...GROK_FORWARDED_COMMANDS.map(({ command, description }) => ({ command, description })),
51
+ ];
52
+
44
53
  /**
45
54
  * Group / forum command menu — keep short; cancel & menu first so topics can
46
55
  * stop a turn without the private reply-keyboard bar.
@@ -56,6 +65,9 @@ export const GROUP_COMMANDS: { command: string; description: string }[] = [
56
65
  { command: "btw", description: "Queue or run: /btw <text>" },
57
66
  { command: "flush", description: "Run queued follow-ups now" },
58
67
  { command: "model", description: "Switch model: /model <id>" },
68
+ { command: "goal", description: "Grok /goal — run until done" },
69
+ { command: "plan", description: "Grok /plan — enter plan mode" },
70
+ { command: "compact", description: "Grok /compact — compress context" },
59
71
  { command: "help", description: "Show help" },
60
72
  ];
61
73
 
@@ -91,4 +103,12 @@ export const HELP_TEXT = [
91
103
  "/flush \u2014 run queued follow-ups immediately",
92
104
  "/reauth \u2014 sign in to Grok",
93
105
  "/accounts \u2014 switch saved Grok accounts",
106
+ "/sandbox \u2014 show or set GROK_SANDBOX (needs /restart)",
107
+ "",
108
+ "GROK BUILD SLASH (forwarded into the active session)",
109
+ "/goal /plan /view_plan /compact /context /session_info",
110
+ "/deep_research /workflow /workflows /loop",
111
+ "/remember /memory /memory_flush /dream",
112
+ "Underscores map to hyphens (e.g. /view_plan \u2192 /view-plan).",
113
+ "Other non-bot Grok /commands and skills are also forwarded.",
94
114
  ].join("\n");