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

When a Feishu group_topic-scoped session triggers `message(action="send")`,
the channel handler previously left replyToMessageId/replyInThread unset for
non-thread-reply actions, so the visible reply fell out of the topic and
posted at the group root.

Auto-thread `send` against the inbound trigger when the active session scope
is `group_topic` or `group_topic_sender`, and propagate the topic-thread
decision uniformly through text, card, and media sends. Plain group sessions
remain unthreaded.

Fixes #74903.
This commit is contained in:
ai-hpc
2026-05-08 17:59:16 +10:00
committed by Mason Huang
parent 8c4c1288b9
commit 1917efe39c
4 changed files with 266 additions and 9 deletions

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

@@ -978,6 +978,44 @@ 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,
}),
);
});

View File

@@ -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<typeof sendMessageFeishu>[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,
});
},
}),