mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
fix(feishu): keep topic sessions stable
Fixes Feishu native topic starter routing by hydrating a missing topic thread ID before session resolution.\n\nCloses #78262.
This commit is contained in:
committed by
GitHub
parent
c0c38194f6
commit
8cc762daff
@@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu: hydrate missing native topic starter thread IDs before session routing so first turns and follow-ups stay in the same topic session. Fixes #78262. Thanks @joeyzenghuan.
|
||||
- Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`.
|
||||
- Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models.
|
||||
- Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen.
|
||||
|
||||
@@ -479,9 +479,10 @@ conversion fails, OpenClaw falls back to a file attachment and logs the reason.
|
||||
|
||||
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.
|
||||
topic session key. If a native topic starter event omits `thread_id`, OpenClaw
|
||||
hydrates it from Feishu before routing the turn. 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2514,6 +2514,75 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates missing native topic thread_id before routing starter events", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockGetMessageFeishu.mockResolvedValueOnce({
|
||||
messageId: "msg-native-topic-first",
|
||||
chatId: "oc-group",
|
||||
chatType: "topic_group",
|
||||
content: "topic starter",
|
||||
contentType: "text",
|
||||
threadId: "omt_native_topic",
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
replyInThread: "enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const firstTurn: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-init" } },
|
||||
message: {
|
||||
message_id: "msg-native-topic-first",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "topic_group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "create native topic" }),
|
||||
},
|
||||
};
|
||||
const secondTurn: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-init" } },
|
||||
message: {
|
||||
message_id: "msg-native-topic-second",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "topic_group",
|
||||
thread_id: "omt_native_topic",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "follow up in same native topic" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event: firstTurn });
|
||||
await dispatchMessage({ cfg, event: secondTurn });
|
||||
|
||||
expect(mockGetMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: "msg-native-topic-first",
|
||||
}),
|
||||
);
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:omt_native_topic" },
|
||||
}),
|
||||
);
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:omt_native_topic" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replies to the topic root when handling a message inside an existing topic", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -78,6 +78,31 @@ const groupNameCache = new Map<string, { name: string; expiresAt: number }>();
|
||||
const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
|
||||
|
||||
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
|
||||
function resolveConfiguredFeishuGroupSessionScope(params: {
|
||||
groupConfig?: {
|
||||
groupSessionScope?: FeishuGroupSessionScope;
|
||||
topicSessionMode?: "enabled" | "disabled";
|
||||
};
|
||||
feishuCfg?: {
|
||||
groupSessionScope?: FeishuGroupSessionScope;
|
||||
topicSessionMode?: "enabled" | "disabled";
|
||||
};
|
||||
}): FeishuGroupSessionScope {
|
||||
const legacyTopicSessionMode =
|
||||
params.groupConfig?.topicSessionMode ?? params.feishuCfg?.topicSessionMode ?? "disabled";
|
||||
return (
|
||||
params.groupConfig?.groupSessionScope ??
|
||||
params.feishuCfg?.groupSessionScope ??
|
||||
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group")
|
||||
);
|
||||
}
|
||||
|
||||
function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
|
||||
return scope === "group_topic" || scope === "group_topic_sender";
|
||||
}
|
||||
|
||||
function evictGroupNameCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, val] of groupNameCache) {
|
||||
@@ -503,6 +528,36 @@ export async function handleFeishuMessage(params: {
|
||||
const groupConfig = isGroup
|
||||
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
||||
: undefined;
|
||||
const groupSessionScope = isGroup
|
||||
? resolveConfiguredFeishuGroupSessionScope({ groupConfig, feishuCfg })
|
||||
: null;
|
||||
let effectiveThreadId = ctx.threadId;
|
||||
if (
|
||||
isGroup &&
|
||||
ctx.chatType === "topic_group" &&
|
||||
!effectiveThreadId &&
|
||||
isFeishuTopicSessionScope(groupSessionScope ?? "group")
|
||||
) {
|
||||
try {
|
||||
const messageInfo = await getMessageFeishu({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
messageId: ctx.messageId,
|
||||
});
|
||||
const hydratedThreadId = messageInfo?.threadId?.trim();
|
||||
if (hydratedThreadId) {
|
||||
ctx = { ...ctx, threadId: hydratedThreadId };
|
||||
effectiveThreadId = hydratedThreadId;
|
||||
log(
|
||||
`feishu[${account.accountId}]: hydrated topic thread_id=${hydratedThreadId} for message=${ctx.messageId}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: failed to hydrate topic thread_id for message=${ctx.messageId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const effectiveGroupSenderAllowFrom = isGroup
|
||||
? (groupConfig?.allowFrom?.length ?? 0) > 0
|
||||
? (groupConfig?.allowFrom ?? [])
|
||||
@@ -514,7 +569,7 @@ export async function handleFeishuMessage(params: {
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
messageId: ctx.messageId,
|
||||
rootId: ctx.rootId,
|
||||
threadId: ctx.threadId,
|
||||
threadId: effectiveThreadId,
|
||||
chatType: ctx.chatType,
|
||||
groupConfig,
|
||||
feishuCfg,
|
||||
|
||||
Reference in New Issue
Block a user