@thxmxx/telegram-mcp 1.2.0 → 1.2.2

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.
@@ -12,7 +12,7 @@ jobs:
12
12
  if: github.event_name == 'push' || github.event.pull_request.merged == true
13
13
  runs-on: ubuntu-latest
14
14
  permissions:
15
- contents: write # needed to push the version bump commit
15
+ contents: write
16
16
  id-token: write
17
17
 
18
18
  steps:
@@ -26,33 +26,44 @@ jobs:
26
26
  node-version: 20
27
27
  registry-url: https://registry.npmjs.org
28
28
 
29
- - name: Install dependencies
30
- run: npm install
31
-
32
- - name: Determine version bump
33
- id: bump
29
+ - name: Get commit message
30
+ id: msg
34
31
  run: |
35
- # Get the commit message that triggered this run
36
32
  if [ "${{ github.event_name }}" = "pull_request" ]; then
37
- MSG="${{ github.event.pull_request.title }}"
33
+ echo "value=${{ github.event.pull_request.title }}" >> $GITHUB_OUTPUT
38
34
  else
39
- MSG="$(git log -1 --pretty=%s)"
35
+ echo "value=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
40
36
  fi
41
37
 
42
- echo "Commit message: $MSG"
38
+ - name: Skip if docs-only
39
+ id: skip
40
+ run: |
41
+ MSG="${{ steps.msg.outputs.value }}"
42
+ if echo "$MSG" | grep -qiE "^docs(\(.+\))?:|^\[skip ci\]|^chore: bump version"; then
43
+ echo "skip=true" >> $GITHUB_OUTPUT
44
+ else
45
+ echo "skip=false" >> $GITHUB_OUTPUT
46
+ fi
43
47
 
44
- # Detect breaking change
48
+ - name: Determine version bump
49
+ id: bump
50
+ if: steps.skip.outputs.skip == 'false'
51
+ run: |
52
+ MSG="${{ steps.msg.outputs.value }}"
45
53
  if echo "$MSG" | grep -qiE "BREAKING CHANGE|!:"; then
46
54
  echo "type=major" >> $GITHUB_OUTPUT
47
- # feat → minor
48
55
  elif echo "$MSG" | grep -qiE "^feat(\(.+\))?:"; then
49
56
  echo "type=minor" >> $GITHUB_OUTPUT
50
- # everything else → patch
51
57
  else
52
58
  echo "type=patch" >> $GITHUB_OUTPUT
53
59
  fi
54
60
 
61
+ - name: Install dependencies
62
+ if: steps.skip.outputs.skip == 'false'
63
+ run: npm install
64
+
55
65
  - name: Bump version
66
+ if: steps.skip.outputs.skip == 'false'
56
67
  run: |
57
68
  git config user.name "github-actions[bot]"
58
69
  git config user.email "github-actions[bot]@users.noreply.github.com"
@@ -63,6 +74,7 @@ jobs:
63
74
  git push
64
75
 
65
76
  - name: Publish
77
+ if: steps.skip.outputs.skip == 'false'
66
78
  run: npm publish --access public --provenance
67
79
  env:
68
80
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md CHANGED
@@ -74,6 +74,14 @@ If you answer from the terminal, Telegram confirms it:
74
74
  | `telegram_ask` | Ask a free-form question. Waits for reply. |
75
75
  | `telegram_choose` | Show option buttons. Waits for a tap. |
76
76
 
77
+ ## Updating
78
+
79
+ ```bash
80
+ npx @thxmxx/telegram-mcp@latest init
81
+ ```
82
+
83
+ Re-runs the setup with the latest version — updates the MCP server and the `/use-telegram` slash command automatically.
84
+
77
85
  ## Requirements
78
86
 
79
87
  - Node.js 18+
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.2",
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