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/index.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor <-> Telegram MCP server (stdio) - thin client.
|
|
3
|
+
*
|
|
4
|
+
* Holds NO Telegram connection. It forwards each tool call to the local worker
|
|
5
|
+
* (`npm run worker`) over a localhost HTTP API, tagging messages with this
|
|
6
|
+
* project's label (TG_PROJECT). Cursor can restart/reload this server freely
|
|
7
|
+
* without dropping the worker's Telegram connection.
|
|
8
|
+
*
|
|
9
|
+
* Tools:
|
|
10
|
+
* - notify_human_task_complete : fire-and-forget completion message.
|
|
11
|
+
* - ask_human_for_guidance : send a question, return immediately with an id.
|
|
12
|
+
* - ask_human_and_wait : send a question and block until answered/timed_out.
|
|
13
|
+
* - check_human_response : poll for the human's reply by id.
|
|
14
|
+
*
|
|
15
|
+
* stdout is reserved for JSON-RPC; diagnostics go to stderr.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, openSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
22
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { configDir, getConfig } from "./config.js";
|
|
25
|
+
function logErr(msg) {
|
|
26
|
+
process.stderr.write(`[telegram-mcp] ${msg}\n`);
|
|
27
|
+
}
|
|
28
|
+
function textResult(text, isError = false) {
|
|
29
|
+
return { content: [{ type: "text", text }], isError };
|
|
30
|
+
}
|
|
31
|
+
const WORKER_DOWN = "Cannot reach the Telegram worker. It should auto-start with this MCP server; " +
|
|
32
|
+
"if it doesn't, run `cursor-telegram-mcp setup` to configure your bot " +
|
|
33
|
+
"(TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID), then reload. `cursor-telegram-mcp doctor` " +
|
|
34
|
+
"diagnoses problems.";
|
|
35
|
+
async function main() {
|
|
36
|
+
const config = getConfig(false);
|
|
37
|
+
const base = config.workerUrl;
|
|
38
|
+
async function callWorker(method, path, body) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${base}${path}`, {
|
|
41
|
+
method,
|
|
42
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
43
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
44
|
+
});
|
|
45
|
+
let parsed = {};
|
|
46
|
+
try {
|
|
47
|
+
parsed = (await res.json());
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
parsed = {};
|
|
51
|
+
}
|
|
52
|
+
return { status: res.status, body: parsed };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null; // worker unreachable
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a worker is running. If `/health` is unreachable, spawn a detached,
|
|
60
|
+
* background worker (a singleton: the worker's own port guard makes concurrent
|
|
61
|
+
* MCP instances safe) and poll until it is ready. In dev (running .ts via tsx)
|
|
62
|
+
* the compiled worker entry won't exist, so this is a no-op fallback and the
|
|
63
|
+
* worker is expected to be started manually.
|
|
64
|
+
*/
|
|
65
|
+
async function ensureWorker() {
|
|
66
|
+
if (await callWorker("GET", "/health"))
|
|
67
|
+
return;
|
|
68
|
+
const workerEntry = fileURLToPath(new URL("./worker.js", import.meta.url));
|
|
69
|
+
if (!existsSync(workerEntry)) {
|
|
70
|
+
logErr(`Worker not auto-started (entry not found: ${workerEntry}). Start it manually.`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(configDir(), { recursive: true });
|
|
75
|
+
const logPath = join(configDir(), "worker.log");
|
|
76
|
+
const logFd = openSync(logPath, "a");
|
|
77
|
+
const child = spawn(process.execPath, [workerEntry], {
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: ["ignore", logFd, logFd],
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
env: process.env,
|
|
82
|
+
});
|
|
83
|
+
child.unref();
|
|
84
|
+
logErr(`Auto-started worker (pid ${child.pid ?? "?"}); logs: ${logPath}`);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
logErr(`Could not auto-start worker: ${String(err)}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
for (let i = 0; i < 30; i++) {
|
|
91
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
92
|
+
if (await callWorker("GET", "/health")) {
|
|
93
|
+
logErr("Worker is up.");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
logErr("Worker did not become ready in time; tools will retry on use.");
|
|
98
|
+
}
|
|
99
|
+
async function pollResponse(questionId, waitMs = 0) {
|
|
100
|
+
const qs = waitMs > 0 ? `?waitMs=${waitMs}` : "";
|
|
101
|
+
return callWorker("GET", `/response/${encodeURIComponent(questionId)}${qs}`);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Long-poll the worker until the question is answered/timed_out or `maxWaitMs`
|
|
105
|
+
* elapses. Issues repeated long-poll requests in chunks of `config.defaultPollWaitMs`
|
|
106
|
+
* so a single fetch never sits open long enough to hit the client's headers
|
|
107
|
+
* timeout. Returns the last worker response (answered/timed_out, or pending if
|
|
108
|
+
* the budget runs out). Non-200 responses (e.g. 404) are returned immediately.
|
|
109
|
+
*/
|
|
110
|
+
async function waitForHumanResponse(questionId, maxWaitMs, initialPollWaitMs = 0) {
|
|
111
|
+
const startedAt = Date.now();
|
|
112
|
+
let res = await pollResponse(questionId, initialPollWaitMs);
|
|
113
|
+
while (true) {
|
|
114
|
+
if (!res || res.status !== 200)
|
|
115
|
+
return res;
|
|
116
|
+
const status = String(res.body.status ?? "");
|
|
117
|
+
if (status === "answered" || status === "timed_out")
|
|
118
|
+
return res;
|
|
119
|
+
const remaining = maxWaitMs - (Date.now() - startedAt);
|
|
120
|
+
if (remaining <= 0)
|
|
121
|
+
return res;
|
|
122
|
+
res = await pollResponse(questionId, Math.min(config.defaultPollWaitMs, remaining));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function responseCheckError(questionId, res) {
|
|
126
|
+
if (res.status === 404) {
|
|
127
|
+
return textResult(`Unknown questionId "${questionId}". It may be from a previous session or mistyped.`, true);
|
|
128
|
+
}
|
|
129
|
+
if (res.status !== 200) {
|
|
130
|
+
return textResult(`Failed to check (status ${res.status}): ${String(res.body.error ?? "unknown")}`, true);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
function formatResponseResult(questionId, body) {
|
|
135
|
+
const status = String(body.status ?? "");
|
|
136
|
+
const elapsedMin = Number(body.elapsedMin ?? 0);
|
|
137
|
+
if (status === "answered") {
|
|
138
|
+
const answer = String(body.answer ?? "");
|
|
139
|
+
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
140
|
+
let text = `status: answered\nquestionId: ${questionId}\n\nHuman's answer:\n${answer}`;
|
|
141
|
+
if (attachments.length > 0) {
|
|
142
|
+
const paths = attachments
|
|
143
|
+
.map((a) => (typeof a === "object" && a && "localPath" in a ? String(a.localPath) : ""))
|
|
144
|
+
.filter(Boolean);
|
|
145
|
+
if (paths.length > 0) {
|
|
146
|
+
text += `\n\nAttachments (${paths.length}):\n${paths.join("\n")}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { text, isError: false };
|
|
150
|
+
}
|
|
151
|
+
if (status === "timed_out") {
|
|
152
|
+
return {
|
|
153
|
+
text: `status: timed_out\nquestionId: ${questionId}\nNo reply after ${elapsedMin} minutes.\n` +
|
|
154
|
+
"Guidance: choose a safe default, or leave a TODO and continue, rather than waiting indefinitely.",
|
|
155
|
+
isError: false,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
text: `status: pending\nquestionId: ${questionId}\nElapsed ${elapsedMin} min. No reply yet. ` +
|
|
160
|
+
"Call check_human_response again with waitMs (e.g. 60000) to long-poll, " +
|
|
161
|
+
"or pass waitMs=0 for an instant status check.",
|
|
162
|
+
isError: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const server = new McpServer({ name: "cursor-telegram", version: "0.5.0" });
|
|
166
|
+
server.registerTool("notify_human_task_complete", {
|
|
167
|
+
title: "Notify human: task complete",
|
|
168
|
+
description: "Send the human a Telegram message reporting that the current task is " +
|
|
169
|
+
"finished. Use a concise summary of what was done. Fire-and-forget: " +
|
|
170
|
+
"returns immediately and does not wait for a reply.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
summary: z
|
|
173
|
+
.string()
|
|
174
|
+
.min(1)
|
|
175
|
+
.describe("Short summary of the completed work (what changed / result)."),
|
|
176
|
+
},
|
|
177
|
+
}, async ({ summary }) => {
|
|
178
|
+
const r = await callWorker("POST", "/notify", { summary, project: config.project });
|
|
179
|
+
if (!r)
|
|
180
|
+
return textResult(WORKER_DOWN, true);
|
|
181
|
+
if (r.status === 200)
|
|
182
|
+
return textResult("Completion message sent to the human via Telegram.");
|
|
183
|
+
if (r.status === 503)
|
|
184
|
+
return textResult("Telegram is not connected in the worker (check TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID and the worker terminal).", true);
|
|
185
|
+
if (r.status === 429) {
|
|
186
|
+
const waitMs = Number(r.body.waitMs ?? 0);
|
|
187
|
+
return textResult(`Rate limited: wait ${Math.ceil(waitMs / 1000)}s before sending another message.`, true);
|
|
188
|
+
}
|
|
189
|
+
return textResult(`Failed to send (status ${r.status}): ${String(r.body.error ?? "unknown")}`, true);
|
|
190
|
+
});
|
|
191
|
+
server.registerTool("ask_human_for_guidance", {
|
|
192
|
+
title: "Ask human for guidance",
|
|
193
|
+
description: "Send the human a question over Telegram and return immediately with a " +
|
|
194
|
+
"question id. Use this when you can do other work while waiting. To get " +
|
|
195
|
+
"the reply, call check_human_response with the returned id and waitMs " +
|
|
196
|
+
"(e.g. 60000) to long-poll until answered — the worker picks up the " +
|
|
197
|
+
"Telegram reply promptly and the tool returns right away (do NOT sleep " +
|
|
198
|
+
"on a fixed timer between checks). If the answer gates your next step, " +
|
|
199
|
+
"prefer ask_human_and_wait instead.",
|
|
200
|
+
inputSchema: {
|
|
201
|
+
question: z
|
|
202
|
+
.string()
|
|
203
|
+
.min(1)
|
|
204
|
+
.describe("The exact question to ask the human. Be specific and include the " +
|
|
205
|
+
"options/context needed to answer from a phone."),
|
|
206
|
+
},
|
|
207
|
+
}, async ({ question }) => {
|
|
208
|
+
const r = await callWorker("POST", "/ask", { question, project: config.project });
|
|
209
|
+
if (!r)
|
|
210
|
+
return textResult(WORKER_DOWN, true);
|
|
211
|
+
if (r.status === 200) {
|
|
212
|
+
const id = String(r.body.id ?? "");
|
|
213
|
+
return textResult(`Question sent to the human (id: ${id}).\n` +
|
|
214
|
+
`To get the reply, call check_human_response with questionId="${id}" and ` +
|
|
215
|
+
"waitMs (e.g. 60000) to long-poll until answered — returns the moment " +
|
|
216
|
+
"the human replies (do NOT sleep on a fixed timer between checks). Use " +
|
|
217
|
+
"ask_human_and_wait instead when the answer gates your next step.");
|
|
218
|
+
}
|
|
219
|
+
if (r.status === 503)
|
|
220
|
+
return textResult("Telegram is not connected in the worker (check TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID and the worker terminal).", true);
|
|
221
|
+
if (r.status === 429) {
|
|
222
|
+
const waitMs = Number(r.body.waitMs ?? 0);
|
|
223
|
+
return textResult(`Rate limited: wait ${Math.ceil(waitMs / 1000)}s before asking another question.`, true);
|
|
224
|
+
}
|
|
225
|
+
return textResult(`Failed to ask (status ${r.status}): ${String(r.body.error ?? "unknown")}`, true);
|
|
226
|
+
});
|
|
227
|
+
server.registerTool("ask_human_and_wait", {
|
|
228
|
+
title: "Ask human and wait for answer",
|
|
229
|
+
description: "Send the human a question over Telegram and block until they answer or " +
|
|
230
|
+
"the question times out. Prefer this over ask_human_for_guidance when you " +
|
|
231
|
+
"must have the human's reply before continuing. Long-polls the worker HTTP " +
|
|
232
|
+
"API and returns immediately when the human replies on Telegram (wakes on " +
|
|
233
|
+
"answer, not on the worker's getUpdates long-poll timeout).",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
question: z
|
|
236
|
+
.string()
|
|
237
|
+
.min(1)
|
|
238
|
+
.describe("The exact question to ask the human. Be specific and include the " +
|
|
239
|
+
"options/context needed to answer from a phone."),
|
|
240
|
+
timeoutMin: z
|
|
241
|
+
.number()
|
|
242
|
+
.int()
|
|
243
|
+
.positive()
|
|
244
|
+
.optional()
|
|
245
|
+
.describe("Maximum minutes to wait for a reply (default: TG_RESPONSE_TIMEOUT_MIN, " +
|
|
246
|
+
"usually 30). Returns timed_out if no reply within this window."),
|
|
247
|
+
},
|
|
248
|
+
}, async ({ question, timeoutMin }) => {
|
|
249
|
+
const maxWaitMin = timeoutMin ?? config.responseTimeoutMin;
|
|
250
|
+
const askRes = await callWorker("POST", "/ask", { question, project: config.project });
|
|
251
|
+
if (!askRes)
|
|
252
|
+
return textResult(WORKER_DOWN, true);
|
|
253
|
+
if (askRes.status === 503) {
|
|
254
|
+
return textResult("Telegram is not connected in the worker (check TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID and the worker terminal).", true);
|
|
255
|
+
}
|
|
256
|
+
if (askRes.status === 429) {
|
|
257
|
+
const waitMs = Number(askRes.body.waitMs ?? 0);
|
|
258
|
+
return textResult(`Rate limited: wait ${Math.ceil(waitMs / 1000)}s before asking another question.`, true);
|
|
259
|
+
}
|
|
260
|
+
if (askRes.status !== 200) {
|
|
261
|
+
return textResult(`Failed to ask (status ${askRes.status}): ${String(askRes.body.error ?? "unknown")}`, true);
|
|
262
|
+
}
|
|
263
|
+
const questionId = String(askRes.body.id ?? "");
|
|
264
|
+
const maxWaitMs = maxWaitMin * 60_000;
|
|
265
|
+
const pollRes = await waitForHumanResponse(questionId, maxWaitMs);
|
|
266
|
+
if (!pollRes)
|
|
267
|
+
return textResult(WORKER_DOWN, true);
|
|
268
|
+
const err = responseCheckError(questionId, pollRes);
|
|
269
|
+
if (err)
|
|
270
|
+
return err;
|
|
271
|
+
const { text, isError } = formatResponseResult(questionId, pollRes.body);
|
|
272
|
+
return textResult(text, isError);
|
|
273
|
+
});
|
|
274
|
+
server.registerTool("check_human_response", {
|
|
275
|
+
title: "Check human response",
|
|
276
|
+
description: "Poll for the human's reply to a previously asked question. Returns the " +
|
|
277
|
+
"answer if it has arrived, or a pending/timed-out status. RECOMMENDED: " +
|
|
278
|
+
"pass waitMs (e.g. 60000) to long-poll until answered or timed_out — the " +
|
|
279
|
+
"worker delivers Telegram replies promptly and this tool wakes immediately " +
|
|
280
|
+
"(do NOT sleep on a fixed timer between checks). Omit waitMs to long-poll " +
|
|
281
|
+
"until timed_out; pass waitMs=0 for an instant status-only check. Prefer " +
|
|
282
|
+
"ask_human_and_wait when you must block until the human replies.",
|
|
283
|
+
inputSchema: {
|
|
284
|
+
questionId: z
|
|
285
|
+
.string()
|
|
286
|
+
.min(1)
|
|
287
|
+
.describe('The question id returned by ask_human_for_guidance (e.g. "Q-1").'),
|
|
288
|
+
waitMs: z
|
|
289
|
+
.number()
|
|
290
|
+
.int()
|
|
291
|
+
.nonnegative()
|
|
292
|
+
.optional()
|
|
293
|
+
.describe("Long-poll budget in milliseconds (recommended, e.g. 60000). The tool " +
|
|
294
|
+
"returns immediately when the human replies. Omit to long-poll until " +
|
|
295
|
+
"timed_out; pass 0 for an immediate status-only check."),
|
|
296
|
+
},
|
|
297
|
+
}, async ({ questionId, waitMs }) => {
|
|
298
|
+
if (waitMs === 0) {
|
|
299
|
+
const r = await pollResponse(questionId, 0);
|
|
300
|
+
if (!r)
|
|
301
|
+
return textResult(WORKER_DOWN, true);
|
|
302
|
+
const err = responseCheckError(questionId, r);
|
|
303
|
+
if (err)
|
|
304
|
+
return err;
|
|
305
|
+
const { text, isError } = formatResponseResult(questionId, r.body);
|
|
306
|
+
return textResult(text, isError);
|
|
307
|
+
}
|
|
308
|
+
const maxWaitMs = waitMs ?? config.responseTimeoutMin * 60_000;
|
|
309
|
+
const r = await waitForHumanResponse(questionId, maxWaitMs);
|
|
310
|
+
if (!r)
|
|
311
|
+
return textResult(WORKER_DOWN, true);
|
|
312
|
+
const err = responseCheckError(questionId, r);
|
|
313
|
+
if (err)
|
|
314
|
+
return err;
|
|
315
|
+
const { text, isError } = formatResponseResult(questionId, r.body);
|
|
316
|
+
return textResult(text, isError);
|
|
317
|
+
});
|
|
318
|
+
const transport = new StdioServerTransport();
|
|
319
|
+
await server.connect(transport);
|
|
320
|
+
logErr(`MCP server started (stdio). Worker: ${base} project: ${config.project}`);
|
|
321
|
+
// Auto-start the worker if it isn't already running, then report status.
|
|
322
|
+
await ensureWorker();
|
|
323
|
+
const health = await callWorker("GET", "/health");
|
|
324
|
+
if (!health)
|
|
325
|
+
logErr(WORKER_DOWN);
|
|
326
|
+
else if (!health.body.connected)
|
|
327
|
+
logErr("Worker is up but Telegram is not connected yet.");
|
|
328
|
+
else
|
|
329
|
+
logErr("Worker reachable and Telegram connected.");
|
|
330
|
+
}
|
|
331
|
+
main().catch((err) => {
|
|
332
|
+
logErr(`Fatal: ${err?.stack ?? err}`);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
});
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find your Telegram chat id and save it to the user config file.
|
|
3
|
+
*
|
|
4
|
+
* Requires TELEGRAM_BOT_TOKEN to be set (via `setup`, the config file, or env):
|
|
5
|
+
* 1. validates the token (getMe) and prints the bot's @username, then
|
|
6
|
+
* 2. long-polls for a message. Open your bot in Telegram and send it any
|
|
7
|
+
* message (e.g. /start). The chat id is saved to config.json.
|
|
8
|
+
*
|
|
9
|
+
* Run this only while the worker is stopped (both use getUpdates).
|
|
10
|
+
*/
|
|
11
|
+
import { configFilePath, getConfig, writeFileConfig } from "./config.js";
|
|
12
|
+
import { GETUPDATES_LONG_POLL_SEC } from "./telegram.js";
|
|
13
|
+
const log = (msg) => process.stderr.write(msg + "\n");
|
|
14
|
+
async function call(base, method, params) {
|
|
15
|
+
const res = await fetch(`${base}/${method}`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
body: JSON.stringify(params ?? {}),
|
|
19
|
+
});
|
|
20
|
+
const data = (await res.json());
|
|
21
|
+
if (!data.ok) {
|
|
22
|
+
throw new Error(`Telegram ${method} failed: ${data.description ?? `HTTP ${res.status}`}`);
|
|
23
|
+
}
|
|
24
|
+
return data.result;
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
const config = getConfig(); // requires TELEGRAM_BOT_TOKEN
|
|
28
|
+
const base = `https://api.telegram.org/bot${config.botToken}`;
|
|
29
|
+
const me = await call(base, "getMe");
|
|
30
|
+
log(`Bot validated: @${me.username ?? me.id}`);
|
|
31
|
+
log("Now open your bot in Telegram and send it a message (e.g. /start).");
|
|
32
|
+
log("Waiting for an incoming message...\n");
|
|
33
|
+
let offset = 0;
|
|
34
|
+
for (;;) {
|
|
35
|
+
const updates = await call(base, "getUpdates", {
|
|
36
|
+
offset,
|
|
37
|
+
timeout: GETUPDATES_LONG_POLL_SEC,
|
|
38
|
+
allowed_updates: ["message"],
|
|
39
|
+
});
|
|
40
|
+
for (const update of updates) {
|
|
41
|
+
offset = update.update_id + 1;
|
|
42
|
+
const chat = update.message?.chat;
|
|
43
|
+
if (!chat)
|
|
44
|
+
continue;
|
|
45
|
+
const who = update.message?.from?.username ?? update.message?.from?.first_name ?? "unknown";
|
|
46
|
+
log("=== Found your chat ===");
|
|
47
|
+
log(`From: ${who}`);
|
|
48
|
+
log(`Chat id: ${chat.id}`);
|
|
49
|
+
writeFileConfig({ TELEGRAM_CHAT_ID: String(chat.id) });
|
|
50
|
+
log(`\nSaved TELEGRAM_CHAT_ID to ${configFilePath()}`);
|
|
51
|
+
log("Reload the MCP in Cursor (the worker auto-starts), or run the worker directly.");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
main().catch((err) => {
|
|
57
|
+
process.stderr.write(`Setup failed: ${err?.message ?? err}\n`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split compound Telegram inbound messages into ordered action segments.
|
|
3
|
+
*
|
|
4
|
+
* Handles leading YES/NO with trailing content, multi-line messages, and
|
|
5
|
+
* /ask or /plan on any line. Reply-to HITL answers should bypass splitting
|
|
6
|
+
* (caller passes the full text as a single answer).
|
|
7
|
+
*/
|
|
8
|
+
/** Standard plan-approval footer sent after Plan (C-n). */
|
|
9
|
+
const APPROVAL_FOOTER_RE = /^\s*reply\s+yes\s+to\s+run\b[\s\S]*\bno\s+to\s+cancel\.?\s*$/i;
|
|
10
|
+
/** True when text is (or ends with) the plan YES/NO footer — not a new task. */
|
|
11
|
+
export function isPlanApprovalFooter(text) {
|
|
12
|
+
const t = text.trim();
|
|
13
|
+
if (t === "")
|
|
14
|
+
return false;
|
|
15
|
+
if (APPROVAL_FOOTER_RE.test(t))
|
|
16
|
+
return true;
|
|
17
|
+
const lines = t.split(/\n/).map((l) => l.trim()).filter(Boolean);
|
|
18
|
+
const last = lines[lines.length - 1] ?? "";
|
|
19
|
+
return APPROVAL_FOOTER_RE.test(last);
|
|
20
|
+
}
|
|
21
|
+
const STATUS_RE = /^\s*status\s*$/i;
|
|
22
|
+
const YES_WORDS = "yes|yea|yeah|y|approve|approved|ok|okay|go|do it|כן|אישור|בצע";
|
|
23
|
+
const NO_WORDS = "no|n|cancel|stop|reject|nope|לא|ביטול|עצור";
|
|
24
|
+
const YES_ONLY_RE = new RegExp(`^\\s*(${YES_WORDS})\\s*[!.]?\\s*$`, "i");
|
|
25
|
+
const NO_ONLY_RE = new RegExp(`^\\s*(${NO_WORDS})\\s*[!.]?\\s*$`, "i");
|
|
26
|
+
const YES_LEADING_RE = new RegExp(`^\\s*(${YES_WORDS})\\b\\s*,?\\s*also\\b\\s*(.*)$`, "is");
|
|
27
|
+
const NO_LEADING_RE = new RegExp(`^\\s*(${NO_WORDS})\\b\\s*,?\\s*also\\b\\s*(.*)$`, "is");
|
|
28
|
+
/** Split body on newlines and ", also" / "Also," boundaries. */
|
|
29
|
+
function splitParts(text) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (const line of text.split(/\n/)) {
|
|
32
|
+
const chunks = line.split(/\s*,\s*also\s*,?\s*|\s+also\s*:\s*/i);
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
const trimmed = chunk.trim();
|
|
35
|
+
if (trimmed !== "")
|
|
36
|
+
parts.push(trimmed);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return parts;
|
|
40
|
+
}
|
|
41
|
+
function parseCommandLine(part) {
|
|
42
|
+
const m = part.match(/^\s*\/(ask|plan)(?:\s+(.*))?$/is);
|
|
43
|
+
if (!m)
|
|
44
|
+
return null;
|
|
45
|
+
const mode = m[1].toLowerCase();
|
|
46
|
+
const rest = (m[2] ?? "").trim();
|
|
47
|
+
if (mode === "ask") {
|
|
48
|
+
return rest === "" ? { kind: "ask_empty" } : { kind: "ask", text: rest };
|
|
49
|
+
}
|
|
50
|
+
return rest === "" ? { kind: "plan_empty" } : { kind: "plan", text: rest };
|
|
51
|
+
}
|
|
52
|
+
function classifyPart(part) {
|
|
53
|
+
if (STATUS_RE.test(part))
|
|
54
|
+
return [{ kind: "status" }];
|
|
55
|
+
if (YES_ONLY_RE.test(part))
|
|
56
|
+
return [{ kind: "approve" }];
|
|
57
|
+
if (NO_ONLY_RE.test(part))
|
|
58
|
+
return [{ kind: "reject" }];
|
|
59
|
+
const yesLead = part.match(YES_LEADING_RE);
|
|
60
|
+
if (yesLead) {
|
|
61
|
+
const rest = (yesLead[2] ?? "").trim();
|
|
62
|
+
if (rest === "")
|
|
63
|
+
return [{ kind: "approve" }];
|
|
64
|
+
return [{ kind: "approve" }, ...splitInboundMessage(rest)];
|
|
65
|
+
}
|
|
66
|
+
const noLead = part.match(NO_LEADING_RE);
|
|
67
|
+
if (noLead) {
|
|
68
|
+
const rest = (noLead[2] ?? "").trim();
|
|
69
|
+
if (rest === "")
|
|
70
|
+
return [{ kind: "reject" }];
|
|
71
|
+
return [{ kind: "reject" }, ...splitInboundMessage(rest)];
|
|
72
|
+
}
|
|
73
|
+
const cmd = parseCommandLine(part);
|
|
74
|
+
if (cmd)
|
|
75
|
+
return [cmd];
|
|
76
|
+
if (isPlanApprovalFooter(part))
|
|
77
|
+
return [{ kind: "approval_footer" }];
|
|
78
|
+
return [{ kind: "plain", text: part }];
|
|
79
|
+
}
|
|
80
|
+
/** Parse inbound text into ordered segments (approve/reject first per part). */
|
|
81
|
+
export function splitInboundMessage(text) {
|
|
82
|
+
const trimmed = text.trim();
|
|
83
|
+
if (trimmed === "")
|
|
84
|
+
return [];
|
|
85
|
+
if (isPlanApprovalFooter(trimmed)) {
|
|
86
|
+
return [{ kind: "approval_footer" }];
|
|
87
|
+
}
|
|
88
|
+
const segments = [];
|
|
89
|
+
for (const part of splitParts(trimmed)) {
|
|
90
|
+
segments.push(...classifyPart(part));
|
|
91
|
+
}
|
|
92
|
+
return segments;
|
|
93
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist the "rolling" command-mode conversation across worker restarts.
|
|
3
|
+
*
|
|
4
|
+
* Command mode keeps ONE long-lived Cursor agent so knowledge carries across
|
|
5
|
+
* every phone message. The agent's id is written here so that, after the worker
|
|
6
|
+
* restarts (manually or via the self-update logic in worker.ts), the next
|
|
7
|
+
* message resumes the same conversation instead of starting fresh.
|
|
8
|
+
*
|
|
9
|
+
* State lives in a small JSON file (default `<configDir>/rolling-session.json`).
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
/** Read the rolling session file. Returns undefined if missing/unreadable. */
|
|
14
|
+
export function loadRollingSession(path) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = readFileSync(path, "utf8");
|
|
17
|
+
const parsed = JSON.parse(raw);
|
|
18
|
+
if (typeof parsed.agentId === "string" &&
|
|
19
|
+
parsed.agentId.trim() !== "" &&
|
|
20
|
+
typeof parsed.model === "string" &&
|
|
21
|
+
typeof parsed.cwd === "string" &&
|
|
22
|
+
typeof parsed.startedAt === "number") {
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Write the rolling session file (creating the parent dir if needed). */
|
|
32
|
+
export function saveRollingSession(path, session) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
35
|
+
writeFileSync(path, JSON.stringify(session, null, 2) + "\n", "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Best-effort: losing the file just means the next restart starts fresh.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Remove the rolling session file (used when starting a new thread). */
|
|
42
|
+
export function clearRollingSession(path) {
|
|
43
|
+
try {
|
|
44
|
+
rmSync(path, { force: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Best-effort.
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive first-time setup (bring-your-own-bot).
|
|
3
|
+
*
|
|
4
|
+
* Walks the user through:
|
|
5
|
+
* 1. Creating a bot with @BotFather and pasting the token (validated).
|
|
6
|
+
* 2. Capturing their chat id by messaging the bot.
|
|
7
|
+
* 3. Optionally enabling command mode with a Cursor API key.
|
|
8
|
+
* Writes everything to the user config file and prints the mcp.json snippet.
|
|
9
|
+
*/
|
|
10
|
+
import { createInterface } from "node:readline/promises";
|
|
11
|
+
import { stdin, stdout } from "node:process";
|
|
12
|
+
import { configFilePath, readFileConfig, writeFileConfig } from "./config.js";
|
|
13
|
+
async function tg(token, method, params) {
|
|
14
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify(params ?? {}),
|
|
18
|
+
});
|
|
19
|
+
const data = (await res.json());
|
|
20
|
+
if (!data.ok)
|
|
21
|
+
throw new Error(data.description ?? `HTTP ${res.status}`);
|
|
22
|
+
return data.result;
|
|
23
|
+
}
|
|
24
|
+
function out(msg = "") {
|
|
25
|
+
stdout.write(msg + "\n");
|
|
26
|
+
}
|
|
27
|
+
async function main() {
|
|
28
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
29
|
+
try {
|
|
30
|
+
const existing = readFileConfig();
|
|
31
|
+
out("cursor-telegram-mcp setup");
|
|
32
|
+
out("=========================");
|
|
33
|
+
out("");
|
|
34
|
+
out("Step 1: Create your Telegram bot (free, ~2 min).");
|
|
35
|
+
out(" - Open Telegram and message @BotFather: https://t.me/BotFather");
|
|
36
|
+
out(" - Send /newbot, choose a name and a username ending in 'bot'.");
|
|
37
|
+
out(" - BotFather replies with a token like 123456789:ABC-DEF...");
|
|
38
|
+
out("");
|
|
39
|
+
// Token (validate via getMe).
|
|
40
|
+
let token = existing.TELEGRAM_BOT_TOKEN ?? "";
|
|
41
|
+
let username;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const def = token ? ` [${token.slice(0, 6)}...]` : "";
|
|
44
|
+
const answer = (await rl.question(`Paste your bot token${def}: `)).trim();
|
|
45
|
+
const candidate = answer || token;
|
|
46
|
+
if (!candidate) {
|
|
47
|
+
out("A token is required. (Ctrl-C to abort.)");
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const me = await tg(candidate, "getMe");
|
|
52
|
+
username = me.username;
|
|
53
|
+
token = candidate;
|
|
54
|
+
out(`Validated bot: @${username ?? me.id}`);
|
|
55
|
+
writeFileConfig({ TELEGRAM_BOT_TOKEN: token });
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
out(`That token did not validate (${String(err)}). Try again.`);
|
|
60
|
+
token = "";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
out("");
|
|
64
|
+
out("Step 2: Capture your chat id.");
|
|
65
|
+
out(` - Open your bot${username ? ` (https://t.me/${username})` : ""} in Telegram.`);
|
|
66
|
+
out(" - Press Start, or send it any message (e.g. 'hi').");
|
|
67
|
+
await rl.question("Press Enter once you've sent your bot a message... ");
|
|
68
|
+
out("Looking for your message...");
|
|
69
|
+
let chatId = "";
|
|
70
|
+
let offset = 0;
|
|
71
|
+
const deadline = Date.now() + 120_000;
|
|
72
|
+
while (Date.now() < deadline && !chatId) {
|
|
73
|
+
const updates = await tg(token, "getUpdates", { offset, timeout: 20, allowed_updates: ["message"] });
|
|
74
|
+
for (const u of updates) {
|
|
75
|
+
offset = u.update_id + 1;
|
|
76
|
+
const chat = u.message?.chat;
|
|
77
|
+
if (chat) {
|
|
78
|
+
chatId = String(chat.id);
|
|
79
|
+
const who = u.message?.from?.username ?? u.message?.from?.first_name ?? "you";
|
|
80
|
+
out(`Found chat id ${chatId} (from ${who}).`);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!chatId) {
|
|
86
|
+
out("No message detected. Re-run `cursor-telegram-mcp setup` (or `login`) and message your bot.");
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
writeFileConfig({ TELEGRAM_CHAT_ID: chatId });
|
|
91
|
+
out("");
|
|
92
|
+
out("Step 3 (optional): Command mode lets you TEXT tasks to the bot and have a");
|
|
93
|
+
out("headless Cursor agent plan + execute them. It needs a Cursor API key");
|
|
94
|
+
out("(https://cursor.com/dashboard/integrations) and the Cursor CLI installed.");
|
|
95
|
+
const key = (await rl.question("Cursor API key (Enter to skip): ")).trim();
|
|
96
|
+
if (key) {
|
|
97
|
+
writeFileConfig({ CURSOR_API_KEY: key });
|
|
98
|
+
out("Command mode enabled. Run `cursor-telegram-mcp doctor` to verify the Cursor CLI.");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
out("Skipped. Notifications and questions still work; enable later by re-running setup.");
|
|
102
|
+
}
|
|
103
|
+
out("");
|
|
104
|
+
out("Done. Config saved to:");
|
|
105
|
+
out(` ${configFilePath()}`);
|
|
106
|
+
out("");
|
|
107
|
+
out("Add this to your project's .cursor/mcp.json (or Cursor Settings -> MCP):");
|
|
108
|
+
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(' }');
|
|
117
|
+
out("");
|
|
118
|
+
out("Then reload MCP in Cursor. The worker auto-starts with the MCP server.");
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
rl.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
stdout.write(`Setup failed: ${err?.message ?? err}\n`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|