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

@ -245,6 +245,10 @@ export class HarnessApprovalQueue {
await this.#rejectInteraction(interaction, payload);
return true;
}
// Optional channel-provided renderer. When present, the approval is shown
// as an interactive card (e.g. Feishu approve/reject buttons) instead of
// plain text. Channels that don't provide one keep the text-reply path.
const render = typeof context?.render === 'function' ? context.render : null;
const text = harnessApprovalText(payload, {
toolCall: interaction.toolCall,
@ -267,6 +271,7 @@ export class HarnessApprovalQueue {
actor,
requiresMention: context.requiresMention === true,
send,
render,
text,
presented: false,
presentationTask: null,
@ -287,6 +292,20 @@ export class HarnessApprovalQueue {
return true;
}
/**
* Submit an approval decision by id, as triggered by a channel card button
* (e.g. Feishu approve/reject). Returns false when no matching pending
* approval is found. Callers may pass the acting user to enforce that only
* the originating actor can decide.
*/
async submitByApprovalId(approvalId, outcome, { actor } = {}) {
const pending = this.#byId.get(cleanText(approvalId));
if (!pending || pending.inactive || pending.resolving || pending.submitting) return false;
if (actor !== undefined && pending.actor !== actor) return false;
await this.#submit(pending, outcome);
return true;
}
async handleResolved(resolution) {
if (resolution?.kind !== 'approval') return false;
const pending = this.#byId.get(cleanText(resolution.interactionId));
@ -353,7 +372,11 @@ export class HarnessApprovalQueue {
if (this.#routes.get(pending.key)?.items[0] !== pending
|| pending.inactive || pending.resolving || pending.presented) return;
if (pending.presentationTask) return pending.presentationTask;
const task = Promise.resolve().then(() => pending.send(pending.text));
// A channel-provided renderer shows the approval as an interactive card
// (e.g. approve/reject buttons); otherwise fall back to plain text.
const task = pending.render
? Promise.resolve().then(() => pending.render(pending, pending.send))
: Promise.resolve().then(() => pending.send(pending.text));
pending.presentationTask = task;
try {
await task;

View file

@ -356,4 +356,15 @@ export default {
'⚠️ Repair verification failed: the dedicated test card could not be sent, so card.action.trigger cannot be confirmed restored. Do not authorize again; check the bot message permission and connection status first.',
'⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。':
'⚠️ Repair verification interrupted: the Runtime stopped before the card.action.trigger test completed, so the repair cannot be confirmed. Do not authorize again; wait for the bot to reconnect.',
// feishu/bridge.mjs — interaction cards (approve/reject / answer buttons)
'该审批已处理或不存在,无需重复操作。':
'This approval has already been processed or does not exist; no need to repeat the action.',
// feishu/feishu-cards.mjs — approval card
'操作参数:\n{operation}': 'Operation parameters:\n{operation}',
'✅ 批准': '✅ Approve',
'❌ 拒绝': '❌ Reject',
'🔐 工具审批': '🔐 Tool approval',
// feishu/feishu-cards.mjs — question card
'❓ 请补充信息{progress}': '❓ Please provide more information{progress}',
};