From f6205de73a4e21986ccb86740191bb14895d0821 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Mar 2026 17:12:25 -0700 Subject: [PATCH] refactor: split feishu helpers and tests --- ...{monitor.account.test.ts => async.test.ts} | 2 +- extensions/feishu/src/async.ts | 24 ++ extensions/feishu/src/bot.broadcast.test.ts | 357 ++++++++++++++++++ extensions/feishu/src/bot.test.ts | 339 ----------------- extensions/feishu/src/monitor.account.ts | 84 +---- extensions/feishu/src/monitor.bot-identity.ts | 85 +++++ src/agents/models-config.providers.ts | 1 - src/agents/pi-embedded-runner/model.ts | 1 - 8 files changed, 470 insertions(+), 423 deletions(-) rename extensions/feishu/src/{monitor.account.test.ts => async.test.ts} (91%) create mode 100644 extensions/feishu/src/bot.broadcast.test.ts create mode 100644 extensions/feishu/src/monitor.bot-identity.ts diff --git a/extensions/feishu/src/monitor.account.test.ts b/extensions/feishu/src/async.test.ts similarity index 91% rename from extensions/feishu/src/monitor.account.test.ts rename to extensions/feishu/src/async.test.ts index 896d23374d5..a59763d4bc9 100644 --- a/extensions/feishu/src/monitor.account.test.ts +++ b/extensions/feishu/src/async.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { waitForAbortableDelay } from "./monitor.account.js"; +import { waitForAbortableDelay } from "./async.js"; afterEach(() => { vi.useRealTimers(); diff --git a/extensions/feishu/src/async.ts b/extensions/feishu/src/async.ts index 7ad6ce792f4..f04978d0bd1 100644 --- a/extensions/feishu/src/async.ts +++ b/extensions/feishu/src/async.ts @@ -60,3 +60,27 @@ export async function raceWithTimeoutAndAbort( } } } + +export function waitForAbortableDelay( + delayMs: number, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + const handleAbort = () => { + clearTimeout(timer); + resolve(false); + }; + + const timer = setTimeout(() => { + abortSignal?.removeEventListener("abort", handleAbort); + resolve(true); + }, delayMs); + timer.unref?.(); + + abortSignal?.addEventListener("abort", handleAbort, { once: true }); + }); +} diff --git a/extensions/feishu/src/bot.broadcast.test.ts b/extensions/feishu/src/bot.broadcast.test.ts new file mode 100644 index 00000000000..81fffa6744c --- /dev/null +++ b/extensions/feishu/src/bot.broadcast.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js"; +import type { FeishuMessageEvent } from "./bot.js"; +import { handleFeishuMessage } from "./bot.js"; +import { setFeishuRuntime } from "./runtime.js"; + +const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgentRoute } = + vi.hoisted(() => ({ + mockCreateFeishuReplyDispatcher: vi.fn(() => ({ + dispatcher: { + sendToolResult: vi.fn(), + sendBlockReply: vi.fn(), + sendFinalReply: vi.fn(), + waitForIdle: vi.fn(), + getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), + markComplete: vi.fn(), + }, + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), + mockCreateFeishuClient: vi.fn(), + mockResolveAgentRoute: vi.fn(), + })); + +vi.mock("./reply-dispatcher.js", () => ({ + createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: mockCreateFeishuClient, +})); + +describe("broadcast dispatch", () => { + const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); + const mockDispatchReplyFromConfig = vi + .fn() + .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }); + const mockWithReplyDispatcher = vi.fn( + async ({ + dispatcher, + run, + onSettled, + }: Parameters[0]) => { + try { + return await run(); + } finally { + dispatcher.markComplete(); + try { + await dispatcher.waitForIdle(); + } finally { + await onSettled?.(); + } + } + }, + ); + const mockShouldComputeCommandAuthorized = vi.fn(() => false); + const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/inbound-clip.mp4", + contentType: "video/mp4", + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockResolveAgentRoute.mockReturnValue({ + agentId: "main", + channel: "feishu", + accountId: "default", + sessionKey: "agent:main:feishu:group:oc-broadcast-group", + mainSessionKey: "agent:main:main", + matchedBy: "default", + }); + mockCreateFeishuClient.mockReturnValue({ + contact: { + user: { + get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }), + }, + }, + }); + setFeishuRuntime({ + system: { + enqueueSystemEvent: vi.fn(), + }, + channel: { + routing: { + resolveAgentRoute: mockResolveAgentRoute, + }, + reply: { + resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })), + formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), + finalizeInboundContext: mockFinalizeInboundContext, + dispatchReplyFromConfig: mockDispatchReplyFromConfig, + withReplyDispatcher: mockWithReplyDispatcher, + }, + commands: { + shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, + resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), + }, + media: { + saveMediaBuffer: mockSaveMediaBuffer, + }, + pairing: { + readAllowFromStore: vi.fn().mockResolvedValue([]), + upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }), + buildPairingReply: vi.fn(() => "Pairing response"), + }, + }, + media: { + detectMime: vi.fn(async () => "application/octet-stream"), + }, + } as unknown as PluginRuntime); + }); + + it("dispatches to all broadcast agents when bot is mentioned", async () => { + const cfg: ClawdbotConfig = { + broadcast: { "oc-broadcast-group": ["susan", "main"] }, + agents: { list: [{ id: "main" }, { id: "susan" }] }, + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: true, + }, + }, + }, + }, + } as unknown as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-broadcast-mentioned", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello @bot" }), + mentions: [ + { key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" }, + ], + }, + }; + + await handleFeishuMessage({ + cfg, + event, + botOpenId: "bot-open-id", + runtime: createRuntimeEnv(), + }); + + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); + const sessionKeys = mockFinalizeInboundContext.mock.calls.map( + (call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey, + ); + expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group"); + expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group"); + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "main" }), + ); + }); + + it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => { + const cfg: ClawdbotConfig = { + broadcast: { "oc-broadcast-group": ["susan", "main"] }, + agents: { list: [{ id: "main" }, { id: "susan" }] }, + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: true, + }, + }, + }, + }, + } as unknown as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-broadcast-not-mentioned", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello everyone" }), + }, + }; + + await handleFeishuMessage({ + cfg, + event, + botOpenId: "ou_known_bot", + runtime: createRuntimeEnv(), + }); + + expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); + }); + + it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => { + const cfg: ClawdbotConfig = { + broadcast: { "oc-broadcast-group": ["susan", "main"] }, + agents: { list: [{ id: "main" }, { id: "susan" }] }, + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: true, + }, + }, + }, + }, + } as unknown as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-broadcast-unknown-bot-id", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello everyone" }), + }, + }; + + await handleFeishuMessage({ + cfg, + event, + runtime: createRuntimeEnv(), + }); + + expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); + }); + + it("preserves single-agent dispatch when no broadcast config", async () => { + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: false, + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-no-broadcast", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await handleFeishuMessage({ + cfg, + event, + runtime: createRuntimeEnv(), + }); + + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + SessionKey: "agent:main:feishu:group:oc-broadcast-group", + }), + ); + }); + + it("cross-account broadcast dedup: second account skips dispatch", async () => { + const cfg: ClawdbotConfig = { + broadcast: { "oc-broadcast-group": ["susan", "main"] }, + agents: { list: [{ id: "main" }, { id: "susan" }] }, + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: false, + }, + }, + }, + }, + } as unknown as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-multi-account-dedup", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await handleFeishuMessage({ + cfg, + event, + runtime: createRuntimeEnv(), + accountId: "account-A", + }); + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); + + mockDispatchReplyFromConfig.mockClear(); + mockFinalizeInboundContext.mockClear(); + + await handleFeishuMessage({ + cfg, + event, + runtime: createRuntimeEnv(), + accountId: "account-B", + }); + expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); + }); + + it("skips unknown agents not in agents.list", async () => { + const cfg: ClawdbotConfig = { + broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] }, + agents: { list: [{ id: "main" }, { id: "susan" }] }, + channels: { + feishu: { + groups: { + "oc-broadcast-group": { + requireMention: false, + }, + }, + }, + }, + } as unknown as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-sender" } }, + message: { + message_id: "msg-broadcast-unknown-agent", + chat_id: "oc-broadcast-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }; + + await handleFeishuMessage({ + cfg, + event, + runtime: createRuntimeEnv(), + }); + + expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); + const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string }) + .SessionKey; + expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group"); + }); +}); diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index ea7f19d6e33..c96094a296f 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2319,342 +2319,3 @@ describe("handleFeishuMessage command authorization", () => { expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); }); }); - -describe("broadcast dispatch", () => { - const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx); - const mockDispatchReplyFromConfig = vi - .fn() - .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } }); - const mockWithReplyDispatcher = vi.fn( - async ({ - dispatcher, - run, - onSettled, - }: Parameters[0]) => { - try { - return await run(); - } finally { - dispatcher.markComplete(); - try { - await dispatcher.waitForIdle(); - } finally { - await onSettled?.(); - } - } - }, - ); - const mockShouldComputeCommandAuthorized = vi.fn(() => false); - const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ - path: "/tmp/inbound-clip.mp4", - contentType: "video/mp4", - }); - - beforeEach(() => { - vi.clearAllMocks(); - mockResolveAgentRoute.mockReturnValue({ - agentId: "main", - channel: "feishu", - accountId: "default", - sessionKey: "agent:main:feishu:group:oc-broadcast-group", - mainSessionKey: "agent:main:main", - matchedBy: "default", - }); - mockCreateFeishuClient.mockReturnValue({ - contact: { - user: { - get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }), - }, - }, - }); - setFeishuRuntime({ - system: { - enqueueSystemEvent: vi.fn(), - }, - channel: { - routing: { - resolveAgentRoute: mockResolveAgentRoute, - }, - reply: { - resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })), - formatAgentEnvelope: vi.fn((params: { body: string }) => params.body), - finalizeInboundContext: mockFinalizeInboundContext, - dispatchReplyFromConfig: mockDispatchReplyFromConfig, - withReplyDispatcher: mockWithReplyDispatcher, - }, - commands: { - shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, - resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false), - }, - media: { - saveMediaBuffer: mockSaveMediaBuffer, - }, - pairing: { - readAllowFromStore: vi.fn().mockResolvedValue([]), - upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }), - buildPairingReply: vi.fn(() => "Pairing response"), - }, - }, - media: { - detectMime: vi.fn(async () => "application/octet-stream"), - }, - } as unknown as PluginRuntime); - }); - - it("dispatches to all broadcast agents when bot is mentioned", async () => { - const cfg: ClawdbotConfig = { - broadcast: { "oc-broadcast-group": ["susan", "main"] }, - agents: { list: [{ id: "main" }, { id: "susan" }] }, - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: true, - }, - }, - }, - }, - } as unknown as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-broadcast-mentioned", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello @bot" }), - mentions: [ - { key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" }, - ], - }, - }; - - await handleFeishuMessage({ - cfg, - event, - botOpenId: "bot-open-id", - runtime: createRuntimeEnv(), - }); - - // Both agents should get dispatched - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); - - // Verify session keys for both agents - const sessionKeys = mockFinalizeInboundContext.mock.calls.map( - (call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey, - ); - expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group"); - expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group"); - - // Active agent (mentioned) gets the real Feishu reply dispatcher - expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); - expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "main" }), - ); - }); - - it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => { - const cfg: ClawdbotConfig = { - broadcast: { "oc-broadcast-group": ["susan", "main"] }, - agents: { list: [{ id: "main" }, { id: "susan" }] }, - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: true, - }, - }, - }, - }, - } as unknown as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-broadcast-not-mentioned", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello everyone" }), - }, - }; - - await handleFeishuMessage({ - cfg, - event, - botOpenId: "ou_known_bot", - runtime: createRuntimeEnv(), - }); - - // No dispatch: requireMention=true and bot not mentioned → returns early. - // The mentioned bot's handler (on another account or same account with - // matching botOpenId) will handle broadcast dispatch for all agents. - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); - expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); - }); - - it("skips broadcast dispatch when bot identity is unknown (requireMention=true)", async () => { - const cfg: ClawdbotConfig = { - broadcast: { "oc-broadcast-group": ["susan", "main"] }, - agents: { list: [{ id: "main" }, { id: "susan" }] }, - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: true, - }, - }, - }, - }, - } as unknown as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-broadcast-unknown-bot-id", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello everyone" }), - }, - }; - - await handleFeishuMessage({ - cfg, - event, - runtime: createRuntimeEnv(), - }); - - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); - expect(mockCreateFeishuReplyDispatcher).not.toHaveBeenCalled(); - }); - - it("preserves single-agent dispatch when no broadcast config", async () => { - const cfg: ClawdbotConfig = { - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: false, - }, - }, - }, - }, - } as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-no-broadcast", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello" }), - }, - }; - - await handleFeishuMessage({ - cfg, - event, - runtime: createRuntimeEnv(), - }); - - // Single dispatch (no broadcast) - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); - expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1); - expect(mockFinalizeInboundContext).toHaveBeenCalledWith( - expect.objectContaining({ - SessionKey: "agent:main:feishu:group:oc-broadcast-group", - }), - ); - }); - - it("cross-account broadcast dedup: second account skips dispatch", async () => { - const cfg: ClawdbotConfig = { - broadcast: { "oc-broadcast-group": ["susan", "main"] }, - agents: { list: [{ id: "main" }, { id: "susan" }] }, - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: false, - }, - }, - }, - }, - } as unknown as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-multi-account-dedup", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello" }), - }, - }; - - // First account handles broadcast normally - await handleFeishuMessage({ - cfg, - event, - runtime: createRuntimeEnv(), - accountId: "account-A", - }); - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2); - - mockDispatchReplyFromConfig.mockClear(); - mockFinalizeInboundContext.mockClear(); - - // Second account: same message ID, different account. - // Per-account dedup passes (different namespace), but cross-account - // broadcast dedup blocks dispatch. - await handleFeishuMessage({ - cfg, - event, - runtime: createRuntimeEnv(), - accountId: "account-B", - }); - expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled(); - }); - - it("skips unknown agents not in agents.list", async () => { - const cfg: ClawdbotConfig = { - broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] }, - agents: { list: [{ id: "main" }, { id: "susan" }] }, - channels: { - feishu: { - groups: { - "oc-broadcast-group": { - requireMention: false, - }, - }, - }, - }, - } as unknown as ClawdbotConfig; - - const event: FeishuMessageEvent = { - sender: { sender_id: { open_id: "ou-sender" } }, - message: { - message_id: "msg-broadcast-unknown-agent", - chat_id: "oc-broadcast-group", - chat_type: "group", - message_type: "text", - content: JSON.stringify({ text: "hello" }), - }, - }; - - await handleFeishuMessage({ - cfg, - event, - runtime: createRuntimeEnv(), - }); - - // Only susan should get dispatched (unknown-agent skipped) - expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1); - const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string }) - .SessionKey; - expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group"); - }); -}); diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index 0104e683532..bac8e75bb44 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -20,6 +20,7 @@ import { warmupDedupFromDisk, } from "./dedup.js"; import { isMentionForwardRequest } from "./mention.js"; +import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js"; import { fetchBotIdentityForMonitor } from "./monitor.startup.js"; import { botNames, botOpenIds } from "./monitor.state.js"; import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js"; @@ -619,70 +620,6 @@ function registerEventHandlers( }); } -// Delays must be >= PROBE_ERROR_TTL_MS (60s) so each retry makes a real network request -// instead of silently hitting the probe error cache. -const BOT_IDENTITY_RETRY_DELAYS_MS = [60_000, 120_000, 300_000, 600_000, 900_000]; - -export function waitForAbortableDelay( - delayMs: number, - abortSignal?: AbortSignal, -): Promise { - if (abortSignal?.aborted) { - return Promise.resolve(false); - } - - return new Promise((resolve) => { - const timer = setTimeout(() => { - abortSignal?.removeEventListener("abort", handleAbort); - resolve(true); - }, delayMs); - timer.unref?.(); - - const handleAbort = () => { - clearTimeout(timer); - resolve(false); - }; - - abortSignal?.addEventListener("abort", handleAbort, { once: true }); - }); -} - -async function retryBotIdentityProbe( - account: ResolvedFeishuAccount, - accountId: string, - runtime: RuntimeEnv | undefined, - abortSignal: AbortSignal | undefined, -): Promise { - const log = runtime?.log ?? console.log; - const error = runtime?.error ?? console.error; - for (let i = 0; i < BOT_IDENTITY_RETRY_DELAYS_MS.length; i++) { - if (abortSignal?.aborted) return; - const delayElapsed = await waitForAbortableDelay(BOT_IDENTITY_RETRY_DELAYS_MS[i], abortSignal); - if (!delayElapsed) { - return; - } - const identity = await fetchBotIdentityForMonitor(account, { runtime, abortSignal }); - if (identity.botOpenId) { - botOpenIds.set(accountId, identity.botOpenId); - if (identity.botName?.trim()) { - botNames.set(accountId, identity.botName.trim()); - } - log( - `feishu[${accountId}]: bot open_id recovered via background retry: ${identity.botOpenId}`, - ); - return; - } - const nextDelay = BOT_IDENTITY_RETRY_DELAYS_MS[i + 1]; - error( - `feishu[${accountId}]: bot identity background retry ${i + 1}/${BOT_IDENTITY_RETRY_DELAYS_MS.length} failed` + - (nextDelay ? `; next attempt in ${nextDelay / 1000}s` : ""), - ); - } - error( - `feishu[${accountId}]: bot identity background retry exhausted; requireMention group messages may be skipped until restart`, - ); -} - export type BotOpenIdSource = | { kind: "prefetched"; botOpenId?: string; botName?: string } | { kind: "fetch" }; @@ -705,26 +642,11 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams): botOpenIdSource.kind === "prefetched" ? { botOpenId: botOpenIdSource.botOpenId, botName: botOpenIdSource.botName } : await fetchBotIdentityForMonitor(account, { runtime, abortSignal }); - const botOpenId = botIdentity.botOpenId; - const botName = botIdentity.botName?.trim(); - botOpenIds.set(accountId, botOpenId ?? ""); - if (botName) { - botNames.set(accountId, botName); - } else { - botNames.delete(accountId); - } + const { botOpenId } = applyBotIdentityState(accountId, botIdentity); log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`); - // When the startup probe failed, retry in the background so the degraded - // state (responding to all group messages) is bounded rather than permanent. if (!botOpenId && !abortSignal?.aborted) { - log( - `feishu[${accountId}]: bot open_id unknown; starting background retry (delays: ${BOT_IDENTITY_RETRY_DELAYS_MS.map((d) => `${d / 1000}s`).join(", ")})`, - ); - log( - `feishu[${accountId}]: requireMention group messages stay gated until bot identity recovery succeeds`, - ); - void retryBotIdentityProbe(account, accountId, runtime, abortSignal); + startBotIdentityRecovery({ account, accountId, runtime, abortSignal }); } const connectionMode = account.config.connectionMode ?? "websocket"; diff --git a/extensions/feishu/src/monitor.bot-identity.ts b/extensions/feishu/src/monitor.bot-identity.ts new file mode 100644 index 00000000000..445a0a30d22 --- /dev/null +++ b/extensions/feishu/src/monitor.bot-identity.ts @@ -0,0 +1,85 @@ +import type { RuntimeEnv } from "../runtime-api.js"; +import { waitForAbortableDelay } from "./async.js"; +import { fetchBotIdentityForMonitor, type FeishuMonitorBotIdentity } from "./monitor.startup.js"; +import { botNames, botOpenIds } from "./monitor.state.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +// Delays must be >= PROBE_ERROR_TTL_MS (60s) so each retry makes a real network request +// instead of silently hitting the probe error cache. +export const BOT_IDENTITY_RETRY_DELAYS_MS = [60_000, 120_000, 300_000, 600_000, 900_000]; + +export function applyBotIdentityState( + accountId: string, + identity: FeishuMonitorBotIdentity, +): { botOpenId?: string; botName?: string } { + const botOpenId = identity.botOpenId?.trim() || undefined; + const botName = identity.botName?.trim() || undefined; + + botOpenIds.set(accountId, botOpenId ?? ""); + if (botName) { + botNames.set(accountId, botName); + } else { + botNames.delete(accountId); + } + + return { botOpenId, botName }; +} + +async function retryBotIdentityProbe( + account: ResolvedFeishuAccount, + accountId: string, + runtime: RuntimeEnv | undefined, + abortSignal: AbortSignal | undefined, +): Promise { + const log = runtime?.log ?? console.log; + const error = runtime?.error ?? console.error; + + for (let i = 0; i < BOT_IDENTITY_RETRY_DELAYS_MS.length; i += 1) { + if (abortSignal?.aborted) { + return; + } + + const delayElapsed = await waitForAbortableDelay(BOT_IDENTITY_RETRY_DELAYS_MS[i], abortSignal); + if (!delayElapsed) { + return; + } + + const identity = await fetchBotIdentityForMonitor(account, { runtime, abortSignal }); + const resolved = applyBotIdentityState(accountId, identity); + if (resolved.botOpenId) { + log( + `feishu[${accountId}]: bot open_id recovered via background retry: ${resolved.botOpenId}`, + ); + return; + } + + const nextDelay = BOT_IDENTITY_RETRY_DELAYS_MS[i + 1]; + error( + `feishu[${accountId}]: bot identity background retry ${i + 1}/${BOT_IDENTITY_RETRY_DELAYS_MS.length} failed` + + (nextDelay ? `; next attempt in ${nextDelay / 1000}s` : ""), + ); + } + + error( + `feishu[${accountId}]: bot identity background retry exhausted; requireMention group messages may be skipped until restart`, + ); +} + +export function startBotIdentityRecovery(params: { + account: ResolvedFeishuAccount; + accountId: string; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; +}): void { + const { account, accountId, runtime, abortSignal } = params; + const log = runtime?.log ?? console.log; + + log( + `feishu[${accountId}]: bot open_id unknown; starting background retry (delays: ${BOT_IDENTITY_RETRY_DELAYS_MS.map((delay) => `${delay / 1000}s`).join(", ")})`, + ); + log( + `feishu[${accountId}]: requireMention group messages stay gated until bot identity recovery succeeds`, + ); + + void retryBotIdentityProbe(account, accountId, runtime, abortSignal); +} diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index b358a78150d..dcb06a5c734 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,6 +1,5 @@ import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; -import { normalizeGoogleApiBaseUrl } from "../infra/google-api-base-url.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { buildAnthropicVertexProvider, diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 77764468c7e..a0fbba87403 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -2,7 +2,6 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { OpenClawConfig } from "../../config/config.js"; import type { ModelDefinitionConfig } from "../../config/types.js"; -import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js"; import { clearProviderRuntimeHookCache, prepareProviderDynamicModel,