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; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; }; const contractApiPromises = new Map>>(); 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>(pluginId: string): Promise { const existing = contractApiPromises.get(pluginId); if (existing) { return (await existing) as T; } const next = importBundledChannelContractArtifact(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; }>("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 > = { 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; }