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

View File

@@ -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`))

View File

@@ -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",

View File

@@ -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)
})