mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-14 10:50:44 +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
394
lib/index.js
394
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -68,6 +68,7 @@ import {
|
||||||
MENU_PAGE_SIZE,
|
MENU_PAGE_SIZE,
|
||||||
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
PRESET_FOLLOW_DEFAULT_SENTINEL,
|
||||||
STEER_CUSTOM_SENTINEL,
|
STEER_CUSTOM_SENTINEL,
|
||||||
|
approvalCard,
|
||||||
completionCard,
|
completionCard,
|
||||||
customSteerCard,
|
customSteerCard,
|
||||||
helpCard,
|
helpCard,
|
||||||
|
|
@ -75,6 +76,7 @@ import {
|
||||||
menuHelpText,
|
menuHelpText,
|
||||||
modelCard,
|
modelCard,
|
||||||
presetCard,
|
presetCard,
|
||||||
|
questionCard,
|
||||||
sessionListCard,
|
sessionListCard,
|
||||||
statusCard,
|
statusCard,
|
||||||
steerCard,
|
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. */
|
/** 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;
|
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 } = {}) {
|
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
|
||||||
if (hasImages || hasFiles || typeof text !== 'string') return false;
|
if (hasImages || hasFiles || typeof text !== 'string') return false;
|
||||||
const command = text.trim();
|
const command = text.trim();
|
||||||
|
|
@ -462,6 +491,8 @@ export class FeishuHarnessBridge {
|
||||||
/** Earliest completion that still needs delivery for each watch. */
|
/** Earliest completion that still needs delivery for each watch. */
|
||||||
#failedWatchSeqs = new Map();
|
#failedWatchSeqs = new Map();
|
||||||
#cardDataTimeoutMs;
|
#cardDataTimeoutMs;
|
||||||
|
/** When true, approval/question interactions render as Feishu cards (buttons). */
|
||||||
|
#interactionCards = true;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
client,
|
client,
|
||||||
|
|
@ -481,6 +512,7 @@ export class FeishuHarnessBridge {
|
||||||
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
||||||
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
|
interactionCards = true,
|
||||||
logger = console,
|
logger = console,
|
||||||
signal,
|
signal,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -519,6 +551,7 @@ export class FeishuHarnessBridge {
|
||||||
this.#repairLinkWaitMs = repairLinkWaitMs;
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
||||||
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
this.#cardDataTimeoutMs = cardDataTimeoutMs;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
|
this.#interactionCards = interactionCards === true;
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
||||||
this.#signal = signal;
|
this.#signal = signal;
|
||||||
|
|
@ -1849,6 +1882,34 @@ export class FeishuHarnessBridge {
|
||||||
// Confirmations triggered by a card interaction stay anchored to the
|
// Confirmations triggered by a card interaction stay anchored to the
|
||||||
// card's message so they land inside the same Feishu topic.
|
// card's message so they land inside the same Feishu topic.
|
||||||
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
|
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)) {
|
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
||||||
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
||||||
await this.#showSessions(
|
await this.#showSessions(
|
||||||
|
|
@ -3552,7 +3613,18 @@ export class FeishuHarnessBridge {
|
||||||
const question = pending.questions[pending.index];
|
const question = pending.questions[pending.index];
|
||||||
if (!question) return;
|
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;
|
pending.index += 1;
|
||||||
if (pending.index < pending.questions.length) {
|
if (pending.index < pending.questions.length) {
|
||||||
if (pending.claimedReplyMessageId === messageId) {
|
if (pending.claimedReplyMessageId === messageId) {
|
||||||
|
|
@ -3570,6 +3642,7 @@ export class FeishuHarnessBridge {
|
||||||
}
|
}
|
||||||
|
|
||||||
pending.submitting = true;
|
pending.submitting = true;
|
||||||
|
const key = pending.key;
|
||||||
try {
|
try {
|
||||||
await pending.interaction.respond({
|
await pending.interaction.respond({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -3587,7 +3660,9 @@ export class FeishuHarnessBridge {
|
||||||
if (error?.code === 'interaction-not-pending') {
|
if (error?.code === 'interaction-not-pending') {
|
||||||
this.#rememberResolvedInteraction(key, pending);
|
this.#rememberResolvedInteraction(key, pending);
|
||||||
this.#clearPendingInteraction(key, pending.interactionId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
pending.submitting = false;
|
pending.submitting = false;
|
||||||
|
|
@ -3595,8 +3670,10 @@ export class FeishuHarnessBridge {
|
||||||
pending.index -= 1;
|
pending.index -= 1;
|
||||||
this.#status.lastError = '回答提交失败。';
|
this.#status.lastError = '回答提交失败。';
|
||||||
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
|
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
|
||||||
await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
|
if (chatId) {
|
||||||
.catch(() => undefined);
|
await this.#send(chatId, t('回答提交失败,请重新发送当前问题的答案。'))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3612,6 +3689,29 @@ export class FeishuHarnessBridge {
|
||||||
actor,
|
actor,
|
||||||
requiresMention,
|
requiresMention,
|
||||||
send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
|
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;
|
})) return;
|
||||||
|
|
||||||
// Approval requests return above; the existing question state machine stays unchanged.
|
// Approval requests return above; the existing question state machine stays unchanged.
|
||||||
|
|
@ -3705,18 +3805,51 @@ export class FeishuHarnessBridge {
|
||||||
async #presentInteraction(pending) {
|
async #presentInteraction(pending) {
|
||||||
const question = pending.questions[pending.index];
|
const question = pending.questions[pending.index];
|
||||||
if (!question) return;
|
if (!question) return;
|
||||||
const messageId = await this.#send(
|
const options = Array.isArray(question?.options) ? question.options : [];
|
||||||
pending.chatId,
|
// Single-choice questions with options render as interactive cards by
|
||||||
harnessQuestionText(
|
// default. Multi-select or free-text questions and the text reply flow
|
||||||
question,
|
// remain when `interactionCards` is disabled (or DSH_IM_INTERACTION_CARDS=0).
|
||||||
pending.index,
|
const interactive = this.#interactionCards
|
||||||
pending.questions.length,
|
&& options.length > 0
|
||||||
{ requiresMention: pending.requiresMention },
|
&& question.multiSelect !== true;
|
||||||
),
|
let messageId;
|
||||||
// Reply to the message that started the turn so the question lands in
|
if (interactive) {
|
||||||
// the same Feishu thread/topic instead of the group's default area.
|
// Single-choice question with options: render each option as a button.
|
||||||
{ replyTo: pending.replyToMessageId },
|
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) {
|
if (messageId) {
|
||||||
pending.questionMessageIds.add(messageId);
|
pending.questionMessageIds.add(messageId);
|
||||||
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
|
if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
|
||||||
|
|
|
||||||
|
|
@ -865,3 +865,68 @@ export function customSteerCard() {
|
||||||
];
|
];
|
||||||
return cardWith(t('➕ 自定义指令'), elements);
|
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,
|
groupResponseMode: this.#groupResponseMode,
|
||||||
repair: this.#repair,
|
repair: this.#repair,
|
||||||
replyTimeoutMs: this.#replyTimeoutMs,
|
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,
|
signal,
|
||||||
logger: this.#logger,
|
logger: this.#logger,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,10 @@ export class HarnessApprovalQueue {
|
||||||
await this.#rejectInteraction(interaction, payload);
|
await this.#rejectInteraction(interaction, payload);
|
||||||
return true;
|
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, {
|
const text = harnessApprovalText(payload, {
|
||||||
toolCall: interaction.toolCall,
|
toolCall: interaction.toolCall,
|
||||||
|
|
@ -267,6 +271,7 @@ export class HarnessApprovalQueue {
|
||||||
actor,
|
actor,
|
||||||
requiresMention: context.requiresMention === true,
|
requiresMention: context.requiresMention === true,
|
||||||
send,
|
send,
|
||||||
|
render,
|
||||||
text,
|
text,
|
||||||
presented: false,
|
presented: false,
|
||||||
presentationTask: null,
|
presentationTask: null,
|
||||||
|
|
@ -287,6 +292,20 @@ export class HarnessApprovalQueue {
|
||||||
return true;
|
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) {
|
async handleResolved(resolution) {
|
||||||
if (resolution?.kind !== 'approval') return false;
|
if (resolution?.kind !== 'approval') return false;
|
||||||
const pending = this.#byId.get(cleanText(resolution.interactionId));
|
const pending = this.#byId.get(cleanText(resolution.interactionId));
|
||||||
|
|
@ -353,7 +372,11 @@ export class HarnessApprovalQueue {
|
||||||
if (this.#routes.get(pending.key)?.items[0] !== pending
|
if (this.#routes.get(pending.key)?.items[0] !== pending
|
||||||
|| pending.inactive || pending.resolving || pending.presented) return;
|
|| pending.inactive || pending.resolving || pending.presented) return;
|
||||||
if (pending.presentationTask) return pending.presentationTask;
|
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;
|
pending.presentationTask = task;
|
||||||
try {
|
try {
|
||||||
await task;
|
await task;
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
'⚠️ 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 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。':
|
'⚠️ 修复验证中断: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.',
|
'⚠️ 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}',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1260,6 +1260,9 @@ test('a threaded Feishu reply answers a pending Harness question before the orig
|
||||||
clearSession: async (key) => sessions.delete(key),
|
clearSession: async (key) => sessions.delete(key),
|
||||||
},
|
},
|
||||||
status,
|
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']),
|
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),
|
clearSession: async (key) => sessions.delete(key),
|
||||||
},
|
},
|
||||||
status,
|
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']),
|
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,
|
state: fixture.state,
|
||||||
status: bridgeStatus(),
|
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']),
|
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, '两个审批均已处理');
|
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 () => {
|
test('question replays are deduplicated and an unrenderable approval is safely rejected', async () => {
|
||||||
const fixture = stateFixture();
|
const fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
|
|
@ -2411,6 +2699,10 @@ test('a multi-question interaction keeps ordered canonical answers', async () =>
|
||||||
},
|
},
|
||||||
state: fixture.state,
|
state: fixture.state,
|
||||||
status: bridgeStatus(),
|
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']),
|
allowedSenderOpenIds: new Set(['ou_user']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2499,6 +2791,9 @@ test('the second answer bypasses the first answer reaction-finalization window',
|
||||||
},
|
},
|
||||||
state: fixture.state,
|
state: fixture.state,
|
||||||
status: bridgeStatus(),
|
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']),
|
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,
|
state: fixture.state,
|
||||||
status: bridgeStatus(),
|
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']),
|
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'); }
|
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 () => {
|
test('/sessions alias uses the interactive session list and paginates across 25 sessions', async () => {
|
||||||
const fixture = stateFixture();
|
const fixture = stateFixture();
|
||||||
const sent = [];
|
const sent = [];
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,11 @@ function fixture(channel, { contextEnhancement, onAsk } = {}) {
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
bridge = new FeishuHarnessBridge({ ...dependencies, status: {}, allowedSenderOpenIds: new Set(['*']),
|
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: {
|
channel: {}, client: { im: { v1: {
|
||||||
message: { create: async (request) => {
|
message: { create: async (request) => {
|
||||||
calls.push(['createMessage', request]);
|
calls.push(['createMessage', request]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue