grok-telegram-bot 2.6.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.
package/.env.example CHANGED
@@ -40,6 +40,19 @@ GROK_TRUST_ALL_TOOLS=true
40
40
  # Approve/Deny buttons in Telegram.
41
41
  AUTO_APPROVE_PERMISSIONS=true
42
42
 
43
+ # Auto-approve Grok plan-mode exit (no Approve / Request changes / Abandon).
44
+ # Default true so 24/7 unattended bots never wait. Set false for Telegram review.
45
+ # AUTO_APPROVE_PLAN=true
46
+
47
+ # Optional Grok agent env (applied on process start / after /restart).
48
+ # GROK_SANDBOX=workspace-safe
49
+ # GROK_MEMORY=
50
+ # GROK_AGENT_PROFILE=
51
+ # GROK_PLUGIN_DIR=
52
+
53
+ # Named instance slug (same as grok-tg --name). Optional.
54
+ # GROK_TG_NAME=
55
+
43
56
  # Comma-separated roots the /projects browser is allowed to list.
44
57
  # Supports ~ for home directory. Defaults to GROK_WORKSPACE's parent + home.
45
58
  # Example: H:\Lucru\Domains,C:\Lucru\Domains
package/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  The latest section is published verbatim as the GitHub Release notes by
8
8
  `.github/workflows/release.yml` when a `vX.Y.Z` tag is pushed.
9
9
 
