perf: optimize messaging plugin tests

This commit is contained in:
Peter Steinberger
2026-04-11 13:16:51 +01:00
parent c7f18d9278
commit 5915d7cb6b
26 changed files with 693 additions and 263 deletions

View File

@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from "vitest";
import { noteChromeMcpBrowserReadiness } from "./doctor-browser.js";
describe("browser doctor readiness", () => {
it("does nothing when Chrome MCP is not configured", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
openclaw: { color: "#FF4500" },
},
},
},
{
noteFn,
},
);
expect(noteFn).not.toHaveBeenCalled();
});
it("warns when Chrome MCP is configured but Chrome is missing", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
defaultProfile: "user",
},
},
{
noteFn,
platform: "darwin",
resolveChromeExecutable: () => null,
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Google Chrome was not found");
expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging");
});
it("warns when detected Chrome is too old for Chrome MCP", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
chromeLive: {
driver: "existing-session",
color: "#00AA00",
},
},
},
},
{
noteFn,
platform: "linux",
resolveChromeExecutable: () => ({ path: "/usr/bin/google-chrome" }),
readVersion: () => "Google Chrome 143.0.7499.4",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain("too old");
expect(String(noteFn.mock.calls[0]?.[0])).toContain("Chrome 144+");
});
it("reports the detected Chrome version for existing-session profiles", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
chromeLive: {
driver: "existing-session",
color: "#00AA00",
},
},
},
},
{
noteFn,
platform: "win32",
resolveChromeExecutable: () => ({
path: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
}),
readVersion: () => "Google Chrome 144.0.7534.0",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain(
"Detected Chrome Google Chrome 144.0.7534.0",
);
});
it("skips Chrome auto-detection when profiles use explicit userDataDir", async () => {
const noteFn = vi.fn();
await noteChromeMcpBrowserReadiness(
{
browser: {
profiles: {
braveLive: {
driver: "existing-session",
userDataDir: "/Users/test/Library/Application Support/BraveSoftware/Brave-Browser",
color: "#FB542B",
},
},
},
},
{
noteFn,
resolveChromeExecutable: () => {
throw new Error("should not look up Chrome");
},
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
expect(String(noteFn.mock.calls[0]?.[0])).toContain("explicit Chromium user data directory");
expect(String(noteFn.mock.calls[0]?.[0])).toContain("brave://inspect/#remote-debugging");
});
});

View File

