Compare commits

..

26 Commits

Author SHA1 Message Date
Kenneth Lien
61c0597779 Merge pull request #825 from anthropics/kenneth/channels-rollup
Channels rollup: resilience + discord port + bucket-1 features
2026-03-20 17:40:59 -07:00
Tobin South
da61886c07 Merge pull request #823 from anthropics/claude/slack-add-claude-plugin-marketplace
fix(plugin): switch stripe plugin to git-subdir source and remove local copy
2026-03-20 23:02:17 +00:00
Claude
802464cff3 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.
2026-03-20 20:30:40 +00:00
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
Claude
af6b2c490b 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.
2026-03-20 20:09:40 +00:00
Claude
2bc9dfb449 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.
2026-03-20 19:59:36 +00: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
13 changed files with 328 additions and 261 deletions

View File

@@ -1212,7 +1212,12 @@
"name": "stripe", "name": "stripe",
"description": "Stripe development plugin for Claude", "description": "Stripe development plugin for Claude",
"category": "development", "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" "homepage": "https://github.com/stripe/ai/tree/main/providers/claude/plugin"
}, },
{ {

View File

@@ -21,7 +21,8 @@ jobs:
- name: Get changed frontmatter files - name: Get changed frontmatter files
id: changed id: changed
run: | 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<<EOF" >> "$GITHUB_OUTPUT" echo "files<<EOF" >> "$GITHUB_OUTPUT"
echo "$FILES" >> "$GITHUB_OUTPUT" echo "$FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT"

View File

@@ -1,7 +1,7 @@
{ {
"name": "discord", "name": "discord",
"description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", "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.2", "version": "0.0.1",
"keywords": [ "keywords": [
"discord", "discord",
"messaging", "messaging",

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

@@ -16,7 +16,6 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
CallToolRequestSchema, CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import { import {
Client, Client,
GatewayIntentBits, GatewayIntentBits,
@@ -30,7 +29,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync,
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')
@@ -59,11 +58,14 @@ if (!TOKEN) {
} }
const INBOX_DIR = join(STATE_DIR, 'inbox') const INBOX_DIR = join(STATE_DIR, 'inbox')
// Permission-reply spec from anthropics/claude-cli-internal // Last-resort safety net — without these the process dies silently on any
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). // unhandled promise rejection. With them it logs and keeps serving tools.
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. process.on('unhandledRejection', err => {
// Strict: no bare yes/no (conversational), no prefix/suffix chatter. process.stderr.write(`discord channel: unhandled rejection: ${err}\n`)
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i })
process.on('uncaughtException', err => {
process.stderr.write(`discord channel: uncaught exception: ${err}\n`)
})
const client = new Client({ const client = new Client({
intents: [ intents: [
@@ -349,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
@@ -424,24 +426,13 @@ function safeAttName(att: Attachment): string {
const mcp = new Server( const mcp = new Server(
{ name: 'discord', version: '1.0.0' }, { name: 'discord', version: '1.0.0' },
{ {
capabilities: { capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
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: [ 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.', '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.',
'', '',
'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.",
'', '',
@@ -450,41 +441,6 @@ 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 () => ({ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [ tools: [
{ {
@@ -524,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: {
@@ -690,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`))
@@ -714,24 +689,6 @@ async function handleInbound(msg: Message): Promise<void> {
const chat_id = msg.channelId 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). // Typing indicator — signals "processing" until we reply (or ~10s elapses).
if ('sendTyping' in msg.channel) { if ('sendTyping' in msg.channel) {
void msg.channel.sendTyping().catch(() => {}) void msg.channel.sendTyping().catch(() => {})
@@ -756,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,
@@ -769,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`)
}) })
} }
@@ -776,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

@@ -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"]
}

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"stripe": {
"type": "http",
"url": "https://mcp.stripe.com"
}
}
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,7 +1,7 @@
{ {
"name": "telegram", "name": "telegram",
"description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", "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.2", "version": "0.0.1",
"keywords": [ "keywords": [
"telegram", "telegram",
"messaging", "messaging",

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,15 +15,14 @@ import {
ListToolsRequestSchema, ListToolsRequestSchema,
CallToolRequestSchema, CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod' import { Bot, GrammyError, InputFile, type Context } from 'grammy'
import { Bot, 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, chmodSync } 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')
@@ -52,11 +51,14 @@ if (!TOKEN) {
} }
const INBOX_DIR = join(STATE_DIR, 'inbox') const INBOX_DIR = join(STATE_DIR, 'inbox')
// Permission-reply spec from anthropics/claude-cli-internal // Last-resort safety net — without these the process dies silently on any
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep). // unhandled promise rejection. With them it logs and keeps serving tools.
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect. process.on('unhandledRejection', err => {
// Strict: no bare yes/no (conversational), no prefix/suffix chatter. process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`)
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i })
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 = ''
@@ -311,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'.
@@ -344,24 +346,13 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
const mcp = new Server( const mcp = new Server(
{ name: 'telegram', version: '1.0.0' }, { name: 'telegram', version: '1.0.0' },
{ {
capabilities: { capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
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: [ 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,36 +361,6 @@ 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 () => ({ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [ tools: [
{ {
@@ -420,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'],
}, },
@@ -437,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'],
}, },
@@ -462,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)
@@ -488,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)
} }
@@ -528,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})` }] }
@@ -555,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)
}) })
@@ -585,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)
@@ -607,28 +771,6 @@ async function handleInbound(
const chat_id = String(ctx.chat!.id) const chat_id = String(ctx.chat!.id)
const msgId = ctx.message?.message_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). // Typing indicator — signals "processing" until we reply (or ~5s elapses).
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {}) void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
@@ -647,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,
@@ -658,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
}
}
})()