mirror of https://github.com/openclaw/openclaw.git
test: split message action runner boundaries
This commit is contained in:
parent
42786afc64
commit
94340fdbae
|
|
@ -286,110 +286,6 @@ describe("runMessageAction context isolation", () => {
|
|||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("requires message when no media hint is provided", async () => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
).rejects.toThrow(/message required/i);
|
||||
});
|
||||
|
||||
it("allows send when only shared interactive payloads are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("allows send when only Slack blocks are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "structured poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollQuestion: "Ready?",
|
||||
pollOption: ["Yes", "No"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string-encoded poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollDurationSeconds: "60",
|
||||
pollPublic: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "snake_case poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
poll_question: "Ready?",
|
||||
poll_option: ["Yes", "No"],
|
||||
poll_public: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative poll duration params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollDurationSeconds: -5,
|
||||
},
|
||||
},
|
||||
])("rejects send actions that include $name", async ({ actionParams }) => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams,
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
).rejects.toThrow(/use action "poll" instead of "send"/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "send when target differs from current slack channel",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
describe("runMessageAction core send routing", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("promotes caption to message for media sends when message is empty", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "m1",
|
||||
chatId: "c1",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "testchat",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "testchat",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "t1",
|
||||
chatId: "c1",
|
||||
}),
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cfg = {
|
||||
channels: {
|
||||
testchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "testchat",
|
||||
target: "channel:abc",
|
||||
media: "https://example.com/cat.png",
|
||||
caption: "caption-only text",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(sendMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "caption-only text",
|
||||
mediaUrl: "https://example.com/cat.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not misclassify send as poll when zero-valued poll params are present", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "m2",
|
||||
chatId: "c1",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "testchat",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "testchat",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "t2",
|
||||
chatId: "c1",
|
||||
}),
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cfg = {
|
||||
channels: {
|
||||
testchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "testchat",
|
||||
target: "channel:abc",
|
||||
media: "https://example.com/file.txt",
|
||||
message: "hello",
|
||||
pollDurationHours: 0,
|
||||
pollDurationSeconds: 0,
|
||||
pollMulti: false,
|
||||
pollQuestion: "",
|
||||
pollOption: [],
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(sendMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "hello",
|
||||
mediaUrl: "https://example.com/file.txt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@ import { jsonResult } from "../../agents/tools/common.js";
|
|||
import type { ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
type ChannelActionHandler = NonNullable<NonNullable<ChannelPlugin["actions"]>["handleAction"]>;
|
||||
|
|
@ -197,127 +197,6 @@ describe("runMessageAction plugin dispatch", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("media caption behavior", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("promotes caption to message for media sends when message is empty", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "m1",
|
||||
chatId: "c1",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "testchat",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "testchat",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "t1",
|
||||
chatId: "c1",
|
||||
}),
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cfg = {
|
||||
channels: {
|
||||
testchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "testchat",
|
||||
target: "channel:abc",
|
||||
media: "https://example.com/cat.png",
|
||||
caption: "caption-only text",
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(sendMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "caption-only text",
|
||||
mediaUrl: "https://example.com/cat.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not misclassify send as poll when zero-valued poll params are present", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "m2",
|
||||
chatId: "c1",
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "testchat",
|
||||
source: "test",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "testchat",
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
sendText: vi.fn().mockResolvedValue({
|
||||
channel: "testchat",
|
||||
messageId: "t2",
|
||||
chatId: "c1",
|
||||
}),
|
||||
sendMedia,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const cfg = {
|
||||
channels: {
|
||||
testchat: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await runMessageAction({
|
||||
cfg,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "testchat",
|
||||
target: "channel:abc",
|
||||
media: "https://example.com/file.txt",
|
||||
message: "hello",
|
||||
pollDurationHours: 0,
|
||||
pollDurationSeconds: 0,
|
||||
pollMulti: false,
|
||||
pollQuestion: "",
|
||||
pollOption: [],
|
||||
},
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(sendMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "hello",
|
||||
mediaUrl: "https://example.com/file.txt",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("card-only send behavior", () => {
|
||||
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
|
||||
jsonResult({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ChannelDirectoryEntryKind,
|
||||
ChannelMessagingAdapter,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { runMessageAction } from "./message-action-runner.js";
|
||||
|
||||
const slackConfig = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const runDrySend = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
actionParams: Record<string, unknown>;
|
||||
toolContext?: Record<string, unknown>;
|
||||
}) =>
|
||||
runMessageAction({
|
||||
cfg: params.cfg,
|
||||
action: "send",
|
||||
params: params.actionParams as never,
|
||||
toolContext: params.toolContext as never,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
type ResolvedTestTarget = { to: string; kind: ChannelDirectoryEntryKind };
|
||||
|
||||
const directOutbound: ChannelOutboundAdapter = { deliveryMode: "direct" };
|
||||
|
||||
function normalizeSlackTarget(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
return trimmed.slice(1).trim();
|
||||
}
|
||||
if (/^channel:/i.test(trimmed)) {
|
||||
return trimmed.replace(/^channel:/i, "").trim();
|
||||
}
|
||||
if (/^user:/i.test(trimmed)) {
|
||||
return trimmed.replace(/^user:/i, "").trim();
|
||||
}
|
||||
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mention?.[1]) {
|
||||
return mention[1];
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function createConfiguredTestPlugin(params: {
|
||||
id: "slack" | "telegram";
|
||||
isConfigured: (cfg: OpenClawConfig) => boolean;
|
||||
normalizeTarget: (raw: string) => string | undefined;
|
||||
resolveTarget: (input: string) => ResolvedTestTarget | null;
|
||||
}): ChannelPlugin {
|
||||
const messaging: ChannelMessagingAdapter = {
|
||||
normalizeTarget: params.normalizeTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => Boolean(params.resolveTarget(raw.trim())),
|
||||
hint: "<id>",
|
||||
resolveTarget: async (resolverParams) => {
|
||||
const resolved = params.resolveTarget(resolverParams.input);
|
||||
return resolved ? { ...resolved, source: "normalized" } : null;
|
||||
},
|
||||
},
|
||||
inferTargetChatType: (inferParams) =>
|
||||
params.resolveTarget(inferParams.to)?.kind === "user" ? "direct" : "group",
|
||||
};
|
||||
return {
|
||||
...createChannelTestPluginBase({
|
||||
id: params.id,
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
resolveAccount: () => ({ enabled: true }),
|
||||
isConfigured: (_account, cfg) => params.isConfigured(cfg),
|
||||
},
|
||||
}),
|
||||
outbound: directOutbound,
|
||||
messaging,
|
||||
};
|
||||
}
|
||||
|
||||
const slackTestPlugin = createConfiguredTestPlugin({
|
||||
id: "slack",
|
||||
isConfigured: (cfg) => Boolean(cfg.channels?.slack?.botToken?.trim()),
|
||||
normalizeTarget: (raw) => normalizeSlackTarget(raw) || undefined,
|
||||
resolveTarget: (input) => {
|
||||
const normalized = normalizeSlackTarget(input);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (/^[A-Z0-9]+$/i.test(normalized)) {
|
||||
const kind = /^U/i.test(normalized) ? "user" : "group";
|
||||
return { to: normalized, kind };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const telegramTestPlugin = createConfiguredTestPlugin({
|
||||
id: "telegram",
|
||||
isConfigured: (cfg) => Boolean(cfg.channels?.telegram?.botToken?.trim()),
|
||||
normalizeTarget: (raw) => raw.trim() || undefined,
|
||||
resolveTarget: (input) => {
|
||||
const normalized = input.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to: normalized.replace(/^telegram:/i, ""),
|
||||
kind: normalized.startsWith("@") ? "user" : "group",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
describe("runMessageAction send validation", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
source: "test",
|
||||
plugin: slackTestPlugin,
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
source: "test",
|
||||
plugin: telegramTestPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("requires message when no media hint is provided", async () => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
).rejects.toThrow(/message required/i);
|
||||
});
|
||||
|
||||
it("allows send when only shared interactive payloads are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "telegram-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
actionParams: {
|
||||
channel: "telegram",
|
||||
target: "123456",
|
||||
interactive: {
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [{ label: "Approve", value: "approve" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it("allows send when only Slack blocks are provided", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "structured poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollQuestion: "Ready?",
|
||||
pollOption: ["Yes", "No"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "string-encoded poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollDurationSeconds: "60",
|
||||
pollPublic: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "snake_case poll params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
poll_question: "Ready?",
|
||||
poll_option: ["Yes", "No"],
|
||||
poll_public: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negative poll duration params",
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
pollDurationSeconds: -5,
|
||||
},
|
||||
},
|
||||
])("rejects send actions that include $name", async ({ actionParams }) => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams,
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
).rejects.toThrow(/use action "poll" instead of "send"/i);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue