@thxmxx/telegram-mcp 1.2.0 → 1.2.1

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +129 -23
  3. package/src/setup.js +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thxmxx/telegram-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Telegram bridge for Claude Code — notify, ask and choose via your phone",
5
5
  "type": "module",
6
6
  "repository": {
package/src/index.js CHANGED
@@ -6,9 +6,8 @@
6
6
  * telegram_notify — send a message (fire and forget)
7
7
  * telegram_ask — ask a question, wait for text reply
8
8
  * telegram_choose — show buttons, wait for a tap
9
- *
10
- * Instance label is auto-generated from cwd + ppid.
11
- * No per-project configuration needed.
9
+ * telegram_listen — wait for user to address this instance by name,
10
+ * returns the next instruction so Claude can keep working
12
11
  */
13
12
 
14
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -36,35 +35,36 @@ if (existsSync(envPath)) {
36
35
  }
37
36
  }
38
37
 
39
- const TOKEN = env.TELEGRAM_BOT_TOKEN;
38
+ const TOKEN = env.TELEGRAM_BOT_TOKEN;
40
39
  const CHAT_ID = env.TELEGRAM_CHAT_ID;
41
40
 
42
41
  if (!TOKEN || !CHAT_ID) {
43
42
  process.stderr.write(
44
43
  "[telegram-mcp] Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID.\n" +
45
- " Run: npx @thxmxx/telegram-mcp init\n"
44
+ " Run: npx @thxmxx/telegram-mcp init\n",
46
45
  );
47
46
  exit(1);
48
47
  }
49
48
 
50
49
  // ── Auto instance label: folder#shortid ───────────────────────────────────────
51
50
 
52
- const folder = process.cwd().split("/").pop() || "claude";
53
- const shortId = randomBytes(2).toString("hex"); // e.g. "a3f2"
54
- const INSTANCE = `${folder}#${shortId}`;
55
- const HDR = `\`[${INSTANCE}]\``;
51
+ const folder = process.cwd().split("/").pop() || "claude";
52
+ const shortId = randomBytes(2).toString("hex");
53
+ const INSTANCE = `${folder}#${shortId}`;
54
+ const HDR = `\`[${INSTANCE}]\``;
56
55
 
57
56
  // ── Telegram client ───────────────────────────────────────────────────────────
58
57
 
59
58
  const bot = new TelegramBot(TOKEN, { polling: true });
60
59
 
