feat(feishu): interactive approval and question cards with buttons

Render Harness approval and single-choice question interactions as
interactive Feishu cards with approve/reject and answer buttons,
matching the already-available interactionCards behaviour. Cards are now
the default; set DSH_IM_INTERACTION_CARDS=0 (or interactionCards=false)
to keep the plain-text reply flow.

- bridge.mjs: approve:/reject:/answer: card actions, interactionCards
  option, gated approval render hook, interactive question presentation,
  and operationArguments/printableText helpers; refactor the inline
  question answer into #submitQuestionAnswer so both text replies and
  card buttons share one path.
- feishu-cards.mjs: approvalCard (approve/reject) and questionCard
  (option buttons) templates.
- feishu-runtime.mjs: default interactionCards from env (on).
- harness-approval.mjs: optional channel render hook + submitByApprovalId.
- i18n-en: add the new card t() keys.
- Tests: pin the plain-text reply flow in state-machine suites that
  drive it, and add dedicated card tests for the default approve/reject,
  answer buttons, and the text fallback. Full suite shows no new
  failures versus upstream.
This commit is contained in:
C3H3-AI 2026-09-01 16:43:36 +08:00
parent c2be2389b0
commit 0b42ea9889
8 changed files with 778 additions and 212 deletions

View file

@ -68,6 +68,7 @@ import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
STEER_CUSTOM_SENTINEL,
approvalCard,
completionCard,
customSteerCard,
helpCard,
@ -75,6 +76,7 @@ import {
menuHelpText,
modelCard,
presetCard,
questionCard,
sessionListCard,
statusCard,
steerCard,
@ -140,6 +142,33 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
/** Matches fast card commands that should not be queued behind a running task. */
const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
/** Pretty-print a tool call's arguments for an approval card. */
function operationArguments(toolCall) {
const source = toolCall?.arguments;
if (source !== null && typeof source === 'object') {
try {
return JSON.stringify(source, null, 2);
} catch {
return null;
}
}
if (typeof source !== 'string') return null;
const raw = printableText(source);
// Harness treats an empty tool argument string as an empty object.
if (!raw) return source === '' ? '{}' : null;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
/** Strip control characters so the approval card text stays clean. */
function printableText(value) {
return String(value ?? '')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '');
}
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
if (hasImages || hasFiles || typeof text !== 'string') return false;
const command = text.trim();
@ -462,6 +491,8 @@ export class FeishuHarnessBridge {
/** Earliest completion that still needs delivery for each watch. */
#failedWatchSeqs = new Map();
#cardDataTimeoutMs;
/** When true, approval/question interactions render as Feishu cards (buttons). */
#interactionCards = true;
constructor({
client,
@ -481,6 +512,7 @@ export class FeishuHarnessBridge {
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
replyTimeoutMs = 600_000,
interactionCards = true,
logger = console,
signal,
}) {
@ -519,6 +551,7 @@ export class FeishuHarnessBridge {
this.#repairLinkWaitMs = repairLinkWaitMs;
this.#cardDataTimeoutMs = cardDataTimeoutMs;
this.#replyTimeoutMs = replyTimeoutMs;
this.#interactionCards = interactionCards === true;
this.#logger = logger;
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
this.#signal = signal;
@ -1849,6 +1882,34 @@ export class FeishuHarnessBridge {
// Confirmations triggered by a card interaction stay anchored to the
// card's message so they land inside the same Feishu topic.
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
// Approval card buttons: approve:<approvalId> / reject:<approvalId>
if (action.startsWith('approve:') || action.startsWith('reject:')) {
const sep = action.indexOf(':');
const approvalId = action.slice(sep + 1);
const outcome = action.startsWith('approve:') ? 'allowed-once' : 'rejected';
const submitted = await this.#approvals.submitByApprovalId(approvalId, outcome);
if (!submitted) {
await reply(t('该审批已处理或不存在,无需重复操作。')).catch(() => undefined);
}
return;
}
// Question option buttons: answer:<interactionId>:<optionLabel>
if (action.startsWith('answer:')) {
const rest = action.slice('answer:'.length);
const sep = rest.indexOf(':');
if (sep !== -1) {
const interactionId = rest.slice(0, sep);
const optionLabel = rest.slice(sep + 1);
const qKey = this.#interactionKeys.get(interactionId);
const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
if (pending && pending.kind === 'question' && !pending.submitting) {
await this.#submitQuestionAnswer(pending, optionLabel, { chatId });
} else {
await reply(INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
}
}
return;
}
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
await this.#showSessions(
@ -3552,7 +3613,18 @@ export class FeishuHarnessBridge {
const question = pending.questions[pending.index];
if (!question) return;
pending.answers.push(harnessAnswerForQuestion(question, text));
await this.#submitQuestionAnswer(pending, text, {
chatId: event.message.chat_id,
messageId,
});
}
async #submitQuestionAnswer(pending, answerText, { chatId, messageId } = {}) {
const question = pending.questions[pending.index];
if (!question) return;
pending.chatId = chatId ?? pending.chatId;
pending.answers.push(harnessAnswerForQuestion(question, answerText));
pending.index += 1;
if (pending.index < pending.questions.length) {
if (pending.claimedReplyMessageId === messageId) {
@ -3570,6 +3642,7 @@ export class FeishuHarnessBridge {
}
pending.submitting = true;
const key = pending.key;
try {
await pending.interaction.respond({
ok: true,
@ -3587,7 +3660,9 @@ export class FeishuHarnessBridge {
if (error?.code === 'interaction-not-pending') {
this.#rememberResolvedInteraction(key, pending);
this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
if (chatId && messageId) {
await this.#send(chatId, INTERACTION_RESOLVED_TEXT(), { replyTo: messageId }).catch(() => undefined);
}
return;
}
pending.submitting = false;
@ -3595,8 +3670,10 @@ export class FeishuHarnessBridge {
pending.index -= 1;
this.#status.lastError = '回答提交失败。';
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
.catch(() => undefined);
if (chatId) {
await this.#send(chatId, t('回答提交失败,请重新发送当前问题的答案。'))
.catch(() => undefined);
}
}
}
@ -3612,6 +3689,29 @@ export class FeishuHarnessBridge {
actor,
requiresMention,
send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
// Approvals render as interactive cards with approve/reject buttons by
// default. Set the bridge `interactionCards` option (or
// DSH_IM_INTERACTION_CARDS=0) to keep the plain-text reply flow.
...(this.#interactionCards
? {
render: async (pending) => {
// Show the approval as an interactive card with approve/reject buttons.
await this.#sendCard(
chatId,
approvalCard({
toolName: pending.toolCall?.name ?? pending.payload?.toolName,
operation: operationArguments(pending.toolCall),
reason: pending.payload?.reason,
approvalId: pending.approvalId,
}),
{ key, replyTo: replyToMessageId },
).catch(() => {
// Fall back to the plain-text approval if the card cannot be sent.
return this.#send(chatId, pending.text, { replyTo: replyToMessageId }).catch(() => undefined);
});
},
}
: {}),
})) return;
// Approval requests return above; the existing question state machine stays unchanged.
@ -3705,18 +3805,51 @@ export class FeishuHarnessBridge {
async #presentInteraction(pending) {
const question = pending.questions[pending.index];
if (!question) return;
const messageId = await this.#send(
pending.chatId,
harnessQuestionText(
question,
pending.index,
pending.questions.length,
{ requiresMention: pending.requiresMention },
),
// Reply to the message that started the turn so the question lands in
// the same Feishu thread/topic instead of the group's default area.
{ replyTo: pending.replyToMessageId },
);
const options = Array.isArray(question?.options) ? question.options : [];
// Single-choice questions with options render as interactive cards by
// default. Multi-select or free-text questions and the text reply flow
// remain when `interactionCards` is disabled (or DSH_IM_INTERACTION_CARDS=0).
const interactive = this.#interactionCards
&& options.length > 0
&& question.multiSelect !== true;
let messageId;
if (interactive) {
// Single-choice question with options: render each option as a button.
messageId = await this.#sendCard(
pending.chatId,
questionCard({
interactionId: pending.interactionId,
header: question.header,
question: question.question,
detail: question.detail,
options,
index: pending.index,
total: pending.questions.length,
}),
{ key: pending.key, replyTo: pending.replyToMessageId },
).catch(() => {
// Fall back to the plain-text question if the card cannot be sent.
return this.#send(
pending.chatId,
harnessQuestionText(question, pending.index, pending.questions.length, {
requiresMention: pending.requiresMention,
}),
{ replyTo: pending.replyToMessageId },
).catch(() => undefined);
});
} else {
// Multi-select or free-text questions keep the plain-text reply flow.
messageId = await this.#send(
pending.chatId,
harnessQuestionText(
question,
pending.index,
pending.questions.length,
{ requiresMention: pending.requiresMention },
),
{ replyTo: pending.replyToMessageId },
);
}
if (messageId) {
pending.questionMessageIds.add(messageId);
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);

View file

@ -865,3 +865,68 @@ export function customSteerCard() {
];
return cardWith(t('➕ 自定义指令'), elements);
}
/**
* Interactive approval card with approve / reject buttons. Action values carry
* the approvalId so the card callback can submit the decision:
* approve:<approvalId> / reject:<approvalId>
* `requiresMention` is advisory; a button click is itself the operator's
* explicit intent, so it does not need an @ mention in groups.
*/
export function approvalCard({ toolName, operation, reason, approvalId }) {
const elements = [];
if (toolName) {
elements.push({ tag: 'div', text: markdown(t('工具:{tool}', { tool: String(toolName) })) });
}
if (operation) {
// Cap the operation text so an oversized argument list cannot overflow the
// card (the plain-text path rejects >6000 chars; here we truncate so the
// approve/reject buttons still render).
const MAX_OPERATION_CHARS = 6_000;
const op = String(operation);
const shown = op.length > MAX_OPERATION_CHARS
? `${op.slice(0, MAX_OPERATION_CHARS)}\n…(操作参数过长,已截断)`
: op;
elements.push({ tag: 'div', text: markdown(t('操作参数:\n{operation}', { operation: shown })) });
}
if (reason) {
elements.push({ tag: 'div', text: markdown(t('原因:{reason}', { reason: String(reason) })) });
}
elements.push(
{ tag: 'hr' },
buttonPair(t('✅ 批准'), `approve:${approvalId}`, t('❌ 拒绝'), `reject:${approvalId}`),
);
return cardWith(t('🔐 工具审批'), elements);
}
/**
* Interactive question card. When the question carries options, each option is
* rendered as its own button; the selected option label is submitted via a
* card callback. Multi-select questions fall back to the plain-text flow (the
* caller decides), because a multi-select needs a confirm step.
* Action: answer:<interactionId>:<optionLabel>
*/
export function questionCard({ interactionId, header, question, detail, options, index, total }) {
const elements = [];
const progress = total > 1 ? `(${index + 1}/${total})` : '';
if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
const qText = typeof question === 'string' && question.trim() ? question : t('请输入你的回答。');
elements.push({ tag: 'div', text: markdown(String(qText)) });
if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
if (Array.isArray(options) && options.length > 0) {
elements.push({ tag: 'hr' });
for (const option of options) {
const label = typeof option?.label === 'string' ? option.label : '';
if (!label) continue;
const description = typeof option?.description === 'string' && option.description.trim()
? option.description.trim()
: '';
// Include the option description in the button so the user sees the full
// meaning (mirrors the text form "1. label — description").
const buttonText = description ? `${label}\n${description}` : label;
elements.push(button(buttonText, `answer:${interactionId}:${label}`));
}
}
return cardWith(t('❓ 请补充信息{progress}', { progress }), elements);
}

View file

@ -288,6 +288,11 @@ export class FeishuRuntime {
groupResponseMode: this.#groupResponseMode,
repair: this.#repair,
replyTimeoutMs: this.#replyTimeoutMs,
// Interaction cards (approval/question buttons) are on by default.
// Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
interactionCards: !['0', 'false', 'no', 'off'].includes(
String(process.env.DSH_IM_INTERACTION_CARDS ?? '').trim().toLowerCase(),
),
signal,
logger: this.#logger,
});