cursor-telegram-mcp 0.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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/agentRunner.js +332 -0
- package/dist/answerWaiters.js +64 -0
- package/dist/cli.js +66 -0
- package/dist/config.js +160 -0
- package/dist/doctor.js +116 -0
- package/dist/formatTelegram.js +28 -0
- package/dist/index.js +334 -0
- package/dist/login.js +59 -0
- package/dist/parseInbound.js +93 -0
- package/dist/session.js +49 -0
- package/dist/setup.js +127 -0
- package/dist/splitMessage.js +61 -0
- package/dist/store.js +81 -0
- package/dist/taskQueue.js +33 -0
- package/dist/telegram.js +241 -0
- package/dist/transcript.js +56 -0
- package/dist/worker.js +667 -0
- package/mcp.client.template.json +12 -0
- package/package.json +58 -0
package/dist/worker.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background Telegram worker (long-running, local, single-user).
|
|
3
|
+
*
|
|
4
|
+
* Owns the single Telegram bot connection (long polling) and an in-memory
|
|
5
|
+
* pending-question store, and exposes a tiny HTTP API on localhost that the
|
|
6
|
+
* thin MCP server calls. Running it in its own process means the bot stays
|
|
7
|
+
* connected across Cursor / MCP reloads, and you can restart it from a terminal
|
|
8
|
+
* (`npm run worker`).
|
|
9
|
+
*
|
|
10
|
+
* Alive only while your laptop is awake (and the worker is running).
|
|
11
|
+
*
|
|
12
|
+
* If CURSOR_API_KEY is set, the worker also runs in "command mode": a message
|
|
13
|
+
* you text the bot (that isn't answering an open question) can be /ask (read-only
|
|
14
|
+
* Q&A), /plan (plan-then-approve), or plain text (same as /plan) -> you reply
|
|
15
|
+
* YES to execute a plan or NO to cancel. Everything runs locally.
|
|
16
|
+
*
|
|
17
|
+
* Run with `npm run worker`. Requires TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID.
|
|
18
|
+
*
|
|
19
|
+
* API (JSON, localhost only):
|
|
20
|
+
* GET /health -> { ok, connected, target, pending, commandMode, queue }
|
|
21
|
+
* POST /notify { summary, project } -> { ok } | 429 { error, waitMs } | 503
|
|
22
|
+
* POST /ask { question, project }-> { id } | 429 | 503
|
|
23
|
+
* POST /mirror { question, project } -> { id, mirrored } | 429 | 503
|
|
24
|
+
* GET /response/:id -> { id, status, answer?, attachments?, elapsedMin } | 404
|
|
25
|
+
* optional ?waitMs=N long-polls until answered or N ms
|
|
26
|
+
*/
|
|
27
|
+
import { createServer } from "node:http";
|
|
28
|
+
import { notifyAnswered, waitForAnswer } from "./answerWaiters.js";
|
|
29
|
+
import { readdirSync, statSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { delimiter, dirname, join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
import { getConfig } from "./config.js";
|
|
34
|
+
import { createStore } from "./store.js";
|
|
35
|
+
import { createCommandRunner } from "./agentRunner.js";
|
|
36
|
+
import { toPlainTelegram } from "./formatTelegram.js";
|
|
37
|
+
import { splitInboundMessage } from "./parseInbound.js";
|
|
38
|
+
import { splitMessage } from "./splitMessage.js";
|
|
39
|
+
import { createTaskQueue } from "./taskQueue.js";
|
|
40
|
+
import { TelegramClient, createStderrLogger, } from "./telegram.js";
|
|
41
|
+
/** Stale command sessions (awaiting approval too long) are swept after this. */
|
|
42
|
+
const COMMAND_TTL_MS = 60 * 60_000;
|
|
43
|
+
/** How often to check whether the worker's own source changed on disk. */
|
|
44
|
+
const UPDATE_CHECK_MS = 15_000;
|
|
45
|
+
/** Directory holding this worker's source (for self-update detection). */
|
|
46
|
+
const SRC_DIR = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
/**
|
|
48
|
+
* Newest mtime among the worker's TypeScript source files. Used to notice when
|
|
49
|
+
* a command-mode task has edited the worker's own code, so it can relaunch on
|
|
50
|
+
* the latest version (under a KeepAlive supervisor like launchd) while idle.
|
|
51
|
+
*/
|
|
52
|
+
function sourceFingerprint() {
|
|
53
|
+
let latest = 0;
|
|
54
|
+
try {
|
|
55
|
+
for (const f of readdirSync(SRC_DIR)) {
|
|
56
|
+
if (!f.endsWith(".ts"))
|
|
57
|
+
continue;
|
|
58
|
+
const m = statSync(join(SRC_DIR, f)).mtimeMs;
|
|
59
|
+
if (m > latest)
|
|
60
|
+
latest = m;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// If the directory can't be read, just skip self-update.
|
|
65
|
+
}
|
|
66
|
+
return latest;
|
|
67
|
+
}
|
|
68
|
+
/** Heartbeat interval while planning or executing a command session. */
|
|
69
|
+
const HEARTBEAT_MS = 60_000;
|
|
70
|
+
/** Last-resort clamp for a single unbreakable line. */
|
|
71
|
+
const HARD_CLAMP = 4096;
|
|
72
|
+
function log(msg) {
|
|
73
|
+
process.stderr.write(`[worker] ${msg}\n`);
|
|
74
|
+
}
|
|
75
|
+
/** Sanitize a project label to a short, single-line token. */
|
|
76
|
+
function cleanProject(raw) {
|
|
77
|
+
const s = String(raw ?? "").trim().replace(/\s+/g, " ");
|
|
78
|
+
return s === "" ? "default" : s.slice(0, 48);
|
|
79
|
+
}
|
|
80
|
+
function sendJson(res, status, body) {
|
|
81
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
82
|
+
res.end(JSON.stringify(body));
|
|
83
|
+
}
|
|
84
|
+
async function readJson(req) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
let raw = "";
|
|
87
|
+
req.on("data", (chunk) => {
|
|
88
|
+
raw += chunk;
|
|
89
|
+
if (raw.length > 1_000_000)
|
|
90
|
+
reject(new Error("Body too large"));
|
|
91
|
+
});
|
|
92
|
+
req.on("end", () => {
|
|
93
|
+
if (raw.trim() === "")
|
|
94
|
+
return resolve({});
|
|
95
|
+
try {
|
|
96
|
+
resolve(JSON.parse(raw));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
reject(new Error("Invalid JSON body"));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
req.on("error", reject);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function isReplyToQuestion(msg, store) {
|
|
106
|
+
if (!msg.quotedMessageId || store.pendingCount() === 0)
|
|
107
|
+
return false;
|
|
108
|
+
const pendings = store.listPending();
|
|
109
|
+
return pendings.some((q) => q.sentMessageId && q.sentMessageId === msg.quotedMessageId);
|
|
110
|
+
}
|
|
111
|
+
function isCommandLike(segments) {
|
|
112
|
+
return segments.some((s) => s.kind === "ask" ||
|
|
113
|
+
s.kind === "plan" ||
|
|
114
|
+
s.kind === "ask_empty" ||
|
|
115
|
+
s.kind === "plan_empty" ||
|
|
116
|
+
s.kind === "status" ||
|
|
117
|
+
s.kind === "approve" ||
|
|
118
|
+
s.kind === "reject" ||
|
|
119
|
+
s.kind === "approval_footer");
|
|
120
|
+
}
|
|
121
|
+
async function main() {
|
|
122
|
+
// Make sure the Cursor CLI (`cursor-agent`, used by command mode) is findable
|
|
123
|
+
// even when launched by a GUI/launchd context with a minimal PATH.
|
|
124
|
+
const localBin = join(homedir(), ".local", "bin");
|
|
125
|
+
const pathParts = (process.env.PATH ?? "").split(delimiter);
|
|
126
|
+
if (!pathParts.includes(localBin)) {
|
|
127
|
+
process.env.PATH = `${localBin}${delimiter}${process.env.PATH ?? ""}`;
|
|
128
|
+
}
|
|
129
|
+
const config = getConfig();
|
|
130
|
+
if (config.chatId === "") {
|
|
131
|
+
log("TELEGRAM_CHAT_ID is not set. Run `npm run login` to find it, then set it in .env.");
|
|
132
|
+
}
|
|
133
|
+
const store = createStore();
|
|
134
|
+
const taskQueue = createTaskQueue();
|
|
135
|
+
const runner = createCommandRunner({
|
|
136
|
+
apiKey: config.cursorApiKey,
|
|
137
|
+
cwd: config.agentCwd,
|
|
138
|
+
model: config.agentModel,
|
|
139
|
+
settingSources: config.agentLoadSettings ? ["all"] : [],
|
|
140
|
+
});
|
|
141
|
+
const commandMode = runner.enabled;
|
|
142
|
+
log(commandMode
|
|
143
|
+
? `Command mode ON (model ${config.agentModel}, cwd ${config.agentCwd}). /ask, /plan, or plain text to plan.`
|
|
144
|
+
: "Command mode OFF (set CURSOR_API_KEY to text tasks to the bot).");
|
|
145
|
+
const client = new TelegramClient({
|
|
146
|
+
botToken: config.botToken,
|
|
147
|
+
logger: createStderrLogger("warn"),
|
|
148
|
+
onReady: () => log(`Telegram connected as @${client.username() ?? "?"}. Target chat: ${config.chatId}`),
|
|
149
|
+
});
|
|
150
|
+
/** Recent mirror dedupe: question hash -> timestamp. */
|
|
151
|
+
const recentMirrors = new Map();
|
|
152
|
+
const MIRROR_DEDUPE_MS = 5 * 60_000;
|
|
153
|
+
/** Photo-only message waiting for a caption. */
|
|
154
|
+
let pendingPhotoAttachments;
|
|
155
|
+
let lastSendAt = 0;
|
|
156
|
+
async function waitSendGap() {
|
|
157
|
+
const elapsed = Date.now() - lastSendAt;
|
|
158
|
+
if (elapsed < config.minSendGapMs) {
|
|
159
|
+
await new Promise((r) => setTimeout(r, config.minSendGapMs - elapsed));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function send(text) {
|
|
163
|
+
const plain = toPlainTelegram(text);
|
|
164
|
+
const chunks = splitMessage(plain);
|
|
165
|
+
let firstId;
|
|
166
|
+
try {
|
|
167
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
168
|
+
let chunk = chunks[i];
|
|
169
|
+
if (chunk.length > HARD_CLAMP) {
|
|
170
|
+
chunk = chunk.slice(0, HARD_CLAMP - 20) + "\n\n[...truncated]";
|
|
171
|
+
}
|
|
172
|
+
if (chunks.length > 1) {
|
|
173
|
+
chunk = `(${i + 1}/${chunks.length})\n${chunk}`;
|
|
174
|
+
}
|
|
175
|
+
if (i > 0)
|
|
176
|
+
await waitSendGap();
|
|
177
|
+
const id = await client.sendText(config.chatId, chunk);
|
|
178
|
+
lastSendAt = Date.now();
|
|
179
|
+
if (i === 0)
|
|
180
|
+
firstId = id;
|
|
181
|
+
}
|
|
182
|
+
return firstId;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
log(`Failed to send Telegram message: ${String(err)}`);
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function isWorkerBusy() {
|
|
190
|
+
return runner.latestActiveSession() != null;
|
|
191
|
+
}
|
|
192
|
+
async function runApproved(id) {
|
|
193
|
+
log(`Approved ${id}; executing...`);
|
|
194
|
+
await send(`Working on ${id}...`);
|
|
195
|
+
let heartbeat;
|
|
196
|
+
const clearHeartbeat = () => {
|
|
197
|
+
if (heartbeat) {
|
|
198
|
+
clearInterval(heartbeat);
|
|
199
|
+
heartbeat = undefined;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
const session = await runner.approve(id, (s) => {
|
|
204
|
+
heartbeat = setInterval(() => {
|
|
205
|
+
void send(`Still working on ${s.id}...`);
|
|
206
|
+
}, HEARTBEAT_MS);
|
|
207
|
+
heartbeat.unref?.();
|
|
208
|
+
});
|
|
209
|
+
log(`Execution ${id} -> ${session.status}`);
|
|
210
|
+
if (session.status === "done") {
|
|
211
|
+
await send(`Done (${id})\n\n${session.result ?? ""}`);
|
|
212
|
+
}
|
|
213
|
+
else if (session.status === "error") {
|
|
214
|
+
await send(`Command ${id} failed:\n${session.error ?? "unknown error"}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
clearHeartbeat();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function formatStatus() {
|
|
222
|
+
const lines = [];
|
|
223
|
+
lines.push(`Command mode: ${commandMode ? "ON" : "OFF"}`);
|
|
224
|
+
const pending = store.listPending();
|
|
225
|
+
if (pending.length === 0) {
|
|
226
|
+
lines.push("Pending questions: none");
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
lines.push(`Pending questions: ${pending.length} (${pending.map((q) => q.id).join(", ")})`);
|
|
230
|
+
}
|
|
231
|
+
const awaiting = runner.listAwaitingApproval();
|
|
232
|
+
if (awaiting.length === 0) {
|
|
233
|
+
lines.push("Awaiting approval: none");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
lines.push(`Awaiting approval: ${awaiting.map((s) => s.id).join(", ")}`);
|
|
237
|
+
}
|
|
238
|
+
const active = runner.latestActiveSession();
|
|
239
|
+
if (active) {
|
|
240
|
+
lines.push(`Active: ${active.status} ${active.id}`);
|
|
241
|
+
}
|
|
242
|
+
const activeAsks = runner.listActiveAsks();
|
|
243
|
+
if (activeAsks.length > 0) {
|
|
244
|
+
lines.push(`Active asks: ${activeAsks.map((s) => s.id).join(", ")}`);
|
|
245
|
+
}
|
|
246
|
+
lines.push(`Queued tasks: ${taskQueue.preview()}`);
|
|
247
|
+
if (commandMode) {
|
|
248
|
+
lines.push("Commands: /ask question, /plan task, plain text plans too");
|
|
249
|
+
}
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|
|
252
|
+
async function runPlanTask(task, attachments) {
|
|
253
|
+
log(`New task received; planning...`);
|
|
254
|
+
await send(`Planning your request...`);
|
|
255
|
+
let heartbeat;
|
|
256
|
+
const clearHeartbeat = () => {
|
|
257
|
+
if (heartbeat) {
|
|
258
|
+
clearInterval(heartbeat);
|
|
259
|
+
heartbeat = undefined;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
let session;
|
|
263
|
+
try {
|
|
264
|
+
session = await runner.plan(task, (s) => {
|
|
265
|
+
heartbeat = setInterval(() => {
|
|
266
|
+
void send(`Still planning ${s.id}...`);
|
|
267
|
+
}, HEARTBEAT_MS);
|
|
268
|
+
heartbeat.unref?.();
|
|
269
|
+
}, attachments);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
clearHeartbeat();
|
|
273
|
+
const errText = String(err);
|
|
274
|
+
log(`Plan error: ${errText}`);
|
|
275
|
+
await send(`Planning failed:\n${errText}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
clearHeartbeat();
|
|
279
|
+
log(`Plan ${session.id} -> ${session.status}`);
|
|
280
|
+
if (session.status === "awaiting_approval") {
|
|
281
|
+
await send(`Plan (${session.id})\n\n${session.plan ?? ""}\n\nReply YES to run it or NO to cancel.`);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
await send(`Could not plan that (${session.id}):\n${session.error ?? "unknown error"}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function runAskQuestion(question, attachments) {
|
|
288
|
+
log(`Ask received; answering...`);
|
|
289
|
+
await send(`Answering...`);
|
|
290
|
+
let heartbeat;
|
|
291
|
+
const clearHeartbeat = () => {
|
|
292
|
+
if (heartbeat) {
|
|
293
|
+
clearInterval(heartbeat);
|
|
294
|
+
heartbeat = undefined;
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
let session;
|
|
298
|
+
try {
|
|
299
|
+
session = await runner.ask(question, (s) => {
|
|
300
|
+
heartbeat = setInterval(() => {
|
|
301
|
+
void send(`Still answering ${s.id}...`);
|
|
302
|
+
}, HEARTBEAT_MS);
|
|
303
|
+
heartbeat.unref?.();
|
|
304
|
+
}, attachments);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
clearHeartbeat();
|
|
308
|
+
const errText = String(err);
|
|
309
|
+
log(`Ask error: ${errText}`);
|
|
310
|
+
await send(`Could not answer:\n${errText}`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
clearHeartbeat();
|
|
314
|
+
log(`Ask ${session.id} -> ${session.status}`);
|
|
315
|
+
if (session.status === "done") {
|
|
316
|
+
await send(`Ask (${session.id})\n\n${session.answer ?? ""}`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
await send(`Could not answer (${session.id}):\n${session.error ?? "unknown error"}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function processQueueItem(item) {
|
|
323
|
+
if (item.kind === "ask") {
|
|
324
|
+
await runAskQuestion(item.text, item.attachments);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
await runPlanTask(item.text, item.attachments);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
let draining = false;
|
|
331
|
+
async function drainQueue() {
|
|
332
|
+
if (draining)
|
|
333
|
+
return;
|
|
334
|
+
draining = true;
|
|
335
|
+
try {
|
|
336
|
+
while (!isWorkerBusy()) {
|
|
337
|
+
const next = taskQueue.dequeue();
|
|
338
|
+
if (!next)
|
|
339
|
+
break;
|
|
340
|
+
log(`Dequeuing ${next.kind}: ${next.text.slice(0, 60)}...`);
|
|
341
|
+
await send(`Starting queued ${next.kind}...`);
|
|
342
|
+
await processQueueItem(next);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
draining = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function enqueueOrRun(item, run) {
|
|
350
|
+
if (isWorkerBusy()) {
|
|
351
|
+
const pos = taskQueue.enqueue(item);
|
|
352
|
+
await send(`Queued (#${pos}): ${item.kind} — will run when current work finishes.`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
await run();
|
|
356
|
+
}
|
|
357
|
+
async function handleSegment(seg, attachments) {
|
|
358
|
+
switch (seg.kind) {
|
|
359
|
+
case "status":
|
|
360
|
+
await send(formatStatus());
|
|
361
|
+
return;
|
|
362
|
+
case "approve": {
|
|
363
|
+
if (!commandMode)
|
|
364
|
+
return;
|
|
365
|
+
const awaiting = runner.latestAwaitingApproval();
|
|
366
|
+
if (!awaiting) {
|
|
367
|
+
await send("No plan awaiting approval.");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (isWorkerBusy()) {
|
|
371
|
+
await send(`BUSY: ${runner.latestActiveSession()?.status} ${runner.latestActiveSession()?.id}. Wait for it to finish.`);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
await send(`Approved ${awaiting.id}.`);
|
|
375
|
+
await runApproved(awaiting.id);
|
|
376
|
+
await drainQueue();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
case "reject": {
|
|
380
|
+
if (!commandMode)
|
|
381
|
+
return;
|
|
382
|
+
const awaiting = runner.latestAwaitingApproval();
|
|
383
|
+
if (!awaiting) {
|
|
384
|
+
await send("No plan awaiting approval.");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
runner.reject(awaiting.id);
|
|
388
|
+
await send(`Cancelled ${awaiting.id}.`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case "approval_footer": {
|
|
392
|
+
if (!commandMode)
|
|
393
|
+
return;
|
|
394
|
+
const awaiting = runner.latestAwaitingApproval();
|
|
395
|
+
if (!awaiting) {
|
|
396
|
+
await send("No plan awaiting approval.");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
await send(`Plan ${awaiting.id} is awaiting approval. Reply YES to run it or NO to cancel.`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
case "ask_empty":
|
|
403
|
+
await send("Usage: /ask your question here");
|
|
404
|
+
return;
|
|
405
|
+
case "plan_empty":
|
|
406
|
+
await send("Usage: /plan your task here");
|
|
407
|
+
return;
|
|
408
|
+
case "ask":
|
|
409
|
+
if (!commandMode)
|
|
410
|
+
return;
|
|
411
|
+
await enqueueOrRun({ kind: "ask", text: seg.text, attachments: attachments.length > 0 ? attachments : undefined }, async () => {
|
|
412
|
+
await runAskQuestion(seg.text, attachments.length > 0 ? attachments : undefined);
|
|
413
|
+
await drainQueue();
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
case "plan":
|
|
417
|
+
if (!commandMode)
|
|
418
|
+
return;
|
|
419
|
+
await enqueueOrRun({ kind: "plan", text: seg.text, attachments: attachments.length > 0 ? attachments : undefined }, async () => {
|
|
420
|
+
await runPlanTask(seg.text, attachments.length > 0 ? attachments : undefined);
|
|
421
|
+
await drainQueue();
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
case "plain":
|
|
425
|
+
if (!commandMode)
|
|
426
|
+
return;
|
|
427
|
+
await enqueueOrRun({ kind: "plan", text: seg.text, attachments: attachments.length > 0 ? attachments : undefined }, async () => {
|
|
428
|
+
await runPlanTask(seg.text, attachments.length > 0 ? attachments : undefined);
|
|
429
|
+
await drainQueue();
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
let incomingChain = Promise.resolve();
|
|
435
|
+
async function handleIncoming(msg) {
|
|
436
|
+
if (msg.photoOnly && msg.attachments.length > 0) {
|
|
437
|
+
pendingPhotoAttachments = msg.attachments;
|
|
438
|
+
await send("Photo received. Send a caption or /ask / /plan command with your message.");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
let attachments = msg.attachments;
|
|
442
|
+
if (pendingPhotoAttachments && pendingPhotoAttachments.length > 0) {
|
|
443
|
+
attachments = [...pendingPhotoAttachments, ...attachments];
|
|
444
|
+
pendingPhotoAttachments = undefined;
|
|
445
|
+
}
|
|
446
|
+
const text = msg.text.trim();
|
|
447
|
+
if (text === "" && attachments.length === 0)
|
|
448
|
+
return;
|
|
449
|
+
if (isReplyToQuestion(msg, store)) {
|
|
450
|
+
const matched = store.matchAndAnswer(msg);
|
|
451
|
+
if (matched) {
|
|
452
|
+
log(`Recorded answer for ${matched.id} [${matched.projectLabel}].`);
|
|
453
|
+
notifyAnswered(matched.id);
|
|
454
|
+
await send(`Answer recorded for ${matched.id}.`);
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (store.pendingCount() > 0 && !isCommandLike(splitInboundMessage(text))) {
|
|
459
|
+
const matched = store.matchAndAnswer(msg);
|
|
460
|
+
if (matched) {
|
|
461
|
+
log(`Recorded answer for ${matched.id} [${matched.projectLabel}].`);
|
|
462
|
+
notifyAnswered(matched.id);
|
|
463
|
+
await send(`Answer recorded for ${matched.id}.`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const segments = splitInboundMessage(text);
|
|
468
|
+
if (segments.length === 0 && attachments.length === 0)
|
|
469
|
+
return;
|
|
470
|
+
if (segments.length === 0 && attachments.length > 0) {
|
|
471
|
+
await send("Send a caption or command with your photo.");
|
|
472
|
+
pendingPhotoAttachments = attachments;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
for (const seg of segments) {
|
|
476
|
+
await handleSegment(seg, attachments);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
client.onIncoming((msg) => {
|
|
480
|
+
if (!TelegramClient.sameChat(msg.fromChatId, config.chatId))
|
|
481
|
+
return;
|
|
482
|
+
incomingChain = incomingChain
|
|
483
|
+
.then(() => handleIncoming(msg))
|
|
484
|
+
.catch((err) => log(`handleIncoming error: ${String(err)}`));
|
|
485
|
+
});
|
|
486
|
+
function rateGuard() {
|
|
487
|
+
const elapsed = Date.now() - lastSendAt;
|
|
488
|
+
return elapsed < config.minSendGapMs
|
|
489
|
+
? { ok: false, waitMs: config.minSendGapMs - elapsed }
|
|
490
|
+
: { ok: true, waitMs: 0 };
|
|
491
|
+
}
|
|
492
|
+
function dedupeMirrorKey(project, question) {
|
|
493
|
+
return `${project}::${question.slice(0, 200)}`;
|
|
494
|
+
}
|
|
495
|
+
async function mirrorQuestion(project, question) {
|
|
496
|
+
const key = dedupeMirrorKey(project, question);
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
const last = recentMirrors.get(key);
|
|
499
|
+
if (last != null && now - last < MIRROR_DEDUPE_MS) {
|
|
500
|
+
const existing = store.listPending().find((q) => q.question === question && q.projectLabel === project);
|
|
501
|
+
return { id: existing?.id ?? "deduped", mirrored: false };
|
|
502
|
+
}
|
|
503
|
+
recentMirrors.set(key, now);
|
|
504
|
+
const record = store.addQuestion(project, question);
|
|
505
|
+
const text = `[${project}] ${record.id}\n${question}\n\nReply to this message to answer.`;
|
|
506
|
+
const sentId = await send(text);
|
|
507
|
+
if (sentId)
|
|
508
|
+
store.setSentMessageId(record.id, sentId);
|
|
509
|
+
return { id: record.id, mirrored: true };
|
|
510
|
+
}
|
|
511
|
+
const server = createServer(async (req, res) => {
|
|
512
|
+
try {
|
|
513
|
+
const url = new URL(req.url ?? "/", `http://${config.workerHost}`);
|
|
514
|
+
const path = url.pathname;
|
|
515
|
+
const method = req.method ?? "GET";
|
|
516
|
+
if (method === "GET" && path === "/health") {
|
|
517
|
+
return sendJson(res, 200, {
|
|
518
|
+
ok: true,
|
|
519
|
+
connected: client.isOpen(),
|
|
520
|
+
target: config.chatId,
|
|
521
|
+
pending: store.pendingCount(),
|
|
522
|
+
commandMode,
|
|
523
|
+
queue: taskQueue.length(),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (method === "POST" && path === "/notify") {
|
|
527
|
+
if (!client.isOpen())
|
|
528
|
+
return sendJson(res, 503, { error: "Telegram not connected" });
|
|
529
|
+
if (config.chatId === "")
|
|
530
|
+
return sendJson(res, 503, { error: "TELEGRAM_CHAT_ID not set" });
|
|
531
|
+
const guard = rateGuard();
|
|
532
|
+
if (!guard.ok)
|
|
533
|
+
return sendJson(res, 429, { error: "rate_limited", waitMs: guard.waitMs });
|
|
534
|
+
const body = await readJson(req);
|
|
535
|
+
const summary = String(body.summary ?? "").trim();
|
|
536
|
+
const project = cleanProject(body.project);
|
|
537
|
+
if (!summary)
|
|
538
|
+
return sendJson(res, 400, { error: "summary is required" });
|
|
539
|
+
await send(`[${project}] Task complete\n\n${summary}`);
|
|
540
|
+
return sendJson(res, 200, { ok: true });
|
|
541
|
+
}
|
|
542
|
+
if (method === "POST" && (path === "/ask" || path === "/mirror")) {
|
|
543
|
+
if (!client.isOpen())
|
|
544
|
+
return sendJson(res, 503, { error: "Telegram not connected" });
|
|
545
|
+
if (config.chatId === "")
|
|
546
|
+
return sendJson(res, 503, { error: "TELEGRAM_CHAT_ID not set" });
|
|
547
|
+
const guard = rateGuard();
|
|
548
|
+
if (!guard.ok)
|
|
549
|
+
return sendJson(res, 429, { error: "rate_limited", waitMs: guard.waitMs });
|
|
550
|
+
const body = await readJson(req);
|
|
551
|
+
const question = String(body.question ?? "").trim();
|
|
552
|
+
const project = cleanProject(body.project);
|
|
553
|
+
if (!question)
|
|
554
|
+
return sendJson(res, 400, { error: "question is required" });
|
|
555
|
+
const { id, mirrored } = await mirrorQuestion(project, question);
|
|
556
|
+
return sendJson(res, 200, { id, mirrored: path === "/mirror" ? mirrored : true });
|
|
557
|
+
}
|
|
558
|
+
if (method === "GET" && path.startsWith("/response/")) {
|
|
559
|
+
const id = decodeURIComponent(path.slice("/response/".length));
|
|
560
|
+
const waitMs = Math.max(0, Number(url.searchParams.get("waitMs") ?? 0));
|
|
561
|
+
function responsePayload(record) {
|
|
562
|
+
const elapsedMin = Math.floor((Date.now() - record.createdAt) / 60000);
|
|
563
|
+
if (record.status === "answered") {
|
|
564
|
+
const attachments = record.answerAttachments?.map((a) => ({
|
|
565
|
+
mimeType: a.mimeType,
|
|
566
|
+
localPath: a.localPath,
|
|
567
|
+
sizeBytes: a.sizeBytes,
|
|
568
|
+
})) ?? [];
|
|
569
|
+
return {
|
|
570
|
+
status: 200,
|
|
571
|
+
body: {
|
|
572
|
+
id: record.id,
|
|
573
|
+
status: "answered",
|
|
574
|
+
answer: record.answer ?? "",
|
|
575
|
+
attachments,
|
|
576
|
+
elapsedMin,
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
if (elapsedMin >= config.responseTimeoutMin) {
|
|
581
|
+
return { status: 200, body: { id: record.id, status: "timed_out", elapsedMin } };
|
|
582
|
+
}
|
|
583
|
+
return { status: 200, body: { id: record.id, status: "pending", elapsedMin } };
|
|
584
|
+
}
|
|
585
|
+
let record = store.get(id);
|
|
586
|
+
if (!record)
|
|
587
|
+
return sendJson(res, 404, { error: "unknown questionId", id });
|
|
588
|
+
let payload = responsePayload(record);
|
|
589
|
+
if (payload.body.status === "pending" && waitMs > 0) {
|
|
590
|
+
const { promise, cancel } = waitForAnswer(id, waitMs);
|
|
591
|
+
req.on("close", cancel);
|
|
592
|
+
await promise;
|
|
593
|
+
record = store.get(id);
|
|
594
|
+
if (!record)
|
|
595
|
+
return sendJson(res, 404, { error: "unknown questionId", id });
|
|
596
|
+
payload = responsePayload(record);
|
|
597
|
+
}
|
|
598
|
+
return sendJson(res, payload.status, payload.body);
|
|
599
|
+
}
|
|
600
|
+
if (method === "POST" &&
|
|
601
|
+
path === "/_test/answer" &&
|
|
602
|
+
process.env.TG_ALLOW_TEST_ROUTES === "1") {
|
|
603
|
+
const body = await readJson(req);
|
|
604
|
+
const testId = String(body.id ?? "");
|
|
605
|
+
const testAnswer = String(body.answer ?? "test");
|
|
606
|
+
const testRecord = store.get(testId);
|
|
607
|
+
if (!testRecord || testRecord.status !== "pending") {
|
|
608
|
+
return sendJson(res, 404, { error: "not pending", id: testId });
|
|
609
|
+
}
|
|
610
|
+
testRecord.status = "answered";
|
|
611
|
+
testRecord.answer = testAnswer;
|
|
612
|
+
testRecord.answeredAt = Date.now();
|
|
613
|
+
notifyAnswered(testId);
|
|
614
|
+
return sendJson(res, 200, { ok: true });
|
|
615
|
+
}
|
|
616
|
+
return sendJson(res, 404, { error: "not found" });
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
return sendJson(res, 500, { error: String(err) });
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
server.on("error", (err) => {
|
|
623
|
+
if (err.code === "EADDRINUSE") {
|
|
624
|
+
log(`worker already running (port ${config.workerPort} in use on ${config.workerHost})`);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
log(`HTTP server error: ${String(err)}`);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
});
|
|
630
|
+
server.listen(config.workerPort, config.workerHost, () => {
|
|
631
|
+
log(`HTTP API listening on http://${config.workerHost}:${config.workerPort}`);
|
|
632
|
+
});
|
|
633
|
+
const sweep = setInterval(() => runner.sweepStale(COMMAND_TTL_MS), 10 * 60_000);
|
|
634
|
+
sweep.unref?.();
|
|
635
|
+
await client.connect().catch((err) => log(`Telegram connection error: ${String(err)}`));
|
|
636
|
+
const shutdown = () => {
|
|
637
|
+
log("Shutting down...");
|
|
638
|
+
server.close();
|
|
639
|
+
client.close().finally(() => process.exit(0));
|
|
640
|
+
};
|
|
641
|
+
process.on("SIGINT", shutdown);
|
|
642
|
+
process.on("SIGTERM", shutdown);
|
|
643
|
+
// Self-update: if a task edits the worker's own source, relaunch on the new
|
|
644
|
+
// code once nothing is in flight. Only effective under a KeepAlive supervisor
|
|
645
|
+
// (launchd `npm run worker:install`), which restarts the process after exit.
|
|
646
|
+
const startFingerprint = sourceFingerprint();
|
|
647
|
+
function isIdleForRestart() {
|
|
648
|
+
return (!isWorkerBusy() &&
|
|
649
|
+
!draining &&
|
|
650
|
+
taskQueue.length() === 0 &&
|
|
651
|
+
runner.listAwaitingApproval().length === 0 &&
|
|
652
|
+
runner.listActiveAsks().length === 0 &&
|
|
653
|
+
store.pendingCount() === 0);
|
|
654
|
+
}
|
|
655
|
+
const updateCheck = setInterval(() => {
|
|
656
|
+
if (sourceFingerprint() > startFingerprint && isIdleForRestart()) {
|
|
657
|
+
log("Source changed and worker idle; restarting to load the latest version...");
|
|
658
|
+
clearInterval(updateCheck);
|
|
659
|
+
shutdown();
|
|
660
|
+
}
|
|
661
|
+
}, UPDATE_CHECK_MS);
|
|
662
|
+
updateCheck.unref?.();
|
|
663
|
+
}
|
|
664
|
+
main().catch((err) => {
|
|
665
|
+
log(`Fatal: ${err?.stack ?? err}`);
|
|
666
|
+
process.exit(1);
|
|
667
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Add this 'telegram' block to a project's .cursor/mcp.json (or Cursor Settings -> MCP). Run `npx cursor-telegram-mcp setup` once first to configure your bot. The MCP auto-starts a shared local worker. Optionally set TG_PROJECT to label this project's messages.",
|
|
3
|
+
"mcpServers": {
|
|
4
|
+
"telegram": {
|
|
5
|
+
"command": "npx",
|
|
6
|
+
"args": ["-y", "cursor-telegram-mcp"],
|
|
7
|
+
"env": {
|
|
8
|
+
"TG_PROJECT": "my-project-label"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|