fix(v2/approvals): render correct title + selected label after click
Approval cards bypass the deliverMessage path that populates pending_questions, so the post-click lookup found nothing and the card edit fell back to "❓ Question" + the raw option value ("approve"/"reject"). Store title and normalized options on pending_approvals as well, and look up either table via a shared getAskQuestionRender helper so the chat-sdk post-click edit and the Discord interaction callback render the per-card title and the selectedLabel (e.g. "✅ Approved"). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
|||||||
import { log } from '../log.js';
|
import { log } from '../log.js';
|
||||||
import { SqliteStateAdapter } from '../state-sqlite.js';
|
import { SqliteStateAdapter } from '../state-sqlite.js';
|
||||||
import { registerWebhookAdapter } from '../webhook-server.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 { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||||
import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.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 selectedOption = event.value || '';
|
||||||
const userId = event.user?.userId || '';
|
const userId = event.user?.userId || '';
|
||||||
|
|
||||||
// Look up the pending question BEFORE dispatching onAction (which deletes it).
|
// Resolve render metadata BEFORE dispatching onAction (which deletes the row).
|
||||||
const pq = getPendingQuestion(questionId);
|
const render = getAskQuestionRender(questionId);
|
||||||
const title = pq?.title ?? '❓ Question';
|
const title = render?.title ?? '❓ Question';
|
||||||
const matched = pq?.options.find((o) => o.value === selectedOption);
|
const matched = render?.options.find((o) => o.value === selectedOption);
|
||||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||||
|
|
||||||
// Update the card to show the selected answer and remove buttons
|
// Update the card to show the selected answer and remove buttons
|
||||||
@@ -519,9 +519,9 @@ async function handleForwardedEvent(
|
|||||||
const originalEmbeds =
|
const originalEmbeds =
|
||||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||||
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
const originalDescription = (originalEmbeds[0]?.description as string) || '';
|
||||||
const pq = questionId ? getPendingQuestion(questionId) : undefined;
|
const render = questionId ? getAskQuestionRender(questionId) : undefined;
|
||||||
const cardTitle = pq?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question');
|
||||||
const matchedOpt = pq?.options.find((o) => o.value === selectedOption);
|
const matchedOpt = render?.options.find((o) => o.value === selectedOption);
|
||||||
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||||
try {
|
try {
|
||||||
await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, {
|
await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, {
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const migration003: Migration = {
|
|||||||
platform_id TEXT,
|
platform_id TEXT,
|
||||||
platform_message_id TEXT,
|
platform_message_id TEXT,
|
||||||
expires_at 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
|
CREATE INDEX idx_pending_approvals_action_status
|
||||||
|
|||||||
@@ -108,16 +108,21 @@ export function deletePendingQuestion(questionId: string): void {
|
|||||||
|
|
||||||
export function createPendingApproval(
|
export function createPendingApproval(
|
||||||
pa: Partial<PendingApproval> &
|
pa: Partial<PendingApproval> &
|
||||||
Pick<PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at'>,
|
Pick<
|
||||||
|
PendingApproval,
|
||||||
|
'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json'
|
||||||
|
>,
|
||||||
): void {
|
): void {
|
||||||
getDb()
|
getDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO pending_approvals
|
`INSERT INTO pending_approvals
|
||||||
(approval_id, session_id, request_id, action, payload, created_at,
|
(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
|
VALUES
|
||||||
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
|
(@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({
|
.run({
|
||||||
session_id: null,
|
session_id: null,
|
||||||
@@ -148,3 +153,20 @@ export function deletePendingApproval(approvalId: string): void {
|
|||||||
export function getPendingApprovalsByAction(action: string): PendingApproval[] {
|
export function getPendingApprovalsByAction(action: string): PendingApproval[] {
|
||||||
return getDb().prepare('SELECT * FROM pending_approvals WHERE action = ?').all(action) as 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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ async function requestApproval(
|
|||||||
const adminChannel = adminMGs[0];
|
const adminChannel = adminMGs[0];
|
||||||
|
|
||||||
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const normalizedOptions = normalizeOptions(APPROVAL_OPTIONS);
|
||||||
createPendingApproval({
|
createPendingApproval({
|
||||||
approval_id: approvalId,
|
approval_id: approvalId,
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
@@ -140,6 +141,8 @@ async function requestApproval(
|
|||||||
action,
|
action,
|
||||||
payload: JSON.stringify(payload),
|
payload: JSON.stringify(payload),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
title,
|
||||||
|
options_json: JSON.stringify(normalizedOptions),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deliveryAdapter) {
|
if (deliveryAdapter) {
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
|||||||
const approvalId = shortApprovalId();
|
const approvalId = shortApprovalId();
|
||||||
const question = buildQuestion(request, originGroup?.name ?? request.agent.name);
|
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;
|
let platformMessageId: string | undefined;
|
||||||
try {
|
try {
|
||||||
platformMessageId = await adapterRef.deliver(
|
platformMessageId = await adapterRef.deliver(
|
||||||
@@ -147,12 +152,9 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: 'ask_question',
|
type: 'ask_question',
|
||||||
questionId: approvalId,
|
questionId: approvalId,
|
||||||
title: 'Credentials Request',
|
title: onecliTitle,
|
||||||
question,
|
question,
|
||||||
options: [
|
options: onecliOptions,
|
||||||
{ label: 'Approve', selectedLabel: '✅ Approved', value: 'approve' },
|
|
||||||
{ label: 'Reject', selectedLabel: '❌ Rejected', value: 'reject' },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -180,6 +182,8 @@ async function handleRequest(request: ApprovalRequest): Promise<Decision> {
|
|||||||
platform_message_id: platformMessageId ?? null,
|
platform_message_id: platformMessageId ?? null,
|
||||||
expires_at: request.expiresAt,
|
expires_at: request.expiresAt,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
title: onecliTitle,
|
||||||
|
options_json: JSON.stringify(onecliOptions),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expiry timer fires just before the gateway's own TTL so our decision lands
|
// Expiry timer fires just before the gateway's own TTL so our decision lands
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export interface PendingApproval {
|
|||||||
platform_message_id: string | null;
|
platform_message_id: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
status: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||||
|
title: string;
|
||||||
|
options_json: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pending credentials (central DB) ──
|
// ── Pending credentials (central DB) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user