Compare commits

..

24 Commits

Author SHA1 Message Date
Kenneth Lien
51bd7bd5f2 Merge remote-tracking branch 'origin/kenneth/telegram-all-file-types' into kenneth/channels-rollup 2026-03-20 13:13:58 -07:00
Kenneth Lien
71b102d75d Merge remote-tracking branch 'origin/kenneth/telegram-bot-commands-795' into kenneth/channels-rollup
# Conflicts:
#	external_plugins/telegram/server.ts
2026-03-20 13:13:58 -07:00
Kenneth Lien
556b21af96 Merge remote-tracking branch 'origin/kenneth/telegram-bot-commands' into kenneth/channels-rollup 2026-03-20 13:13:08 -07:00
Kenneth Lien
87e0f09336 Merge remote-tracking branch 'origin/kenneth/discord-resilience' into kenneth/channels-rollup 2026-03-20 13:13:08 -07:00
Kenneth Lien
aa4f7c4fb0 Merge remote-tracking branch 'origin/kenneth/discord-edit-notif-guidance' into kenneth/channels-rollup 2026-03-20 13:13:08 -07:00
Kenneth Lien
24a170a704 Merge remote-tracking branch 'origin/kenneth/channels-state-dir' into kenneth/channels-rollup 2026-03-20 13:13:07 -07:00
Kenneth Lien
f3fc62a8e7 Merge remote-tracking branch 'origin/kenneth/telegram-409' into kenneth/channels-rollup
# Conflicts:
#	external_plugins/telegram/server.ts
2026-03-20 13:13:07 -07:00
Kenneth Lien
757480dd76 Merge remote-tracking branch 'origin/kenneth/telegram-shutdown' into kenneth/channels-rollup 2026-03-20 13:12:58 -07:00
Kenneth Lien
1636fedbd4 Sanitize user-controlled filenames and download path components
- safeName() strips <>[]\r\n; from file_name/title before they hit the
  <channel> 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)
