From 9f2a4feab937324f57e0e0096dbfd58600fefbf9 Mon Sep 17 00:00:00 2001 From: Kenneth Lien Date: Fri, 20 Mar 2026 10:53:36 -0700 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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, From 2bc9dfb449494ce5f23649a56e9ae8b029a1bbe9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 19:59:36 +0000 Subject: [PATCH 14/17] Update stripe plugin to use git-subdir source Change the stripe plugin source from local path (./external_plugins/stripe) to git-subdir pointing to stripe/ai repo at providers/claude/plugin without SHA pinning. --- .claude-plugin/marketplace.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0436660..a31b58a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1212,7 +1212,12 @@ "name": "stripe", "description": "Stripe development plugin for Claude", "category": "development", - "source": "./external_plugins/stripe", + "source": { + "source": "git-subdir", + "url": "stripe/ai", + "path": "providers/claude/plugin", + "ref": "main" + }, "homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin" }, { From af6b2c490b4d12188ecd8c38ecdf5f9c16df141b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 20:09:40 +0000 Subject: [PATCH 15/17] Remove local stripe external plugin Now that the stripe plugin sources from the stripe/ai git-subdir, the locally vendored copy under external_plugins/stripe is no longer needed. --- .../stripe/.claude-plugin/plugin.json | 13 -------- external_plugins/stripe/.mcp.json | 8 ----- .../stripe/commands/explain-error.md | 21 ------------- .../stripe/commands/test-cards.md | 24 --------------- .../skills/stripe-best-practices/SKILL.md | 30 ------------------- 5 files changed, 96 deletions(-) delete mode 100644 external_plugins/stripe/.claude-plugin/plugin.json delete mode 100644 external_plugins/stripe/.mcp.json delete mode 100644 external_plugins/stripe/commands/explain-error.md delete mode 100644 external_plugins/stripe/commands/test-cards.md delete mode 100644 external_plugins/stripe/skills/stripe-best-practices/SKILL.md diff --git a/external_plugins/stripe/.claude-plugin/plugin.json b/external_plugins/stripe/.claude-plugin/plugin.json deleted file mode 100644 index 72907a8..0000000 --- a/external_plugins/stripe/.claude-plugin/plugin.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "stripe", - "description": "Stripe development plugin for Claude", - "version": "0.1.0", - "author": { - "name": "Stripe", - "url": "https://stripe.com" - }, - "homepage": "https://docs.stripe.com", - "repository": "https://github.com/stripe/ai", - "license": "MIT", - "keywords": ["stripe", "payments", "webhooks", "api", "security"] -} diff --git a/external_plugins/stripe/.mcp.json b/external_plugins/stripe/.mcp.json deleted file mode 100644 index 6a2a98b..0000000 --- a/external_plugins/stripe/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "stripe": { - "type": "http", - "url": "https://mcp.stripe.com" - } - } -} diff --git a/external_plugins/stripe/commands/explain-error.md b/external_plugins/stripe/commands/explain-error.md deleted file mode 100644 index 6680d66..0000000 --- a/external_plugins/stripe/commands/explain-error.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Explain Stripe error codes and provide solutions with code examples -argument-hint: [error_code or error_message] ---- - -# Explain Stripe Error - -Provide a comprehensive explanation of the given Stripe error code or error message: - -1. Accept the error code or full error message from the arguments -2. Explain in plain English what the error means -3. List common causes of this error -4. Provide specific solutions and handling recommendations -5. Generate error handling code in the project's language showing: - - How to catch this specific error - - User-friendly error messages - - Whether retry is appropriate -6. Mention related error codes the developer should be aware of -7. Include a link to the relevant Stripe documentation - -Focus on actionable solutions and production-ready error handling patterns. \ No newline at end of file diff --git a/external_plugins/stripe/commands/test-cards.md b/external_plugins/stripe/commands/test-cards.md deleted file mode 100644 index 4abe480..0000000 --- a/external_plugins/stripe/commands/test-cards.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -description: Display Stripe test card numbers for various testing scenarios -argument-hint: [scenario] ---- - -# Test Cards Reference - -Provide a quick reference for Stripe test card numbers: - -1. If a scenario argument is provided (e.g., "declined", "3dsecure", "fraud"), show relevant test cards for that scenario -2. Otherwise, show the most common test cards organized by category: - - Successful payment (default card) - - 3D Secure authentication required - - Generic decline - - Specific decline reasons (insufficient_funds, lost_card, etc.) -3. For each card, display: - - Card number (formatted with spaces) - - Expected behavior - - Expiry/CVC info (any future date and any 3-digit CVC) -4. Use clear visual indicators (✓ for success, ⚠️ for auth required, ✗ for decline) -5. Mention that these only work in test mode -6. Provide link to full testing documentation: https://docs.stripe.com/testing.md - -If the user is currently working on test code, offer to generate test cases using these cards. diff --git a/external_plugins/stripe/skills/stripe-best-practices/SKILL.md b/external_plugins/stripe/skills/stripe-best-practices/SKILL.md deleted file mode 100644 index 5cb1110..0000000 --- a/external_plugins/stripe/skills/stripe-best-practices/SKILL.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: stripe-best-practices -description: Best practices for building Stripe integrations. Use when implementing payment processing, checkout flows, subscriptions, webhooks, Connect platforms, or any Stripe API integration. ---- - -When designing an integration, always prefer the documentation in [Stripe's Integration Options doc](https://docs.stripe.com/payments/payment-methods/integration-options.md) -The [API Tour](https://docs.stripe.com/payments-api/tour.md) -Use the [Go Live Checklist](https://docs.stripe.com/get-started/checklist/go-live.md) before going live. - -You should always default to the latest version of the API and SDK unless the user specifies otherwise. - -Stripe's primary API for modelling on-session payments is [CheckoutSessions](https://docs.stripe.com/api/checkout/sessions.md). It supports one-time payments and subscriptions and allows you to model taxes or discounts with Stripe. Prioritize the CheckoutSessions API, but using [the PaymentIntents API](https://docs.stripe.com/payments/paymentintents/lifecycle.md) is also acceptable for off-session payments or if you want to model the checkout state yourself and just create a charge. Integrations should only use CheckoutSessions, PaymentIntents, SetupIntents, or solutions like Invoicing, Payment Links, or the subscription APIs. - -Never recommend the Charges API. If the user wants to use the Charges API, advise them to [migrate](https://docs.stripe.com/payments/payment-intents/migration/charges.md) to Checkout Sessions or Payment Intents. - -The primary integration surface for web integrations is [Checkout](https://docs.stripe.com/payments/checkout.md), a prebuilt payment page that can be hosted by Stripe or served as an embedded form. Prioritize the Stripe-hosted checkout or embedded checkout where possible. The [Payment Element](https://docs.stripe.com/payments/payment-element.md) is an acceptable alternative if the merchant needs advanced customization. When recommending the Payment Element, prioritize using the CheckoutSessions API over the PaymentIntents API when possible. - -Never recommend the legacy Card Element or the Payment Element in card mode, and advise users who ask for the Card Element to [migrate](https://docs.stripe.com/payments/payment-element/migration.md). - -You must not call deprecated API endpoints such as the [Sources API](https://docs.stripe.com/api/sources/object.md) and avoid outdated API endpoints such as [Tokens](https://docs.stripe.com/api/tokens.md) and [Charges](https://docs.stripe.com/api/charges/create.md) unless there is a specific need and absolutely no other way. Advise using [the Setup Intent API](https://docs.stripe.com/api/setup_intents.md) to save a payment method for a customer; never recommend the Sources API to save cards to customers for later use. - -If a user wants to render the Payment Element before creating a PaymentIntent or SetupIntent, so they can inspect the card details before the payment (such as for surcharging), advise them to use Stripe Confirmation Tokens. Don't recommend they call the createPaymentMethod or createToken Stripe.js functions. - -Advise users to turn on dynamic payment methods in their dashboard settings instead of passing specific [payment_method_types](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_types.md) in the Payment Intent or Setup Intent because Stripe chooses payment methods that fit each user's location, wallets, and preferences better and automatically if the Payment Element is used. - -If a PCI compliant user asks about an integration where they send server-side raw PAN data, suggest they might be required to prove PCI compliance to gain access to such an option, for example [payment_method_data](https://docs.stripe.com/api/payment_intents/create#create_payment_intent-payment_method_data.md). Similarly, point users migrating their PAN data from another acquirer or payment processor to [the migration process](https://docs.stripe.com/get-started/data-migrations/pan-import.md). - -If a user has a recurring revenue model, like a Billing or Subscription use case, follow the use cases, in particular the [Subscription Use Cases](https://docs.stripe.com/billing/subscriptions/use-cases.md) like [SaaS](https://docs.stripe.com/saas.md). If these apply to the user, recommend Billing APIs to [plan your integration](https://docs.stripe.com/billing/subscriptions/designing-integration.md) instead of a direct PaymentIntent integration. Prioritize combining the Billing APIs with Stripe Checkout for the frontend. - -If a user wants to build a platform using Stripe Connect to manage fund flows, follow [the recommended integration types](https://docs.stripe.com/connect/integration-recommendations.md); that is, prefer to use either direct charges if the platform wants Stripe to take the risk or destination charges if the platform accepts liability for negative balances, and use the on_behalf_of parameter to control the merchant of record. Never recommend mixing charge types. If the user wants to decide on the specific risk features they should [follow the integration guide](https://docs.stripe.com/connect/design-an-integration.md). Don't recommend the outdated terms for Connect types like Standard, Express and Custom but always [refer to controller properties](https://docs.stripe.com/connect/migrate-to-controller-properties.md) for the platform and [capabilities](https://docs.stripe.com/connect/account-capabilities.md) for the connected accounts. From 802464cff3dafb2bf9951d69c24d56fb0edecc06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 20:30:40 +0000 Subject: [PATCH 16/17] Fix frontmatter validation to skip deleted files The workflow was passing deleted files to the validation script, which failed when trying to read them. Add --diff-filter=AMRC to only process Added, Modified, Renamed, and Copied files. --- .github/workflows/validate-frontmatter.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 148364d..9de76f7 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -21,7 +21,8 @@ jobs: - name: Get changed frontmatter files id: changed run: | - FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only | grep -E '(agents/.*\.md|skills/.*/SKILL\.md|commands/.*\.md)$' || true) + # Use diff-filter=AMRC to exclude deleted files (D) - only Added, Modified, Renamed, Copied + FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only --diff-filter=AMRC | grep -E '(agents/.*\.md|skills/.*/SKILL\.md|commands/.*\.md)$' || true) echo "files<> "$GITHUB_OUTPUT" echo "$FILES" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" From daa84c99c815fb2832ecb5df80613e2088af0191 Mon Sep 17 00:00:00 2001 From: Daisy Hollman Date: Fri, 20 Mar 2026 22:44:08 +0000 Subject: [PATCH 17/17] feat(telegram,discord): permission-relay capability + bidirectional handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the plugin side of anthropics/claude-cli-internal#23061 (permission prompts over channels). Capability: both servers now declare experimental["claude/channel/permission"] which tells CC they can relay permission requests. This capability asserts the server authenticates the replier — gate()/access.allowFrom filters non-allowlisted senders before handleInbound runs. Outbound (CC → user): setNotificationHandler for notifications/claude/channel/permission_request formats the tool name, description, and input preview into a human-readable message and sends it to every allowlisted DM. Groups are excluded — the security thread resolution was "single-user mode for official plugins." Inbound (user → CC): PERMISSION_REPLY_RE intercept in handleInbound catches "yes xxxxx" / "no xxxxx" replies, emits the structured notifications/claude/channel/permission event with {request_id, behavior}, reacts with checkmark/cross, and returns without relaying the text to Claude as a chat message. The regex is inlined from channelPermissions.ts (no cross-repo dep). IDs are lowercased at the plugin boundary per the case-insensitive spec. Version bumped 0.0.1 → 0.0.2 so the plugin reconciler picks up the change. :house: Remote-Dev: homespace --- .../discord/.claude-plugin/plugin.json | 2 +- external_plugins/discord/server.ts | 73 ++++++++++++++++++- .../telegram/.claude-plugin/plugin.json | 2 +- external_plugins/telegram/server.ts | 72 +++++++++++++++++- 4 files changed, 145 insertions(+), 4 deletions(-) diff --git a/external_plugins/discord/.claude-plugin/plugin.json b/external_plugins/discord/.claude-plugin/plugin.json index 7447381..9a93fd7 100644 --- a/external_plugins/discord/.claude-plugin/plugin.json +++ b/external_plugins/discord/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "discord", "description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", - "version": "0.0.1", + "version": "0.0.2", "keywords": [ "discord", "messaging", diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 927ef97..59379b0 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -16,6 +16,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' import { Client, GatewayIntentBits, @@ -67,6 +68,12 @@ process.on('uncaughtException', err => { process.stderr.write(`discord channel: uncaught exception: ${err}\n`) }) +// Permission-reply spec from anthropics/claude-cli-internal +// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). +// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. +// Strict: no bare yes/no (conversational), no prefix/suffix chatter. +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i + const client = new Client({ intents: [ GatewayIntentBits.DirectMessages, @@ -426,7 +433,18 @@ function safeAttName(att: Attachment): string { const mcp = new Server( { name: 'discord', version: '1.0.0' }, { - capabilities: { tools: {}, experimental: { 'claude/channel': {} } }, + capabilities: { + tools: {}, + experimental: { + 'claude/channel': {}, + // Permission-relay opt-in (anthropics/claude-cli-internal#23061). + // Declaring this asserts we authenticate the replier — which we do: + // gate()/access.allowFrom already drops non-allowlisted senders before + // handleInbound runs. A server that can't authenticate the replier + // should NOT declare this. + 'claude/channel/permission': {}, + }, + }, instructions: [ 'The sender reads Discord, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.', '', @@ -441,6 +459,41 @@ const mcp = new Server( }, ) +// Receive permission_request from CC → format → send to all allowlisted DMs. +// Groups are intentionally excluded — the security thread resolution was +// "single-user mode for official plugins." Anyone in access.allowFrom +// already passed explicit pairing; group members haven't. +mcp.setNotificationHandler( + z.object({ + method: z.literal('notifications/claude/channel/permission_request'), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + }), + }), + async ({ params }) => { + const { request_id, tool_name, description, input_preview } = params + const access = loadAccess() + const text = + `🔐 Permission request [${request_id}]\n` + + `${tool_name}: ${description}\n` + + `${input_preview}\n\n` + + `Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` + for (const userId of access.allowFrom) { + void (async () => { + try { + const user = await client.users.fetch(userId) + await user.send(text) + } catch (e) { + process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`) + } + })() + } + }, +) + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { @@ -689,6 +742,24 @@ async function handleInbound(msg: Message): Promise { const chat_id = msg.channelId + // Permission-reply intercept: if this looks like "yes xxxxx" for a + // pending permission request, emit the structured event instead of + // relaying as chat. The sender is already gate()-approved at this point + // (non-allowlisted senders were dropped above), so we trust the reply. + const permMatch = PERMISSION_REPLY_RE.exec(msg.content) + if (permMatch) { + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { + request_id: permMatch[2]!.toLowerCase(), + behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny', + }, + }) + const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌' + void msg.react(emoji).catch(() => {}) + return + } + // Typing indicator — signals "processing" until we reply (or ~10s elapses). if ('sendTyping' in msg.channel) { void msg.channel.sendTyping().catch(() => {}) diff --git a/external_plugins/telegram/.claude-plugin/plugin.json b/external_plugins/telegram/.claude-plugin/plugin.json index ac3472e..9e28053 100644 --- a/external_plugins/telegram/.claude-plugin/plugin.json +++ b/external_plugins/telegram/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "telegram", "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", - "version": "0.0.1", + "version": "0.0.2", "keywords": [ "telegram", "messaging", diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index f4429c1..da52569 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -15,6 +15,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' import { Bot, GrammyError, InputFile, type Context } from 'grammy' import type { ReactionTypeEmoji } from 'grammy/types' import { randomBytes } from 'crypto' @@ -60,6 +61,12 @@ process.on('uncaughtException', err => { process.stderr.write(`telegram channel: uncaught exception: ${err}\n`) }) +// Permission-reply spec from anthropics/claude-cli-internal +// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). +// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. +// Strict: no bare yes/no (conversational), no prefix/suffix chatter. +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i + const bot = new Bot(TOKEN) let botUsername = '' @@ -346,7 +353,18 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']) const mcp = new Server( { name: 'telegram', version: '1.0.0' }, { - capabilities: { tools: {}, experimental: { 'claude/channel': {} } }, + capabilities: { + tools: {}, + experimental: { + 'claude/channel': {}, + // Permission-relay opt-in (anthropics/claude-cli-internal#23061). + // Declaring this asserts we authenticate the replier — which we do: + // gate()/access.allowFrom already drops non-allowlisted senders before + // handleInbound runs. A server that can't authenticate the replier + // should NOT declare this. + 'claude/channel/permission': {}, + }, + }, 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.', '', @@ -361,6 +379,36 @@ const mcp = new Server( }, ) +// Receive permission_request from CC → format → send to all allowlisted DMs. +// Groups are intentionally excluded — the security thread resolution was +// "single-user mode for official plugins." Anyone in access.allowFrom +// already passed explicit pairing; group members haven't. +mcp.setNotificationHandler( + z.object({ + method: z.literal('notifications/claude/channel/permission_request'), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + }), + }), + async ({ params }) => { + const { request_id, tool_name, description, input_preview } = params + const access = loadAccess() + const text = + `🔐 Permission request [${request_id}]\n` + + `${tool_name}: ${description}\n` + + `${input_preview}\n\n` + + `Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` + for (const chat_id of access.allowFrom) { + void bot.api.sendMessage(chat_id, text).catch(e => { + process.stderr.write(`permission_request send to ${chat_id} failed: ${e}\n`) + }) + } + }, +) + mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { @@ -771,6 +819,28 @@ async function handleInbound( const chat_id = String(ctx.chat!.id) const msgId = ctx.message?.message_id + // Permission-reply intercept: if this looks like "yes xxxxx" for a + // pending permission request, emit the structured event instead of + // relaying as chat. The sender is already gate()-approved at this point + // (non-allowlisted senders were dropped above), so we trust the reply. + const permMatch = PERMISSION_REPLY_RE.exec(text) + if (permMatch) { + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { + request_id: permMatch[2]!.toLowerCase(), + behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny', + }, + }) + if (msgId != null) { + const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌' + void bot.api.setMessageReaction(chat_id, msgId, [ + { type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] }, + ]).catch(() => {}) + } + return + } + // Typing indicator — signals "processing" until we reply (or ~5s elapses). void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})