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/index.ts CHANGED
@@ -6,35 +6,114 @@
6
6
  * Lifetime rules (critical):
7
7
  * - Prefer staying up over clean-but-dead exits.
8
8
  * - Uncaught errors are logged; the process keeps polling Telegram.
9
- * - grammY long-poll is restarted on transport/network death.
10
- * - Supervised launches (GROK_TG_SUPERVISED=1 / service) may exit for relaunch;
11
- * bare/manual runs re-exec when possible rather than dying silently.
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.
12
13
  */
13
- import { spawn } from "node:child_process";
14
14
  import { join } from "node:path";
15
15
  import type { Bot } from "grammy";
16
+ import { GrammyError, HttpError } from "grammy";
16
17
  import { GrokClient } from "./grok/client.js";
17
18
  import { createBot } from "./bot/bot.js";
18
- import { CANONICAL_DIR, INSTANCE_DIR, loadConfig } from "./config.js";
19
+ import { CANONICAL_DIR, loadConfig } from "./config.js";
19
20
  import { InstanceLock } from "./app/instance-lock.js";
21
+ import {
22
+ isIntentionalShutdown,
23
+ markIntentionalShutdown,
24
+ } from "./app/lifetime-flag.js";
20
25
  import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
21
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
+
22
68
  async function main(): Promise<void> {
23
69
  process.stdout.write("\u{1F916} Grok Telegram Bot — starting…\n");
24
70
 
25
71
  const cfg = loadConfig();
26
72
  setLogLevel(cfg.logLevel);
27
73
  enableFileLogging(cfg.logFile);
28
- 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
+ });
29
107
 
30
108
  // Single-instance guard: kill any ghost/duplicate already polling this token.
31
109
  const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
32
110
  if (cfg.singleInstance && !(await lock.acquire())) {
33
111
  const msg =
34
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");
35
114
  log.warn(msg);
36
- process.stdout.write(`\u26D4 ${msg}\n`);
37
- process.exit(0);
115
+ scream(`\u26D4 ${msg}`);
116
+ process.exit(1);
38
117
  }
39
118
 
40
119
  log.info("starting Grok Telegram Bot");
@@ -47,13 +126,15 @@ async function main(): Promise<void> {
47
126
  // partially inconsistent one. Registering these handlers overrides Node's
48
127
  // default "print and exit" for uncaughtException.
49
128
  process.on("uncaughtException", (err) => {
129
+ noteExit(`uncaughtException:${err.message}`);
50
130
  log.error("uncaughtException (process stays up):", err);
131
+ scream(`\u26A0\uFE0F uncaughtException (staying up): ${err.stack || err.message}`);
51
132
  });
52
133
  process.on("unhandledRejection", (reason) => {
53
- log.error(
54
- "unhandledRejection (process stays up):",
55
- reason instanceof Error ? reason : String(reason),
56
- );
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}`);
57
138
  });
58
139
 
59
140
  const grok = new GrokClient({
@@ -75,6 +156,7 @@ async function main(): Promise<void> {
75
156
  } catch (e) {
76
157
  const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
77
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`);
78
160
  await sleep(wait);
79
161
  }
80
162
  }
@@ -94,7 +176,9 @@ async function main(): Promise<void> {
94
176
  break;
95
177
  } catch (e) {
96
178
  const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
97
- log.error(`createBot failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
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`);
98
182
  await sleep(wait);
99
183
  }
100
184
  }
