mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-05 22:52:54 +00:00
* feat(plugin-sdk): add reply payload sending hook * fix(dispatch): compose caller beforeDeliver with plugin hooks instead of nullish-coalescing ClawSweeper review identified that Telegram's identity beforeDeliver would skip reply_payload_sending and message_sending hooks entirely. Now we always compose caller-provided hooks with global plugin hooks so plugins get a chance to run even when the caller already supplies a beforeDeliver hook. Also adds regression test for composition case. * test(dispatch): align beforeDeliver hook assertion with current context * fix(plugin-sdk): remove leftover merge markers from hook types * feat(plugin-sdk): add reply payload sending hook * fix(plugin-sdk): protect reply payload media trust * fix(auto-reply): honor suppressed routed ACP blocks * fix(auto-reply): avoid double message sending hooks * fix(auto-reply): require routed reply kind * test(auto-reply): type routed suppression mock * fix(auto-reply): honor reply payload hooks in followups * fix(auto-reply): suppress empty hooked dispatcher replies * fix(auto-reply): wire reply payload hooks at dispatcher boundary * fix(plugins): preserve reply payload metadata in hooks * fix(auto-reply): defer reply hook availability checks * fix(auto-reply): preserve message hook order for routed payloads * fix(auto-reply): persist routed payload hook decisions * fix(auto-reply): run routed payload hooks inside delivery * fix(auto-reply): enforce message hooks after payload edits * fix(auto-reply): gate source reply mirrors on delivery * fix(auto-reply): scope hook-mutated media delivery * chore(plugin-sdk): refresh reply hook api baseline * fix(auto-reply): mirror delivered source replies --------- Co-authored-by: Rob via OpenClaw <noreply@openclaw.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
787 lines
22 KiB
TypeScript
787 lines
22 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
ChannelMessagingAdapter,
|
|
ChannelPlugin,
|
|
ChannelThreadingAdapter,
|
|
} 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 { SILENT_REPLY_TOKEN } from "../tokens.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
deliverOutboundPayloads: vi.fn(),
|
|
hookRunner: undefined as
|
|
| {
|
|
hasHooks: ReturnType<typeof vi.fn>;
|
|
runMessageSending: ReturnType<typeof vi.fn>;
|
|
runReplyPayloadSending: ReturnType<typeof vi.fn>;
|
|
}
|
|
| undefined,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/deliver-runtime.js", () => ({
|
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
|
deliverOutboundPayloadsInternal: mocks.deliverOutboundPayloads,
|
|
}));
|
|
|
|
vi.mock("../../infra/outbound/deliver.js", () => ({
|
|
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
|
|
deliverOutboundPayloadsInternal: mocks.deliverOutboundPayloads,
|
|
}));
|
|
|
|
vi.mock("../../plugins/hook-runner-global.js", () => ({
|
|
getGlobalHookRunner: () => mocks.hookRunner,
|
|
}));
|
|
|
|
const { routeReply: routeReplyRuntime } = await import("./route-reply.js");
|
|
type RouteReplyParams = Parameters<typeof routeReplyRuntime>[0];
|
|
const routeReply = (
|
|
params: Omit<RouteReplyParams, "replyKind"> & { replyKind?: RouteReplyParams["replyKind"] },
|
|
) => routeReplyRuntime({ replyKind: "final", ...params });
|
|
|
|
function compileSlackInteractiveRepliesForTest(
|
|
payload: Parameters<NonNullable<ChannelMessagingAdapter["transformReplyPayload"]>>[0]["payload"],
|
|
) {
|
|
const text = payload.text ?? "";
|
|
if (!text.includes("[[slack_select:") && !text.includes("[[slack_buttons:")) {
|
|
return payload;
|
|
}
|
|
return {
|
|
...payload,
|
|
channelData: {
|
|
...payload.channelData,
|
|
slack: {
|
|
...(payload.channelData?.slack as Record<string, unknown> | undefined),
|
|
blocks: [{ type: "section", text }],
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
const slackMessaging: ChannelMessagingAdapter = {
|
|
transformReplyPayload: ({ payload, cfg }) =>
|
|
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
|
|
?.capabilities?.interactiveReplies === true
|
|
? compileSlackInteractiveRepliesForTest(payload)
|
|
: payload,
|
|
enableInteractiveReplies: ({ cfg }) =>
|
|
(cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
|
|
?.capabilities?.interactiveReplies === true,
|
|
hasStructuredReplyPayload: ({ payload }) => {
|
|
const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
|
|
if (typeof blocks === "string") {
|
|
return blocks.trim().length > 0;
|
|
}
|
|
return Array.isArray(blocks) && blocks.length > 0;
|
|
},
|
|
};
|
|
|
|
const slackThreading: ChannelThreadingAdapter = {
|
|
resolveReplyTransport: ({ threadId, replyToId }) => ({
|
|
replyToId: resolveSlackThreadTsCandidate(replyToId) ?? resolveSlackThreadTsCandidate(threadId),
|
|
threadId: null,
|
|
}),
|
|
};
|
|
|
|
function resolveSlackThreadTsCandidate(value?: string | number | null): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const normalized = value.trim();
|
|
return /^\d+\.\d+$/.test(normalized) ? normalized : undefined;
|
|
}
|
|
|
|
const mattermostThreading: ChannelThreadingAdapter = {
|
|
resolveReplyTransport: ({ threadId, replyToId }) => ({
|
|
replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
|
|
threadId,
|
|
}),
|
|
};
|
|
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
function lastDelivery() {
|
|
const call = mocks.deliverOutboundPayloads.mock.calls.at(-1);
|
|
if (!call) {
|
|
throw new Error("Expected outbound delivery call");
|
|
}
|
|
const delivery = call[0];
|
|
if (!delivery || typeof delivery !== "object") {
|
|
throw new Error("expected outbound delivery");
|
|
}
|
|
return delivery as Record<string, unknown>;
|
|
}
|
|
|
|
function expectLastDeliveryFields(fields: Record<string, unknown>) {
|
|
const delivery = lastDelivery();
|
|
for (const [key, expected] of Object.entries(fields)) {
|
|
expect(delivery[key]).toEqual(expected);
|
|
}
|
|
}
|
|
|
|
function lastDeliveryPayload(index = 0): Record<string, unknown> {
|
|
const payloads = lastDelivery().payloads;
|
|
expect(Array.isArray(payloads)).toBe(true);
|
|
const payload = (payloads as unknown[])[index];
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new Error(`expected delivery payload ${index}`);
|
|
}
|
|
return payload as Record<string, unknown>;
|
|
}
|
|
|
|
async function expectSlackNoDelivery(
|
|
payload: Parameters<typeof routeReply>[0]["payload"],
|
|
overrides: Partial<Parameters<typeof routeReply>[0]> = {},
|
|
) {
|
|
mocks.deliverOutboundPayloads.mockClear();
|
|
const res = await routeReply({
|
|
payload,
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
...overrides,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
return res;
|
|
}
|
|
|
|
describe("routeReply", () => {
|
|
beforeEach(() => {
|
|
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",
|
|
threading: mattermostThreading,
|
|
}),
|
|
source: "test",
|
|
},
|
|
]),
|
|
);
|
|
mocks.deliverOutboundPayloads.mockReset();
|
|
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
|
mocks.hookRunner = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
setActivePluginRegistry(createTestRegistry());
|
|
});
|
|
|
|
it("skips sends when abort signal is already aborted", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const res = await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
abortSignal: controller.signal,
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
expect(res.error).toContain("aborted");
|
|
expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("no-ops on empty payload", async () => {
|
|
await expectSlackNoDelivery({});
|
|
});
|
|
|
|
it("suppresses reasoning payloads", async () => {
|
|
await expectSlackNoDelivery({ text: "step", isReasoning: true });
|
|
});
|
|
|
|
it("drops silent token payloads", async () => {
|
|
await expectSlackNoDelivery({ text: SILENT_REPLY_TOKEN });
|
|
});
|
|
|
|
it("does not drop payloads that merely start with the silent token", async () => {
|
|
const res = await routeReply({
|
|
payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe(`${SILENT_REPLY_TOKEN} -- (why am I here?)`);
|
|
});
|
|
|
|
it("passes replayable reply payload hook context to routed delivery", async () => {
|
|
const res = await routeReply({
|
|
payload: { text: "hello" },
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
accountId: "acct-1",
|
|
sessionKey: "agent:test",
|
|
requesterSenderId: "sender-1",
|
|
cfg: {} as never,
|
|
replyKind: "block",
|
|
runId: "run-1",
|
|
});
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(lastDeliveryPayload()).toMatchObject({ text: "hello" });
|
|
expect(lastDelivery().replyPayloadSendingHook).toMatchObject({
|
|
kind: "block",
|
|
channel: "telegram",
|
|
sessionKey: "agent:test",
|
|
runId: "run-1",
|
|
context: {
|
|
channelId: "telegram",
|
|
accountId: "acct-1",
|
|
conversationId: "chat-1",
|
|
sessionKey: "agent:test",
|
|
senderId: "sender-1",
|
|
runId: "run-1",
|
|
},
|
|
});
|
|
expect(lastDelivery()).not.toHaveProperty("skipMessageSendingHooks");
|
|
});
|
|
|
|
it("leaves message_sending enforcement to routed durable delivery", async () => {
|
|
const res = await routeReply({
|
|
payload: { text: "secret" },
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
accountId: "acct-1",
|
|
cfg: {} as never,
|
|
});
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(lastDelivery()).toMatchObject({
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
accountId: "acct-1",
|
|
payloads: [expect.objectContaining({ text: "secret" })],
|
|
replyPayloadSendingHook: expect.objectContaining({
|
|
kind: "final",
|
|
channel: "telegram",
|
|
context: expect.objectContaining({
|
|
channelId: "telegram",
|
|
accountId: "acct-1",
|
|
conversationId: "chat-1",
|
|
}),
|
|
}),
|
|
});
|
|
expect(lastDelivery()).not.toHaveProperty("skipMessageSendingHooks");
|
|
});
|
|
|
|
it("returns routed reply hook suppression reasons from durable delivery", async () => {
|
|
mocks.deliverOutboundPayloads.mockImplementationOnce(
|
|
async ({
|
|
onPayloadDeliveryOutcome,
|
|
}: {
|
|
onPayloadDeliveryOutcome?: (outcome: unknown) => void;
|
|
}) => {
|
|
onPayloadDeliveryOutcome?.({
|
|
index: 0,
|
|
status: "suppressed",
|
|
reason: "cancelled_by_reply_payload_sending_hook",
|
|
});
|
|
return [];
|
|
},
|
|
);
|
|
|
|
const res = await routeReply({
|
|
payload: { text: "hello" },
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
cfg: {} as never,
|
|
});
|
|
|
|
expect(res).toEqual({
|
|
ok: true,
|
|
suppressed: true,
|
|
reason: "cancelled_by_reply_payload_sending_hook",
|
|
});
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
expect(lastDelivery().replyPayloadSendingHook).toMatchObject({
|
|
kind: "final",
|
|
channel: "telegram",
|
|
context: {
|
|
channelId: "telegram",
|
|
conversationId: "chat-1",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("suppresses routed delivery when reply payload hooks cancel", async () => {
|
|
mocks.deliverOutboundPayloads.mockImplementationOnce(
|
|
async ({
|
|
onPayloadDeliveryOutcome,
|
|
}: {
|
|
onPayloadDeliveryOutcome?: (outcome: unknown) => void;
|
|
}) => {
|
|
onPayloadDeliveryOutcome?.({
|
|
index: 0,
|
|
status: "suppressed",
|
|
reason: "cancelled_by_reply_payload_sending_hook",
|
|
});
|
|
return [];
|
|
},
|
|
);
|
|
|
|
const res = await routeReply({
|
|
payload: { text: "hello" },
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
cfg: {} as never,
|
|
});
|
|
|
|
expect(res).toEqual({
|
|
ok: true,
|
|
suppressed: true,
|
|
reason: "cancelled_by_reply_payload_sending_hook",
|
|
});
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("suppresses routed delivery when reply payload hooks empty the payload", async () => {
|
|
mocks.deliverOutboundPayloads.mockImplementationOnce(
|
|
async ({
|
|
onPayloadDeliveryOutcome,
|
|
}: {
|
|
onPayloadDeliveryOutcome?: (outcome: unknown) => void;
|
|
}) => {
|
|
onPayloadDeliveryOutcome?.({
|
|
index: 0,
|
|
status: "suppressed",
|
|
reason: "empty_after_reply_payload_sending_hook",
|
|
});
|
|
return [];
|
|
},
|
|
);
|
|
|
|
const res = await routeReply({
|
|
payload: { text: "hello" },
|
|
channel: "telegram",
|
|
to: "chat-1",
|
|
cfg: {} as never,
|
|
});
|
|
|
|
expect(res).toEqual({
|
|
ok: true,
|
|
suppressed: true,
|
|
reason: "empty_after_reply_payload_sending_hook",
|
|
});
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes policySessionKey through to outbound delivery targets", async () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
silentReply: {
|
|
group: "allow",
|
|
internal: "allow",
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
const res = await routeReply({
|
|
payload: { text: "native command response" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg,
|
|
sessionKey: "agent:main:main",
|
|
policySessionKey: "agent:main:direct:U123",
|
|
isGroup: true,
|
|
});
|
|
|
|
expect(res.ok).toBe(true);
|
|
expect(lastDeliveryPayload().text).toBe("native command response");
|
|
const session = lastDelivery().session as Record<string, unknown>;
|
|
expect(session.key).toBe("agent:main:main");
|
|
expect(session.policyKey).toBe("agent:main:direct:U123");
|
|
expect(session.conversationType).toBeUndefined();
|
|
});
|
|
|
|
it("uses explicit policy conversation type to suppress routed direct silent replies", async () => {
|
|
const cfg = {
|
|
agents: {
|
|
defaults: {
|
|
silentReply: {
|
|
internal: "allow",
|
|
},
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
|
|
await expectSlackNoDelivery(
|
|
{ text: SILENT_REPLY_TOKEN },
|
|
{
|
|
cfg,
|
|
sessionKey: "agent:main:main",
|
|
policySessionKey: "agent:main:main",
|
|
policyConversationType: "direct",
|
|
},
|
|
);
|
|
});
|
|
|
|
it("applies responsePrefix when routing", async () => {
|
|
const cfg = {
|
|
messages: { responsePrefix: "[openclaw]" },
|
|
} as unknown as OpenClawConfig;
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg,
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("[openclaw] hi");
|
|
});
|
|
|
|
it("routes directive-only Slack replies when interactive replies are enabled", async () => {
|
|
const cfg = {
|
|
channels: {
|
|
slack: {
|
|
capabilities: { interactiveReplies: true },
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
await routeReply({
|
|
payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg,
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("[[slack_select: Choose one | Alpha:alpha]]");
|
|
});
|
|
|
|
it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
|
|
await expectSlackNoDelivery({
|
|
text: " ",
|
|
channelData: {
|
|
slack: {
|
|
blocks: " ",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not derive responsePrefix from agent identity when routing", async () => {
|
|
const cfg = {
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "rich",
|
|
identity: { name: "Richbot", theme: "lion bot", emoji: "lion" },
|
|
},
|
|
],
|
|
},
|
|
messages: {},
|
|
} as unknown as OpenClawConfig;
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
sessionKey: "agent:rich:main",
|
|
cfg,
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("hi");
|
|
});
|
|
|
|
it("uses threadId for Slack when replyToId is missing", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
threadId: "456.789",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
replyToId: "456.789",
|
|
threadId: null,
|
|
});
|
|
});
|
|
|
|
it("passes thread id to Telegram sends", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
threadId: 42,
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
threadId: 42,
|
|
});
|
|
});
|
|
|
|
it("formats BTW replies prominently on routed sends", async () => {
|
|
await routeReply({
|
|
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("BTW\nQuestion: what is 17 * 19?\n\n323");
|
|
});
|
|
|
|
it("formats BTW replies prominently on routed discord sends", async () => {
|
|
await routeReply({
|
|
payload: { text: "323", btw: { question: "what is 17 * 19?" } },
|
|
channel: "discord",
|
|
to: "channel:123456",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "discord",
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("BTW\nQuestion: what is 17 * 19?\n\n323");
|
|
});
|
|
|
|
it("passes replyToId to Telegram sends", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi", replyToId: "123" },
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
replyToId: "123",
|
|
});
|
|
});
|
|
|
|
it("preserves audioAsVoice on routed outbound payloads", async () => {
|
|
await routeReply({
|
|
payload: { text: "voice caption", mediaUrl: "file:///tmp/clip.mp3", audioAsVoice: true },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
});
|
|
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("voice caption");
|
|
expect(lastDeliveryPayload().mediaUrl).toBe("file:///tmp/clip.mp3");
|
|
expect(lastDeliveryPayload().audioAsVoice).toBe(true);
|
|
});
|
|
|
|
it("uses replyToId as threadTs for Slack", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi", replyToId: "1710000000.0001" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
replyToId: "1710000000.0001",
|
|
threadId: null,
|
|
});
|
|
});
|
|
|
|
it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
threadId: "1710000000.9999",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
replyToId: "1710000000.9999",
|
|
threadId: null,
|
|
});
|
|
});
|
|
|
|
it("uses Slack threadId when routed replyToId is an internal message id", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi", replyToId: "msg-internal-1" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
threadId: "1710000000.9999",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
replyToId: "1710000000.9999",
|
|
threadId: null,
|
|
});
|
|
});
|
|
|
|
it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "mattermost",
|
|
to: "channel:CHAN1",
|
|
threadId: "post-root",
|
|
cfg: {
|
|
channels: {
|
|
mattermost: {
|
|
enabled: true,
|
|
botToken: "test-token",
|
|
baseUrl: "https://chat.example.com",
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "mattermost",
|
|
to: "channel:CHAN1",
|
|
replyToId: "post-root",
|
|
threadId: "post-root",
|
|
});
|
|
});
|
|
|
|
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,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "slack",
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("caption");
|
|
expect(lastDeliveryPayload().mediaUrls).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("routes WhatsApp with the account id intact", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "whatsapp",
|
|
to: "+15551234567",
|
|
accountId: "acc-1",
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "whatsapp",
|
|
to: "+15551234567",
|
|
accountId: "acc-1",
|
|
});
|
|
});
|
|
|
|
it("routes MS Teams via outbound delivery", async () => {
|
|
const cfg = {
|
|
channels: {
|
|
msteams: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
} as unknown as OpenClawConfig;
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "msteams",
|
|
to: "conversation:19:abc@thread.tacv2",
|
|
cfg,
|
|
});
|
|
expectLastDeliveryFields({
|
|
channel: "msteams",
|
|
to: "conversation:19:abc@thread.tacv2",
|
|
cfg,
|
|
});
|
|
expect(lastDeliveryPayload().text).toBe("hi");
|
|
});
|
|
|
|
it("passes mirror data when sessionKey is set", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
sessionKey: "agent:main:main",
|
|
isGroup: true,
|
|
groupId: "channel:C123",
|
|
cfg: {} as never,
|
|
});
|
|
const mirror = lastDelivery().mirror as Record<string, unknown>;
|
|
expect(mirror.sessionKey).toBe("agent:main:main");
|
|
expect(mirror.text).toBe("hi");
|
|
expect(mirror.isGroup).toBe(true);
|
|
expect(mirror.groupId).toBe("channel:C123");
|
|
});
|
|
|
|
it("skips mirror data when mirror is false", async () => {
|
|
await routeReply({
|
|
payload: { text: "hi" },
|
|
channel: "slack",
|
|
to: "channel:C123",
|
|
sessionKey: "agent:main:main",
|
|
mirror: false,
|
|
cfg: {} as never,
|
|
});
|
|
expectLastDeliveryFields({
|
|
mirror: undefined,
|
|
});
|
|
});
|
|
});
|