mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 08:42:52 +00:00
fix(feishu): refresh inbound session routes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
Reference in New Issue
Block a user