cursor-telegram-mcp 0.5.0 → 0.7.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/README.md +104 -24
- package/dist/agentRunner.js +119 -9
- package/dist/cli.js +11 -0
- package/dist/config.js +19 -1
- package/dist/formatTelegram.js +8 -0
- package/dist/index.js +35 -6
- package/dist/install.js +231 -0
- package/dist/parseInbound.js +35 -3
- package/dist/setup.js +31 -11
- package/dist/store.js +97 -12
- package/dist/telegram.js +57 -17
- package/dist/watchdog.js +62 -0
- package/dist/worker.js +203 -54
- package/package.json +1 -1
package/dist/install.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursor-telegram-mcp install` / `uninstall` — always-on worker on macOS.
|
|
3
|
+
*
|
|
4
|
+
* Generates a per-user launchd agent (no hardcoded paths) so the background
|
|
5
|
+
* worker starts at login, restarts if it crashes, and keeps the Mac awake
|
|
6
|
+
* enough to deliver Telegram messages. Designed for the npm-distributed
|
|
7
|
+
* package: the plist points at the Node that is running this command and the
|
|
8
|
+
* installed CLI, and config is read from `<configDir>/config.json` (written by
|
|
9
|
+
* `setup`), so nothing is tied to a checkout location.
|
|
10
|
+
*
|
|
11
|
+
* macOS only for now; on other platforms it prints guidance and exits.
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { homedir, platform, tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join, sep } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
const LABEL = "com.cursor-telegram.worker";
|
|
19
|
+
const WATCHDOG_LABEL = "com.cursor-telegram.watchdog";
|
|
20
|
+
/** Older label used by the in-repo dev scripts; booted out on install to avoid a double-run. */
|
|
21
|
+
const LEGACY_LABEL = "com.cursor-remote-chat.worker";
|
|
22
|
+
/** How often (seconds) the watchdog checks worker /health. */
|
|
23
|
+
const WATCHDOG_INTERVAL_SEC = 120;
|
|
24
|
+
function plistPath(label = LABEL) {
|
|
25
|
+
return join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
26
|
+
}
|
|
27
|
+
function logPaths() {
|
|
28
|
+
const dir = join(homedir(), "Library", "Logs");
|
|
29
|
+
return {
|
|
30
|
+
out: join(dir, "cursor-telegram-worker.log"),
|
|
31
|
+
err: join(dir, "cursor-telegram-worker.err.log"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function xmlEscape(s) {
|
|
35
|
+
return s
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">");
|
|
39
|
+
}
|
|
40
|
+
/** Absolute path to the installed CLI entry (dist/cli.js) running this code. */
|
|
41
|
+
function cliEntry() {
|
|
42
|
+
return fileURLToPath(import.meta.url).replace(/install\.js$/, "cli.js");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether this package lives in a stable location. `npx -y cursor-telegram-mcp`
|
|
46
|
+
* runs from an ephemeral cache that npm may garbage-collect, which would leave
|
|
47
|
+
* the launch agent pointing at a path that disappears. A global install
|
|
48
|
+
* (`npm i -g`) is durable. We refuse to install a launch agent from an
|
|
49
|
+
* ephemeral path unless explicitly forced.
|
|
50
|
+
*/
|
|
51
|
+
function isDurableInstall() {
|
|
52
|
+
const p = fileURLToPath(import.meta.url);
|
|
53
|
+
if (p.includes(`${sep}_npx${sep}`))
|
|
54
|
+
return false;
|
|
55
|
+
if (p.startsWith(tmpdir()))
|
|
56
|
+
return false;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
function buildProgramArguments() {
|
|
60
|
+
const node = process.execPath;
|
|
61
|
+
const cli = cliEntry();
|
|
62
|
+
const caffeinate = "/usr/bin/caffeinate";
|
|
63
|
+
// caffeinate -ims keeps the system + display awake while the worker runs so
|
|
64
|
+
// Telegram delivery is never blocked by idle sleep (AC power still required
|
|
65
|
+
// for a fully closed-lid Mac, which we document in the README).
|
|
66
|
+
if (existsSync(caffeinate)) {
|
|
67
|
+
return [caffeinate, "-ims", node, cli, "worker"];
|
|
68
|
+
}
|
|
69
|
+
return [node, cli, "worker"];
|
|
70
|
+
}
|
|
71
|
+
function buildPlist() {
|
|
72
|
+
const args = buildProgramArguments();
|
|
73
|
+
const { out, err } = logPaths();
|
|
74
|
+
const nodeDir = dirname(process.execPath);
|
|
75
|
+
const pathEnv = [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":");
|
|
76
|
+
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
77
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
78
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
79
|
+
<plist version="1.0">
|
|
80
|
+
<dict>
|
|
81
|
+
<key>Label</key>
|
|
82
|
+
<string>${LABEL}</string>
|
|
83
|
+
<key>ProgramArguments</key>
|
|
84
|
+
<array>
|
|
85
|
+
${argXml}
|
|
86
|
+
</array>
|
|
87
|
+
<key>WorkingDirectory</key>
|
|
88
|
+
<string>${xmlEscape(homedir())}</string>
|
|
89
|
+
<key>EnvironmentVariables</key>
|
|
90
|
+
<dict>
|
|
91
|
+
<key>PATH</key>
|
|
92
|
+
<string>${xmlEscape(pathEnv)}</string>
|
|
93
|
+
</dict>
|
|
94
|
+
<key>RunAtLoad</key>
|
|
95
|
+
<true/>
|
|
96
|
+
<key>KeepAlive</key>
|
|
97
|
+
<true/>
|
|
98
|
+
<key>ThrottleInterval</key>
|
|
99
|
+
<integer>10</integer>
|
|
100
|
+
<key>ProcessType</key>
|
|
101
|
+
<string>Background</string>
|
|
102
|
+
<key>StandardOutPath</key>
|
|
103
|
+
<string>${xmlEscape(out)}</string>
|
|
104
|
+
<key>StandardErrorPath</key>
|
|
105
|
+
<string>${xmlEscape(err)}</string>
|
|
106
|
+
</dict>
|
|
107
|
+
</plist>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
function buildWatchdogPlist() {
|
|
111
|
+
const node = process.execPath;
|
|
112
|
+
const cli = cliEntry();
|
|
113
|
+
const nodeDir = dirname(process.execPath);
|
|
114
|
+
const pathEnv = [nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(":");
|
|
115
|
+
const { out, err } = logPaths();
|
|
116
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
117
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
118
|
+
<plist version="1.0">
|
|
119
|
+
<dict>
|
|
120
|
+
<key>Label</key>
|
|
121
|
+
<string>${WATCHDOG_LABEL}</string>
|
|
122
|
+
<key>ProgramArguments</key>
|
|
123
|
+
<array>
|
|
124
|
+
<string>${xmlEscape(node)}</string>
|
|
125
|
+
<string>${xmlEscape(cli)}</string>
|
|
126
|
+
<string>watchdog</string>
|
|
127
|
+
</array>
|
|
128
|
+
<key>EnvironmentVariables</key>
|
|
129
|
+
<dict>
|
|
130
|
+
<key>PATH</key>
|
|
131
|
+
<string>${xmlEscape(pathEnv)}</string>
|
|
132
|
+
<key>TG_WORKER_LABEL</key>
|
|
133
|
+
<string>${LABEL}</string>
|
|
134
|
+
</dict>
|
|
135
|
+
<key>RunAtLoad</key>
|
|
136
|
+
<true/>
|
|
137
|
+
<key>StartInterval</key>
|
|
138
|
+
<integer>${WATCHDOG_INTERVAL_SEC}</integer>
|
|
139
|
+
<key>ProcessType</key>
|
|
140
|
+
<string>Background</string>
|
|
141
|
+
<key>StandardOutPath</key>
|
|
142
|
+
<string>${xmlEscape(out)}</string>
|
|
143
|
+
<key>StandardErrorPath</key>
|
|
144
|
+
<string>${xmlEscape(err)}</string>
|
|
145
|
+
</dict>
|
|
146
|
+
</plist>
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
function domain() {
|
|
150
|
+
return `gui/${process.getuid?.() ?? 501}`;
|
|
151
|
+
}
|
|
152
|
+
function launchctl(args) {
|
|
153
|
+
try {
|
|
154
|
+
execFileSync("launchctl", args, { stdio: "ignore" });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// bootout of a non-loaded agent is expected to fail; callers tolerate it
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function requireMac() {
|
|
161
|
+
if (platform() !== "darwin") {
|
|
162
|
+
process.stderr.write("Always-on install is macOS only for now.\n" +
|
|
163
|
+
"On other systems, run `cursor-telegram-mcp worker` under your own\n" +
|
|
164
|
+
"process manager (systemd, pm2, a login item, etc.).\n");
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function runInstall() {
|
|
169
|
+
requireMac();
|
|
170
|
+
if (!isDurableInstall() && process.env.TG_FORCE_INSTALL !== "1") {
|
|
171
|
+
process.stderr.write([
|
|
172
|
+
"This looks like a temporary (npx) copy, which npm may delete later —",
|
|
173
|
+
"an always-on launch agent must point at a stable install.",
|
|
174
|
+
"",
|
|
175
|
+
"Install it globally first, then run install:",
|
|
176
|
+
" npm i -g cursor-telegram-mcp",
|
|
177
|
+
" cursor-telegram-mcp install",
|
|
178
|
+
"",
|
|
179
|
+
"(Set TG_FORCE_INSTALL=1 to override, not recommended.)",
|
|
180
|
+
"",
|
|
181
|
+
].join("\n"));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
const path = plistPath();
|
|
185
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
186
|
+
mkdirSync(join(homedir(), "Library", "Logs"), { recursive: true });
|
|
187
|
+
writeFileSync(path, buildPlist(), "utf8");
|
|
188
|
+
// Avoid two workers fighting over the port: stop any previous/legacy agent.
|
|
189
|
+
launchctl(["bootout", domain(), path]);
|
|
190
|
+
launchctl(["bootout", domain(), join(homedir(), "Library", "LaunchAgents", `${LEGACY_LABEL}.plist`)]);
|
|
191
|
+
execFileSync("launchctl", ["bootstrap", domain(), path], { stdio: "inherit" });
|
|
192
|
+
// Watchdog: restarts the worker if it ever goes unreachable or its poll loop
|
|
193
|
+
// wedges, so the bot is never silently offline while the Mac is on.
|
|
194
|
+
const wdPath = plistPath(WATCHDOG_LABEL);
|
|
195
|
+
writeFileSync(wdPath, buildWatchdogPlist(), "utf8");
|
|
196
|
+
launchctl(["bootout", domain(), wdPath]);
|
|
197
|
+
execFileSync("launchctl", ["bootstrap", domain(), wdPath], { stdio: "inherit" });
|
|
198
|
+
const { out } = logPaths();
|
|
199
|
+
process.stdout.write([
|
|
200
|
+
`Installed always-on worker: ${path}`,
|
|
201
|
+
`Installed watchdog (every ${WATCHDOG_INTERVAL_SEC}s): ${wdPath}`,
|
|
202
|
+
`Logs: ${out}`,
|
|
203
|
+
"",
|
|
204
|
+
"It will start now and at every login. To stop it:",
|
|
205
|
+
" cursor-telegram-mcp uninstall",
|
|
206
|
+
"",
|
|
207
|
+
"Tip: keep the Mac on AC power so it never fully sleeps.",
|
|
208
|
+
"",
|
|
209
|
+
].join("\n"));
|
|
210
|
+
}
|
|
211
|
+
export function runUninstall() {
|
|
212
|
+
requireMac();
|
|
213
|
+
const wdPath = plistPath(WATCHDOG_LABEL);
|
|
214
|
+
launchctl(["bootout", domain(), wdPath]);
|
|
215
|
+
if (existsSync(wdPath))
|
|
216
|
+
rmSync(wdPath);
|
|
217
|
+
const path = plistPath();
|
|
218
|
+
launchctl(["bootout", domain(), path]);
|
|
219
|
+
if (existsSync(path))
|
|
220
|
+
rmSync(path);
|
|
221
|
+
process.stdout.write("Uninstalled always-on worker and watchdog.\n");
|
|
222
|
+
}
|
|
223
|
+
// Only act when invoked directly as the `install`/`uninstall` subcommand, so
|
|
224
|
+
// other modules (e.g. setup) can import runInstall() without side effects.
|
|
225
|
+
const sub = process.argv[2];
|
|
226
|
+
if (sub === "install" || sub === "uninstall") {
|
|
227
|
+
if (sub === "uninstall")
|
|
228
|
+
runUninstall();
|
|
229
|
+
else
|
|
230
|
+
runInstall();
|
|
231
|
+
}
|
package/dist/parseInbound.js
CHANGED
|
@@ -5,6 +5,18 @@
|
|
|
5
5
|
* /ask or /plan on any line. Reply-to HITL answers should bypass splitting
|
|
6
6
|
* (caller passes the full text as a single answer).
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Parse an explicit question target prefix like "Q-3 ...", "Q3 ...", "#3 ..."
|
|
10
|
+
* or "@Q-3 ...". Returns the normalized question id ("Q-3") and the remaining
|
|
11
|
+
* answer text, or null when the text doesn't start with such a prefix. Used to
|
|
12
|
+
* let a Telegram reply target a specific pending question when several are open.
|
|
13
|
+
*/
|
|
14
|
+
export function parseTargetId(text) {
|
|
15
|
+
const m = text.match(/^\s*@?\s*(?:q-?|#)(\d+)\b[\s:.\-]*([\s\S]*)$/i);
|
|
16
|
+
if (!m)
|
|
17
|
+
return null;
|
|
18
|
+
return { id: `Q-${m[1]}`, rest: (m[2] ?? "").trim() };
|
|
19
|
+
}
|
|
8
20
|
/** Standard plan-approval footer sent after Plan (C-n). */
|
|
9
21
|
const APPROVAL_FOOTER_RE = /^\s*reply\s+yes\s+to\s+run\b[\s\S]*\bno\s+to\s+cancel\.?\s*$/i;
|
|
10
22
|
/** True when text is (or ends with) the plan YES/NO footer — not a new task. */
|
|
@@ -18,7 +30,9 @@ export function isPlanApprovalFooter(text) {
|
|
|
18
30
|
const last = lines[lines.length - 1] ?? "";
|
|
19
31
|
return APPROVAL_FOOTER_RE.test(last);
|
|
20
32
|
}
|
|
21
|
-
const STATUS_RE = /^\s
|
|
33
|
+
const STATUS_RE = /^\s*\/?status\s*$/i;
|
|
34
|
+
const PENDING_RE = /^\s*\/?(pending|questions)\s*$/i;
|
|
35
|
+
const RESET_RE = /^\s*\/?(reset|new)\s*$/i;
|
|
22
36
|
const YES_WORDS = "yes|yea|yeah|y|approve|approved|ok|okay|go|do it|כן|אישור|בצע";
|
|
23
37
|
const NO_WORDS = "no|n|cancel|stop|reject|nope|לא|ביטול|עצור";
|
|
24
38
|
const YES_ONLY_RE = new RegExp(`^\\s*(${YES_WORDS})\\s*[!.]?\\s*$`, "i");
|
|
@@ -52,6 +66,10 @@ function parseCommandLine(part) {
|
|
|
52
66
|
function classifyPart(part) {
|
|
53
67
|
if (STATUS_RE.test(part))
|
|
54
68
|
return [{ kind: "status" }];
|
|
69
|
+
if (PENDING_RE.test(part))
|
|
70
|
+
return [{ kind: "pending" }];
|
|
71
|
+
if (RESET_RE.test(part))
|
|
72
|
+
return [{ kind: "reset" }];
|
|
55
73
|
if (YES_ONLY_RE.test(part))
|
|
56
74
|
return [{ kind: "approve" }];
|
|
57
75
|
if (NO_ONLY_RE.test(part))
|
|
@@ -85,9 +103,23 @@ export function splitInboundMessage(text) {
|
|
|
85
103
|
if (isPlanApprovalFooter(trimmed)) {
|
|
86
104
|
return [{ kind: "approval_footer" }];
|
|
87
105
|
}
|
|
88
|
-
const
|
|
106
|
+
const raw = [];
|
|
89
107
|
for (const part of splitParts(trimmed)) {
|
|
90
|
-
|
|
108
|
+
raw.push(...classifyPart(part));
|
|
109
|
+
}
|
|
110
|
+
// Coalesce consecutive plain segments into ONE task. Without this, a single
|
|
111
|
+
// multi-line free-text paste is split per line into many separate plan tasks
|
|
112
|
+
// (the cause of a runaway where one paste spawned C-1..C-n). Explicit /ask
|
|
113
|
+
// and /plan commands and leading approve/reject stay as their own segments.
|
|
114
|
+
const segments = [];
|
|
115
|
+
for (const seg of raw) {
|
|
116
|
+
const prev = segments[segments.length - 1];
|
|
117
|
+
if (seg.kind === "plain" && prev && prev.kind === "plain") {
|
|
118
|
+
prev.text = `${prev.text}\n${seg.text}`;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
segments.push(seg);
|
|
122
|
+
}
|
|
91
123
|
}
|
|
92
124
|
return segments;
|
|
93
125
|
}
|
package/dist/setup.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createInterface } from "node:readline/promises";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
|
+
import { platform } from "node:os";
|
|
12
13
|
import { configFilePath, readFileConfig, writeFileConfig } from "./config.js";
|
|
14
|
+
import { runInstall } from "./install.js";
|
|
13
15
|
async function tg(token, method, params) {
|
|
14
16
|
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
15
17
|
method: "POST",
|
|
@@ -101,21 +103,39 @@ async function main() {
|
|
|
101
103
|
out("Skipped. Notifications and questions still work; enable later by re-running setup.");
|
|
102
104
|
}
|
|
103
105
|
out("");
|
|
104
|
-
out("
|
|
106
|
+
out("Config saved to:");
|
|
105
107
|
out(` ${configFilePath()}`);
|
|
106
108
|
out("");
|
|
107
|
-
out("
|
|
109
|
+
out("Step 4 (recommended): keep your bot online whenever your computer is on.");
|
|
110
|
+
out("This runs a tiny background worker that starts at login and restarts");
|
|
111
|
+
out("itself if it ever stops - no need to open Cursor or run anything.");
|
|
112
|
+
if (platform() === "darwin") {
|
|
113
|
+
const yes = (await rl.question("Install the always-on service now? [Y/n]: ")).trim().toLowerCase();
|
|
114
|
+
if (yes === "" || yes === "y" || yes === "yes") {
|
|
115
|
+
try {
|
|
116
|
+
runInstall();
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
out(`Could not install the service automatically: ${String(err)}`);
|
|
120
|
+
out("You can run it later with: cursor-telegram-mcp install");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
out("Skipped. Enable it later with: cursor-telegram-mcp install");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
out("On Linux/Windows, run `cursor-telegram-mcp worker` under your service");
|
|
129
|
+
out("manager (systemd / Task Scheduler) so it starts at boot.");
|
|
130
|
+
}
|
|
131
|
+
out("");
|
|
132
|
+
out("Optional: to let Cursor agents message you, add this to a project's");
|
|
133
|
+
out(".cursor/mcp.json (or Cursor Settings -> MCP), then reload MCP:");
|
|
108
134
|
out("");
|
|
109
|
-
out(' {');
|
|
110
|
-
out('
|
|
111
|
-
out(' "telegram": {');
|
|
112
|
-
out(' "command": "npx",');
|
|
113
|
-
out(' "args": ["-y", "cursor-telegram-mcp"]');
|
|
114
|
-
out(' }');
|
|
115
|
-
out(' }');
|
|
116
|
-
out(' }');
|
|
135
|
+
out(' { "mcpServers": { "telegram": { "command": "npx",');
|
|
136
|
+
out(' "args": ["-y", "cursor-telegram-mcp"] } } }');
|
|
117
137
|
out("");
|
|
118
|
-
out("
|
|
138
|
+
out("You're set - text your bot to test it.");
|
|
119
139
|
}
|
|
120
140
|
finally {
|
|
121
141
|
rl.close();
|
package/dist/store.js
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending-question store with optional disk persistence.
|
|
3
|
+
*
|
|
4
|
+
* When a `persistPath` is given, the store is loaded on construction and saved
|
|
5
|
+
* (debounced) on every change, so pending questions survive a worker restart and
|
|
6
|
+
* remain answerable. Without a path it behaves as a pure in-memory store.
|
|
7
|
+
* Answered questions are kept briefly so a poll that arrives after the reply can
|
|
8
|
+
* still read it, then pruned to bound memory (and to bound the persisted file).
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
1
12
|
/** Keep answered questions this long after they are answered, then prune. */
|
|
2
13
|
const ANSWERED_TTL_MS = 60 * 60_000;
|
|
14
|
+
/** Debounce window for writing the persisted store to disk. */
|
|
15
|
+
const SAVE_DEBOUNCE_MS = 200;
|
|
3
16
|
export class QuestionStore {
|
|
4
17
|
nextId = 1;
|
|
5
18
|
questions = new Map();
|
|
19
|
+
persistPath;
|
|
20
|
+
saveTimer;
|
|
21
|
+
constructor(opts = {}) {
|
|
22
|
+
this.persistPath = opts.persistPath;
|
|
23
|
+
this.load();
|
|
24
|
+
}
|
|
6
25
|
/** Create a new pending question. */
|
|
7
|
-
addQuestion(projectLabel, question) {
|
|
26
|
+
addQuestion(projectLabel, question, agentLabel) {
|
|
8
27
|
this.prune();
|
|
9
28
|
const record = {
|
|
10
29
|
id: `Q-${this.nextId++}`,
|
|
11
30
|
projectLabel,
|
|
31
|
+
agentLabel: agentLabel && agentLabel !== "" ? agentLabel : undefined,
|
|
12
32
|
question,
|
|
13
33
|
status: "pending",
|
|
14
34
|
createdAt: Date.now(),
|
|
15
35
|
};
|
|
16
36
|
this.questions.set(record.id, record);
|
|
37
|
+
this.scheduleSave();
|
|
17
38
|
return record;
|
|
18
39
|
}
|
|
19
40
|
/** Attach the outgoing Telegram message id to a question. */
|
|
20
41
|
setSentMessageId(id, messageId) {
|
|
21
42
|
const record = this.questions.get(id);
|
|
22
|
-
if (record)
|
|
43
|
+
if (record) {
|
|
23
44
|
record.sentMessageId = messageId;
|
|
45
|
+
this.scheduleSave();
|
|
46
|
+
}
|
|
24
47
|
}
|
|
25
48
|
/** Look up a single question by id. */
|
|
26
49
|
get(id) {
|
|
@@ -42,40 +65,102 @@ export class QuestionStore {
|
|
|
42
65
|
}
|
|
43
66
|
/**
|
|
44
67
|
* Match an inbound message to a pending question and record the answer.
|
|
45
|
-
*
|
|
46
|
-
* question
|
|
68
|
+
* Resolution order: an explicit `targetId` > an exact reply-to (swipe) match >
|
|
69
|
+
* the sole pending question. When more than one question is pending and there
|
|
70
|
+
* is no target or reply-to, returns null so the caller can disambiguate (we no
|
|
71
|
+
* longer silently answer the oldest). A `targetId` that is not pending also
|
|
72
|
+
* returns null. `answerText` overrides the recorded answer (used when the id
|
|
73
|
+
* prefix has been stripped from the inbound text).
|
|
47
74
|
*/
|
|
48
|
-
matchAndAnswer(incoming) {
|
|
75
|
+
matchAndAnswer(incoming, opts = {}) {
|
|
49
76
|
const pendings = [...this.questions.values()]
|
|
50
77
|
.filter((q) => q.status === "pending")
|
|
51
78
|
.sort((a, b) => a.createdAt - b.createdAt);
|
|
52
79
|
if (pendings.length === 0)
|
|
53
80
|
return null;
|
|
54
81
|
let target;
|
|
55
|
-
if (
|
|
82
|
+
if (opts.targetId) {
|
|
83
|
+
target = pendings.find((q) => q.id === opts.targetId);
|
|
84
|
+
if (!target)
|
|
85
|
+
return null; // unknown / already-answered target
|
|
86
|
+
}
|
|
87
|
+
else if (incoming.quotedMessageId) {
|
|
56
88
|
target = pendings.find((q) => q.sentMessageId && q.sentMessageId === incoming.quotedMessageId);
|
|
57
89
|
}
|
|
58
|
-
if (!target)
|
|
59
|
-
|
|
90
|
+
if (!target) {
|
|
91
|
+
if (pendings.length === 1)
|
|
92
|
+
target = pendings[0];
|
|
93
|
+
else
|
|
94
|
+
return null; // ambiguous: caller disambiguates
|
|
95
|
+
}
|
|
60
96
|
target.status = "answered";
|
|
61
|
-
target.answer = incoming.text;
|
|
97
|
+
target.answer = opts.answerText ?? incoming.text;
|
|
62
98
|
if (incoming.attachments.length > 0) {
|
|
63
99
|
target.answerAttachments = incoming.attachments;
|
|
64
100
|
}
|
|
65
101
|
target.answeredAt = incoming.timestamp || Date.now();
|
|
102
|
+
this.scheduleSave();
|
|
66
103
|
return target;
|
|
67
104
|
}
|
|
68
105
|
/** Drop answered questions older than the TTL. */
|
|
69
106
|
prune() {
|
|
70
107
|
const cutoff = Date.now() - ANSWERED_TTL_MS;
|
|
108
|
+
let removed = false;
|
|
71
109
|
for (const [id, q] of this.questions) {
|
|
72
110
|
if (q.status === "answered" && (q.answeredAt ?? q.createdAt) < cutoff) {
|
|
73
111
|
this.questions.delete(id);
|
|
112
|
+
removed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (removed)
|
|
116
|
+
this.scheduleSave();
|
|
117
|
+
}
|
|
118
|
+
/** Load persisted questions (best-effort) on startup. */
|
|
119
|
+
load() {
|
|
120
|
+
if (!this.persistPath)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
const data = JSON.parse(readFileSync(this.persistPath, "utf8"));
|
|
124
|
+
if (Array.isArray(data.questions)) {
|
|
125
|
+
for (const q of data.questions) {
|
|
126
|
+
if (q && typeof q.id === "string")
|
|
127
|
+
this.questions.set(q.id, q);
|
|
128
|
+
}
|
|
74
129
|
}
|
|
130
|
+
if (typeof data.nextId === "number" && data.nextId > this.nextId) {
|
|
131
|
+
this.nextId = data.nextId;
|
|
132
|
+
}
|
|
133
|
+
this.prune();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// No (or unreadable) persisted state: start fresh.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Debounced write so bursts of changes coalesce into one disk write. */
|
|
140
|
+
scheduleSave() {
|
|
141
|
+
if (!this.persistPath || this.saveTimer)
|
|
142
|
+
return;
|
|
143
|
+
this.saveTimer = setTimeout(() => {
|
|
144
|
+
this.saveTimer = undefined;
|
|
145
|
+
this.saveNow();
|
|
146
|
+
}, SAVE_DEBOUNCE_MS);
|
|
147
|
+
this.saveTimer.unref?.();
|
|
148
|
+
}
|
|
149
|
+
/** Write the current state to disk now (best-effort). */
|
|
150
|
+
saveNow() {
|
|
151
|
+
if (!this.persistPath)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(this.persistPath), { recursive: true });
|
|
155
|
+
const data = { nextId: this.nextId, questions: [...this.questions.values()] };
|
|
156
|
+
writeFileSync(this.persistPath, JSON.stringify(data), "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Best-effort persistence: a failed write must not break the worker.
|
|
75
160
|
}
|
|
76
161
|
}
|
|
77
162
|
}
|
|
78
|
-
/** Build a
|
|
79
|
-
export function createStore() {
|
|
80
|
-
return new QuestionStore();
|
|
163
|
+
/** Build a store, optionally backed by a JSON file at `opts.persistPath`. */
|
|
164
|
+
export function createStore(opts = {}) {
|
|
165
|
+
return new QuestionStore(opts);
|
|
81
166
|
}
|
package/dist/telegram.js
CHANGED
|
@@ -59,6 +59,8 @@ export class TelegramClient {
|
|
|
59
59
|
stopping = false;
|
|
60
60
|
offset = 0;
|
|
61
61
|
me = null;
|
|
62
|
+
/** Last time getUpdates returned successfully (ms epoch); liveness signal. */
|
|
63
|
+
lastPollOkAt = Date.now();
|
|
62
64
|
constructor(opts) {
|
|
63
65
|
this.opts = opts;
|
|
64
66
|
this.logger = opts.logger ?? silentLogger();
|
|
@@ -70,23 +72,46 @@ export class TelegramClient {
|
|
|
70
72
|
isOpen() {
|
|
71
73
|
return this.open;
|
|
72
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Milliseconds since getUpdates last returned successfully. A large value
|
|
77
|
+
* while `open` means the poll loop is stuck (e.g. a stale socket after the
|
|
78
|
+
* Mac slept) even though the connection looks up — used by /health and the
|
|
79
|
+
* watchdog to detect a silently-offline worker.
|
|
80
|
+
*/
|
|
81
|
+
lastPollAgeMs() {
|
|
82
|
+
return Date.now() - this.lastPollOkAt;
|
|
83
|
+
}
|
|
73
84
|
username() {
|
|
74
85
|
return this.me?.username;
|
|
75
86
|
}
|
|
76
87
|
onIncoming(handler) {
|
|
77
88
|
this.handlers.push(handler);
|
|
78
89
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Call a Bot API method with a hard client-side timeout. Without this, a
|
|
92
|
+
* socket left half-open by a sleep/wake could make `fetch` hang forever and
|
|
93
|
+
* silently take the bot offline. On timeout the call rejects (AbortError) and
|
|
94
|
+
* the caller retries.
|
|
95
|
+
*/
|
|
96
|
+
async call(method, params, timeoutMs = 20_000) {
|
|
97
|
+
const ac = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(`${this.base}/${method}`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify(params ?? {}),
|
|
104
|
+
signal: ac.signal,
|
|
105
|
+
});
|
|
106
|
+
const data = (await res.json());
|
|
107
|
+
if (!data.ok) {
|
|
108
|
+
throw new Error(`Telegram ${method} failed: ${data.description ?? `HTTP ${res.status}`}`);
|
|
109
|
+
}
|
|
110
|
+
return data.result;
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
clearTimeout(timer);
|
|
88
114
|
}
|
|
89
|
-
return data.result;
|
|
90
115
|
}
|
|
91
116
|
async connect() {
|
|
92
117
|
await mkdir(this.mediaDir, { recursive: true });
|
|
@@ -100,13 +125,19 @@ export class TelegramClient {
|
|
|
100
125
|
sweep.unref?.();
|
|
101
126
|
}
|
|
102
127
|
async pollLoop() {
|
|
128
|
+
// Abort getUpdates a bit after the server-side long-poll window so a dead
|
|
129
|
+
// socket (e.g. after sleep/wake) can't wedge the loop indefinitely.
|
|
130
|
+
const pollTimeoutMs = (GETUPDATES_LONG_POLL_SEC + 15) * 1000;
|
|
131
|
+
let backoffMs = 1000;
|
|
103
132
|
while (!this.stopping) {
|
|
104
133
|
try {
|
|
105
134
|
const updates = await this.call("getUpdates", {
|
|
106
135
|
offset: this.offset,
|
|
107
136
|
timeout: GETUPDATES_LONG_POLL_SEC,
|
|
108
137
|
allowed_updates: ["message"],
|
|
109
|
-
});
|
|
138
|
+
}, pollTimeoutMs);
|
|
139
|
+
this.lastPollOkAt = Date.now();
|
|
140
|
+
backoffMs = 1000;
|
|
110
141
|
for (const update of updates) {
|
|
111
142
|
this.offset = update.update_id + 1;
|
|
112
143
|
const msg = await this.toIncoming(update.message);
|
|
@@ -118,8 +149,9 @@ export class TelegramClient {
|
|
|
118
149
|
catch (err) {
|
|
119
150
|
if (this.stopping)
|
|
120
151
|
break;
|
|
121
|
-
this.logger.warn(`getUpdates error (
|
|
122
|
-
await new Promise((r) => setTimeout(r,
|
|
152
|
+
this.logger.warn(`getUpdates error (retry in ${backoffMs}ms): ${String(err)}`);
|
|
153
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
154
|
+
backoffMs = Math.min(backoffMs * 2, 30_000);
|
|
123
155
|
}
|
|
124
156
|
}
|
|
125
157
|
}
|
|
@@ -145,10 +177,18 @@ export class TelegramClient {
|
|
|
145
177
|
throw new Error(`Attachment too large (${file.file_size} bytes, max ${this.maxAttachmentBytes})`);
|
|
146
178
|
}
|
|
147
179
|
const url = `https://api.telegram.org/file/bot${this.opts.botToken}/${file.file_path}`;
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
180
|
+
const ac = new AbortController();
|
|
181
|
+
const timer = setTimeout(() => ac.abort(), 60_000);
|
|
182
|
+
let buf;
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(url, { signal: ac.signal });
|
|
185
|
+
if (!res.ok)
|
|
186
|
+
throw new Error(`Download failed: HTTP ${res.status}`);
|
|
187
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
152
192
|
if (buf.length > this.maxAttachmentBytes) {
|
|
153
193
|
throw new Error(`Attachment too large (${buf.length} bytes, max ${this.maxAttachmentBytes})`);
|
|
154
194
|
}
|