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.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- 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
|
-
* -
|
|
11
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
process.exit(
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
const shutdown = (code: number): void => {
|
|
189
|
+
const shutdown = (code: number, reason: string): void => {
|
|
107
190
|
if (shuttingDown) return;
|
|
108
|
-
|
|
109
|
-
log.info(
|
|
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
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
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 (
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
// that
|
|
151
|
-
while (!shuttingDown) {
|
|
152
|
-
await runPollingForever(
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
264
|
+
|
|
265
|
+
// Stay in this process — re-enter polling after a short pause.
|
|
266
|
+
await sleep(3000);
|
|
163
267
|
}
|
|
164
|
-
clearInterval(
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
});
|
package/src/projects/manager.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
+
return [...byName.values()].sort(
|
|
66
73
|
(a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name),
|
|
67
74
|
);
|
|
68
|
-
|
|
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.
|
|
88
|
+
return this.listAll()
|
|
76
89
|
.filter((p) => p.name.toLowerCase().includes(q))
|
|
77
90
|
.slice(0, limit);
|
|
78
91
|
}
|
package/src/render/chunk.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
25
|
-
// Reopen the fence at the top of the next chunk.
|
|
26
|
-
const reopen = "
|
|
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 =
|
|
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
|
-
|
|
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
|
|
package/src/render/hashtags.ts
CHANGED
|
@@ -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.
|
|
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
|
+
}
|