From 61ca43d19313e92631674a208b14c321bd26584b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:12 +0000 Subject: [PATCH 1/5] fix(discord): resolve user ID from DM interactions for approval clicks Discord puts the clicking user at interaction.member.user for guild interactions but interaction.user for DM interactions. The Gateway handler only checked interaction.member, so DM button clicks resolved to an empty user ID and were silently rejected as unauthorized. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e0..6c9f802 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -105,7 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -162,6 +162,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter content: serialized, timestamp: message.metadata.dateSent.toISOString(), isMention, + isGroup, }; } @@ -195,13 +196,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true)); }); // DMs — by definition addressed to the bot. Thread id flows through @@ -216,7 +217,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, }); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false)); }); // Plain messages in unsubscribed threads. @@ -231,7 +232,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // flood gate. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); // Handle button clicks (ask_user_question) @@ -501,7 +502,10 @@ async function handleForwardedEvent( // type 3 = MessageComponent (button/select) if (interaction.type === 3) { const customId = (interaction.data as Record)?.custom_id as string; - const user = (interaction.member as Record)?.user as Record | undefined; + // In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly. + const user = + ((interaction.member as Record)?.user as Record | undefined) ?? + (interaction.user as Record | undefined); const interactionId = interaction.id as string; const interactionToken = interaction.token as string; From d121cd1cd6b12a764ae79be479bc8d7950fddea4 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:23 +0000 Subject: [PATCH 2/5] fix(router): pass isGroup from adapter through to messaging group creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router hardcoded is_group=0 when auto-creating messaging groups, causing channel mentions to be misclassified as DMs. The Chat SDK bridge knows which handler fired (onDirectMessage vs onNewMention) so thread the signal through InboundMessage → InboundEvent → router. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 4 ++++ src/index.ts | 1 + src/router.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index d8d8f9d..82247a1 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -56,6 +56,8 @@ export interface InboundEvent { * See InboundMessage.isMention for the full explanation. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; }; replyTo?: DeliveryAddress; } @@ -81,6 +83,8 @@ export interface InboundMessage { * router falls back to text-match against agent_group_name. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/index.ts b/src/index.ts index d3de4d9..ea9fba6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ async function main(): Promise { content: JSON.stringify(message.content), timestamp: message.timestamp, isMention: message.isMention, + isGroup: message.isGroup, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index 538c270..3cf0192 100644 --- a/src/router.ts +++ b/src/router.ts @@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise { channel_type: event.channelType, platform_id: event.platformId, name: null, - is_group: 0, + is_group: event.message.isGroup ? 1 : 0, unknown_sender_policy: 'request_approval', denied_at: null, created_at: new Date().toISOString(), From 15f30682d79a4fd2721990ee6b126178da41afc0 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:34 +0000 Subject: [PATCH 3/5] fix(approvals): show human-readable names in approval cards Channel and sender approval cards showed raw platform IDs (e.g. discord:1475578393738219540:...) instead of readable context. Extract sender name from the event content for channel approvals, and use the channel type name for sender approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/permissions/channel-approval.ts | 20 ++++++++++++++++---- src/modules/permissions/sender-approval.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index caef815..e4b2142 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -101,13 +101,25 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) return; } - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; - const isGroup = originMg?.is_group === 1; + const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; + + // Extract sender name from the event content for a human-readable card. + let senderName: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + senderName = (parsed.senderName ?? parsed.sender) as string | undefined; + } catch { + // non-critical — fall through to generic wording + } const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const question = isGroup - ? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?` - : `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`; + ? senderName + ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : senderName + ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` + : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; createPendingChannelApproval({ messaging_group_id: messagingGroupId, diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index e08123a..a20e14f 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -88,7 +88,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const approvalId = generateId(); const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + const originName = originMg?.name ?? `a ${originChannelType} channel`; const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; From 40f5683c3660d8f63982bfcb9f29f1157607a12e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:45 +0000 Subject: [PATCH 4/5] fix(approvals): show correct post-click labels on channel/sender cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender only checked pending_questions and pending_approvals, missing the channel and sender approval tables. Approval button clicks showed the raw value ("approve") instead of the selectedLabel ("✅ Wired"). Extend the lookup to also check pending_channel_approvals and pending_sender_approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/sessions.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a6..e9461ca 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,5 +1,5 @@ import type { PendingApproval, PendingQuestion, Session } from '../types.js'; -import { getDb } from './connection.js'; +import { getDb, hasTable } from './connection.js'; // ── Sessions ── @@ -192,6 +192,35 @@ export function getAskQuestionRender( const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as | { title: string; options_json: string } | undefined; - if (!a || !a.title) return undefined; - return { title: a.title, options: JSON.parse(a.options_json) }; + if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; + + // Channel-registration approval — options are fixed constants. + if (hasTable(getDb(), 'pending_channel_approvals')) { + const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); + if (c) { + return { + title: '📣 Channel registration', + options: [ + { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, + { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, + ], + }; + } + } + + // Unknown-sender approval — options are fixed constants. + if (hasTable(getDb(), 'pending_sender_approvals')) { + const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); + if (s) { + return { + title: '👤 New sender', + options: [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, + ], + }; + } + } + + return undefined; } From f351e460083b6128a9ee6d8d5108bb17706bd0fc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:47 +0300 Subject: [PATCH 5/5] refactor(approvals): persist title+options on channel/sender approval tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender used to hardcode the card title and option labels for pending_channel_approvals and pending_sender_approvals in the DB-access layer, duplicating wording that already lived in the approval modules. That caused a visible drift between the initial card title — picked per event in channel-approval.ts ("📣 Bot mentioned in new chat" vs. "💬 New direct message") — and the post-click render, which always showed the constant "📣 Channel registration". Mirror the pattern already used by pending_approvals: add title / options_json columns on both pending_*_approvals tables via migration 013, have the approval modules write them at creation time, and let getAskQuestionRender just SELECT. - Migration 013 ALTERs the two tables to add title + options_json. - PendingChannelApproval / PendingSenderApproval types and their create functions grow the two fields. - channel-approval.ts / sender-approval.ts normalize options once and pass both title and options_json into the insert. - getAskQuestionRender drops the hardcoded render objects and reads the stored values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../013-approval-render-metadata.ts | 27 ++++++++++++++++ src/db/migrations/index.ts | 2 ++ src/db/sessions.ts | 32 ++++++------------- src/modules/permissions/channel-approval.ts | 5 ++- .../db/pending-channel-approvals.ts | 8 +++-- .../db/pending-sender-approvals.ts | 10 ++++-- src/modules/permissions/sender-approval.ts | 5 ++- 7 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 src/db/migrations/013-approval-render-metadata.ts diff --git a/src/db/migrations/013-approval-render-metadata.ts b/src/db/migrations/013-approval-render-metadata.ts new file mode 100644 index 0000000..3a1af28 --- /dev/null +++ b/src/db/migrations/013-approval-render-metadata.ts @@ -0,0 +1,27 @@ +/** + * Persist ask_question render metadata (title + options_json) on + * `pending_channel_approvals` and `pending_sender_approvals`, mirroring the + * columns migration 003 / module-approvals-title-options added to + * `pending_approvals`. + * + * Before this, `getAskQuestionRender` hardcoded the title + option labels + * for these two tables in the DB-access layer — duplicating wording that + * also lived in the approval modules and causing a visible drift between + * the initial card title ("📣 Bot mentioned in new chat" / "💬 New direct + * message", chosen per event) and the post-click render ("📣 Channel + * registration", constant). Storing the render metadata alongside the row + * lets both sides read from the same source. + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration013: Migration = { + version: 13, + name: 'approval-render-metadata', + up(db: Database.Database) { + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 33e6963..b46e678 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -9,6 +9,7 @@ import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; +import { migration013 } from './013-approval-render-metadata.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -29,6 +30,7 @@ const migrations: Migration[] = [ migration010, migration011, migration012, + migration013, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/sessions.ts b/src/db/sessions.ts index e9461ca..5c53ad5 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -194,32 +194,20 @@ export function getAskQuestionRender( | undefined; if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; - // Channel-registration approval — options are fixed constants. + // Channel-registration + unknown-sender approvals persist title/options_json + // the same way pending_approvals does — just SELECT and return. if (hasTable(getDb(), 'pending_channel_approvals')) { - const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); - if (c) { - return { - title: '📣 Channel registration', - options: [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, - ], - }; - } + const c = getDb() + .prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(id) as { title: string; options_json: string } | undefined; + if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) }; } - // Unknown-sender approval — options are fixed constants. if (hasTable(getDb(), 'pending_sender_approvals')) { - const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); - if (s) { - return { - title: '👤 New sender', - options: [ - { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, - { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, - ], - }; - } + const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as + | { title: string; options_json: string } + | undefined; + if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) }; } return undefined; diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index e4b2142..8ab41bc 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -120,6 +120,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) : senderName ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingChannelApproval({ messaging_group_id: messagingGroupId, @@ -127,6 +128,8 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -151,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) questionId: messagingGroupId, title, question, - options: normalizeOptions(APPROVAL_OPTIONS), + options, }), ); log.info('Channel registration card delivered', { diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d3e665a..d402074 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -17,6 +17,10 @@ export interface PendingChannelApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingChannelApproval(row: PendingChannelApproval): void { @@ -24,11 +28,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void .prepare( `INSERT INTO pending_channel_approvals ( messaging_group_id, agent_group_id, original_message, - approver_user_id, created_at + approver_user_id, created_at, title, options_json ) VALUES ( @messaging_group_id, @agent_group_id, @original_message, - @approver_user_id, @created_at + @approver_user_id, @created_at, @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts index 77a5699..4d32bf4 100644 --- a/src/modules/permissions/db/pending-sender-approvals.ts +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -19,6 +19,10 @@ export interface PendingSenderApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingSenderApproval(row: PendingSenderApproval): void { @@ -26,11 +30,13 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void { .prepare( `INSERT INTO pending_sender_approvals ( id, messaging_group_id, agent_group_id, sender_identity, - sender_name, original_message, approver_user_id, created_at + sender_name, original_message, approver_user_id, created_at, + title, options_json ) VALUES ( @id, @messaging_group_id, @agent_group_id, @sender_identity, - @sender_name, @original_message, @approver_user_id, @created_at + @sender_name, @original_message, @approver_user_id, @created_at, + @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index a20e14f..fb3e24e 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -92,6 +92,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingSenderApproval({ id: approvalId, @@ -102,6 +103,8 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): original_message: JSON.stringify(event), approver_user_id: target.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): questionId: approvalId, title, question, - options: APPROVAL_OPTIONS, + options, }), ); log.info('Unknown-sender approval card delivered', {