diff --git a/CHANGELOG.md b/CHANGELOG.md index 32640b6f2e3..3a18012f1a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu: refresh inbound session delivery context for DM, group, and broadcast turns so later replies do not inherit stale WebChat routing. Fixes #78274. - Agents/OpenAI streams: yield via `setTimeout(0)` instead of `setImmediate` between bursty Responses chunks so abort timers can fire during the yield, keeping cancel-on-timeout responsive on hot streams. Refs #82462. - Feishu: detect SecretRef top-level credentials as a configured default account instead of treating object-backed app secrets as missing. - CLI/completion: resolve concrete PowerShell profile paths and reload commands during setup and doctor completion installation. Fixes #44296. (#83059) Thanks @yu-xin-c. diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts index adc951c19a7..9943ccbe372 100644 --- a/extensions/feishu/src/bot.broadcast.test.ts +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -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 diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 428171769c9..78be24aecd8 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -206,6 +206,15 @@ function createFeishuBotRuntime(overrides: DeepPartial = {}): 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( return call[argIndex] as T; } +function lastMockCallArg( + 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); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 77fb6a0b7f8..1e206f03b27 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -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)}`, diff --git a/extensions/feishu/src/policy.ts b/extensions/feishu/src/policy.ts index dea7c5a5913..5d3a586a169 100644 --- a/extensions/feishu/src/policy.ts +++ b/extensions/feishu/src/policy.ts @@ -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 "";