Merge pull request #111 from C3H3-AI/feat/feishu-interaction-cards

feat(feishu): interactive approval and question cards with buttons
This commit is contained in:
xiemanR 2026-09-03 01:01:14 +08:00 • committed by GitHub
commit 3d25137824
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1015 additions and 226 deletions

File diff suppressed because one or more lines are too long

View file

@ -76,6 +76,7 @@ import {
MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL,
STEER_CUSTOM_SENTINEL,
approvalCard,
completionCard,
customSteerCard,
helpCard,
@ -83,6 +84,7 @@ import {
menuHelpText,
modelCard,
presetCard,
questionCard,
sessionListCard,
statusCard,
steerCard,
@ -148,6 +150,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|workspaces|wsl|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();
@ -471,6 +500,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,
@ -490,6 +521,7 @@ export class FeishuHarnessBridge {
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
replyTimeoutMs = 600_000,
interactionCards = true,
logger = console,
signal,
}) {
@ -528,6 +560,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;
@ -1710,7 +1743,7 @@ export class FeishuHarnessBridge {
return;
}
}
await this.#handleCardAction(resolvedAction, entry);
await this.#handleCardAction(resolvedAction, { ...entry, actor: entry.operatorOpenId });
}, {
lane: isStop || isRealSteer ? 'control' : 'regular',
coalesceStop: isStop,
@ -1868,10 +1901,49 @@ export class FeishuHarnessBridge {
sessionPage = 0,
sessionLimit = null,
selections = [],
actor = null,
}) {
// 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';
// Bind the decision to the operator so another allowed group member
// cannot decide someone else's approval.
const submitted = await this.#approvals.submitByApprovalId(approvalId, outcome, { actor });
if (!submitted) {
await reply(t('该审批已处理或不存在,无需重复操作。')).catch(() => undefined);
}
return;
}
// Question option buttons: answer:<interactionId>:<index>:<optionLabel>
if (action.startsWith('answer:')) {
const rest = action.slice('answer:'.length);
const firstSep = rest.indexOf(':');
if (firstSep !== -1) {
const interactionId = rest.slice(0, firstSep);
const afterId = rest.slice(firstSep + 1);
const indexSep = afterId.indexOf(':');
const indexText = indexSep === -1 ? afterId : afterId.slice(0, indexSep);
const optionLabel = indexSep === -1 ? '' : afterId.slice(indexSep + 1);
const qKey = this.#interactionKeys.get(interactionId);
const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
// Only the actor who started the interaction may answer it, and the
// card must still target the current question (a stale card from an
// earlier question in a multi-question interaction must not submit).
if (pending && pending.kind === 'question' && !pending.submitting
&& pending.actor === actor
&& Number(indexText) === pending.index) {
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(
@ -3585,7 +3657,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) {
@ -3603,6 +3686,7 @@ export class FeishuHarnessBridge {
}
pending.submitting = true;
const key = pending.key;
try {
await pending.interaction.respond({
ok: true,
@ -3620,7 +3704,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;
@ -3628,8 +3714,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);
}
}
}
@ -3645,6 +3733,32 @@ 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(async () => {
// Fall back to the plain-text approval if the card cannot be
// sent. If the text send also fails, let the error propagate so
// the pending approval is not marked as presented and the
// existing retry/reconnect logic can run.
await this.#send(chatId, pending.text, { replyTo: replyToMessageId });
});
},
}
: {}),
})) return;
// Approval requests return above; the existing question state machine stays unchanged.
@ -3738,18 +3852,53 @@ 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(async () => {
// Fall back to the plain-text question if the card cannot be sent.
// If the text send also fails, let the error propagate so the pending
// question is not marked as presented and the existing retry logic runs.
await this.#send(
pending.chatId,
harnessQuestionText(question, pending.index, pending.questions.length, {
requiresMention: pending.requiresMention,
}),
{ replyTo: pending.replyToMessageId },
);
});
} 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

