mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-13 10:11:20 +00:00
530 lines
16 KiB
TypeScript
530 lines
16 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expect } from "vitest";
|
|
import { createChannelConversationBindingManager } from "../../../src/channels/plugins/conversation-bindings.js";
|
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
import {
|
|
getSessionBindingService,
|
|
type SessionBindingCapabilities,
|
|
type SessionBindingRecord,
|
|
} from "../../../src/infra/outbound/session-binding-service.js";
|
|
import {
|
|
sessionBindingContractChannelIds,
|
|
type SessionBindingContractChannelId,
|
|
} from "./manifest.js";
|
|
import { importBundledChannelContractArtifact } from "./runtime-artifacts.js";
|
|
import "../../../src/channels/plugins/registry.js";
|
|
|
|
type SessionBindingContractEntry = {
|
|
id: string;
|
|
expectedCapabilities: SessionBindingCapabilities;
|
|
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
|
bindAndResolve: () => Promise<SessionBindingRecord>;
|
|
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
|
cleanup: () => Promise<void> | void;
|
|
};
|
|
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
|
|
|
|
const matrixSessionBindingStateDir = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), "openclaw-matrix-session-binding-contract-"),
|
|
);
|
|
const matrixSessionBindingAuth = {
|
|
accountId: "ops",
|
|
homeserver: "https://matrix.example.org",
|
|
userId: "@bot:example.org",
|
|
accessToken: "token",
|
|
} as const;
|
|
|
|
async function getContractApi<T extends Record<string, unknown>>(pluginId: string): Promise<T> {
|
|
const existing = contractApiPromises.get(pluginId);
|
|
if (existing) {
|
|
return (await existing) as T;
|
|
}
|
|
const next = importBundledChannelContractArtifact<T>(pluginId, "contract-api");
|
|
contractApiPromises.set(pluginId, next);
|
|
return await next;
|
|
}
|
|
|
|
function expectResolvedSessionBinding(params: {
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
targetSessionKey: string;
|
|
}) {
|
|
expect(
|
|
getSessionBindingService().resolveByConversation({
|
|
channel: params.channel,
|
|
accountId: params.accountId,
|
|
conversationId: params.conversationId,
|
|
parentConversationId: params.parentConversationId,
|
|
}),
|
|
)?.toMatchObject({
|
|
targetSessionKey: params.targetSessionKey,
|
|
});
|
|
}
|
|
|
|
async function unbindAndExpectClearedSessionBinding(binding: SessionBindingRecord) {
|
|
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();
|
|
}
|
|
|
|
function expectClearedSessionBinding(params: {
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
}) {
|
|
expect(
|
|
getSessionBindingService().resolveByConversation({
|
|
channel: params.channel,
|
|
accountId: params.accountId,
|
|
conversationId: params.conversationId,
|
|
}),
|
|
).toBeNull();
|
|
}
|
|
|
|
function resetMatrixSessionBindingStateDir() {
|
|
fs.rmSync(matrixSessionBindingStateDir, { recursive: true, force: true });
|
|
fs.mkdirSync(matrixSessionBindingStateDir, { recursive: true });
|
|
}
|
|
|
|
async function createContractMatrixThreadBindingManager() {
|
|
resetMatrixSessionBindingStateDir();
|
|
const { setMatrixRuntime, createMatrixThreadBindingManager } = await getContractApi<{
|
|
setMatrixRuntime: (runtime: unknown) => void;
|
|
createMatrixThreadBindingManager: (params: {
|
|
accountId: string;
|
|
auth: typeof matrixSessionBindingAuth;
|
|
client: unknown;
|
|
idleTimeoutMs: number;
|
|
maxAgeMs: number;
|
|
enableSweeper: boolean;
|
|
}) => Promise<unknown>;
|
|
}>("matrix");
|
|
setMatrixRuntime({
|
|
state: {
|
|
resolveStateDir: () => matrixSessionBindingStateDir,
|
|
},
|
|
} as never);
|
|
return await createMatrixThreadBindingManager({
|
|
accountId: matrixSessionBindingAuth.accountId,
|
|
auth: matrixSessionBindingAuth,
|
|
client: {} as never,
|
|
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
|
maxAgeMs: 0,
|
|
enableSweeper: false,
|
|
});
|
|
}
|
|
|
|
const baseSessionBindingCfg = {
|
|
session: { mainKey: "main", scope: "per-sender" },
|
|
} satisfies OpenClawConfig;
|
|
|
|
const sessionBindingContractEntries: Record<
|
|
SessionBindingContractChannelId,
|
|
Omit<SessionBindingContractEntry, "id">
|
|
> = {
|
|
bluebubbles: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current"],
|
|
},
|
|
getCapabilities: () => {
|
|
void createChannelConversationBindingManager({
|
|
channelId: "bluebubbles",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "bluebubbles",
|
|
accountId: "default",
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
await createChannelConversationBindingManager({
|
|
channelId: "bluebubbles",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
const service = getSessionBindingService();
|
|
const binding = await service.bind({
|
|
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "bluebubbles",
|
|
accountId: "default",
|
|
conversationId: "+15555550123",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "codex",
|
|
label: "codex-main",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "bluebubbles",
|
|
accountId: "default",
|
|
conversationId: "+15555550123",
|
|
targetSessionKey: "agent:codex:acp:binding:bluebubbles:default:abc123",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
const manager = await createChannelConversationBindingManager({
|
|
channelId: "bluebubbles",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
await manager?.stop();
|
|
expectClearedSessionBinding({
|
|
channel: "bluebubbles",
|
|
accountId: "default",
|
|
conversationId: "+15555550123",
|
|
});
|
|
},
|
|
},
|
|
discord: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current", "child"],
|
|
},
|
|
getCapabilities: async () => {
|
|
const { createThreadBindingManager } = await getContractApi<{
|
|
createThreadBindingManager: (params: {
|
|
accountId: string;
|
|
cfg?: OpenClawConfig;
|
|
persist: boolean;
|
|
enableSweeper: boolean;
|
|
}) => unknown;
|
|
}>("discord");
|
|
createThreadBindingManager({
|
|
accountId: "default",
|
|
cfg: baseSessionBindingCfg,
|
|
persist: false,
|
|
enableSweeper: false,
|
|
});
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
const { createThreadBindingManager } = await getContractApi<{
|
|
createThreadBindingManager: (params: {
|
|
accountId: string;
|
|
cfg?: OpenClawConfig;
|
|
persist: boolean;
|
|
enableSweeper: boolean;
|
|
}) => unknown;
|
|
}>("discord");
|
|
createThreadBindingManager({
|
|
accountId: "default",
|
|
cfg: baseSessionBindingCfg,
|
|
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: {
|
|
agentId: "discord",
|
|
label: "discord-child",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:123456789012345678",
|
|
targetSessionKey: "agent:discord:child:thread-1",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
expectClearedSessionBinding({
|
|
channel: "discord",
|
|
accountId: "default",
|
|
conversationId: "channel:123456789012345678",
|
|
});
|
|
},
|
|
},
|
|
feishu: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current"],
|
|
},
|
|
getCapabilities: async () => {
|
|
const { createFeishuThreadBindingManager } = await getContractApi<{
|
|
createFeishuThreadBindingManager: (params: {
|
|
accountId?: string;
|
|
cfg: OpenClawConfig;
|
|
}) => unknown;
|
|
}>("feishu");
|
|
createFeishuThreadBindingManager({
|
|
accountId: "default",
|
|
cfg: baseSessionBindingCfg,
|
|
});
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
const { createFeishuThreadBindingManager } = await getContractApi<{
|
|
createFeishuThreadBindingManager: (params: {
|
|
accountId?: string;
|
|
cfg: OpenClawConfig;
|
|
}) => unknown;
|
|
}>("feishu");
|
|
createFeishuThreadBindingManager({
|
|
accountId: "default",
|
|
cfg: baseSessionBindingCfg,
|
|
});
|
|
const service = getSessionBindingService();
|
|
const binding = await service.bind({
|
|
targetSessionKey: "agent:feishu:child:thread-1",
|
|
targetKind: "subagent",
|
|
conversation: {
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
parentConversationId: "oc_group_chat",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "feishu",
|
|
label: "feishu-child",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
parentConversationId: "oc_group_chat",
|
|
targetSessionKey: "agent:feishu:child:thread-1",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
expectClearedSessionBinding({
|
|
channel: "feishu",
|
|
accountId: "default",
|
|
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
});
|
|
},
|
|
},
|
|
imessage: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current"],
|
|
},
|
|
getCapabilities: () => {
|
|
void createChannelConversationBindingManager({
|
|
channelId: "imessage",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
await createChannelConversationBindingManager({
|
|
channelId: "imessage",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
const service = getSessionBindingService();
|
|
const binding = await service.bind({
|
|
targetSessionKey: "agent:imessage:current",
|
|
targetKind: "session",
|
|
conversation: {
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
conversationId: "+15555550124",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "imessage",
|
|
label: "imessage-main",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
conversationId: "+15555550124",
|
|
targetSessionKey: "agent:imessage:current",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
const manager = await createChannelConversationBindingManager({
|
|
channelId: "imessage",
|
|
cfg: baseSessionBindingCfg,
|
|
accountId: "default",
|
|
});
|
|
await manager?.stop();
|
|
expectClearedSessionBinding({
|
|
channel: "imessage",
|
|
accountId: "default",
|
|
conversationId: "+15555550124",
|
|
});
|
|
},
|
|
},
|
|
matrix: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current", "child"],
|
|
},
|
|
getCapabilities: async () => {
|
|
await createContractMatrixThreadBindingManager();
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "matrix",
|
|
accountId: matrixSessionBindingAuth.accountId,
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
await createContractMatrixThreadBindingManager();
|
|
const service = getSessionBindingService();
|
|
const binding = await service.bind({
|
|
targetSessionKey: "agent:matrix:thread",
|
|
targetKind: "subagent",
|
|
conversation: {
|
|
channel: "matrix",
|
|
accountId: matrixSessionBindingAuth.accountId,
|
|
conversationId: "$thread",
|
|
parentConversationId: "!room:example.org",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "matrix",
|
|
label: "matrix-thread",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "matrix",
|
|
accountId: matrixSessionBindingAuth.accountId,
|
|
conversationId: "$thread",
|
|
parentConversationId: "!room:example.org",
|
|
targetSessionKey: "agent:matrix:thread",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
expectClearedSessionBinding({
|
|
channel: "matrix",
|
|
accountId: matrixSessionBindingAuth.accountId,
|
|
conversationId: "$thread",
|
|
});
|
|
},
|
|
},
|
|
telegram: {
|
|
expectedCapabilities: {
|
|
adapterAvailable: true,
|
|
bindSupported: true,
|
|
unbindSupported: true,
|
|
placements: ["current", "child"],
|
|
},
|
|
getCapabilities: async () => {
|
|
const { createTelegramThreadBindingManager } = await getContractApi<{
|
|
createTelegramThreadBindingManager: (params: {
|
|
accountId: string;
|
|
persist: boolean;
|
|
enableSweeper: boolean;
|
|
}) => unknown;
|
|
}>("telegram");
|
|
createTelegramThreadBindingManager({
|
|
accountId: "default",
|
|
persist: false,
|
|
enableSweeper: false,
|
|
});
|
|
return getSessionBindingService().getCapabilities({
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
});
|
|
},
|
|
bindAndResolve: async () => {
|
|
const { createTelegramThreadBindingManager } = await getContractApi<{
|
|
createTelegramThreadBindingManager: (params: {
|
|
accountId: string;
|
|
persist: boolean;
|
|
enableSweeper: boolean;
|
|
}) => unknown;
|
|
}>("telegram");
|
|
createTelegramThreadBindingManager({
|
|
accountId: "default",
|
|
persist: false,
|
|
enableSweeper: false,
|
|
});
|
|
const service = getSessionBindingService();
|
|
const binding = await service.bind({
|
|
targetSessionKey: "agent:telegram:child:thread-1",
|
|
targetKind: "subagent",
|
|
conversation: {
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-100200300:topic:77",
|
|
},
|
|
placement: "current",
|
|
metadata: {
|
|
agentId: "telegram",
|
|
label: "telegram-topic",
|
|
},
|
|
});
|
|
expectResolvedSessionBinding({
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-100200300:topic:77",
|
|
targetSessionKey: "agent:telegram:child:thread-1",
|
|
});
|
|
return binding;
|
|
},
|
|
unbindAndVerify: unbindAndExpectClearedSessionBinding,
|
|
cleanup: async () => {
|
|
expectClearedSessionBinding({
|
|
channel: "telegram",
|
|
accountId: "default",
|
|
conversationId: "-100200300:topic:77",
|
|
});
|
|
},
|
|
},
|
|
};
|
|
|
|
let sessionBindingContractRegistryCache: SessionBindingContractEntry[] | undefined;
|
|
|
|
export function getSessionBindingContractRegistry(): SessionBindingContractEntry[] {
|
|
sessionBindingContractRegistryCache ??= sessionBindingContractChannelIds.map((id) => ({
|
|
id,
|
|
...sessionBindingContractEntries[id],
|
|
}));
|
|
return sessionBindingContractRegistryCache;
|
|
}
|