refactor: split feishu helpers and tests

This commit is contained in:
Peter Steinberger
2026-03-24 17:12:25 -07:00
parent 5cdb50abe6
commit f6205de73a
8 changed files with 470 additions and 423 deletions

View File

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

View File

@@ -60,3 +60,27 @@ export async function raceWithTimeoutAndAbort<T>(
}
}
}
export function waitForAbortableDelay(
delayMs: number,
abortSignal?: AbortSignal,
): Promise<boolean> {
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 });
});
}

View File

@@ -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<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[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");
});
});

View File

@@ -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<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[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");
});
});

View File

@@ -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<boolean> {
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<void> {
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";

View File

@@ -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<void> {
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);
}

View File

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

View File

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