mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20:46 +08:00
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:
parent
c2be2389b0
commit
0b42ea9889
8 changed files with 778 additions and 212 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue