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.
- package/.env.example +64 -2
- package/CHANGELOG.md +156 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +242 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
"
|
|
26
|
-
);
|
|
27
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
log.info(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
});
|
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
|
|