mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:00:44 +00:00
fix(feishu): stabilize topic group session keys
This commit is contained in:
@@ -4,7 +4,7 @@ import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { downloadMessageResourceFeishu } from "./media.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import type { FeishuMediaInfo } from "./types.js";
|
||||
import type { FeishuChatType, FeishuMediaInfo } from "./types.js";
|
||||
|
||||
export type FeishuMention = {
|
||||
key: string;
|
||||
@@ -54,6 +54,7 @@ export function resolveFeishuGroupSession(params: {
|
||||
messageId: string;
|
||||
rootId?: string;
|
||||
threadId?: string;
|
||||
chatType?: FeishuChatType;
|
||||
groupConfig?: {
|
||||
groupSessionScope?: GroupSessionScope;
|
||||
topicSessionMode?: "enabled" | "disabled";
|
||||
@@ -65,7 +66,8 @@ export function resolveFeishuGroupSession(params: {
|
||||
replyInThread?: "enabled" | "disabled";
|
||||
};
|
||||
}): ResolvedFeishuGroupSession {
|
||||
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
|
||||
const { chatId, senderOpenId, messageId, rootId, threadId, chatType, groupConfig, feishuCfg } =
|
||||
params;
|
||||
const normalizedThreadId = threadId?.trim();
|
||||
const normalizedRootId = rootId?.trim();
|
||||
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
|
||||
@@ -78,9 +80,14 @@ export function resolveFeishuGroupSession(params: {
|
||||
groupConfig?.groupSessionScope ??
|
||||
feishuCfg?.groupSessionScope ??
|
||||
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
||||
const normalizedTopicGroupThreadId =
|
||||
chatType === "topic_group" ? (normalizedThreadId ?? normalizedRootId) : undefined;
|
||||
const topicScope =
|
||||
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
||||
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
||||
? (normalizedTopicGroupThreadId ??
|
||||
normalizedRootId ??
|
||||
normalizedThreadId ??
|
||||
(replyInThread ? messageId : null))
|
||||
: null;
|
||||
|
||||
let peerId = chatId;
|
||||
|
||||
@@ -2031,6 +2031,65 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses thread_id as the canonical topic key in Feishu topic groups", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const topicStarter: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_starter_message",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "topic_group",
|
||||
root_id: "omt_topic_1",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "topic starter" }),
|
||||
},
|
||||
};
|
||||
const topicReply: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_reply_message",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "topic_group",
|
||||
root_id: "om_topic_starter_message",
|
||||
thread_id: "omt_topic_1",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "topic reply" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event: topicStarter });
|
||||
await dispatchMessage({ cfg, event: topicReply });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses thread_id as topic key when root_id is missing", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -54,7 +54,11 @@ import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
|
||||
export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
import type { FeishuMessageContext, FeishuMessageInfo } from "./types.js";
|
||||
import {
|
||||
isFeishuGroupChatType,
|
||||
type FeishuMessageContext,
|
||||
type FeishuMessageInfo,
|
||||
} from "./types.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
export { toMessageResourceType } from "./bot-content.js";
|
||||
@@ -300,7 +304,7 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
const isGroup = isFeishuGroupChatType(ctx.chatType);
|
||||
const isDirect = !isGroup;
|
||||
const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
|
||||
|
||||
@@ -391,6 +395,7 @@ export async function handleFeishuMessage(params: {
|
||||
messageId: ctx.messageId,
|
||||
rootId: ctx.rootId,
|
||||
threadId: ctx.threadId,
|
||||
chatType: ctx.chatType,
|
||||
groupConfig,
|
||||
feishuCfg,
|
||||
})
|
||||
|
||||
@@ -122,8 +122,9 @@ const GroupSessionScopeSchema = z
|
||||
* - "disabled" (default): All messages in a group share one session
|
||||
* - "enabled": Messages in different topics get separate sessions
|
||||
*
|
||||
* Topic routing uses `root_id` when present to keep session continuity and
|
||||
* falls back to `thread_id` when `root_id` is unavailable.
|
||||
* Topic routing uses Feishu topic-group `thread_id` when the event identifies a
|
||||
* native topic group, and keeps `root_id` precedence for normal groups so
|
||||
* reply-created threads stay on the initiating message session.
|
||||
*/
|
||||
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
||||
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
|
||||
|
||||
@@ -14,7 +14,7 @@ export type FeishuMessageEvent = {
|
||||
parent_id?: string;
|
||||
thread_id?: string;
|
||||
chat_id: string;
|
||||
chat_type: "p2p" | "group" | "private";
|
||||
chat_type: "p2p" | "group" | "topic_group" | "private";
|
||||
message_type: string;
|
||||
content: string;
|
||||
create_time?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
export type { MentionTarget } from "./mention-target.types.js";
|
||||
import type { MentionTarget } from "./mention-target.types.js";
|
||||
import { isFeishuGroupChatType } from "./types.js";
|
||||
|
||||
/**
|
||||
* Escape regex metacharacters so user-controlled mention fields are treated literally.
|
||||
@@ -46,7 +47,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDirectMessage = event.message.chat_type !== "group";
|
||||
const isDirectMessage = !isFeishuGroupChatType(event.message.chat_type);
|
||||
const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
|
||||
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -156,7 +156,9 @@ export async function resolveReactionSyntheticEvent(
|
||||
}
|
||||
|
||||
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
|
||||
return value === "group" || value === "private" || value === "p2p" ? value : undefined;
|
||||
return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
type RegisterEventHandlersContext = {
|
||||
|
||||
@@ -59,7 +59,9 @@ type FeishuMessageReceiveHandlerContext = {
|
||||
};
|
||||
|
||||
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
|
||||
return value === "group" || value === "private" || value === "p2p" ? value : undefined;
|
||||
return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
|
||||
|
||||
@@ -268,7 +268,10 @@ function parseFeishuMessageItem(
|
||||
messageId: item.message_id ?? fallbackMessageId ?? "",
|
||||
chatId: item.chat_id ?? "",
|
||||
chatType:
|
||||
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
|
||||
item.chat_type === "group" ||
|
||||
item.chat_type === "topic_group" ||
|
||||
item.chat_type === "private" ||
|
||||
item.chat_type === "p2p"
|
||||
? item.chat_type
|
||||
: undefined,
|
||||
senderId: item.sender?.id,
|
||||
|
||||
@@ -43,7 +43,7 @@ export type FeishuMessageContext = {
|
||||
senderId: string;
|
||||
senderOpenId: string;
|
||||
senderName?: string;
|
||||
chatType: "p2p" | "group" | "private";
|
||||
chatType: FeishuChatType;
|
||||
mentionedBot: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
rootId?: string;
|
||||
@@ -60,7 +60,11 @@ export type FeishuSendResult = {
|
||||
chatId: string;
|
||||
};
|
||||
|
||||
export type FeishuChatType = "p2p" | "group" | "private";
|
||||
export type FeishuChatType = "p2p" | "group" | "topic_group" | "private";
|
||||
|
||||
export function isFeishuGroupChatType(chatType: FeishuChatType | undefined): boolean {
|
||||
return chatType === "group" || chatType === "topic_group";
|
||||
}
|
||||
|
||||
export type FeishuMessageInfo = {
|
||||
messageId: string;
|
||||
|
||||
Reference in New Issue
Block a user