Files
clawdbot/src/auto-reply/reply/route-reply.test.ts
SocialNerd42069 0d6e78b718 fix(slack): respect verbose setting and preserve thread context for tool notifications
Fixes two bugs in Slack tool notification delivery:

1. Tool notifications ignored verbose=false - normalized verbose values so
   boolean false/'false' are properly treated as 'off'

2. Thread context lost - Slack outbound adapter now falls back to threadId
   when replyToId is missing, and MessageThreadId is set for thread replies

Closes #1333
2026-01-21 06:29:36 +00:00

436 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createIMessageTestPlugin,
createOutboundTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { discordOutbound } from "../../channels/plugins/outbound/discord.js";
import { imessageOutbound } from "../../channels/plugins/outbound/imessage.js";
import { signalOutbound } from "../../channels/plugins/outbound/signal.js";
import { slackOutbound } from "../../channels/plugins/outbound/slack.js";
import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js";
import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.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 () => ({
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" })),
deliverOutboundPayloads: vi.fn(),
}));
vi.mock("../../discord/send.js", () => ({
sendMessageDiscord: mocks.sendMessageDiscord,
}));
vi.mock("../../imessage/send.js", () => ({
sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../signal/send.js", () => ({
sendMessageSignal: mocks.sendMessageSignal,
}));
vi.mock("../../slack/send.js", () => ({
sendMessageSlack: mocks.sendMessageSlack,
}));
vi.mock("../../telegram/send.js", () => ({
sendMessageTelegram: mocks.sendMessageTelegram,
}));
vi.mock("../../web/outbound.js", () => ({
sendMessageWhatsApp: mocks.sendMessageWhatsApp,
sendPollWhatsApp: mocks.sendMessageWhatsApp,
}));
vi.mock("../../infra/outbound/deliver.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
);
return {
...actual,
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
};
});
const actualDeliver = await vi.importActual<typeof import("../../infra/outbound/deliver.js")>(
"../../infra/outbound/deliver.js",
);
const { routeReply } = await import("./route-reply.js");
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
plugins: [],
tools: [],
channels,
providers: [],
gatewayHandlers: {},
httpHandlers: [],
cliRegistrars: [],
services: [],
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: "Bot Framework; enterprise support.",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
outbound: params.outbound,
});
describe("routeReply", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
});
afterEach(() => {
setActivePluginRegistry(emptyRegistry);
});
it("skips sends when abort signal is already aborted", async () => {
mocks.sendMessageSlack.mockClear();
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.sendMessageSlack).not.toHaveBeenCalled();
});
it("no-ops on empty payload", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: {},
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("drops silent token payloads", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: { text: SILENT_REPLY_TOKEN },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("drops payloads that start with the silent token", async () => {
mocks.sendMessageSlack.mockClear();
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);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("applies responsePrefix when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
messages: { responsePrefix: "[clawdbot]" },
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[clawdbot] hi",
expect.any(Object),
);
});
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: "🦁" },
},
],
},
messages: {},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
});
it("uses threadId for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
threadId: "456.789",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "456.789" }),
);
});
it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "telegram",
to: "telegram:123",
threadId: 42,
cfg: {} as never,
});
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
"telegram:123",
"hi",
expect.objectContaining({ messageThreadId: 42 }),
);
});
it("passes replyToId to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({
payload: { text: "hi", replyToId: "123" },
channel: "telegram",
to: "telegram:123",
cfg: {} as never,
});
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
"telegram:123",
"hi",
expect.objectContaining({ replyToMessageId: 123 }),
);
});
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" }),
);
});
it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
threadId: "1710000000.9999",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "1710000000.9999" }),
);
});
it("sends multiple mediaUrls (caption only on first)", async () => {
mocks.sendMessageSlack.mockClear();
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" }),
);
});
it("routes WhatsApp via outbound sender (accountId honored)", async () => {
mocks.sendMessageWhatsApp.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "whatsapp",
to: "+15551234567",
accountId: "acc-1",
cfg: {} as never,
});
expect(mocks.sendMessageWhatsApp).toHaveBeenCalledWith(
"+15551234567",
"hi",
expect.objectContaining({ accountId: "acc-1", verbose: false }),
);
});
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
setActivePluginRegistry(
createRegistry([
{
pluginId: "msteams",
source: "test",
plugin: createMSTeamsPlugin({
outbound: createMSTeamsOutbound(),
}),
},
]),
);
const cfg = {
channels: {
msteams: {
enabled: true,
},
},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "msteams",
to: "conversation:19:abc@thread.tacv2",
cfg,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
}),
);
});
it("passes mirror data when sessionKey is set", async () => {
mocks.deliverOutboundPayloads.mockResolvedValue([]);
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:main:main",
cfg: {} as never,
});
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
expect.objectContaining({
mirror: expect.objectContaining({
sessionKey: "agent:main:main",
text: "hi",
}),
}),
);
});
});
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" }),
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",
},
]);