grok-telegram-bot 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
package/src/config.ts CHANGED
@@ -72,16 +72,51 @@ function nonNegNum(v: string | undefined, def: number): number {
72
72
  return Number.isFinite(n) && n >= 0 ? n : def;
73
73
  }
74
74
 
75
+ /** Comma-separated list (spaces around commas ok). Used for ALLOWED_USERS, PROJECT_ROOTS, etc. */
75
76
  function list(v: string | undefined): string[] {
76
77
  return (v || "")
77
78
  .split(",")
78
- .map((s) => s.trim())
79
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
79
80
  .filter(Boolean);
80
81
  }
81
82
 
83
+ /**
84
+ * Parse ALLOWED_USERS. Blank/unset → allow everyone. Non-blank → only numeric
85
+ * Telegram user ids (invalid tokens dropped). If every token is invalid, deny
86
+ * everyone (fail closed) rather than treating as open.
87
+ */
88
+ export function parseAllowedUsers(raw: string | undefined): {
89
+ ids: Set<string>;
90
+ allowAll: boolean;
91
+ dropped: string[];
92
+ } {
93
+ if (raw === undefined || raw.trim() === "") {
94
+ return { ids: new Set(), allowAll: true, dropped: [] };
95
+ }
96
+ const tokens = list(raw);
97
+ const dropped: string[] = [];
98
+ const ids = new Set<string>();
99
+ for (const t of tokens) {
100
+ // Telegram user ids are positive integers (string form).
101
+ if (/^\d+$/.test(t)) ids.add(t);
102
+ else dropped.push(t);
103
+ }
104
+ return { ids, allowAll: false, dropped };
105
+ }
106
+
82
107
  export interface AppConfig {
83
108
  token: string;
109
+ /**
110
+ * Telegram user ids allowed to use the bot (private + groups). Populated from
111
+ * comma-separated ALLOWED_USERS. See {@link allowAllUsers}.
112
+ */
84
113
  allowedUsers: Set<string>;
114
+ /**
115
+ * True only when ALLOWED_USERS was blank/unset. When false, only ids in
116
+ * {@link allowedUsers} may use the bot (even if the set is empty after
117
+ * filtering invalid tokens — fail closed).
118
+ */
119
+ allowAllUsers: boolean;
85
120
  grokCliPath: string;
86
121
  workspace: string;
87
122
  /** Optional xAI API key for headless hosts. When set, exported to the agent
@@ -167,6 +202,45 @@ export interface AppConfig {
167
202
  * the AI-written recheck prompt (or built-in default if the AI left it blank).
168
203
  */
169
204
  selfRecheckPrompt: string;
205
+ /**
206
+ * Max wait for quiet meta ACP prompts (self-recheck decision, suggestions).
207
+ * On timeout the session prompt is cancelled so Done is not blocked forever.
208
+ * QUIET_PROMPT_TIMEOUT_MS, default 90s.
209
+ */
210
+ quietPromptTimeoutMs: number;
211
+ /**
212
+ * Telegram forum supergroup id for project topics (TOPIC_GROUP_ID). When set
213
+ * and the bot is admin, the bot manages topics: AI Chat + optional one topic
214
+ * per catalog project. Empty/undefined disables forum management.
215
+ */
216
+ topicGroupId?: number;
217
+ /**
218
+ * Auto-create a forum topic for each catalog project (TOPIC_AUTO_CREATE).
219
+ * Default true when TOPIC_GROUP_ID is set.
220
+ */
221
+ topicAutoCreateProjects: boolean;
222
+ /** Display name for the default AI chat topic (TOPIC_AI_CHAT_NAME). */
223
+ topicAiChatName: string;
224
+ /**
225
+ * Sibling Telegram bot usernames the agent may call via telegram bridge
226
+ * `bot_command` / `list_bots` (ALLOWED_TELEGRAM_BOTS, comma-separated, with
227
+ * or without @). Empty = feature off.
228
+ */
229
+ allowedTelegramBots: string[];
230
+ /**
231
+ * Optional command catalogs per bot (TELEGRAM_BOT_COMMANDS). Keys are
232
+ * usernames (no @); values are command names without slash + optional
233
+ * description. Shown by list_bots / first-prompt directive.
234
+ */
235
+ telegramBotCommands: Record<string, Array<{ command: string; description?: string }>>;
236
+ /** Hard timeout waiting for a sibling bot's reply (TELEGRAM_BOT_REPLY_TIMEOUT_MS). */
237
+ telegramBotReplyTimeoutMs: number;
238
+ /**
239
+ * After the last message/edit from the triggered bot, wait this many ms of
240
+ * silence before treating the reply as finished (TELEGRAM_BOT_SETTLE_MS).
241
+ * Streaming bots that edit one message need this "typing done" equivalent.
242
+ */
243
+ telegramBotSettleMs: number;
170
244
  }
