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
@@ -0,0 +1,100 @@
1
+ /**
2
+ * List the sessions a sibling telegram-bot currently treats as /running
3
+ * (controlledSessions in its data/settings.json), enriched with on-disk meta
4
+ * and first-prompt titles when available.
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { basename } from "node:path";
8
+ import { createLogger } from "../logger.js";
9
+ import { type ForeignSessionMeta, resolveForeignMeta } from "./history-readers.js";
10
+ import type { ImportSource } from "./sources.js";
11
+
12
+ const log = createLogger("import:list");
13
+
14
+ export interface ImportableSession extends ForeignSessionMeta {
15
+ /** Source tool id (kiro / opencode / …). */
16
+ sourceId: string;
17
+ /** True when listed from that bot's controlledSessions (/running). */
18
+ fromRunning: boolean;
19
+ }
20
+
21
+ interface RawControlled {
22
+ sessionId?: string;
23
+ projectPath?: string;
24
+ projectName?: string;
25
+ }
26
+
27
+ interface RawChatSettings {
28
+ controlledSessions?: RawControlled[];
29
+ sessionId?: string;
30
+ projectPath?: string;
31
+ projectName?: string;
32
+ foregroundSessionId?: string;
33
+ }
34
+
35
+ /**
36
+ * Sessions currently controlled by the source bot (its /running list).
37
+ * When `chatId` is set, prefer that chat's controlled list; otherwise union
38
+ * every chat's controlled sessions (deduped by session id).
39
+ */
40
+ export function listRunningFromSource(
41
+ src: ImportSource,
42
+ chatId?: number,
43
+ ): ImportableSession[] {
44
+ const controlled = readControlled(src, chatId);
45
+ const out: ImportableSession[] = [];
46
+ const seen = new Set<string>();
47
+
48
+ for (const c of controlled) {
49
+ if (!c.sessionId || seen.has(c.sessionId)) continue;
50
+ seen.add(c.sessionId);
51
+ const meta = resolveForeignMeta(src.format, src.sessionsRoot, c.sessionId, {
52
+ cwd: c.projectPath,
53
+ projectName: c.projectName || (c.projectPath ? basename(c.projectPath) : undefined),
54
+ });
55
+ if (!meta.cwd && c.projectPath) meta.cwd = c.projectPath;
56
+ if (!meta.projectName) {
57
+ meta.projectName = c.projectName || (meta.cwd ? basename(meta.cwd) : undefined);
58
+ }
59
+ out.push({ ...meta, sourceId: src.id, fromRunning: true });
60
+ }
61
+
62
+ return out;
63
+ }
64
+
65
+ function readControlled(src: ImportSource, chatId?: number): RawControlled[] {
66
+ if (!existsSync(src.settingsPath)) {
67
+ log.warn(`settings missing for ${src.id}: ${src.settingsPath}`);
68
+ return [];
69
+ }
70
+ let raw: Record<string, RawChatSettings>;
71
+ try {
72
+ raw = JSON.parse(readFileSync(src.settingsPath, "utf-8")) as Record<string, RawChatSettings>;
73
+ } catch (e) {
74
+ log.warn(`cannot parse settings for ${src.id}:`, (e as Error).message);
75
+ return [];
76
+ }
77
+
78
+ const collect = (s: RawChatSettings | undefined): RawControlled[] => {
79
+ if (!s) return [];
80
+ const list = [...(s.controlledSessions ?? [])];
81
+ // Also include the single-session fields if controlledSessions is empty.
82
+ if (list.length === 0 && s.sessionId) {
83
+ list.push({
84
+ sessionId: s.sessionId,
85
+ projectPath: s.projectPath,
86
+ projectName: s.projectName,
87
+ });
88
+ }
89
+ return list;
90
+ };
91
+
92
+ if (chatId !== undefined) {
93
+ const key = String(chatId);
94
+ if (raw[key]) return collect(raw[key]);
95
+ }
96
+
97
+ const all: RawControlled[] = [];
98
+ for (const s of Object.values(raw)) all.push(...collect(s));
99
+ return all;
100
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Foreign session sources that can be imported into Grok: the four sibling
3
+ * Telegram bots on this machine. Each source has a bot root (for settings /
4
+ * /running controlled sessions) and a tool session store (history on disk).
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ export type ImportSourceId = "kiro" | "opencode" | "claude" | "codex";
11
+
12
+ /** How history is stored for a source tool. */
13
+ export type HistoryFormat = "acp-jsonl" | "codex-rollout" | "opencode-storage";
14
+
15
+ export interface ImportSource {
16
+ id: ImportSourceId;
17
+ /** Short label shown in the source picker. */
18
+ label: string;
19
+ /** Absolute path to the sibling telegram-bot project root. */
20
+ botRoot: string;
21
+ /** Path to that bot's settings.json (holds controlledSessions /running). */
22
+ settingsPath: string;
23
+ /**
24
+ * Tool session store root:
25
+ * - acp-jsonl: dir of `<id>.json` + `<id>.jsonl`
26
+ * - codex-rollout: `$CODEX_HOME/sessions` tree of rollouts
27
+ * - opencode-storage: `~/.local/share/opencode` (storage/ under it)
28
+ */
29
+ sessionsRoot: string;
30
+ format: HistoryFormat;
31
+ }
32
+
33
+ const DOMAINS = "H:\\Lucru\\Domains";
34
+
35
+ /** Built-in import sources (fixed paths as requested). */
36
+ export const IMPORT_SOURCES: readonly ImportSource[] = [
37
+ {
38
+ id: "kiro",
39
+ label: "Kiro",
40
+ botRoot: join(DOMAINS, "kiro-telegram-bot"),
41
+ settingsPath: join(DOMAINS, "kiro-telegram-bot", "data", "settings.json"),
42
+ sessionsRoot: join(homedir(), ".kiro", "sessions", "cli"),
43
+ format: "acp-jsonl",
44
+ },
45
+ {
46
+ id: "opencode",
47
+ label: "OpenCode",
48
+ botRoot: join(DOMAINS, "opencode-telegram-bot"),
49
+ settingsPath: join(DOMAINS, "opencode-telegram-bot", "data", "settings.json"),
50
+ sessionsRoot: join(homedir(), ".local", "share", "opencode"),
51
+ format: "opencode-storage",
52
+ },
53
+ {
54
+ id: "claude",
55
+ label: "Claude",
56
+ botRoot: join(DOMAINS, "claude-telegram-bot"),
57
+ settingsPath: join(DOMAINS, "claude-telegram-bot", "data", "settings.json"),
58
+ sessionsRoot: join(DOMAINS, "claude-telegram-bot", "data", "sessions"),
59
+ format: "acp-jsonl",
60
+ },
61
+ {
62
+ id: "codex",
63
+ label: "Codex",
64
+ botRoot: join(DOMAINS, "codex-telegram-bot"),
65
+ settingsPath: join(DOMAINS, "codex-telegram-bot", "data", "settings.json"),
66
+ sessionsRoot: join(homedir(), ".codex", "sessions"),
67
+ format: "codex-rollout",
68
+ },
69
+ ] as const;
70
+
71
+ export function getImportSource(id: string): ImportSource | undefined {
72
+ return IMPORT_SOURCES.find((s) => s.id === id);
73
+ }
74
+
75
+ /** True when the bot root exists on disk. */
76
+ export function sourceAvailable(src: ImportSource): boolean {
77
+ return existsSync(src.botRoot);
78
+ }
package/src/index.ts CHANGED
@@ -2,29 +2,118 @@
2
2
  * Grok Telegram Bot — entry point.
3
3
  * Starts the Grok ACP bridge (`grok agent stdio`), the Telegram bot, and wires
4
4
  * graceful shutdown between them.
5
+ *
6
+ * Lifetime rules (critical):
7
+ * - Prefer staying up over clean-but-dead exits.
8
+ * - Uncaught errors are logged; the process keeps polling Telegram.
9
+ * - grammY long-poll is restarted on transport/network death, 429, 409, etc.
10
+ * - Never exit without a clear stderr + log line.
11
+ * - Interactive `npm start` stays in-process (no silent detach re-exec).
12
+ * - Supervised launches (GROK_TG_SUPERVISED=1) may exit for external relaunch.
5
13
  */
14
+ import { join } from "node:path";
15
+ import type { Bot } from "grammy";
16
+ import { GrammyError, HttpError } from "grammy";
6
17
  import { GrokClient } from "./grok/client.js";
7
18
  import { createBot } from "./bot/bot.js";
8
19
  import { CANONICAL_DIR, loadConfig } from "./config.js";
9
20
  import { InstanceLock } from "./app/instance-lock.js";
10
- import { join } from "node:path";
21
+ import {
22
+ isIntentionalShutdown,
23
+ markIntentionalShutdown,
24
+ } from "./app/lifetime-flag.js";
11
25
  import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
12
26
 
27
+ /** Last known reason for process end (read by exit/beforeExit handlers). */
28
+ let exitReason = "unknown";
29
+ /** Intentional exit in progress (SIGINT, fatal, lock, supervised relaunch). */
30
+ let shuttingDown = false;
31
+ /** beforeExit keep-alive armed at most once (avoid infinite 60s re-arm spam). */
32
+ let beforeExitKeepalive: ReturnType<typeof setInterval> | undefined;
33
+ /** Set immediately after config load; used by scream/shutdown. */
34
+ let log!: ReturnType<typeof createLogger>;
35
+
36
+ function noteExit(reason: string): void {
37
+ exitReason = reason;
38
+ }
39
+
40
+ function beginShutdown(reason: string): void {
41
+ shuttingDown = true;
42
+ markIntentionalShutdown(reason);
43
+ noteExit(reason);
44
+ if (beforeExitKeepalive) {
45
+ clearInterval(beforeExitKeepalive);
46
+ beforeExitKeepalive = undefined;
47
+ }
48
+ }
49
+
50
+ function scream(msg: string): void {
51
+ try {
52
+ process.stderr.write(`${msg}\n`);
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ try {
57
+ process.stdout.write(`${msg}\n`);
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ try {
62
+ log?.error(msg);
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ }
67
+
13
68
  async function main(): Promise<void> {
14
69
  process.stdout.write("\u{1F916} Grok Telegram Bot — starting…\n");
15
70
 
16
71
  const cfg = loadConfig();
17
72
  setLogLevel(cfg.logLevel);
18
73
  enableFileLogging(cfg.logFile);
19
- const log = createLogger("main");
74
+ log = createLogger("main");
75
+
76
+ // Always leave a trail — silent process death was the top reliability bug.
77
+ process.on("exit", (code) => {
78
+ const line = `[main] process exit code=${code} reason=${exitReason}`;
79
+ try {
80
+ // File logger may already be gone; still try console.
81
+ console.error(line);
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ });
86
+ process.on("beforeExit", (code) => {
87
+ // Never fight an intentional shutdown / fatal exit / updater re-exec.
88
+ if (shuttingDown || isIntentionalShutdown()) return;
89
+ if (beforeExitKeepalive) return; // already holding the process open
90
+ // Event loop emptied without an intentional shutdown — keep a handle alive
91
+ // so we don't vanish with code 0 and no explanation.
92
+ scream(
93
+ `\u26A0\uFE0F Event loop emptying (beforeExit code=${code}, reason=${exitReason}). ` +
94
+ `Keeping process alive — this usually means polling stopped unexpectedly.`,
95
+ );
96
+ noteExit(`beforeExit:${exitReason}`);
97
+ // One ref'd interval (not stacking setTimeouts that re-fire beforeExit forever).
98
+ beforeExitKeepalive = setInterval(() => {
99
+ if (shuttingDown || isIntentionalShutdown()) {
100
+ if (beforeExitKeepalive) clearInterval(beforeExitKeepalive);
101
+ return;
102
+ }
103
+ log?.warn(`beforeExit keepalive still holding process (reason=${exitReason})`);
104
+ }, 60_000);
105
+ beforeExitKeepalive.ref?.();
106
+ });
20
107
 
21
108
  // Single-instance guard: kill any ghost/duplicate already polling this token.
22
109
  const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
23
110
  if (cfg.singleInstance && !(await lock.acquire())) {
24
- process.stdout.write(
25
- "\u26D4 Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.\n",
26
- );
27
- process.exit(0);
111
+ const msg =
112
+ "Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.";
113
+ beginShutdown("instance-lock-held");
114
+ log.warn(msg);
115
+ scream(`\u26D4 ${msg}`);
116
+ process.exit(1);
28
117
  }
29
118
 
30
119
  log.info("starting Grok Telegram Bot");
@@ -33,6 +122,21 @@ async function main(): Promise<void> {
33
122
  log.info(`sessions: ${cfg.sessionsDir}`);
34
123
  log.info(`log file: ${cfg.logFile}`);
35
124
 
125
+ // Never die silently from stray exceptions — a dead bot is worse than a
126
+ // partially inconsistent one. Registering these handlers overrides Node's
127
+ // default "print and exit" for uncaughtException.
128
+ process.on("uncaughtException", (err) => {
129
+ noteExit(`uncaughtException:${err.message}`);
130
+ log.error("uncaughtException (process stays up):", err);
131
+ scream(`\u26A0\uFE0F uncaughtException (staying up): ${err.stack || err.message}`);
132
+ });
133
+ process.on("unhandledRejection", (reason) => {
134
+ const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
135
+ noteExit(`unhandledRejection:${msg.slice(0, 120)}`);
136
+ log.error("unhandledRejection (process stays up):", reason instanceof Error ? reason : String(reason));
137
+ scream(`\u26A0\uFE0F unhandledRejection (staying up): ${msg}`);
138
+ });
139
+
36
140
  const grok = new GrokClient({
37
141
  grokCliPath: cfg.grokCliPath,
38
142
  workspace: cfg.workspace,
@@ -44,20 +148,65 @@ async function main(): Promise<void> {
44
148
  promptIdleTimeoutMs: cfg.promptIdleMs,
45
149
  });
46
150
 
47
- await grok.start();
48
- const { bot, registry, scheduler, updater } = await createBot(cfg, grok);
49
- scheduler.start();
50
- await updater.start();
151
+ // Retry ACP connect — agent crash at boot should not kill the Telegram bot.
152
+ for (let attempt = 1; ; attempt++) {
153
+ try {
154
+ await grok.start();
155
+ break;
156
+ } catch (e) {
157
+ const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
158
+ log.error(`Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
159
+ scream(`\u26A0\uFE0F Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
160
+ await sleep(wait);
161
+ }
162
+ }
51
163
 
52
- let shuttingDown = false;
53
- const shutdown = (code: number): void => {
164
+ // createBot can fail on transient Telegram API issues — retry rather than die.
165
+ let bot: Bot;
166
+ let registry: Awaited<ReturnType<typeof createBot>>["registry"];
167
+ let scheduler: Awaited<ReturnType<typeof createBot>>["scheduler"];
168
+ let updater: Awaited<ReturnType<typeof createBot>>["updater"];
169
+ for (let attempt = 1; ; attempt++) {
170
+ try {
171
+ const bundle = await createBot(cfg, grok);
172
+ bot = bundle.bot;
173
+ registry = bundle.registry;
174
+ scheduler = bundle.scheduler;
175
+ updater = bundle.updater;
176
+ break;
177
+ } catch (e) {
178
+ const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
179
+ const detail = formatTelegramError(e);
180
+ log.error(`createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
181
+ scream(`\u26A0\uFE0F createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
182
+ await sleep(wait);
183
+ }
184
+ }
185
+
186
+ scheduler!.start();
187
+ await updater!.start();
188
+
189
+ const shutdown = (code: number, reason: string): void => {
54
190
  if (shuttingDown) return;
55
- shuttingDown = true;
56
- log.info("shutting down…");
57
- scheduler.stop();
58
- updater.stop();
59
- registry.disposeAll();
60
- void bot.stop().catch(() => {});
191
+ beginShutdown(reason);
192
+ log.info(`shutting down (code=${code}, reason=${reason})…`);
193
+ scream(`\u{1F6D1} Shutting down (code=${code}, reason=${reason})`);
194
+ try {
195
+ scheduler!.stop();
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ try {
200
+ updater!.stop();
201
+ } catch {
202
+ /* ignore */
203
+ }
204
+ try {
205
+ registry!.disposeAll();
206
+ } catch {
207
+ /* ignore */
208
+ }
209
+ void bot!.stop().catch(() => {});
61
210
  grok.stop();
62
211
  lock.release();
63
212
  setTimeout(() => process.exit(code), 500);
@@ -65,20 +214,156 @@ async function main(): Promise<void> {
65
214
 
66
215
  grok.on("restarted", () => log.info("Grok bridge re-bound; sessions continue on next message."));
67
216
 
68
- process.on("SIGINT", () => shutdown(0));
69
- process.on("SIGTERM", () => shutdown(0));
70
- process.on("uncaughtException", (err) => log.error("uncaughtException:", err));
71
- process.on("unhandledRejection", (err) => log.error("unhandledRejection:", err));
217
+ process.on("SIGINT", () => shutdown(0, "SIGINT"));
218
+ process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
72
219
 
73
- await bot.start({
74
- onStart: (info) => {
75
- log.info(`bot online as @${info.username}`);
76
- process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
77
- },
78
- });
220
+ // Keepalive + lock heartbeat — REF'd so the process cannot exit with an empty
221
+ // event loop while we still intend to run (unref was a silent-death footgun).
222
+ let botUsername = "?";
223
+ const keepalive = setInterval(() => {
224
+ try {
225
+ if (shuttingDown || isIntentionalShutdown()) return;
226
+ lock.touch();
227
+ log.info(
228
+ `keepalive: alive as @${botUsername} polling=${bot!.isRunning()} ` +
229
+ `pid=${process.pid} uptime=${Math.floor(process.uptime())}s`,
230
+ );
231
+ } catch (e) {
232
+ log.warn(`keepalive tick failed: ${(e as Error).message}`);
233
+ }
234
+ }, 5 * 60_000);
235
+ // Explicitly ref — do not unref.
236
+ keepalive.ref?.();
237
+
238
+ // Keep long-polling forever. On network / 409 / 429 / grammY fatal, wait and
239
+ // restart the poller in-process. Interactive runs never detach-relaunch
240
+ // (that looked like a silent death in the terminal).
241
+ while (!shuttingDown && !isIntentionalShutdown()) {
242
+ await runPollingForever(
243
+ bot!,
244
+ log,
245
+ () => shuttingDown || isIntentionalShutdown(),
246
+ (name) => {
247
+ botUsername = name;
248
+ },
249
+ );
250
+ if (shuttingDown || isIntentionalShutdown()) break;
251
+
252
+ // runPollingForever should only return when shuttingDown; if not, recover.
253
+ scream("\u26A0\uFE0F Telegram polling loop returned unexpectedly; recovering in-process");
254
+ log.error("Telegram polling loop returned unexpectedly; recovering in-process (no silent exit)");
255
+ noteExit("polling-loop-returned");
256
+
257
+ if (process.env.GROK_TG_SUPERVISED === "1") {
258
+ log.info("supervised mode — exiting for external relaunch");
259
+ noteExit("supervised-relaunch");
260
+ clearInterval(keepalive);
261
+ shutdown(1, "supervised-relaunch");
262
+ return;
263
+ }
264
+
265
+ // Stay in this process — re-enter polling after a short pause.
266
+ await sleep(3000);
267
+ }
268
+ clearInterval(keepalive);
269
+ }
270
+
271
+ /** grammY start with automatic recovery and loud classification of API errors. */
272
+ async function runPollingForever(
273
+ bot: Bot,
274
+ log: ReturnType<typeof createLogger>,
275
+ isShuttingDown: () => boolean,
276
+ onOnline: (username: string) => void,
277
+ ): Promise<void> {
278
+ let attempt = 0;
279
+ while (!isShuttingDown()) {
280
+ try {
281
+ attempt = 0;
282
+ await bot.start({
283
+ onStart: (info) => {
284
+ onOnline(info.username);
285
+ log.info(`bot online as @${info.username}`);
286
+ process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
287
+ },
288
+ });
289
+ // bot.start resolves when polling is stopped (bot.stop) or loop ends.
290
+ if (isShuttingDown()) return;
291
+ log.warn("bot.start resolved without shutdown; restarting polling in 2s");
292
+ scream("\u26A0\uFE0F bot.start resolved without shutdown; restarting polling in 2s");
293
+ await sleep(2000);
294
+ } catch (e) {
295
+ attempt++;
296
+ const classified = classifyPollingError(e);
297
+ const wait = classified.waitMs ?? Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
298
+ const line =
299
+ `Telegram polling failed (attempt ${attempt}, ${classified.kind}): ${classified.message}; ` +
300
+ `retry in ${wait}ms`;
301
+ log.error(line);
302
+ scream(`\u26A0\uFE0F ${line}`);
303
+ try {
304
+ await bot.stop();
305
+ } catch {
306
+ /* ignore */
307
+ }
308
+ await sleep(wait);
309
+ }
310
+ }
311
+ }
312
+
313
+ function classifyPollingError(e: unknown): { kind: string; message: string; waitMs?: number } {
314
+ const message = formatTelegramError(e);
315
+ if (e instanceof GrammyError) {
316
+ if (e.error_code === 429) {
317
+ const ra = e.parameters?.retry_after;
318
+ const waitMs = typeof ra === "number" ? (ra + 1) * 1000 : 10_000;
319
+ return { kind: "429-rate-limit", message, waitMs };
320
+ }
321
+ if (e.error_code === 409) {
322
+ return {
323
+ kind: "409-conflict",
324
+ message: `${message} (another getUpdates consumer for this token?)`,
325
+ waitMs: 15_000,
326
+ };
327
+ }
328
+ if (e.error_code === 401) {
329
+ return {
330
+ kind: "401-unauthorized",
331
+ message: `${message} (check TELEGRAM_BOT_TOKEN)`,
332
+ waitMs: 60_000,
333
+ };
334
+ }
335
+ return { kind: `grammy-${e.error_code}`, message };
336
+ }
337
+ if (e instanceof HttpError) {
338
+ return { kind: "http-network", message, waitMs: 5_000 };
339
+ }
340
+ const msg = (e as Error)?.message ?? String(e);
341
+ if (/ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang|fetch failed|network/i.test(msg)) {
342
+ return { kind: "network", message: msg, waitMs: 5_000 };
343
+ }
344
+ return { kind: "unknown", message: msg };
345
+ }
346
+
347
+ function formatTelegramError(e: unknown): string {
348
+ if (e instanceof GrammyError) {
349
+ const ra = e.parameters?.retry_after;
350
+ const extra = typeof ra === "number" ? ` retry_after=${ra}s` : "";
351
+ return `${e.message} (code ${e.error_code}${extra})`;
352
+ }
353
+ if (e instanceof HttpError) return `HttpError: ${e.message}`;
354
+ if (e instanceof Error) return e.message;
355
+ return String(e);
356
+ }
357
+
358
+ function sleep(ms: number): Promise<void> {
359
+ return new Promise((r) => setTimeout(r, ms));
79
360
  }
80
361
 
81
362
  main().catch((err) => {
82
- console.error("Fatal:", err instanceof Error ? err.message : err);
83
- process.exit(1);
363
+ // Mark intentional exit so beforeExit keep-alive cannot swallow a real fatal.
364
+ beginShutdown(`fatal:${err instanceof Error ? err.message : String(err)}`);
365
+ const text = err instanceof Error ? err.stack || err.message : String(err);
366
+ console.error("Fatal:", text);
367
+ scream(`\u274C Fatal (exiting in 1s): ${text}`);
368
+ setTimeout(() => process.exit(1), 1000);
84
369
  });
@@ -34,6 +34,13 @@ export class ProjectManager {
34
34
 
35
35
  /** List projects, de-duplicated by (case-insensitive) name. */
36
36
  list(limit = 100): ProjectEntry[] {
37
+ const out = this.listAll();
38
+ if (!Number.isFinite(limit) || limit <= 0 || limit >= out.length) return out;
39
+ return out.slice(0, limit);
40
+ }
41
+
42
+ /** Full catalog (no slice). Used by forum topic setup for 1000+ projects. */
43
+ listAll(): ProjectEntry[] {
37
44
  const byName = new Map<string, ProjectEntry>();
38
45
 
39
46
  for (const root of this.roots) {
@@ -62,17 +69,23 @@ export class ProjectManager {
62
69
 
63
70
  // Freshest first (directory mtime); callers may refine `lastUsed` with
64
71
  // session activity and re-sort. Alphabetical as a stable tiebreak.
65
- const out = [...byName.values()].sort(
72
+ return [...byName.values()].sort(
66
73
  (a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name),
67
74
  );
68
- return out.slice(0, limit);
75
+ }
76
+
77
+ /** Exact catalog name match (case-insensitive). */
78
+ findExactByName(name: string): ProjectEntry | undefined {
79
+ const key = name.trim().toLowerCase();
80
+ if (!key) return undefined;
81
+ return this.listAll().find((p) => p.name.toLowerCase() === key);
69
82
  }
70
83
 
71
84
  /** Projects whose name contains the query (case-insensitive). */
72
85
  search(query: string, limit = 100): ProjectEntry[] {
73
86
  const q = query.trim().toLowerCase();
74
87
  if (!q) return this.list(limit);
75
- return this.list(1000)
88
+ return this.listAll()
76
89
  .filter((p) => p.name.toLowerCase().includes(q))
77
90
  .slice(0, limit);
78
91
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Split a MarkdownV2 string into Telegram-sized chunks (<= 4096 chars) without
3
- * breaking code fences. If a split happens inside a ``` block, the block is
4
- * closed before the boundary and reopened in the next chunk.
3
+ * breaking code fences. If a split happens inside a fenced block, the block is
4
+ * closed before the boundary and reopened in the next chunk (same tick length).
5
5
  */
6
6
  const LIMIT = 4000; // headroom under Telegram's 4096 hard limit
7
7
 
@@ -12,18 +12,19 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
12
12
  const chunks: string[] = [];
13
13
  let current: string[] = [];
14
14
  let size = 0;
15
- let fenceLang: string | null = null; // non-null => currently inside a fence
15
+ /** Open fence: tick count + optional lang; null when outside a fence. */
16
+ let openFence: { ticks: number; lang: string } | null = null;
16
17
 
17
18
  const flush = (): void => {
18
19
  if (current.length === 0) return;
19
20
  let body = current.join("\n");
20
- if (fenceLang !== null) body += "\n```"; // close dangling fence
21
+ if (openFence) body += "\n" + "`".repeat(openFence.ticks); // close dangling fence
21
22
  chunks.push(body);
22
23
  current = [];
23
24
  size = 0;
24
- if (fenceLang !== null) {
25
- // Reopen the fence at the top of the next chunk.
26
- const reopen = "```" + fenceLang;
25
+ if (openFence) {
26
+ // Reopen the fence at the top of the next chunk (preserve tick length).
27
+ const reopen = "`".repeat(openFence.ticks) + openFence.lang;
27
28
  current.push(reopen);
28
29
  size = reopen.length + 1;
29
30
  }
@@ -31,9 +32,9 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
31
32
 
32
33
  for (const rawLine of lines) {
33
34
  const line = rawLine;
34
- const fenceMatch = /^```(.*)$/.exec(line);
35
+ const fenceMatch = /^(```+)(.*)$/.exec(line);
35
36
 
36
- // Hard-split a single oversized line.
37
+ // Hard-split a single oversized line (never mid-fence marker line).
37
38
  if (line.length + 1 > limit && fenceMatch === null) {
38
39
  flush();
39
40
  for (let i = 0; i < line.length; i += limit) {
@@ -48,7 +49,13 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
48
49
  size += line.length + 1;
49
50
 
50
51
  if (fenceMatch) {
51
- fenceLang = fenceLang === null ? (fenceMatch[1] ?? "").trim() : null;
52
+ const ticks = fenceMatch[1]!.length;
53
+ const lang = (fenceMatch[2] ?? "").trim();
54
+ if (!openFence) {
55
+ openFence = { ticks, lang };
56
+ } else if (ticks >= openFence.ticks) {
57
+ openFence = null;
58
+ }
52
59
  }
53
60
  }
54
61