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:
Peter Steinberger
2026-05-06 07:30:27 +01:00
committed by GitHub
parent c0c38194f6
commit 8cc762daff
4 changed files with 130 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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