mirror of https://github.com/openclaw/openclaw.git
refactor(plugins): genericize core channel seams
This commit is contained in:
parent
856592cf00
commit
03a43fe231
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue