feat(feishu): prefer thread_id for topic session routing (openclaw#29788) thanks @songyaolun

Verified:
- pnpm test -- extensions/feishu/src/bot.test.ts extensions/feishu/src/reply-dispatcher.test.ts
- pnpm build

Co-authored-by: songyaolun <26423459+songyaolun@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
YolenSong
2026-03-03 07:33:08 +08:00
committed by GitHub
parent 28c88e9fa1
commit f22fc17c78
7 changed files with 368 additions and 62 deletions

View File

@@ -164,6 +164,7 @@ export type FeishuMessageEvent = {
message_id: string;
root_id?: string;
parent_id?: string;
thread_id?: string;
chat_id: string;
chat_type: "p2p" | "group" | "private";
message_type: string;
@@ -193,6 +194,94 @@ export type FeishuBotAddedEvent = {
operator_tenant_key?: string;
};
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
type ResolvedFeishuGroupSession = {
peerId: string;
parentPeer: { kind: "group"; id: string } | null;
groupSessionScope: GroupSessionScope;
replyInThread: boolean;
threadReply: boolean;
};
function resolveFeishuGroupSession(params: {
chatId: string;
senderOpenId: string;
messageId: string;
rootId?: string;
threadId?: string;
groupConfig?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
feishuCfg?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
}): ResolvedFeishuGroupSession {
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
const normalizedThreadId = threadId?.trim();
const normalizedRootId = rootId?.trim();
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
const replyInThread =
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
threadReply;
const legacyTopicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
const groupSessionScope: GroupSessionScope =
groupConfig?.groupSessionScope ??
feishuCfg?.groupSessionScope ??
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
// Keep topic session keys stable across the "first turn creates thread" flow:
// first turn may only have message_id, while the next turn carries root_id/thread_id.
// Prefer root_id first so both turns stay on the same peer key.
const topicScope =
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
: null;
let peerId = chatId;
switch (groupSessionScope) {
case "group_sender":
peerId = `${chatId}:sender:${senderOpenId}`;
break;
case "group_topic":
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
break;
case "group_topic_sender":
peerId = topicScope
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
: `${chatId}:sender:${senderOpenId}`;
break;
case "group":
default:
peerId = chatId;
break;
}
const parentPeer =
topicScope &&
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
? {
kind: "group" as const,
id: chatId,
}
: null;
return {
peerId,
parentPeer,
groupSessionScope,
replyInThread,
threadReply,
};
}
function parseMessageContent(content: string, messageType: string): string {
if (messageType === "post") {
// Extract text content from rich text post
@@ -624,6 +713,7 @@ export function parseFeishuMessageEvent(
mentionedBot,
rootId: event.message.root_id || undefined,
parentId: event.message.parent_id || undefined,
threadId: event.message.thread_id || undefined,
content,
contentType: event.message.message_type,
};
@@ -785,6 +875,18 @@ export async function handleFeishuMessage(params: {
const groupConfig = isGroup
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
: undefined;
const groupSession = isGroup
? resolveFeishuGroupSession({
chatId: ctx.chatId,
senderOpenId: ctx.senderOpenId,
messageId: ctx.messageId,
rootId: ctx.rootId,
threadId: ctx.threadId,
groupConfig,
feishuCfg,
})
: null;
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -853,10 +955,10 @@ export async function handleFeishuMessage(params: {
log(
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
);
if (chatHistories) {
if (chatHistories && groupHistoryKey) {
recordPendingHistoryEntryIfEnabled({
historyMap: chatHistories,
historyKey: ctx.chatId,
historyKey: groupHistoryKey,
limit: historyLimit,
entry: {
sender: ctx.senderOpenId,
@@ -951,50 +1053,14 @@ export async function handleFeishuMessage(params: {
// Using a group-scoped From causes the agent to treat different users as the same person.
const feishuFrom = `feishu:${ctx.senderOpenId}`;
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
// Resolve peer ID for session routing.
// Default is one session per group chat; this can be customized with groupSessionScope.
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
"group";
let topicRootForSession: string | null = null;
const replyInThread =
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
if (isGroup) {
const legacyTopicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
groupSessionScope =
groupConfig?.groupSessionScope ??
feishuCfg?.groupSessionScope ??
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
// When topic-scoped sessions are enabled and replyInThread is on, the first
// bot reply creates the thread rooted at the current message ID.
if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
}
switch (groupSessionScope) {
case "group_sender":
peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
break;
case "group_topic":
peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
break;
case "group_topic_sender":
peerId = topicRootForSession
? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
: `${ctx.chatId}:sender:${ctx.senderOpenId}`;
break;
case "group":
default:
peerId = ctx.chatId;
break;
}
log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
if (isGroup && groupSession) {
log(
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
);
}
let route = core.channel.routing.resolveAgentRoute({
@@ -1005,16 +1071,7 @@ export async function handleFeishuMessage(params: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
// Add parentPeer for binding inheritance in topic-scoped modes.
parentPeer:
isGroup &&
topicRootForSession &&
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
? {
kind: "group",
id: ctx.chatId,
}
: null,
parentPeer,
});
// Dynamic agent creation for DM users
@@ -1111,7 +1168,7 @@ export async function handleFeishuMessage(params: {
});
let combinedBody = body;
const historyKey = isGroup ? ctx.chatId : undefined;
const historyKey = groupHistoryKey;
if (isGroup && historyKey && chatHistories) {
combinedBody = buildPendingHistoryContextFromMap({
@@ -1184,6 +1241,7 @@ export async function handleFeishuMessage(params: {
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
messageCreateTimeMs,