mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 17:32:16 +00:00
refactor: split feishu helpers and tests
This commit is contained in:
@@ -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();
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
357
extensions/feishu/src/bot.broadcast.test.ts
Normal file
357
extensions/feishu/src/bot.broadcast.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
85
extensions/feishu/src/monitor.bot-identity.ts
Normal file
85
extensions/feishu/src/monitor.bot-identity.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user