Pārlūkot izejas kodu

feat(telegram,discord): inline buttons for permission approval (#945)

Replace "Reply 'yes abcde' to allow" text instruction with native
inline buttons (Telegram InlineKeyboard, Discord ButtonBuilder).
One tap to approve/deny instead of typing a 5-char ID.

- Telegram: callback_query handler with allowFrom gate, edits message
  to show outcome and remove buttons after decision
- Discord: interactionCreate handler with allowFrom gate, updates
  interaction with outcome and clears components
- Text-reply path (PERMISSION_REPLY_RE) kept as fallback
- Bump both plugins to v0.0.3

:house: Remote-Dev: homespace
Daisy S. Hollman 5 mēneši atpakaļ
vecāks
revīzija
b3a0714d7f

+ 1 - 1
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",

+ 43 - 3
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<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),
+    )
     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:<id>` or `perm:deny:<id>` — 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`))

+ 1 - 1
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",

+ 37 - 4
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:<id>` or `perm:deny:<id>` — 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)
 })