fix(feishu): stabilize topic group session keys

This commit is contained in:
Peter Steinberger
2026-04-25 07:52:43 +01:00
parent d068cb960d
commit bb5e278f63
12 changed files with 105 additions and 14 deletions

View File

@@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Feishu: back off streaming-card creation after HTTP 400 startup failures, so unsupported card setups fall back without delaying every message. Fixes #56981. Thanks @JinnanDuan.
- Feishu/topic groups: key native Feishu/Lark topic-group sessions by `thread_id` so starter messages and replies with different `root_id` formats stay in the same `group_topic` conversation. Fixes #71438. Thanks @1335848090.
- Feishu: suppress duplicate final card delivery when idle closes a streaming card before the final payload arrives. (#68491) Thanks @MoerAI.
- Signal: preserve sender attachment filenames and resolve missing MIME types from those filenames, so Linux `signal-cli` voice notes without `contentType` still enter audio transcription. Fixes #48614. Thanks @mindfury.
- Telegram/agents: suppress the phantom "Agent couldn't generate a response" fallback after a reply was already committed through the messaging tool. (#70623) Thanks @chinar-amrutkar.

View File

@@ -430,6 +430,12 @@ Full configuration: [Gateway configuration](/gateway/configuration)
- ✅ Thread replies
- ✅ Media replies stay thread-aware when replying to a thread message
For `groupSessionScope: "group_topic"` and `"group_topic_sender"`, native
Feishu/Lark topic groups use the event `thread_id` (`omt_*`) as the canonical
topic session key. Normal group replies that OpenClaw turns into threads keep
using the reply root message ID (`om_*`) so the first turn and follow-up turn
stay in the same session.
---
## Related

View File

@@ -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;

View File

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

View File

@@ -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,
})

View File

@@ -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();

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 = {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;