fix(feishu): separate synthetic ids from reply targets

This commit is contained in:
Peter Steinberger
2026-04-26 00:56:43 +01:00
parent e918e5f75c
commit 77d04a39d8
9 changed files with 67 additions and 5 deletions

View File

@@ -65,6 +65,9 @@ Docs: https://docs.openclaw.ai
- 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.
- Feishu: keep synthetic card-action and bot-menu ids out of platform reply
targets, using the real card callback message id when Feishu provides one and
plain-sending otherwise. Fixes #71673. 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

@@ -122,6 +122,8 @@ export function parseFeishuMessageEvent(
const ctx: FeishuMessageContext = {
chatId: event.message.chat_id,
messageId: event.message.message_id,
replyTargetMessageId: event.message.reply_target_message_id?.trim() || undefined,
suppressReplyTarget: event.message.suppress_reply_target === true,
senderId: senderUserId || senderOpenId || "",
// Keep the historical field name, but fall back to user_id when open_id is unavailable
// (common in some mobile app deliveries).
@@ -1037,7 +1039,11 @@ export async function handleFeishuMessage(params: {
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
const replyTargetMessageId =
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
isTopicSession || configReplyInThread
? (ctx.rootId ??
ctx.replyTargetMessageId ??
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
if (broadcastAgents) {

View File

@@ -22,7 +22,9 @@ export type FeishuCardActionEvent = {
value: Record<string, unknown>;
tag: string;
};
open_message_id?: string;
context: {
open_message_id?: string;
open_id?: string;
user_id?: string;
chat_id?: string;
@@ -107,6 +109,7 @@ function buildSyntheticMessageEvent(
content: string,
chatType: "p2p" | "group",
): FeishuMessageEvent {
const replyTargetMessageId = event.context.open_message_id ?? event.open_message_id;
return {
sender: {
sender_id: {
@@ -117,6 +120,8 @@ function buildSyntheticMessageEvent(
},
message: {
message_id: `card-action-${event.token}`,
...(replyTargetMessageId ? { reply_target_message_id: replyTargetMessageId } : {}),
...(!replyTargetMessageId ? { suppress_reply_target: true } : {}),
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType,
message_type: "text",

View File

@@ -10,6 +10,8 @@ export type FeishuMessageEvent = {
};
message: {
message_id: string;
reply_target_message_id?: string;
suppress_reply_target?: boolean;
root_id?: string;
parent_id?: string;
thread_id?: string;

View File

@@ -210,6 +210,7 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven
const unionId = firstString(operator.union_id);
const tag = readString(action.tag);
const actionValue = action.value;
const openMessageId = firstString(value.open_message_id, context.open_message_id);
const contextOpenId = firstString(context.open_id, openId);
const contextUserId = firstString(context.user_id, userId);
const chatId = firstString(context.chat_id, context.open_chat_id);
@@ -227,7 +228,9 @@ function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEven
value: actionValue,
tag,
},
...(openMessageId ? { open_message_id: openMessageId } : {}),
context: {
...(openMessageId ? { open_message_id: openMessageId } : {}),
...(contextOpenId ? { open_id: contextOpenId } : {}),
...(contextUserId ? { user_id: contextUserId } : {}),
...(chatId ? { chat_id: chatId } : {}),

View File

@@ -93,6 +93,7 @@ export function createFeishuBotMenuHandler(params: {
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
suppress_reply_target: true,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",

View File

@@ -179,7 +179,7 @@ describe("Feishu bot-menu lifecycle", () => {
expect.objectContaining({
accountId: "acct-menu",
chatId: "p2p:ou_user1",
replyToMessageId: "bot-menu:quick-actions:1700000000001",
replyToMessageId: undefined,
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(

View File

@@ -181,7 +181,7 @@ describe("Feishu card-action lifecycle", () => {
expect.objectContaining({
accountId: "acct-card",
chatId: "p2p:ou_user1",
replyToMessageId: "card-action-tok-card-once",
replyToMessageId: undefined,
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
@@ -233,7 +233,12 @@ describe("Feishu card-action lifecycle", () => {
expect.objectContaining({
accountId: "acct-card",
chatId,
replyToMessageId: "card-action-tok-card-v2-context",
replyToMessageId: "om_card_v2",
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
MessageSid: "card-action-tok-card-v2-context",
}),
);
});
@@ -261,7 +266,42 @@ describe("Feishu card-action lifecycle", () => {
expect.objectContaining({
accountId: "acct-card",
chatId: "ou_user1",
replyToMessageId: "card-action-tok-card-sdk-flat",
replyToMessageId: "om_sdk_card",
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
MessageSid: "card-action-tok-card-sdk-flat",
}),
);
});
it("plain-sends card action replies when Feishu provides no real message id", async () => {
const onCardAction = await setupLifecycleMonitor();
await onCardAction({
open_id: "ou_user1",
token: "tok-card-no-reply-target",
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: undefined,
}),
);
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
expect.objectContaining({
MessageSid: "card-action-tok-card-no-reply-target",
}),
);
});

View File

@@ -40,6 +40,8 @@ export type FeishuIdType = "open_id" | "user_id" | "union_id" | "chat_id";
export type FeishuMessageContext = {
chatId: string;
messageId: string;
replyTargetMessageId?: string;
suppressReplyTarget?: boolean;
senderId: string;
senderOpenId: string;
senderName?: string;