fix(feishu): refresh inbound session routes

This commit is contained in:
Vincent Koc
2026-05-17 19:49:49 +08:00
parent 3c6ec521d5
commit f9c8cb7877
5 changed files with 432 additions and 1 deletions

View File

@@ -284,6 +284,43 @@ describe("broadcast dispatch", () => {
const sessionKeys = finalizeInboundContextCalls.map((call) => call.SessionKey);
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
const recordCalls = (
runtimeStub.channel.session.recordInboundSession as unknown as {
mock: {
calls: Array<
[
{
updateLastRoute?: {
sessionKey?: unknown;
channel?: unknown;
to?: unknown;
};
},
]
>;
};
}
).mock.calls;
expect(
recordCalls
.map(([call]) => ({
sessionKey: call.updateLastRoute?.["sessionKey"],
channel: call.updateLastRoute?.["channel"],
to: call.updateLastRoute?.["to"],
}))
.toSorted((left, right) => String(left.sessionKey).localeCompare(String(right.sessionKey))),
).toEqual([
{
sessionKey: "agent:main:feishu:group:oc-broadcast-group",
channel: "feishu",
to: "chat:oc-broadcast-group",
},
{
sessionKey: "agent:susan:feishu:group:oc-broadcast-group",
channel: "feishu",
to: "chat:oc-broadcast-group",
},
]);
expect(mockGetChatInfo).toHaveBeenCalledTimes(1);
expect(
finalizeInboundContextCalls

View File

@@ -206,6 +206,15 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
kind: "message",
canStartAgentTurn: true,
});
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
updateLastRoute: turn.record?.updateLastRoute,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
return {
dispatchResult: await turn.runDispatch(),
};
@@ -247,6 +256,14 @@ function mockCallArg<T>(
return call[argIndex] as T;
}
function lastMockCallArg<T>(
mock: { mock: { calls: unknown[][] } },
argIndex = 0,
_type?: (value: unknown) => value is T,
): T | undefined {
return mock.mock.calls.at(-1)?.[argIndex] as T | undefined;
}
type FeishuRoutePeer = { id: string; kind: "direct" | "group" };
function expectResolvedRouteCall(
@@ -556,6 +573,324 @@ describe("handleFeishuMessage ACP routing", () => {
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
});
it("records Feishu DM last-route updates on the resolved session", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "main",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-dm-last-route",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
const recordParams = lastMockCallArg<{
sessionKey?: string;
updateLastRoute?: {
accountId?: string;
channel?: string;
sessionKey?: string;
to?: string;
};
}>(recordInboundSession);
expect(recordParams?.sessionKey).toBe("agent:main:main");
expect(recordParams?.updateLastRoute).toMatchObject({
sessionKey: "agent:main:main",
channel: "feishu",
to: "user:ou_sender_1",
accountId: "default",
});
});
it("pins shared Feishu DM last-route updates to the configured owner", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
runtime.channel.pairing.readAllowFromStore = vi.fn().mockResolvedValue(["ou_sender_2"]);
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "main",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_owner"], dmPolicy: "pairing" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_2" } },
message: {
message_id: "msg-dm-last-route-secondary",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
const recordParams = lastMockCallArg<{
updateLastRoute?: {
mainDmOwnerPin?: {
ownerRecipient?: string;
senderRecipient?: string;
onSkip?: unknown;
};
};
}>(recordInboundSession);
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toMatchObject({
ownerRecipient: "user:ou_owner",
senderRecipient: "user:ou_sender_2",
});
expect(typeof recordParams?.updateLastRoute?.mainDmOwnerPin?.onSkip).toBe("function");
});
it("matches Feishu DM owner pins against user_id allowlist entries", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
accountId: "default",
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "main",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["user_123"], dmPolicy: "allowlist" } },
},
event: {
sender: { sender_id: { open_id: "ou_owner", user_id: "user_123" } },
message: {
message_id: "msg-dm-last-route-user-id-owner",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
const recordParams = lastMockCallArg<{
updateLastRoute?: {
mainDmOwnerPin?: {
ownerRecipient?: string;
senderRecipient?: string;
};
};
}>(recordInboundSession);
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toMatchObject({
ownerRecipient: "user:user_123",
senderRecipient: "user:user_123",
});
});
it("records Feishu group last-route updates on the resolved session", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
mockResolveAgentRoute.mockReturnValue({
agentId: "agent-B",
channel: "feishu",
accountId: "default",
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
mainSessionKey: "agent:agent-B:main",
lastRoutePolicy: "session",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: {
feishu: {
enabled: true,
allowFrom: ["ou_sender_1"],
groups: {
oc_group_chat: {
allow: true,
requireMention: false,
},
},
},
},
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-group-last-route",
chat_id: "oc_group_chat",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello group" }),
},
},
});
const recordParams = lastMockCallArg<{
sessionKey?: string;
updateLastRoute?: {
accountId?: string;
channel?: string;
sessionKey?: string;
to?: string;
};
}>(recordInboundSession);
expect(recordParams?.sessionKey).toBe("agent:agent-B:feishu:group:oc_group_chat");
expect(recordParams?.updateLastRoute).toMatchObject({
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
channel: "feishu",
to: "chat:oc_group_chat",
accountId: "default",
});
});
it("records configured Feishu thread replies with the dispatcher fallback target", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
mockResolveAgentRoute.mockReturnValue({
agentId: "agent-B",
channel: "feishu",
accountId: "default",
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
mainSessionKey: "agent:agent-B:main",
lastRoutePolicy: "session",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: {
feishu: {
enabled: true,
allowFrom: ["ou_sender_1"],
groups: {
oc_group_chat: {
allow: true,
requireMention: false,
replyInThread: "enabled",
},
},
},
},
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-group-thread-fallback",
chat_id: "oc_group_chat",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "start a thread" }),
},
},
});
const recordParams = lastMockCallArg<{
updateLastRoute?: {
threadId?: string;
to?: string;
};
}>(recordInboundSession);
expect(recordParams?.updateLastRoute).toMatchObject({
to: "chat:oc_group_chat",
threadId: "msg-group-thread-fallback",
});
});
it("records auto-threaded Feishu group replies with the dispatcher target", async () => {
const runtime = createFeishuBotRuntime();
const recordInboundSession = vi.fn(async () => undefined);
runtime.channel.session.recordInboundSession = recordInboundSession;
mockResolveAgentRoute.mockReturnValue({
agentId: "agent-B",
channel: "feishu",
accountId: "default",
sessionKey: "agent:agent-B:feishu:group:oc_group_chat",
mainSessionKey: "agent:agent-B:main",
lastRoutePolicy: "session",
matchedBy: "default",
});
setFeishuRuntime(runtime);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: {
feishu: {
enabled: true,
allowFrom: ["ou_sender_1"],
groups: {
oc_group_chat: {
allow: true,
requireMention: false,
},
},
},
},
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-group-auto-thread",
chat_id: "oc_group_chat",
chat_type: "group",
message_type: "text",
root_id: "om_thread_root",
content: JSON.stringify({ text: "continue the thread" }),
},
},
});
const recordParams = lastMockCallArg<{
updateLastRoute?: {
threadId?: string;
to?: string;
};
}>(recordInboundSession);
expect(recordParams?.updateLastRoute).toMatchObject({
to: "chat:oc_group_chat",
threadId: "msg-group-auto-thread",
});
});
it("passes reasoning preview permission from session state into the dispatcher", async () => {
mockResolveFeishuReasoningPreviewEnabled.mockReturnValue(true);

View File

@@ -11,11 +11,13 @@ import {
createChannelHistoryWindow,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
import {
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import {
@@ -43,6 +45,7 @@ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
import {
hasExplicitFeishuGroupConfig,
normalizeFeishuAllowEntry,
resolveFeishuDmIngressAccess,
resolveFeishuGroupConfig,
resolveFeishuGroupConversationIngressAccess,
@@ -1327,6 +1330,53 @@ export async function handleFeishuMessage(params: {
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
const lastRouteThreadId =
isGroup && (isTopicSession || configReplyInThread || threadReply)
? replyTargetMessageId
: undefined;
const pinnedMainDmOwner = !isGroup
? resolvePinnedMainDmOwnerFromAllowlist({
dmScope: cfg.session?.dmScope,
allowFrom: configAllowFrom,
normalizeEntry: normalizeFeishuAllowEntry,
})
: null;
const pinnedMainDmSenderRecipient = pinnedMainDmOwner
? [ctx.senderOpenId, senderUserId]
.map((id) => (id ? normalizeFeishuAllowEntry(id) : ""))
.find((recipient) => recipient === pinnedMainDmOwner)
: undefined;
const buildFeishuInboundLastRouteUpdate = (params: {
accountId: string;
sessionKey: string;
}) => {
const inboundLastRouteSessionKey =
params.sessionKey === route.sessionKey
? resolveInboundLastRouteSessionKey({
route,
sessionKey: params.sessionKey,
})
: params.sessionKey;
return {
sessionKey: inboundLastRouteSessionKey,
channel: "feishu" as const,
to: feishuTo,
accountId: params.accountId,
...(lastRouteThreadId ? { threadId: lastRouteThreadId } : {}),
mainDmOwnerPin:
!isGroup && inboundLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner
? {
ownerRecipient: pinnedMainDmOwner,
senderRecipient: pinnedMainDmSenderRecipient ?? feishuTo,
onSkip: (skipParams: { ownerRecipient: string; senderRecipient: string }) => {
log(
`feishu[${account.accountId}]: skip main-session last route for ${skipParams.senderRecipient} (pinned owner ${skipParams.ownerRecipient})`,
);
},
}
: undefined,
};
};
if (broadcastAgents) {
// Cross-account dedup: in multi-account setups, Feishu delivers the same
@@ -1370,6 +1420,10 @@ export async function handleFeishuMessage(params: {
agentId,
});
const agentRecord = {
updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: agentSessionKey,
accountId: route.accountId,
}),
onRecordError: (err: unknown) => {
log(
`feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`,
@@ -1595,6 +1649,10 @@ export async function handleFeishuMessage(params: {
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
record: {
updateLastRoute: buildFeishuInboundLastRouteUpdate({
sessionKey: route.sessionKey,
accountId: route.accountId,
}),
onRecordError: (err) => {
log(
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,

View File

@@ -39,7 +39,7 @@ const feishuIngressIdentity = defineStableChannelIngressIdentity({
resolveEntryId: ({ entryIndex }) => `feishu-entry-${entryIndex + 1}`,
});
function normalizeFeishuAllowEntry(raw: string): string {
export function normalizeFeishuAllowEntry(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";