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..927ef97 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') @@ -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 @@ -423,7 +432,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 +480,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: { @@ -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) +}) 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..f4429c1 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -15,14 +15,14 @@ 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' 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') @@ -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 = '' @@ -304,7 +313,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'. @@ -341,9 +350,9 @@ 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).', + '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.", '', @@ -372,6 +381,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'], }, @@ -389,15 +403,31 @@ 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).', + 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: { 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 +444,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 +472,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) } @@ -480,12 +513,33 @@ 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()) + // 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) + return { content: [{ type: 'text', text: path }] } + } 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})` }] } @@ -507,6 +561,80 @@ 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) + +// 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( + `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( + `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` + ) +}) + +bot.command('status', async ctx => { + if (ctx.chat?.type !== 'private') return + 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) }) @@ -537,10 +665,94 @@ bot.on('message:photo', async ctx => { }) }) +bot.on('message:document', async ctx => { + const doc = ctx.message.document + 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, + }) +}) + +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 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, + }) +}) + +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: safeName(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 +} + +// 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, downloadImage: (() => Promise) | undefined, + attachment?: AttachmentMeta, ): Promise { const result = gate(ctx) @@ -577,7 +789,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, @@ -588,14 +800,63 @@ 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 } : {}), + } : {}), }, }, + }).catch(err => { + process.stderr.write(`telegram channel: failed to deliver inbound to Claude: ${err}\n`) }) } -void bot.start({ - onStart: info => { - botUsername = info.username - process.stderr.write(`telegram channel: polling as @${info.username}\n`) - }, +// 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`) }) + +// 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`) + 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(() => {}) + }, + }) + 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 + } + // 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 + } + } +})()