171
245
 
172
246
  export function loadConfig(): AppConfig {
@@ -205,9 +279,17 @@ export function loadConfig(): AppConfig {
205
279
  ? resolve(expandHome(process.env.LOG_FILE.trim()))
206
280
  : join(logsDir, "grok-telegram-bot.log");
207
281
 
282
+ const allowedParsed = parseAllowedUsers(process.env.ALLOWED_USERS);
283
+ if (allowedParsed.dropped.length > 0) {
284
+ // Avoid importing logger at top (config loads early); stderr is fine at boot.
285
+ console.warn(
286
+ `[config] ALLOWED_USERS ignored non-numeric token(s): ${allowedParsed.dropped.join(", ")}`,
287
+ );
288
+ }
208
289
  const cfg: AppConfig = {
209
290
  token,
210
- allowedUsers: new Set(list(process.env.ALLOWED_USERS)),
291
+ allowedUsers: allowedParsed.ids,
292
+ allowAllUsers: allowedParsed.allowAll,
211
293
  grokCliPath: resolveGrokPath(process.env.GROK_CLI_PATH?.trim()),
212
294
  workspace,
213
295
  grokApiKey: process.env.XAI_API_KEY?.trim() || process.env.GROK_API_KEY?.trim() || undefined,
@@ -264,11 +346,128 @@ export function loadConfig(): AppConfig {
264
346
  true,
265
347
  ),
266
348
  selfRecheckPrompt: (process.env.SELF_RECHECK_PROMPT ?? "").trim(),
349
+ quietPromptTimeoutMs: num(process.env.QUIET_PROMPT_TIMEOUT_MS, 90_000),
350
+ topicGroupId: parseTopicGroupId(
351
+ process.env.TOPIC_GROUP_ID ?? process.env.FORUM_GROUP_ID,
352
+ ),
353
+ topicAutoCreateProjects: bool(process.env.TOPIC_AUTO_CREATE, true),
354
+ topicAiChatName: (process.env.TOPIC_AI_CHAT_NAME ?? "AI Chat").trim() || "AI Chat",
355
+ allowedTelegramBots: parseTelegramBotUsernames(process.env.ALLOWED_TELEGRAM_BOTS),
356
+ telegramBotCommands: parseTelegramBotCommands(process.env.TELEGRAM_BOT_COMMANDS),
357
+ telegramBotReplyTimeoutMs: num(process.env.TELEGRAM_BOT_REPLY_TIMEOUT_MS, 45_000),
358
+ telegramBotSettleMs: num(process.env.TELEGRAM_BOT_SETTLE_MS, 2_000),
267
359
  };
268
360
 
269
361
  return cfg;
270
362
  }
271
363
 
364
+ /** Normalize comma-separated bot usernames (strip @, lowercase, unique). */
365
+ export function parseTelegramBotUsernames(raw: string | undefined): string[] {
366
+ const seen = new Set<string>();
367
+ const out: string[] = [];
368
+ for (const t of list(raw)) {
369
+ const u = t.replace(/^@/, "").toLowerCase();
370
+ if (!u || seen.has(u)) continue;
371
+ // Telegram usernames: 5–32 characters (letter first, then alnum/underscore).
372
+ if (!/^[a-z][a-z0-9_]{4,31}$/i.test(u)) continue;
373
+ seen.add(u);
374
+ out.push(u);
375
+ }
376
+ return out;
377
+ }
378
+
379
+ /**
380
+ * Parse optional command catalogs.
381
+ *
382
+ * Formats (both supported):
383
+ * 1) Compact: `helperbot:status,help,ping;otherbot:start|Start the bot,info`
384
+ * - `;` separates bots, `:` separates username from commands
385
+ * - `,` separates commands; optional `cmd|description`
386
+ * 2) JSON object: `{"helperbot":["status","help"],"otherbot":[{"command":"start","description":"…"}]}`
387
+ */
388
+ export function parseTelegramBotCommands(
389
+ raw: string | undefined,
390
+ ): Record<string, Array<{ command: string; description?: string }>> {
391
+ const out: Record<string, Array<{ command: string; description?: string }>> = {};
392
+ if (raw === undefined || raw.trim() === "") return out;
393
+ const trimmed = raw.trim();
394
+
395
+ if (trimmed.startsWith("{")) {
396
+ try {
397
+ const parsed = JSON.parse(trimmed) as unknown;
398
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
399
+ for (const [key, val] of Object.entries(parsed as Record<string, unknown>)) {
400
+ const u = key.replace(/^@/, "").toLowerCase();
401
+ if (!u) continue;
402
+ const cmds = normalizeCommandList(val);
403
+ if (cmds.length) out[u] = cmds;
404
+ }
405
+ }
406
+ } catch {
407
+ /* fall through to compact parser */
408
+ }
409
+ if (Object.keys(out).length > 0) return out;
410
+ }
411
+
412
+ for (const botPart of trimmed.split(";")) {
413
+ const piece = botPart.trim();
414
+ if (!piece) continue;
415
+ const colon = piece.indexOf(":");
416
+ if (colon <= 0) continue;
417
+ const u = piece.slice(0, colon).replace(/^@/, "").toLowerCase().trim();
418
+ if (!u) continue;
419
+ const rest = piece.slice(colon + 1).trim();
420
+ if (!rest) continue;
421
+ const cmds = normalizeCommandList(rest.split(",").map((s) => s.trim()).filter(Boolean));
422
+ if (cmds.length) out[u] = cmds;
423
+ }
424
+ return out;
425
+ }
426
+
427
+ function normalizeCommandList(
428
+ val: unknown,
429
+ ): Array<{ command: string; description?: string }> {
430
+ const items: unknown[] = Array.isArray(val)
431
+ ? val
432
+ : typeof val === "string"
433
+ ? val.split(",").map((s) => s.trim()).filter(Boolean)
434
+ : [];
435
+ const seen = new Set<string>();
436
+ const out: Array<{ command: string; description?: string }> = [];
437
+ for (const item of items) {
438
+ let command = "";
439
+ let description: string | undefined;
440
+ if (typeof item === "string") {
441
+ const pipe = item.indexOf("|");
442
+ if (pipe >= 0) {
443
+ command = item.slice(0, pipe).trim();
444
+ description = item.slice(pipe + 1).trim() || undefined;
445
+ } else {
446
+ command = item.trim();
447
+ }
448
+ } else if (item && typeof item === "object") {
449
+ const rec = item as Record<string, unknown>;
450
+ command = String(rec.command ?? rec.cmd ?? rec.name ?? "").trim();
451
+ const d = String(rec.description ?? rec.desc ?? rec.help ?? "").trim();
452
+ if (d) description = d;
453
+ }
454
+ command = command.replace(/^\//, "").toLowerCase();
455
+ if (!command || !/^[a-z0-9_]{1,32}$/.test(command) || seen.has(command)) continue;
456
+ seen.add(command);
457
+ out.push(description ? { command, description: description.slice(0, 120) } : { command });
458
+ if (out.length >= 40) break;
459
+ }
460
+ return out;
461
+ }
462
+
463
+ /** Parse a Telegram chat/group id (may be negative for supergroups). */
464
+ function parseTopicGroupId(v: string | undefined): number | undefined {
465
+ if (v === undefined || v.trim() === "") return undefined;
466
+ const n = Number(v.trim());
467
+ if (!Number.isFinite(n) || n === 0) return undefined;
468
+ return Math.trunc(n);
469
+ }
470
+
272
471
  /** Parse 0–100 percentage; blank → default. */
273
472
  function clampPct(v: string | undefined, def: number): number {
274
473
  if (v === undefined || v === "") return def;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Pure resolution of unbound-topic bind text: absolute directory path or
3
+ * exact catalog project name only (no fuzzy/partial matches).
4
+ */
5
+ import { homedir } from "node:os";
6
+ import { isAbsolute, join, resolve } from "node:path";
7
+
8
+ export type BindProjectLookup = {
9
+ name: string;
10
+ path: string;
11
+ };
12
+
13
+ export type ResolveBindTargetResult =
14
+ | { ok: true; path: string; created?: boolean }
15
+ | { ok: false; error: string };
16
+
17
+ /** Strip quotes/backticks and expand leading ~. */
18
+ export function normalizeBindInput(text: string): string {
19
+ let raw = text.trim().replace(/^["'`]+|["'`]+$/g, "").trim();
20
+ if (!raw) return "";
21
+ // Expand ~ / ~/foo
22
+ if (raw === "~") raw = homedir();
23
+ else if (raw.startsWith("~/") || raw.startsWith("~\\")) {
24
+ raw = join(homedir(), raw.slice(2));
25
+ }
26
+ return raw;
27
+ }
28
+
29
+ /** Safe single-segment project folder name (no path separators / drive). */
30
+ export function isSafeProjectFolderName(name: string): boolean {
31
+ const t = name.trim();
32
+ if (!t || t.length > 80) return false;
33
+ if (/[\\/]/.test(t)) return false;
34
+ if (/^[a-zA-Z]:/.test(t)) return false; // drive letter
35
+ if (/[<>:"|?*\x00-\x1f]/.test(t)) return false;
36
+ if (t === "." || t === "..") return false;
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * Resolve user text to a project directory.
42
+ * - Existing filesystem path (absolute or resolvable) that is a directory
43
+ * - Exact catalog project name (case-insensitive)
44
+ * - Optional: create missing **absolute** directories (agent new-project flow)
45
+ * - Optional: create `defaultRoot/<safeName>` for a simple name when missing
46
+ * Never uses substring / partial name matching for catalog lookup.
47
+ */
48
+ export function resolveBindTarget(
49
+ text: string,
50
+ opts: {
51
+ existsSync: (p: string) => boolean;
52
+ isDirectory: (p: string) => boolean;
53
+ findExactByName: (name: string) => BindProjectLookup | undefined;
54
+ /**
55
+ * When true, if path is absolute and missing, create the directory.
56
+ * Also creates `defaultRoot/<name>` for a safe single-segment name.
57
+ */
58
+ createIfMissing?: boolean;
59
+ mkdirSync?: (p: string) => void;
60
+ /** First PROJECT_ROOTS entry — used to create new projects by short name. */
61
+ defaultRoot?: string;
62
+ },
63
+ ): ResolveBindTargetResult {
64
+ const raw = normalizeBindInput(text);
65
+ if (!raw) return { ok: false, error: "Empty path." };
66
+
67
+ if (opts.existsSync(raw)) {
68
+ const path = resolve(raw);
69
+ if (!opts.isDirectory(path)) {
70
+ return { ok: false, error: `Not a directory: ${path}` };
71
+ }
72
+ return { ok: true, path };
73
+ }
74
+
75
+ // Absolute path that does not exist yet — agent "create project + topic" flow.
76
+ // Only isAbsolute(raw) so relative names never mkdir under process cwd.
77
+ if (opts.createIfMissing && isAbsolute(raw)) {
78
+ const absCandidate = resolve(raw);
79
+ if (!opts.existsSync(absCandidate)) {
80
+ const made = tryMkdir(absCandidate, opts);
81
+ if (!made.ok) return made;
82
+ return { ok: true, path: absCandidate, created: true };
83
+ }
84
+ }
85
+
86
+ const hit = opts.findExactByName(raw);
87
+ if (hit) {
88
+ if (!opts.isDirectory(hit.path)) {
89
+ return { ok: false, error: `Not a directory: ${hit.path}` };
90
+ }
91
+ return { ok: true, path: hit.path };
92
+ }
93
+
94
+ // Short project name → create under first PROJECT_ROOTS (agent new project).
95
+ if (
96
+ opts.createIfMissing &&
97
+ opts.defaultRoot &&
98
+ opts.mkdirSync &&
99
+ isSafeProjectFolderName(raw)
100
+ ) {
101
+ const full = resolve(opts.defaultRoot, raw.trim().replace(/[<>:"/\\|?*]/g, "_"));
102
+ if (opts.existsSync(full)) {
103
+ if (!opts.isDirectory(full)) {
104
+ return { ok: false, error: `Not a directory: ${full}` };
105
+ }
106
+ return { ok: true, path: full };
107
+ }
108
+ const made = tryMkdir(full, opts);
109
+ if (!made.ok) return made;
110
+ return { ok: true, path: full, created: true };
111
+ }
112
+
113
+ return {
114
+ ok: false,
115
+ error:
116
+ `Path not found: "${raw}". Use an absolute folder path (missing folders are created), ` +
117
+ `a simple project name under PROJECT_ROOTS, or an **exact** catalog name.`,
118
+ };
119
+ }
120
+
121
+ function tryMkdir(
122
+ absCandidate: string,
123
+ opts: {
124
+ mkdirSync?: (p: string) => void;
125
+ isDirectory: (p: string) => boolean;
126
+ },
127
+ ): { ok: true } | { ok: false; error: string } {
128
+ if (!opts.mkdirSync) {
129
+ return {
130
+ ok: false,
131
+ error: `Path does not exist and cannot create: ${absCandidate}`,
132
+ };
133
+ }
134
+ try {
135
+ opts.mkdirSync(absCandidate);
136
+ } catch (e) {
137
+ return {
138
+ ok: false,
139
+ error: `Could not create directory ${absCandidate}: ${(e as Error).message}`,
140
+ };
141
+ }
142
+ if (!opts.isDirectory(absCandidate)) {
143
+ return { ok: false, error: `Not a directory after create: ${absCandidate}` };
144
+ }
145
+ return { ok: true };
146
+ }