diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 93a4985f068..730dddea4a2 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -578,6 +578,149 @@ describe("feishuPlugin actions", () => { }); }); + it("auto-threads `send` text against the inbound trigger in group_topic sessions", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", text: "topic reply" }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1:topic:om_inbound", + toolContext: { currentMessageId: "om_inbound" }, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + text: "topic reply", + accountId: undefined, + replyToMessageId: "om_inbound", + replyInThread: true, + }); + }); + + it("auto-threads `send` cards against the inbound trigger in group_topic sessions", async () => { + sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_topic_card", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { + to: "chat:oc_group_1", + presentation: { + title: "Topic update", + blocks: [{ type: "text", text: "topic reply" }], + }, + }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1:topic:om_inbound", + toolContext: { currentMessageId: "om_inbound" }, + } as never); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_inbound", + replyInThread: true, + }), + ); + }); + + it("auto-threads `send` media against the inbound trigger in group_topic sessions", async () => { + feishuOutboundSendMediaMock.mockResolvedValueOnce({ + channel: "feishu", + messageId: "om_topic_media", + details: { messageId: "om_topic_media", chatId: "oc_group_1" }, + }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { + to: "chat:oc_group_1", + message: "topic reply", + media: "/tmp/image.png", + }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1:topic:om_inbound", + toolContext: { currentMessageId: "om_inbound" }, + mediaLocalRoots: ["/tmp"], + } as never); + + expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith( + expect.objectContaining({ + threadId: "om_inbound", + }), + ); + expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith( + expect.not.objectContaining({ replyToId: expect.anything() }), + ); + }); + + it("auto-threads `send` in group_topic_sender sessions too", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", text: "topic reply" }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1:topic:om_inbound:sender:ou_user", + toolContext: { currentMessageId: "om_inbound" }, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_inbound", + replyInThread: true, + }), + ); + }); + + it("does not auto-thread `send` in plain group sessions (no topic)", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_plain", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", text: "plain group reply" }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1", + toolContext: { currentMessageId: "om_inbound" }, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + text: "plain group reply", + accountId: undefined, + replyToMessageId: undefined, + replyInThread: false, + }); + }); + + it("does not auto-thread `send` in group_topic when no inbound currentMessageId is available", async () => { + sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_topic", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { to: "chat:oc_group_1", text: "topic reply" }, + cfg, + accountId: undefined, + sessionKey: "feishu:group:oc_group_1:topic:om_inbound", + toolContext: {}, + } as never); + + expect(sendMessageFeishuMock).toHaveBeenCalledWith({ + cfg, + to: "chat:oc_group_1", + text: "topic reply", + accountId: undefined, + replyToMessageId: undefined, + replyInThread: false, + }); + }); + it("creates pins", async () => { createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index eb710836618..13c3cdeb715 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -6,6 +6,7 @@ import { } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelMessageActionAdapter, + ChannelMessageActionContext, ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; @@ -351,6 +352,49 @@ function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean { return false; } +function isFeishuGroupTopicSessionKey(sessionKey: string | null | undefined): boolean { + if (typeof sessionKey !== "string" || !sessionKey) { + return false; + } + const parsed = parseFeishuConversationId({ conversationId: sessionKey }); + return parsed?.scope === "group_topic" || parsed?.scope === "group_topic_sender"; +} + +type FeishuActionReplyAnchor = { + replyToMessageId: string | undefined; + replyInThread: boolean; +}; + +type FeishuSendActionContext = Pick< + ChannelMessageActionContext, + "action" | "params" | "sessionKey" | "toolContext" +>; + +function resolveFeishuTopicAutoThreadAnchor(ctx: FeishuSendActionContext): string | undefined { + if (ctx.action !== "send") { + return undefined; + } + if (!isFeishuGroupTopicSessionKey(ctx.sessionKey)) { + return undefined; + } + const inbound = ctx.toolContext?.currentMessageId; + return typeof inbound === "string" && inbound.length > 0 ? inbound : undefined; +} + +function buildFeishuSendReplyAnchor(ctx: FeishuSendActionContext): FeishuActionReplyAnchor { + if (ctx.action === "thread-reply") { + return { + replyToMessageId: resolveFeishuMessageId(ctx.params), + replyInThread: true, + }; + } + const autoThreadId = resolveFeishuTopicAutoThreadAnchor(ctx); + return { + replyToMessageId: autoThreadId, + replyInThread: autoThreadId !== undefined, + }; +} + function isSupportedFeishuDirectConversationId(conversationId: string): boolean { const trimmed = conversationId.trim(); if (!trimmed || trimmed.includes(":")) { @@ -754,8 +798,7 @@ export const feishuPlugin: ChannelPlugin { expect(sendMediaFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_reply_target", + replyInThread: false, + }), + ); + }); + + it("forwards threadId as replyInThread=true to sendMediaFeishu", async () => { + await feishuOutbound.sendMedia?.({ + cfg: emptyConfig, + to: "chat_1", + text: "", + mediaUrl: "https://example.com/image.png", + threadId: "om_topic_root", + accountId: "main", + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_topic_root", + replyInThread: true, + }), + ); + }); + + it("prefers replyToId over threadId (inline reply) when both are set", async () => { + await feishuOutbound.sendMedia?.({ + cfg: emptyConfig, + to: "chat_1", + text: "", + mediaUrl: "https://example.com/image.png", + replyToId: "om_inline", + threadId: "om_topic_root", + accountId: "main", + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_inline", + replyInThread: false, }), ); }); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 6753380f576..7a1e62b15f2 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -408,6 +408,20 @@ function resolveReplyToMessageId(params: { return trimmed || undefined; } +type FeishuMediaReplyMode = { + replyToMessageId: string | undefined; + replyInThread: boolean; +}; + +function resolveFeishuMediaReplyMode(params: { + replyToId?: string | null; + threadId?: string | number | null; +}): FeishuMediaReplyMode { + const replyToMessageId = resolveReplyToMessageId(params); + const replyInThread = params.threadId != null && !params.replyToId; + return { replyToMessageId, replyInThread }; +} + async function sendCommentThreadReply(params: { cfg: Parameters[0]["cfg"]; to: string; @@ -456,9 +470,10 @@ async function sendOutboundText(params: { to: string; text: string; replyToMessageId?: string; + replyInThread?: boolean; accountId?: string; }) { - const { cfg, to, text, accountId, replyToMessageId } = params; + const { cfg, to, text, accountId, replyToMessageId, replyInThread } = params; const commentResult = await sendCommentThreadReply({ cfg, to, @@ -474,10 +489,17 @@ async function sendOutboundText(params: { const renderMode = account.config?.renderMode ?? "auto"; if (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))) { - return sendMarkdownCardFeishu({ cfg, to, text, accountId, replyToMessageId }); + return sendMarkdownCardFeishu({ + cfg, + to, + text, + accountId, + replyToMessageId, + replyInThread, + }); } - return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId }); + return sendMessageFeishu({ cfg, to, text, accountId, replyToMessageId, replyInThread }); } export const feishuOutbound: ChannelOutboundAdapter = { @@ -653,7 +675,10 @@ export const feishuOutbound: ChannelOutboundAdapter = { replyToId, threadId, }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({ + replyToId, + threadId, + }); const commentTarget = parseFeishuCommentTarget(to); if (commentTarget) { const commentText = [text?.trim(), mediaUrl?.trim()].filter(Boolean).join("\n\n"); @@ -663,6 +688,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { text: commentText || mediaUrl || text || "", accountId: accountId ?? undefined, replyToMessageId, + replyInThread, }); } @@ -681,6 +707,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { text, accountId: accountId ?? undefined, replyToMessageId, + replyInThread, }); } @@ -694,6 +721,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { accountId: accountId ?? undefined, mediaLocalRoots, replyToMessageId, + replyInThread, ...(audioAsVoice === true ? { audioAsVoice: true } : {}), }); if (result.voiceIntentDegradedToFile && text?.trim()) { @@ -703,6 +731,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { text, accountId: accountId ?? undefined, replyToMessageId, + replyInThread, }); } return result; @@ -717,6 +746,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { text: fallbackText, accountId: accountId ?? undefined, replyToMessageId, + replyInThread, }); } } @@ -728,6 +758,7 @@ export const feishuOutbound: ChannelOutboundAdapter = { text: text ?? "", accountId: accountId ?? undefined, replyToMessageId, + replyInThread, }); }, }),