10
+ ## [2.7.0] - 2026-08-30
11
+
12
+ Contributor batch from [@vincenthehna](https://github.com/vincenthehna) (PRs
13
+ #6–#8), ported onto current main and credited here. PR #9 (one process / many
14
+ tokens) was **not** merged: it conflicts with forum General + `TOPIC_GROUP_ID`
15
+ (which bot owns the group?) and with the named-instance model.
16
+
17
+ ### Added
18
+
19
+ - **Grok Build slash catalog on Telegram** (PR #6 / #7). `/goal`, `/plan`,
20
+ `/compact`, `/workflow(s)`, `/deep_research`, memory/imagine commands, and
21
+ other ACP-useful shell builtins are advertised (menu ≤100) and forwarded into
22
+ the active session (`executeCommand` then prompt fallback). Underscores map
23
+ to Grok hyphens; collision aliases (`/grok_new`, `/memory_flush`, …) keep
24
+ bot-owned bare names. `/goal` is refused in **General** (use a project topic
25
+ or AI Chat). Catch-all forwards unknown Grok/skills slashes.
26
+ - **Named instances** (PR #8). `grok-tg --name work setup|install|status`
27
+ gives each BotFather token its own `~/.grok/tg/instances/<slug>/` and unique
28
+ OS service (`grok-telegram-bot-work`). `grok-tg instances` lists them. Default
29
+ bot and `TELEGRAM_BOT_TOKEN` are unchanged.
30
+ - **Optional interactive plan review** (PR #7). `AUTO_APPROVE_PLAN=false`
31
+ shows Approve / Request changes / Abandon (timeout still unblocks). Default
32
+ remains auto-approve so 24/7 unattended bots never wait. `ask_user_question`
33
+ gets Telegram buttons unless auto-skip is on.
34
+ - **`/sandbox`** plus spawn env: `GROK_SANDBOX`, `GROK_MEMORY`,
35
+ `--agent-profile`, `--plugin-dir`. `session/new` sets `_meta.yoloMode` when
36
+ tools are trusted.
37
+ - **CI workflow** (`typecheck` job) so `main` can require status checks.
38
+
39
+ ### Docs
40
+
41
+ - README / INSTALL: several bots on one host via `--name`.
42
+ - AGENTS.md: CLI instances.
43
+
10
44
  ## [2.6.0] - 2026-08-30
11
45
 
12
46
  General becomes a chat-like **manager** for the forum group: memory-first
@@ -1001,6 +1035,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
1001
1035
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
1002
1036
  cross-platform 24/7 background service.
1003
1037
 
1038
+ [2.7.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.7.0
1004
1039
  [2.6.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.6.0
1005
1040
  [2.5.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.5.0
1006
1041
  [2.4.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.4.0
package/README.md CHANGED
@@ -120,8 +120,21 @@ ghost/duplicate that was still polling Telegram (the usual cause of a stale
120
120
  "⛔ Not authorized"), so the fresh process with your current `.env` wins. A
121
121
  plain `grok-tg run` yields to an already-running background service instead.
122
122
 
123
- Startup options: `grok-tg setup [--path] | run | install | status | logs [n] |
124
- stop | restart | uninstall`. Or try it without installing: `npx
123
+ Want a **second Telegram bot** (one private chat per project) without switching
124
+ sessions? Create another bot in @BotFather and:
125
+
126
+ ```bash
127
+ grok-tg --name work setup <NEW_BOT_TOKEN> <YOUR_USER_ID>
128
+ grok-tg --name work install
129
+ grok-tg instances
130
+ ```
131
+
132
+ Each named instance has its own `~/.grok/tg/instances/<slug>/` and unique
133
+ service (`grok-telegram-bot-work`). The default bot is unchanged.
134
+
135
+ Startup options: `grok-tg [--name <slug>] setup [--path] | run | install |
136
+ status | logs [n] | stop | restart | uninstall | instances`. Or try it without
137
+ installing: `npx
125
138
  grok-telegram-bot setup`. See **[docs/INSTALL.md](./docs/INSTALL.md)** for the
126
139
  full guide.
127
140
 
package/docs/INSTALL.md CHANGED
@@ -56,6 +56,8 @@ grok-tg run # run in the foreground (Ctrl-C to stop)
56
56
  | `grok-tg logs [n]` | Tail the last `n` log lines (default 100). |
57
57
  | `grok-tg stop` / `restart` / `start` | Control the running service. |
58
58
  | `grok-tg uninstall` | Stop + remove the background service. |
59
+ | `grok-tg --name <slug> …` | Same commands for a **second Telegram bot** (own token, own chat, own service). |
60
+ | `grok-tg instances` | List the default bot and every named instance. |
59
61
  | `grok-tg help` | Show all commands. |
60
62
 
61
63
  The background service is **user-level** and auto-detected per platform — a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/scripts/setup.mjs CHANGED
@@ -3,7 +3,8 @@
3
3
  * Easy setup: creates/updates the bot's .env, auto-detects the `grok` binary
4
4
  * and sensible PROJECT_ROOTS, and optionally writes the bot token / user id:
5
5
  *
6
- * node scripts/setup.mjs [--path] [--instance <dir>] [<TELEGRAM_BOT_TOKEN> [ALLOWED_USER_ID]]
6
+ * node scripts/setup.mjs [--path] [--instance <dir>] [--name <slug>]
7
+ * [<TELEGRAM_BOT_TOKEN> [ALLOWED_USER_ID]]
7
8
  *
8
9
  * By default the .env lives in the canonical, path-independent home
9
10
  * `~/.grok/tg/.env`, so the bot loads the SAME config no matter where it's
@@ -29,8 +30,23 @@ function expandHome(p) {
29
30
  function resolveInstanceDir() {
30
31
  const flag = process.argv.indexOf("--instance");
31
32
  if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]);
32
- const envDir = (process.env.GROK_TG_DIR || process.env.GROK_TG_CWD || "").trim();
33
+ const nameEq = process.argv.find((a) => a.startsWith("--name="));
34
+ const nameFlag = process.argv.indexOf("--name");
35
+ const name = nameEq
36
+ ? nameEq.slice("--name=".length)
37
+ : nameFlag !== -1
38
+ ? process.argv[nameFlag + 1]
39
+ : process.env.GROK_TG_NAME;
40
+ if (name && String(name).trim()) {
41
+ return join(CANONICAL_DIR, "instances", String(name).trim().toLowerCase());
42
+ }
43
+ const envDir = (process.env.GROK_TG_DIR || "").trim();
33
44
  if (envDir) return resolve(expandHome(envDir));
45
+ const cwdHint = (process.env.GROK_TG_CWD || "").trim();
46
+ if (cwdHint) {
47
+ const hint = resolve(expandHome(cwdHint));
48
+ if (existsSync(join(hint, ".env"))) return hint;
49
+ }
34
50
  if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
35
51
  return CANONICAL_DIR;
36
52
  }
@@ -41,7 +57,8 @@ const positionals = [];
41
57
  for (let i = 0; i < argv.length; i++) {
42
58
  const a = argv[i];
43
59
  if (a === "--path") pathOnly = true;
44
- else if (a === "--instance") i++;
60
+ else if (a === "--instance" || a === "--name") i++;
61
+ else if (a.startsWith("--instance=") || a.startsWith("--name=")) continue;
45
62
  else positionals.push(a);
46
63
  }
47
64
  const [tokenArg, userArg] = positionals;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Named bot instances: each Telegram bot token gets its own directory
3
+ * (`.env`, `logs/`, `data/`) and a unique OS service name so several bots
4
+ * can run 24/7 on one host without overwriting each other.
5
+ *
6
+ * The unnamed default stays at `~/.grok/tg` with the original service id.
7
+ * `grok-tg --name work …` uses `~/.grok/tg/instances/work` and
8
+ * `grok-telegram-bot-work`.
9
+ */
10
+ import { existsSync, readdirSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, join, resolve } from "node:path";
13
+
14
+ /** Canonical home for the default (unnamed) instance. */
15
+ export const CANONICAL_DIR = join(homedir(), ".grok", "tg");
16
+ export const INSTANCES_SUBDIR = "instances";
17
+
18
+ export const DEFAULT_SERVICE_ID = "grok-telegram-bot";
19
+ export const DEFAULT_WINDOWS_TASK = "GrokTelegramBot";
20
+ export const DEFAULT_MACOS_LABEL = "com.grok.telegrambot";
21
+
22
+ const RESERVED = new Set(["default", "instances", "locks", "logs", "data"]);
23
+ const SLUG_RE = /^[a-z][a-z0-9-]{0,31}$/;
24
+
25
+ export interface InstanceFlags {
26
+ instanceDir?: string;
27
+ name?: string;
28
+ }
29
+
30
+ export interface ServiceIdentity {
31
+ id: string;
32
+ displayName: string;
33
+ windowsTaskName: string;
34
+ macosLabel: string;
35
+ slug?: string;
36
+ }
37
+
38
+ export interface KnownInstance {
39
+ /** `(default)` or the instance slug. */
40
+ name: string;
41
+ dir: string;
42
+ slug?: string;
43
+ identity: ServiceIdentity;
44
+ }
45
+
46
+ export function expandHome(p: string): string {
47
+ if (p === "~") return homedir();
48
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
49
+ return p;
50
+ }
51
+
52
+ export function parseInstanceFlags(argv: string[]): InstanceFlags {
53
+ const out: InstanceFlags = {};
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (!a) continue;
57
+ if ((a === "--instance" || a === "--name") && argv[i + 1]) {
58
+ const value = argv[++i]!;
59
+ if (a === "--instance") out.instanceDir = value;
60
+ else out.name = value;
61
+ continue;
62
+ }
63
+ if (a.startsWith("--instance=")) out.instanceDir = a.slice("--instance=".length);
64
+ else if (a.startsWith("--name=")) out.name = a.slice("--name=".length);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** Drop `--instance` / `--name` (and their values) so the CLI command remains first. */
70
+ export function stripInstanceFlags(argv: string[]): string[] {
71
+ const out: string[] = [];
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i];
74
+ if (!a) continue;
75
+ if (a === "--instance" || a === "--name") {
76
+ i++;
77
+ continue;
78
+ }
79
+ if (a.startsWith("--instance=") || a.startsWith("--name=")) continue;
80
+ out.push(a);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function parseInstanceSlug(raw: string): string {
86
+ const slug = raw.trim().toLowerCase();
87
+ if (!SLUG_RE.test(slug)) {
88
+ throw new Error(
89
+ `Invalid instance name "${raw}". Use 1–32 characters: start with a letter, then letters, digits or hyphens (e.g. work, home, coding).`,
90
+ );
91
+ }
92
+ if (RESERVED.has(slug)) {
93
+ throw new Error(`Instance name "${slug}" is reserved. Pick another name.`);
94
+ }
95
+ return slug;
96
+ }
97
+
98
+ export function namedInstanceDir(name: string, canonicalDir = CANONICAL_DIR): string {
99
+ return join(canonicalDir, INSTANCES_SUBDIR, parseInstanceSlug(name));
100
+ }
101
+
102
+ /**
103
+ * Resolve this process's instance directory.
104
+ *
105
+ * Order: `--instance` → `--name` / `GROK_TG_NAME` → `GROK_TG_DIR` → a
106
+ * `GROK_TG_CWD` or cwd that already contains `.env` → `~/.grok/tg`.
107
+ *
108
+ * `GROK_TG_CWD` is only a "user launched from here" hint (the `grok-tg`
109
+ * launcher always sets it). It must not override `--name`, otherwise a
110
+ * named instance could never be selected.
111
+ */
112
+ export function resolveInstanceDir(opts: {
113
+ argv?: string[];
114
+ envDir?: string;
115
+ nameEnv?: string;
116
+ cwdHint?: string;
117
+ cwd?: string;
118
+ canonicalDir?: string;
119
+ } = {}): string {
120
+ const canonicalDir = opts.canonicalDir ?? CANONICAL_DIR;
121
+ const cwd = opts.cwd ?? process.cwd();
122
+ const flags = parseInstanceFlags(opts.argv ?? process.argv);
123
+ if (flags.instanceDir?.trim()) return resolve(expandHome(flags.instanceDir.trim()));
124
+ const name = flags.name?.trim() || opts.nameEnv?.trim();
125
+ if (name) return namedInstanceDir(name, canonicalDir);
126
+ if (opts.envDir?.trim()) return resolve(expandHome(opts.envDir.trim()));
127
+ const hinted = opts.cwdHint?.trim();
128
+ if (hinted) {
129
+ const hint = resolve(expandHome(hinted));
130
+ if (existsSync(join(hint, ".env"))) return hint;
131
+ }
132
+ if (existsSync(join(cwd, ".env"))) return cwd;
133
+ return canonicalDir;
134
+ }
135
+
136
+ /**
137
+ * OS service names for this instance.
138
+ *
139
+ * Unique names are used only for `--name` / `~/.grok/tg/instances/<slug>` so
140
+ * an existing default install (`grok-telegram-bot.service`) is unchanged.
141
+ * A random folder with a `.env` keeps the default service id.
142
+ */
143
+ export function serviceIdentity(
144
+ instanceDir: string,
145
+ name?: string,
146
+ canonicalDir = CANONICAL_DIR,
147
+ ): ServiceIdentity {
148
+ const slug = name?.trim()
149
+ ? parseInstanceSlug(name)
150
+ : inferNamedInstanceSlug(instanceDir, canonicalDir);
151
+ if (!slug) {
152
+ return {
153
+ id: DEFAULT_SERVICE_ID,
154
+ displayName: "Grok Telegram Bot",
155
+ windowsTaskName: DEFAULT_WINDOWS_TASK,
156
+ macosLabel: DEFAULT_MACOS_LABEL,
157
+ };
158
+ }
159
+ return {
160
+ id: `${DEFAULT_SERVICE_ID}-${slug}`,
161
+ displayName: `Grok Telegram Bot (${slug})`,
162
+ windowsTaskName: `${DEFAULT_WINDOWS_TASK}-${slug}`,
163
+ macosLabel: `${DEFAULT_MACOS_LABEL}.${slug}`,
164
+ slug,
165
+ };
166
+ }
167
+
168
+ export function listKnownInstances(canonicalDir = CANONICAL_DIR): KnownInstance[] {
169
+ const out: KnownInstance[] = [];
170
+ if (existsSync(join(canonicalDir, ".env"))) {
171
+ out.push({
172
+ name: "(default)",
173
+ dir: canonicalDir,
174
+ identity: serviceIdentity(canonicalDir, undefined, canonicalDir),
175
+ });
176
+ }
177
+ const root = join(canonicalDir, INSTANCES_SUBDIR);
178
+ if (!existsSync(root)) return out;
179
+ let names: string[] = [];
180
+ try {
181
+ names = readdirSync(root, { withFileTypes: true })
182
+ .filter((d) => d.isDirectory())
183
+ .map((d) => d.name)
184
+ .sort();
185
+ } catch {
186
+ return out;
187
+ }
188
+ for (const name of names) {
189
+ const dir = join(root, name);
190
+ if (!existsSync(join(dir, ".env"))) continue;
191
+ out.push({
192
+ name,
193
+ dir,
194
+ slug: name,
195
+ identity: serviceIdentity(dir, undefined, canonicalDir),
196
+ });
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /** Basename when `instanceDir` is a direct child of `<canonical>/instances`. */
202
+ function namedChildName(instanceDir: string, canonicalDir: string): string | undefined {
203
+ const resolved = resolve(instanceDir);
204
+ const parent = resolve(join(resolved, ".."));
205
+ const root = resolve(join(canonicalDir, INSTANCES_SUBDIR));
206
+ if (parent !== root) return undefined;
207
+ return basename(resolved);
208
+ }
209
+
210
+ function inferNamedInstanceSlug(instanceDir: string, canonicalDir: string): string | undefined {
211
+ const child = namedChildName(instanceDir, canonicalDir);
212
+ if (!child) return undefined;
213
+ try {
214
+ return parseInstanceSlug(child);
215
+ } catch {
216
+ const fallback = child
217
+ .toLowerCase()
218
+ .replace(/[^a-z0-9-]+/g, "-")
219
+ .replace(/^-+|-+$/g, "")
220
+ .slice(0, 32);
221
+ return fallback && SLUG_RE.test(fallback) && !RESERVED.has(fallback) ? fallback : "extra";
222
+ }
223
+ }
package/src/app/types.ts CHANGED
@@ -97,6 +97,11 @@ export interface PromptInput {
97
97
  * "Thinking…" then streams the agent reply into.
98
98
  */
99
99
  seedMessageId?: number;
100
+ /**
101
+ * Grok Build slash command (`/goal`, `/compact`, …). Must stay the first
102
+ * line of the prompt — skip manager/complexity wrappers.
103
+ */
104
+ rawSlashCommand?: boolean;
100
105
  }
101
106
 
102
107
  export function textPrompt(
@@ -108,6 +113,7 @@ export function textPrompt(
108
113
  promptId?: string;
109
114
  reportBack?: PromptInput["reportBack"];
110
115
  seedMessageId?: number;
116
+ rawSlashCommand?: boolean;
111
117
  },
112
118
  ): PromptInput {
113
119
  return {
@@ -120,5 +126,6 @@ export function textPrompt(
120
126
  skipSelfRecheck: opts?.skipSelfRecheck,
121
127
  reportBack: opts?.reportBack,
122
128
  seedMessageId: opts?.seedMessageId,
129
+ rawSlashCommand: opts?.rawSlashCommand,
123
130
  };
124
131
  }
@@ -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
+ }