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

@ -1260,6 +1260,9 @@ test('a threaded Feishu reply answers a pending Harness question before the orig
clearSession: async (key) => sessions.delete(key),
},
status,
// Pin the plain-text question/approval path (official behaviour). The
// default interactionCards=true is covered by dedicated card tests below.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_user']),
});
@ -1388,6 +1391,9 @@ test('a Harness question is presented as a threaded reply inside a topic group',
clearSession: async (key) => sessions.delete(key),
},
status,
// Pin the plain-text threaded-reply question path. Default interaction
// cards are exercised by the dedicated card tests below.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_user']),
});
@ -1731,6 +1737,9 @@ test('Feishu handles approval replies on the fast lane and presents approvals in
},
state: fixture.state,
status: bridgeStatus(),
// Pin the plain-text approval path (official behaviour). The default card
// approval with approve/reject buttons is covered by dedicated card tests.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_user']),
});
@ -1781,6 +1790,285 @@ test('Feishu handles approval replies on the fast lane and presents approvals in
assert.equal(sent.at(-1).text, '两个审批均已处理');
});
test('an approval is presented as an interactive card with approve and reject buttons by default', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-approval-card']]);
const sent = [];
const decisions = [];
const decided = deferred();
const bridge = new FeishuHarnessBridge({
// No interactionCards option: the default (cards on) is under test.
client: cardClient(async (outgoing) => sent.push(outgoing)),
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, _text, options) => {
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-card-id',
rpcId: 'rpc-approval-card-id',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'approval-card-id',
toolName: 'bash',
callId: 'call-card',
reason: '需要执行一个危险命令',
},
toolCall: {
callId: 'call-card',
name: 'bash',
arguments: JSON.stringify({ operation: 'rm -rf /tmp/x' }),
},
respond: async (result) => {
decisions.push(result);
decided.resolve();
return { accepted: true };
},
});
await decided.promise;
return '审批已通过';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('approval-card-start', '执行危险命令'));
await eventually(
() => sent.some(({ msgType }) => msgType === 'interactive'),
'the approval card was not sent',
);
const card = cards(sent).at(-1).content;
const actions = buttonsFromCard(card).map(callbackAction).filter(Boolean);
assert.ok(actions.includes('approve:approval-card-id'), 'approve button action missing');
assert.ok(actions.includes('reject:approval-card-id'), 'reject button action missing');
// The approvalId must not leak into visible card text; it lives only in the
// approve/reject callback actions.
const visibleText = collectVisibleCardText(card);
assert.equal(visibleText.includes('approval-card-id'), false,
'approval id must not appear in visible card text');
assert.equal(decisions.length, 0);
await bridge.onCardAction(cardActionEvent('om_card_1', 'approve:approval-card-id', 'ou_user'));
await turn;
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-approval-card',
approvalId: 'approval-card-id',
outcome: 'allowed-once',
},
}]);
});
test('approval card reject button submits a rejected outcome', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-approval-reject']]);
const sent = [];
const decisions = [];
const decided = deferred();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, _text, options) => {
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-reject-id',
rpcId: 'rpc-approval-reject-id',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'approval-reject-id',
toolName: 'write_file',
callId: 'call-reject',
reason: '覆盖现有文件',
},
toolCall: {
callId: 'call-reject',
name: 'write_file',
arguments: JSON.stringify({ path: '/etc/hosts' }),
},
respond: async (result) => {
decisions.push(result);
decided.resolve();
return { accepted: true };
},
});
await decided.promise;
return '已拒绝';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('approval-reject-start', '覆盖文件'));
await eventually(
() => sent.some(({ msgType }) => msgType === 'interactive'),
'the approval card was not sent',
);
await bridge.onCardAction(cardActionEvent('om_card_1', 'reject:approval-reject-id', 'ou_user'));
await turn;
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-approval-reject',
approvalId: 'approval-reject-id',
outcome: 'rejected',
},
}]);
});
test('a single-choice question is presented as a card with option buttons by default', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-question-card']]);
const sent = [];
const submitted = deferred();
const bridge = new FeishuHarnessBridge({
client: cardClient(async (outgoing) => sent.push(outgoing)),
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, _text, options) => {
await options.onInteraction({
kind: 'question',
interactionId: 'question-card-id',
rpcId: 'rpc-question-card-id',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{
id: 'env',
header: '测试环境',
question: '请选择测试环境',
options: [{ label: '测试环境' }, { label: '生产环境' }],
}],
},
respond: async (result) => {
submitted.resolve(result);
return { accepted: true };
},
});
await submitted.promise;
return '你选择了:测试环境';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('question-card-start', '请先调用 ask_user_question'));
await eventually(
() => sent.some(({ msgType }) => msgType === 'interactive'),
'the question card was not sent',
);
const card = cards(sent).at(-1).content;
const actions = buttonsFromCard(card).map(callbackAction).filter(Boolean);
assert.ok(actions.includes('answer:question-card-id:测试环境'),
'first option button action missing');
assert.ok(actions.includes('answer:question-card-id:生产环境'),
'second option button action missing');
await bridge.onCardAction(
cardActionEvent('om_card_1', 'answer:question-card-id:测试环境', 'ou_user'),
);
await turn;
assert.deepEqual(await submitted.promise, {
ok: true,
value: {
sessionId: 'session-question-card',
answer: {
answers: [{ id: 'env', selected: ['测试环境'] }],
},
},
});
});
test('an interaction card falls back to plain text when the card send fails', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-approval-fallback']]);
const sent = [];
const decisions = [];
const decided = deferred();
const failingCard = {
im: { v1: { message: {
create: async (request) => {
if (request.data.msg_type === 'interactive') {
throw new Error('card disabled');
}
const outgoing = { text: JSON.parse(request.data.content).text };
sent.push(outgoing);
return { code: 0, data: { message_id: `om_fb_${sent.length}` } };
},
} } },
};
const bridge = new FeishuHarnessBridge({
client: failingCard,
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, _text, options) => {
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-fallback-id',
rpcId: 'rpc-approval-fallback-id',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'approval-fallback-id',
toolName: 'bash',
callId: 'call-fallback',
reason: '审批文本降级测试',
},
toolCall: {
callId: 'call-fallback',
name: 'bash',
arguments: JSON.stringify({ operation: 'echo hello' }),
},
respond: async (result) => {
decisions.push(result);
decided.resolve();
return { accepted: true };
},
});
await decided.promise;
return '审批已通过';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('approval-fallback-start', '触发降级'));
await eventually(
() => sent.some(({ text }) => text.includes('审批文本降级测试')),
'approval did not fall back to plain text after a card send failure',
);
assert.equal(sent.some(({ text }) => text.includes('approval-fallback-id')), false);
// The text fallback keeps the official approve/reject reply flow working.
await bridge.accept(event('approval-fallback-allow', '批准'));
await turn;
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-approval-fallback',
approvalId: 'approval-fallback-id',
outcome: 'allowed-once',
},
}]);
});
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
const fixture = stateFixture();
const sent = [];
@ -2411,6 +2699,10 @@ test('a multi-question interaction keeps ordered canonical answers', async () =>
},
state: fixture.state,
status: bridgeStatus(),
// Pin the plain-text question path so the ordered (1/2)-(2/2) flow and the
// multi-select (text) presentation can be asserted directly. Default
// interaction cards are covered by dedicated card tests.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_user']),
});
@ -2499,6 +2791,9 @@ test('the second answer bypasses the first answer reaction-finalization window',
},
state: fixture.state,
status: bridgeStatus(),
// Pin the plain-text question path so the reaction-finalization window
// assertions stay valid. Default interaction cards are card-tested below.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_user']),
});
@ -2577,6 +2872,9 @@ test('a group interaction question tells the user to mention the bot again', asy
},
state: fixture.state,
status: bridgeStatus(),
// Pin the plain-text mention reminder. Default interaction cards are
// covered by the dedicated card tests below.
interactionCards: false,
allowedSenderOpenIds: new Set(['ou_a']),
});
@ -4563,6 +4861,28 @@ test('preset card selection does not expose internal update errors', async () =>
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
// Collect every visible text fragment of a Card 2.0 object (the "lark_md" and
// "plain_text" elements) while deliberately excluding callback action values,
// which may legitimately carry identifiers such as approval ids or answer labels.
function collectVisibleCardText(card) {
const fragments = [];
const visit = (value) => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!value || typeof value !== 'object') return;
if ((value.tag === 'lark_md' || value.tag === 'plain_text')
&& typeof value.content === 'string') {
fragments.push(value.content);
return;
}
for (const child of Object.values(value)) visit(child);
};
visit(card);
return fragments.join('\n');
}
test('/sessions alias uses the interactive session list and paginates across 25 sessions', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -155,6 +155,11 @@ function fixture(channel, { contextEnhancement, onAsk } = {}) {
});
} else {
bridge = new FeishuHarnessBridge({ ...dependencies, status: {}, allowedSenderOpenIds: new Set(['*']),
// This shared suite asserts that approval/question replies are not
// context-enhanced. It drives the plain-text reply flow, so pin the
// text presentation; the default interaction cards are tested in the
// Feishu bridge tests.
interactionCards: false,
channel: {}, client: { im: { v1: {
message: { create: async (request) => {
calls.push(['createMessage', request]);