60
+ // ── Helpers ───────────────────────────────────────────────────────────────────
61
+
61
62
  function waitForReply(timeoutMs = 300_000) {
62
63
  return new Promise((resolve, reject) => {
63
64
  const timer = setTimeout(() => {
64
65
  bot.removeListener("message", handler);
65
66
  reject(new Error("Timed out (5 min)"));
66
67
  }, timeoutMs);
67
-
68
68
  function handler(msg) {
69
69
  if (String(msg.chat.id) !== String(CHAT_ID)) return;
70
70
  if (msg.text?.startsWith("/")) return;
@@ -82,7 +82,6 @@ function waitForCallback(timeoutMs = 300_000) {
82
82
  bot.removeListener("callback_query", handler);
83
83
  reject(new Error("Timed out (5 min)"));
84
84
  }, timeoutMs);
85
-
86
85
  function handler(query) {
87
86
  if (String(query.from.id) !== String(CHAT_ID)) return;
88
87
  clearTimeout(timer);
@@ -94,11 +93,54 @@ function waitForCallback(timeoutMs = 300_000) {
94
93
  });
95
94
  }
96
95
 
96
+ /**
97
+ * Wait for a message addressed to THIS instance.
98
+ * Format: "@instance-label <instruction>"
99
+ * e.g. "@backend#a3f2 now refactor the auth module"
100
+ *
101
+ * Ignores messages addressed to other instances silently.
102
+ * Times out after `timeoutMs` (default: 1 hour).
103
+ */
104
+ function waitForAddressedMessage(timeoutMs = 3_600_000) {
105
+ const mention = `@${INSTANCE}`.toLowerCase();
106
+
107
+ return new Promise((resolve, reject) => {
108
+ const timer = setTimeout(() => {
109
+ bot.removeListener("message", handler);
110
+ reject(new Error(`No message addressed to ${INSTANCE} within timeout`));
111
+ }, timeoutMs);
112
+
113
+ function handler(msg) {
114
+ if (String(msg.chat.id) !== String(CHAT_ID)) return;
115
+ if (!msg.text) return;
116
+
117
+ const text = msg.text.trim();
118
+ const lower = text.toLowerCase();
119
+
120
+ // Message must start with @instance-label
121
+ if (!lower.startsWith(mention)) return;
122
+
123
+ // Extract the instruction after the mention
124
+ const instruction = text.slice(mention.length).trim();
125
+ if (!instruction) return;
126
+
127
+ clearTimeout(timer);
128
+ bot.removeListener("message", handler);
129
+ resolve(instruction);
130
+ }
131
+
132
+ bot.on("message", handler);
133
+ });
134
+ }
135
+
97
136
  function terminalPrompt(question) {
98
137
  return new Promise((resolve) => {
99
138
  const rl = createInterface({ input, output, terminal: true });
100
139
  process.stderr.write(`\n[${INSTANCE}] ${question}\n> `);
101
- rl.once("line", (line) => { rl.close(); resolve(line.trim()); });
140
+ rl.once("line", (line) => {
141
+ rl.close();
142
+ resolve(line.trim());
143
+ });
102
144
  });
103
145
  }
104
146
 
@@ -113,7 +155,9 @@ function raceCallback(question, options) {
113
155
  const numbered = options.map((o, i) => ` ${i + 1}. ${o}`).join("\n");
114
156
  return Promise.race([
115
157
  waitForCallback(),
116
- terminalPrompt(`${question}\n${numbered}\nChoose (1-${options.length})`).then((v) => {
158
+ terminalPrompt(
159
+ `${question}\n${numbered}\nChoose (1-${options.length})`,
160
+ ).then((v) => {
117
161
  const idx = parseInt(v, 10) - 1;
118
162
  return { source: "terminal", value: options[idx] ?? v };
119
163
  }),
@@ -124,52 +168,111 @@ function raceCallback(question, options) {
124
168
 
125
169
  const server = new McpServer({ name: "telegram-mcp", version: "1.0.0" });
126
170
 
171
+ // ── telegram_notify ───────────────────────────────────────────────────────────
172
+
127
173
  server.tool(
128
174
  "telegram_notify",
129
175
  "Send a Telegram notification to the user. Use for progress updates and task completions. Does NOT wait for a reply.",
130
176
  { message: z.string().describe("The message to send") },
131
177
  async ({ message }) => {
132
- await bot.sendMessage(CHAT_ID, `${HDR} ${message}`, { parse_mode: "Markdown" });
178
+ await bot.sendMessage(CHAT_ID, `${HDR} ${message}`, {
179
+ parse_mode: "Markdown",
180
+ });
133
181
  process.stderr.write(`[${INSTANCE}] notify: ${message}\n`);
134
182
  return { content: [{ type: "text", text: "Sent." }] };
135
- }
183
+ },
136
184
  );
137
185
 
186
+ // ── telegram_ask ──────────────────────────────────────────────────────────────
187
+
138
188
  server.tool(
139
189
  "telegram_ask",
140
- "Ask the user a free-form question via Telegram and wait for their reply. The same question is shown on the terminal — whoever answers first wins.",
190
+ "Ask the user a free-form question via Telegram and wait for their reply. The same question appears on the terminal — whoever answers first wins.",
141
191
  { question: z.string().describe("The question to ask") },
142
192
  async ({ question }) => {
143
- await bot.sendMessage(CHAT_ID, `${HDR} ❓ ${question}`, { parse_mode: "Markdown" });
193
+ await bot.sendMessage(CHAT_ID, `${HDR} ❓ ${question}`, {
194
+ parse_mode: "Markdown",
195
+ });
144
196
  const { source, value } = await raceReply(question);
145
197
  if (source === "terminal") {
146
- await bot.sendMessage(CHAT_ID, `${HDR} ✅ Answered from terminal: *${value}*`, { parse_mode: "Markdown" });
198
+ await bot.sendMessage(
199
+ CHAT_ID,
200
+ `${HDR} ✅ Answered from terminal: *${value}*`,
201
+ { parse_mode: "Markdown" },
202
+ );
147
203
  }
148
204
  process.stderr.write(`[${INSTANCE}] ask (${source}): ${value}\n`);
149
205
  return { content: [{ type: "text", text: value }] };
150
- }
206
+ },
151
207
  );
152
208
 
209
+ // ── telegram_choose ───────────────────────────────────────────────────────────
210
+
153
211
  server.tool(
154
212
  "telegram_choose",
155
213
  "Ask the user to pick one option. Shows inline buttons on Telegram and a numbered list on the terminal. Whoever responds first wins.",
156
214
  {
157
215
  question: z.string().describe("The question or prompt"),
158
- options: z.array(z.string()).min(2).max(10).describe("Options to present (2–10)"),
216
+ options: z
217
+ .array(z.string())
218
+ .min(2)
219
+ .max(10)
220
+ .describe("Options to present (2–10)"),
159
221
  },
160
222
  async ({ question, options }) => {
161
223
  const keyboard = {
162
- inline_keyboard: options.map((opt) => [{ text: opt, callback_data: opt }]),
224
+ inline_keyboard: options.map((opt) => [
225
+ { text: opt, callback_data: opt },
226
+ ]),
163
227
  };
164
228
  await bot.sendMessage(CHAT_ID, `${HDR} 🔘 ${question}`, {
165
229
  parse_mode: "Markdown",
166
230
  reply_markup: keyboard,
167
231
  });
168
232
  const { source, value } = await raceCallback(question, options);
169
- await bot.sendMessage(CHAT_ID, `${HDR} ✅ *${value}* _(via ${source})_`, { parse_mode: "Markdown" });
233
+ await bot.sendMessage(CHAT_ID, `${HDR} ✅ *${value}* _(via ${source})_`, {
234
+ parse_mode: "Markdown",
235
+ });
170
236
  process.stderr.write(`[${INSTANCE}] choose (${source}): ${value}\n`);
171
237
  return { content: [{ type: "text", text: value }] };
172
- }
238
+ },
239
+ );
240
+
241
+ // ── telegram_listen ───────────────────────────────────────────────────────────
242
+
243
+ server.tool(
244
+ "telegram_listen",
245
+ `Wait for the user to send a new instruction addressed to this instance on Telegram.
246
+ Call this after completing a task to stay available for follow-up work.
247
+ The user must address messages as: @${INSTANCE} <instruction>
248
+ Returns the instruction text when received. Times out after 1 hour of inactivity.
249
+ When this tool returns, execute the instruction and call telegram_listen again when done.`,
250
+ {},
251
+ async () => {
252
+ await bot.sendMessage(
253
+ CHAT_ID,
254
+ `${HDR} ✅ Task complete — waiting for your next instruction.\n` +
255
+ `_Address me as_ \`@${INSTANCE} <your instruction>\``,
256
+ { parse_mode: "Markdown" },
257
+ );
258
+
259
+ process.stderr.write(`[${INSTANCE}] listening for @${INSTANCE} ...\n`);
260
+
261
+ try {
262
+ const instruction = await waitForAddressedMessage();
263
+ process.stderr.write(`[${INSTANCE}] received: ${instruction}\n`);
264
+ return { content: [{ type: "text", text: instruction }] };
265
+ } catch (err) {
266
+ await bot.sendMessage(
267
+ CHAT_ID,
268
+ `${HDR} 💤 Timed out after 1 hour of inactivity.`,
269
+ {
270
+ parse_mode: "Markdown",
271
+ },
272
+ );
273
+ return { content: [{ type: "text", text: `timeout: ${err.message}` }] };
274
+ }
275
+ },
173
276
  );
174
277
 
175
278
  // ── Start ─────────────────────────────────────────────────────────────────────
@@ -177,3 +280,6 @@ server.tool(
177
280
  const transport = new StdioServerTransport();
178
281
  await server.connect(transport);
179
282
  process.stderr.write(`[telegram-mcp] Ready — instance: ${INSTANCE}\n`);
283
+ process.stderr.write(
284
+ `[telegram-mcp] Address messages as: @${INSTANCE} <instruction>\n`,
285
+ );
package/src/setup.js CHANGED
@@ -171,6 +171,17 @@ Examples: \`backend#a3f2\`, \`frontend#9c11\`
171
171
 
172
172
  Use this exact label in every Telegram message for the entire session.
173
173
  Send as first message: \`[label] ✅ Telegram active — ready.\`
174
+ Also tell the user: _Address me as_ \`@[label] <instruction>\` for follow-up tasks.
175
+
176
+ ## Staying alive after a task
177
+
178
+ After completing any task, ALWAYS call telegram_listen (no arguments).
179
+ - It will notify the user the task is done and wait for the next instruction
180
+ - When it returns, execute the new instruction
181
+ - Then call telegram_listen again
182
+ - This keeps the session alive indefinitely until timeout (1 hour of inactivity)
183
+
184
+ Never exit or stop after a task — always loop back to telegram_listen.
174
185
  `,
175
186
  );
176
187