mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix(feishu): stabilize topic group session keys
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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