From 8bfa06e992be4ff32a254a987bf99022eaa49cad Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Apr 2026 22:45:03 +0100 Subject: [PATCH] refactor: enforce plugin-owned channel boundaries --- .github/workflows/ci.yml | 1 + AGENTS.md | 3 +- extensions/mattermost/src/channel.ts | 7 + extensions/slack/src/channel.ts | 7 + .../whatsapp/src/session-contract.test.ts | 12 +- extensions/whatsapp/src/session-contract.ts | 4 + extensions/whatsapp/src/shared.ts | 7 +- package.json | 3 + scripts/check-channel-agnostic-boundaries.mjs | 10 + scripts/check-tsgo-core-boundary.mjs | 59 +++++ scripts/profile-tsgo.mjs | 8 + src/agents/acp-spawn.test.ts | 104 ++++++++- src/agents/acp-spawn.ts | 65 ------ .../channel-setup/plugin-install.test.ts | 215 +++++++++--------- src/commands/channels.add.test.ts | 144 ++++++------ src/commands/channels.mock-harness.ts | 6 + .../channels.plugin-install.test-helpers.ts | 46 ++-- src/commands/channels.remove.test.ts | 18 +- src/flows/channel-setup.test.ts | 80 +++---- src/routing/session-key.test.ts | 10 +- src/sessions/send-policy.ts | 15 +- src/sessions/session-chat-type-shared.ts | 9 - src/utils/delivery-context.test.ts | 65 +++--- src/utils/delivery-context.ts | 16 -- tsconfig.core.test.agents.json | 10 + tsconfig.core.test.non-agents.json | 10 + 26 files changed, 546 insertions(+), 388 deletions(-) create mode 100644 scripts/check-tsgo-core-boundary.mjs create mode 100644 tsconfig.core.test.agents.json create mode 100644 tsconfig.core.test.non-agents.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897c28f7d86..fa6f0bf124b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1264,6 +1264,7 @@ jobs: run_check "plugin-extension-boundary" pnpm run lint:plugins:no-extension-imports run_check "lint:tmp:no-random-messaging" pnpm run lint:tmp:no-random-messaging run_check "lint:tmp:channel-agnostic-boundaries" pnpm run lint:tmp:channel-agnostic-boundaries + run_check "lint:tmp:tsgo-core-boundary" pnpm run lint:tmp:tsgo-core-boundary run_check "lint:tmp:no-raw-channel-fetch" pnpm run lint:tmp:no-raw-channel-fetch run_check "lint:agent:ingress-owner" pnpm run lint:agent:ingress-owner run_check "lint:plugins:no-register-http-handler" pnpm run lint:plugins:no-register-http-handler diff --git a/AGENTS.md b/AGENTS.md index 94907075f8b..bcf88075aad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,10 +130,11 @@ - TypeScript checks are split by architecture boundary: - `pnpm tsgo` / `pnpm tsgo:core`: core production roots (`src/`, `ui/`, `packages/`; no `extensions/` include roots). - `pnpm tsgo:core:test`: core colocated tests. + - `pnpm tsgo:core:test:agents` / `pnpm tsgo:core:test:non-agents`: core test slices for isolating the heavy agent graph while debugging type-check performance. - `pnpm tsgo:extensions`: bundled extension production graph. - `pnpm tsgo:extensions:test`: bundled extension colocated tests. - `pnpm tsgo:all`: every TypeScript graph above; this is what `pnpm check` runs. - - `pnpm tsgo:profile [core-test|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`; if a core graph lists `extensions//`, treat that as boundary/perf debt from imports (usually plugin-sdk facades or shared helpers pulling extension sources). + - `pnpm tsgo:profile [core-test|core-test-agents|core-test-non-agents|extensions-test|--all]`: profile fresh graph cost into `.artifacts/tsgo-profile/`; if a core graph lists `extensions//`, treat that as boundary/perf debt from imports (usually plugin-sdk facades or shared helpers pulling extension sources). - Narrow aliases remain for local loops: `pnpm tsgo:test:src`, `pnpm tsgo:test:ui`, `pnpm tsgo:test:packages`. - Do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes for repo type checking. Use `tsgo` graphs. `tsc` is allowed only when emitting declaration/package-boundary compatibility artifacts that `tsgo` does not replace. - Boundary rule: core must not know extension implementation details. Extensions hook into core through manifests, registries, capabilities, and public `openclaw/plugin-sdk/*` contracts. If you find core production code naming a specific extension, or a core test that is really testing extension-owned behavior, call it out and prefer moving coverage/logic to the owning extension or a generic contract test. diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 1a1eb6e81a6..ee064f73934 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -288,6 +288,13 @@ export const mattermostPlugin: ChannelPlugin = create messaging: { defaultMarkdownTableMode: "off", normalizeTarget: normalizeMattermostMessagingTarget, + resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { + const parent = parentConversationId?.trim(); + const child = conversationId.trim(); + return parent && parent !== child + ? { to: `channel:${parent}`, threadId: child } + : { to: normalizeMattermostMessagingTarget(`channel:${child}`) }; + }, resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeMattermostTargetId, diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 9e22b3ee91e..81d4ba8bd40 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -333,6 +333,13 @@ export const slackPlugin: ChannelPlugin = crea }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, + resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { + const parent = parentConversationId?.trim(); + const child = conversationId.trim(); + return parent && parent !== child + ? { to: `channel:${parent}`, threadId: child } + : { to: normalizeSlackMessagingTarget(`channel:${child}`) }; + }, resolveSessionTarget: ({ id }) => normalizeSlackMessagingTarget(`channel:${id}`), parseExplicitTarget: ({ raw }) => parseSlackExplicitTarget(raw), inferTargetChatType: ({ to }) => parseSlackExplicitTarget(to)?.chatType, diff --git a/extensions/whatsapp/src/session-contract.test.ts b/extensions/whatsapp/src/session-contract.test.ts index c9ff5a6b2df..03fe2be8675 100644 --- a/extensions/whatsapp/src/session-contract.test.ts +++ b/extensions/whatsapp/src/session-contract.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session-contract.js"; +import { + canonicalizeLegacySessionKey, + deriveLegacySessionChatType, + isLegacyGroupSessionKey, +} from "./session-contract.js"; describe("whatsapp legacy session contract", () => { it("canonicalizes legacy WhatsApp group keys to channel-qualified agent keys", () => { @@ -16,6 +20,12 @@ describe("whatsapp legacy session contract", () => { it("does not claim generic non-WhatsApp group keys", () => { expect(isLegacyGroupSessionKey("group:abc")).toBe(false); + expect(deriveLegacySessionChatType("group:abc")).toBeUndefined(); expect(canonicalizeLegacySessionKey({ key: "group:abc", agentId: "main" })).toBeNull(); }); + + it("derives chat type for legacy WhatsApp group keys", () => { + expect(deriveLegacySessionChatType("123@g.us")).toBe("group"); + expect(deriveLegacySessionChatType("whatsapp:123@g.us")).toBe("group"); + }); }); diff --git a/extensions/whatsapp/src/session-contract.ts b/extensions/whatsapp/src/session-contract.ts index 5e7f456f33f..e5c3d4fc751 100644 --- a/extensions/whatsapp/src/session-contract.ts +++ b/extensions/whatsapp/src/session-contract.ts @@ -28,6 +28,10 @@ export function isLegacyGroupSessionKey(key: string): boolean { return extractLegacyWhatsAppGroupId(key) !== null; } +export function deriveLegacySessionChatType(key: string): "group" | undefined { + return isLegacyGroupSessionKey(key) ? "group" : undefined; +} + export function canonicalizeLegacySessionKey(params: { key: string; agentId: string; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index 22822ebb728..5fc4e02d5eb 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -32,7 +32,11 @@ import { unsupportedSecretRefSurfacePatterns, } from "./security-contract.js"; import { applyWhatsAppSecurityConfigFixes } from "./security-fix.js"; -import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session-contract.js"; +import { + canonicalizeLegacySessionKey, + deriveLegacySessionChatType, + isLegacyGroupSessionKey, +} from "./session-contract.js"; export const WHATSAPP_CHANNEL = "whatsapp" as const; @@ -245,6 +249,7 @@ export function createWhatsAppPluginBase(params: { config: base.config!, messaging: { defaultMarkdownTableMode: "bullets", + deriveLegacySessionChatType, resolveLegacyGroupSessionKey, isLegacyGroupSessionKey, canonicalizeLegacySessionKey: (params) => diff --git a/package.json b/package.json index 1a4cf5421e2..23b40d600e1 100644 --- a/package.json +++ b/package.json @@ -1321,6 +1321,7 @@ "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", + "lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", "lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs", "lint:web-search-provider-boundaries": "node scripts/check-web-search-provider-boundaries.mjs", @@ -1473,6 +1474,8 @@ "tsgo:all": "pnpm tsgo:core && pnpm tsgo:core:test && pnpm tsgo:extensions && pnpm tsgo:extensions:test", "tsgo:core": "node scripts/run-tsgo.mjs -p tsconfig.core.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core.tsbuildinfo", "tsgo:core:test": "node scripts/run-tsgo.mjs -p tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo", + "tsgo:core:test:agents": "node scripts/run-tsgo.mjs -p tsconfig.core.test.agents.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-agents.tsbuildinfo", + "tsgo:core:test:non-agents": "node scripts/run-tsgo.mjs -p tsconfig.core.test.non-agents.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test-non-agents.tsbuildinfo", "tsgo:extensions": "node scripts/run-tsgo.mjs -p tsconfig.extensions.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions.tsbuildinfo", "tsgo:extensions:test": "node scripts/run-tsgo.mjs -p tsconfig.extensions.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/extensions-test.tsbuildinfo", "tsgo:prod": "pnpm tsgo:core && pnpm tsgo:extensions", diff --git a/scripts/check-channel-agnostic-boundaries.mjs b/scripts/check-channel-agnostic-boundaries.mjs index 3a1e553acde..b1cb8084451 100644 --- a/scripts/check-channel-agnostic-boundaries.mjs +++ b/scripts/check-channel-agnostic-boundaries.mjs @@ -23,6 +23,9 @@ const acpCoreProtectedSources = [ const channelCoreProtectedSources = [ path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"), path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"), + path.join(repoRoot, "src", "sessions", "send-policy.ts"), + path.join(repoRoot, "src", "sessions", "session-chat-type-shared.ts"), + path.join(repoRoot, "src", "utils", "delivery-context.ts"), ]; const acpUserFacingTextSources = [ path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"), @@ -41,11 +44,18 @@ const channelIds = [ "imessage", "irc", "line", + "mattermost", "matrix", "msteams", + "nextcloud-talk", + "nostr", + "qqbot", "signal", "slack", + "synology-chat", "telegram", + "tlon", + "twitch", "web", "whatsapp", "zalo", diff --git a/scripts/check-tsgo-core-boundary.mjs b/scripts/check-tsgo-core-boundary.mjs new file mode 100644 index 00000000000..5d35dd83230 --- /dev/null +++ b/scripts/check-tsgo-core-boundary.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); +const tsgoPath = path.join(repoRoot, "node_modules", ".bin", "tsgo"); + +const coreGraphs = [ + { name: "core", config: "tsconfig.core.json" }, + { name: "core-test", config: "tsconfig.core.test.json" }, + { name: "core-test-agents", config: "tsconfig.core.test.agents.json" }, + { name: "core-test-non-agents", config: "tsconfig.core.test.non-agents.json" }, +]; + +function normalizeFilePath(filePath) { + const normalized = filePath.trim().replaceAll("\\", "/"); + const normalizedRoot = repoRoot.replaceAll("\\", "/"); + if (normalized.startsWith(`${normalizedRoot}/`)) { + return normalized.slice(normalizedRoot.length + 1); + } + return normalized; +} + +function listGraphFiles(graph) { + const result = spawnSync(tsgoPath, ["-p", graph.config, "--pretty", "false", "--listFilesOnly"], { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + shell: process.platform === "win32", + }); + if (result.error) { + throw result.error; + } + if ((result.status ?? 1) !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`${graph.name} file listing failed with exit code ${result.status}\n${output}`); + } + return (result.stdout ?? "").split(/\r?\n/u).map(normalizeFilePath).filter(Boolean); +} + +const violations = []; +for (const graph of coreGraphs) { + const extensionFiles = listGraphFiles(graph).filter((file) => file.startsWith("extensions/")); + for (const file of extensionFiles) { + violations.push(`${graph.name}: ${file}`); + } +} + +if (violations.length > 0) { + console.error("Core tsgo graphs must not include bundled extension files:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + console.error( + "Move extension-owned behavior behind plugin SDK contracts, public artifacts, or extension-local tests.", + ); + process.exit(1); +} diff --git a/scripts/profile-tsgo.mjs b/scripts/profile-tsgo.mjs index f2a8d565edb..52db37f8849 100644 --- a/scripts/profile-tsgo.mjs +++ b/scripts/profile-tsgo.mjs @@ -22,6 +22,14 @@ const GRAPH_DEFINITIONS = { config: "tsconfig.core.test.json", description: "core colocated test graph", }, + "core-test-agents": { + config: "tsconfig.core.test.agents.json", + description: "core agent colocated test graph", + }, + "core-test-non-agents": { + config: "tsconfig.core.test.non-agents.json", + description: "core non-agent colocated test graph", + }, extensions: { config: "tsconfig.extensions.json", description: "bundled extension production graph", diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 2a56c7a6f8c..e65a482f0e0 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -305,6 +305,15 @@ function expectAgentGatewayCall(overrides: AgentCallParams): void { expect(agentCall?.params?.threadId).toBe(overrides.threadId); } +function resolveMatrixRoomTargetForTest(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + const normalized = trimmed.replace(/^(?:matrix:)?(?:channel:|room:)/iu, "").trim(); + return normalized || undefined; +} + function enableMatrixAcpThreadBindings(): void { replaceSpawnConfig({ ...hoisted.state.cfg, @@ -318,6 +327,42 @@ function enableMatrixAcpThreadBindings(): void { }, }, }); + const matrixPlugin = { + messaging: { + resolveDeliveryTarget: ({ + conversationId, + parentConversationId, + }: { + conversationId: string; + parentConversationId?: string; + }) => { + const parent = resolveMatrixRoomTargetForTest(parentConversationId); + const child = conversationId.trim(); + return parent ? { to: `room:${parent}`, threadId: child } : { to: `room:${child}` }; + }, + resolveInboundConversation: ({ + to, + threadId, + }: { + to?: string; + threadId?: string | number; + }) => { + const parent = resolveMatrixRoomTargetForTest(to); + const thread = threadId != null ? String(threadId).trim() : ""; + return thread && parent + ? { conversationId: thread, parentConversationId: parent } + : parent + ? { conversationId: parent } + : undefined; + }, + }, + }; + hoisted.getChannelPluginMock.mockImplementation((channelId: string) => + channelId === "matrix" ? matrixPlugin : undefined, + ); + hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) => + channelId === "matrix" ? matrixPlugin : undefined, + ); registerSessionBindingAdapter({ channel: "matrix", accountId: "default", @@ -342,6 +387,28 @@ function enableLineCurrentConversationBindings(): void { }, }, }); + const linePlugin = { + messaging: { + resolveInboundConversation: ({ + conversationId, + to, + }: { + conversationId?: string; + to?: string; + }) => { + const source = (conversationId ?? to ?? "").trim(); + const normalized = + source.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1]?.trim() ?? source; + return normalized ? { conversationId: normalized } : undefined; + }, + }, + }; + hoisted.getChannelPluginMock.mockImplementation((channelId: string) => + channelId === "line" ? linePlugin : undefined, + ); + hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) => + channelId === "line" ? linePlugin : undefined, + ); registerSessionBindingAdapter({ channel: "line", accountId: "default", @@ -369,10 +436,45 @@ function enableTelegramCurrentConversationBindings(): void { }, }, }); + const telegramPlugin = { + messaging: { + resolveInboundConversation: ({ + conversationId, + to, + threadId, + }: { + conversationId?: string; + to?: string; + threadId?: string | number; + }) => { + const source = (conversationId ?? to ?? "").trim(); + const normalized = source.replace(/^telegram:(?:group:|channel:|direct:)?/i, ""); + const explicitThreadId = threadId == null ? "" : String(threadId).trim(); + if (/^-?\d+$/.test(normalized) && /^\d+$/.test(explicitThreadId)) { + return { conversationId: `${normalized}:topic:${explicitThreadId}` }; + } + const topicMatch = /^(-?\d+):topic:(\d+)$/i.exec(normalized); + if (topicMatch?.[1] && topicMatch[2]) { + return { conversationId: `${topicMatch[1]}:topic:${topicMatch[2]}` }; + } + return /^-?\d+$/.test(normalized) ? { conversationId: normalized } : undefined; + }, + }, + }; + hoisted.getChannelPluginMock.mockImplementation((channelId: string) => + channelId === "telegram" ? telegramPlugin : undefined, + ); + hoisted.getLoadedChannelPluginMock.mockImplementation((channelId: string) => + channelId === "telegram" ? telegramPlugin : undefined, + ); registerSessionBindingAdapter({ channel: "telegram", accountId: "default", - capabilities: createSessionBindingCapabilities(), + capabilities: { + bindSupported: true, + unbindSupported: true, + placements: ["current"] satisfies SessionBindingPlacement[], + }, bind: async (input) => await hoisted.sessionBindingBindMock(input), listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey), resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref), diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 536e6109eb9..a3eb0929d47 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -225,65 +225,11 @@ type AcpSpawnBootstrapDeliveryPlan = { }; function resolvePlacementWithoutChannelPlugin(params: { - channel: string; capabilities: { placements: Array<"current" | "child"> }; }): "current" | "child" { - switch (params.channel) { - case "discord": - case "matrix": - return params.capabilities.placements.includes("child") ? "child" : "current"; - case "line": - case "telegram": - return "current"; - } return params.capabilities.placements.includes("child") ? "child" : "current"; } -function normalizeLineConversationIdFallback(value: string | undefined): string | undefined { - const trimmed = normalizeOptionalString(value) ?? ""; - if (!trimmed) { - return undefined; - } - const normalized = trimmed.match(/^line:(?:(?:user|group|room):)?(.+)$/i)?.[1]?.trim() ?? trimmed; - return normalized ? normalized : undefined; -} - -function normalizeTelegramConversationIdFallback(params: { - to?: string; - threadId?: string | number; - groupId?: string; -}): string | undefined { - const explicitGroupId = normalizeOptionalString(params.groupId); - const explicitThreadId = - params.threadId != null ? normalizeOptionalString(String(params.threadId)) : undefined; - if ( - explicitGroupId && - explicitThreadId && - /^-?\d+$/.test(explicitGroupId) && - /^\d+$/.test(explicitThreadId) - ) { - return `${explicitGroupId}:topic:${explicitThreadId}`; - } - - const trimmed = normalizeOptionalString(params.to) ?? ""; - if (!trimmed) { - return undefined; - } - const normalized = trimmed.replace(/^telegram:(?:group:|channel:|direct:)?/i, ""); - const topicMatch = /^(-?\d+):topic:(\d+)$/i.exec(normalized); - if (topicMatch?.[1] && topicMatch[2]) { - return `${topicMatch[1]}:topic:${topicMatch[2]}`; - } - return /^-?\d+$/.test(normalized) ? normalized : undefined; -} - -const threadBindingFallbackConversationResolvers = { - line: (params: { to?: string; groupId?: string }) => - normalizeLineConversationIdFallback(params.groupId ?? params.to), - telegram: (params: { to?: string; threadId?: string | number; groupId?: string }) => - normalizeTelegramConversationIdFallback(params), -} as const; - function resolvePluginConversationRefForThreadBinding(params: { channelId: string; to?: string; @@ -559,7 +505,6 @@ function resolveConversationRefForThreadBinding(params: { }): { conversationId: string; parentConversationId?: string } | null { const channel = normalizeOptionalLowercaseString(params.channel); const normalizedChannelId = channel ? normalizeChannelId(channel) : null; - const channelKey = normalizedChannelId ?? channel ?? null; const pluginResolvedConversation = normalizedChannelId ? resolvePluginConversationRefForThreadBinding({ channelId: normalizedChannelId, @@ -571,15 +516,6 @@ function resolveConversationRefForThreadBinding(params: { if (pluginResolvedConversation) { return pluginResolvedConversation; } - const compatibilityConversationId = - channelKey && Object.hasOwn(threadBindingFallbackConversationResolvers, channelKey) - ? threadBindingFallbackConversationResolvers[ - channelKey as keyof typeof threadBindingFallbackConversationResolvers - ](params) - : undefined; - if (compatibilityConversationId) { - return normalizeConversationTargetRef({ conversationId: compatibilityConversationId }); - } const parentConversationId = resolveConversationIdFromTargets({ targets: [params.to], }); @@ -682,7 +618,6 @@ function prepareAcpThreadBinding(params: { const placementToUse = pluginPlacement ?? resolvePlacementWithoutChannelPlugin({ - channel: policy.channel, capabilities, }); if (!capabilities.bindSupported || !capabilities.placements.includes(placementToUse)) { diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index ff30abdf7a3..62c5c86218f 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -99,19 +99,19 @@ import { } from "./plugin-install.js"; const baseEntry: ChannelPluginCatalogEntry = { - id: "zalo", - pluginId: "zalo", + id: "bundled-chat", + pluginId: "bundled-chat", meta: { - id: "zalo", - label: "Zalo", - selectionLabel: "Zalo (Bot API)", - docsPath: "/channels/zalo", - docsLabel: "zalo", + id: "bundled-chat", + label: "Bundled Chat", + selectionLabel: "Bundled Chat", + docsPath: "/channels/bundled-chat", + docsLabel: "bundled chat", blurb: "Test", }, install: { - npmSpec: "@openclaw/zalo", - localPath: bundledPluginRoot("zalo"), + npmSpec: "@openclaw/bundled-chat", + localPath: bundledPluginRoot("bundled-chat"), }, }; @@ -132,7 +132,10 @@ beforeEach(() => { function mockRepoLocalPathExists() { vi.mocked(fs.existsSync).mockImplementation((value) => { const raw = String(value); - return raw.endsWith(`${path.sep}.git`) || raw.endsWith(`${path.sep}extensions${path.sep}zalo`); + return ( + raw.endsWith(`${path.sep}.git`) || + raw.endsWith(`${path.sep}extensions${path.sep}bundled-chat`) + ); }); } @@ -157,7 +160,7 @@ async function runInitialValueForChannel(channel: "dev" | "beta") { function expectPluginLoadedFromLocalPath( result: Awaited>, ) { - const expectedPath = path.resolve(process.cwd(), bundledPluginRoot("zalo")); + const expectedPath = path.resolve(process.cwd(), bundledPluginRoot("bundled-chat")); expect(result.installed).toBe(true); expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); } @@ -172,8 +175,8 @@ describe("ensureChannelSetupPluginInstalled", () => { vi.mocked(fs.existsSync).mockReturnValue(false); installPluginFromNpmSpec.mockResolvedValue({ ok: true, - pluginId: "zalo", - targetDir: "/tmp/zalo", + pluginId: "bundled-chat", + targetDir: "/tmp/bundled-chat", extensions: [], }); @@ -185,13 +188,13 @@ describe("ensureChannelSetupPluginInstalled", () => { }); expect(result.installed).toBe(true); - expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); - expect(result.cfg.plugins?.allow).toContain("zalo"); - expect(result.cfg.plugins?.installs?.zalo?.source).toBe("npm"); - expect(result.cfg.plugins?.installs?.zalo?.spec).toBe("@openclaw/zalo"); - expect(result.cfg.plugins?.installs?.zalo?.installPath).toBe("/tmp/zalo"); + expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true); + expect(result.cfg.plugins?.allow).toContain("bundled-chat"); + expect(result.cfg.plugins?.installs?.["bundled-chat"]?.source).toBe("npm"); + expect(result.cfg.plugins?.installs?.["bundled-chat"]?.spec).toBe("@openclaw/bundled-chat"); + expect(result.cfg.plugins?.installs?.["bundled-chat"]?.installPath).toBe("/tmp/bundled-chat"); expect(installPluginFromNpmSpec).toHaveBeenCalledWith( - expect.objectContaining({ spec: "@openclaw/zalo" }), + expect.objectContaining({ spec: "@openclaw/bundled-chat" }), ); }); @@ -211,7 +214,7 @@ describe("ensureChannelSetupPluginInstalled", () => { }); expectPluginLoadedFromLocalPath(result); - expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); + expect(result.cfg.plugins?.entries?.["bundled-chat"]?.enabled).toBe(true); }); it("uses the catalog plugin id for local-path installs", async () => { @@ -226,16 +229,16 @@ describe("ensureChannelSetupPluginInstalled", () => { cfg, entry: { ...baseEntry, - id: "teams", - pluginId: "@openclaw/msteams-plugin", + id: "external-chat", + pluginId: "@vendor/external-chat-plugin", }, prompter, runtime, }); expect(result.installed).toBe(true); - expect(result.pluginId).toBe("@openclaw/msteams-plugin"); - expect(result.cfg.plugins?.entries?.["@openclaw/msteams-plugin"]?.enabled).toBe(true); + expect(result.pluginId).toBe("@vendor/external-chat-plugin"); + expect(result.cfg.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true); }); it("defaults to local on dev channel when local path exists", async () => { @@ -255,11 +258,11 @@ describe("ensureChannelSetupPluginInstalled", () => { resolveBundledPluginSources.mockReturnValue( new Map([ [ - "zalo", + "bundled-chat", { - pluginId: "zalo", - localPath: bundledPluginRootAt("/opt/openclaw", "zalo"), - npmSpec: "@openclaw/zalo", + pluginId: "bundled-chat", + localPath: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), + npmSpec: "@openclaw/bundled-chat", }, ], ]), @@ -278,7 +281,7 @@ describe("ensureChannelSetupPluginInstalled", () => { options: expect.arrayContaining([ expect.objectContaining({ value: "local", - hint: bundledPluginRootAt("/opt/openclaw", "zalo"), + hint: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), }), ]), }), @@ -294,11 +297,11 @@ describe("ensureChannelSetupPluginInstalled", () => { resolveBundledPluginSources.mockReturnValue( new Map([ [ - "whatsapp", + "bundled-chat", { - pluginId: "whatsapp", - localPath: bundledPluginRootAt("/opt/openclaw", "whatsapp"), - npmSpec: "@openclaw/whatsapp", + pluginId: "bundled-chat", + localPath: bundledPluginRootAt("/opt/openclaw", "bundled-chat"), + npmSpec: "@openclaw/bundled-chat", }, ], ]), @@ -307,16 +310,16 @@ describe("ensureChannelSetupPluginInstalled", () => { await ensureChannelSetupPluginInstalled({ cfg, entry: { - id: "whatsapp", + id: "bundled-chat", meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", + id: "bundled-chat", + label: "Bundled Chat", + selectionLabel: "Bundled Chat", + docsPath: "/channels/bundled-chat", blurb: "Test", }, install: { - npmSpec: "@vendor/whatsapp-fork", + npmSpec: "@vendor/bundled-chat-fork", }, }, prompter, @@ -329,7 +332,7 @@ describe("ensureChannelSetupPluginInstalled", () => { options: [ expect.objectContaining({ value: "npm", - label: "Download from npm (@vendor/whatsapp-fork)", + label: "Download from npm (@vendor/bundled-chat-fork)", }), expect.objectContaining({ value: "skip", @@ -397,13 +400,13 @@ describe("ensureChannelSetupPluginInstalled", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = { plugins: {}, - channels: { telegram: { enabled: true } } as never, + channels: { "external-chat": { enabled: true } } as never, }; const autoEnabledConfig = { ...cfg, plugins: { entries: { - telegram: { enabled: true }, + "external-chat": { enabled: true }, }, }, } as OpenClawConfig; @@ -435,12 +438,12 @@ describe("ensureChannelSetupPluginInstalled", () => { it("scopes channel reloads when setup starts from an empty registry", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; - getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" }); + getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" }); reloadChannelSetupPluginRegistryForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -451,11 +454,11 @@ describe("ensureChannelSetupPluginInstalled", () => { autoEnabledReasons: {}, workspaceDir: "/tmp/openclaw-workspace", cache: false, - onlyPluginIds: ["@openclaw/telegram-plugin"], + onlyPluginIds: ["@vendor/external-chat-plugin"], includeSetupOnlyChannelPlugins: true, }), ); - expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", { + expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); }); @@ -478,7 +481,7 @@ describe("ensureChannelSetupPluginInstalled", () => { reloadChannelSetupPluginRegistryForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -492,7 +495,7 @@ describe("ensureChannelSetupPluginInstalled", () => { it("scopes channel reloads when the global registry is populated but the pinned channel registry is empty", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; - getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" }); + getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" }); const activeRegistry = createEmptyPluginRegistry(); activeRegistry.plugins.push( createPluginRecord({ @@ -510,7 +513,7 @@ describe("ensureChannelSetupPluginInstalled", () => { reloadChannelSetupPluginRegistryForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); } finally { @@ -521,7 +524,7 @@ describe("ensureChannelSetupPluginInstalled", () => { expect.objectContaining({ activationSourceConfig: cfg, autoEnabledReasons: {}, - onlyPluginIds: ["@openclaw/telegram-plugin"], + onlyPluginIds: ["@vendor/external-chat-plugin"], }), ); }); @@ -529,12 +532,12 @@ describe("ensureChannelSetupPluginInstalled", () => { it("can load a channel-scoped snapshot without activating the global registry", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; - getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@openclaw/telegram-plugin" }); + getChannelPluginCatalogEntry.mockReturnValue({ pluginId: "@vendor/external-chat-plugin" }); loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -545,12 +548,12 @@ describe("ensureChannelSetupPluginInstalled", () => { autoEnabledReasons: {}, workspaceDir: "/tmp/openclaw-workspace", cache: false, - onlyPluginIds: ["@openclaw/telegram-plugin"], + onlyPluginIds: ["@vendor/external-chat-plugin"], includeSetupOnlyChannelPlugins: true, activate: false, }), ); - expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", { + expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); }); @@ -559,25 +562,25 @@ describe("ensureChannelSetupPluginInstalled", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; getChannelPluginCatalogEntry - .mockReturnValueOnce({ pluginId: "evil-telegram-shadow", origin: "workspace" }) - .mockReturnValueOnce({ pluginId: "@openclaw/telegram-plugin", origin: "bundled" }); + .mockReturnValueOnce({ pluginId: "evil-external-chat-shadow", origin: "workspace" }) + .mockReturnValueOnce({ pluginId: "@vendor/external-chat-plugin", origin: "bundled" }); loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ - onlyPluginIds: ["@openclaw/telegram-plugin"], + onlyPluginIds: ["@vendor/external-chat-plugin"], }), ); - expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "telegram", { + expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "external-chat", { workspaceDir: "/tmp/openclaw-workspace", }); - expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "telegram", { + expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "external-chat", { workspaceDir: "/tmp/openclaw-workspace", excludeWorkspace: true, }); @@ -588,24 +591,24 @@ describe("ensureChannelSetupPluginInstalled", () => { const cfg: OpenClawConfig = { plugins: { enabled: true, - allow: ["trusted-telegram-shadow"], + allow: ["trusted-external-chat-shadow"], }, }; getChannelPluginCatalogEntry.mockReturnValue({ - pluginId: "trusted-telegram-shadow", + pluginId: "trusted-external-chat-shadow", origin: "workspace", }); loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ - onlyPluginIds: ["trusted-telegram-shadow"], + onlyPluginIds: ["trusted-external-chat-shadow"], }), ); expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1); @@ -618,7 +621,7 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -633,14 +636,14 @@ describe("ensureChannelSetupPluginInstalled", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; loadPluginManifestRegistry.mockReturnValue({ - plugins: [{ id: "custom-telegram-plugin", channels: ["telegram"] }], + plugins: [{ id: "custom-external-chat-plugin", channels: ["external-chat"] }], diagnostics: [], }); loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -651,7 +654,7 @@ describe("ensureChannelSetupPluginInstalled", () => { autoEnabledReasons: {}, workspaceDir: "/tmp/openclaw-workspace", cache: false, - onlyPluginIds: ["custom-telegram-plugin"], + onlyPluginIds: ["custom-external-chat-plugin"], includeSetupOnlyChannelPlugins: true, activate: false, }), @@ -664,10 +667,10 @@ describe("ensureChannelSetupPluginInstalled", () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-plugin", + id: "custom-external-chat-plugin", channels: [], activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -677,13 +680,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.objectContaining({ - onlyPluginIds: ["custom-telegram-plugin"], + onlyPluginIds: ["custom-external-chat-plugin"], }), ); expect(loadPluginManifestRegistry).toHaveBeenCalledWith( @@ -699,10 +702,10 @@ describe("ensureChannelSetupPluginInstalled", () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-plugin", + id: "custom-external-chat-plugin", channels: [], activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -712,7 +715,7 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); @@ -730,11 +733,11 @@ describe("ensureChannelSetupPluginInstalled", () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "evil-telegram-shadow", + id: "evil-external-chat-shadow", channels: [], origin: "workspace", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -744,13 +747,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["evil-telegram-shadow"], + onlyPluginIds: ["evil-external-chat-shadow"], }), ); expect( @@ -769,11 +772,11 @@ describe("ensureChannelSetupPluginInstalled", () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-plugin", + id: "custom-external-chat-plugin", channels: [], origin: "bundled", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -783,13 +786,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["custom-telegram-plugin"], + onlyPluginIds: ["custom-external-chat-plugin"], }), ); expect( @@ -802,17 +805,17 @@ describe("ensureChannelSetupPluginInstalled", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = { plugins: { - deny: ["custom-telegram-plugin"], + deny: ["custom-external-chat-plugin"], }, }; loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-plugin", + id: "custom-external-chat-plugin", channels: [], origin: "bundled", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -822,13 +825,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["custom-telegram-plugin"], + onlyPluginIds: ["custom-external-chat-plugin"], }), ); expect( @@ -842,20 +845,20 @@ describe("ensureChannelSetupPluginInstalled", () => { const cfg: OpenClawConfig = { plugins: { enabled: true, - allow: ["evil-telegram-shadow"], + allow: ["evil-external-chat-shadow"], entries: { - "evil-telegram-shadow": { enabled: false }, + "evil-external-chat-shadow": { enabled: false }, }, }, }; loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "evil-telegram-shadow", + id: "evil-external-chat-shadow", channels: [], origin: "workspace", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -865,13 +868,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["evil-telegram-shadow"], + onlyPluginIds: ["evil-external-chat-shadow"], }), ); expect( @@ -885,18 +888,18 @@ describe("ensureChannelSetupPluginInstalled", () => { const cfg: OpenClawConfig = { plugins: { entries: { - "custom-telegram-plugin": { enabled: false }, + "custom-external-chat-plugin": { enabled: false }, }, }, }; loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-plugin", + id: "custom-external-chat-plugin", channels: [], origin: "bundled", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -906,13 +909,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["custom-telegram-plugin"], + onlyPluginIds: ["custom-external-chat-plugin"], }), ); expect( @@ -927,11 +930,11 @@ describe("ensureChannelSetupPluginInstalled", () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ { - id: "custom-telegram-global", + id: "custom-external-chat-global", channels: [], origin: "global", activation: { - onChannels: ["telegram"], + onChannels: ["external-chat"], }, }, ], @@ -941,13 +944,13 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "telegram", + channel: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }); expect(loadOpenClawPlugins).toHaveBeenCalledWith( expect.not.objectContaining({ - onlyPluginIds: ["custom-telegram-global"], + onlyPluginIds: ["custom-external-chat-global"], }), ); expect( @@ -963,8 +966,8 @@ describe("ensureChannelSetupPluginInstalled", () => { loadChannelSetupPluginRegistrySnapshotForChannel({ cfg, runtime, - channel: "msteams", - pluginId: "@openclaw/msteams-plugin", + channel: "external-chat", + pluginId: "@vendor/external-chat-plugin", workspaceDir: "/tmp/openclaw-workspace", }); @@ -975,7 +978,7 @@ describe("ensureChannelSetupPluginInstalled", () => { autoEnabledReasons: {}, workspaceDir: "/tmp/openclaw-workspace", cache: false, - onlyPluginIds: ["@openclaw/msteams-plugin"], + onlyPluginIds: ["@vendor/external-chat-plugin"], includeSetupOnlyChannelPlugins: true, activate: false, }), diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index faa054839a8..483b840f42d 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -8,10 +8,10 @@ import { ensureChannelSetupPluginInstalled, loadChannelSetupPluginRegistrySnapshotForChannel, } from "./channel-setup/plugin-install.js"; -import { configMocks, offsetMocks } from "./channels.mock-harness.js"; +import { configMocks, lifecycleMocks } from "./channels.mock-harness.js"; import { - createMSTeamsCatalogEntry, - createMSTeamsSetupPlugin, + createExternalChatCatalogEntry, + createExternalChatSetupPlugin, } from "./channels.plugin-install.test-helpers.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; @@ -53,66 +53,66 @@ vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks); const runtime = createTestRuntime(); function listConfiguredAccountIds( - channelConfig: { accounts?: Record; botToken?: string } | undefined, + channelConfig: { accounts?: Record; token?: string } | undefined, ): string[] { const accountIds = Object.keys(channelConfig?.accounts ?? {}); if (accountIds.length > 0) { return accountIds; } - if (channelConfig?.botToken) { + if (channelConfig?.token) { return [DEFAULT_ACCOUNT_ID]; } return []; } -function createTelegramAddTestPlugin(): ChannelPlugin { - const resolveTelegramAccount = ( +function createLifecycleChatAddTestPlugin(): ChannelPlugin { + const resolveLifecycleChatAccount = ( cfg: Parameters>[0], accountId: string, ) => { - const telegram = cfg.channels?.telegram as + const lifecycleChat = cfg.channels?.["lifecycle-chat"] as | { - botToken?: string; + token?: string; enabled?: boolean; - accounts?: Record; + accounts?: Record; } | undefined; const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; - const scoped = telegram?.accounts?.[resolvedAccountId]; + const scoped = lifecycleChat?.accounts?.[resolvedAccountId]; return { - token: scoped?.botToken ?? telegram?.botToken ?? "", + token: scoped?.token ?? lifecycleChat?.token ?? "", enabled: typeof scoped?.enabled === "boolean" ? scoped.enabled - : typeof telegram?.enabled === "boolean" - ? telegram.enabled + : typeof lifecycleChat?.enabled === "boolean" + ? lifecycleChat.enabled : true, }; }; return { ...createChannelTestPluginBase({ - id: "telegram", - label: "Telegram", - docsPath: "/channels/telegram", + id: "lifecycle-chat", + label: "Lifecycle Chat", + docsPath: "/channels/lifecycle-chat", }), config: { listAccountIds: (cfg) => listConfiguredAccountIds( - cfg.channels?.telegram as - | { accounts?: Record; botToken?: string } + cfg.channels?.["lifecycle-chat"] as + | { accounts?: Record; token?: string } | undefined, ), - resolveAccount: resolveTelegramAccount, + resolveAccount: resolveLifecycleChatAccount, }, setup: { resolveAccountId: ({ accountId }) => accountId || DEFAULT_ACCOUNT_ID, applyAccountConfig: ({ cfg, accountId, input }) => { - const telegram = (cfg.channels?.telegram as + const lifecycleChat = (cfg.channels?.["lifecycle-chat"] as | { enabled?: boolean; - botToken?: string; - accounts?: Record; + token?: string; + accounts?: Record; } | undefined) ?? { enabled: true }; const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; @@ -121,10 +121,10 @@ function createTelegramAddTestPlugin(): ChannelPlugin { ...cfg, channels: { ...cfg.channels, - telegram: { - ...telegram, + "lifecycle-chat": { + ...lifecycleChat, enabled: true, - ...(input.token ? { botToken: input.token } : {}), + ...(input.token ? { token: input.token } : {}), }, }, }; @@ -133,14 +133,14 @@ function createTelegramAddTestPlugin(): ChannelPlugin { ...cfg, channels: { ...cfg.channels, - telegram: { - ...telegram, + "lifecycle-chat": { + ...lifecycleChat, enabled: true, accounts: { - ...telegram.accounts, + ...lifecycleChat.accounts, [resolvedAccountId]: { - ...telegram.accounts?.[resolvedAccountId], - ...(input.token ? { botToken: input.token } : {}), + ...lifecycleChat.accounts?.[resolvedAccountId], + ...(input.token ? { token: input.token } : {}), }, }, }, @@ -150,10 +150,10 @@ function createTelegramAddTestPlugin(): ChannelPlugin { }, lifecycle: { onAccountConfigChanged: async ({ prevCfg, nextCfg, accountId }) => { - const prevTelegram = resolveTelegramAccount(prevCfg, accountId) as { token?: string }; - const nextTelegram = resolveTelegramAccount(nextCfg, accountId) as { token?: string }; - if ((prevTelegram.token ?? "").trim() !== (nextTelegram.token ?? "").trim()) { - await offsetMocks.deleteTelegramUpdateOffset({ accountId }); + const prev = resolveLifecycleChatAccount(prevCfg, accountId) as { token?: string }; + const next = resolveLifecycleChatAccount(nextCfg, accountId) as { token?: string }; + if ((prev.token ?? "").trim() !== (next.token ?? "").trim()) { + await lifecycleMocks.onAccountConfigChanged({ accountId }); } }, }, @@ -164,17 +164,17 @@ function setMinimalChannelsAddRegistryForTests(): void { setActivePluginRegistry( createTestRegistry([ { - pluginId: "telegram", - plugin: createTelegramAddTestPlugin(), + pluginId: "lifecycle-chat", + plugin: createLifecycleChatAddTestPlugin(), source: "test", }, ]), ); } -function registerMSTeamsSetupPlugin(pluginId = "@openclaw/msteams-plugin"): void { +function registerExternalChatSetupPlugin(pluginId = "@vendor/external-chat-plugin"): void { vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( - createTestRegistry([{ pluginId, plugin: createMSTeamsSetupPlugin(), source: "test" }]), + createTestRegistry([{ pluginId, plugin: createExternalChatSetupPlugin(), source: "test" }]), ); } @@ -235,7 +235,7 @@ describe("channelsAddCommand", () => { .mockImplementation(async (params: { nextConfig: unknown }) => { await configMocks.writeConfigFile(params.nextConfig); }); - offsetMocks.deleteTelegramUpdateOffset.mockClear(); + lifecycleMocks.onAccountConfigChanged.mockClear(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); @@ -255,54 +255,54 @@ describe("channelsAddCommand", () => { setMinimalChannelsAddRegistryForTests(); }); - it("clears telegram update offsets only when the token changes", async () => { + it("runs channel lifecycle hooks only when account config changes", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { channels: { - telegram: { botToken: "old-token", enabled: true }, + "lifecycle-chat": { token: "old-token", enabled: true }, }, }, }); await channelsAddCommand( - { channel: "telegram", account: "default", token: "new-token" }, + { channel: "lifecycle-chat", account: "default", token: "new-token" }, runtime, { hasFlags: true }, ); - expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledTimes(1); - expect(offsetMocks.deleteTelegramUpdateOffset).toHaveBeenCalledWith({ accountId: "default" }); + expect(lifecycleMocks.onAccountConfigChanged).toHaveBeenCalledTimes(1); + expect(lifecycleMocks.onAccountConfigChanged).toHaveBeenCalledWith({ accountId: "default" }); - offsetMocks.deleteTelegramUpdateOffset.mockClear(); + lifecycleMocks.onAccountConfigChanged.mockClear(); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config: { channels: { - telegram: { botToken: "same-token", enabled: true }, + "lifecycle-chat": { token: "same-token", enabled: true }, }, }, }); await channelsAddCommand( - { channel: "telegram", account: "default", token: "same-token" }, + { channel: "lifecycle-chat", account: "default", token: "same-token" }, runtime, { hasFlags: true }, ); - expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled(); + expect(lifecycleMocks.onAccountConfigChanged).not.toHaveBeenCalled(); }); it("loads external channel setup snapshots for newly installed and existing plugins", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); - const catalogEntry = createMSTeamsCatalogEntry(); + const catalogEntry = createExternalChatCatalogEntry(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); - registerMSTeamsSetupPlugin("msteams"); + registerExternalChatSetupPlugin("external-chat"); await channelsAddCommand( { - channel: "msteams", + channel: "external-chat", account: "default", token: "tenant-scoped", }, @@ -317,7 +317,7 @@ describe("channelsAddCommand", () => { expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ channels: { - msteams: expect.objectContaining({ + "external-chat": expect.objectContaining({ enabled: true, }), }, @@ -333,7 +333,7 @@ describe("channelsAddCommand", () => { await channelsAddCommand( { - channel: "msteams", + channel: "external-chat", account: "default", token: "tenant-installed", }, @@ -346,7 +346,7 @@ describe("channelsAddCommand", () => { expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ channels: { - msteams: expect.objectContaining({ + "external-chat": expect.objectContaining({ enabled: true, }), }, @@ -358,43 +358,43 @@ describe("channelsAddCommand", () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); setActivePluginRegistry(createTestRegistry()); const catalogEntry: ChannelPluginCatalogEntry = { - id: "msteams", - pluginId: "@openclaw/msteams-plugin", + id: "external-chat", + pluginId: "@vendor/external-chat-plugin", meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", + id: "external-chat", + label: "External Chat", + selectionLabel: "External Chat", + docsPath: "/channels/external-chat", + blurb: "external chat channel", }, install: { - npmSpec: "@openclaw/msteams", + npmSpec: "@vendor/external-chat", }, }; catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, installed: true, - pluginId: "@vendor/teams-runtime", + pluginId: "@vendor/external-chat-runtime", })); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry([ { - pluginId: "@vendor/teams-runtime", + pluginId: "@vendor/external-chat-runtime", plugin: { ...createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", - docsPath: "/channels/msteams", + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", }), setup: { applyAccountConfig: vi.fn(({ cfg, input }) => ({ ...cfg, channels: { ...cfg.channels, - msteams: { + "external-chat": { enabled: true, - tenantId: input.token, + token: input.token, }, }, })), @@ -407,7 +407,7 @@ describe("channelsAddCommand", () => { await channelsAddCommand( { - channel: "msteams", + channel: "external-chat", account: "default", token: "tenant-scoped", }, @@ -419,7 +419,7 @@ describe("channelsAddCommand", () => { expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ channels: { - msteams: expect.objectContaining({ + "external-chat": expect.objectContaining({ enabled: true, }), }, diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index 0fff7120b2b..216f508ab7d 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -27,6 +27,12 @@ export const offsetMocks: { deleteTelegramUpdateOffset: vi.fn().mockResolvedValue(undefined) as unknown as MockFn, }; +export const lifecycleMocks: { + onAccountConfigChanged: MockFn; +} = { + onAccountConfigChanged: vi.fn().mockResolvedValue(undefined) as unknown as MockFn, +}; + export const secretMocks = { resolveCommandConfigWithSecrets: vi.fn(async ({ config }: { config: unknown }) => ({ resolvedConfig: config, diff --git a/src/commands/channels.plugin-install.test-helpers.ts b/src/commands/channels.plugin-install.test-helpers.ts index 25a2fc9ac17..0bd8434af4b 100644 --- a/src/commands/channels.plugin-install.test-helpers.ts +++ b/src/commands/channels.plugin-install.test-helpers.ts @@ -14,38 +14,38 @@ export function createMockChannelSetupPluginInstallModule( }; } -export function createMSTeamsCatalogEntry(): ChannelPluginCatalogEntry { +export function createExternalChatCatalogEntry(): ChannelPluginCatalogEntry { return { - id: "msteams", - pluginId: "@openclaw/msteams-plugin", + id: "external-chat", + pluginId: "@vendor/external-chat-plugin", meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams", - docsPath: "/channels/msteams", - blurb: "teams channel", + id: "external-chat", + label: "External Chat", + selectionLabel: "External Chat", + docsPath: "/channels/external-chat", + blurb: "external chat channel", }, install: { - npmSpec: "@openclaw/msteams", + npmSpec: "@vendor/external-chat", }, }; } -export function createMSTeamsSetupPlugin(): ChannelPlugin { +export function createExternalChatSetupPlugin(): ChannelPlugin { return { ...createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", - docsPath: "/channels/msteams", + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", }), setup: { applyAccountConfig: vi.fn(({ cfg, input }) => ({ ...cfg, channels: { ...cfg.channels, - msteams: { + "external-chat": { enabled: true, - tenantId: input.token, + token: input.token, }, }, })), @@ -53,23 +53,23 @@ export function createMSTeamsSetupPlugin(): ChannelPlugin { } as ChannelPlugin; } -export function createMSTeamsDeletePlugin(): ChannelPlugin { +export function createExternalChatDeletePlugin(): ChannelPlugin { return { ...createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", - docsPath: "/channels/msteams", + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", }), config: { ...createChannelTestPluginBase({ - id: "msteams", - label: "Microsoft Teams", - docsPath: "/channels/msteams", + id: "external-chat", + label: "External Chat", + docsPath: "/channels/external-chat", }).config, deleteAccount: vi.fn(({ cfg }: { cfg: Record }) => { const channels = (cfg.channels as Record | undefined) ?? {}; const nextChannels = { ...channels }; - delete nextChannels.msteams; + delete nextChannels["external-chat"]; return { ...cfg, channels: nextChannels as ChannelsConfig, diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts index 51000ea8d5e..3e145948647 100644 --- a/src/commands/channels.remove.test.ts +++ b/src/commands/channels.remove.test.ts @@ -8,8 +8,8 @@ import { } from "./channel-setup/plugin-install.js"; import { configMocks } from "./channels.mock-harness.js"; import { - createMSTeamsCatalogEntry, - createMSTeamsDeletePlugin, + createExternalChatCatalogEntry, + createExternalChatDeletePlugin, } from "./channels.plugin-install.test-helpers.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; @@ -86,22 +86,22 @@ describe("channelsRemoveCommand", () => { ...baseConfigSnapshot, config: { channels: { - msteams: { + "external-chat": { enabled: true, - tenantId: "tenant-1", + token: "token-1", }, }, }, }); - const catalogEntry: ChannelPluginCatalogEntry = createMSTeamsCatalogEntry(); + const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); - const scopedPlugin = createMSTeamsDeletePlugin(); + const scopedPlugin = createExternalChatDeletePlugin(); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) .mockReturnValueOnce(createTestRegistry()) .mockReturnValueOnce( createTestRegistry([ { - pluginId: "@openclaw/msteams-plugin", + pluginId: "@vendor/external-chat-plugin", plugin: scopedPlugin, source: "test", }, @@ -110,7 +110,7 @@ describe("channelsRemoveCommand", () => { await channelsRemoveCommand( { - channel: "msteams", + channel: "external-chat", account: "default", delete: true, }, @@ -125,7 +125,7 @@ describe("channelsRemoveCommand", () => { expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.not.objectContaining({ channels: expect.objectContaining({ - msteams: expect.anything(), + "external-chat": expect.anything(), }), }), ); diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 7b4ef145fee..7e6121e55ee 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -190,8 +190,8 @@ describe("setupChannels workspace shadow exclusion", () => { resolveDefaultAgentId.mockReturnValue("default"); listTrustedChannelPluginCatalogEntries.mockReturnValue([ { - id: "telegram", - pluginId: "@openclaw/telegram-plugin", + id: "external-chat", + pluginId: "@vendor/external-chat-plugin", origin: "bundled", }, ]); @@ -234,8 +234,8 @@ describe("setupChannels workspace shadow exclusion", () => { ); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ - channel: "telegram", - pluginId: "@openclaw/telegram-plugin", + channel: "external-chat", + pluginId: "@vendor/external-chat-plugin", workspaceDir: "/tmp/openclaw-workspace", }), ); @@ -243,14 +243,14 @@ describe("setupChannels workspace shadow exclusion", () => { it("keeps trusted workspace overrides eligible during preload", async () => { listTrustedChannelPluginCatalogEntries.mockReturnValue([ - { id: "telegram", pluginId: "trusted-telegram-shadow", origin: "workspace" }, + { id: "external-chat", pluginId: "trusted-external-chat-shadow", origin: "workspace" }, ]); await setupChannels( { plugins: { enabled: true, - allow: ["trusted-telegram-shadow"], + allow: ["trusted-external-chat-shadow"], }, } as never, {} as never, @@ -262,8 +262,8 @@ describe("setupChannels workspace shadow exclusion", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ - channel: "telegram", - pluginId: "trusted-telegram-shadow", + channel: "external-chat", + pluginId: "trusted-external-chat-shadow", workspaceDir: "/tmp/openclaw-workspace", }), ); @@ -273,8 +273,8 @@ describe("setupChannels workspace shadow exclusion", () => { resolveChannelSetupEntries.mockReturnValue({ entries: [ { - id: "telegram", - meta: makeMeta("telegram", "Telegram"), + id: "external-chat", + meta: makeMeta("external-chat", "External Chat"), }, ], installedCatalogEntries: [], @@ -413,52 +413,52 @@ describe("setupChannels workspace shadow exclusion", () => { cfg: { ...cfg, channels: { - telegram: { token: "secret" }, + "external-chat": { token: "secret" }, }, } as never, })); const setupWizard = { - channel: "telegram", + channel: "external-chat", getStatus: vi.fn(async () => ({ - channel: "telegram", + channel: "external-chat", configured: false, statusLines: [], })), configure, } as ChannelSetupPlugin["setupWizard"]; - const telegramPlugin = makeSetupPlugin({ - id: "telegram", - label: "Telegram", + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", setupWizard, }); - const installedCatalogEntry = makeCatalogEntry("telegram", "Telegram", { - pluginId: "telegram", + const installedCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "external-chat", origin: "bundled", }); resolveChannelSetupEntries.mockReturnValue({ entries: [ { - id: "telegram", - meta: makeMeta("telegram", "Telegram"), + id: "external-chat", + meta: makeMeta("external-chat", "External Chat"), }, ], installedCatalogEntries: [installedCatalogEntry], installableCatalogEntries: [], - installedCatalogById: new Map([["telegram", installedCatalogEntry]]), + installedCatalogById: new Map([["external-chat", installedCatalogEntry]]), installableCatalogById: new Map(), }); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( makePluginRegistry({ channels: [ { - pluginId: "telegram", + pluginId: "external-chat", source: "bundled", - plugin: telegramPlugin, + plugin: externalChatPlugin, }, ], }), ); - const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const next = await setupChannels( {} as never, @@ -478,8 +478,8 @@ describe("setupChannels workspace shadow exclusion", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ - channel: "telegram", - pluginId: "telegram", + channel: "external-chat", + pluginId: "external-chat", workspaceDir: "/tmp/openclaw-workspace", }), ); @@ -492,16 +492,16 @@ describe("setupChannels workspace shadow exclusion", () => { ); expect(next).toEqual({ channels: { - telegram: { token: "secret" }, + "external-chat": { token: "secret" }, }, }); }); it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => { const setupWizard = { - channel: "telegram", + channel: "external-chat", getStatus: vi.fn(async () => ({ - channel: "telegram", + channel: "external-chat", configured: true, statusLines: [], })), @@ -510,8 +510,8 @@ describe("setupChannels workspace shadow exclusion", () => { resolveChannelSetupEntries.mockReturnValue({ entries: [ { - id: "telegram", - meta: makeMeta("telegram", "Telegram"), + id: "external-chat", + meta: makeMeta("external-chat", "External Chat"), }, ], installedCatalogEntries: [], @@ -519,11 +519,11 @@ describe("setupChannels workspace shadow exclusion", () => { installedCatalogById: new Map(), installableCatalogById: new Map(), }); - const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const note = vi.fn(async () => undefined); const cfg = { channels: { - telegram: { enabled: false, token: "secret" }, + "external-chat": { enabled: false, token: "secret" }, }, }; @@ -544,13 +544,13 @@ describe("setupChannels workspace shadow exclusion", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - "telegram cannot be configured while disabled. Enable it before setup.", + "external-chat cannot be configured while disabled. Enable it before setup.", "Channel setup", ); expect(setupWizard.configure).not.toHaveBeenCalled(); expect(next).toEqual({ channels: { - telegram: { enabled: false, token: "secret" }, + "external-chat": { enabled: false, token: "secret" }, }, }); }); @@ -559,8 +559,8 @@ describe("setupChannels workspace shadow exclusion", () => { resolveChannelSetupEntries.mockReturnValue({ entries: [ { - id: "telegram", - meta: makeMeta("telegram", "Telegram"), + id: "external-chat", + meta: makeMeta("external-chat", "External Chat"), }, ], installedCatalogEntries: [], @@ -568,12 +568,12 @@ describe("setupChannels workspace shadow exclusion", () => { installedCatalogById: new Map(), installableCatalogById: new Map(), }); - const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); const note = vi.fn(async () => undefined); const cfg = { plugins: { enabled: false }, channels: { - telegram: { enabled: true, token: "secret" }, + "external-chat": { enabled: true, token: "secret" }, }, }; @@ -594,7 +594,7 @@ describe("setupChannels workspace shadow exclusion", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); expect(note).toHaveBeenCalledWith( - "telegram cannot be configured while plugins disabled. Enable it before setup.", + "external-chat cannot be configured while plugins disabled. Enable it before setup.", "Channel setup", ); }); diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts index 56cbdd234a9..0bf5294ae66 100644 --- a/src/routing/session-key.test.ts +++ b/src/routing/session-key.test.ts @@ -77,14 +77,20 @@ describe("deriveSessionChatTypeFromKey", () => { { key: "agent:main:discord:channel:c1", expected: "channel" }, { key: "agent:main:telegram:dm:123456", expected: "direct" }, { key: "telegram:dm:123456", expected: "direct" }, - { key: "discord:acc-1:guild-123:channel-456", expected: "channel" }, - { key: "12345-678@g.us", expected: "group" }, { key: "agent:main:main", expected: "unknown" }, { key: "agent:main", expected: "unknown" }, { key: "", expected: "unknown" }, ] as const)("derives chat type for %j => $expected", ({ key, expected }) => { expect(deriveSessionChatTypeFromKey(key)).toBe(expected); }); + + it("uses plugin-owned legacy chat-type hooks after generic token parsing", () => { + expect( + deriveSessionChatTypeFromKey("legacy-room:abc", [ + (sessionKey) => (sessionKey.startsWith("legacy-room:") ? "channel" : undefined), + ]), + ).toBe("channel"); + }); }); describe("thread session suffix parsing", () => { diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 36a2d14d037..480cf7602e2 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -5,6 +5,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; +import { deriveSessionChatType } from "./session-chat-type.js"; export type SessionSendPolicyDecision = "allow" | "deny"; @@ -63,17 +64,9 @@ function deriveChatTypeFromKey(key?: string): SessionChatType | undefined { if (tokens.has("direct") || tokens.has("dm")) { return "direct"; } - if (/^group:[^:]+$/u.test(normalizedKey)) { - return "group"; - } - if (/^[0-9]+(?:-[0-9]+)*@g\.us$/u.test(normalizedKey)) { - return "group"; - } - if (/^whatsapp:(?!.*:group:).+@g\.us$/u.test(normalizedKey)) { - return "group"; - } - if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/u.test(normalizedKey)) { - return "channel"; + const derived = deriveSessionChatType(normalizedKey); + if (derived !== "unknown") { + return derived; } return undefined; } diff --git a/src/sessions/session-chat-type-shared.ts b/src/sessions/session-chat-type-shared.ts index 535f872487f..8c232f1f516 100644 --- a/src/sessions/session-chat-type-shared.ts +++ b/src/sessions/session-chat-type-shared.ts @@ -9,15 +9,6 @@ function deriveBuiltInLegacySessionChatType( if (/^group:[^:]+$/.test(scopedSessionKey)) { return "group"; } - if (/^[0-9]+(?:-[0-9]+)*@g\.us$/.test(scopedSessionKey)) { - return "group"; - } - if (/^whatsapp:(?!.*:group:).+@g\.us$/.test(scopedSessionKey)) { - return "group"; - } - if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scopedSessionKey)) { - return "channel"; - } return undefined; } diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 71342a4ffcc..93616236f48 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -39,6 +39,31 @@ describe("delivery context helpers", () => { }, }, }, + { + pluginId: "thread-child-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ + id: "thread-child-chat", + label: "Thread child chat", + }), + messaging: { + resolveDeliveryTarget: ({ + conversationId, + parentConversationId, + }: { + conversationId: string; + parentConversationId?: string; + }) => { + const parent = parentConversationId?.trim(); + const child = conversationId.trim(); + return parent && parent !== child + ? { to: `channel:${parent}`, threadId: child } + : { to: `channel:${child}` }; + }, + }, + }, + }, ]), ); }); @@ -149,37 +174,15 @@ describe("delivery context helpers", () => { }); }); - it.each([ - { - channel: "slack", - conversationId: "1710000000.000100", - parentConversationId: "C123", - expected: { to: "channel:C123", threadId: "1710000000.000100" }, - }, - { - channel: "telegram", - conversationId: "42", - parentConversationId: "-10099", - expected: { to: "channel:-10099", threadId: "42" }, - }, - { - channel: "mattermost", - conversationId: "msg-child-id", - parentConversationId: "channel-parent-id", - expected: { to: "channel:channel-parent-id", threadId: "msg-child-id" }, - }, - ])( - "resolves parent-scoped thread delivery targets for $channel", - ({ channel, conversationId, parentConversationId, expected }) => { - expect( - resolveConversationDeliveryTarget({ - channel, - conversationId, - parentConversationId, - }), - ).toEqual(expected); - }, - ); + it("resolves parent-scoped thread delivery targets through channel messaging hooks", () => { + expect( + resolveConversationDeliveryTarget({ + channel: "thread-child-chat", + conversationId: "msg-child-id", + parentConversationId: "channel-parent-id", + }), + ).toEqual({ to: "channel:channel-parent-id", threadId: "msg-child-id" }); + }); it("derives delivery context from a session entry", () => { expect( diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 4c58a39a322..9b7cf90dca4 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -67,22 +67,6 @@ export function resolveConversationDeliveryTarget(params: { : typeof params.parentConversationId === "string" ? normalizeOptionalString(params.parentConversationId) : undefined; - const isThreadChild = - conversationId && parentConversationId && parentConversationId !== conversationId; - if (channel && isThreadChild) { - if (channel === "matrix") { - return { - to: `room:${parentConversationId}`, - threadId: conversationId, - }; - } - if (channel === "slack" || channel === "mattermost" || channel === "telegram") { - return { - to: `channel:${parentConversationId}`, - threadId: conversationId, - }; - } - } const pluginTarget = channel && conversationId ? getChannelPlugin( diff --git a/tsconfig.core.test.agents.json b/tsconfig.core.test.agents.json new file mode 100644 index 00000000000..e0ca1b761af --- /dev/null +++ b/tsconfig.core.test.agents.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.test.json", + "include": [ + "src/**/*.d.ts", + "src/agents/**/*.test.ts", + "src/agents/**/*.test.tsx", + "ui/**/*.d.ts", + "packages/**/*.d.ts" + ] +} diff --git a/tsconfig.core.test.non-agents.json b/tsconfig.core.test.non-agents.json new file mode 100644 index 00000000000..4c7f6cadd06 --- /dev/null +++ b/tsconfig.core.test.non-agents.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.core.test.json", + "exclude": [ + "node_modules", + "dist", + "**/dist/**", + "src/agents/**/*.test.ts", + "src/agents/**/*.test.tsx" + ] +}