From 4b1e2a28ceab32b49b447d6255d6f3c4fa8774de Mon Sep 17 00:00:00 2001 From: Noah Zweben Date: Mon, 23 Mar 2026 22:53:47 -0700 Subject: [PATCH] feat(telegram,discord): compact permission messages with expandable details (#952) * feat(telegram,discord): compact permission messages with expandable details Replace verbose permission request messages with a compact format showing only the tool name. Adds a "See more" button that expands inline to show tool_name, description, and pretty-printed input_preview JSON. Yes/No buttons replace Allow/Deny. Bump plugin versions to 0.0.4. * revert: restore Allow/Deny button labels --- .../discord/.claude-plugin/plugin.json | 2 +- external_plugins/discord/server.ts | 53 ++++++++++++++++--- .../telegram/.claude-plugin/plugin.json | 2 +- external_plugins/telegram/server.ts | 42 ++++++++++++--- 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/external_plugins/discord/.claude-plugin/plugin.json b/external_plugins/discord/.claude-plugin/plugin.json index 56d56bd..6418b1e 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.3", + "version": "0.0.4", "keywords": [ "discord", "messaging", diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index f5c1376..1752ebf 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -463,6 +463,9 @@ const mcp = new Server( }, ) +// Stores full permission details for "See more" expansion keyed by request_id. +const pendingPermissions = new Map() + // 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 @@ -479,12 +482,14 @@ mcp.setNotificationHandler( }), async ({ params }) => { const { request_id, tool_name, description, input_preview } = params + pendingPermissions.set(request_id, { tool_name, description, input_preview }) const access = loadAccess() - const text = - `🔐 Permission request [${request_id}]\n` + - `${tool_name}: ${description}\n` + - `${input_preview}` + const text = `🔐 Permission: ${tool_name}` const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`perm:more:${request_id}`) + .setLabel('See more') + .setStyle(ButtonStyle.Secondary), new ButtonBuilder() .setCustomId(`perm:allow:${request_id}`) .setLabel('Allow') @@ -734,11 +739,11 @@ client.on('error', err => { }) // Button-click handler for permission requests. customId is -// `perm:allow:` or `perm:deny:` — set when the request was sent. +// `perm:allow:`, `perm:deny:`, or `perm:more:`. // 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) + const m = /^perm:(allow|deny|more):([a-km-z]{5})$/.exec(interaction.customId) if (!m) return const access = loadAccess() if (!access.allowFrom.includes(interaction.user.id)) { @@ -746,10 +751,46 @@ client.on('interactionCreate', async (interaction: Interaction) => { return } const [, behavior, request_id] = m + + if (behavior === 'more') { + const details = pendingPermissions.get(request_id) + if (!details) { + await interaction.reply({ content: 'Details no longer available.', ephemeral: true }).catch(() => {}) + return + } + const { tool_name, description, input_preview } = details + let prettyInput: string + try { + prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2) + } catch { + prettyInput = input_preview + } + const expanded = + `🔐 Permission: ${tool_name}\n\n` + + `tool_name: ${tool_name}\n` + + `description: ${description}\n` + + `input_preview:\n${prettyInput}` + 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), + ) + await interaction.update({ content: expanded, components: [row] }).catch(() => {}) + return + } + void mcp.notification({ method: 'notifications/claude/channel/permission', params: { request_id, behavior }, }) + pendingPermissions.delete(request_id) 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. diff --git a/external_plugins/telegram/.claude-plugin/plugin.json b/external_plugins/telegram/.claude-plugin/plugin.json index 83cfc40..2763481 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.3", + "version": "0.0.4", "keywords": [ "telegram", "messaging", diff --git a/external_plugins/telegram/server.ts b/external_plugins/telegram/server.ts index 67574ce..3211bba 100644 --- a/external_plugins/telegram/server.ts +++ b/external_plugins/telegram/server.ts @@ -379,6 +379,9 @@ const mcp = new Server( }, ) +// Stores full permission details for "See more" expansion keyed by request_id. +const pendingPermissions = new Map() + // 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 @@ -395,12 +398,11 @@ mcp.setNotificationHandler( }), async ({ params }) => { const { request_id, tool_name, description, input_preview } = params + pendingPermissions.set(request_id, { tool_name, description, input_preview }) const access = loadAccess() - const text = - `🔐 Permission request [${request_id}]\n` + - `${tool_name}: ${description}\n` + - `${input_preview}` + const text = `🔐 Permission: ${tool_name}` const keyboard = new InlineKeyboard() + .text('See more', `perm:more:${request_id}`) .text('✅ Allow', `perm:allow:${request_id}`) .text('❌ Deny', `perm:deny:${request_id}`) for (const chat_id of access.allowFrom) { @@ -686,11 +688,11 @@ bot.command('status', async ctx => { }) // Inline-button handler for permission requests. Callback data is -// `perm:allow:` or `perm:deny:` — set when the request was sent. +// `perm:allow:`, `perm:deny:`, or `perm:more:`. // 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) + const m = /^perm:(allow|deny|more):([a-km-z]{5})$/.exec(data) if (!m) { await ctx.answerCallbackQuery().catch(() => {}) return @@ -702,10 +704,38 @@ bot.on('callback_query:data', async ctx => { return } const [, behavior, request_id] = m + + if (behavior === 'more') { + const details = pendingPermissions.get(request_id) + if (!details) { + await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}) + return + } + const { tool_name, description, input_preview } = details + let prettyInput: string + try { + prettyInput = JSON.stringify(JSON.parse(input_preview), null, 2) + } catch { + prettyInput = input_preview + } + const expanded = + `🔐 Permission: ${tool_name}\n\n` + + `tool_name: ${tool_name}\n` + + `description: ${description}\n` + + `input_preview:\n${prettyInput}` + const keyboard = new InlineKeyboard() + .text('✅ Allow', `perm:allow:${request_id}`) + .text('❌ Deny', `perm:deny:${request_id}`) + await ctx.editMessageText(expanded, { reply_markup: keyboard }).catch(() => {}) + await ctx.answerCallbackQuery().catch(() => {}) + return + } + void mcp.notification({ method: 'notifications/claude/channel/permission', params: { request_id, behavior }, }) + pendingPermissions.delete(request_id) 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