feat(v2/approvals): per-card titles and structured options
Approval cards now carry a required title (Add MCP Request, Install Packages Request, Rebuild Request, Credentials Request) and structured options with distinct pre-click label, post-click selectedLabel (e.g. "✅ Approved" / "❌ Rejected"), and value used for click routing. The title and normalized options are persisted in pending_questions so the post-click card edit can render the correct per-type title and selected label on both chat-sdk channels and Discord interactions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
46
src/channels/ask-question.ts
Normal file
46
src/channels/ask-question.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared ask_question payload schema + normalization.
|
||||
*
|
||||
* Producers (host-side approvals, container-side ask_user_question MCP tool)
|
||||
* emit an `ask_question` payload. Options may be bare strings for ergonomics,
|
||||
* but are normalized here into a consistent shape before delivery, persistence,
|
||||
* and rendering.
|
||||
*/
|
||||
|
||||
export interface OptionInput {
|
||||
label: string;
|
||||
selectedLabel?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export type RawOption = string | OptionInput;
|
||||
|
||||
export interface NormalizedOption {
|
||||
label: string;
|
||||
selectedLabel: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function normalizeOption(raw: RawOption): NormalizedOption {
|
||||
if (typeof raw === 'string') {
|
||||
return { label: raw, selectedLabel: raw, value: raw };
|
||||
}
|
||||
const label = raw.label;
|
||||
return {
|
||||
label,
|
||||
selectedLabel: raw.selectedLabel ?? label,
|
||||
value: raw.value ?? label,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOptions(raws: RawOption[]): NormalizedOption[] {
|
||||
return raws.map(normalizeOption);
|
||||
}
|
||||
|
||||
export interface AskQuestionPayload {
|
||||
type: 'ask_question';
|
||||
questionId: string;
|
||||
title: string;
|
||||
question: string;
|
||||
options: NormalizedOption[];
|
||||
}
|
||||
@@ -21,6 +21,8 @@ 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 { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type { ChannelAdapter, ChannelSetup, ConversationConfig, InboundMessage } from './adapter.js';
|
||||
|
||||
/** Adapter with optional gateway support (e.g., Discord). */
|
||||
@@ -243,11 +245,17 @@ 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);
|
||||
const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)';
|
||||
|
||||
// Update the card to show the selected answer and remove buttons
|
||||
try {
|
||||
const tid = event.threadId;
|
||||
await adapter.editMessage(tid, event.messageId, {
|
||||
markdown: `❓ **Question**\n\n${selectedOption ? `✅ **${selectedOption}**` : '(clicked)'}`,
|
||||
markdown: `${title}\n\n${selectedLabel}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn('Failed to update card after action', { err });
|
||||
@@ -342,17 +350,27 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
||||
// Ask question card — render as Card with buttons
|
||||
if (content.type === 'ask_question' && content.questionId && content.options) {
|
||||
const questionId = content.questionId as string;
|
||||
const options = content.options as string[];
|
||||
const title = content.title as string;
|
||||
const question = content.question as string;
|
||||
if (!title) {
|
||||
log.error('ask_question missing required title — skipping delivery', { questionId });
|
||||
return;
|
||||
}
|
||||
const options: NormalizedOption[] = normalizeOptions(content.options as never);
|
||||
const card = Card({
|
||||
title: '❓ Question',
|
||||
title,
|
||||
children: [
|
||||
CardText(content.question as string),
|
||||
Actions(options.map((opt) => Button({ id: `ncq:${questionId}:${opt}`, label: opt, value: opt }))),
|
||||
CardText(question),
|
||||
Actions(
|
||||
options.map((opt) =>
|
||||
Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
const result = await adapter.postMessage(tid, {
|
||||
card,
|
||||
fallbackText: `${content.question}\nOptions: ${options.join(', ')}`,
|
||||
fallbackText: `${title}\n\n${question}\nOptions: ${options.map((o) => o.label).join(', ')}`,
|
||||
});
|
||||
return result?.id;
|
||||
}
|
||||
@@ -502,6 +520,10 @@ async function handleForwardedEvent(
|
||||
const originalEmbeds =
|
||||
((interaction.message as Record<string, unknown>)?.embeds as Array<Record<string, unknown>>) || [];
|
||||
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 selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId;
|
||||
try {
|
||||
await fetch(`https://discord.com/api/v10/interactions/${interactionId}/${interactionToken}/callback`, {
|
||||
method: 'POST',
|
||||
@@ -511,8 +533,8 @@ async function handleForwardedEvent(
|
||||
data: {
|
||||
embeds: [
|
||||
{
|
||||
title: '❓ Question',
|
||||
description: `${originalDescription}\n\n✅ **${selectedOption || customId}**`,
|
||||
title: cardTitle,
|
||||
description: `${originalDescription}\n\n${selectedLabel}`,
|
||||
},
|
||||
],
|
||||
components: [], // remove buttons
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ASSISTANT_HAS_OWN_NUMBER, ASSISTANT_NAME, DATA_DIR } from '../config.js
|
||||
import { readEnvFile } from '../env.js';
|
||||
import { log } from '../log.js';
|
||||
import { registerChannelAdapter } from './channel-registry.js';
|
||||
import { normalizeOptions, type NormalizedOption } from './ask-question.js';
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
ChannelSetup,
|
||||
@@ -69,7 +70,7 @@ const SENT_MESSAGE_CACHE_MAX = 256;
|
||||
const RECONNECT_DELAY_MS = 5000;
|
||||
const PENDING_QUESTIONS_MAX = 64;
|
||||
|
||||
/** Normalize an option name to a slash command: "Approve" → "/approve" */
|
||||
/** Normalize an option label to a slash command: "Approve" → "/approve" */
|
||||
function optionToCommand(option: string): string {
|
||||
return '/' + option.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
@@ -183,7 +184,7 @@ registerChannelAdapter('whatsapp', {
|
||||
string,
|
||||
{
|
||||
questionId: string;
|
||||
options: string[];
|
||||
options: NormalizedOption[];
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -549,15 +550,17 @@ registerChannelAdapter('whatsapp', {
|
||||
const pending = pendingQuestions.get(chatJid);
|
||||
if (pending && content.startsWith('/')) {
|
||||
const cmd = content.trim().toLowerCase();
|
||||
const matched = pending.options.find((o) => optionToCommand(o) === cmd);
|
||||
const matched = pending.options.find((o) => optionToCommand(o.label) === cmd);
|
||||
if (matched) {
|
||||
const voterName = msg.pushName || sender.split('@')[0];
|
||||
setupConfig.onAction(pending.questionId, matched, sender);
|
||||
setupConfig.onAction(pending.questionId, matched.value, sender);
|
||||
pendingQuestions.delete(chatJid);
|
||||
// Past tense for common actions: Approve→Approved, Reject→Rejected
|
||||
const label = matched.endsWith('e') ? `${matched}d` : `${matched}ed`;
|
||||
await sendRawMessage(chatJid, `*${label}* by ${voterName}`);
|
||||
log.info('Question answered', { questionId: pending.questionId, matched, voterName });
|
||||
await sendRawMessage(chatJid, `${matched.selectedLabel} by ${voterName}`);
|
||||
log.info('Question answered', {
|
||||
questionId: pending.questionId,
|
||||
value: matched.value,
|
||||
voterName,
|
||||
});
|
||||
continue; // Don't forward this reply to the agent
|
||||
}
|
||||
}
|
||||
@@ -621,11 +624,16 @@ registerChannelAdapter('whatsapp', {
|
||||
// Ask question → text with slash command replies
|
||||
if (content.type === 'ask_question' && content.questionId && content.options) {
|
||||
const questionId = content.questionId as string;
|
||||
const title = content.title as string;
|
||||
const question = content.question as string;
|
||||
const options = content.options as string[];
|
||||
if (!title) {
|
||||
log.error('ask_question missing required title — skipping delivery', { questionId });
|
||||
return;
|
||||
}
|
||||
const options: NormalizedOption[] = normalizeOptions(content.options as never);
|
||||
|
||||
const optionLines = options.map((o) => ` ${optionToCommand(o)}`).join('\n');
|
||||
const text = `${question}\n\nReply with:\n${optionLines}`;
|
||||
const optionLines = options.map((o) => ` ${optionToCommand(o.label)}`).join('\n');
|
||||
const text = `*${title}*\n\n${question}\n\nReply with:\n${optionLines}`;
|
||||
const msgId = await sendRawMessage(platformId, text);
|
||||
if (msgId) {
|
||||
pendingQuestions.set(platformId, { questionId, options });
|
||||
|
||||
Reference in New Issue
Block a user