diff --git a/external_plugins/discord/.claude-plugin/plugin.json b/external_plugins/discord/.claude-plugin/plugin.json index 9a93fd7..56d56bd 100644 --- a/external_plugins/discord/.claude-plugin/plugin.json +++ b/external_plugins/discord/.claude-plugin/plugin.json @@ -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.2", + "version": "0.0.3", "keywords": [ "discord", "messaging", diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 59379b0..f5c1376 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -22,8 +22,12 @@ import { 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' @@ -479,13 +483,24 @@ mcp.setNotificationHandler( 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.` + `${input_preview}` + const row = new ActionRowBuilder().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), + ) for (const userId of access.allowFrom) { void (async () => { try { const user = await client.users.fetch(userId) - await user.send(text) + await user.send({ content: text, components: [row] }) } catch (e) { process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`) } @@ -718,6 +733,31 @@ client.on('error', err => { process.stderr.write(`discord channel: client error: ${err}\n`) }) +// Button-click handler for permission requests. customId is +// `perm:allow:` or `perm:deny:` — set when the request was sent. +// 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):([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 + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { request_id, behavior }, + }) + 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`)) diff --git a/external_plugins/telegram/.claude-plugin/plugin.json b/external_plugins/telegram/.claude-plugin/plugin.json index 9e28053..83cfc40 100644 --- a/external_plugins/telegram/.claude-plugin/plugin.json +++ b/external_plugins/telegram/.claude-plugin/plugin.json @@ -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.2", + "version": "0.0.3", "keywords": [ "telegram", "messaging", diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index da52569..67574ce 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -16,7 +16,7 @@ import { CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import { Bot, GrammyError, InputFile, type Context } from 'grammy' +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' @@ -399,10 +399,12 @@ mcp.setNotificationHandler( 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.` + `${input_preview}` + const keyboard = new InlineKeyboard() + .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).catch(e => { + void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => { process.stderr.write(`permission_request send to ${chat_id} failed: ${e}\n`) }) } @@ -683,6 +685,37 @@ 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:` or `perm:deny:` — set when the request was sent. +// 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):([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 + void mcp.notification({ + method: 'notifications/claude/channel/permission', + params: { request_id, behavior }, + }) + 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) })