mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-21 11:53:08 +00:00
Compare commits
1 Commits
main
...
daisy/plug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43cc5f8766 |
@@ -1212,12 +1212,7 @@
|
|||||||
"name": "stripe",
|
"name": "stripe",
|
||||||
"description": "Stripe development plugin for Claude",
|
"description": "Stripe development plugin for Claude",
|
||||||
"category": "development",
|
"category": "development",
|
||||||
"source": {
|
"source": "./external_plugins/stripe",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
3
.github/workflows/validate-frontmatter.yml
vendored
3
.github/workflows/validate-frontmatter.yml
vendored
@@ -21,8 +21,7 @@ jobs:
|
|||||||
- name: Get changed frontmatter files
|
- name: Get changed frontmatter files
|
||||||
id: changed
|
id: changed
|
||||||
run: |
|
run: |
|
||||||
# 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 | grep -E '(agents/.*\.md|skills/.*/SKILL\.md|commands/.*\.md)$' || true)
|
||||||
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"
|
||||||
|
|||||||
@@ -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.1",
|
"version": "0.0.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"discord",
|
"discord",
|
||||||
"messaging",
|
"messaging",
|
||||||
|
|||||||
@@ -55,9 +55,7 @@ Install the plugin:
|
|||||||
/discord:configure MTIz...
|
/discord:configure MTIz...
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
> 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.**
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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,
|
||||||
@@ -29,7 +30,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 = process.env.DISCORD_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'discord')
|
const 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')
|
||||||
@@ -58,14 +59,11 @@ 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
|
// Permission-reply spec from anthropics/claude-cli-internal
|
||||||
// unhandled promise rejection. With them it logs and keeps serving tools.
|
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep).
|
||||||
process.on('unhandledRejection', err => {
|
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect.
|
||||||
process.stderr.write(`discord channel: unhandled rejection: ${err}\n`)
|
// Strict: no bare yes/no (conversational), no prefix/suffix chatter.
|
||||||
})
|
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
||||||
process.on('uncaughtException', err => {
|
|
||||||
process.stderr.write(`discord channel: uncaught exception: ${err}\n`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
@@ -351,7 +349,7 @@ function checkApprovals(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!STATIC) setInterval(checkApprovals, 5000).unref()
|
if (!STATIC) setInterval(checkApprovals, 5000)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -426,13 +424,24 @@ 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: { tools: {}, experimental: { 'claude/channel': {} } },
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
experimental: {
|
||||||
|
'claude/channel': {},
|
||||||
|
// Permission-relay opt-in (anthropics/claude-cli-internal#23061).
|
||||||
|
// Declaring this asserts we authenticate the replier — which we do:
|
||||||
|
// gate()/access.allowFrom already drops non-allowlisted senders before
|
||||||
|
// handleInbound runs. A server that can't authenticate the replier
|
||||||
|
// should NOT declare this.
|
||||||
|
'claude/channel/permission': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
instructions: [
|
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 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.',
|
'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).',
|
||||||
'',
|
'',
|
||||||
"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.",
|
||||||
'',
|
'',
|
||||||
@@ -441,6 +450,41 @@ const mcp = new Server(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Receive permission_request from CC → format → send to all allowlisted DMs.
|
||||||
|
// Groups are intentionally excluded — the security thread resolution was
|
||||||
|
// "single-user mode for official plugins." Anyone in access.allowFrom
|
||||||
|
// already passed explicit pairing; group members haven't.
|
||||||
|
mcp.setNotificationHandler(
|
||||||
|
z.object({
|
||||||
|
method: z.literal('notifications/claude/channel/permission_request'),
|
||||||
|
params: z.object({
|
||||||
|
request_id: z.string(),
|
||||||
|
tool_name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
input_preview: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async ({ params }) => {
|
||||||
|
const { request_id, tool_name, description, input_preview } = params
|
||||||
|
const access = loadAccess()
|
||||||
|
const text =
|
||||||
|
`🔐 Permission request [${request_id}]\n` +
|
||||||
|
`${tool_name}: ${description}\n` +
|
||||||
|
`${input_preview}\n\n` +
|
||||||
|
`Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.`
|
||||||
|
for (const userId of access.allowFrom) {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const user = await client.users.fetch(userId)
|
||||||
|
await user.send(text)
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
@@ -480,7 +524,7 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'edit_message',
|
name: 'edit_message',
|
||||||
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.',
|
description: 'Edit a message the bot previously sent. Useful for progress updates (send "working…" then edit to the result).',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -646,25 +690,6 @@ 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`))
|
||||||
@@ -689,6 +714,24 @@ 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(() => {})
|
||||||
@@ -713,7 +756,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)' : '')
|
||||||
|
|
||||||
mcp.notification({
|
void mcp.notification({
|
||||||
method: 'notifications/claude/channel',
|
method: 'notifications/claude/channel',
|
||||||
params: {
|
params: {
|
||||||
content,
|
content,
|
||||||
@@ -726,8 +769,6 @@ 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`)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,7 +776,4 @@ 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`)
|
||||||
})
|
})
|
||||||
|
|
||||||
client.login(TOKEN).catch(err => {
|
await client.login(TOKEN)
|
||||||
process.stderr.write(`discord channel: login failed: ${err}\n`)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|||||||
13
external_plugins/stripe/.claude-plugin/plugin.json
Normal file
13
external_plugins/stripe/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
8
external_plugins/stripe/.mcp.json
Normal file
8
external_plugins/stripe/.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"stripe": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.stripe.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
external_plugins/stripe/commands/explain-error.md
Normal file
21
external_plugins/stripe/commands/explain-error.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
24
external_plugins/stripe/commands/test-cards.md
Normal file
24
external_plugins/stripe/commands/test-cards.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
@@ -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.1",
|
"version": "0.0.2",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"telegram",
|
"telegram",
|
||||||
"messaging",
|
"messaging",
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ Install the plugin:
|
|||||||
/telegram:configure 123456789:AAHfiqksKZ8...
|
/telegram:configure 123456789:AAHfiqksKZ8...
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
> 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.**
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,15 @@ import {
|
|||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js'
|
} from '@modelcontextprotocol/sdk/types.js'
|
||||||
import { Bot, GrammyError, InputFile, type Context } from 'grammy'
|
import { z } from 'zod'
|
||||||
|
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 = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
const 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')
|
||||||
@@ -51,14 +52,11 @@ 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
|
// Permission-reply spec from anthropics/claude-cli-internal
|
||||||
// unhandled promise rejection. With them it logs and keeps serving tools.
|
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep).
|
||||||
process.on('unhandledRejection', err => {
|
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect.
|
||||||
process.stderr.write(`telegram channel: unhandled rejection: ${err}\n`)
|
// Strict: no bare yes/no (conversational), no prefix/suffix chatter.
|
||||||
})
|
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
||||||
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 = ''
|
||||||
@@ -313,7 +311,7 @@ function checkApprovals(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!STATIC) setInterval(checkApprovals, 5000).unref()
|
if (!STATIC) setInterval(checkApprovals, 5000)
|
||||||
|
|
||||||
// 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'.
|
||||||
@@ -346,13 +344,24 @@ 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: { tools: {}, experimental: { 'claude/channel': {} } },
|
capabilities: {
|
||||||
|
tools: {},
|
||||||
|
experimental: {
|
||||||
|
'claude/channel': {},
|
||||||
|
// Permission-relay opt-in (anthropics/claude-cli-internal#23061).
|
||||||
|
// Declaring this asserts we authenticate the replier — which we do:
|
||||||
|
// gate()/access.allowFrom already drops non-allowlisted senders before
|
||||||
|
// handleInbound runs. A server that can't authenticate the replier
|
||||||
|
// should NOT declare this.
|
||||||
|
'claude/channel/permission': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
instructions: [
|
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. 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.',
|
'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.',
|
||||||
'',
|
'',
|
||||||
'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.',
|
'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).',
|
||||||
'',
|
'',
|
||||||
"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.",
|
||||||
'',
|
'',
|
||||||
@@ -361,6 +370,36 @@ const mcp = new Server(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Receive permission_request from CC → format → send to all allowlisted DMs.
|
||||||
|
// Groups are intentionally excluded — the security thread resolution was
|
||||||
|
// "single-user mode for official plugins." Anyone in access.allowFrom
|
||||||
|
// already passed explicit pairing; group members haven't.
|
||||||
|
mcp.setNotificationHandler(
|
||||||
|
z.object({
|
||||||
|
method: z.literal('notifications/claude/channel/permission_request'),
|
||||||
|
params: z.object({
|
||||||
|
request_id: z.string(),
|
||||||
|
tool_name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
input_preview: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async ({ params }) => {
|
||||||
|
const { request_id, tool_name, description, input_preview } = params
|
||||||
|
const access = loadAccess()
|
||||||
|
const text =
|
||||||
|
`🔐 Permission request [${request_id}]\n` +
|
||||||
|
`${tool_name}: ${description}\n` +
|
||||||
|
`${input_preview}\n\n` +
|
||||||
|
`Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.`
|
||||||
|
for (const chat_id of access.allowFrom) {
|
||||||
|
void bot.api.sendMessage(chat_id, text).catch(e => {
|
||||||
|
process.stderr.write(`permission_request send to ${chat_id} failed: ${e}\n`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
@@ -381,11 +420,6 @@ 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'],
|
||||||
},
|
},
|
||||||
@@ -403,31 +437,15 @@ 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 interim progress updates. Edits don\'t trigger push notifications — send a new reply when a long task completes so the user\'s device pings.',
|
description: 'Edit a message the bot previously sent. Useful for progress updates (send "working…" then edit to the result).',
|
||||||
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'],
|
||||||
},
|
},
|
||||||
@@ -444,8 +462,6 @@ 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)
|
||||||
|
|
||||||
@@ -472,7 +488,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -513,33 +528,12 @@ 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})` }] }
|
||||||
@@ -561,80 +555,6 @@ 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)
|
||||||
})
|
})
|
||||||
@@ -665,94 +585,10 @@ 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)
|
||||||
|
|
||||||
@@ -771,6 +607,28 @@ 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(() => {})
|
||||||
|
|
||||||
@@ -789,7 +647,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.
|
||||||
mcp.notification({
|
void mcp.notification({
|
||||||
method: 'notifications/claude/channel',
|
method: 'notifications/claude/channel',
|
||||||
params: {
|
params: {
|
||||||
content: text,
|
content: text,
|
||||||
@@ -800,63 +658,14 @@ 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`)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without this, any throw in a message handler stops polling permanently
|
void bot.start({
|
||||||
// (grammy's default error handler calls bot.stop() and rethrows).
|
onStart: info => {
|
||||||
bot.catch(err => {
|
botUsername = info.username
|
||||||
process.stderr.write(`telegram channel: handler error (polling continues): ${err.error}\n`)
|
process.stderr.write(`telegram channel: polling as @${info.username}\n`)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 409 Conflict = another getUpdates consumer is still active (zombie from a
|
|
||||||
// previous session, or a second Claude Code instance). Retry with backoff
|
|
||||||
// until the slot frees up instead of crashing on the first rejection.
|
|
||||||
void (async () => {
|
|
||||||
for (let attempt = 1; ; attempt++) {
|
|
||||||
try {
|
|
||||||
await bot.start({
|
|
||||||
onStart: info => {
|
|
||||||
botUsername = info.username
|
|
||||||
process.stderr.write(`telegram channel: polling as @${info.username}\n`)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user