mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-04-19 06:54:16 +00:00
Compare commits
5 Commits
sarah/rest
...
telegram-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec682345d1 | ||
|
|
fce00fe3c3 | ||
|
|
b3a0714d7f | ||
|
|
15268f03d2 | ||
|
|
daa84c99c8 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"keywords": [
|
||||
"discord",
|
||||
"messaging",
|
||||
|
||||
@@ -47,7 +47,6 @@ These are Claude Code commands — run `claude` to start a session first.
|
||||
Install the plugin:
|
||||
```
|
||||
/plugin install discord@claude-plugins-official
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
**5. Give the server the token.**
|
||||
|
||||
@@ -16,13 +16,18 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
Partials,
|
||||
ChannelType,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ActionRowBuilder,
|
||||
type Message,
|
||||
type Attachment,
|
||||
type Interaction,
|
||||
} from 'discord.js'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
|
||||
@@ -67,6 +72,12 @@ process.on('uncaughtException', err => {
|
||||
process.stderr.write(`discord channel: uncaught exception: ${err}\n`)
|
||||
})
|
||||
|
||||
// Permission-reply spec from anthropics/claude-cli-internal
|
||||
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep).
|
||||
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect.
|
||||
// Strict: no bare yes/no (conversational), no prefix/suffix chatter.
|
||||
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.DirectMessages,
|
||||
@@ -426,7 +437,18 @@ function safeAttName(att: Attachment): string {
|
||||
const mcp = new Server(
|
||||
{ name: 'discord', version: '1.0.0' },
|
||||
{
|
||||
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
|
||||
capabilities: {
|
||||
tools: {},
|
||||
experimental: {
|
||||
'claude/channel': {},
|
||||
// Permission-relay opt-in (anthropics/claude-cli-internal#23061).
|
||||
// Declaring this asserts we authenticate the replier — which we do:
|
||||
// gate()/access.allowFrom already drops non-allowlisted senders before
|
||||
// handleInbound runs. A server that can't authenticate the replier
|
||||
// should NOT declare this.
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
},
|
||||
instructions: [
|
||||
'The sender reads Discord, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
|
||||
'',
|
||||
@@ -441,6 +463,57 @@ const mcp = new Server(
|
||||
},
|
||||
)
|
||||
|
||||
// Stores full permission details for "See more" expansion keyed by request_id.
|
||||
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string }>()
|
||||
|
||||
// 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
|
||||
pendingPermissions.set(request_id, { tool_name, description, input_preview })
|
||||
const access = loadAccess()
|
||||
const text = `🔐 Permission: ${tool_name}`
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`perm:more:${request_id}`)
|
||||
.setLabel('See more')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`perm:allow:${request_id}`)
|
||||
.setLabel('Allow')
|
||||
.setEmoji('✅')
|
||||
.setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`perm:deny:${request_id}`)
|
||||
.setLabel('Deny')
|
||||
.setEmoji('❌')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
)
|
||||
for (const userId of access.allowFrom) {
|
||||
void (async () => {
|
||||
try {
|
||||
const user = await client.users.fetch(userId)
|
||||
await user.send({ content: text, components: [row] })
|
||||
} catch (e) {
|
||||
process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
@@ -665,6 +738,67 @@ client.on('error', err => {
|
||||
process.stderr.write(`discord channel: client error: ${err}\n`)
|
||||
})
|
||||
|
||||
// Button-click handler for permission requests. customId is
|
||||
// `perm:allow:<id>`, `perm:deny:<id>`, or `perm:more:<id>`.
|
||||
// Security mirrors the text-reply path: allowFrom must contain the sender.
|
||||
client.on('interactionCreate', async (interaction: Interaction) => {
|
||||
if (!interaction.isButton()) return
|
||||
const m = /^perm:(allow|deny|more):([a-km-z]{5})$/.exec(interaction.customId)
|
||||
if (!m) return
|
||||
const access = loadAccess()
|
||||
if (!access.allowFrom.includes(interaction.user.id)) {
|
||||
await interaction.reply({ content: 'Not authorized.', ephemeral: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
const [, behavior, request_id] = m
|
||||
|
||||
if (behavior === 'more') {
|
||||
const details = pendingPermissions.get(request_id)
|
||||
if (!details) {
|
||||
await interaction.reply({ content: 'Details no longer available.', ephemeral: true }).catch(() => {})
|
||||
return
|
||||
}
|
||||
const { tool_name, description, input_preview } = details
|
||||
let prettyInput: string
|
||||
try {
|
||||
prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2)
|
||||
} catch {
|
||||
prettyInput = input_preview
|
||||
}
|
||||
const expanded =
|
||||
`🔐 Permission: ${tool_name}\n\n` +
|
||||
`tool_name: ${tool_name}\n` +
|
||||
`description: ${description}\n` +
|
||||
`input_preview:\n${prettyInput}`
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`perm:allow:${request_id}`)
|
||||
.setLabel('Allow')
|
||||
.setEmoji('✅')
|
||||
.setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`perm:deny:${request_id}`)
|
||||
.setLabel('Deny')
|
||||
.setEmoji('❌')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
)
|
||||
await interaction.update({ content: expanded, components: [row] }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
void mcp.notification({
|
||||
method: 'notifications/claude/channel/permission',
|
||||
params: { request_id, behavior },
|
||||
})
|
||||
pendingPermissions.delete(request_id)
|
||||
const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
|
||||
// Replace buttons with the outcome so the same request can't be answered
|
||||
// twice and the chat history shows what was chosen.
|
||||
await interaction
|
||||
.update({ content: `${interaction.message.content}\n\n${label}`, components: [] })
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
client.on('messageCreate', msg => {
|
||||
if (msg.author.bot) return
|
||||
handleInbound(msg).catch(e => process.stderr.write(`discord: handleInbound failed: ${e}\n`))
|
||||
@@ -689,6 +823,24 @@ async function handleInbound(msg: Message): Promise<void> {
|
||||
|
||||
const chat_id = msg.channelId
|
||||
|
||||
// Permission-reply intercept: if this looks like "yes xxxxx" for a
|
||||
// pending permission request, emit the structured event instead of
|
||||
// relaying as chat. The sender is already gate()-approved at this point
|
||||
// (non-allowlisted senders were dropped above), so we trust the reply.
|
||||
const permMatch = PERMISSION_REPLY_RE.exec(msg.content)
|
||||
if (permMatch) {
|
||||
void mcp.notification({
|
||||
method: 'notifications/claude/channel/permission',
|
||||
params: {
|
||||
request_id: permMatch[2]!.toLowerCase(),
|
||||
behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny',
|
||||
},
|
||||
})
|
||||
const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌'
|
||||
void msg.react(emoji).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
// Typing indicator — signals "processing" until we reply (or ~10s elapses).
|
||||
if ('sendTyping' in msg.channel) {
|
||||
void msg.channel.sendTyping().catch(() => {})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "telegram",
|
||||
"description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"keywords": [
|
||||
"telegram",
|
||||
"messaging",
|
||||
|
||||
@@ -27,7 +27,6 @@ These are Claude Code commands — run `claude` to start a session first.
|
||||
Install the plugin:
|
||||
```
|
||||
/plugin install telegram@claude-plugins-official
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
**3. Give the server the token.**
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { Bot, GrammyError, InputFile, type Context } from 'grammy'
|
||||
import { z } from 'zod'
|
||||
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context } from 'grammy'
|
||||
import type { ReactionTypeEmoji } from 'grammy/types'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
|
||||
@@ -60,6 +61,12 @@ process.on('uncaughtException', err => {
|
||||
process.stderr.write(`telegram channel: uncaught exception: ${err}\n`)
|
||||
})
|
||||
|
||||
// Permission-reply spec from anthropics/claude-cli-internal
|
||||
// src/services/mcp/channelPermissions.ts — inlined (no CC repo dep).
|
||||
// 5 lowercase letters a-z minus 'l'. Case-insensitive for phone autocorrect.
|
||||
// Strict: no bare yes/no (conversational), no prefix/suffix chatter.
|
||||
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
||||
|
||||
const bot = new Bot(TOKEN)
|
||||
let botUsername = ''
|
||||
|
||||
@@ -346,7 +353,18 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
|
||||
const mcp = new Server(
|
||||
{ name: 'telegram', version: '1.0.0' },
|
||||
{
|
||||
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
|
||||
capabilities: {
|
||||
tools: {},
|
||||
experimental: {
|
||||
'claude/channel': {},
|
||||
// Permission-relay opt-in (anthropics/claude-cli-internal#23061).
|
||||
// Declaring this asserts we authenticate the replier — which we do:
|
||||
// gate()/access.allowFrom already drops non-allowlisted senders before
|
||||
// handleInbound runs. A server that can't authenticate the replier
|
||||
// should NOT declare this.
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
},
|
||||
instructions: [
|
||||
'The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
|
||||
'',
|
||||
@@ -361,6 +379,40 @@ const mcp = new Server(
|
||||
},
|
||||
)
|
||||
|
||||
// Stores full permission details for "See more" expansion keyed by request_id.
|
||||
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string }>()
|
||||
|
||||
// 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
|
||||
pendingPermissions.set(request_id, { tool_name, description, input_preview })
|
||||
const access = loadAccess()
|
||||
const text = `🔐 Permission: ${tool_name}`
|
||||
const keyboard = new InlineKeyboard()
|
||||
.text('See more', `perm:more:${request_id}`)
|
||||
.text('✅ Allow', `perm:allow:${request_id}`)
|
||||
.text('❌ Deny', `perm:deny:${request_id}`)
|
||||
for (const chat_id of access.allowFrom) {
|
||||
void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
|
||||
process.stderr.write(`permission_request send to ${chat_id} failed: ${e}\n`)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
@@ -635,6 +687,65 @@ bot.command('status', async ctx => {
|
||||
await ctx.reply(`Not paired. Send me a message to get a pairing code.`)
|
||||
})
|
||||
|
||||
// Inline-button handler for permission requests. Callback data is
|
||||
// `perm:allow:<id>`, `perm:deny:<id>`, or `perm:more:<id>`.
|
||||
// Security mirrors the text-reply path: allowFrom must contain the sender.
|
||||
bot.on('callback_query:data', async ctx => {
|
||||
const data = ctx.callbackQuery.data
|
||||
const m = /^perm:(allow|deny|more):([a-km-z]{5})$/.exec(data)
|
||||
if (!m) {
|
||||
await ctx.answerCallbackQuery().catch(() => {})
|
||||
return
|
||||
}
|
||||
const access = loadAccess()
|
||||
const senderId = String(ctx.from.id)
|
||||
if (!access.allowFrom.includes(senderId)) {
|
||||
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
||||
return
|
||||
}
|
||||
const [, behavior, request_id] = m
|
||||
|
||||
if (behavior === 'more') {
|
||||
const details = pendingPermissions.get(request_id)
|
||||
if (!details) {
|
||||
await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {})
|
||||
return
|
||||
}
|
||||
const { tool_name, description, input_preview } = details
|
||||
let prettyInput: string
|
||||
try {
|
||||
prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2)
|
||||
} catch {
|
||||
prettyInput = input_preview
|
||||
}
|
||||
const expanded =
|
||||
`🔐 Permission: ${tool_name}\n\n` +
|
||||
`tool_name: ${tool_name}\n` +
|
||||
`description: ${description}\n` +
|
||||
`input_preview:\n${prettyInput}`
|
||||
const keyboard = new InlineKeyboard()
|
||||
.text('✅ Allow', `perm:allow:${request_id}`)
|
||||
.text('❌ Deny', `perm:deny:${request_id}`)
|
||||
await ctx.editMessageText(expanded, { reply_markup: keyboard }).catch(() => {})
|
||||
await ctx.answerCallbackQuery().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
void mcp.notification({
|
||||
method: 'notifications/claude/channel/permission',
|
||||
params: { request_id, behavior },
|
||||
})
|
||||
pendingPermissions.delete(request_id)
|
||||
const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
|
||||
await ctx.answerCallbackQuery({ text: label }).catch(() => {})
|
||||
// Replace buttons with the outcome so the same request can't be answered
|
||||
// twice and the chat history shows what was chosen.
|
||||
const msg = ctx.callbackQuery.message
|
||||
if (msg && 'text' in msg && msg.text) {
|
||||
await ctx.editMessageText(`${msg.text}\n\n${label}`).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
bot.on('message:text', async ctx => {
|
||||
await handleInbound(ctx, ctx.message.text, undefined)
|
||||
})
|
||||
@@ -771,6 +882,28 @@ async function handleInbound(
|
||||
const chat_id = String(ctx.chat!.id)
|
||||
const msgId = ctx.message?.message_id
|
||||
|
||||
// Permission-reply intercept: if this looks like "yes xxxxx" for a
|
||||
// pending permission request, emit the structured event instead of
|
||||
// relaying as chat. The sender is already gate()-approved at this point
|
||||
// (non-allowlisted senders were dropped above), so we trust the reply.
|
||||
const permMatch = PERMISSION_REPLY_RE.exec(text)
|
||||
if (permMatch) {
|
||||
void mcp.notification({
|
||||
method: 'notifications/claude/channel/permission',
|
||||
params: {
|
||||
request_id: permMatch[2]!.toLowerCase(),
|
||||
behavior: permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny',
|
||||
},
|
||||
})
|
||||
if (msgId != null) {
|
||||
const emoji = permMatch[1]!.toLowerCase().startsWith('y') ? '✅' : '❌'
|
||||
void bot.api.setMessageReaction(chat_id, msgId, [
|
||||
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
||||
]).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Typing indicator — signals "processing" until we reply (or ~5s elapses).
|
||||
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user