@@ -102,11 +186,11 @@ async function main(): Promise<void> {
102
186
  scheduler!.start();
103
187
  await updater!.start();
104
188
 
105
- let shuttingDown = false;
106
- const shutdown = (code: number): void => {
189
+ const shutdown = (code: number, reason: string): void => {
107
190
  if (shuttingDown) return;
108
- shuttingDown = true;
109
- log.info("shutting down…");
191
+ beginShutdown(reason);
192
+ log.info(`shutting down (code=${code}, reason=${reason})…`);
193
+ scream(`\u{1F6D1} Shutting down (code=${code}, reason=${reason})`);
110
194
  try {
111
195
  scheduler!.stop();
112
196
  } catch {
@@ -130,45 +214,66 @@ async function main(): Promise<void> {
130
214
 
131
215
  grok.on("restarted", () => log.info("Grok bridge re-bound; sessions continue on next message."));
132
216
 
133
- process.on("SIGINT", () => shutdown(0));
134
- process.on("SIGTERM", () => shutdown(0));
217
+ process.on("SIGINT", () => shutdown(0, "SIGINT"));
218
+ process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
135
219
 
136
- // Refresh the single-instance lock periodically so a long-running process
137
- // stays clearly "alive" on disk (start time + pid).
138
- const lockHeartbeat = setInterval(() => {
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(() => {
139
224
  try {
140
- if (!shuttingDown) lock.touch();
141
- } catch {
142
- /* non-fatal */
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}`);
143
233
  }
144
- }, 60_000);
145
- lockHeartbeat.unref?.();
146
-
147
- // Keep long-polling forever. On network / 409 / grammY fatal, wait and restart
148
- // the poller instead of ending the process (silent death was a critical bug).
149
- // If the inner loop still returns without shutdown, attempt self-relaunch; if
150
- // that fails, resume polling rather than dying.
151
- while (!shuttingDown) {
152
- await runPollingForever(bot!, log, () => shuttingDown);
153
- if (shuttingDown) break;
154
- log.error("Telegram polling loop exited unexpectedly; attempting self-relaunch");
155
- const replaced = await selfRelaunch(cfg.projectRoot, log);
156
- if (replaced) {
157
- clearInterval(lockHeartbeat);
158
- shutdown(1);
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");
159
262
  return;
160
263
  }
161
- log.error("self-relaunch failed; resuming Telegram polling in 5s");
162
- await sleep(5000);
264
+
265
+ // Stay in this process — re-enter polling after a short pause.
266
+ await sleep(3000);
163
267
  }
164
- clearInterval(lockHeartbeat);
268
+ clearInterval(keepalive);
165
269
  }
166
270
 
167
- /** grammY start with automatic recovery. */
271
+ /** grammY start with automatic recovery and loud classification of API errors. */
168
272
  async function runPollingForever(
169
273
  bot: Bot,
170
274
  log: ReturnType<typeof createLogger>,
171
275
  isShuttingDown: () => boolean,
276
+ onOnline: (username: string) => void,
172
277
  ): Promise<void> {
173
278
  let attempt = 0;
174
279
  while (!isShuttingDown()) {
@@ -176,20 +281,25 @@ async function runPollingForever(
176
281
  attempt = 0;
177
282
  await bot.start({
178
283
  onStart: (info) => {
284
+ onOnline(info.username);
179
285
  log.info(`bot online as @${info.username}`);
180
286
  process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
181
287
  },
182
288
  });
183
- // bot.start resolves when polling is stopped (bot.stop).
289
+ // bot.start resolves when polling is stopped (bot.stop) or loop ends.
184
290
  if (isShuttingDown()) return;
185
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");
186
293
  await sleep(2000);
187
294
  } catch (e) {
188
295
  attempt++;
189
- const wait = Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
190
- log.error(
191
- `Telegram polling failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`,
192
- );
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}`);
193
303
  try {
194
304
  await bot.stop();
195
305
  } catch {
@@ -200,32 +310,49 @@ async function runPollingForever(
200
310
  }
201
311
  }
202
312
 
203
- /**
204
- * Spawn a fresh bot process then let the caller exit (manual / non-supervised).
205
- * Returns true when a replacement was started (or supervised exit is expected).
206
- */
207
- async function selfRelaunch(projectRoot: string, log: ReturnType<typeof createLogger>): Promise<boolean> {
208
- if (process.env.GROK_TG_SUPERVISED === "1") {
209
- log.info("supervised mode — exiting for external relaunch");
210
- return true;
211
- }
212
- try {
213
- const child = spawn(
214
- process.execPath,
215
- ["--import", "tsx", join(projectRoot, "src", "index.ts"), "--instance", INSTANCE_DIR],
216
- { detached: true, stdio: "ignore", cwd: projectRoot, env: process.env },
217
- );
218
- child.unref();
219
- if (!child.pid) {
220
- log.error("self-relaunch spawn produced no pid — staying alive");
221
- return false;
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
+ };
222
334
  }
223
- log.info(`spawned replacement pid ${child.pid}`);
224
- return true;
225
- } catch (e) {
226
- log.error(`self-relaunch failed: ${(e as Error).message} — staying alive`);
227
- return false;
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})`;
228
352
  }
353
+ if (e instanceof HttpError) return `HttpError: ${e.message}`;
354
+ if (e instanceof Error) return e.message;
355
+ return String(e);
229
356
  }
230
357
 
231
358
  function sleep(ms: number): Promise<void> {
@@ -233,7 +360,10 @@ function sleep(ms: number): Promise<void> {
233
360
  }
234
361
 
235
362
  main().catch((err) => {
236
- console.error("Fatal:", err instanceof Error ? err.stack || err.message : err);
237
- // Delay exit so logs flush; VBS restart loop / supervisor can bring us back.
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}`);
238
368
  setTimeout(() => process.exit(1), 1000);
239
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
 
@@ -10,6 +10,8 @@ export interface TagInput {
10
10
  projectName?: string;
11
11
  cwd?: string;
12
12
  sessionId?: string;
13
+ /** Short id for a single user turn; becomes `#prompt_<id>`. */
14
+ promptId?: string;
13
15
  }
14
16
 
15
17
  /** Sanitise a value into a Telegram-safe hashtag body (letters/digits/_ only). */
@@ -25,10 +27,12 @@ export function tagSafe(v: string): string {
25
27
  /**
26
28
  * Build the hashtag footer. `#proj_` is always present; `#sess_` is added only
27
29
  * when the session id is known, so partial callers (e.g. a static /history view)
28
- * still tag consistently. Order: project · session.
30
+ * still tag consistently. `#prompt_` is added when a turn-level id is known.
31
+ * Order: project · session · prompt.
29
32
  */
30
33
  export function sessionHashtags(input: TagInput): string {
31
34
  const tags = [`#proj_${tagSafe(input.projectName || basename(input.cwd || "") || "none")}`];
32
35
  if (input.sessionId) tags.push(`#sess_${tagSafe(input.sessionId.slice(0, 8))}`);
36
+ if (input.promptId) tags.push(`#prompt_${tagSafe(input.promptId)}`);
33
37
  return tags.join(" ");
34
38
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Manager (OpenClaw-style) directive for the Telegram General topic.
3
+ *
4
+ * General is a chat-like orchestrator: it routes work to project topics,
5
+ * never implements project code itself, and reports statuses back to the user.
6
+ * Keep tidy-idempotent (no digit `{progress:…}` markers) so history cleaners
7
+ * can strip by exact match.
8
+ */
9
+ import type { PromptInput } from "../app/types.js";
10
+
11
+ export const MANAGER_DIRECTIVE_MARKER = "MANAGER MODE (General topic — OpenClaw-style orchestrator):";
12
+
13
+ /** Marker for child-session completion wakes injected into General. */
14
+ export const MANAGER_WORK_REPORT_MARKER =
15
+ "MANAGER WORK REPORT (system — analyze and report to the user; do not invent facts):";
16
+
17
+ /**
18
+ * First-prompt / steering block for General. Free of real progress digit tokens.
19
+ */
20
+ export const MANAGER_DIRECTIVE = [
21
+ MANAGER_DIRECTIVE_MARKER,
22
+ "You are the global manager of this Telegram forum group — like OpenClaw.",
23
+ "This topic is a chat control room, not a coding workspace.",
24
+ "",
25
+ "How you behave:",
26
+ "- The user SEES your free-form prose in this chat. Always answer them in short chat text.",
27
+ "- Keep replies brief (a few sentences). No progress bars, tool dumps, job tables, or",
28
+ " \"Dispatching…\" / \"Sending to…\" narration.",
29
+ "- Use telegram JSON for side effects: search_memory, list_topics, send_prompt, notify.",
30
+ "- notify is an OPTIONAL extra ping — do NOT rely on it as the only user-facing channel.",
31
+ "- Example good reply: \"On it — continuing the ship gate in WindowsStoreListingGenerator.\"",
32
+ "- NEVER emit task-progress markers (no progress percent footers).",
33
+ "- NEVER enter plan mode, run self-recheck, or dump long tool/trace spam here.",
34
+ "- NEVER implement app code, edit project files, run builds/tests, or do multi-file work in General.",
35
+ "- Always DELEGATE real work to the correct project topic via telegram bridge actions.",
36
+ "",
37
+ "MEMORY-FIRST (mandatory — do this BEFORE any git/shell/file tools):",
38
+ "1. Read the auto-injected MANAGER CONTEXT (General history, memory hits, topics, jobs).",
39
+ "2. Call search_memory with the user's keywords (and list_topics if needed).",
40
+ "3. Prefer Telegram/bot memory + project topic session history over `git log` / filesystem.",
41
+ "4. Only use git if the user explicitly asks for git, or after memory has no useful hits.",
42
+ "5. Order of truth for \"what changed / last work\":",
43
+ " (a) General chat + manager memory hits with [age] stamps (newest first),",
44
+ " (b) that project's MOST RECENT topic sessions (highest recency / last user prompts / Done notes),",
45
+ " (c) ignore older sessions for the same app when a newer session exists,",
46
+ " (d) then optional git — never jump to git first.",
47
+ "6. When summarizing last work: weight last user prompts and assistant Done text by time,",
48
+ " not by how many times a keyword appears in an old session.",
49
+ "",
50
+ "Dispatch workflow:",
51
+ "1. Identify the target topic (exact title, #threadId, or create a new project topic).",
52
+ "2. Build a RICH child prompt from memory (what was done, what remains, acceptance criteria).",
53
+ "3. Emit one telegram JSON block: create_topic/set_path as needed, then send_prompt,",
54
+ " plus optional single notify if the user should hear about it.",
55
+ "4. After bridge results: silent is fine if dispatch ok; notify only on failure or if user asked.",
56
+ "5. MANAGER WORK REPORT wakes: notify only for outcomes the user needs (done/fail/important);",
57
+ " otherwise process silently (no notify).",
58
+ "",
59
+ "RESUME RELATED SESSIONS (critical):",
60
+ "- When memory/context shows a related session (session=019fc9ec or full UUID) and the user",
61
+ " wants a follow-up / continue / fix there, you MUST pass session_id on send_prompt.",
62
+ "- Without session_id the bridge uses the topic's CURRENT open session — often the wrong one.",
63
+ "- topic must be the EXACT forum title or #threadId from list_topics / memory — NEVER \"…\" / \"...\" / placeholders.",
64
+ "- If you only know the session id, omit topic: { \"action\": \"send_prompt\", \"session_id\": \"019fc9ec\", \"prompt\": \"...\" }",
65
+ "- Example: { \"action\": \"send_prompt\", \"topic\": \"MyApp\", \"session_id\": \"019fc9ec\", \"prompt\": \"...\" }",
66
+ "- Only omit session_id for brand-new work on the topic's foreground, or use new_session=true for a fresh session.",
67
+ "- On Topic not found: call list_topics and retry with the exact name or #id (or session_id only).",
68
+ "",
69
+ "New projects: create_topic with name + absolute path (folder is created if missing),",
70
+ "then send_prompt into that topic with the full kickoff instructions.",
71
+ "",
72
+ "User message:",
73
+ ].join("\n");
74
+
75
+ /** True when text is a system work-report wake (meta; skip recheck / manager re-wrap noise). */
76
+ export function isManagerWorkReportPrompt(text: string): boolean {
77
+ return text.trimStart().startsWith(MANAGER_WORK_REPORT_MARKER);
78
+ }
79
+
80
+ /** Prepend manager directive (idempotent). */
81
+ export function wrapManagerDirective(input: PromptInput): PromptInput {
82
+ const body = input.text.trim() || "(see attached media / files)";
83
+ if (body.startsWith(MANAGER_DIRECTIVE_MARKER) || body.includes(MANAGER_DIRECTIVE_MARKER)) {
84
+ return input;
85
+ }
86
+ return {
87
+ ...input,
88
+ text: `${MANAGER_DIRECTIVE}\n${body}`,
89
+ };
90
+ }
91
+
92
+ /** Build the meta prompt that wakes General after a child topic finishes. */
93
+ export function buildManagerWorkReportPrompt(payload: {
94
+ jobId: string;
95
+ targetName: string;
96
+ targetThreadId: number;
97
+ targetPath: string;
98
+ userAskPreview: string;
99
+ dispatchPromptPreview: string;
100
+ status: "done" | "failed" | "cancelled";
101
+ stopReason?: string;
102
+ error?: string;
103
+ assistantSummary: string;
104
+ filesSummary?: string;
105
+ childSessionId?: string;
106
+ }): string {
107
+ const lines = [
108
+ MANAGER_WORK_REPORT_MARKER,
109
+ "```json",
110
+ JSON.stringify(
111
+ {
112
+ jobId: payload.jobId,
113
+ status: payload.status,
114
+ target: {
115
+ name: payload.targetName,
116
+ threadId: payload.targetThreadId,
117
+ path: payload.targetPath,
118
+ },
119
+ userAskPreview: payload.userAskPreview.slice(0, 500),
120
+ dispatchPromptPreview: payload.dispatchPromptPreview.slice(0, 800),
121
+ stopReason: payload.stopReason,
122
+ error: payload.error,
123
+ childSessionId: payload.childSessionId,
124
+ filesSummary: payload.filesSummary,
125
+ assistantSummary: payload.assistantSummary.slice(0, 3500),
126
+ },
127
+ null,
128
+ 2,
129
+ ),
130
+ "```",
131
+ "If the user needs to know (success, failure, blocked, needs a decision), reply in short prose.",
132
+ "If this is routine and they did not ask for a status, a one-line note is enough.",
133
+ "Do not re-emit the same send_prompt unless a retry is clearly useful.",
134
+ "No progress markers. No job tables or multi-message status spam.",
135
+ ];
136
+ return lines.join("\n");
137
+ }