2026-03-20 11:56:57 -07:00
Kenneth Lien
ea382ec6a4 Tighten /start and /help copy
Less chatty, more precise. Explicitly mentions the /telegram:access
skill and the 6-char code format.
2026-03-20 11:55:56 -07:00
Kenneth Lien
9a101ba34c 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
2026-03-20 11:54:48 -07:00
Kenneth Lien
a9bc23da6f telegram: handle all inbound file types + download_attachment tool 2026-03-20 11:51:42 -07:00
Kenneth Lien
521f858e11 telegram: add /start /help /status bot commands 2026-03-20 11:47:39 -07:00
Kenneth Lien
a7cb39c269 telegram: add MarkdownV2 parse_mode to reply/edit_message 2026-03-20 11:45:46 -07:00
Kenneth Lien
aa71c24314 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.
2026-03-20 11:28:51 -07:00
Kenneth Lien
5c58308be4 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
2026-03-20 11:27:09 -07:00
Kenneth Lien
3d8042f259 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.
2026-03-20 11:07:05 -07:00
Kenneth Lien
14927ff475 telegram/discord: make state dir configurable via env var
Hardcoded ~/.claude/channels/<name>/ 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
2026-03-20 10:56:57 -07:00
Kenneth Lien
1daff5f224 telegram: retry on 409 Conflict instead of crashing
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)
2026-03-20 10:55:27 -07:00
Kenneth Lien
2aa90a8387 telegram: exit when Claude Code closes the connection
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)
2026-03-20 10:54:33 -07:00
Kenneth Lien
9f2a4feab9 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
2026-03-20 10:53:36 -07:00
Tobin South
90accf6fd2 add(plugin): mcp-server-dev — skills for building MCP servers (#731) 2026-03-20 17:51:32 +00:00
Kenneth Lien
562a27feec Merge pull request #811 from anthropics/kenneth/chmod-env-files
Lock telegram/discord .env files to owner (chmod 600)
2026-03-20 10:48:05 -07:00
Kenneth Lien
8140fbad22 Lock telegram/discord .env files to owner (chmod 600)
The bot token is a credential. Tighten perms on load so hand-written
or pre-existing .env files get locked down, and update the configure
skill to chmod after writing. No-op on Windows.
2026-03-20 10:37:13 -07:00
6 changed files with 328 additions and 24 deletions

View File

@@ -55,7 +55,9 @@ Install the plugin:
/discord:configure MTIz... /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.** **6. Relaunch with the channel flag.**

View File

@@ -25,11 +25,11 @@ import {
type Attachment, type Attachment,
} from 'discord.js' } from 'discord.js'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs' import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join, sep } from 'path' 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 ACCESS_FILE = join(STATE_DIR, 'access.json')
const APPROVED_DIR = join(STATE_DIR, 'approved') const APPROVED_DIR = join(STATE_DIR, 'approved')
const ENV_FILE = join(STATE_DIR, '.env') const ENV_FILE = join(STATE_DIR, '.env')
@@ -37,6 +37,8 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/discord/.env into process.env. Real env wins. // Load ~/.claude/channels/discord/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives. // Plugin-spawned servers don't get an env block — this is where the token lives.
try { try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/) const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2] if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
@@ -56,6 +58,15 @@ if (!TOKEN) {
} }
const INBOX_DIR = join(STATE_DIR, 'inbox') 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({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.DirectMessages, GatewayIntentBits.DirectMessages,
@@ -340,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). // Discord caps messages at 2000 chars (hard limit — larger sends reject).
// Split long replies, preferring paragraph boundaries when chunkMode is // Split long replies, preferring paragraph boundaries when chunkMode is
@@ -421,7 +432,7 @@ const mcp = new Server(
'', '',
'Messages from Discord arrive as <channel source="discord" chat_id="..." message_id="..." user="..." ts="...">. 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.', 'Messages from Discord arrive as <channel source="discord" chat_id="..." message_id="..." user="..." ts="...">. 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.", "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.",
'', '',
@@ -469,7 +480,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
}, },
{ {
name: 'edit_message', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@@ -635,6 +646,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
await mcp.connect(new StdioServerTransport()) 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 => { client.on('messageCreate', msg => {
if (msg.author.bot) return if (msg.author.bot) return
handleInbound(msg).catch(e => process.stderr.write(`discord: handleInbound failed: ${e}\n`)) handleInbound(msg).catch(e => process.stderr.write(`discord: handleInbound failed: ${e}\n`))
@@ -683,7 +713,7 @@ async function handleInbound(msg: Message): Promise<void> {
// forgeable by any allowlisted sender typing that string. // forgeable by any allowlisted sender typing that string.
const content = msg.content || (atts.length > 0 ? '(attachment)' : '') const content = msg.content || (atts.length > 0 ? '(attachment)' : '')
void mcp.notification({ mcp.notification({
method: 'notifications/claude/channel', method: 'notifications/claude/channel',
params: { params: {
content, content,
@@ -696,6 +726,8 @@ async function handleInbound(msg: Message): Promise<void> {
...(atts.length > 0 ? { attachment_count: String(atts.length), attachments: atts.join('; ') } : {}), ...(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`)
}) })
} }
@@ -703,4 +735,7 @@ client.once('ready', c => {
process.stderr.write(`discord channel: gateway connected as ${c.user.tag}\n`) 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)
})

View File

@@ -80,7 +80,8 @@ as the correct long-term choice. Don't skip the lockdown offer.
2. `mkdir -p ~/.claude/channels/discord` 2. `mkdir -p ~/.claude/channels/discord`
3. Read existing `.env` if present; update/add the `DISCORD_BOT_TOKEN=` line, 3. Read existing `.env` if present; update/add the `DISCORD_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value. preserve other keys. Write back, no quotes around the value.
4. Confirm, then show the no-args status so the user sees where they stand. 4. `chmod 600 ~/.claude/channels/discord/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token ### `clear` — remove the token

View File

@@ -35,7 +35,9 @@ Install the plugin:
/telegram:configure 123456789:AAHfiqksKZ8... /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.** **4. Relaunch with the channel flag.**

View File

@@ -15,14 +15,14 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
CallToolRequestSchema, CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } 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 type { ReactionTypeEmoji } from 'grammy/types'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync } from 'fs' import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
import { homedir } from 'os' import { homedir } from 'os'
import { join, extname, sep } from 'path' 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 ACCESS_FILE = join(STATE_DIR, 'access.json')
const APPROVED_DIR = join(STATE_DIR, 'approved') const APPROVED_DIR = join(STATE_DIR, 'approved')
const ENV_FILE = join(STATE_DIR, '.env') const ENV_FILE = join(STATE_DIR, '.env')
@@ -30,6 +30,8 @@ const ENV_FILE = join(STATE_DIR, '.env')
// Load ~/.claude/channels/telegram/.env into process.env. Real env wins. // Load ~/.claude/channels/telegram/.env into process.env. Real env wins.
// Plugin-spawned servers don't get an env block — this is where the token lives. // Plugin-spawned servers don't get an env block — this is where the token lives.
try { try {
// Token is a credential — lock to owner. No-op on Windows (would need ACLs).
chmodSync(ENV_FILE, 0o600)
for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) { for (const line of readFileSync(ENV_FILE, 'utf8').split('\n')) {
const m = line.match(/^(\w+)=(.*)$/) const m = line.match(/^(\w+)=(.*)$/)
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2] if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2]
@@ -49,6 +51,15 @@ if (!TOKEN) {
} }
const INBOX_DIR = join(STATE_DIR, 'inbox') 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) const bot = new Bot(TOKEN)
let botUsername = '' let botUsername = ''
@@ -302,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 // Telegram caps messages at 4096 chars. Split long replies, preferring
// paragraph boundaries when chunkMode is 'newline'. // paragraph boundaries when chunkMode is 'newline'.
@@ -339,9 +350,9 @@ const mcp = new Server(
instructions: [ 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.', '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 <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. 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 <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. 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.", "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.",
'', '',
@@ -370,6 +381,11 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
items: { type: 'string' }, items: { type: 'string' },
description: 'Absolute file paths to attach. Images send as photos (inline preview); other types as documents. Max 50MB each.', 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'], required: ['chat_id', 'text'],
}, },
@@ -387,15 +403,31 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
required: ['chat_id', 'message_id', 'emoji'], 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 <channel> 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', 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
chat_id: { type: 'string' }, chat_id: { type: 'string' },
message_id: { type: 'string' }, message_id: { type: 'string' },
text: { 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'], required: ['chat_id', 'message_id', 'text'],
}, },
@@ -412,6 +444,8 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
const text = args.text as string const text = args.text as string
const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
const files = (args.files as string[] | 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) assertAllowedChat(chat_id)
@@ -438,6 +472,7 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
(replyMode === 'all' || i === 0) (replyMode === 'all' || i === 0)
const sent = await bot.api.sendMessage(chat_id, chunks[i], { const sent = await bot.api.sendMessage(chat_id, chunks[i], {
...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}), ...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}),
...(parseMode ? { parse_mode: parseMode } : {}),
}) })
sentIds.push(sent.message_id) sentIds.push(sent.message_id)
} }
@@ -478,12 +513,33 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
]) ])
return { content: [{ type: 'text', text: 'reacted' }] } 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': { case 'edit_message': {
assertAllowedChat(args.chat_id as string) 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( const edited = await bot.api.editMessageText(
args.chat_id as string, args.chat_id as string,
Number(args.message_id), Number(args.message_id),
args.text as string, args.text as string,
...(editParseMode ? [{ parse_mode: editParseMode }] : []),
) )
const id = typeof edited === 'object' ? edited.message_id : args.message_id const id = typeof edited === 'object' ? edited.message_id : args.message_id
return { content: [{ type: 'text', text: `edited (id: ${id})` }] } return { content: [{ type: 'text', text: `edited (id: ${id})` }] }
@@ -505,6 +561,80 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
await mcp.connect(new StdioServerTransport()) 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 <code>\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 => { bot.on('message:text', async ctx => {
await handleInbound(ctx, ctx.message.text, undefined) await handleInbound(ctx, ctx.message.text, undefined)
}) })
@@ -535,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 <channel>
// 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( async function handleInbound(
ctx: Context, ctx: Context,
text: string, text: string,
downloadImage: (() => Promise<string | undefined>) | undefined, downloadImage: (() => Promise<string | undefined>) | undefined,
attachment?: AttachmentMeta,
): Promise<void> { ): Promise<void> {
const result = gate(ctx) const result = gate(ctx)
@@ -575,7 +789,7 @@ async function handleInbound(
// image_path goes in meta only — an in-content "[image attached — read: PATH]" // image_path goes in meta only — an in-content "[image attached — read: PATH]"
// annotation is forgeable by any allowlisted sender typing that string. // annotation is forgeable by any allowlisted sender typing that string.
void mcp.notification({ mcp.notification({
method: 'notifications/claude/channel', method: 'notifications/claude/channel',
params: { params: {
content: text, content: text,
@@ -586,14 +800,63 @@ async function handleInbound(
user_id: String(from.id), user_id: String(from.id),
ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(), ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
...(imagePath ? { image_path: imagePath } : {}), ...(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({ // Without this, any throw in a message handler stops polling permanently
onStart: info => { // (grammy's default error handler calls bot.stop() and rethrows).
botUsername = info.username bot.catch(err => {
process.stderr.write(`telegram channel: polling as @${info.username}\n`) 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
}
}
})()

View File

@@ -77,7 +77,8 @@ offer.
2. `mkdir -p ~/.claude/channels/telegram` 2. `mkdir -p ~/.claude/channels/telegram`
3. Read existing `.env` if present; update/add the `TELEGRAM_BOT_TOKEN=` line, 3. Read existing `.env` if present; update/add the `TELEGRAM_BOT_TOKEN=` line,
preserve other keys. Write back, no quotes around the value. preserve other keys. Write back, no quotes around the value.
4. Confirm, then show the no-args status so the user sees where they stand. 4. `chmod 600 ~/.claude/channels/telegram/.env` — the token is a credential.
5. Confirm, then show the no-args status so the user sees where they stand.
### `clear` — remove the token ### `clear` — remove the token