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.
@@ -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, "&amp;")
37
+ .replace(/</g, "&lt;")
38
+ .replace(/>/g, "&gt;");
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
+ }
@@ -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*status\s*$/i;
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 segments = [];
106
+ const raw = [];
89
107
  for (const part of splitParts(trimmed)) {
90
- segments.push(...classifyPart(part));
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("Done. Config saved to:");
106
+ out("Config saved to:");
105
107
  out(` ${configFilePath()}`);
106
108
  out("");
107
- out("Add this to your project's .cursor/mcp.json (or Cursor Settings -> MCP):");
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(' "mcpServers": {');
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("Then reload MCP in Cursor. The worker auto-starts with the MCP server.");
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
- * Prefers an exact reply-to match; otherwise answers the oldest pending
46
- * question (FIFO). Returns the answered record, or null if nothing matched.
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 (incoming.quotedMessageId) {
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
- target = pendings[0];
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 fresh in-memory store. */
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
- async call(method, params) {
80
- const res = await fetch(`${this.base}/${method}`, {
81
- method: "POST",
82
- headers: { "content-type": "application/json" },
83
- body: JSON.stringify(params ?? {}),
84
- });
85
- const data = (await res.json());
86
- if (!data.ok) {
87
- throw new Error(`Telegram ${method} failed: ${data.description ?? `HTTP ${res.status}`}`);
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 (retrying): ${String(err)}`);
122
- await new Promise((r) => setTimeout(r, 3000));
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 res = await fetch(url);
149
- if (!res.ok)
150
- throw new Error(`Download failed: HTTP ${res.status}`);
151
- const buf = Buffer.from(await res.arrayBuffer());
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
  }