fix(feishu): keep group_topic message-tool replies inside the topic (#77151)

Merged via squash.

Prepared head SHA: 3a47a09da1
Co-authored-by: ai-hpc <183861985+ai-hpc@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
NVIDIAN
2026-05-09 07:51:36 -07:00
committed by GitHub
parent d44aeb6901
commit aecd4fba7e
5 changed files with 355 additions and 11 deletions

View File

@@ -2,6 +2,14 @@
Docs: https://docs.openclaw.ai
## Unreleased
### Changes
### Fixes
- Feishu: auto-thread `message(action="send")` replies inside the topic when the active session is group_topic or group_topic_sender, and propagate `replyInThread` through text, card, and media outbound adapters so topic-scoped sessions no longer post at the group root. Fixes #74903. (#77151) Thanks @ai-hpc.
## 2026.5.9
### Changes

View File

@@ -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" });

View File

@@ -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<ResolvedFeishuAccount, FeishuProbeResul
if (!to) {
throw new Error(`Feishu ${ctx.action} requires a target (to).`);
}
const replyToMessageId =
ctx.action === "thread-reply" ? resolveFeishuMessageId(ctx.params) : undefined;
const { replyToMessageId, replyInThread } = buildFeishuSendReplyAnchor(ctx);
if (ctx.action === "thread-reply" && !replyToMessageId) {
throw new Error("Feishu thread-reply requires messageId.");
}
@@ -791,7 +834,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
replyInThread,
});
} else if (mediaUrl) {
result = await sendMedia!({
@@ -801,7 +844,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
mediaUrl,
accountId: ctx.accountId ?? undefined,
mediaLocalRoots: ctx.mediaLocalRoots,
replyToId: replyToMessageId,
...(replyInThread
? { threadId: replyToMessageId }
: { replyToId: replyToMessageId }),
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
});
} else {
@@ -811,7 +856,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
text: text!,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
replyInThread,
});
}
return jsonActionResult({

View File

@@ -381,6 +381,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
to: "chat_1",
text: "hello",
replyToMessageId: "om_thread_2",
replyInThread: true,
accountId: "main",
}),
);
@@ -958,6 +959,58 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
);
expect(sendMessageFeishuMock.mock.calls[0][0].replyToMessageId).toBeUndefined();
});
it("propagates threadId as replyInThread=true to sendMessageFeishu", async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1",
text: "topic reply",
threadId: "om_topic_root",
accountId: "main",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_topic_root",
replyInThread: true,
}),
);
});
it("propagates threadId as replyInThread=true to sendStructuredCardFeishu when renderMode=card", async () => {
await sendText({
cfg: cardRenderConfig,
to: "chat_1",
text: "```code```",
threadId: "om_topic_root",
accountId: "main",
});
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_topic_root",
replyInThread: true,
}),
);
});
it("prefers replyToId over threadId for plain text (inline reply, no auto-thread)", async () => {
await sendText({
cfg: emptyConfig,
to: "chat_1",
text: "inline reply",
replyToId: "om_inline",
threadId: "om_topic_root",
accountId: "main",
});
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_inline",
replyInThread: false,
}),
);
});
});
describe("feishuOutbound.sendMedia replyToId forwarding", () => {
@@ -978,6 +1031,63 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
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,
}),
);
});
it("treats whitespace-only replyToId as absent for replyInThread (falls back to threadId)", async () => {
await feishuOutbound.sendMedia?.({
cfg: emptyConfig,
to: "chat_1",
text: "",
mediaUrl: "https://example.com/image.png",
replyToId: " ",
threadId: "om_topic_root",
accountId: "main",
});
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_topic_root",
replyInThread: true,
}),
);
});

View File

@@ -408,6 +408,21 @@ 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 trimmedReplyToId = params.replyToId?.trim() || undefined;
const replyToMessageId = resolveReplyToMessageId(params);
const replyInThread = params.threadId != null && !trimmedReplyToId;
return { replyToMessageId, replyInThread };
}
async function sendCommentThreadReply(params: {
cfg: Parameters<typeof sendMessageFeishu>[0]["cfg"];
to: string;
@@ -456,9 +471,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 +490,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 = {
@@ -581,7 +604,10 @@ export const feishuOutbound: ChannelOutboundAdapter = {
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
const { replyToMessageId, replyInThread } = resolveFeishuMediaReplyMode({
replyToId,
threadId,
});
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
// auto-upload and send as Feishu image message instead of leaking path text.
@@ -594,6 +620,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
mediaUrl: localImagePath,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
mediaLocalRoots,
});
} catch (err) {
@@ -609,6 +636,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
@@ -629,7 +657,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
replyInThread,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
@@ -640,6 +668,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
},
sendMedia: async ({
@@ -653,7 +682,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 +695,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text: commentText || mediaUrl || text || "",
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
@@ -681,6 +714,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
@@ -694,6 +728,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
accountId: accountId ?? undefined,
mediaLocalRoots,
replyToMessageId,
replyInThread,
...(audioAsVoice === true ? { audioAsVoice: true } : {}),
});
if (result.voiceIntentDegradedToFile && text?.trim()) {
@@ -703,6 +738,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
return result;
@@ -717,6 +753,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text: fallbackText,
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
}
@@ -728,6 +765,7 @@ export const feishuOutbound: ChannelOutboundAdapter = {
text: text ?? "",
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
},
}),