mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 04:20:34 +00:00
Tests: centralize contract coverage follow-ups (#48751)
* Plugins: harden global contract coverage * Channels: tighten global contract coverage * Channels: centralize inbound contract coverage * Channels: move inbound contract helpers into core * Tests: rename local inbound context checks * Tests: stabilize contract runner profile * Tests: split scoped contract lanes * Channels: move inbound dispatch testkit into contracts * Plugins: share provider contract registry helpers * Plugins: reuse provider contract registry helpers
This commit is contained in:
@@ -6,7 +6,9 @@ for (const entry of directoryContractRegistry) {
|
||||
describe(`${entry.id} directory contract`, () => {
|
||||
installChannelDirectoryContractSuite({
|
||||
plugin: entry.plugin,
|
||||
invokeLookups: entry.invokeLookups,
|
||||
coverage: entry.coverage,
|
||||
cfg: entry.cfg,
|
||||
accountId: entry.accountId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
18
src/channels/plugins/contracts/dispatch-inbound-capture.ts
Normal file
18
src/channels/plugins/contracts/dispatch-inbound-capture.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function buildDispatchInboundCaptureMock<T extends Record<string, unknown>>(
|
||||
actual: T,
|
||||
setCtx: (ctx: unknown) => void,
|
||||
) {
|
||||
const dispatchInboundMessage = vi.fn(async (params: { ctx: unknown }) => {
|
||||
setCtx(params.ctx);
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage,
|
||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
|
||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
|
||||
};
|
||||
}
|
||||
20
src/channels/plugins/contracts/inbound-contract-capture.ts
Normal file
20
src/channels/plugins/contracts/inbound-contract-capture.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MsgContext } from "../../../auto-reply/templating.js";
|
||||
import { buildDispatchInboundCaptureMock } from "./dispatch-inbound-capture.js";
|
||||
|
||||
export type InboundContextCapture = {
|
||||
ctx: MsgContext | undefined;
|
||||
};
|
||||
|
||||
export function createInboundContextCapture(): InboundContextCapture {
|
||||
return { ctx: undefined };
|
||||
}
|
||||
|
||||
export async function buildDispatchInboundContextCapture(
|
||||
importOriginal: <T extends Record<string, unknown>>() => Promise<T>,
|
||||
capture: InboundContextCapture,
|
||||
) {
|
||||
const actual = await importOriginal<typeof import("../../../auto-reply/dispatch.js")>();
|
||||
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
||||
capture.ctx = ctx as MsgContext;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { vi } from "vitest";
|
||||
import { createInboundContextCapture } from "./inbound-contract-capture.js";
|
||||
import { buildDispatchInboundContextCapture } from "./inbound-contract-capture.js";
|
||||
|
||||
export const inboundCtxCapture = createInboundContextCapture();
|
||||
|
||||
vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
return await buildDispatchInboundContextCapture(importOriginal, inboundCtxCapture);
|
||||
});
|
||||
299
src/channels/plugins/contracts/inbound.contract.test.ts
Normal file
299
src/channels/plugins/contracts/inbound.contract.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js";
|
||||
import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js";
|
||||
import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js";
|
||||
import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js";
|
||||
import type { MsgContext } from "../../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { inboundCtxCapture } from "./inbound-contract-dispatch-mock.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
const signalCapture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined }));
|
||||
const bufferedReplyCapture = vi.hoisted(() => ({
|
||||
ctx: undefined as MsgContext | undefined,
|
||||
}));
|
||||
const dispatchInboundMessageMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
signalCapture.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../auto-reply/dispatch.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => {
|
||||
bufferedReplyCapture.ctx = params.ctx;
|
||||
return { queuedFinal: false };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../extensions/signal/src/send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendTypingSignal: vi.fn(async () => true),
|
||||
sendReadReceiptSignal: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({
|
||||
trackBackgroundTask: (tasks: Set<Promise<unknown>>, task: Promise<unknown>) => {
|
||||
tasks.add(task);
|
||||
void task.finally(() => {
|
||||
tasks.delete(task);
|
||||
});
|
||||
},
|
||||
updateLastRouteInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({
|
||||
deliverWebReply: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { processDiscordMessage } =
|
||||
await import("../../../../extensions/discord/src/monitor/message-handler.process.js");
|
||||
const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
|
||||
await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js");
|
||||
const { createSignalEventHandler } =
|
||||
await import("../../../../extensions/signal/src/monitor/event-handler.js");
|
||||
const { createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
|
||||
await import("../../../../extensions/signal/src/monitor/event-handler.test-harness.js");
|
||||
const { processMessage } =
|
||||
await import("../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js");
|
||||
|
||||
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
userTokenSource: "none",
|
||||
config,
|
||||
replyToMode: config.replyToMode,
|
||||
replyToModeByChatType: config.replyToModeByChatType,
|
||||
dm: config.dm,
|
||||
};
|
||||
}
|
||||
|
||||
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
|
||||
return {
|
||||
channel: "D123",
|
||||
channel_type: "im",
|
||||
user: "U1",
|
||||
text: "hi",
|
||||
ts: "1.000",
|
||||
...overrides,
|
||||
} as SlackMessageEvent;
|
||||
}
|
||||
|
||||
function makeWhatsAppProcessArgs(sessionStorePath: string) {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: {}, session: { store: sessionStorePath } } as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
msg: {
|
||||
id: "msg1",
|
||||
from: "123@g.us",
|
||||
to: "+15550001111",
|
||||
chatType: "group",
|
||||
body: "hi",
|
||||
senderName: "Alice",
|
||||
senderJid: "alice@s.whatsapp.net",
|
||||
senderE164: "+15550002222",
|
||||
groupSubject: "Test Group",
|
||||
groupParticipants: [],
|
||||
} as unknown as Record<string, unknown>,
|
||||
route: {
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:whatsapp:group:123",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any,
|
||||
groupHistoryKey: "123@g.us",
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
connectionId: "conn",
|
||||
verbose: false,
|
||||
maxMediaBytes: 1,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
replyResolver: (async () => undefined) as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any,
|
||||
backgroundTasks: new Set<Promise<unknown>>(),
|
||||
rememberSentText: () => {},
|
||||
echoHas: () => false,
|
||||
echoForget: () => {},
|
||||
buildCombinedEchoKey: () => "echo",
|
||||
groupHistory: [],
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function removeDirEventually(dir: string) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("channel inbound contract", () => {
|
||||
let whatsappSessionDir = "";
|
||||
|
||||
beforeEach(() => {
|
||||
inboundCtxCapture.ctx = undefined;
|
||||
signalCapture.ctx = undefined;
|
||||
bufferedReplyCapture.ctx = undefined;
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (whatsappSessionDir) {
|
||||
await removeDirEventually(whatsappSessionDir);
|
||||
whatsappSessionDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Discord inbound context finalized", async () => {
|
||||
const messageCtx = await createBaseDiscordMessageContext({
|
||||
cfg: { messages: {} },
|
||||
ackReactionScope: "direct",
|
||||
...createDiscordDirectMessageContextOverrides(),
|
||||
});
|
||||
|
||||
await processDiscordMessage(messageCtx);
|
||||
|
||||
expect(inboundCtxCapture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(inboundCtxCapture.ctx!);
|
||||
});
|
||||
|
||||
it("keeps Signal inbound context finalized", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
attachments: [],
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(signalCapture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(signalCapture.ctx!);
|
||||
});
|
||||
|
||||
it("keeps Slack inbound context finalized", async () => {
|
||||
const ctx = createInboundSlackTestContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
ctx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account: createSlackAccount(),
|
||||
message: createSlackMessage({}),
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expectChannelInboundContextContract(prepared!.ctxPayload);
|
||||
});
|
||||
|
||||
it("keeps Telegram inbound context finalized", async () => {
|
||||
const { getLoadConfigMock, getOnHandler, onSpy, sendMessageSpy } =
|
||||
await import("../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js");
|
||||
const { resetInboundDedupe } = await import("../../../auto-reply/reply/inbound-dedupe.js");
|
||||
|
||||
resetInboundDedupe();
|
||||
onSpy.mockReset();
|
||||
sendMessageSpy.mockReset();
|
||||
sendMessageSpy.mockResolvedValue({ message_id: 77 });
|
||||
getLoadConfigMock().mockReset();
|
||||
getLoadConfigMock().mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
|
||||
const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js");
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
const payload = bufferedReplyCapture.ctx;
|
||||
expect(payload).toBeTruthy();
|
||||
expectChannelInboundContextContract(payload!);
|
||||
});
|
||||
|
||||
it("keeps WhatsApp inbound context finalized", async () => {
|
||||
whatsappSessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-"));
|
||||
const sessionStorePath = path.join(whatsappSessionDir, "sessions.json");
|
||||
|
||||
await processMessage(makeWhatsAppProcessArgs(sessionStorePath));
|
||||
|
||||
expect(bufferedReplyCapture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(bufferedReplyCapture.ctx!);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inboundCtxCapture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
const { processDiscordMessage } =
|
||||
await import("../../../../extensions/discord/src/monitor/message-handler.process.js");
|
||||
const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
|
||||
await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js");
|
||||
|
||||
describe("discord inbound contract", () => {
|
||||
it("keeps inbound context finalized", async () => {
|
||||
inboundCtxCapture.ctx = undefined;
|
||||
const messageCtx = await createBaseDiscordMessageContext({
|
||||
cfg: { messages: {} },
|
||||
ackReactionScope: "direct",
|
||||
...createDiscordDirectMessageContextOverrides(),
|
||||
});
|
||||
|
||||
await processDiscordMessage(messageCtx);
|
||||
|
||||
expect(inboundCtxCapture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(inboundCtxCapture.ctx!);
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSignalEventHandler } from "../../../../extensions/signal/src/monitor/event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "../../../../extensions/signal/src/monitor/event-handler.test-harness.js";
|
||||
import type { MsgContext } from "../../../auto-reply/templating.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
const capture = vi.hoisted(() => ({ ctx: undefined as MsgContext | undefined }));
|
||||
const dispatchInboundMessageMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
async (params: {
|
||||
ctx: MsgContext;
|
||||
replyOptions?: { onReplyStart?: () => void | Promise<void> };
|
||||
}) => {
|
||||
capture.ctx = params.ctx;
|
||||
await Promise.resolve(params.replyOptions?.onReplyStart?.());
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../../../auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../auto-reply/dispatch.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithDispatcher: dispatchInboundMessageMock,
|
||||
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../extensions/signal/src/send.js", () => ({
|
||||
sendMessageSignal: vi.fn(),
|
||||
sendTypingSignal: vi.fn(async () => true),
|
||||
sendReadReceiptSignal: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("signal inbound contract", () => {
|
||||
beforeEach(() => {
|
||||
capture.ctx = undefined;
|
||||
dispatchInboundMessageMock.mockClear();
|
||||
});
|
||||
|
||||
it("keeps inbound context finalized", async () => {
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: { inbound: { debounceMs: 0 } } } as any,
|
||||
historyLimit: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await handler(
|
||||
createSignalReceiveEvent({
|
||||
dataMessage: {
|
||||
message: "hi",
|
||||
attachments: [],
|
||||
groupInfo: { groupId: "g1", groupName: "Test Group" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(capture.ctx!);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js";
|
||||
import { prepareSlackMessage } from "../../../../extensions/slack/src/monitor/message-handler/prepare.js";
|
||||
import { createInboundSlackTestContext } from "../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js";
|
||||
import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
userTokenSource: "none",
|
||||
config,
|
||||
replyToMode: config.replyToMode,
|
||||
replyToModeByChatType: config.replyToModeByChatType,
|
||||
dm: config.dm,
|
||||
};
|
||||
}
|
||||
|
||||
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
|
||||
return {
|
||||
channel: "D123",
|
||||
channel_type: "im",
|
||||
user: "U1",
|
||||
text: "hi",
|
||||
ts: "1.000",
|
||||
...overrides,
|
||||
} as SlackMessageEvent;
|
||||
}
|
||||
|
||||
describe("slack inbound contract", () => {
|
||||
it("keeps inbound context finalized", async () => {
|
||||
const ctx = createInboundSlackTestContext({
|
||||
cfg: {
|
||||
channels: { slack: { enabled: true } },
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
ctx.resolveUserName = async () => ({ name: "Alice" }) as any;
|
||||
|
||||
const prepared = await prepareSlackMessage({
|
||||
ctx,
|
||||
account: createSlackAccount(),
|
||||
message: createSlackMessage({}),
|
||||
opts: { source: "message" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeTruthy();
|
||||
expectChannelInboundContextContract(prepared!.ctxPayload);
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getLoadConfigMock,
|
||||
getOnHandler,
|
||||
onSpy,
|
||||
replySpy,
|
||||
} from "../../../../extensions/telegram/src/bot.create-telegram-bot.test-harness.js";
|
||||
import type { MsgContext } from "../../../auto-reply/templating.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
const { createTelegramBot } = await import("../../../../extensions/telegram/src/bot.js");
|
||||
|
||||
describe("telegram inbound contract", () => {
|
||||
const loadConfig = getLoadConfigMock();
|
||||
|
||||
beforeEach(() => {
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
});
|
||||
|
||||
it("keeps inbound context finalized", async () => {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 42, type: "group", title: "Ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 2,
|
||||
from: {
|
||||
id: 99,
|
||||
first_name: "Ada",
|
||||
last_name: "Lovelace",
|
||||
username: "ada",
|
||||
},
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
const payload = replySpy.mock.calls[0]?.[0] as MsgContext | undefined;
|
||||
expect(payload).toBeTruthy();
|
||||
expectChannelInboundContextContract(payload!);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { processMessage } from "../../../../extensions/whatsapp/src/auto-reply/monitor/process-message.js";
|
||||
import type { MsgContext } from "../../../auto-reply/templating.js";
|
||||
import { expectChannelInboundContextContract } from "./suites.js";
|
||||
|
||||
const capture = vi.hoisted(() => ({
|
||||
ctx: undefined as MsgContext | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../../auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (params: { ctx: MsgContext }) => {
|
||||
capture.ctx = params.ctx;
|
||||
return { queuedFinal: false };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../extensions/whatsapp/src/auto-reply/monitor/last-route.js", () => ({
|
||||
trackBackgroundTask: (tasks: Set<Promise<unknown>>, task: Promise<unknown>) => {
|
||||
tasks.add(task);
|
||||
void task.finally(() => {
|
||||
tasks.delete(task);
|
||||
});
|
||||
},
|
||||
updateLastRouteInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () => ({
|
||||
deliverWebReply: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
function makeProcessArgs(sessionStorePath: string) {
|
||||
return {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
cfg: { messages: {}, session: { store: sessionStorePath } } as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
msg: {
|
||||
id: "msg1",
|
||||
from: "123@g.us",
|
||||
to: "+15550001111",
|
||||
chatType: "group",
|
||||
body: "hi",
|
||||
senderName: "Alice",
|
||||
senderJid: "alice@s.whatsapp.net",
|
||||
senderE164: "+15550002222",
|
||||
groupSubject: "Test Group",
|
||||
groupParticipants: [],
|
||||
} as unknown as Record<string, unknown>,
|
||||
route: {
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:whatsapp:group:123",
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any,
|
||||
groupHistoryKey: "123@g.us",
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
connectionId: "conn",
|
||||
verbose: false,
|
||||
maxMediaBytes: 1,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
replyResolver: (async () => undefined) as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
replyLogger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as any,
|
||||
backgroundTasks: new Set<Promise<unknown>>(),
|
||||
rememberSentText: () => {},
|
||||
echoHas: () => false,
|
||||
echoForget: () => {},
|
||||
buildCombinedEchoKey: () => "echo",
|
||||
groupHistory: [],
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function removeDirEventually(dir: string) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOTEMPTY" || attempt === 2) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("whatsapp inbound contract", () => {
|
||||
let sessionDir = "";
|
||||
|
||||
afterEach(async () => {
|
||||
capture.ctx = undefined;
|
||||
if (sessionDir) {
|
||||
await removeDirEventually(sessionDir);
|
||||
sessionDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps inbound context finalized", async () => {
|
||||
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-whatsapp-contract-"));
|
||||
const sessionStorePath = path.join(sessionDir, "sessions.json");
|
||||
|
||||
await processMessage(makeProcessArgs(sessionStorePath));
|
||||
|
||||
expect(capture.ctx).toBeTruthy();
|
||||
expectChannelInboundContextContract(capture.ctx!);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,48 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionContractRegistry,
|
||||
channelPluginSurfaceKeys,
|
||||
directoryContractRegistry,
|
||||
pluginContractRegistry,
|
||||
sessionBindingContractRegistry,
|
||||
setupContractRegistry,
|
||||
statusContractRegistry,
|
||||
surfaceContractRegistry,
|
||||
threadingContractRegistry,
|
||||
type ChannelPluginSurface,
|
||||
} from "./registry.js";
|
||||
|
||||
const orderedSurfaceKeys = [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
] as const satisfies readonly ChannelPluginSurface[];
|
||||
function listFilesRecursively(dir: string): string[] {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listFilesRecursively(fullPath));
|
||||
continue;
|
||||
}
|
||||
files.push(fullPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function discoverSessionBindingChannels() {
|
||||
const extensionsDir = path.resolve(import.meta.dirname, "../../../../extensions");
|
||||
const channels = new Set<string>();
|
||||
for (const filePath of listFilesRecursively(extensionsDir)) {
|
||||
if (!filePath.endsWith(".ts") || filePath.endsWith(".test.ts")) {
|
||||
continue;
|
||||
}
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
for (const match of source.matchAll(
|
||||
/registerSessionBindingAdapter\(\{[\s\S]*?channel:\s*"([^"]+)"/g,
|
||||
)) {
|
||||
channels.add(match[1]);
|
||||
}
|
||||
}
|
||||
return [...channels].toSorted();
|
||||
}
|
||||
|
||||
describe("channel contract registry", () => {
|
||||
it("does not duplicate channel plugin ids", () => {
|
||||
@@ -35,7 +58,7 @@ describe("channel contract registry", () => {
|
||||
|
||||
it("declares the actual owned channel plugin surfaces explicitly", () => {
|
||||
for (const entry of surfaceContractRegistry) {
|
||||
const actual = orderedSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface]));
|
||||
const actual = channelPluginSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface]));
|
||||
expect([...entry.surfaces].toSorted()).toEqual(actual.toSorted());
|
||||
}
|
||||
});
|
||||
@@ -84,7 +107,7 @@ describe("channel contract registry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only installs deep directory coverage for plugins that declare directory", () => {
|
||||
it("covers every declared directory surface with an explicit contract level", () => {
|
||||
const directorySurfaceIds = new Set(
|
||||
surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
@@ -93,5 +116,27 @@ describe("channel contract registry", () => {
|
||||
for (const entry of directoryContractRegistry) {
|
||||
expect(directorySurfaceIds.has(entry.id)).toBe(true);
|
||||
}
|
||||
expect(directoryContractRegistry.map((entry) => entry.id).toSorted()).toEqual(
|
||||
[...directorySurfaceIds].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("only installs lookup directory coverage for plugins that declare directory", () => {
|
||||
const directorySurfaceIds = new Set(
|
||||
surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
for (const entry of directoryContractRegistry.filter(
|
||||
(candidate) => candidate.coverage === "lookups",
|
||||
)) {
|
||||
expect(directorySurfaceIds.has(entry.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps session binding coverage aligned with registered session binding adapters", () => {
|
||||
expect(sessionBindingContractRegistry.map((entry) => entry.id).toSorted()).toEqual(
|
||||
discoverSessionBindingChannels(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { expect, vi } from "vitest";
|
||||
import {
|
||||
__testing as discordThreadBindingTesting,
|
||||
createThreadBindingManager as createDiscordThreadBindingManager,
|
||||
} from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
|
||||
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import { setMatrixRuntime } from "../../../../extensions/matrix/src/runtime.js";
|
||||
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
resolveDefaultLineAccountId,
|
||||
resolveLineAccount,
|
||||
listLineAccountIds,
|
||||
} from "../../../line/accounts.js";
|
||||
import { bundledChannelRuntimeSetters, requireBundledChannelPlugin } from "../bundled.js";
|
||||
import {
|
||||
bundledChannelPlugins,
|
||||
bundledChannelRuntimeSetters,
|
||||
requireBundledChannelPlugin,
|
||||
} from "../bundled.js";
|
||||
import type { ChannelPlugin } from "../types.js";
|
||||
|
||||
type PluginContractEntry = {
|
||||
@@ -57,6 +73,17 @@ type StatusContractEntry = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export const channelPluginSurfaceKeys = [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
] as const;
|
||||
|
||||
export type ChannelPluginSurface =
|
||||
| "actions"
|
||||
| "setup"
|
||||
@@ -92,7 +119,18 @@ type ThreadingContractEntry = {
|
||||
type DirectoryContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
invokeLookups: boolean;
|
||||
coverage: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type SessionBindingContractEntry = {
|
||||
id: string;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
const telegramListActionsMock = vi.fn();
|
||||
@@ -133,28 +171,18 @@ bundledChannelRuntimeSetters.setLineRuntime({
|
||||
},
|
||||
} as never);
|
||||
|
||||
export const pluginContractRegistry: PluginContractEntry[] = [
|
||||
{ id: "bluebubbles", plugin: requireBundledChannelPlugin("bluebubbles") },
|
||||
{ id: "discord", plugin: requireBundledChannelPlugin("discord") },
|
||||
{ id: "feishu", plugin: requireBundledChannelPlugin("feishu") },
|
||||
{ id: "googlechat", plugin: requireBundledChannelPlugin("googlechat") },
|
||||
{ id: "imessage", plugin: requireBundledChannelPlugin("imessage") },
|
||||
{ id: "irc", plugin: requireBundledChannelPlugin("irc") },
|
||||
{ id: "line", plugin: requireBundledChannelPlugin("line") },
|
||||
{ id: "matrix", plugin: requireBundledChannelPlugin("matrix") },
|
||||
{ id: "mattermost", plugin: requireBundledChannelPlugin("mattermost") },
|
||||
{ id: "msteams", plugin: requireBundledChannelPlugin("msteams") },
|
||||
{ id: "nextcloud-talk", plugin: requireBundledChannelPlugin("nextcloud-talk") },
|
||||
{ id: "nostr", plugin: requireBundledChannelPlugin("nostr") },
|
||||
{ id: "signal", plugin: requireBundledChannelPlugin("signal") },
|
||||
{ id: "slack", plugin: requireBundledChannelPlugin("slack") },
|
||||
{ id: "synology-chat", plugin: requireBundledChannelPlugin("synology-chat") },
|
||||
{ id: "telegram", plugin: requireBundledChannelPlugin("telegram") },
|
||||
{ id: "tlon", plugin: requireBundledChannelPlugin("tlon") },
|
||||
{ id: "whatsapp", plugin: requireBundledChannelPlugin("whatsapp") },
|
||||
{ id: "zalo", plugin: requireBundledChannelPlugin("zalo") },
|
||||
{ id: "zalouser", plugin: requireBundledChannelPlugin("zalouser") },
|
||||
];
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
|
||||
},
|
||||
} as never);
|
||||
|
||||
export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map(
|
||||
(plugin) => ({
|
||||
id: plugin.id,
|
||||
plugin,
|
||||
}),
|
||||
);
|
||||
|
||||
export const actionContractRegistry: ActionsContractEntry[] = [
|
||||
{
|
||||
@@ -500,189 +528,13 @@ export const statusContractRegistry: StatusContractEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const surfaceContractRegistry: SurfaceContractEntry[] = [
|
||||
{
|
||||
id: "bluebubbles",
|
||||
plugin: requireBundledChannelPlugin("bluebubbles"),
|
||||
surfaces: ["actions", "setup", "status", "outbound", "messaging", "threading", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
plugin: requireBundledChannelPlugin("discord"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "feishu",
|
||||
plugin: requireBundledChannelPlugin("feishu"),
|
||||
surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "googlechat",
|
||||
plugin: requireBundledChannelPlugin("googlechat"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "imessage",
|
||||
plugin: requireBundledChannelPlugin("imessage"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "irc",
|
||||
plugin: requireBundledChannelPlugin("irc"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "line",
|
||||
plugin: requireBundledChannelPlugin("line"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "directory", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "matrix",
|
||||
plugin: requireBundledChannelPlugin("matrix"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mattermost",
|
||||
plugin: requireBundledChannelPlugin("mattermost"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msteams",
|
||||
plugin: requireBundledChannelPlugin("msteams"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "nextcloud-talk",
|
||||
plugin: requireBundledChannelPlugin("nextcloud-talk"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "nostr",
|
||||
plugin: requireBundledChannelPlugin("nostr"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "signal",
|
||||
plugin: requireBundledChannelPlugin("signal"),
|
||||
surfaces: ["actions", "setup", "status", "outbound", "messaging", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
plugin: requireBundledChannelPlugin("slack"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "synology-chat",
|
||||
plugin: requireBundledChannelPlugin("synology-chat"),
|
||||
surfaces: ["setup", "outbound", "messaging", "directory", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
plugin: requireBundledChannelPlugin("telegram"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tlon",
|
||||
plugin: requireBundledChannelPlugin("tlon"),
|
||||
surfaces: ["setup", "status", "outbound", "messaging", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "whatsapp",
|
||||
plugin: requireBundledChannelPlugin("whatsapp"),
|
||||
surfaces: ["actions", "setup", "status", "outbound", "messaging", "directory", "gateway"],
|
||||
},
|
||||
{
|
||||
id: "zalo",
|
||||
plugin: requireBundledChannelPlugin("zalo"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "zalouser",
|
||||
plugin: requireBundledChannelPlugin("zalouser"),
|
||||
surfaces: [
|
||||
"actions",
|
||||
"setup",
|
||||
"status",
|
||||
"outbound",
|
||||
"messaging",
|
||||
"threading",
|
||||
"directory",
|
||||
"gateway",
|
||||
],
|
||||
},
|
||||
];
|
||||
export const surfaceContractRegistry: SurfaceContractEntry[] = bundledChannelPlugins.map(
|
||||
(plugin) => ({
|
||||
id: plugin.id,
|
||||
plugin,
|
||||
surfaces: channelPluginSurfaceKeys.filter((surface) => Boolean(plugin[surface])),
|
||||
}),
|
||||
);
|
||||
|
||||
export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("threading"))
|
||||
@@ -691,12 +543,258 @@ export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContra
|
||||
plugin: entry.plugin,
|
||||
}));
|
||||
|
||||
const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]);
|
||||
const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]);
|
||||
const matrixDirectoryCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example.com",
|
||||
userId: "@lobster:example.com",
|
||||
accessToken: "matrix-access-token",
|
||||
dm: {
|
||||
allowFrom: ["matrix:@alice:example.com"],
|
||||
},
|
||||
groupAllowFrom: ["matrix:@team:example.com"],
|
||||
groups: {
|
||||
"!room:example.com": {
|
||||
users: ["matrix:@alice:example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
invokeLookups: !directoryShapeOnlyIds.has(entry.id),
|
||||
coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups",
|
||||
...(entry.id === "matrix" ? { cfg: matrixDirectoryCfg } : {}),
|
||||
}));
|
||||
|
||||
const baseSessionBindingCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
|
||||
{
|
||||
id: "discord",
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createDiscordThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createDiscordThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
label: "codex-discord",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:discord:child:thread-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: async (binding) => {
|
||||
const service = getSessionBindingService();
|
||||
const removed = await service.unbind({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "contract-test",
|
||||
});
|
||||
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
|
||||
expect(service.resolveByConversation(binding.conversation)).toBeNull();
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createDiscordThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
manager.stop();
|
||||
discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:123456789012345678",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "feishu",
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseSessionBindingCfg, accountId: "default" });
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: async (binding) => {
|
||||
const service = getSessionBindingService();
|
||||
const removed = await service.unbind({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "contract-test",
|
||||
});
|
||||
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
|
||||
expect(service.resolveByConversation(binding.conversation)).toBeNull();
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createFeishuThreadBindingManager({
|
||||
cfg: baseSessionBindingCfg,
|
||||
accountId: "default",
|
||||
});
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "telegram",
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
unbindAndVerify: async (binding) => {
|
||||
const service = getSessionBindingService();
|
||||
const removed = await service.unbind({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "contract-test",
|
||||
});
|
||||
expect(removed.map((entry) => entry.bindingId)).toContain(binding.bindingId);
|
||||
expect(service.resolveByConversation(binding.conversation)).toBeNull();
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,151 +1,26 @@
|
||||
import { beforeEach, describe, expect } from "vitest";
|
||||
import {
|
||||
__testing as feishuThreadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import {
|
||||
__testing as telegramThreadBindingTesting,
|
||||
createTelegramThreadBindingManager,
|
||||
} from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
getSessionBindingService,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { beforeEach, describe } from "vitest";
|
||||
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
|
||||
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
|
||||
import { sessionBindingContractRegistry } from "./registry.js";
|
||||
import { installSessionBindingContractSuite } from "./suites.js";
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
discordThreadBindingTesting.resetThreadBindingsForTests();
|
||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
|
||||
});
|
||||
|
||||
describe("feishu session binding contract", () => {
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
for (const entry of sessionBindingContractRegistry) {
|
||||
describe(`${entry.id} session binding contract`, () => {
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: entry.expectedCapabilities,
|
||||
getCapabilities: entry.getCapabilities,
|
||||
bindAndResolve: entry.bindAndResolve,
|
||||
unbindAndVerify: entry.unbindAndVerify,
|
||||
cleanup: entry.cleanup,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram session binding contract", () => {
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -393,18 +393,20 @@ export function installChannelThreadingContractSuite(params: {
|
||||
|
||||
export function installChannelDirectoryContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
invokeLookups?: boolean;
|
||||
coverage?: "lookups" | "presence";
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}) {
|
||||
it("exposes the base directory contract", async () => {
|
||||
const directory = params.plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
|
||||
if (params.invokeLookups === false) {
|
||||
if (params.coverage === "presence") {
|
||||
return;
|
||||
}
|
||||
const self = await directory?.self?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
runtime: contractRuntime,
|
||||
});
|
||||
if (self) {
|
||||
@@ -413,8 +415,8 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
|
||||
const peers =
|
||||
(await directory?.listPeers?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
@@ -426,8 +428,8 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
|
||||
const groups =
|
||||
(await directory?.listGroups?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
@@ -439,8 +441,8 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
|
||||
if (directory?.listGroupMembers && groups[0]?.id) {
|
||||
const members = await directory.listGroupMembers({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
accountId: params.accountId ?? "default",
|
||||
groupId: groups[0].id,
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
@@ -456,6 +458,7 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
export function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
@@ -477,6 +480,11 @@ export function installSessionBindingContractSuite(params: {
|
||||
expect(typeof binding.boundAt).toBe("number");
|
||||
});
|
||||
|
||||
it("unbinds a registered binding through the shared service", async () => {
|
||||
const binding = await params.bindAndResolve();
|
||||
await params.unbindAndVerify(binding);
|
||||
});
|
||||
|
||||
it("cleans up registered bindings", async () => {
|
||||
await params.cleanup();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user