Compare commits

...

1 Commits

Author SHA1 Message Date
Daisy Hollman
10b57b54a1 feat(telegram,discord): inline buttons for permission approval
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

🏠 Remote-Dev: homespace
2026-03-24 00:48:20 +00:00
4 changed files with 82 additions and 9 deletions

View File

@@ -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.2", "version": "0.0.3",
"keywords": [ "keywords": [
"discord", "discord",
"messaging", "messaging",

View File

@@ -22,8 +22,12 @@ import {
GatewayIntentBits, GatewayIntentBits,
Partials, Partials,
ChannelType, ChannelType,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
type Message, type Message,
type Attachment, type Attachment,
type Interaction,
} from 'discord.js' } from 'discord.js'
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'
@@ -479,13 +483,24 @@ mcp.setNotificationHandler(
const text = const text =
`🔐 Permission request [${request_id}]\n` + `🔐 Permission request [${request_id}]\n` +
`${tool_name}: ${description}\n` + `${tool_name}: ${description}\n` +
`${input_preview}\n\n` + `${input_preview}`
`Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` 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) { for (const userId of access.allowFrom) {
void (async () => { void (async () => {
try { try {
const user = await client.users.fetch(userId) const user = await client.users.fetch(userId)
await user.send(text) await user.send({ content: text, components: [row] })
} catch (e) { } catch (e) {
process.stderr.write(`permission_request send to ${userId} failed: ${e}\n`) 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`) 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 => { 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`))

View File

@@ -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.2", "version": "0.0.3",
"keywords": [ "keywords": [
"telegram", "telegram",
"messaging", "messaging",

View File

@@ -16,7 +16,7 @@ import {
CallToolRequestSchema, CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js' } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod' 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 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'
@@ -399,10 +399,12 @@ mcp.setNotificationHandler(
const text = const text =
`🔐 Permission request [${request_id}]\n` + `🔐 Permission request [${request_id}]\n` +
`${tool_name}: ${description}\n` + `${tool_name}: ${description}\n` +
`${input_preview}\n\n` + `${input_preview}`
`Reply "yes ${request_id}" to allow or "no ${request_id}" to deny.` const keyboard = new InlineKeyboard()
.text('✅ Allow', `perm:allow:${request_id}`)
.text('❌ Deny', `perm:deny:${request_id}`)
for (const chat_id of access.allowFrom) { 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`) 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.`) 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 => { bot.on('message:text', async ctx => {
await handleInbound(ctx, ctx.message.text, undefined) await handleInbound(ctx, ctx.message.text, undefined)
}) })