diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index f3a3e91..7cbfad7 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -21,7 +21,7 @@ import { import { log } from '../log.js'; import { SqliteStateAdapter } from '../state-sqlite.js'; import { registerWebhookAdapter } from '../webhook-server.js'; -import { getPendingQuestion } from '../db/sessions.js'; +import { getAskQuestionRender } from '../db/sessions.js'; import { normalizeOptions, type NormalizedOption } from './ask-question.js'; import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js'; @@ -244,10 +244,10 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const selectedOption = event.value || ''; const userId = event.user?.userId || ''; - // Look up the pending question BEFORE dispatching onAction (which deletes it). - const pq = getPendingQuestion(questionId); - const title = pq?.title ?? '❓ Question'; - const matched = pq?.options.find((o) => o.value === selectedOption); + // Resolve render metadata BEFORE dispatching onAction (which deletes the row). + const render = getAskQuestionRender(questionId); + const title = render?.title ?? '❓ Question'; + const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; // Update the card to show the selected answer and remove buttons @@ -519,9 +519,9 @@ async function handleForwardedEvent( const originalEmbeds = ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; - const pq = questionId ? getPendingQuestion(questionId) : undefined; - const cardTitle = pq?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); - const matchedOpt = pq?.options.find((o) => o.value === selectedOption); + const render = questionId ? getAskQuestionRender(questionId) : undefined; + const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); + const matchedOpt = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; try { await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, { diff --git a/src/db/migrations/003-pending-approvals.ts b/src/db/migrations/003-pending-approvals.ts index 08b99c7..e6e59a6 100644 --- a/src/db/migrations/003-pending-approvals.ts +++ b/src/db/migrations/003-pending-approvals.ts @@ -29,7 +29,9 @@ export const migration003: Migration = { platform_id TEXT, platform_message_id TEXT, expires_at TEXT, - status TEXT NOT NULL DEFAULT 'pending' + status TEXT NOT NULL DEFAULT 'pending', + title TEXT NOT NULL DEFAULT '', + options_json TEXT NOT NULL DEFAULT '[]' ); CREATE INDEX idx_pending_approvals_action_status diff --git a/src/db/sessions.ts b/src/db/sessions.ts index 870434d..495046a 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -108,16 +108,21 @@ export function deletePendingQuestion(questionId: string): void { export function createPendingApproval( pa: Partial & - Pick, + Pick< + PendingApproval, + 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json' + >, ): void { getDb() .prepare( `INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, - agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status) + agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, + title, options_json) VALUES (@approval_id, @session_id, @request_id, @action, @payload, @created_at, - @agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status)`, + @agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status, + @title, @options_json)`, ) .run({ session_id: null, @@ -148,3 +153,20 @@ export function deletePendingApproval(approvalId: string): void { export function getPendingApprovalsByAction(action: string): PendingApproval[] { return getDb().prepare('SELECT * FROM pending_approvals WHERE action = ?').all(action) as PendingApproval[]; } + +/** + * Resolve ask_question render metadata (title + normalized options) for any + * card, regardless of whether it was persisted as a pending_question (generic + * ask_user_question) or a pending_approval (self-mod / OneCLI credential). + */ +export function getAskQuestionRender( + id: string, +): { title: string; options: import('../channels/ask-question.js').NormalizedOption[] } | undefined { + const q = getPendingQuestion(id); + if (q) return { title: q.title, options: q.options }; + 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) }; +} diff --git a/src/delivery.ts b/src/delivery.ts index 51bb7b5..a2d93fa 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -133,6 +133,7 @@ async function requestApproval( const adminChannel = adminMGs[0]; const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS); createPendingApproval({ approval_id: approvalId, session_id: session.id, @@ -140,6 +141,8 @@ async function requestApproval( action, payload: JSON.stringify(payload), created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(normalizedOptions), }); if (deliveryAdapter) { diff --git a/src/onecli-approvals.ts b/src/onecli-approvals.ts index 7030d7e..9cc313a 100644 --- a/src/onecli-approvals.ts +++ b/src/onecli-approvals.ts @@ -137,6 +137,11 @@ async function handleRequest(request: ApprovalRequest): Promise { const approvalId = shortApprovalId(); const question = buildQuestion(request, originGroup?.name ?? request.agent.name); + const onecliTitle = 'Credentials Request'; + const onecliOptions = [ + { label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' }, + { label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' }, + ]; let platformMessageId: string | undefined; try { platformMessageId = await adapterRef.deliver( @@ -147,12 +152,9 @@ async function handleRequest(request: ApprovalRequest): Promise { JSON.stringify({ type: 'ask_question', questionId: approvalId, - title: 'Credentials Request', + title: onecliTitle, question, - options: [ - { label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' }, - { label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' }, - ], + options: onecliOptions, }), ); } catch (err) { @@ -180,6 +182,8 @@ async function handleRequest(request: ApprovalRequest): Promise { platform_message_id: platformMessageId ?? null, expires_at: request.expiresAt, status: 'pending', + title: onecliTitle, + options_json: JSON.stringify(onecliOptions), }); // Expiry timer fires just before the gateway's own TTL so our decision lands diff --git a/src/types.ts b/src/types.ts index 74494e8..08f7e78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,8 @@ export interface PendingApproval { platform_message_id: string | null; expires_at: string | null; status: 'pending' | 'approved' | 'rejected' | 'expired'; + title: string; + options_json: string; } // ── Pending credentials (central DB) ──