From 9f2a4feab937324f57e0e0096dbfd58600fefbf9 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 10:53:36 -0700 Subject: [PATCH 01/13] telegram: add error handlers to stop silent polling death The bot would silently stop delivering messages after the first error: grammy's default handler calls bot.stop() on any middleware throw, and void bot.start() / void mcp.notification() swallow rejections with no log. - bot.catch(): log and keep polling on handler errors - bot.start().catch(): log when polling dies (bad token, 409, network) - mcp.notification().catch(): log when inbound delivery to Claude fails - process-level unhandledRejection/uncaughtException as a safety net Fixes #756 #759 #761 #777 #809, partial #788 --- external_plugins/telegram/server.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..9786fc8 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -51,6 +51,15 @@ if (!TOKEN) { } const INBOX_DIR = join(STATE_DIR, 'inbox') +// Last-resort safety net — without these the process dies silently on any +// unhandled promise rejection. With them it logs and keeps serving tools. +process.on('unhandledRejection', err => { + process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`) +}) +process.on('uncaughtException', err => { + process.stderr.write(`telegram channel: uncaught exception: ${err}\n`) +}) + const bot = new Bot(TOKEN) let botUsername = '' @@ -577,7 +586,7 @@ async function handleInbound( // image_path goes in meta only — an in-content "[image attached — read: PATH]" // annotation is forgeable by any allowlisted sender typing that string. - void mcp.notification({ + mcp.notification({ method: 'notifications/claude/channel', params: { content: text, @@ -590,12 +599,25 @@ async function handleInbound( ...(imagePath ? { image_path: imagePath } : {}), }, }, + }).catch(err => { + process.stderr.write(`telegram channel: failed to deliver inbound to Claude: ${err}\n`) }) } -void bot.start({ +// Without this, any throw in a message handler stops polling permanently +// (grammy's default error handler calls bot.stop() and rethrows). +bot.catch(err => { + process.stderr.write(`telegram channel: handler error (polling continues): ${err.error}\n`) +}) + +bot.start({ onStart: info => { botUsername = info.username process.stderr.write(`telegram channel: polling as @${info.username}\n`) }, +}).catch(err => { + // bot.start() only rejects if polling can't begin or dies unrecoverably — + // bad token, 409 conflict, network gone. Log it so the user isn't left + // wondering why messages stopped arriving. + process.stderr.write(`telegram channel: polling stopped: ${err}\n`) }) From 2aa90a83876b548c5db2e3ecae22d93088e6e0e5 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 10:54:33 -0700 Subject: [PATCH 02/13] telegram: exit when Claude Code closes the connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the MCP stdio transport closes, the bot kept polling Telegram as a zombie process — holding the token and causing 409 Conflict for the next session. - Listen for stdin end/close and SIGTERM/SIGINT -> bot.stop() + exit - Force-exit after 2s if bot.stop() stalls on the long-poll timeout - unref the approval-check interval so it doesn't keep us alive Fixes #793, partial #788 (issue 3) --- external_plugins/telegram/server.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..c5b0091 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -304,7 +304,7 @@ function checkApprovals(): void { } } -if (!STATIC) setInterval(checkApprovals, 5000) +if (!STATIC) setInterval(checkApprovals, 5000).unref() // Telegram caps messages at 4096 chars. Split long replies, preferring // paragraph boundaries when chunkMode is 'newline'. @@ -507,6 +507,24 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { await mcp.connect(new StdioServerTransport()) +// When Claude Code closes the MCP connection, stdin gets EOF. Without this +// the bot keeps polling forever as a zombie, holding the token and blocking +// the next session with 409 Conflict. +let shuttingDown = false +function shutdown(): void { + if (shuttingDown) return + shuttingDown = true + process.stderr.write('telegram channel: shutting down\n') + // bot.stop() signals the poll loop to end; the current getUpdates request + // may take up to its long-poll timeout to return. Force-exit after 2s. + setTimeout(() => process.exit(0), 2000) + void Promise.resolve(bot.stop()).finally(() => process.exit(0)) +} +process.stdin.on('end', shutdown) +process.stdin.on('close', shutdown) +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) + bot.on('message:text', async ctx => { await handleInbound(ctx, ctx.message.text, undefined) }) From 1daff5f2242e93a31fe734475caba9d19770ec43 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 10:55:27 -0700 Subject: [PATCH 03/13] telegram: retry on 409 Conflict instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During /mcp reload or when a zombie from a previous session still holds the polling slot, the new process gets 409 Conflict on its first getUpdates and dies immediately. Retry with backoff until the slot frees — typically within a second or two. Also handles the two-sessions case: the second Claude Code instance keeps retrying (with a clear message about what's happening) and takes over when the first one exits. Fixes #804 #794, partial #788 (issue 4) --- external_plugins/telegram/server.ts | 38 +++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..977c206 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -15,7 +15,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' -import { Bot, InputFile, type Context } from 'grammy' +import { Bot, GrammyError, InputFile, type Context } from 'grammy' import type { ReactionTypeEmoji } from 'grammy/types' import { randomBytes } from 'crypto' import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs' @@ -593,9 +593,33 @@ async function handleInbound( }) } -void bot.start({ - onStart: info => { - botUsername = info.username - process.stderr.write(`telegram channel: polling as @${info.username}\n`) - }, -}) +// 409 Conflict = another getUpdates consumer is still active (zombie from a +// previous session, or a second Claude Code instance). Retry with backoff +// until the slot frees up instead of crashing on the first rejection. +void (async () => { + for (let attempt = 1; ; attempt++) { + try { + await bot.start({ + onStart: info => { + botUsername = info.username + process.stderr.write(`telegram channel: polling as @${info.username}\n`) + }, + }) + return // bot.stop() was called — clean exit from the loop + } catch (err) { + if (err instanceof GrammyError && err.error_code === 409) { + const delay = Math.min(1000 * attempt, 15000) + const detail = attempt === 1 + ? ' — another instance is polling (zombie session, or a second Claude Code running?)' + : '' + process.stderr.write( + `telegram channel: 409 Conflict${detail}, retrying in ${delay / 1000}s\n`, + ) + await new Promise(r => setTimeout(r, delay)) + continue + } + process.stderr.write(`telegram channel: polling failed: ${err}\n`) + return + } + } +})() From 14927ff475758115791aceb4b53c0aadce8db4d8 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 10:56:57 -0700 Subject: [PATCH 04/13] telegram/discord: make state dir configurable via env var Hardcoded ~/.claude/channels// meant only one bot per machine. Respect TELEGRAM_STATE_DIR / DISCORD_STATE_DIR so users can run multiple bots with separate tokens and allowlists. Also fixed README path ('in your project' -> '~/...') to match the code. Fixes #792 --- external_plugins/discord/README.md | 4 +++- external_plugins/discord/server.ts | 2 +- external_plugins/telegram/README.md | 4 +++- external_plugins/telegram/server.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/external_plugins/discord/README.md b/external_plugins/discord/README.md index 3cbdf2b..ad04f6f 100644 --- a/external_plugins/discord/README.md +++ b/external_plugins/discord/README.md @@ -55,7 +55,9 @@ Install the plugin: /discord:configure MTIz... ``` -Writes `DISCORD_BOT_TOKEN=...` to `.claude/channels/discord/.env` in your project. You can also write that file by hand, or set the variable in your shell environment — shell takes precedence. +Writes `DISCORD_BOT_TOKEN=...` to `~/.claude/channels/discord/.env`. You can also write that file by hand, or set the variable in your shell environment — shell takes precedence. + +> To run multiple bots on one machine (different tokens, separate allowlists), point `DISCORD_STATE_DIR` at a different directory per instance. **6. Relaunch with the channel flag.** diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..4d220be 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -29,7 +29,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, import { homedir } from 'os' import { join, sep } from 'path' -const STATE_DIR = join(homedir(), '.claude', 'channels', 'discord') +const STATE_DIR = process.env.DISCORD_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'discord') const ACCESS_FILE = join(STATE_DIR, 'access.json') const APPROVED_DIR = join(STATE_DIR, 'approved') const ENV_FILE = join(STATE_DIR, '.env') diff --git a/external_plugins/telegram/README.md b/external_plugins/telegram/README.md index 8d5632e..d72dbc1 100644 --- a/external_plugins/telegram/README.md +++ b/external_plugins/telegram/README.md @@ -35,7 +35,9 @@ Install the plugin: /telegram:configure 123456789:AAHfiqksKZ8... ``` -Writes `TELEGRAM_BOT_TOKEN=...` to `.claude/channels/telegram/.env` in your project. You can also write that file by hand, or set the variable in your shell environment — shell takes precedence. +Writes `TELEGRAM_BOT_TOKEN=...` to `~/.claude/channels/telegram/.env`. You can also write that file by hand, or set the variable in your shell environment — shell takes precedence. + +> To run multiple bots on one machine (different tokens, separate allowlists), point `TELEGRAM_STATE_DIR` at a different directory per instance. **4. Relaunch with the channel flag.** diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..98271d0 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -22,7 +22,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, import { homedir } from 'os' import { join, extname, sep } from 'path' -const STATE_DIR = join(homedir(), '.claude', 'channels', 'telegram') +const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram') const ACCESS_FILE = join(STATE_DIR, 'access.json') const APPROVED_DIR = join(STATE_DIR, 'approved') const ENV_FILE = join(STATE_DIR, '.env') From 3d8042f259f80248da94b8e07a906c78c49d3501 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:07:05 -0700 Subject: [PATCH 05/13] Silently return when bot.stop() aborts the setup phase If bot.stop() is called while bot.start() is still in setup (deleteWebhook/ getMe), grammy rejects with 'Aborted delay'. Expected, not an error. --- external_plugins/telegram/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 977c206..574a141 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -618,6 +618,8 @@ void (async () => { await new Promise(r => setTimeout(r, delay)) continue } + // bot.stop() mid-setup rejects with grammy's "Aborted delay" — expected, not an error. + if (err instanceof Error && err.message === 'Aborted delay') return process.stderr.write(`telegram channel: polling failed: ${err}\n`) return } From 5c58308be4c6f234a90bc93464bc2c065c4a54f0 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:27:09 -0700 Subject: [PATCH 06/13] discord/telegram: guide assistant to send new reply on completion Message edits don't trigger push notifications on the user's device. Update system instructions and edit_message tool description to steer the assistant toward edit-for-progress + new-reply-on-completion. Fixes #786 --- external_plugins/discord/server.ts | 4 ++-- external_plugins/telegram/server.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..2854dba 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -423,7 +423,7 @@ const mcp = new Server( '', 'Messages from Discord arrive as . If the tag has attachment_count, the attachments attribute lists name/type/size — call download_attachment(chat_id, message_id) to fetch them. Reply with the reply tool — pass chat_id back. Use reply_to (set to a message_id) only when replying to an earlier message; the latest message doesn\'t need a quote-reply, omit reply_to for normal responses.', '', - 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, and edit_message to update a message you previously sent (e.g. progress → result).', + 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, and edit_message for interim progress updates. Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings.', '', "fetch_messages pulls real Discord history. Discord's search API isn't available to bots — if the user asks you to find an old message, fetch more history or ask them roughly when it was.", '', @@ -471,7 +471,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ }, { name: 'edit_message', - description: 'Edit a message the bot previously sent. Useful for progress updates (send "working…" then edit to the result).', + description: 'Edit a message the bot previously sent. Useful for interim progress updates. Edits don\'t trigger push notifications — send a new reply when a long task completes so the user\'s device pings.', inputSchema: { type: 'object', properties: { diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..19e2441 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -343,7 +343,7 @@ const mcp = new Server( '', 'Messages from Telegram arrive as . If the tag has an image_path attribute, Read that file — it is a photo the sender attached. Reply with the reply tool — pass chat_id back. Use reply_to (set to a message_id) only when replying to an earlier message; the latest message doesn\'t need a quote-reply, omit reply_to for normal responses.', '', - 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, and edit_message to update a message you previously sent (e.g. progress → result).', + 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, and edit_message for interim progress updates. Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings.', '', "Telegram's Bot API exposes no history or search — you only see messages as they arrive. If you need earlier context, ask the user to paste it or summarize.", '', @@ -391,7 +391,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ }, { name: 'edit_message', - description: 'Edit a message the bot previously sent. Useful for progress updates (send "working…" then edit to the result).', + description: 'Edit a message the bot previously sent. Useful for interim progress updates. Edits don\'t trigger push notifications — send a new reply when a long task completes so the user\'s device pings.', inputSchema: { type: 'object', properties: { From aa71c24314ab2336e4ae55d59490180822789d49 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:28:51 -0700 Subject: [PATCH 07/13] discord: port resilience fixes from telegram Same patterns as #812/#813 for the discord channel: - process-level unhandledRejection/uncaughtException handlers - client.on('error') to log discord.js errors - mcp.notification().catch() so inbound delivery failures surface - stdin close / SIGTERM -> client.destroy() + exit (zombie fix) - .unref() the approval-check interval - client.login().catch() to log+exit on bad token instead of crashing Discord is inherently more resilient than telegram (discord.js auto-reconnects, no 409 equivalent), but these gaps were still there. --- external_plugins/discord/server.ts | 39 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..81faeba 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -58,6 +58,15 @@ if (!TOKEN) { } const INBOX_DIR = join(STATE_DIR, 'inbox') +// Last-resort safety net — without these the process dies silently on any +// unhandled promise rejection. With them it logs and keeps serving tools. +process.on('unhandledRejection', err => { + process.stderr.write(`discord channel: unhandled rejection: ${err}\n`) +}) +process.on('uncaughtException', err => { + process.stderr.write(`discord channel: uncaught exception: ${err}\n`) +}) + const client = new Client({ intents: [ GatewayIntentBits.DirectMessages, @@ -342,7 +351,7 @@ function checkApprovals(): void { } } -if (!STATIC) setInterval(checkApprovals, 5000) +if (!STATIC) setInterval(checkApprovals, 5000).unref() // Discord caps messages at 2000 chars (hard limit — larger sends reject). // Split long replies, preferring paragraph boundaries when chunkMode is @@ -637,6 +646,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { await mcp.connect(new StdioServerTransport()) +// When Claude Code closes the MCP connection, stdin gets EOF. Without this +// the gateway stays connected as a zombie holding resources. +let shuttingDown = false +function shutdown(): void { + if (shuttingDown) return + shuttingDown = true + process.stderr.write('discord channel: shutting down\n') + setTimeout(() => process.exit(0), 2000) + void Promise.resolve(client.destroy()).finally(() => process.exit(0)) +} +process.stdin.on('end', shutdown) +process.stdin.on('close', shutdown) +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) + +client.on('error', err => { + process.stderr.write(`discord channel: client error: ${err}\n`) +}) + client.on('messageCreate', msg => { if (msg.author.bot) return handleInbound(msg).catch(e => process.stderr.write(`discord: handleInbound failed: ${e}\n`)) @@ -685,7 +713,7 @@ async function handleInbound(msg: Message): Promise { // forgeable by any allowlisted sender typing that string. const content = msg.content || (atts.length > 0 ? '(attachment)' : '') - void mcp.notification({ + mcp.notification({ method: 'notifications/claude/channel', params: { content, @@ -698,6 +726,8 @@ async function handleInbound(msg: Message): Promise { ...(atts.length > 0 ? { attachment_count: String(atts.length), attachments: atts.join('; ') } : {}), }, }, + }).catch(err => { + process.stderr.write(`discord channel: failed to deliver inbound to Claude: ${err}\n`) }) } @@ -705,4 +735,7 @@ client.once('ready', c => { process.stderr.write(`discord channel: gateway connected as ${c.user.tag}\n`) }) -await client.login(TOKEN) +client.login(TOKEN).catch(err => { + process.stderr.write(`discord channel: login failed: ${err}\n`) + process.exit(1) +}) From a7cb39c269de4c32436c02a0b46cec3dae7df79f Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:45:46 -0700 Subject: [PATCH 08/13] telegram: add MarkdownV2 parse_mode to reply/edit_message --- external_plugins/telegram/server.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..44d7945 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -372,6 +372,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos (inline preview); other types as documents. Max 50MB each.', }, + format: { + type: 'string', + enum: ['text', 'markdownv2'], + description: "Rendering mode. 'markdownv2' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per MarkdownV2 rules. Default: 'text' (plain, no escaping needed).", + }, }, required: ['chat_id', 'text'], }, @@ -398,6 +403,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ chat_id: { type: 'string' }, message_id: { type: 'string' }, text: { type: 'string' }, + format: { + type: 'string', + enum: ['text', 'markdownv2'], + description: "Rendering mode. 'markdownv2' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per MarkdownV2 rules. Default: 'text' (plain, no escaping needed).", + }, }, required: ['chat_id', 'message_id', 'text'], }, @@ -414,6 +424,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { const text = args.text as string const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined const files = (args.files as string[] | undefined) ?? [] + const format = (args.format as string | undefined) ?? 'text' + const parseMode = format === 'markdownv2' ? 'MarkdownV2' as const : undefined assertAllowedChat(chat_id) @@ -440,6 +452,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { (replyMode === 'all' || i === 0) const sent = await bot.api.sendMessage(chat_id, chunks[i], { ...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}), + ...(parseMode ? { parse_mode: parseMode } : {}), }) sentIds.push(sent.message_id) } @@ -482,10 +495,13 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { } case 'edit_message': { assertAllowedChat(args.chat_id as string) + const editFormat = (args.format as string | undefined) ?? 'text' + const editParseMode = editFormat === 'markdownv2' ? 'MarkdownV2' as const : undefined const edited = await bot.api.editMessageText( args.chat_id as string, Number(args.message_id), args.text as string, + ...(editParseMode ? [{ parse_mode: editParseMode }] : []), ) const id = typeof edited === 'object' ? edited.message_id : args.message_id return { content: [{ type: 'text', text: `edited (id: ${id})` }] } From 521f858e112d7e4e0854abe08d5a34631509d475 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:47:39 -0700 Subject: [PATCH 09/13] telegram: add /start /help /status bot commands --- external_plugins/telegram/server.ts | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..e6c8259 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -507,6 +507,52 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { await mcp.connect(new StdioServerTransport()) +bot.command('start', async ctx => { + await ctx.reply( + `👋 Hi! I'm a bridge between Telegram and Claude Code.\n\n` + + `How to set up:\n` + + `1. Send me any message here\n` + + `2. I'll give you a pairing code\n` + + `3. Run that code in Claude Code to link your account\n\n` + + `Once paired, your messages here go straight to Claude.` + ) +}) + +bot.command('help', async ctx => { + await ctx.reply( + `I relay messages between Telegram and Claude Code.\n\n` + + `What works:\n` + + `- Text messages\n` + + `- Photos (with captions)\n` + + `- Replies to specific messages\n\n` + + `Use /start for setup instructions.` + ) +}) + +bot.command('status', async ctx => { + const from = ctx.from + if (!from) return + const senderId = String(from.id) + const access = loadAccess() + + if (access.allowFrom.includes(senderId)) { + const name = from.username ? `@${from.username}` : senderId + await ctx.reply(`Paired as ${name}.`) + return + } + + for (const [code, p] of Object.entries(access.pending)) { + if (p.senderId === senderId) { + await ctx.reply( + `Pending pairing — run in Claude Code:\n\n/telegram:access pair ${code}` + ) + return + } + } + + await ctx.reply(`Not paired. Send me a message to get a pairing code.`) +}) + bot.on('message:text', async ctx => { await handleInbound(ctx, ctx.message.text, undefined) }) @@ -597,5 +643,10 @@ void bot.start({ onStart: info => { botUsername = info.username process.stderr.write(`telegram channel: polling as @${info.username}\n`) + void bot.api.setMyCommands([ + { command: 'start', description: 'Welcome and setup guide' }, + { command: 'help', description: 'What this bot can do' }, + { command: 'status', description: 'Check your pairing status' }, + ]).catch(() => {}) }, }) From a9bc23da6f263eddd73379c764b5dba091be29ba Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:51:06 -0700 Subject: [PATCH 10/13] telegram: handle all inbound file types + download_attachment tool --- external_plugins/telegram/server.ts | 110 +++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 8acd52a..a1b88f8 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -341,7 +341,7 @@ const mcp = new Server( instructions: [ 'The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.', '', - 'Messages from Telegram arrive as . If the tag has an image_path attribute, Read that file — it is a photo the sender attached. Reply with the reply tool — pass chat_id back. Use reply_to (set to a message_id) only when replying to an earlier message; the latest message doesn\'t need a quote-reply, omit reply_to for normal responses.', + 'Messages from Telegram arrive as . If the tag has an image_path attribute, Read that file — it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. Reply with the reply tool — pass chat_id back. Use reply_to (set to a message_id) only when replying to an earlier message; the latest message doesn\'t need a quote-reply, omit reply_to for normal responses.', '', 'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, and edit_message to update a message you previously sent (e.g. progress → result).', '', @@ -389,6 +389,17 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ required: ['chat_id', 'message_id', 'emoji'], }, }, + { + name: 'download_attachment', + description: 'Download a file attachment from a Telegram message to the local inbox. Use when the inbound meta shows attachment_file_id. Returns the local file path ready to Read. Telegram caps bot downloads at 20MB.', + inputSchema: { + type: 'object', + properties: { + file_id: { type: 'string', description: 'The attachment_file_id from inbound meta' }, + }, + required: ['file_id'], + }, + }, { name: 'edit_message', description: 'Edit a message the bot previously sent. Useful for progress updates (send "working…" then edit to the result).', @@ -480,6 +491,21 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { ]) return { content: [{ type: 'text', text: 'reacted' }] } } + case 'download_attachment': { + const file_id = args.file_id as string + const file = await bot.api.getFile(file_id) + if (!file.file_path) throw new Error('Telegram returned no file_path — file may have expired') + const url = `https://api.telegram.org/file/bot${TOKEN}/${file.file_path}` + const res = await fetch(url) + if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + const ext = file.file_path.split('.').pop() ?? 'bin' + const uniqueId = file.file_unique_id ?? file_id.slice(0, 12) + const path = join(INBOX_DIR, `${Date.now()}-${uniqueId}.${ext}`) + mkdirSync(INBOX_DIR, { recursive: true }) + writeFileSync(path, buf) + return { content: [{ type: 'text', text: path }] } + } case 'edit_message': { assertAllowedChat(args.chat_id as string) const edited = await bot.api.editMessageText( @@ -537,10 +563,85 @@ bot.on('message:photo', async ctx => { }) }) +bot.on('message:document', async ctx => { + const doc = ctx.message.document + const text = ctx.message.caption ?? `(document: ${doc.file_name ?? 'file'})` + await handleInbound(ctx, text, undefined, { + kind: 'document', + file_id: doc.file_id, + size: doc.file_size, + mime: doc.mime_type, + name: doc.file_name, + }) +}) + +bot.on('message:voice', async ctx => { + const voice = ctx.message.voice + const text = ctx.message.caption ?? '(voice message)' + await handleInbound(ctx, text, undefined, { + kind: 'voice', + file_id: voice.file_id, + size: voice.file_size, + mime: voice.mime_type, + }) +}) + +bot.on('message:audio', async ctx => { + const audio = ctx.message.audio + const text = ctx.message.caption ?? `(audio: ${audio.title ?? audio.file_name ?? 'audio'})` + await handleInbound(ctx, text, undefined, { + kind: 'audio', + file_id: audio.file_id, + size: audio.file_size, + mime: audio.mime_type, + name: audio.file_name, + }) +}) + +bot.on('message:video', async ctx => { + const video = ctx.message.video + const text = ctx.message.caption ?? '(video)' + await handleInbound(ctx, text, undefined, { + kind: 'video', + file_id: video.file_id, + size: video.file_size, + mime: video.mime_type, + name: video.file_name, + }) +}) + +bot.on('message:video_note', async ctx => { + const vn = ctx.message.video_note + await handleInbound(ctx, '(video note)', undefined, { + kind: 'video_note', + file_id: vn.file_id, + size: vn.file_size, + }) +}) + +bot.on('message:sticker', async ctx => { + const sticker = ctx.message.sticker + const emoji = sticker.emoji ? ` ${sticker.emoji}` : '' + await handleInbound(ctx, `(sticker${emoji})`, undefined, { + kind: 'sticker', + file_id: sticker.file_id, + size: sticker.file_size, + }) +}) + +type AttachmentMeta = { + kind: string + file_id: string + size?: number + mime?: string + name?: string +} + async function handleInbound( ctx: Context, text: string, downloadImage: (() => Promise) | undefined, + attachment?: AttachmentMeta, ): Promise { const result = gate(ctx) @@ -588,6 +689,13 @@ async function handleInbound( user_id: String(from.id), ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(), ...(imagePath ? { image_path: imagePath } : {}), + ...(attachment ? { + attachment_kind: attachment.kind, + attachment_file_id: attachment.file_id, + ...(attachment.size != null ? { attachment_size: String(attachment.size) } : {}), + ...(attachment.mime ? { attachment_mime: attachment.mime } : {}), + ...(attachment.name ? { attachment_name: attachment.name } : {}), + } : {}), }, }, }) From 9a101ba34c8d58410beaaad7f053ba9434fc6953 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:54:48 -0700 Subject: [PATCH 11/13] Restrict bot commands to DMs (security) - /status in a group would leak the sender's pending pairing code to other group members, who could then pair as that user - Commands in non-allowlisted groups confirm bot presence and enable spam - /start now acknowledges dmPolicy === 'disabled' instead of lying - setMyCommands scoped to private chats so the / menu only shows in DMs --- external_plugins/telegram/server.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index e6c8259..58ef37b 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -507,7 +507,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { await mcp.connect(new StdioServerTransport()) +// Commands are DM-only. Responding in groups would: (1) leak pairing codes via +// /status to other group members, (2) confirm bot presence in non-allowlisted +// groups, (3) spam channels the operator never approved. Silent drop matches +// the gate's behavior for unrecognized groups. + bot.command('start', async ctx => { + if (ctx.chat?.type !== 'private') return + const access = loadAccess() + if (access.dmPolicy === 'disabled') { + await ctx.reply(`This bot isn't accepting new connections.`) + return + } await ctx.reply( `👋 Hi! I'm a bridge between Telegram and Claude Code.\n\n` + `How to set up:\n` + @@ -519,6 +530,7 @@ bot.command('start', async ctx => { }) bot.command('help', async ctx => { + if (ctx.chat?.type !== 'private') return await ctx.reply( `I relay messages between Telegram and Claude Code.\n\n` + `What works:\n` + @@ -530,6 +542,7 @@ bot.command('help', async ctx => { }) bot.command('status', async ctx => { + if (ctx.chat?.type !== 'private') return const from = ctx.from if (!from) return const senderId = String(from.id) @@ -643,10 +656,13 @@ void bot.start({ onStart: info => { botUsername = info.username process.stderr.write(`telegram channel: polling as @${info.username}\n`) - void bot.api.setMyCommands([ - { command: 'start', description: 'Welcome and setup guide' }, - { command: 'help', description: 'What this bot can do' }, - { command: 'status', description: 'Check your pairing status' }, - ]).catch(() => {}) + void bot.api.setMyCommands( + [ + { command: 'start', description: 'Welcome and setup guide' }, + { command: 'help', description: 'What this bot can do' }, + { command: 'status', description: 'Check your pairing status' }, + ], + { scope: { type: 'all_private_chats' } }, + ).catch(() => {}) }, }) From ea382ec6a43f18478df6acb8b7026fae72eda02a Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:55:56 -0700 Subject: [PATCH 12/13] Tighten /start and /help copy Less chatty, more precise. Explicitly mentions the /telegram:access skill and the 6-char code format. --- external_plugins/telegram/server.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 58ef37b..38a10ec 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -520,24 +520,21 @@ bot.command('start', async ctx => { return } await ctx.reply( - `👋 Hi! I'm a bridge between Telegram and Claude Code.\n\n` + - `How to set up:\n` + - `1. Send me any message here\n` + - `2. I'll give you a pairing code\n` + - `3. Run that code in Claude Code to link your account\n\n` + - `Once paired, your messages here go straight to Claude.` + `This bot bridges Telegram to a Claude Code session.\n\n` + + `To pair:\n` + + `1. DM me anything — you'll get a 6-char code\n` + + `2. In Claude Code: /telegram:access pair \n\n` + + `After that, DMs here reach that session.` ) }) bot.command('help', async ctx => { if (ctx.chat?.type !== 'private') return await ctx.reply( - `I relay messages between Telegram and Claude Code.\n\n` + - `What works:\n` + - `- Text messages\n` + - `- Photos (with captions)\n` + - `- Replies to specific messages\n\n` + - `Use /start for setup instructions.` + `Messages you send here route to a paired Claude Code session. ` + + `Text and photos are forwarded; replies and reactions come back.\n\n` + + `/start — pairing instructions\n` + + `/status — check your pairing state` ) }) From 1636fedbd46691ece874fcf7425e2178da47ddc5 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 11:56:57 -0700 Subject: [PATCH 13/13] Sanitize user-controlled filenames and download path components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safeName() strips <>[]\r\n; from file_name/title before they hit the notification — delimiter chars would let an uploader break out of the tag or forge meta entries - download_attachment strips ext/uniqueId to alphanumeric before join() — defense-in-depth against path traversal (file_unique_id is Telegram-controlled so this is belt-and-braces) --- external_plugins/telegram/server.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index a1b88f8..0cff1f0 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -499,8 +499,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { const res = await fetch(url) if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`) const buf = Buffer.from(await res.arrayBuffer()) - const ext = file.file_path.split('.').pop() ?? 'bin' - const uniqueId = file.file_unique_id ?? file_id.slice(0, 12) + // file_path is from Telegram (trusted), but strip to safe chars anyway + // so nothing downstream can be tricked by an unexpected extension. + const rawExt = file.file_path.includes('.') ? file.file_path.split('.').pop()! : 'bin' + const ext = rawExt.replace(/[^a-zA-Z0-9]/g, '') || 'bin' + const uniqueId = (file.file_unique_id ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || 'dl' const path = join(INBOX_DIR, `${Date.now()}-${uniqueId}.${ext}`) mkdirSync(INBOX_DIR, { recursive: true }) writeFileSync(path, buf) @@ -565,13 +568,14 @@ bot.on('message:photo', async ctx => { bot.on('message:document', async ctx => { const doc = ctx.message.document - const text = ctx.message.caption ?? `(document: ${doc.file_name ?? 'file'})` + const name = safeName(doc.file_name) + const text = ctx.message.caption ?? `(document: ${name ?? 'file'})` await handleInbound(ctx, text, undefined, { kind: 'document', file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, - name: doc.file_name, + name, }) }) @@ -588,13 +592,14 @@ bot.on('message:voice', async ctx => { bot.on('message:audio', async ctx => { const audio = ctx.message.audio - const text = ctx.message.caption ?? `(audio: ${audio.title ?? audio.file_name ?? 'audio'})` + const name = safeName(audio.file_name) + const text = ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? 'audio'})` await handleInbound(ctx, text, undefined, { kind: 'audio', file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, - name: audio.file_name, + name, }) }) @@ -606,7 +611,7 @@ bot.on('message:video', async ctx => { file_id: video.file_id, size: video.file_size, mime: video.mime_type, - name: video.file_name, + name: safeName(video.file_name), }) }) @@ -637,6 +642,13 @@ type AttachmentMeta = { name?: string } +// Filenames and titles are uploader-controlled. They land inside the +// notification — delimiter chars would let the uploader break out of the tag +// or forge a second meta entry. +function safeName(s: string | undefined): string | undefined { + return s?.replace(/[<>\[\]\r\n;]/g, '_') +} + async function handleInbound( ctx: Context, text: string,