From 03a43fe23157ecd391b2374a72db1ca29dbb3462 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 18:49:47 +0100 Subject: [PATCH] refactor(plugins): genericize core channel seams --- .../src/bot-message-dispatch.runtime.ts | 8 +- .../telegram/src/bot-message-dispatch.test.ts | 2 +- .../telegram/src/bot-message-dispatch.ts | 7 +- src/channels/thread-bindings-policy.test.ts | 50 +++++-- src/cli/deps.test.ts | 83 ++++++------ src/cli/outbound-send-mapping.ts | 11 +- src/cli/plugins-install-config.test.ts | 30 +++-- .../program/message/register.thread.test.ts | 66 +++++++-- src/config/commands.test.ts | 2 +- src/config/config-misc.test.ts | 7 +- src/config/config.acp-binding-cutover.test.ts | 125 +++--------------- .../thread-bindings-config-keys.test.ts | 38 ++---- src/hooks/message-hook-mappers.test.ts | 113 ++++++++++------ src/infra/exec-approval-surface.test.ts | 4 + src/infra/outbound/deliver.test-outbounds.ts | 3 + src/infra/outbound/send-deps.ts | 8 +- src/plugins/interactive.test.ts | 120 +++++++++++++++-- src/security/audit.test.ts | 6 +- src/utils/delivery-context.test.ts | 55 ++++++-- test/extension-test-boundary.test.ts | 1 + 20 files changed, 447 insertions(+), 292 deletions(-) diff --git a/extensions/telegram/src/bot-message-dispatch.runtime.ts b/extensions/telegram/src/bot-message-dispatch.runtime.ts index 93cc3a63bad..ca49e4f10e4 100644 --- a/extensions/telegram/src/bot-message-dispatch.runtime.ts +++ b/extensions/telegram/src/bot-message-dispatch.runtime.ts @@ -4,9 +4,9 @@ export { resolveSessionStoreEntry, resolveStorePath, } from "openclaw/plugin-sdk/config-runtime"; -export { getAgentScopedMediaLocalRoots } from "./telegram-media.runtime.js"; +export { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +export { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; export { - generateTopicLabel, + generateTelegramTopicLabel as generateTopicLabel, resolveAutoTopicLabelConfig, - resolveChunkMode, -} from "openclaw/plugin-sdk/reply-runtime"; +} from "./auto-topic-label.js"; diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index c3ddbda274f..70035d74198 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -1,10 +1,10 @@ import type { Bot } from "grammy"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveChunkMode as resolveChunkModeRuntime } from "../../../src/auto-reply/chunk.js"; -import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "../../../src/auto-reply/reply/auto-topic-label-config.js"; import { resolveMarkdownTableMode as resolveMarkdownTableModeRuntime } from "../../../src/config/markdown-tables.js"; import { resolveSessionStoreEntry as resolveSessionStoreEntryRuntime } from "../../../src/config/sessions/store.js"; import { getAgentScopedMediaLocalRoots as getAgentScopedMediaLocalRootsRuntime } from "../../../src/media/local-roots.js"; +import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label.js"; import type { TelegramBotDeps } from "./bot-deps.js"; import { createSequencedTestDraftStream, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index e2056ea1a31..93ab6f0c2ae 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -16,7 +16,6 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { generateTelegramTopicLabel, resolveAutoTopicLabelConfig } from "./auto-topic-label.js"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -27,14 +26,14 @@ import { resolveDefaultModelForAgent, } from "./bot-message-dispatch.agent.runtime.js"; import { + generateTopicLabel, loadSessionStore, resolveMarkdownTableMode, resolveSessionStoreEntry, resolveStorePath, getAgentScopedMediaLocalRoots, - resolveChunkMode, resolveAutoTopicLabelConfig, - generateTopicLabel, + resolveChunkMode, } from "./bot-message-dispatch.runtime.js"; import type { TelegramBotOptions } from "./bot.js"; import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; @@ -943,7 +942,7 @@ export const dispatchTelegramMessage = async ({ const topicThreadId = threadSpec.id!; void (async () => { try { - const label = await generateTelegramTopicLabel({ + const label = await generateTopicLabel({ userMessage, prompt: autoTopicConfig.prompt, cfg, diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index 6dad6cf5cd5..c58a065e764 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { requiresNativeThreadContextForThreadHere, resolveThreadBindingPlacementForCurrentContext, @@ -6,39 +8,61 @@ import { } from "./thread-bindings-policy.js"; describe("thread binding spawn policy helpers", () => { - it("treats Discord and Matrix as automatic child-thread spawn channels", () => { - expect(supportsAutomaticThreadBindingSpawn("discord")).toBe(true); - expect(supportsAutomaticThreadBindingSpawn("matrix")).toBe(true); - expect(supportsAutomaticThreadBindingSpawn("telegram")).toBe(false); + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "child-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "child-chat", label: "Child chat" }), + conversationBindings: { defaultTopLevelPlacement: "child" }, + }, + }, + { + pluginId: "current-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "current-chat", label: "Current chat" }), + conversationBindings: { defaultTopLevelPlacement: "current" }, + }, + }, + ]), + ); + }); + + it("treats child-placement channels as automatic child-thread spawn channels", () => { + expect(supportsAutomaticThreadBindingSpawn("child-chat")).toBe(true); + expect(supportsAutomaticThreadBindingSpawn("current-chat")).toBe(false); + expect(supportsAutomaticThreadBindingSpawn("unknown-chat")).toBe(false); }); it("allows thread-here on threadless conversation channels without a native thread id", () => { - expect(requiresNativeThreadContextForThreadHere("telegram")).toBe(false); - expect(requiresNativeThreadContextForThreadHere("feishu")).toBe(false); - expect(requiresNativeThreadContextForThreadHere("line")).toBe(false); - expect(requiresNativeThreadContextForThreadHere("discord")).toBe(true); + expect(requiresNativeThreadContextForThreadHere("current-chat")).toBe(false); + expect(requiresNativeThreadContextForThreadHere("unknown-chat")).toBe(false); + expect(requiresNativeThreadContextForThreadHere("child-chat")).toBe(true); }); it("resolves current vs child placement from the current channel context", () => { expect( resolveThreadBindingPlacementForCurrentContext({ - channel: "discord", + channel: "child-chat", }), ).toBe("child"); expect( resolveThreadBindingPlacementForCurrentContext({ - channel: "discord", + channel: "child-chat", threadId: "thread-1", }), ).toBe("current"); expect( resolveThreadBindingPlacementForCurrentContext({ - channel: "telegram", + channel: "current-chat", }), ).toBe("current"); expect( resolveThreadBindingPlacementForCurrentContext({ - channel: "line", + channel: "unknown-chat", }), ).toBe("current"); }); diff --git a/src/cli/deps.test.ts b/src/cli/deps.test.ts index 12e086d168c..abfd05fb07b 100644 --- a/src/cli/deps.test.ts +++ b/src/cli/deps.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../test/helpers/import-fresh.ts"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; -const moduleLoads = vi.hoisted(() => ({ +const runtimeFactories = vi.hoisted(() => ({ whatsapp: vi.fn(), telegram: vi.fn(), discord: vi.fn(), @@ -19,35 +20,27 @@ const sendFns = vi.hoisted(() => ({ imessage: vi.fn(async () => ({ messageId: "i1", chatId: "imessage:1" })), })); -vi.mock("./send-runtime/whatsapp.js", () => { - moduleLoads.whatsapp(); - return { runtimeSend: { sendMessage: sendFns.whatsapp } }; -}); +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => + ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"].map( + (id) => + ({ + id, + meta: { label: id, selectionLabel: id, docsPath: `/channels/${id}`, blurb: "" }, + }) as ChannelPlugin, + ), +})); -vi.mock("./send-runtime/telegram.js", () => { - moduleLoads.telegram(); - return { runtimeSend: { sendMessage: sendFns.telegram } }; -}); - -vi.mock("./send-runtime/discord.js", () => { - moduleLoads.discord(); - return { runtimeSend: { sendMessage: sendFns.discord } }; -}); - -vi.mock("./send-runtime/slack.js", () => { - moduleLoads.slack(); - return { runtimeSend: { sendMessage: sendFns.slack } }; -}); - -vi.mock("./send-runtime/signal.js", () => { - moduleLoads.signal(); - return { runtimeSend: { sendMessage: sendFns.signal } }; -}); - -vi.mock("./send-runtime/imessage.js", () => { - moduleLoads.imessage(); - return { runtimeSend: { sendMessage: sendFns.imessage } }; -}); +vi.mock("./send-runtime/channel-outbound-send.js", () => ({ + createChannelOutboundRuntimeSend: ({ + channelId, + }: { + channelId: keyof typeof runtimeFactories; + }) => { + runtimeFactories[channelId](); + return { sendMessage: sendFns[channelId] }; + }, +})); describe("createDefaultDeps", () => { async function loadCreateDefaultDeps(scope: string) { @@ -59,13 +52,13 @@ describe("createDefaultDeps", () => { ).createDefaultDeps; } - function expectUnusedModulesNotLoaded(exclude: keyof typeof moduleLoads): void { - const keys = Object.keys(moduleLoads) as Array; + function expectUnusedRuntimeFactoriesNotLoaded(exclude: keyof typeof runtimeFactories): void { + const keys = Object.keys(runtimeFactories) as Array; for (const key of keys) { if (key === exclude) { continue; } - expect(moduleLoads[key]).not.toHaveBeenCalled(); + expect(runtimeFactories[key]).not.toHaveBeenCalled(); } } @@ -73,34 +66,34 @@ describe("createDefaultDeps", () => { vi.clearAllMocks(); }); - it("does not load provider modules until a dependency is used", async () => { + it("does not build runtime send surfaces until a dependency is used", async () => { const createDefaultDeps = await loadCreateDefaultDeps("lazy-load"); const deps = createDefaultDeps(); - expect(moduleLoads.whatsapp).not.toHaveBeenCalled(); - expect(moduleLoads.telegram).not.toHaveBeenCalled(); - expect(moduleLoads.discord).not.toHaveBeenCalled(); - expect(moduleLoads.slack).not.toHaveBeenCalled(); - expect(moduleLoads.signal).not.toHaveBeenCalled(); - expect(moduleLoads.imessage).not.toHaveBeenCalled(); + expect(runtimeFactories.whatsapp).not.toHaveBeenCalled(); + expect(runtimeFactories.telegram).not.toHaveBeenCalled(); + expect(runtimeFactories.discord).not.toHaveBeenCalled(); + expect(runtimeFactories.slack).not.toHaveBeenCalled(); + expect(runtimeFactories.signal).not.toHaveBeenCalled(); + expect(runtimeFactories.imessage).not.toHaveBeenCalled(); - const sendTelegram = deps["telegram"] as (...args: unknown[]) => Promise; + const sendTelegram = deps.telegram as (...args: unknown[]) => Promise; await sendTelegram("chat", "hello", { verbose: false }); - expect(moduleLoads.telegram).toHaveBeenCalledTimes(1); + expect(runtimeFactories.telegram).toHaveBeenCalledTimes(1); expect(sendFns.telegram).toHaveBeenCalledTimes(1); - expectUnusedModulesNotLoaded("telegram"); + expectUnusedRuntimeFactoriesNotLoaded("telegram"); }); - it("reuses module cache after first dynamic import", async () => { + it("reuses cached runtime send surfaces after first lazy load", async () => { const createDefaultDeps = await loadCreateDefaultDeps("module-cache"); const deps = createDefaultDeps(); - const sendDiscord = deps["discord"] as (...args: unknown[]) => Promise; + const sendDiscord = deps.discord as (...args: unknown[]) => Promise; await sendDiscord("channel", "first", { verbose: false }); await sendDiscord("channel", "second", { verbose: false }); - expect(moduleLoads.discord).toHaveBeenCalledTimes(1); + expect(runtimeFactories.discord).toHaveBeenCalledTimes(1); expect(sendFns.discord).toHaveBeenCalledTimes(2); }); }); diff --git a/src/cli/outbound-send-mapping.ts b/src/cli/outbound-send-mapping.ts index 40546107c75..0c2e5fe18a7 100644 --- a/src/cli/outbound-send-mapping.ts +++ b/src/cli/outbound-send-mapping.ts @@ -31,8 +31,15 @@ function resolveLegacyDepKeysForChannel(channelId: string): string[] { return []; } const pascal = compact.charAt(0).toUpperCase() + compact.slice(1); - const keys = new Set([`send${pascal}`]); - if (pascal.startsWith("I") && pascal.length > 1) { + const keys = new Set(); + if (compact === "whatsapp") { + keys.add("sendWhatsApp"); + } else if (compact === "imessage") { + keys.add("sendIMessage"); + } else { + keys.add(`send${pascal}`); + } + if (compact !== "imessage" && pascal.startsWith("I") && pascal.length > 1) { keys.add(`sendI${pascal.slice(1)}`); } if (pascal.startsWith("Ms") && pascal.length > 2) { diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index bd1404902dd..d3bc69c3124 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { bundledPluginRootAt, repoInstallSpec } from "../../test/helpers/bundled-plugin-paths.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ConfigFileSnapshot } from "../config/types.openclaw.js"; +import { resolvePluginInstallRequestContext } from "./plugin-install-config-policy.js"; import { loadConfigForInstall } from "./plugins-install-command.js"; const hoisted = vi.hoisted(() => ({ @@ -47,10 +48,13 @@ function makeSnapshot(overrides: Partial = {}): ConfigFileSn } describe("loadConfigForInstall", () => { - const matrixNpmRequest = { - rawSpec: "@openclaw/matrix", - normalizedSpec: "@openclaw/matrix", - }; + const matrixNpmRequest = (() => { + const resolved = resolvePluginInstallRequestContext({ rawSpec: "@openclaw/matrix" }); + if (!resolved.ok) { + throw new Error(resolved.error); + } + return resolved.request; + })(); beforeEach(() => { loadConfigMock.mockReset(); @@ -83,7 +87,7 @@ describe("loadConfigForInstall", () => { expect(result).toBe(cfg); }); - it("falls back to snapshot config for explicit Matrix reinstall when issues match the known upgrade failure", async () => { + it("falls back to snapshot config for explicit bundled-plugin reinstall when issues match the known upgrade failure", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; loadConfigMock.mockImplementation(() => { @@ -110,7 +114,7 @@ describe("loadConfigForInstall", () => { expect(result).toBe(snapshotCfg); }); - it("allows explicit repo-checkout Matrix reinstall recovery", async () => { + it("allows explicit repo-checkout bundled-plugin reinstall recovery", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; loadConfigMock.mockImplementation(() => { @@ -125,15 +129,21 @@ describe("loadConfigForInstall", () => { }), ); - const result = await loadConfigForInstall({ + const repoRequest = resolvePluginInstallRequestContext({ rawSpec: MATRIX_REPO_INSTALL_SPEC, - normalizedSpec: MATRIX_REPO_INSTALL_SPEC, + }); + if (!repoRequest.ok) { + throw new Error(repoRequest.error); + } + + const result = await loadConfigForInstall({ + ...repoRequest.request, resolvedPath: bundledPluginRootAt("/tmp/repo", "matrix"), }); expect(result).toBe(snapshotCfg); }); - it("rejects unrelated invalid config even during Matrix reinstall", async () => { + it("rejects unrelated invalid config even during bundled-plugin reinstall recovery", async () => { const invalidConfigErr = new Error("config invalid"); (invalidConfigErr as { code?: string }).code = "INVALID_CONFIG"; loadConfigMock.mockImplementation(() => { @@ -147,7 +157,7 @@ describe("loadConfigForInstall", () => { ); await expect(loadConfigForInstall(matrixNpmRequest)).rejects.toThrow( - "Config invalid outside the Matrix upgrade recovery path", + "Config invalid outside the bundled recovery path for matrix", ); }); diff --git a/src/cli/program/message/register.thread.test.ts b/src/cli/program/message/register.thread.test.ts index c7fd3409c9b..5cc3ffd564b 100644 --- a/src/cli/program/message/register.thread.test.ts +++ b/src/cli/program/message/register.thread.test.ts @@ -1,5 +1,10 @@ import { Command } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setActivePluginRegistry } from "../../../plugins/runtime.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../../test-utils/channel-plugins.js"; import type { MessageCliHelpers } from "./helpers.js"; import { registerMessageThreadCommands } from "./register.thread.js"; @@ -18,10 +23,47 @@ describe("registerMessageThreadCommands", () => { ); beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "topic-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "topic-chat", label: "Topic chat" }), + actions: { + resolveCliActionRequest: ({ + action, + args, + }: { + action: string; + args: Record; + }) => { + if (action !== "thread-create") { + return null; + } + const { threadName, ...rest } = args; + return { + action: "topic-create", + args: { + ...rest, + name: threadName, + }, + }; + }, + }, + }, + }, + { + pluginId: "plain-chat", + source: "test", + plugin: createChannelTestPluginBase({ id: "plain-chat", label: "Plain chat" }), + }, + ]), + ); runMessageAction.mockClear(); }); - it("routes Telegram thread create to topic-create with Telegram params", async () => { + it("routes plugin-remapped thread create actions through channel hooks", async () => { const message = new Command().exitOverride(); registerMessageThreadCommands(message, createHelpers(runMessageAction)); @@ -30,9 +72,9 @@ describe("registerMessageThreadCommands", () => { "thread", "create", "--channel", - " Telegram ", + " topic-chat ", "-t", - "-1001234567890", + "room-1", "--thread-name", "Build Updates", "-m", @@ -44,17 +86,17 @@ describe("registerMessageThreadCommands", () => { expect(runMessageAction).toHaveBeenCalledWith( "topic-create", expect.objectContaining({ - channel: " Telegram ", - target: "-1001234567890", + channel: " topic-chat ", + target: "room-1", name: "Build Updates", message: "hello", }), ); - const telegramCall = runMessageAction.mock.calls.at(0); - expect(telegramCall?.[1]).not.toHaveProperty("threadName"); + const remappedCall = runMessageAction.mock.calls.at(0); + expect(remappedCall?.[1]).not.toHaveProperty("threadName"); }); - it("keeps non-Telegram thread create on thread-create params", async () => { + it("keeps default thread create params when the channel does not remap the action", async () => { const message = new Command().exitOverride(); registerMessageThreadCommands(message, createHelpers(runMessageAction)); @@ -63,7 +105,7 @@ describe("registerMessageThreadCommands", () => { "thread", "create", "--channel", - "discord", + "plain-chat", "-t", "channel:123", "--thread-name", @@ -77,13 +119,13 @@ describe("registerMessageThreadCommands", () => { expect(runMessageAction).toHaveBeenCalledWith( "thread-create", expect.objectContaining({ - channel: "discord", + channel: "plain-chat", target: "channel:123", threadName: "Build Updates", message: "hello", }), ); - const discordCall = runMessageAction.mock.calls.at(0); - expect(discordCall?.[1]).not.toHaveProperty("name"); + const defaultCall = runMessageAction.mock.calls.at(0); + expect(defaultCall?.[1]).not.toHaveProperty("name"); }); }); diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index 2b9b9c432b1..701730d197c 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { setDefaultChannelPluginRegistryForTests } from "../commands/channel-test-helpers.js"; +import { setDefaultChannelPluginRegistryForTests } from "../commands/channel-test-registry.js"; import { isCommandFlagEnabled, isRestartEnabled, diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 4ba8fcb4f64..a2b580ab326 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -714,12 +714,7 @@ describe("config strict validation", () => { expect(snap.valid).toBe(true); expect(snap.legacyIssues.some((issue) => issue.path === "session.threadBindings")).toBe(true); - expect( - snap.legacyIssues.some((issue) => issue.path === "channels.discord.threadBindings"), - ).toBe(true); - expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe( - true, - ); + expect(snap.legacyIssues.some((issue) => issue.path === "channels")).toBe(true); expect(snap.sourceConfig.session?.threadBindings).toMatchObject({ idleHours: 24, }); diff --git a/src/config/config.acp-binding-cutover.test.ts b/src/config/config.acp-binding-cutover.test.ts index c1b2944bdd0..d0894aacbf5 100644 --- a/src/config/config.acp-binding-cutover.test.ts +++ b/src/config/config.acp-binding-cutover.test.ts @@ -25,13 +25,13 @@ describe("ACP binding cutover schema", () => { { type: "route", agentId: "main", - match: { channel: "discord", accountId: "default" }, + match: { channel: "chat-a", accountId: "default" }, }, { type: "acp", agentId: "coding", match: { - channel: "discord", + channel: "chat-a", accountId: "default", peer: { kind: "channel", id: "1478836151241412759" }, }, @@ -101,7 +101,7 @@ describe("ACP binding cutover schema", () => { { type: "acp", agentId: "codex", - match: { channel: "discord", accountId: "default" }, + match: { channel: "chat-a", accountId: "default" }, }, ], }); @@ -109,14 +109,14 @@ describe("ACP binding cutover schema", () => { expect(parsed.success).toBe(false); }); - it("rejects ACP bindings on unsupported channels", () => { + it("accepts ACP bindings for arbitrary channel ids when the peer target is explicit", () => { const parsed = OpenClawSchema.safeParse({ bindings: [ { type: "acp", agentId: "codex", match: { - channel: "slack", + channel: "plugin-chat", accountId: "default", peer: { kind: "channel", id: "C123456" }, }, @@ -124,132 +124,51 @@ describe("ACP binding cutover schema", () => { ], }); - expect(parsed.success).toBe(false); - }); - - it("rejects non-canonical Telegram ACP topic peer IDs", () => { - const parsed = OpenClawSchema.safeParse({ - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "telegram", - accountId: "default", - peer: { kind: "group", id: "42" }, - }, - }, - ], - }); - - expect(parsed.success).toBe(false); - }); - - it("accepts canonical Feishu ACP DM and topic peer IDs", () => { - const parsed = OpenClawSchema.safeParse({ - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "feishu", - accountId: "default", - peer: { kind: "direct", id: "ou_user_123" }, - }, - }, - { - type: "acp", - agentId: "codex", - match: { - channel: "feishu", - accountId: "default", - peer: { kind: "direct", id: "user_123" }, - }, - }, - { - type: "acp", - agentId: "codex", - match: { - channel: "feishu", - accountId: "default", - peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root:sender:ou_user_123" }, - }, - }, - ], - }); - expect(parsed.success).toBe(true); }); - it("rejects non-canonical Feishu ACP peer IDs", () => { + it("accepts ACP bindings for generic direct and group peer kinds", () => { const parsed = OpenClawSchema.safeParse({ bindings: [ { type: "acp", agentId: "codex", match: { - channel: "feishu", + channel: "plugin-chat", accountId: "default", - peer: { kind: "group", id: "oc_group_chat:sender:ou_user_123" }, + peer: { kind: "direct", id: "peer-42" }, + }, + }, + { + type: "acp", + agentId: "codex", + match: { + channel: "plugin-chat", + accountId: "default", + peer: { kind: "group", id: "group-42" }, }, }, ], }); - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); }); - it("rejects Feishu ACP DM peer IDs keyed by union id", () => { + it("accepts deprecated dm peer kind for backward compatibility", () => { const parsed = OpenClawSchema.safeParse({ bindings: [ { type: "acp", agentId: "codex", match: { - channel: "feishu", + channel: "plugin-chat", accountId: "default", - peer: { kind: "direct", id: "on_union_user_123" }, + peer: { kind: "dm", id: "legacy-peer" }, }, }, ], }); - expect(parsed.success).toBe(false); - }); - - it("rejects Feishu ACP topic peer IDs with non-canonical sender ids", () => { - const parsed = OpenClawSchema.safeParse({ - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "feishu", - accountId: "default", - peer: { kind: "group", id: "oc_group_chat:topic:om_topic_root:sender:user_123" }, - }, - }, - ], - }); - - expect(parsed.success).toBe(false); - }); - - it("rejects bare Feishu group chat ACP peer IDs", () => { - const parsed = OpenClawSchema.safeParse({ - bindings: [ - { - type: "acp", - agentId: "codex", - match: { - channel: "feishu", - accountId: "default", - peer: { kind: "group", id: "oc_group_chat" }, - }, - }, - ], - }); - - expect(parsed.success).toBe(false); + expect(parsed.success).toBe(true); }); }); diff --git a/src/config/thread-bindings-config-keys.test.ts b/src/config/thread-bindings-config-keys.test.ts index 3733017ecac..d454636acc8 100644 --- a/src/config/thread-bindings-config-keys.test.ts +++ b/src/config/thread-bindings-config-keys.test.ts @@ -24,10 +24,10 @@ describe("thread binding config keys", () => { ); }); - it("rejects legacy channels.discord.threadBindings.ttlHours", () => { + it("rejects legacy channels..threadBindings.ttlHours", () => { const result = validateConfigObjectRaw({ channels: { - discord: { + demo: { threadBindings: { ttlHours: 24, }, @@ -41,16 +41,16 @@ describe("thread binding config keys", () => { } expect(result.issues).toContainEqual( expect.objectContaining({ - path: "channels.discord.threadBindings", + path: "channels", message: expect.stringContaining("ttlHours"), }), ); }); - it("rejects legacy channels.discord.accounts..threadBindings.ttlHours", () => { + it("rejects legacy channels..accounts..threadBindings.ttlHours", () => { const result = validateConfigObjectRaw({ channels: { - discord: { + demo: { accounts: { alpha: { threadBindings: { @@ -68,7 +68,7 @@ describe("thread binding config keys", () => { } expect(result.issues).toContainEqual( expect.objectContaining({ - path: "channels.discord.accounts", + path: "channels", message: expect.stringContaining("ttlHours"), }), ); @@ -93,10 +93,10 @@ describe("thread binding config keys", () => { ); }); - it("migrates Discord threadBindings.ttlHours for root and account entries", () => { + it("migrates channel threadBindings.ttlHours for root and account entries", () => { const result = migrateLegacyConfig({ channels: { - discord: { + demo: { threadBindings: { ttlHours: 12, }, @@ -117,30 +117,16 @@ describe("thread binding config keys", () => { }, }); - const discord = result.config?.channels?.discord; - expect(discord?.threadBindings?.idleHours).toBe(12); - expect( - (discord?.threadBindings as Record | undefined)?.ttlHours, - ).toBeUndefined(); - - expect(discord?.accounts?.alpha?.threadBindings?.idleHours).toBe(6); - expect( - (discord?.accounts?.alpha?.threadBindings as Record | undefined)?.ttlHours, - ).toBeUndefined(); - - expect(discord?.accounts?.beta?.threadBindings?.idleHours).toBe(4); - expect( - (discord?.accounts?.beta?.threadBindings as Record | undefined)?.ttlHours, - ).toBeUndefined(); + expect(result.config).toBeNull(); expect(result.changes).toContain( - "Moved channels.discord.threadBindings.ttlHours → channels.discord.threadBindings.idleHours.", + "Moved channels.demo.threadBindings.ttlHours → channels.demo.threadBindings.idleHours.", ); expect(result.changes).toContain( - "Moved channels.discord.accounts.alpha.threadBindings.ttlHours → channels.discord.accounts.alpha.threadBindings.idleHours.", + "Moved channels.demo.accounts.alpha.threadBindings.ttlHours → channels.demo.accounts.alpha.threadBindings.idleHours.", ); expect(result.changes).toContain( - "Removed channels.discord.accounts.beta.threadBindings.ttlHours (channels.discord.accounts.beta.threadBindings.idleHours already set).", + "Removed channels.demo.accounts.beta.threadBindings.ttlHours (channels.demo.accounts.beta.threadBindings.idleHours already set).", ); }); }); diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index 7bdf8f1048c..d4cf8f5ebf5 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildCanonicalSentMessageHookContext, deriveInboundMessageHookContext, @@ -17,18 +19,18 @@ import { function makeInboundCtx(overrides: Partial = {}): FinalizedMsgContext { return { - From: "telegram:user:123", - To: "telegram:chat:456", + From: "demo-chat:user:123", + To: "demo-chat:chat:456", Body: "body", BodyForAgent: "body-for-agent", BodyForCommands: "commands-body", RawBody: "raw-body", Transcript: "hello transcript", Timestamp: 1710000000, - Provider: "telegram", - Surface: "telegram", - OriginatingChannel: "telegram", - OriginatingTo: "telegram:chat:456", + Provider: "demo-chat", + Surface: "demo-chat", + OriginatingChannel: "demo-chat", + OriginatingTo: "demo-chat:chat:456", AccountId: "acc-1", MessageSid: "msg-1", SenderId: "sender-1", @@ -46,15 +48,50 @@ function makeInboundCtx(overrides: Partial = {}): Finalized } describe("message hook mappers", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "claim-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "claim-chat", label: "Claim chat" }), + messaging: { + resolveInboundConversation: ({ + from, + to, + isGroup, + }: { + from?: string; + to?: string; + isGroup?: boolean; + }) => { + const normalizedTo = to?.replace(/^channel:/i, "").trim(); + const normalizedFrom = from?.replace(/^claim-chat:/i, "").trim(); + if (isGroup && normalizedTo) { + return { conversationId: `channel:${normalizedTo}` }; + } + if (normalizedFrom) { + return { conversationId: `user:${normalizedFrom}` }; + } + return null; + }, + }, + }, + }, + ]), + ); + }); + it("derives canonical inbound context with body precedence and group metadata", () => { const canonical = deriveInboundMessageHookContext(makeInboundCtx()); expect(canonical.content).toBe("commands-body"); - expect(canonical.channelId).toBe("telegram"); - expect(canonical.conversationId).toBe("telegram:chat:456"); + expect(canonical.channelId).toBe("demo-chat"); + expect(canonical.conversationId).toBe("demo-chat:chat:456"); expect(canonical.messageId).toBe("msg-1"); expect(canonical.isGroup).toBe(true); - expect(canonical.groupId).toBe("telegram:chat:456"); + expect(canonical.groupId).toBe("demo-chat:chat:456"); expect(canonical.guildId).toBe("guild-1"); }); @@ -98,12 +135,12 @@ describe("message hook mappers", () => { const canonical = deriveInboundMessageHookContext(makeInboundCtx()); expect(toPluginMessageContext(canonical)).toEqual({ - channelId: "telegram", + channelId: "demo-chat", accountId: "acc-1", - conversationId: "telegram:chat:456", + conversationId: "demo-chat:chat:456", }); expect(toPluginMessageReceivedEvent(canonical)).toEqual({ - from: "telegram:user:123", + from: "demo-chat:user:123", content: "commands-body", timestamp: 1710000000, metadata: expect.objectContaining({ @@ -113,12 +150,12 @@ describe("message hook mappers", () => { }), }); expect(toInternalMessageReceivedContext(canonical)).toEqual({ - from: "telegram:user:123", + from: "demo-chat:user:123", content: "commands-body", timestamp: 1710000000, - channelId: "telegram", + channelId: "demo-chat", accountId: "acc-1", - conversationId: "telegram:chat:456", + conversationId: "demo-chat:chat:456", messageId: "msg-1", metadata: expect.objectContaining({ senderUsername: "userone", @@ -127,12 +164,12 @@ describe("message hook mappers", () => { }); }); - it("normalizes Discord channel targets for inbound claim contexts", () => { + it("uses channel plugin claim resolvers for grouped conversations", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", + Provider: "claim-chat", + Surface: "claim-chat", + OriginatingChannel: "claim-chat", To: "channel:123456789012345678", OriginatingTo: "channel:123456789012345678", GroupChannel: "general", @@ -141,7 +178,7 @@ describe("message hook mappers", () => { ); expect(toPluginInboundClaimContext(canonical)).toEqual({ - channelId: "discord", + channelId: "claim-chat", accountId: "acc-1", conversationId: "channel:123456789012345678", parentConversationId: undefined, @@ -150,13 +187,13 @@ describe("message hook mappers", () => { }); }); - it("normalizes Discord DM targets for inbound claim contexts", () => { + it("uses channel plugin claim resolvers for direct-message conversations", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", - From: "discord:1177378744822943744", + Provider: "claim-chat", + Surface: "claim-chat", + OriginatingChannel: "claim-chat", + From: "claim-chat:1177378744822943744", To: "channel:1480574946919846079", OriginatingTo: "channel:1480574946919846079", GroupChannel: undefined, @@ -165,7 +202,7 @@ describe("message hook mappers", () => { ); expect(toPluginInboundClaimContext(canonical)).toEqual({ - channelId: "discord", + channelId: "claim-chat", accountId: "acc-1", conversationId: "user:1177378744822943744", parentConversationId: undefined, @@ -185,45 +222,45 @@ describe("message hook mappers", () => { const preprocessed = toInternalMessagePreprocessedContext(canonical, cfg); expect(preprocessed.transcript).toBeUndefined(); expect(preprocessed.isGroup).toBe(true); - expect(preprocessed.groupId).toBe("telegram:chat:456"); + expect(preprocessed.groupId).toBe("demo-chat:chat:456"); expect(preprocessed.cfg).toBe(cfg); }); it("maps sent context consistently for plugin/internal hooks", () => { const canonical = buildCanonicalSentMessageHookContext({ - to: "telegram:chat:456", + to: "demo-chat:chat:456", content: "reply", success: false, error: "network error", - channelId: "telegram", + channelId: "demo-chat", accountId: "acc-1", messageId: "out-1", isGroup: true, - groupId: "telegram:chat:456", + groupId: "demo-chat:chat:456", }); expect(toPluginMessageContext(canonical)).toEqual({ - channelId: "telegram", + channelId: "demo-chat", accountId: "acc-1", - conversationId: "telegram:chat:456", + conversationId: "demo-chat:chat:456", }); expect(toPluginMessageSentEvent(canonical)).toEqual({ - to: "telegram:chat:456", + to: "demo-chat:chat:456", content: "reply", success: false, error: "network error", }); expect(toInternalMessageSentContext(canonical)).toEqual({ - to: "telegram:chat:456", + to: "demo-chat:chat:456", content: "reply", success: false, error: "network error", - channelId: "telegram", + channelId: "demo-chat", accountId: "acc-1", - conversationId: "telegram:chat:456", + conversationId: "demo-chat:chat:456", messageId: "out-1", isGroup: true, - groupId: "telegram:chat:456", + groupId: "demo-chat:chat:456", }); }); }); diff --git a/src/infra/exec-approval-surface.test.ts b/src/infra/exec-approval-surface.test.ts index fafbb05571c..3a1e32a48ef 100644 --- a/src/infra/exec-approval-surface.test.ts +++ b/src/infra/exec-approval-surface.test.ts @@ -89,12 +89,14 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { getChannelPluginMock.mockImplementation((channel: string) => channel === "telegram" ? { + meta: { label: "Telegram" }, auth: { getActionAvailabilityState: () => ({ kind: "enabled" }), }, } : channel === "discord" ? { + meta: { label: "Discord" }, auth: { getActionAvailabilityState: () => ({ kind: "disabled" }), }, @@ -131,6 +133,7 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { it("reads approval availability from approvalCapability when auth is omitted", () => { getChannelPluginMock.mockReturnValue({ + meta: { label: "Discord" }, approvalCapability: { getActionAvailabilityState: () => ({ kind: "disabled" }), }, @@ -154,6 +157,7 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => { getChannelPluginMock.mockImplementation((channel: string) => channel === "telegram" ? { + meta: { label: "Telegram" }, auth: { getActionAvailabilityState: () => ({ kind: "disabled" }), }, diff --git a/src/infra/outbound/deliver.test-outbounds.ts b/src/infra/outbound/deliver.test-outbounds.ts index f6f099e53fe..e0a2929dd33 100644 --- a/src/infra/outbound/deliver.test-outbounds.ts +++ b/src/infra/outbound/deliver.test-outbounds.ts @@ -1,6 +1,7 @@ import { chunkMarkdownTextWithMode, chunkText } from "../../auto-reply/chunk.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { sanitizeForPlainText } from "../../plugin-sdk/outbound-runtime.js"; import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js"; type SignalSendFn = ( @@ -57,6 +58,7 @@ function withSignalChannel(result: Awaited>) { export const signalOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeForPlainText(text), sendFormattedText: async ({ cfg, to, text, accountId, deps, abortSignal }) => { const send = resolveSignalSender(deps); const maxBytes = resolveSignalMaxBytes(cfg, accountId ?? undefined); @@ -169,6 +171,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { chunker: chunkText, chunkerMode: "text", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeForPlainText(text), sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { const send = resolveWhatsAppSender(deps); return withWhatsAppChannel( diff --git a/src/infra/outbound/send-deps.ts b/src/infra/outbound/send-deps.ts index 19a53190f7a..925d82b02e9 100644 --- a/src/infra/outbound/send-deps.ts +++ b/src/infra/outbound/send-deps.ts @@ -11,11 +11,15 @@ function resolveLegacyDepKeysForChannel(channelId: string): string[] { return []; } const pascal = compact.charAt(0).toUpperCase() + compact.slice(1); - const keys = new Set([`send${pascal}`]); + const keys = new Set(); if (compact === "whatsapp") { keys.add("sendWhatsApp"); + } else if (compact === "imessage") { + keys.add("sendIMessage"); + } else { + keys.add(`send${pascal}`); } - if (pascal.startsWith("I") && pascal.length > 1) { + if (compact !== "imessage" && pascal.startsWith("I") && pascal.length > 1) { keys.add(`sendI${pascal.slice(1)}`); } if (pascal.startsWith("Ms") && pascal.length > 2) { diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 09e883015e4..f13dd916320 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,16 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; -import type { - DiscordInteractiveHandlerContext, - DiscordInteractiveHandlerRegistration, -} from "../../extensions/discord/src/interactive-dispatch.js"; -import type { - SlackInteractiveHandlerContext, - SlackInteractiveHandlerRegistration, -} from "../../extensions/slack/src/interactive-dispatch.js"; -import type { - TelegramInteractiveHandlerContext, - TelegramInteractiveHandlerRegistration, -} from "../../extensions/telegram/src/interactive-dispatch.js"; import * as conversationBinding from "./conversation-binding.js"; import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js"; import { @@ -18,6 +6,114 @@ import { dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "./interactive.js"; +import type { PluginInteractiveRegistration } from "./types.js"; + +type TelegramInteractiveHandlerContext = { + accountId: string; + callbackId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + senderUsername?: string; + threadId?: string | number; + isGroup?: boolean; + isForum?: boolean; + auth?: { isAuthorizedSender?: boolean }; + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; + callback: { data: string }; + channel: string; + requestConversationBinding: (...args: unknown[]) => Promise; + detachConversationBinding: (...args: unknown[]) => Promise; + getCurrentConversationBinding: (...args: unknown[]) => Promise; + respond: { + reply: (...args: unknown[]) => Promise; + editMessage: (...args: unknown[]) => Promise; + editButtons: (...args: unknown[]) => Promise; + clearButtons: (...args: unknown[]) => Promise; + deleteMessage: (...args: unknown[]) => Promise; + }; +}; + +type DiscordInteractiveHandlerContext = { + accountId: string; + interactionId: string; + conversationId: string; + parentConversationId?: string; + guildId?: string; + senderId?: string; + senderUsername?: string; + auth?: { isAuthorizedSender?: boolean }; + interaction: { + kind: string; + messageId?: string; + values?: string[]; + namespace?: string; + payload?: string; + }; + channel: string; + requestConversationBinding: (...args: unknown[]) => Promise; + detachConversationBinding: (...args: unknown[]) => Promise; + getCurrentConversationBinding: (...args: unknown[]) => Promise; + respond: { + acknowledge: (...args: unknown[]) => Promise; + reply: (...args: unknown[]) => Promise; + followUp: (...args: unknown[]) => Promise; + editMessage: (...args: unknown[]) => Promise; + clearComponents: (...args: unknown[]) => Promise; + }; +}; + +type SlackInteractiveHandlerContext = { + accountId: string; + interactionId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string; + senderId?: string; + senderUsername?: string; + auth?: { isAuthorizedSender?: boolean }; + interaction: { + kind: string; + actionId?: string; + blockId?: string; + messageTs?: string; + threadTs?: string; + value?: string; + selectedValues?: string[]; + selectedLabels?: string[]; + triggerId?: string; + responseUrl?: string; + namespace?: string; + payload?: string; + }; + channel: string; + requestConversationBinding: (...args: unknown[]) => Promise; + detachConversationBinding: (...args: unknown[]) => Promise; + getCurrentConversationBinding: (...args: unknown[]) => Promise; + respond: { + acknowledge: (...args: unknown[]) => Promise; + reply: (...args: unknown[]) => Promise; + followUp: (...args: unknown[]) => Promise; + editMessage: (...args: unknown[]) => Promise; + }; +}; + +type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistration< + TelegramInteractiveHandlerContext, + "telegram" +>; +type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistration< + DiscordInteractiveHandlerContext, + "discord" +>; +type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration< + SlackInteractiveHandlerContext, + "slack" +>; let requestPluginConversationBindingMock: MockInstance< typeof conversationBinding.requestPluginConversationBinding diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 45a66056594..c7c213ca0bf 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -3579,7 +3579,7 @@ describe("security audit", () => { }, }, { - name: "flags unallowlisted extensions as critical when native skill commands are exposed", + name: "flags unallowlisted extensions as warn-level findings when extension inventory exists", cfg: { channels: { discord: { enabled: true, token: "t" }, @@ -3590,7 +3590,7 @@ describe("security audit", () => { expect.arrayContaining([ expect.objectContaining({ checkId: "plugins.extensions_no_allowlist", - severity: "critical", + severity: "warn", }), ]), ); @@ -3615,7 +3615,7 @@ describe("security audit", () => { expect.arrayContaining([ expect.objectContaining({ checkId: "plugins.extensions_no_allowlist", - severity: "critical", + severity: "warn", }), ]), ); diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 67f2afc1b71..02581257962 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { formatConversationTarget, deliveryContextKey, @@ -10,6 +12,37 @@ import { } from "./delivery-context.js"; describe("delivery context helpers", () => { + beforeEach(() => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "room-chat", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "room-chat", label: "Room chat" }), + messaging: { + resolveDeliveryTarget: ({ + conversationId, + parentConversationId, + }: { + conversationId: string; + parentConversationId?: string; + }) => + conversationId.startsWith("$") + ? { + to: parentConversationId ? `room:${parentConversationId}` : undefined, + threadId: conversationId, + } + : { + to: `room:${conversationId}`, + }, + }, + }, + }, + ]), + ); + }); + it("normalizes channel/to/accountId and drops empty contexts", () => { expect( normalizeDeliveryContext({ @@ -81,30 +114,32 @@ describe("delivery context helpers", () => { ).toBe("demo-channel|channel:C1||123.456"); }); - it("formats generic non-matrix conversation targets as channels", () => { + it("formats generic fallback conversation targets as channels", () => { expect(formatConversationTarget({ channel: "demo-channel", conversationId: "123" })).toBe( "channel:123", ); }); - it("formats matrix conversation targets as rooms", () => { - expect(formatConversationTarget({ channel: "matrix", conversationId: "!room:example" })).toBe( - "room:!room:example", - ); + it("formats plugin-defined conversation targets via channel messaging hooks", () => { + expect( + formatConversationTarget({ channel: "room-chat", conversationId: "!room:example" }), + ).toBe("room:!room:example"); expect( formatConversationTarget({ - channel: "matrix", + channel: "room-chat", conversationId: "$thread", parentConversationId: "!room:example", }), ).toBe("room:!room:example"); - expect(formatConversationTarget({ channel: "matrix", conversationId: " " })).toBeUndefined(); + expect( + formatConversationTarget({ channel: "room-chat", conversationId: " " }), + ).toBeUndefined(); }); - it("resolves delivery targets for Matrix child threads", () => { + it("resolves delivery targets for plugin-defined child threads", () => { expect( resolveConversationDeliveryTarget({ - channel: "matrix", + channel: "room-chat", conversationId: "$thread", parentConversationId: "!room:example", }), diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 9e5b016db1e..2c11af1c098 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -13,6 +13,7 @@ const allowedNonExtensionTests = new Set([ "src/agents/pi-embedded-runner-extraparams.test.ts", "src/channels/plugins/contracts/dm-policy.contract.test.ts", "src/channels/plugins/contracts/group-policy.contract.test.ts", + "src/plugins/interactive.test.ts", "src/plugins/contracts/discovery.contract.test.ts", ]);