fix(feishu): accept v2 card action callbacks

This commit is contained in:
Peter Steinberger
2026-04-26 00:41:11 +01:00
parent 12c16576cd
commit a1090b6043
4 changed files with 102 additions and 30 deletions

View File

@@ -62,6 +62,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- Feishu: accept Schema 2.0 card action callbacks that report
`context.open_chat_id` instead of legacy `context.chat_id`, so button
callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.
- QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs
directly for owner-authorized senders instead of returning `cronParams` and
relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937)

View File

@@ -14,8 +14,8 @@ import { sendCardFeishu, sendMessageFeishu } from "./send.js";
export type FeishuCardActionEvent = {
operator: {
open_id: string;
user_id: string;
union_id: string;
user_id?: string;
union_id?: string;
};
token: string;
action: {
@@ -23,9 +23,9 @@ export type FeishuCardActionEvent = {
tag: string;
};
context: {
open_id: string;
user_id: string;
chat_id: string;
open_id?: string;
user_id?: string;
chat_id?: string;
};
};

View File

@@ -183,43 +183,44 @@ function parseFeishuBotRemovedChatId(value: unknown): string | null {
return readString(value.chat_id) ?? null;
}
function firstString(...values: unknown[]): string | undefined {
for (const value of values) {
const stringValue = readString(value);
const trimmed = stringValue?.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEvent | null {
if (!isRecord(value)) {
return null;
}
const operator = value.operator;
const operator = isRecord(value.operator) ? value.operator : {};
const action = value.action;
const context = value.context;
if (!isRecord(operator) || !isRecord(action) || !isRecord(context)) {
const context = isRecord(value.context) ? value.context : {};
if (!isRecord(action)) {
return null;
}
const token = readString(value.token);
const openId = readString(operator.open_id);
const userId = readString(operator.user_id);
const unionId = readString(operator.union_id);
const openId = firstString(operator.open_id, value.open_id, context.open_id);
const userId = firstString(operator.user_id, value.user_id, context.user_id);
const unionId = firstString(operator.union_id);
const tag = readString(action.tag);
const actionValue = action.value;
const contextOpenId = readString(context.open_id);
const contextUserId = readString(context.user_id);
const chatId = readString(context.chat_id);
if (
!token ||
!openId ||
!userId ||
!unionId ||
!tag ||
!isRecord(actionValue) ||
!contextOpenId ||
!contextUserId ||
!chatId
) {
const contextOpenId = firstString(context.open_id, openId);
const contextUserId = firstString(context.user_id, userId);
const chatId = firstString(context.chat_id, context.open_chat_id);
if (!token || !openId || !tag || !isRecord(actionValue)) {
return null;
}
return {
operator: {
open_id: openId,
user_id: userId,
union_id: unionId,
...(userId ? { user_id: userId } : {}),
...(unionId ? { union_id: unionId } : {}),
},
token,
action: {
@@ -227,9 +228,9 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven
tag,
},
context: {
open_id: contextOpenId,
user_id: contextUserId,
chat_id: chatId,
...(contextOpenId ? { open_id: contextOpenId } : {}),
...(contextUserId ? { user_id: contextUserId } : {}),
...(chatId ? { chat_id: chatId } : {}),
},
};
}

View File

@@ -198,6 +198,74 @@ describe("Feishu card-action lifecycle", () => {
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("routes v2 callbacks that report open_chat_id instead of chat_id", async () => {
const onCardAction = await setupLifecycleMonitor();
const chatId = "oc_group_v2";
await onCardAction({
operator: {
open_id: "ou_user1",
},
token: "tok-card-v2-context",
action: {
tag: "button",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: {
u: "ou_user1",
h: chatId,
t: "group",
e: Date.now() + 60_000,
},
}),
},
context: {
open_message_id: "om_card_v2",
open_chat_id: chatId,
},
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct-card",
chatId,
replyToMessageId: "card-action-tok-card-v2-context",
}),
);
});
it("routes SDK-style card callbacks without context as direct callbacks", async () => {
const onCardAction = await setupLifecycleMonitor();
await onCardAction({
open_id: "ou_user1",
user_id: "user_1",
tenant_key: "tenant_1",
open_message_id: "om_sdk_card",
token: "tok-card-sdk-flat",
action: {
tag: "button",
value: {
command: "/help",
},
},
});
expect(lastRuntime?.error).not.toHaveBeenCalled();
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "acct-card",
chatId: "ou_user1",
replyToMessageId: "card-action-tok-card-sdk-flat",
}),
);
});
it("does not duplicate delivery when retrying after a post-send failure", async () => {
const onCardAction = await setupLifecycleMonitor();
const event = createCardActionEvent({