import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; const sendMock = vi.fn(); const reactMock = vi.fn(); const updateLastRouteMock = vi.fn(); const dispatchMock = vi.fn(); const readAllowFromStoreMock = vi.fn(); const upsertPairingRequestMock = vi.fn(); vi.mock("./send.js", () => ({ sendMessageDiscord: (...args: unknown[]) => sendMock(...args), reactMessageDiscord: async (...args: unknown[]) => { reactMock(...args); }, })); vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({ dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args), })); vi.mock("../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); vi.mock("../config/sessions.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), resolveSessionKey: vi.fn(), }; }); beforeEach(() => { vi.useRealTimers(); sendMock.mockReset().mockResolvedValue(undefined); updateLastRouteMock.mockReset(); dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => { dispatcher.sendFinalReply({ text: "hi" }); return { queuedFinal: true, counts: { final: 1 } }; }); readAllowFromStoreMock.mockReset().mockResolvedValue([]); upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); __resetDiscordChannelInfoCacheForTest(); }); describe("discord tool result dispatch", () => { it("accepts guild messages when mentionPatterns match", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd", }, }, session: { store: "/tmp/clawdbot-sessions.json" }, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { requireMention: true } }, }, }, messages: { responsePrefix: "PFX", groupChat: { mentionPatterns: ["\\bclawd\\b"] }, }, } as ReturnType; const handler = createDiscordMessageHandler({ cfg, discordConfig: cfg.channels.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), error: vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, }, botUserId: "bot-id", guildHistories: new Map(), historyLimit: 0, mediaMaxBytes: 10_000, textLimit: 2000, replyToMode: "off", dmEnabled: true, groupDmEnabled: false, guildEntries: { "*": { requireMention: true } }, }); const client = { fetchChannel: vi.fn().mockResolvedValue({ type: ChannelType.GuildText, name: "general", }), } as unknown as Client; await handler( { message: { id: "m2", content: "clawd: hello", channelId: "c1", timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "u1", bot: false, username: "Ada" }, }, author: { id: "u1", bot: false, username: "Ada" }, member: { nickname: "Ada" }, guild: { id: "g1", name: "Guild" }, guild_id: "g1", }, client, ); expect(dispatchMock).toHaveBeenCalledTimes(1); expect(sendMock).toHaveBeenCalledTimes(1); }, 20_000); it("accepts guild reply-to-bot messages as implicit mentions", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd", }, }, session: { store: "/tmp/clawdbot-sessions.json" }, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { requireMention: true } }, }, }, } as ReturnType; const handler = createDiscordMessageHandler({ cfg, discordConfig: cfg.channels.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), error: vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, }, botUserId: "bot-id", guildHistories: new Map(), historyLimit: 0, mediaMaxBytes: 10_000, textLimit: 2000, replyToMode: "off", dmEnabled: true, groupDmEnabled: false, guildEntries: { "*": { requireMention: true } }, }); const client = { fetchChannel: vi.fn().mockResolvedValue({ type: ChannelType.GuildText, name: "general", }), } as unknown as Client; await handler( { message: { id: "m3", content: "following up", channelId: "c1", timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "u1", bot: false, username: "Ada" }, referencedMessage: { id: "m2", channelId: "c1", content: "bot reply", timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "bot-id", bot: true, username: "Clawdbot" }, }, }, author: { id: "u1", bot: false, username: "Ada" }, member: { nickname: "Ada" }, guild: { id: "g1", name: "Guild" }, guild_id: "g1", channel: { id: "c1", type: ChannelType.GuildText }, client, data: { id: "m3", content: "following up", channel_id: "c1", guild_id: "g1", type: MessageType.Default, mentions: [], }, }, client, ); expect(dispatchMock).toHaveBeenCalledTimes(1); const payload = dispatchMock.mock.calls[0]?.[0]?.ctx as Record; expect(payload.WasMentioned).toBe(true); }); it("forks thread sessions and injects starter context", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: | { SessionKey?: string; ParentSessionKey?: string; ThreadStarterBody?: string; ThreadLabel?: string; } | undefined; dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { capturedCtx = ctx; dispatcher.sendFinalReply({ text: "hi" }); return { queuedFinal: true, counts: { final: 1 } }; }); const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd", }, }, session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { requireMention: false } }, }, }, } as ReturnType; const handler = createDiscordMessageHandler({ cfg, discordConfig: cfg.channels.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), error: vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, }, botUserId: "bot-id", guildHistories: new Map(), historyLimit: 0, mediaMaxBytes: 10_000, textLimit: 2000, replyToMode: "off", dmEnabled: true, groupDmEnabled: false, guildEntries: { "*": { requireMention: false } }, }); const threadChannel = { type: ChannelType.GuildText, name: "thread-name", parentId: "p1", parent: { id: "p1", name: "general" }, isThread: () => true, fetchStarterMessage: async () => ({ content: "starter message", author: { tag: "Alice#1", username: "Alice" }, createdTimestamp: Date.now(), }), }; const client = { fetchChannel: vi.fn().mockResolvedValue({ type: ChannelType.GuildText, name: "thread-name", }), rest: { get: vi.fn().mockResolvedValue({ content: "starter message", author: { id: "u1", username: "Alice", discriminator: "0001" }, timestamp: new Date().toISOString(), }), }, } as unknown as Client; await handler( { message: { id: "m4", content: "thread reply", channelId: "t1", channel: threadChannel, timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, }, author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, member: { displayName: "Bob" }, guild: { id: "g1", name: "Guild" }, guild_id: "g1", }, client, ); expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:p1"); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); }); it("treats forum threads as distinct sessions without channel payloads", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: | { SessionKey?: string; ParentSessionKey?: string; ThreadStarterBody?: string; ThreadLabel?: string; } | undefined; dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { capturedCtx = ctx; dispatcher.sendFinalReply({ text: "hi" }); return { queuedFinal: true, counts: { final: 1 } }; }); const cfg = { agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" }, session: { store: "/tmp/clawdbot-sessions.json" }, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { requireMention: false } }, }, }, routing: { allowFrom: [] }, } as ReturnType; const handler = createDiscordMessageHandler({ cfg, discordConfig: cfg.channels.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), error: vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, }, botUserId: "bot-id", guildHistories: new Map(), historyLimit: 0, mediaMaxBytes: 10_000, textLimit: 2000, replyToMode: "off", dmEnabled: true, groupDmEnabled: false, guildEntries: { "*": { requireMention: false } }, }); const fetchChannel = vi .fn() .mockResolvedValueOnce({ type: ChannelType.PublicThread, name: "topic-1", parentId: "forum-1", }) .mockResolvedValueOnce({ type: ChannelType.GuildForum, name: "support", }); const restGet = vi.fn().mockResolvedValue({ content: "starter message", author: { id: "u1", username: "Alice", discriminator: "0001" }, timestamp: new Date().toISOString(), }); const client = { fetchChannel, rest: { get: restGet, }, } as unknown as Client; await handler( { message: { id: "m6", content: "thread reply", channelId: "t1", timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, }, author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, member: { displayName: "Bob" }, guild: { id: "g1", name: "Guild" }, guild_id: "g1", }, client, ); expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1"); expect(capturedCtx?.ParentSessionKey).toBe("agent:main:discord:channel:forum-1"); expect(capturedCtx?.ThreadStarterBody).toContain("starter message"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support"); expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1")); }); it("scopes thread sessions to the routed agent", async () => { const { createDiscordMessageHandler } = await import("./monitor.js"); let capturedCtx: | { SessionKey?: string; ParentSessionKey?: string; } | undefined; dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { capturedCtx = ctx; dispatcher.sendFinalReply({ text: "hi" }); return { queuedFinal: true, counts: { final: 1 } }; }); const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd", }, }, session: { store: "/tmp/clawdbot-sessions.json" }, messages: { responsePrefix: "PFX" }, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { requireMention: false } }, }, }, bindings: [{ agentId: "support", match: { channel: "discord", guildId: "g1" } }], } as ReturnType; const handler = createDiscordMessageHandler({ cfg, discordConfig: cfg.channels.discord, accountId: "default", token: "token", runtime: { log: vi.fn(), error: vi.fn(), exit: (code: number): never => { throw new Error(`exit ${code}`); }, }, botUserId: "bot-id", guildHistories: new Map(), historyLimit: 0, mediaMaxBytes: 10_000, textLimit: 2000, replyToMode: "off", dmEnabled: true, groupDmEnabled: false, guildEntries: { "*": { requireMention: false } }, }); const threadChannel = { type: ChannelType.GuildText, name: "thread-name", parentId: "p1", parent: { id: "p1", name: "general" }, isThread: () => true, }; const client = { fetchChannel: vi.fn().mockResolvedValue({ type: ChannelType.GuildText, name: "thread-name", }), rest: { get: vi.fn().mockResolvedValue({ content: "starter message", author: { id: "u1", username: "Alice", discriminator: "0001" }, timestamp: new Date().toISOString(), }), }, } as unknown as Client; await handler( { message: { id: "m5", content: "thread reply", channelId: "t1", channel: threadChannel, timestamp: new Date().toISOString(), type: MessageType.Default, attachments: [], embeds: [], mentionedEveryone: false, mentionedUsers: [], mentionedRoles: [], author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, }, author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" }, member: { displayName: "Bob" }, guild: { id: "g1", name: "Guild" }, guild_id: "g1", }, client, ); expect(capturedCtx?.SessionKey).toBe("agent:support:discord:channel:t1"); expect(capturedCtx?.ParentSessionKey).toBe("agent:support:discord:channel:p1"); }); });