@ -869,3 +869,70 @@ 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;
// Action carries the question index so a stale card from a previous
// question cannot be applied to the current one: answer:<interactionId>:<index>:<label>
elements.push(button(buttonText, `answer:${interactionId}:${index}:${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,
});

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}',
};

View file

@ -1394,6 +1394,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']),
});
@ -1522,6 +1525,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']),
});
@ -1865,6 +1871,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']),
});
@ -1915,6 +1924,482 @@ 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:0:测试环境'),
'first option button action missing');
assert.ok(actions.includes('answer:question-card-id:0:生产环境'),
'second option button action missing');
await bridge.onCardAction(
cardActionEvent('om_card_1', 'answer:question-card-id:0:测试环境', '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('a different allowed group member cannot approve or answer an interaction card', async () => {
const fixture = stateFixture([['group:oc_group', 'session-group-actor']]);
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-actor-bound',
rpcId: 'rpc-approval-actor-bound',
sessionId,
payload: {
type: 'approval/requested',
sessionId,
approvalId: 'approval-actor-bound',
toolName: 'bash',
callId: 'call-actor',
reason: '需要确认',
},
toolCall: { callId: 'call-actor', name: 'bash', arguments: '{}' },
respond: async (result) => {
decisions.push(result);
decided.resolve();
return { accepted: true };
},
});
await decided.promise;
return '已完成';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner', 'ou_member']),
});
const turn = bridge.accept(event('actor-bound-start', '发起审批', {
senderOpenId: 'ou_owner',
chat_type: 'group',
chat_id: 'oc_group',
mentions: [{ key: '@bot', id: { open_id: 'bot' } }],
}));
await eventually(
() => sent.some(({ msgType }) => msgType === 'interactive'),
'the approval card was not sent',
);
// A different allowed group member clicks approve: must be ignored.
await bridge.onCardAction(
cardActionEvent('om_card_1', 'approve:approval-actor-bound', 'ou_member'),
);
assert.deepEqual(decisions, [], 'another allowed member must not approve');
// The originating actor's click does go through.
await bridge.onCardAction(
cardActionEvent('om_card_1', 'approve:approval-actor-bound', 'ou_owner'),
);
await turn;
assert.deepEqual(decisions, [{
ok: true,
value: {
sessionId: 'session-group-actor',
approvalId: 'approval-actor-bound',
outcome: 'allowed-once',
},
}]);
});
test('a stale question card cannot answer the next question in a multi-question interaction', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-stale-card']]);
const sent = [];
const response = 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: 'stale-question',
rpcId: 'stale-question',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [
{ id: 'first', question: '第一问', options: [{ label: 'A' }, { label: 'B' }] },
{ id: 'second', question: '第二问', options: [{ label: 'C' }, { label: 'D' }] },
],
},
respond: async (result) => {
response.resolve(result);
return { accepted: true };
},
});
await response.promise;
return '两问均完成';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
const turn = bridge.accept(event('stale-card-start', '分步提问'));
await eventually(
() => sent.some(({ msgType }) => msgType === 'interactive'),
'the first question card was not sent',
);
// Answer question 1 via its card (index 0), advancing to question 2.
await bridge.onCardAction(cardActionEvent('om_card_1', 'answer:stale-question:0:A', 'ou_user'));
await eventually(
() => cards(sent).length >= 2,
'the second question card was not sent',
);
// A stale click on question 1's old card (index 0) must not answer question 2.
await bridge.onCardAction(cardActionEvent('om_card_1', 'answer:stale-question:0:B', 'ou_user'));
// Answer the current question 2 via its card (index 1).
await bridge.onCardAction(cardActionEvent('om_card_2', 'answer:stale-question:1:C', 'ou_user'));
await turn;
assert.deepEqual(await response.promise, {
ok: true,
value: {
sessionId: 'session-stale-card',
answer: {
answers: [
{ id: 'first', selected: ['A'] },
{ id: 'second', selected: ['C'] },
],
},
},
});
});
test('failure of both the card and the text fallback does not mark the question as presented', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-present-fail']]);
const sent = [];
let responds = 0;
let respondResult = null;
const bridge = new FeishuHarnessBridge({
// Both interactive cards and plain text fail to send.
client: {
im: { v1: { message: {
create: async (request) => {
sent.push(request.data.msg_type);
throw new Error('send disabled');
},
} } },
},
harness: {
sessionExists: async () => true,
createSession: async () => assert.fail('the existing session should be reused'),
ask: async (sessionId, _text, options) => {
// Presenting the question fails (card and text fallback both throw),
// so the interaction is not presented as an answerable question.
await options.onInteraction({
kind: 'question',
interactionId: 'present-fail',
rpcId: 'present-fail',
sessionId,
payload: {
type: 'question/requested',
sessionId,
questions: [{ id: 'only', question: '唯一问题', options: [{ label: 'X' }, { label: 'Y' }] }],
},
respond: async (result) => {
responds += 1;
respondResult = result;
return { accepted: true };
},
});
return 'done';
},
},
state: fixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
// The question card and its text fallback both fail, so the question is
// never presented as an answerable card. Both sends are attempted, and the
// interaction is only ever cancelled (never answered with a choice).
const turn = bridge.accept(event('present-fail-start', '触发问题'));
await eventually(() => sent.length >= 2, 'neither the card nor the text fallback was attempted');
await turn.catch(() => undefined);
assert.equal(responds, 1, 'the interaction must be resolved by cancellation only');
assert.equal(respondResult?.ok, false, 'it must not be answered as a presented question');
assert.equal(respondResult?.error?.code, 'cancelled', 'it must be cancelled, not answered');
});
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
const fixture = stateFixture();
const sent = [];
@ -2545,6 +3030,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']),
});
@ -2633,6 +3122,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']),
});
@ -2711,6 +3203,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']),
});
@ -3542,6 +4037,10 @@ function issue86Fixture({ withProgressBeforeQuestion }) {
const bridge = new FeishuHarnessBridge({
client,
channel: new VerifiedFeishuChannel({ client }),
// issue #86 tests assert the streaming-card rotation flow, which drives
// the plain-text question reply; the interaction card path is tested
// separately, so pin the text presentation here.
interactionCards: false,
harness: {
sessionExists: async () => true,
ask: async (sessionId, _text, options) => {
@ -3706,6 +4205,10 @@ function issue86RotationFixture({
const bridge = new FeishuHarnessBridge({
client,
channel: new VerifiedFeishuChannel({ client }),
// issue #86 rotation tests assert the streaming-card flow with the
// plain-text question reply; the interaction card path is tested
// separately, so pin the text presentation here.
interactionCards: false,
harness: {
sessionExists: async () => true,
currentWorkspace: () => null,
@ -4697,6 +5200,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

@ -161,6 +161,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]);