fix(feishu): reply inside P2P direct-message threads (#92136)

* fix(feishu): keep P2P replies inside direct-message threads

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

* fix(clownfish): address review for ghcrawl-165996-agentic-merge (1)

Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>

---------

Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: LiaoyuanNing@TTC <259494737+LiaoyuanNing@users.noreply.github.com>
This commit is contained in:
openclaw-clownfish[bot]
2026-06-11 18:57:51 +09:00
committed by GitHub
parent 1e878dde7c
commit c692fabeba
2 changed files with 130 additions and 13 deletions

View File

@@ -525,6 +525,40 @@ describe("handleFeishuMessage ACP routing", () => {
expect(message.text).toContain("runtime unavailable");
});
it("surfaces configured ACP initialization failures inside P2P direct-message threads", async () => {
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
createConfiguredBindingReadiness(false, "runtime unavailable"),
);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-thread-child",
root_id: "msg-thread-root",
thread_id: "omt-acp-dm-thread",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_dm",
replyToMessageId: "msg-thread-root",
replyInThread: true,
}),
);
});
it("routes Feishu topic messages through active bound conversations", async () => {
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
@@ -3359,6 +3393,79 @@ describe("handleFeishuMessage command authorization", () => {
expect(dispatcherOptions.rootId).toBe("om_topic_sender_root");
});
it("keeps P2P replies inside a direct-message thread when Feishu supplies thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-thread-dm" } },
message: {
message_id: "om_dm_thread_child",
root_id: "om_dm_thread_root",
thread_id: "omt_dm_thread",
chat_id: "oc-dm-thread",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello inside a DM thread" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_thread_root",
rootId: "om_dm_thread_root",
skipReplyToInMessages: false,
replyInThread: true,
threadReply: true,
}),
);
});
it("keeps root_id-only P2P replies as quote replies outside thread mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-quote-dm" } },
message: {
message_id: "om_dm_quote_reply",
root_id: "om_dm_quote_root",
chat_id: "oc-dm-quote",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "quoted DM reply" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_quote_reply",
rootId: "om_dm_quote_root",
skipReplyToInMessages: true,
replyInThread: false,
threadReply: false,
}),
);
});
it("forces thread replies when inbound message contains thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -802,7 +802,14 @@ export async function handleFeishuMessage(params: {
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
const directThreadReply = !isGroup && Boolean(ctx.threadId?.trim());
const defaultReplyTargetMessageId =
ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId);
const directThreadRootId = directThreadReply ? ctx.rootId?.trim() || undefined : undefined;
const directThreadReplyTargetMessageId = directThreadReply
? (directThreadRootId ?? defaultReplyTargetMessageId)
: undefined;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadReply;
const feishuAcpConversationSupported =
!isGroup ||
groupSession?.groupSessionScope === "group_topic" ||
@@ -906,10 +913,13 @@ export async function handleFeishuMessage(params: {
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
const replyTargetMessageId =
const acpTopicReply =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender")
groupSession?.groupSessionScope === "group_topic_sender");
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: acpTopicReply
? (ctx.rootId ?? ctx.messageId)
: ctx.messageId;
await sendMessageFeishu({
@@ -917,7 +927,7 @@ export async function handleFeishuMessage(params: {
to: `chat:${ctx.chatId}`,
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
replyToMessageId: replyTargetMessageId,
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
replyInThread,
accountId: account.accountId,
}).catch((err: unknown) => {
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
@@ -1387,13 +1397,13 @@ export async function handleFeishuMessage(params: {
const configReplyInThread =
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
const replyTargetMessageId =
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;
const topicReplyTargetMessageId = ctx.rootId ?? defaultReplyTargetMessageId;
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: isTopicSession || configReplyInThread
? topicReplyTargetMessageId
: defaultReplyTargetMessageId;
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadReply;
const lastRouteThreadId =
isGroup && (isTopicSession || configReplyInThread || threadReply)
? replyTargetMessageId
@@ -1518,7 +1528,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
skipReplyToInMessages: !isGroup && !directThreadReply,
replyInThread,
rootId: ctx.rootId,
threadReply,
@@ -1694,7 +1704,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup,
skipReplyToInMessages: !isGroup && !directThreadReply,
replyInThread,
rootId: ctx.rootId,
threadReply,