|
|
@@ -15,6 +15,7 @@ import {
|
|
|
ListToolsRequestSchema,
|
|
|
CallToolRequestSchema,
|
|
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
|
+import { z } from 'zod'
|
|
|
import { Bot, GrammyError, InputFile, type Context } from 'grammy'
|
|
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
|
import { randomBytes } from 'crypto'
|
|
|
@@ -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,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 () => ({
|
|
|
tools: [
|
|
|
{
|
|
|
@@ -771,6 +819,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(() => {})
|
|
|
|