mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 21:40:43 +00:00
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:
@@ -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" });
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user