refactor(plugins): genericize core channel seams

This commit is contained in:
Peter Steinberger 2026-04-03 18:49:47 +01:00
parent 856592cf00
commit 03a43fe231
No known key found for this signature in database
20 changed files with 447 additions and 292 deletions

View File

@ -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";

View File

@ -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,

View File

@ -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,

View File

@ -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");
});

View File

@ -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<keyof typeof moduleLoads>;
function expectUnusedRuntimeFactoriesNotLoaded(exclude: keyof typeof runtimeFactories): void {
const keys = Object.keys(runtimeFactories) as Array<keyof typeof runtimeFactories>;
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<unknown>;
const sendTelegram = deps.telegram as (...args: unknown[]) => Promise<unknown>;
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<unknown>;
const sendDiscord = deps.discord as (...args: unknown[]) => Promise<unknown>;
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);
});
});

View File

@ -31,8 +31,15 @@ function resolveLegacyDepKeysForChannel(channelId: string): string[] {
return [];
}
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
const keys = new Set<string>([`send${pascal}`]);
if (pascal.startsWith("I") && pascal.length > 1) {
const keys = new Set<string>();
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) {

View File

@ -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<ConfigFileSnapshot> = {}): 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",
);
});

View File

@ -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<string, unknown>;
}) => {
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");
});
});

View File

@ -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,

View File

@ -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,
});

View File

@ -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);
});
});

View File

@ -24,10 +24,10 @@ describe("thread binding config keys", () => {
);
});
it("rejects legacy channels.discord.threadBindings.ttlHours", () => {
it("rejects legacy channels.<id>.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.<id>.threadBindings.ttlHours", () => {
it("rejects legacy channels.<id>.accounts.<id>.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<string, unknown> | undefined)?.ttlHours,
).toBeUndefined();
expect(discord?.accounts?.alpha?.threadBindings?.idleHours).toBe(6);
expect(
(discord?.accounts?.alpha?.threadBindings as Record<string, unknown> | undefined)?.ttlHours,
).toBeUndefined();
expect(discord?.accounts?.beta?.threadBindings?.idleHours).toBe(4);
expect(
(discord?.accounts?.beta?.threadBindings as Record<string, unknown> | 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).",
);
});
});

View File

@ -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> = {}): 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<FinalizedMsgContext> = {}): 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",
});
});
});

View File

@ -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" }),
},

View File

@ -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<ReturnType<SignalSendFn>>) {
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(

View File

@ -11,11 +11,15 @@ function resolveLegacyDepKeysForChannel(channelId: string): string[] {
return [];
}
const pascal = compact.charAt(0).toUpperCase() + compact.slice(1);
const keys = new Set<string>([`send${pascal}`]);
const keys = new Set<string>();
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) {

View File

@ -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<unknown>;
detachConversationBinding: (...args: unknown[]) => Promise<unknown>;
getCurrentConversationBinding: (...args: unknown[]) => Promise<unknown>;
respond: {
reply: (...args: unknown[]) => Promise<void>;
editMessage: (...args: unknown[]) => Promise<void>;
editButtons: (...args: unknown[]) => Promise<void>;
clearButtons: (...args: unknown[]) => Promise<void>;
deleteMessage: (...args: unknown[]) => Promise<void>;
};
};
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<unknown>;
detachConversationBinding: (...args: unknown[]) => Promise<unknown>;
getCurrentConversationBinding: (...args: unknown[]) => Promise<unknown>;
respond: {
acknowledge: (...args: unknown[]) => Promise<void>;
reply: (...args: unknown[]) => Promise<void>;
followUp: (...args: unknown[]) => Promise<void>;
editMessage: (...args: unknown[]) => Promise<void>;
clearComponents: (...args: unknown[]) => Promise<void>;
};
};
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<unknown>;
detachConversationBinding: (...args: unknown[]) => Promise<unknown>;
getCurrentConversationBinding: (...args: unknown[]) => Promise<unknown>;
respond: {
acknowledge: (...args: unknown[]) => Promise<void>;
reply: (...args: unknown[]) => Promise<void>;
followUp: (...args: unknown[]) => Promise<void>;
editMessage: (...args: unknown[]) => Promise<void>;
};
};
type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistration<
TelegramInteractiveHandlerContext,
"telegram"
>;
type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistration<
DiscordInteractiveHandlerContext,
"discord"
>;
type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration<
SlackInteractiveHandlerContext,
"slack"
>;
let requestPluginConversationBindingMock: MockInstance<
typeof conversationBinding.requestPluginConversationBinding

View File

@ -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",
}),
]),
);

View File

@ -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",
}),

View File

@ -13,6 +13,7 @@ const allowedNonExtensionTests = new Set<string>([
"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",
]);