mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 05:40:23 +00:00
test: move extension-owned coverage into plugins
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
__testing as feishuThreadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "../../../../extensions/feishu/api.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
getSessionBindingService,
|
||||
registerSessionBindingAdapter,
|
||||
type SessionBindingRecord,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { buildCommandTestParams } from "../commands-spawn.test-harness.js";
|
||||
import {
|
||||
@@ -20,9 +18,39 @@ const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
function registerFeishuBindingAdapterForTest(accountId: string) {
|
||||
const bindings: SessionBindingRecord[] = [];
|
||||
registerSessionBindingAdapter({
|
||||
channel: "feishu",
|
||||
accountId,
|
||||
capabilities: { placements: ["current"] },
|
||||
bind: async (input) => {
|
||||
const record: SessionBindingRecord = {
|
||||
bindingId: `${input.conversation.channel}:${input.conversation.accountId}:${input.conversation.conversationId}`,
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
targetKind: input.targetKind,
|
||||
conversation: input.conversation,
|
||||
status: "active",
|
||||
boundAt: Date.now(),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
};
|
||||
bindings.push(record);
|
||||
return record;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
bindings.filter((binding) => binding.targetSessionKey === targetSessionKey),
|
||||
resolveByConversation: (ref) =>
|
||||
bindings.find(
|
||||
(binding) =>
|
||||
binding.conversation.channel === ref.channel &&
|
||||
binding.conversation.accountId === ref.accountId &&
|
||||
binding.conversation.conversationId === ref.conversationId,
|
||||
) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
describe("commands-acp context", () => {
|
||||
beforeEach(() => {
|
||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
});
|
||||
|
||||
@@ -233,7 +261,7 @@ describe("commands-acp context", () => {
|
||||
});
|
||||
|
||||
it("preserves sender-scoped Feishu topic ids after ACP takeover from the live binding record", async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "work" });
|
||||
registerFeishuBindingAdapterForTest("work");
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:work:abc123",
|
||||
targetKind: "session",
|
||||
|
||||
@@ -1,75 +1,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discordOutbound,
|
||||
imessageOutbound,
|
||||
signalOutbound,
|
||||
slackOutbound,
|
||||
telegramOutbound,
|
||||
whatsappOutbound,
|
||||
} from "../../../test/channel-outbounds.js";
|
||||
import type {
|
||||
ChannelMessagingAdapter,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelThreadingAdapter,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
|
||||
sendMessageMSTeams: vi.fn(async (_params: unknown) => ({
|
||||
messageId: "m1",
|
||||
conversationId: "c1",
|
||||
})),
|
||||
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
|
||||
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
|
||||
sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({
|
||||
messageId: "m1",
|
||||
channelId: "c1",
|
||||
})),
|
||||
deliverOutboundPayloads: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../extensions/discord/src/send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageDiscord: mocks.sendMessageDiscord,
|
||||
sendPollDiscord: mocks.sendMessageDiscord,
|
||||
sendWebhookMessageDiscord: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../../../extensions/imessage/src/send.js", () => ({
|
||||
sendMessageIMessage: mocks.sendMessageIMessage,
|
||||
}));
|
||||
vi.mock("../../../extensions/signal/src/send.js", () => ({
|
||||
sendMessageSignal: mocks.sendMessageSignal,
|
||||
}));
|
||||
vi.mock("../../../extensions/slack/src/send.js", () => ({
|
||||
sendMessageSlack: mocks.sendMessageSlack,
|
||||
}));
|
||||
vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../extensions/telegram/src/send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageTelegram: mocks.sendMessageTelegram,
|
||||
};
|
||||
});
|
||||
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
|
||||
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
|
||||
sendPollWhatsApp: mocks.sendMessageWhatsApp,
|
||||
}));
|
||||
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
|
||||
sendMessageMattermost: mocks.sendMessageMattermost,
|
||||
}));
|
||||
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
|
||||
"../../infra/outbound/deliver-runtime.js",
|
||||
@@ -79,67 +25,9 @@ vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
|
||||
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
||||
};
|
||||
});
|
||||
const actualDeliver = await vi.importActual<
|
||||
typeof import("../../infra/outbound/deliver-runtime.js")
|
||||
>("../../infra/outbound/deliver-runtime.js");
|
||||
|
||||
const { routeReply } = await import("./route-reply.js");
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
plugins: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
typedHooks: [],
|
||||
commands: [],
|
||||
channels,
|
||||
channelSetups: channels.map((entry) => ({
|
||||
pluginId: entry.pluginId,
|
||||
plugin: entry.plugin,
|
||||
source: entry.source,
|
||||
enabled: true,
|
||||
})),
|
||||
providers: [],
|
||||
speechProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
imageGenerationProviders: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const createMSTeamsOutbound = (): ChannelOutboundAdapter => ({
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ cfg, to, text }) => {
|
||||
const result = await mocks.sendMessageMSTeams({ cfg, to, text });
|
||||
return { channel: "msteams", ...result };
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl }) => {
|
||||
const result = await mocks.sendMessageMSTeams({ cfg, to, text, mediaUrl });
|
||||
return { channel: "msteams", ...result };
|
||||
},
|
||||
});
|
||||
|
||||
const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Teams SDK; enterprise support.",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
outbound: params.outbound,
|
||||
});
|
||||
|
||||
const slackMessaging: ChannelMessagingAdapter = {
|
||||
enableInteractiveReplies: ({ cfg }) =>
|
||||
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
|
||||
@@ -160,32 +48,36 @@ const slackThreading: ChannelThreadingAdapter = {
|
||||
}),
|
||||
};
|
||||
|
||||
const mattermostOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => {
|
||||
const result = await mocks.sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => {
|
||||
const result = await mocks.sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
mediaUrl,
|
||||
});
|
||||
return { channel: "mattermost", ...result };
|
||||
},
|
||||
};
|
||||
function createChannelPlugin(
|
||||
id: ChannelPlugin["id"],
|
||||
options: {
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
label?: string;
|
||||
} = {},
|
||||
): ChannelPlugin {
|
||||
return {
|
||||
...createChannelTestPluginBase({
|
||||
id,
|
||||
label: options.label ?? String(id),
|
||||
config: { listAccountIds: () => [], resolveAccount: () => ({}) },
|
||||
}),
|
||||
...(options.messaging ? { messaging: options.messaging } : {}),
|
||||
...(options.threading ? { threading: options.threading } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectSlackNoSend(
|
||||
function expectLastDelivery(
|
||||
matcher: Partial<Parameters<(typeof mocks.deliverOutboundPayloads.mock.calls)[number][0]>[0]>,
|
||||
) {
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenLastCalledWith(expect.objectContaining(matcher));
|
||||
}
|
||||
|
||||
async function expectSlackNoDelivery(
|
||||
payload: Parameters<typeof routeReply>[0]["payload"],
|
||||
overrides: Partial<Parameters<typeof routeReply>[0]> = {},
|
||||
) {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockClear();
|
||||
const res = await routeReply({
|
||||
payload,
|
||||
channel: "slack",
|
||||
@@ -194,22 +86,69 @@ async function expectSlackNoSend(
|
||||
...overrides,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("routeReply", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createChannelPlugin("discord", { label: "Discord" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: createChannelPlugin("slack", {
|
||||
label: "Slack",
|
||||
messaging: slackMessaging,
|
||||
threading: slackThreading,
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createChannelPlugin("telegram", { label: "Telegram" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createChannelPlugin("whatsapp", { label: "WhatsApp" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: createChannelPlugin("signal", { label: "Signal" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createChannelPlugin("imessage", { label: "iMessage" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "msteams",
|
||||
plugin: createChannelPlugin("msteams", { label: "Microsoft Teams" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "mattermost",
|
||||
plugin: createChannelPlugin("mattermost", { label: "Mattermost" }),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
mocks.deliverOutboundPayloads.mockReset();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
});
|
||||
|
||||
it("skips sends when abort signal is already aborted", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const res = await routeReply({
|
||||
@@ -221,23 +160,22 @@ describe("routeReply", () => {
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toContain("aborted");
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops on empty payload", async () => {
|
||||
await expectSlackNoSend({});
|
||||
await expectSlackNoDelivery({});
|
||||
});
|
||||
|
||||
it("suppresses reasoning payloads", async () => {
|
||||
await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true });
|
||||
await expectSlackNoDelivery({ text: "Reasoning:\n_step_", isReasoning: true });
|
||||
});
|
||||
|
||||
it("drops silent token payloads", async () => {
|
||||
await expectSlackNoSend({ text: SILENT_REPLY_TOKEN });
|
||||
await expectSlackNoDelivery({ text: SILENT_REPLY_TOKEN });
|
||||
});
|
||||
|
||||
it("does not drop payloads that merely start with the silent token", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
|
||||
channel: "slack",
|
||||
@@ -245,15 +183,18 @@ describe("routeReply", () => {
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
`${SILENT_REPLY_TOKEN} -- (why am I here?)`,
|
||||
expect.any(Object),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: `${SILENT_REPLY_TOKEN} -- (why am I here?)`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies responsePrefix when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
messages: { responsePrefix: "[openclaw]" },
|
||||
} as unknown as OpenClawConfig;
|
||||
@@ -263,15 +204,12 @@ describe("routeReply", () => {
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"[openclaw] hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
expectLastDelivery({
|
||||
payloads: [expect.objectContaining({ text: "[openclaw] hi" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes directive-only Slack replies when interactive replies are enabled", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
@@ -285,22 +223,25 @@ describe("routeReply", () => {
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: "actions",
|
||||
block_id: "openclaw_reply_select_1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: undefined,
|
||||
interactive: {
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
placeholder: "Choose one",
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
|
||||
await expectSlackNoSend({
|
||||
await expectSlackNoDelivery({
|
||||
text: " ",
|
||||
channelData: {
|
||||
slack: {
|
||||
@@ -311,13 +252,12 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("does not derive responsePrefix from agent identity when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "rich",
|
||||
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
|
||||
identity: { name: "Richbot", theme: "lion bot", emoji: "lion" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -330,11 +270,12 @@ describe("routeReply", () => {
|
||||
sessionKey: "agent:rich:main",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
|
||||
expectLastDelivery({
|
||||
payloads: [expect.objectContaining({ text: "hi" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses threadId for Slack when replyToId is missing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
@@ -342,15 +283,14 @@ describe("routeReply", () => {
|
||||
threadId: "456.789",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ threadTs: "456.789" }),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
replyToId: "456.789",
|
||||
threadId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "telegram",
|
||||
@@ -358,65 +298,54 @@ describe("routeReply", () => {
|
||||
threadId: 42,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 42,
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently on routed sends", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.any(Object),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
payloads: [expect.objectContaining({ text: "BTW\nQuestion: what is 17 * 19?\n\n323" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("formats BTW replies prominently on routed discord sends", async () => {
|
||||
mocks.sendMessageDiscord.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
||||
channel: "discord",
|
||||
to: "channel:123456",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
|
||||
"channel:123456",
|
||||
"BTW\nQuestion: what is 17 * 19?\n\n323",
|
||||
expect.any(Object),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "discord",
|
||||
payloads: [expect.objectContaining({ text: "BTW\nQuestion: what is 17 * 19?\n\n323" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "123" },
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves audioAsVoice on routed outbound payloads", async () => {
|
||||
mocks.deliverOutboundPayloads.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "voice caption", mediaUrl: "file:///tmp/clip.mp3", audioAsVoice: true },
|
||||
channel: "slack",
|
||||
@@ -424,38 +353,34 @@ describe("routeReply", () => {
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: "voice caption",
|
||||
mediaUrl: "file:///tmp/clip.mp3",
|
||||
audioAsVoice: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: "voice caption",
|
||||
mediaUrl: "file:///tmp/clip.mp3",
|
||||
audioAsVoice: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses replyToId as threadTs for Slack", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "1710000000.0001" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ threadTs: "1710000000.0001" }),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
replyToId: "1710000000.0001",
|
||||
threadId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
@@ -463,15 +388,14 @@ describe("routeReply", () => {
|
||||
threadId: "1710000000.9999",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ threadTs: "1710000000.9999" }),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
replyToId: "1710000000.9999",
|
||||
threadId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "mattermost",
|
||||
@@ -487,41 +411,33 @@ describe("routeReply", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "mattermost",
|
||||
to: "channel:CHAN1",
|
||||
replyToId: "post-root",
|
||||
threadId: "post-root",
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "mattermost",
|
||||
to: "channel:CHAN1",
|
||||
replyToId: "post-root",
|
||||
threadId: "post-root",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends multiple mediaUrls (caption only on first)", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
it("preserves multiple mediaUrls as a single outbound payload", async () => {
|
||||
await routeReply({
|
||||
payload: { text: "caption", mediaUrls: ["a", "b"] },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"channel:C123",
|
||||
"caption",
|
||||
expect.objectContaining({ mediaUrl: "a" }),
|
||||
);
|
||||
expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"channel:C123",
|
||||
"",
|
||||
expect.objectContaining({ mediaUrl: "b" }),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "slack",
|
||||
payloads: [
|
||||
expect.objectContaining({
|
||||
text: "caption",
|
||||
mediaUrls: ["a", "b"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes WhatsApp via outbound sender (accountId honored)", async () => {
|
||||
mocks.sendMessageWhatsApp.mockClear();
|
||||
it("routes WhatsApp with the account id intact", async () => {
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "whatsapp",
|
||||
@@ -529,26 +445,14 @@ describe("routeReply", () => {
|
||||
accountId: "acc-1",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageWhatsApp).toHaveBeenCalledWith(
|
||||
"+15551234567",
|
||||
"hi",
|
||||
expect.objectContaining({ accountId: "acc-1", verbose: false }),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
accountId: "acc-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes MS Teams via proactive sender", async () => {
|
||||
mocks.sendMessageMSTeams.mockClear();
|
||||
setActivePluginRegistry(
|
||||
createRegistry([
|
||||
{
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
plugin: createMSTeamsPlugin({
|
||||
outbound: createMSTeamsOutbound(),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
it("routes MS Teams via outbound delivery", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
msteams: {
|
||||
@@ -562,17 +466,15 @@ describe("routeReply", () => {
|
||||
to: "conversation:19:abc@thread.tacv2",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
to: "conversation:19:abc@thread.tacv2",
|
||||
text: "hi",
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
channel: "msteams",
|
||||
to: "conversation:19:abc@thread.tacv2",
|
||||
cfg,
|
||||
payloads: [expect.objectContaining({ text: "hi" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes mirror data when sessionKey is set", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
@@ -582,20 +484,17 @@ describe("routeReply", () => {
|
||||
groupId: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "hi",
|
||||
isGroup: true,
|
||||
groupId: "channel:C123",
|
||||
}),
|
||||
expectLastDelivery({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "hi",
|
||||
isGroup: true,
|
||||
groupId: "channel:C123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips mirror data when mirror is false", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
@@ -604,76 +503,8 @@ describe("routeReply", () => {
|
||||
mirror: false,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: undefined,
|
||||
}),
|
||||
);
|
||||
expectLastDelivery({
|
||||
mirror: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
const defaultRegistry = createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "discord",
|
||||
outbound: discordOutbound,
|
||||
label: "Discord",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: {
|
||||
...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }),
|
||||
messaging: slackMessaging,
|
||||
threading: slackThreading,
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "telegram",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "telegram",
|
||||
outbound: telegramOutbound,
|
||||
label: "Telegram",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
outbound: whatsappOutbound,
|
||||
label: "WhatsApp",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "signal",
|
||||
plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound, label: "Signal" }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createIMessageTestPlugin({ outbound: imessageOutbound }),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "msteams",
|
||||
plugin: createMSTeamsPlugin({
|
||||
outbound: createMSTeamsOutbound(),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "mattermost",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "mattermost",
|
||||
outbound: mattermostOutbound,
|
||||
label: "Mattermost",
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user