@@ -7,10 +7,8 @@ const cliMocks = vi.hoisted(() => ({
registerMatrixCli: vi.fn(),
}));
vi.mock("./src/cli.js", async () => {
const actual = await vi.importActual<typeof import("./src/cli.js")>("./src/cli.js");
vi.mock("./src/cli.js", () => {
return {
...actual,
registerMatrixCli: cliMocks.registerMatrixCli,
};
});

View File

@@ -3,8 +3,10 @@ import { createNonExitingRuntimeEnv } from "../../../test/helpers/plugins/runtim
const resolveMatrixTargetsMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock("./resolve-targets.js", () => ({
resolveMatrixTargets: resolveMatrixTargetsMock,
vi.mock("./resolver.runtime.js", () => ({
matrixResolverRuntime: {
resolveMatrixTargets: resolveMatrixTargetsMock,
},
}));
import { matrixResolverAdapter } from "./resolver.js";

View File

@@ -29,38 +29,15 @@ function writeFixtureFile(fixtureRoot: string, relativePath: string, value: stri
fs.writeFileSync(fullPath, value, "utf8");
}
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("loads the plugin-entry runtime wrapper through native ESM import", async () => {
const wrapperPath = path.join(
process.cwd(),
"extensions",
"matrix",
"src",
"plugin-entry.runtime.js",
);
const wrapperUrl = pathToFileURL(wrapperPath);
const mod = await import(wrapperUrl.href);
expect(mod).toMatchObject({
ensureMatrixCryptoRuntime: expect.any(Function),
handleVerifyRecoveryKey: expect.any(Function),
handleVerificationBootstrap: expect.any(Function),
handleVerificationStatus: expect.any(Function),
});
}, 240_000);
it("loads the packaged runtime wrapper without recursing through the stable root alias", async () => {
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-");
const wrapperSource = fs.readFileSync(
path.join(REPO_ROOT, "extensions", "matrix", "src", "plugin-entry.runtime.js"),
"utf8",
function writeJitiFixture(fixtureRoot: string) {
writeFixtureFile(
fixtureRoot,
"node_modules/jiti/index.js",
`module.exports = require(${JSON.stringify(JITI_ENTRY_PATH)});\n`,
);
}
function writeOpenClawPackageFixture(fixtureRoot: string) {
writeFixtureFile(
fixtureRoot,
"package.json",
@@ -77,11 +54,52 @@ it("loads the packaged runtime wrapper without recursing through the stable root
) + "\n",
);
writeFixtureFile(fixtureRoot, "dist/plugin-sdk/index.js", "export {};\n");
}
afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("loads the source-checkout runtime wrapper through native ESM import", async () => {
const fixtureRoot = makeFixtureRoot(".tmp-matrix-source-runtime-");
const wrapperSource = fs.readFileSync(
path.join(REPO_ROOT, "extensions", "matrix", "src", "plugin-entry.runtime.js"),
"utf8",
);
writeOpenClawPackageFixture(fixtureRoot);
writeJitiFixture(fixtureRoot);
writeFixtureFile(fixtureRoot, "extensions/matrix/src/plugin-entry.runtime.js", wrapperSource);
writeFixtureFile(
fixtureRoot,
"node_modules/jiti/index.js",
`module.exports = require(${JSON.stringify(JITI_ENTRY_PATH)});\n`,
"extensions/matrix/plugin-entry.handlers.runtime.js",
PACKAGED_RUNTIME_STUB,
);
const wrapperUrl = pathToFileURL(
path.join(fixtureRoot, "extensions", "matrix", "src", "plugin-entry.runtime.js"),
);
const mod = await import(`${wrapperUrl.href}?t=${Date.now()}`);
expect(mod).toMatchObject({
ensureMatrixCryptoRuntime: expect.any(Function),
handleVerifyRecoveryKey: expect.any(Function),
handleVerificationBootstrap: expect.any(Function),
handleVerificationStatus: expect.any(Function),
});
}, 240_000);
it("loads the packaged runtime wrapper without recursing through the stable root alias", async () => {
const fixtureRoot = makeFixtureRoot(".tmp-matrix-runtime-");
const wrapperSource = fs.readFileSync(
path.join(REPO_ROOT, "extensions", "matrix", "src", "plugin-entry.runtime.js"),
"utf8",
);
writeOpenClawPackageFixture(fixtureRoot);
writeJitiFixture(fixtureRoot);
writeFixtureFile(fixtureRoot, "dist/plugin-entry.runtime-C88YIa_v.js", wrapperSource);
writeFixtureFile(
fixtureRoot,

View File

@@ -0,0 +1,5 @@
import { resolveMatrixTargets } from "./resolve-targets.js";
export const matrixResolverRuntime = {
resolveMatrixTargets,
};

View File

@@ -3,8 +3,8 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ResolvedMatrixAccount } from "./matrix/accounts.js";
const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
() => import("./channel.runtime.js"),
"matrixChannelRuntime",
() => import("./resolver.runtime.js"),
"matrixResolverRuntime",
);
type MatrixResolver = NonNullable<ChannelPlugin<ResolvedMatrixAccount>["resolver"]>;

View File

@@ -4,25 +4,6 @@ const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
vi.mock("./bot-message-context.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-message-context.runtime.js")>(
"./bot-message-context.runtime.js",
);
return {
...actual,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(...args),
};
});
vi.mock("./bot-message-context.session.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
"./bot-message-context.session.runtime.js",
);
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("./conversation-route.js", async () => {
const actual =
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
@@ -35,6 +16,19 @@ vi.mock("./conversation-route.js", async () => {
let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
const configuredBindingRuntime = {
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(...args),
} as NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["runtime"]
>;
const configuredBindingSessionRuntime = {
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
} as NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
function createConfiguredTelegramBinding() {
return {
spec: {
@@ -162,6 +156,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
it("treats configured topic bindings as explicit route matches on non-default accounts", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
@@ -182,6 +178,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
it("skips ACP session initialization when topic access is denied", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
@@ -201,6 +199,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
it("defers ACP session initialization for unauthorized control commands", async () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,
@@ -229,6 +229,8 @@ describe("buildTelegramMessageContext ACP configured bindings", () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
runtime: configuredBindingRuntime,
sessionRuntime: configuredBindingSessionRuntime,
message: {
chat: { id: -1001234567890, type: "supergroup", title: "OpenClaw", is_forum: true },
message_thread_id: 42,

View File

@@ -1,25 +1,56 @@
import { vi, type Mock } from "vitest";
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
type BuildTelegramMessageContextForTest =
typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest;
type BuildTelegramMessageContextForTestParams = Parameters<BuildTelegramMessageContextForTest>[0];
type TelegramTestSessionRuntime = NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
const hoisted = vi.hoisted((): { recordInboundSessionMock: AsyncUnknownMock } => ({
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
}));
export const recordInboundSessionMock: AsyncUnknownMock = hoisted.recordInboundSessionMock;
const finalizeInboundContextForTest = ((ctx) => {
const next = ctx as Record<string, unknown>;
const body = typeof next.Body === "string" ? next.Body : "";
next.Body = body;
next.BodyForAgent =
typeof next.BodyForAgent === "string"
? next.BodyForAgent
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.BodyForCommands =
typeof next.BodyForCommands === "string"
? next.BodyForCommands
: typeof next.CommandBody === "string"
? next.CommandBody
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.CommandAuthorized = Boolean(next.CommandAuthorized);
return next;
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
const recordInboundSessionForTest: NonNullable<
TelegramTestSessionRuntime["recordInboundSession"]
> = async (params) => {
await recordInboundSessionMock(params);
};
vi.mock("./bot-message-context.session.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
"./bot-message-context.session.runtime.js",
);
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
export const telegramRouteTestSessionRuntime = {
finalizeInboundContext: finalizeInboundContextForTest,
readSessionUpdatedAt: () => undefined,
recordInboundSession: recordInboundSessionForTest,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => "/tmp/openclaw/session-store.json",
} satisfies TelegramTestSessionRuntime;
export async function loadTelegramMessageContextRouteHarness() {
vi.resetModules();
const [
{ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot },
{ buildTelegramMessageContextForTest },
@@ -27,10 +58,20 @@ export async function loadTelegramMessageContextRouteHarness() {
import("../../../src/config/config.js"),
import("./bot-message-context.test-harness.js"),
]);
const buildTelegramMessageContextForRouteTest = (
params: BuildTelegramMessageContextForTestParams,
) =>
buildTelegramMessageContextForTest({
...params,
sessionRuntime: {
...telegramRouteTestSessionRuntime,
...params.sessionRuntime,
},
});
return {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
buildTelegramMessageContextForTest,
buildTelegramMessageContextForTest: buildTelegramMessageContextForRouteTest,
};
}

View File

@@ -25,6 +25,7 @@ import { isSenderAllowed, normalizeAllowFrom } from "./bot-access.js";
import type {
TelegramMediaRef,
TelegramMessageContextOptions,
TelegramMessageContextSessionRuntimeOverrides,
} from "./bot-message-context.types.js";
import {
buildGroupLabel,
@@ -43,6 +44,38 @@ type FinalizedTelegramInboundContext = ReturnType<
typeof import("./bot-message-context.session.runtime.js").finalizeInboundContext
>;
type TelegramMessageContextSessionRuntime =
typeof import("./bot-message-context.session.runtime.js");
const sessionRuntimeMethods = [
"finalizeInboundContext",
"readSessionUpdatedAt",
"recordInboundSession",
"resolveInboundLastRouteSessionKey",
"resolvePinnedMainDmOwnerFromAllowlist",
"resolveStorePath",
] as const satisfies readonly (keyof TelegramMessageContextSessionRuntime)[];
function hasCompleteSessionRuntime(
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
): runtime is TelegramMessageContextSessionRuntime {
return Boolean(
runtime && sessionRuntimeMethods.every((method) => typeof runtime[method] === "function"),
);
}
async function loadTelegramMessageContextSessionRuntime(
runtime: TelegramMessageContextSessionRuntimeOverrides | undefined,
): Promise<TelegramMessageContextSessionRuntime> {
if (hasCompleteSessionRuntime(runtime)) {
return runtime;
}
return {
...(await import("./bot-message-context.session.runtime.js")),
...runtime,
};
}
export async function buildTelegramInboundContextPayload(params: {
cfg: OpenClawConfig;
primaryCtx: TelegramContext;
@@ -72,6 +105,7 @@ export async function buildTelegramInboundContextPayload(params: {
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array<string | number>;
effectiveGroupAllow?: NormalizedAllowFrom;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
}): Promise<{
ctxPayload: FinalizedTelegramInboundContext;
skillFilter: string[] | undefined;
@@ -105,6 +139,7 @@ export async function buildTelegramInboundContextPayload(params: {
options,
dmAllowFrom,
effectiveGroupAllow,
sessionRuntime: sessionRuntimeOverride,
} = params;
const replyTarget = describeReplyTarget(msg);
const forwardOrigin = normalizeForwardedContext(msg);
@@ -194,7 +229,7 @@ export async function buildTelegramInboundContextPayload(params: {
const conversationLabel = isGroup
? (groupLabel ?? `group:${chatId}`)
: buildSenderLabel(msg, senderId || chatId);
const sessionRuntime = await import("./bot-message-context.session.runtime.js");
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
const storePath = sessionRuntime.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});

View File

@@ -15,13 +15,14 @@ const internalHookMocks = vi.hoisted(() => ({
triggerInternalHook: vi.fn(async () => undefined),
}));
vi.mock("openclaw/plugin-sdk/hook-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/hook-runtime")>(
"openclaw/plugin-sdk/hook-runtime",
);
vi.mock("openclaw/plugin-sdk/hook-runtime", () => {
return {
...actual,
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
fireAndForgetHook: (task: Promise<unknown>) => void task,
toInternalMessageReceivedContext: (context: Record<string, unknown>) => ({
...context,
metadata: { to: context.to },
}),
triggerInternalHook: internalHookMocks.triggerInternalHook,
};
});

View File

@@ -6,17 +6,52 @@ export const baseTelegramMessageContextConfig = {
messages: { groupChat: { mentionPatterns: [] } },
} as never;
type TelegramTestSessionRuntime = NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
const finalizeInboundContextForTest = ((ctx) => {
const next = ctx as Record<string, unknown>;
const body = typeof next.Body === "string" ? next.Body : "";
next.Body = body;
next.BodyForAgent =
typeof next.BodyForAgent === "string"
? next.BodyForAgent
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.BodyForCommands =
typeof next.BodyForCommands === "string"
? next.BodyForCommands
: typeof next.CommandBody === "string"
? next.CommandBody
: typeof next.RawBody === "string"
? next.RawBody
: body;
next.CommandAuthorized = Boolean(next.CommandAuthorized);
return next;
}) as NonNullable<TelegramTestSessionRuntime["finalizeInboundContext"]>;
type BuildTelegramMessageContextForTestParams = {
message: Record<string, unknown>;
allMedia?: TelegramMediaRef[];
options?: BuildTelegramMessageContextParams["options"];
cfg?: Record<string, unknown>;
accountId?: string;
runtime?: BuildTelegramMessageContextParams["runtime"];
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"];
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
};
const telegramMessageContextSessionRuntimeForTest = {
finalizeInboundContext: finalizeInboundContextForTest,
readSessionUpdatedAt: () => undefined,
recordInboundSession: async () => undefined,
resolveInboundLastRouteSessionKey: ({ route, sessionKey }) =>
route.lastRoutePolicy === "main" ? route.mainSessionKey : sessionKey,
resolvePinnedMainDmOwnerFromAllowlist: () => null,
resolveStorePath: () => "/tmp/openclaw/session-store.json",
} satisfies NonNullable<BuildTelegramMessageContextParams["sessionRuntime"]>;
export async function buildTelegramMessageContextForTest(
params: BuildTelegramMessageContextForTestParams,
): Promise<
@@ -46,6 +81,14 @@ export async function buildTelegramMessageContextForTest(
} as never,
cfg: (params.cfg ?? baseTelegramMessageContextConfig) as never,
loadFreshConfig: () => (params.cfg ?? baseTelegramMessageContextConfig) as never,
runtime: {
recordChannelActivity: () => undefined,
...params.runtime,
},
sessionRuntime: {
...telegramMessageContextSessionRuntimeForTest,
...params.sessionRuntime,
},
account: { accountId: params.accountId ?? "default" } as never,
historyLimit: 0,
groupHistories: new Map(),

View File

@@ -1,18 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { telegramRouteTestSessionRuntime } from "./bot-message-context.route-test-support.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
type TelegramTestSessionRuntime = NonNullable<
import("./bot-message-context.types.js").BuildTelegramMessageContextParams["sessionRuntime"]
>;
const recordInboundSessionForThreadBindingTest: NonNullable<
TelegramTestSessionRuntime["recordInboundSession"]
> = async (params) => {
await recordInboundSessionMock(params);
};
vi.mock("./bot-message-context.session.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./bot-message-context.session.runtime.js")>(
"./bot-message-context.session.runtime.js",
);
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("./conversation-route.js", async () => {
const actual =
await vi.importActual<typeof import("./conversation-route.js")>("./conversation-route.js");
@@ -23,6 +23,11 @@ vi.mock("./conversation-route.js", async () => {
};
});
const threadBindingSessionRuntime = {
...telegramRouteTestSessionRuntime,
recordInboundSession: recordInboundSessionForThreadBindingTest,
} satisfies TelegramTestSessionRuntime;
function createBoundRoute(params: { accountId: string; sessionKey: string; agentId: string }) {
return {
configuredBinding: null,
@@ -55,6 +60,7 @@ describe("buildTelegramMessageContext thread binding override", () => {
);
const ctx = await buildTelegramMessageContextForTest({
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
@@ -94,6 +100,7 @@ describe("buildTelegramMessageContext thread binding override", () => {
const ctx = await buildTelegramMessageContextForTest({
accountId: "work",
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: -100200300, type: "supergroup", is_forum: true },
@@ -132,6 +139,7 @@ describe("buildTelegramMessageContext thread binding override", () => {
);
const ctx = await buildTelegramMessageContextForTest({
sessionRuntime: threadBindingSessionRuntime,
message: {
message_id: 1,
chat: { id: 1234, type: "private" },

View File

@@ -107,6 +107,8 @@ export const buildTelegramMessageContext = async ({
resolveGroupRequireMention,
resolveTelegramGroupConfig,
loadFreshConfig,
runtime,
sessionRuntime,
upsertPairingRequest,
sendChatActionHandler,
}: BuildTelegramMessageContextParams): Promise<TelegramMessageContext | null> => {
@@ -146,7 +148,9 @@ export const buildTelegramMessageContext = async ({
? (groupConfig.dmPolicy ?? dmPolicy)
: dmPolicy;
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const freshCfg = loadFreshConfig?.() ?? (await loadTelegramMessageContextRuntime()).loadConfig();
const freshCfg =
loadFreshConfig?.() ??
(runtime?.loadConfig ?? (await loadTelegramMessageContextRuntime()).loadConfig)();
let { route, configuredBinding, configuredBindingSessionKey } = resolveTelegramConversationRoute({
cfg: freshCfg,
accountId: account.accountId,
@@ -269,7 +273,9 @@ export const buildTelegramMessageContext = async ({
if (!configuredBinding) {
return true;
}
const { ensureConfiguredBindingRouteReady } = await loadTelegramMessageContextRuntime();
const ensureConfiguredBindingRouteReady =
runtime?.ensureConfiguredBindingRouteReady ??
(await loadTelegramMessageContextRuntime()).ensureConfiguredBindingRouteReady;
const ensured = await ensureConfiguredBindingRouteReady({
cfg: freshCfg,
bindingResolution: configuredBinding,
@@ -328,7 +334,10 @@ export const buildTelegramMessageContext = async ({
baseRequireMention,
);
(await loadTelegramMessageContextRuntime()).recordChannelActivity({
const recordChannelActivity =
runtime?.recordChannelActivity ??
(await loadTelegramMessageContextRuntime()).recordChannelActivity;
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "inbound",
@@ -399,49 +408,51 @@ export const buildTelegramMessageContext = async ({
resolvedStatusReactionEmojis,
);
let allowedStatusReactionEmojisPromise: Promise<Set<TelegramReactionEmoji> | null> | null = null;
const statusReactionController: StatusReactionController | null =
const createStatusReactionController =
statusReactionsEnabled && msg.message_id
? (await loadTelegramMessageContextRuntime()).createStatusReactionController({
enabled: true,
adapter: {
setReaction: async (emoji: string) => {
if (reactionApi) {
if (!allowedStatusReactionEmojisPromise) {
allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({
chat: msg.chat,
chatId,
getChat: getChatApi ?? undefined,
}).catch((err) => {
logVerbose(
`telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`,
);
return null;
});
}
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
const resolvedEmoji = resolveTelegramReactionVariant({
requestedEmoji: emoji,
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
allowedEmojiReactions: allowedStatusReactionEmojis,
});
if (!resolvedEmoji) {
return;
}
await reactionApi(chatId, msg.message_id, [
{ type: "emoji", emoji: resolvedEmoji },
]);
}
},
// Telegram replaces atomically — no removeReaction needed
},
initialEmoji: ackReaction,
emojis: resolvedStatusReactionEmojis,
timing: statusReactionsConfig?.timing,
onError: (err) => {
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
},
})
? (runtime?.createStatusReactionController ??
(await loadTelegramMessageContextRuntime()).createStatusReactionController)
: null;
const statusReactionController: StatusReactionController | null = createStatusReactionController
? createStatusReactionController({
enabled: true,
adapter: {
setReaction: async (emoji: string) => {
if (reactionApi) {
if (!allowedStatusReactionEmojisPromise) {
allowedStatusReactionEmojisPromise = resolveTelegramAllowedEmojiReactions({
chat: msg.chat,
chatId,
getChat: getChatApi ?? undefined,
}).catch((err) => {
logVerbose(
`telegram status-reaction available_reactions lookup failed for chat ${chatId}: ${String(err)}`,
);
return null;
});
}
const allowedStatusReactionEmojis = await allowedStatusReactionEmojisPromise;
const resolvedEmoji = resolveTelegramReactionVariant({
requestedEmoji: emoji,
variantsByRequestedEmoji: statusReactionVariantsByEmoji,
allowedEmojiReactions: allowedStatusReactionEmojis,
});
if (!resolvedEmoji) {
return;
}
await reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: resolvedEmoji }]);
}
},
// Telegram replaces atomically — no removeReaction needed
},
initialEmoji: ackReaction,
emojis: resolvedStatusReactionEmojis,
timing: statusReactionsConfig?.timing,
onError: (err) => {
logVerbose(`telegram status-reaction error for chat ${chatId}: ${String(err)}`);
},
})
: null;
// When status reactions are enabled, setQueued() replaces the simple ack reaction
const ackReactionPromise: Promise<boolean> | null = statusReactionController
@@ -494,6 +505,7 @@ export const buildTelegramMessageContext = async ({
dmAllowFrom,
effectiveGroupAllow,
commandAuthorized: bodyResult.commandAuthorized,
sessionRuntime,
});
return {

View File

@@ -44,6 +44,28 @@ export type ResolveGroupActivation = (params: {
export type ResolveGroupRequireMention = (chatId: string | number) => boolean;
export type TelegramMessageContextRuntimeOverrides = Partial<
Pick<
typeof import("./bot-message-context.runtime.js"),
| "createStatusReactionController"
| "ensureConfiguredBindingRouteReady"
| "loadConfig"
| "recordChannelActivity"
>
>;
export type TelegramMessageContextSessionRuntimeOverrides = Partial<
Pick<
typeof import("./bot-message-context.session.runtime.js"),
| "finalizeInboundContext"
| "readSessionUpdatedAt"
| "recordInboundSession"
| "resolveInboundLastRouteSessionKey"
| "resolvePinnedMainDmOwnerFromAllowlist"
| "resolveStorePath"
>
>;
export type BuildTelegramMessageContextParams = {
primaryCtx: TelegramContext;
allMedia: TelegramMediaRef[];
@@ -64,6 +86,8 @@ export type BuildTelegramMessageContextParams = {
resolveGroupRequireMention: ResolveGroupRequireMention;
resolveTelegramGroupConfig: ResolveTelegramGroupConfig;
loadFreshConfig?: () => OpenClawConfig;
runtime?: TelegramMessageContextRuntimeOverrides;
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
upsertPairingRequest?: typeof import("openclaw/plugin-sdk/conversation-runtime").upsertChannelPairingRequest;
/** Global (per-account) handler for sendChatAction 401 backoff (#27092). */
sendChatActionHandler: import("./sendchataction-401-backoff.js").TelegramSendChatActionHandler;

View File

@@ -1,4 +1,3 @@
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createReplyDispatcher,
@@ -7,7 +6,6 @@ import {
type MsgContext,
} from "openclaw/plugin-sdk/reply-runtime";
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { beforeEach, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
@@ -51,9 +49,6 @@ export function getLoadWebMediaMock(): AnyMock {
return loadWebMedia;
}
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
@@ -206,6 +201,25 @@ function parseModelRef(raw: string): { provider?: string; model: string } {
return { model: trimmed };
}
function normalizeLowercaseStringOrEmptyForTest(value: string | undefined): string {
return value?.trim().toLowerCase() ?? "";
}
function resolveDefaultModelForAgentForTest(params: { cfg: OpenClawConfig }): {
provider: string;
model: string;
} {
const modelConfig = params.cfg.agents?.defaults?.model;
const rawModel =
typeof modelConfig === "string" ? modelConfig : (modelConfig?.primary ?? "openai/gpt-5.4");
const parsed = parseModelRef(rawModel);
const provider = normalizeLowercaseStringOrEmptyForTest(parsed.provider) || "openai";
return {
provider: provider === "bedrock" ? "amazon-bedrock" : provider,
model: parsed.model || "gpt-5.4",
};
}
function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
byProvider: Map<string, Set<string>>;
providers: string[];
@@ -214,7 +228,7 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
} {
const byProvider = new Map<string, Set<string>>();
const add = (providerRaw: string | undefined, modelRaw: string | undefined) => {
const provider = normalizeLowercaseStringOrEmpty(providerRaw);
const provider = normalizeLowercaseStringOrEmptyForTest(providerRaw);
const model = modelRaw?.trim();
if (!provider || !model) {
return;
@@ -224,7 +238,7 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
byProvider.set(provider, existing);
};
const resolvedDefault = resolveDefaultModelForAgent({ cfg });
const resolvedDefault = resolveDefaultModelForAgentForTest({ cfg });
add(resolvedDefault.provider, resolvedDefault.model);
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {

View File

@@ -1,9 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
const { botCtorSpy, telegramBotDepsForTest } =
const { botCtorSpy, telegramBotDepsForTest, telegramBotRuntimeForTest } =
await import("./bot.create-telegram-bot.test-harness.js");
const { telegramBotRuntimeForTest } = await import("./bot.create-telegram-bot.test-harness.js");
const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
await import("./bot.js");
setTelegramBotRuntimeForTest(

View File

@@ -14,12 +14,9 @@ const listTelegramAccountIdsMock = vi.hoisted(() => vi.fn());
const inspectTelegramAccountMock = vi.hoisted(() => vi.fn());
const lookupTelegramChatIdMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime")>(
"openclaw/plugin-sdk/runtime",
);
vi.mock("openclaw/plugin-sdk/runtime-secret-resolution", () => {
return {
...actual,
getChannelsCommandSecretTargetIds: () => ["channels"],
resolveCommandSecretRefsViaGateway: resolveCommandSecretRefsViaGatewayMock,
};
});

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/testing";
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
@@ -30,9 +30,11 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
describe("maybePersistResolvedTelegramTarget", () => {
let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget;
beforeEach(async () => {
vi.resetModules();
beforeAll(async () => {
({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js"));
});
beforeEach(() => {
readConfigFileSnapshotForWrite.mockReset();
writeConfigFile.mockReset();
loadCronStore.mockReset();

View File

@@ -6,6 +6,13 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveOAuthDir } from "openclaw/plugin-sdk/state-paths";
import { hasWebCredsSync } from "./src/creds-files.js";
type WhatsAppAuthPresenceParams =
| {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}
| OpenClawConfig;
function addAccountAuthDirs(
authDirs: Set<string>,
accountId: string,
@@ -63,8 +70,11 @@ function listWhatsAppAuthDirs(
}
export function hasAnyWhatsAppAuth(
cfg: OpenClawConfig,
params: WhatsAppAuthPresenceParams,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return listWhatsAppAuthDirs(cfg, env).some((authDir) => hasWebCredsSync(authDir));
const cfg = params && typeof params === "object" && "cfg" in params ? params.cfg : params;
const resolvedEnv =
params && typeof params === "object" && "cfg" in params ? (params.env ?? env) : env;
return listWhatsAppAuthDirs(cfg, resolvedEnv).some((authDir) => hasWebCredsSync(authDir));
}

View File

@@ -1,10 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
mockExtractMessageContent,
mockGetContentType,
mockIsJidGroup,
mockNormalizeMessageContent,
} from "../../../../test/mocks/baileys.js";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mockNormalizeMessageContent } from "../../../../test/mocks/baileys.js";
type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
@@ -14,14 +9,8 @@ const { normalizeMessageContent, downloadMediaMessage } = vi.hoisted(() => ({
}));
vi.mock("@whiskeysockets/baileys", async () => {
const actual =
await vi.importActual<typeof import("@whiskeysockets/baileys")>("@whiskeysockets/baileys");
return {
...actual,
DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
DisconnectReason: { loggedOut: 401 },
normalizeMessageContent,
downloadMediaMessage,
};
@@ -41,9 +30,11 @@ async function expectMimetype(message: Record<string, unknown>, expected: string
}
describe("downloadInboundMedia", () => {
beforeEach(async () => {
vi.resetModules();
beforeAll(async () => {
({ downloadInboundMedia } = await import("./media.js"));
});
beforeEach(() => {
normalizeMessageContent.mockClear();
downloadMediaMessage.mockClear();
mockSock.updateMediaMessage.mockClear();

View File

@@ -53,6 +53,51 @@ const sessionState = vi.hoisted(() => ({
sock: undefined as MockSock | undefined,
}));
const inboundRuntimeMocks = vi.hoisted(() => {
const wrapperKeys = [
"ephemeralMessage",
"viewOnceMessage",
"viewOnceMessageV2",
"viewOnceMessageV2Extension",
"documentWithCaptionMessage",
] as const;
function normalizeMessageContent(message: unknown): unknown {
let current = message;
while (current && typeof current === "object") {
const record = current as Record<string, unknown>;
const wrapper = wrapperKeys
.map((key) => record[key])
.find(
(candidate): candidate is { message: unknown } =>
Boolean(candidate) &&
typeof candidate === "object" &&
"message" in (candidate as Record<string, unknown>) &&
Boolean((candidate as { message?: unknown }).message),
);
if (!wrapper) {
break;
}
current = wrapper.message;
}
return current;
}
return {
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("fake-media-data")),
isJidGroup: vi.fn((jid: string | undefined | null) =>
typeof jid === "string" ? jid.endsWith("@g.us") : false,
),
normalizeMessageContent: vi.fn(normalizeMessageContent),
saveMediaBuffer: vi.fn().mockResolvedValue({
id: "mid",
path: "/tmp/mid",
size: 1,
contentType: "image/jpeg",
}),
};
});
function createResolvedMock() {
return vi.fn().mockResolvedValue(undefined);
}
@@ -77,21 +122,15 @@ function createMockSock(): MockSock {
};
}
vi.mock("./inbound/save-media.runtime.js", () => {
vi.mock("./inbound/runtime-api.js", () => {
return {
saveMediaBuffer: vi.fn().mockResolvedValue({
id: "mid",
path: "/tmp/mid",
size: 1,
contentType: "image/jpeg",
}),
DisconnectReason: { loggedOut: 401 },
...inboundRuntimeMocks,
};
});
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn().mockImplementation(async () => {
if (!sessionState.sock) {
throw new Error("mock WhatsApp socket not initialized");
@@ -100,6 +139,7 @@ vi.mock("./session.js", async () => {
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
getStatusCode: vi.fn(() => 500),
formatError: (err: unknown) => (err instanceof Error ? err.message : String(err)),
};
});
@@ -111,9 +151,11 @@ export function getSock(): MockSock {
}
type MonitorWebInbox = typeof import("./inbound.js").monitorWebInbox;
type ResetWebInboundDedupe = typeof import("./inbound.js").resetWebInboundDedupe;
export type InboxOnMessage = NonNullable<Parameters<MonitorWebInbox>[0]["onMessage"]>;
export type InboxMonitorOptions = Parameters<MonitorWebInbox>[0];
let monitorWebInbox: MonitorWebInbox;
let resetWebInboundDedupe: ResetWebInboundDedupe;
function expectInboxPairingReplyText(
text: string,
@@ -142,7 +184,8 @@ export function getMonitorWebInbox(): MonitorWebInbox {
}
export async function settleInboundWork() {
await new Promise((resolve) => setTimeout(resolve, 25));
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
export async function waitForMessageCalls(onMessage: ReturnType<typeof vi.fn>, count: number) {
@@ -223,13 +266,14 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean }
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
vi.clearAllMocks();
sessionState.sock = createMockSock();
resetPairingSecurityMocks(DEFAULT_WEB_INBOX_CONFIG);
const inboundModule = await import("./inbound.js");
monitorWebInbox = inboundModule.monitorWebInbox;
const { resetWebInboundDedupe } = inboundModule;
if (!monitorWebInbox || !resetWebInboundDedupe) {
const inboundModule = await import("./inbound.js");
monitorWebInbox = inboundModule.monitorWebInbox;
resetWebInboundDedupe = inboundModule.resetWebInboundDedupe;
}
resetWebInboundDedupe();
if (createAuthDir) {
authDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));

View File

@@ -2,7 +2,10 @@
// Keep this list additive and scoped to the bundled Matrix surface.
import { createOptionalChannelSetupSurface } from "./channel-setup.js";
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
import {
createLazyFacadeArrayValue,
loadBundledPluginPublicSurfaceModuleSync,
} from "./facade-loader.js";
type MatrixFacadeModule = typeof import("@openclaw/matrix/contract-api.js");
@@ -190,10 +193,10 @@ export {
export { setMatrixRuntime } from "./matrix-runtime-surface.js";
export const singleAccountKeysToMove: MatrixFacadeModule["singleAccountKeysToMove"] =
loadMatrixFacadeModule().singleAccountKeysToMove;
createLazyFacadeArrayValue(() => loadMatrixFacadeModule().singleAccountKeysToMove);
export const namedAccountPromotionKeys: MatrixFacadeModule["namedAccountPromotionKeys"] =
loadMatrixFacadeModule().namedAccountPromotionKeys;
createLazyFacadeArrayValue(() => loadMatrixFacadeModule().namedAccountPromotionKeys);
export const resolveSingleAccountPromotionTarget: MatrixFacadeModule["resolveSingleAccountPromotionTarget"] =
((...args) =>

View File

@@ -1,6 +1,9 @@
// Manual facade. Keep loader boundary explicit.
type FacadeModule = typeof import("@openclaw/telegram/contract-api.js");
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-loader.js";
import {
createLazyFacadeArrayValue,
loadBundledPluginPublicSurfaceModuleSync,
} from "./facade-loader.js";
function loadFacadeModule(): FacadeModule {
return loadBundledPluginPublicSurfaceModuleSync<FacadeModule>({
@@ -17,7 +20,7 @@ export const parseTelegramTopicConversation: FacadeModule["parseTelegramTopicCon
)) as FacadeModule["parseTelegramTopicConversation"];
export const singleAccountKeysToMove: FacadeModule["singleAccountKeysToMove"] =
loadFacadeModule().singleAccountKeysToMove;
createLazyFacadeArrayValue(() => loadFacadeModule().singleAccountKeysToMove);
export const collectTelegramSecurityAuditFindings: FacadeModule["collectTelegramSecurityAuditFindings"] =
((...args) =>

View File

@@ -19,7 +19,6 @@ const allowedNonExtensionTests = new Set<string>([
"src/commands/onboard-channels.e2e.test.ts",
"src/gateway/hooks.test.ts",
"src/infra/outbound/deliver.test.ts",
"src/media-generation/provider-capabilities.contract.test.ts",
"src/plugins/interactive.test.ts",
"src/plugins/contracts/discovery.contract.test.ts",
"src/plugin-sdk/telegram-command-config.test.ts",

View File

@@ -21,14 +21,30 @@ type TelegramProbe = import("@openclaw/telegram/api.js").TelegramProbe;
type TelegramTokenResolution = import("@openclaw/telegram/api.js").TelegramTokenResolution;
type WhatsAppApiSurface = typeof import("@openclaw/whatsapp/api.js");
const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } =
loadBundledPluginApiSync<DiscordApiSurface>("discord");
const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } =
loadBundledPluginApiSync<SlackApiSurface>("slack");
const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } =
loadBundledPluginApiSync<TelegramApiSurface>("telegram");
const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } =
loadBundledPluginApiSync<WhatsAppApiSurface>("whatsapp");
let discordApi: DiscordApiSurface | undefined;
let slackApi: SlackApiSurface | undefined;
let telegramApi: TelegramApiSurface | undefined;
let whatsappApi: WhatsAppApiSurface | undefined;
function getDiscordApi(): DiscordApiSurface {
discordApi ??= loadBundledPluginApiSync<DiscordApiSurface>("discord");
return discordApi;
}
function getSlackApi(): SlackApiSurface {
slackApi ??= loadBundledPluginApiSync<SlackApiSurface>("slack");
return slackApi;
}
function getTelegramApi(): TelegramApiSurface {
telegramApi ??= loadBundledPluginApiSync<TelegramApiSurface>("telegram");
return telegramApi;
}
function getWhatsAppApi(): WhatsAppApiSurface {
whatsappApi ??= loadBundledPluginApiSync<WhatsAppApiSurface>("whatsapp");
return whatsappApi;
}
type DirectoryListFn = (params: {
cfg: OpenClawConfig;
@@ -59,6 +75,9 @@ async function expectDirectoryIds(
export function describeDiscordPluginsCoreExtensionContract() {
describe("discord plugins-core extension contract", () => {
const listPeers = () => getDiscordApi().listDiscordDirectoryPeersFromConfig;
const listGroups = () => getDiscordApi().listDiscordDirectoryGroupsFromConfig;
it("DiscordProbe satisfies BaseProbeResult", () => {
expectTypeOf<DiscordProbe>().toMatchTypeOf<BaseProbeResult>();
});
@@ -90,17 +109,14 @@ export function describeDiscordPluginsCoreExtensionContract() {
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listDiscordDirectoryPeersFromConfig,
listPeers(),
cfg,
["user:111", "user:12345", "user:222", "user:333", "user:444"],
{ sorted: true },
);
await expectDirectoryIds(
listDiscordDirectoryGroupsFromConfig,
cfg,
["channel:555", "channel:666", "channel:777"],
{ sorted: true },
);
await expectDirectoryIds(listGroups(), cfg, ["channel:555", "channel:666", "channel:777"], {
sorted: true,
});
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
@@ -125,8 +141,8 @@ export function describeDiscordPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
await expectDirectoryIds(listPeers(), cfg, ["user:111"]);
await expectDirectoryIds(listGroups(), cfg, ["channel:555"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
@@ -147,7 +163,7 @@ export function describeDiscordPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
const groups = await listDiscordDirectoryGroupsFromConfig({
const groups = await listGroups()({
cfg,
accountId: "default",
query: "666",
@@ -160,6 +176,9 @@ export function describeDiscordPluginsCoreExtensionContract() {
export function describeSlackPluginsCoreExtensionContract() {
describe("slack plugins-core extension contract", () => {
const listPeers = () => getSlackApi().listSlackDirectoryPeersFromConfig;
const listGroups = () => getSlackApi().listSlackDirectoryGroupsFromConfig;
it("SlackProbe satisfies BaseProbeResult", () => {
expectTypeOf<SlackProbe>().toMatchTypeOf<BaseProbeResult>();
});
@@ -178,12 +197,12 @@ export function describeSlackPluginsCoreExtensionContract() {
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listSlackDirectoryPeersFromConfig,
listPeers(),
cfg,
["user:u123", "user:u234", "user:u777", "user:u999"],
{ sorted: true },
);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]);
});
it("keeps directories readable when tokens are unresolved SecretRefs", async () => {
@@ -203,8 +222,8 @@ export function describeSlackPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
await expectDirectoryIds(listPeers(), cfg, ["user:u123"]);
await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
@@ -219,7 +238,7 @@ export function describeSlackPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
const peers = await listSlackDirectoryPeersFromConfig({
const peers = await listPeers()({
cfg,
accountId: "default",
query: "user:u",
@@ -233,6 +252,9 @@ export function describeSlackPluginsCoreExtensionContract() {
export function describeTelegramPluginsCoreExtensionContract() {
describe("telegram plugins-core extension contract", () => {
const listPeers = () => getTelegramApi().listTelegramDirectoryPeersFromConfig;
const listGroups = () => getTelegramApi().listTelegramDirectoryGroupsFromConfig;
it("TelegramProbe satisfies BaseProbeResult", () => {
expectTypeOf<TelegramProbe>().toMatchTypeOf<BaseProbeResult>();
});
@@ -253,13 +275,10 @@ export function describeTelegramPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(
listTelegramDirectoryPeersFromConfig,
cfg,
["123", "456", "@alice", "@bob"],
{ sorted: true },
);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
await expectDirectoryIds(listPeers(), cfg, ["123", "456", "@alice", "@bob"], {
sorted: true,
});
await expectDirectoryIds(listGroups(), cfg, ["-1001"]);
});
it("keeps fallback semantics when accountId is omitted", async () => {
@@ -280,8 +299,8 @@ export function describeTelegramPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
await expectDirectoryIds(listPeers(), cfg, ["@alice"]);
await expectDirectoryIds(listGroups(), cfg, ["-1001"]);
});
});
@@ -301,8 +320,8 @@ export function describeTelegramPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
await expectDirectoryIds(listPeers(), cfg, ["@alice"]);
await expectDirectoryIds(listGroups(), cfg, ["-1001"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
@@ -315,7 +334,7 @@ export function describeTelegramPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
const groups = await listTelegramDirectoryGroupsFromConfig({
const groups = await listGroups()({
cfg,
accountId: "default",
query: "-100",
@@ -328,6 +347,9 @@ export function describeTelegramPluginsCoreExtensionContract() {
export function describeWhatsAppPluginsCoreExtensionContract() {
describe("whatsapp plugins-core extension contract", () => {
const listPeers = () => getWhatsAppApi().listWhatsAppDirectoryPeersFromConfig;
const listGroups = () => getWhatsAppApi().listWhatsAppDirectoryGroupsFromConfig;
it("lists peers/groups from config", async () => {
const cfg = {
channels: {
@@ -338,8 +360,8 @@ export function describeWhatsAppPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]);
await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]);
await expectDirectoryIds(listPeers(), cfg, ["+15550000000"]);
await expectDirectoryIds(listGroups(), cfg, ["999@g.us"]);
});
it("applies query and limit filtering for config-backed directories", async () => {
@@ -351,7 +373,7 @@ export function describeWhatsAppPluginsCoreExtensionContract() {
},
} as unknown as OpenClawConfig;
const groups = await listWhatsAppDirectoryGroupsFromConfig({
const groups = await listGroups()({
cfg,
accountId: "default",
query: "@g.us",

View File

@@ -28,18 +28,14 @@ type MatrixTestApiSurface = typeof import("@openclaw/matrix/test-api.js");
type TelegramApiSurface = typeof import("@openclaw/telegram/api.js");
type TelegramTestApiSurface = typeof import("@openclaw/telegram/test-api.js");
const { bluebubblesPlugin } = loadBundledPluginApiSync<BluebubblesApiSurface>("bluebubbles");
const { discordPlugin, discordThreadBindingTesting } =
loadBundledPluginTestApiSync<DiscordTestApiSurface>("discord");
const { feishuPlugin, feishuThreadBindingTesting } =
loadBundledPluginApiSync<FeishuApiSurface>("feishu");
const { imessagePlugin } = loadBundledPluginApiSync<IMessageApiSurface>("imessage");
const { resetMatrixThreadBindingsForTests } = loadBundledPluginApiSync<MatrixApiSurface>("matrix");
const { matrixPlugin, setMatrixRuntime } =
loadBundledPluginTestApiSync<MatrixTestApiSurface>("matrix");
const { telegramPlugin } = loadBundledPluginApiSync<TelegramApiSurface>("telegram");
const { resetTelegramThreadBindingsForTests } =
loadBundledPluginTestApiSync<TelegramTestApiSurface>("telegram");
let bluebubblesApi: BluebubblesApiSurface | undefined;
let discordTestApi: DiscordTestApiSurface | undefined;
let feishuApi: FeishuApiSurface | undefined;
let imessageApi: IMessageApiSurface | undefined;
let matrixApi: MatrixApiSurface | undefined;
let matrixTestApi: MatrixTestApiSurface | undefined;
let telegramApi: TelegramApiSurface | undefined;
let telegramTestApi: TelegramTestApiSurface | undefined;
type DiscordThreadBindingTesting = {
resetThreadBindingsForTests: () => void;
@@ -48,47 +44,58 @@ type DiscordThreadBindingTesting = {
type ResetTelegramThreadBindingsForTests = () => Promise<void>;
function getBluebubblesPlugin(): ChannelPlugin {
return bluebubblesPlugin as unknown as ChannelPlugin;
bluebubblesApi ??= loadBundledPluginApiSync<BluebubblesApiSurface>("bluebubbles");
return bluebubblesApi.bluebubblesPlugin as unknown as ChannelPlugin;
}
function getDiscordPlugin(): ChannelPlugin {
return discordPlugin as unknown as ChannelPlugin;
discordTestApi ??= loadBundledPluginTestApiSync<DiscordTestApiSurface>("discord");
return discordTestApi.discordPlugin as unknown as ChannelPlugin;
}
function getFeishuPlugin(): ChannelPlugin {
return feishuPlugin as unknown as ChannelPlugin;
feishuApi ??= loadBundledPluginApiSync<FeishuApiSurface>("feishu");
return feishuApi.feishuPlugin as unknown as ChannelPlugin;
}
function getIMessagePlugin(): ChannelPlugin {
return imessagePlugin as unknown as ChannelPlugin;
imessageApi ??= loadBundledPluginApiSync<IMessageApiSurface>("imessage");
return imessageApi.imessagePlugin as unknown as ChannelPlugin;
}
function getMatrixPlugin(): ChannelPlugin {
return matrixPlugin as unknown as ChannelPlugin;
matrixTestApi ??= loadBundledPluginTestApiSync<MatrixTestApiSurface>("matrix");
return matrixTestApi.matrixPlugin as unknown as ChannelPlugin;
}
function getSetMatrixRuntime(): (runtime: PluginRuntime) => void {
return setMatrixRuntime;
matrixTestApi ??= loadBundledPluginTestApiSync<MatrixTestApiSurface>("matrix");
return matrixTestApi.setMatrixRuntime;
}
function getTelegramPlugin(): ChannelPlugin {
return telegramPlugin as unknown as ChannelPlugin;
telegramApi ??= loadBundledPluginApiSync<TelegramApiSurface>("telegram");
return telegramApi.telegramPlugin as unknown as ChannelPlugin;
}
function getDiscordThreadBindingTesting(): DiscordThreadBindingTesting {
return discordThreadBindingTesting;
discordTestApi ??= loadBundledPluginTestApiSync<DiscordTestApiSurface>("discord");
return discordTestApi.discordThreadBindingTesting;
}
function getResetTelegramThreadBindingsForTests(): ResetTelegramThreadBindingsForTests {
return resetTelegramThreadBindingsForTests;
telegramTestApi ??= loadBundledPluginTestApiSync<TelegramTestApiSurface>("telegram");
return telegramTestApi.resetTelegramThreadBindingsForTests;
}
async function getFeishuThreadBindingTesting() {
return feishuThreadBindingTesting;
feishuApi ??= loadBundledPluginApiSync<FeishuApiSurface>("feishu");
return feishuApi.feishuThreadBindingTesting;
}
async function getResetMatrixThreadBindingsForTests() {
return resetMatrixThreadBindingsForTests;
matrixApi ??= loadBundledPluginApiSync<MatrixApiSurface>("matrix");
return matrixApi.resetMatrixThreadBindingsForTests;
}
function resolveSessionBindingContractRuntimeConfig(id: string) {
@@ -106,21 +113,51 @@ function resolveSessionBindingContractRuntimeConfig(id: string) {
};
}
function setSessionBindingPluginRegistryForTests(): void {
getSetMatrixRuntime()({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
function getSessionBindingPlugin(id: string): ChannelPlugin {
switch (id) {
case "bluebubbles":
return getBluebubblesPlugin();
case "discord":
return getDiscordPlugin();
case "feishu":
return getFeishuPlugin();
case "imessage":
return getIMessagePlugin();
case "matrix":
getSetMatrixRuntime()({
state: {
resolveStateDir: (_env, homeDir) => (homeDir ?? (() => "/tmp"))(),
},
} as PluginRuntime);
return getMatrixPlugin();
case "telegram":
return getTelegramPlugin();
default:
throw new Error(`missing session binding plugin fixture for ${id}`);
}
}
const channels = [
getBluebubblesPlugin(),
getDiscordPlugin(),
getFeishuPlugin(),
getIMessagePlugin(),
getMatrixPlugin(),
getTelegramPlugin(),
].map((plugin) => ({
async function resetSessionBindingPluginFixtureForTests(id: string): Promise<void> {
switch (id) {
case "discord":
getDiscordThreadBindingTesting().resetThreadBindingsForTests();
return;
case "feishu":
(await getFeishuThreadBindingTesting()).resetFeishuThreadBindingsForTests();
return;
case "matrix":
(await getResetMatrixThreadBindingsForTests())();
return;
case "telegram":
await getResetTelegramThreadBindingsForTests()();
return;
default:
return;
}
}
function setSessionBindingPluginRegistryForTests(id: string): void {
const channels = [getSessionBindingPlugin(id)].map((plugin) => ({
pluginId: plugin.id,
plugin,
source: "test" as const,
@@ -182,12 +219,9 @@ export function describeSessionBindingRegistryBackedContract(id: string) {
setRuntimeConfigSnapshot(runtimeConfig);
// These suites only exercise the session-binding channels, so avoid the broader
// default registry helper and seed only the six plugins this contract lane needs.
setSessionBindingPluginRegistryForTests();
setSessionBindingPluginRegistryForTests(entry.id);
sessionBindingTesting.resetSessionBindingAdaptersForTests();
getDiscordThreadBindingTesting().resetThreadBindingsForTests();
(await getFeishuThreadBindingTesting()).resetFeishuThreadBindingsForTests();
(await getResetMatrixThreadBindingsForTests())();
await getResetTelegramThreadBindingsForTests()();
await resetSessionBindingPluginFixtureForTests(entry.id);
});
afterEach(() => {
clearRuntimeConfigSnapshot();