From 236e041ef9d7eed926bf5902c69f1c7e570bca80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Mar 2026 20:10:52 +0000 Subject: [PATCH] test: share discord monitor fixtures --- ...messages-mentionpatterns-match.e2e.test.ts | 304 ++-------------- ...ends-status-replies-responseprefix.test.ts | 159 +-------- .../src/monitor.tool-result.test-helpers.ts | 326 ++++++++++++++++++ .../monitor/inbound-context.test-helpers.ts | 37 ++ .../message-handler.inbound-context.test.ts | 35 +- ...outbound-adapter.interactive-order.test.ts | 24 +- .../src/outbound-adapter.test-harness.ts | 24 ++ .../discord/src/outbound-adapter.test.ts | 24 +- .../contracts/inbound.contract.test.ts | 36 +- 9 files changed, 439 insertions(+), 530 deletions(-) create mode 100644 extensions/discord/src/monitor.tool-result.test-helpers.ts create mode 100644 extensions/discord/src/monitor/inbound-context.test-helpers.ts diff --git a/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index e7f5ad4045b..bc1bcb81b6e 100644 --- a/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -1,237 +1,49 @@ -import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatchMock } from "./monitor.tool-result.test-harness.js"; import { - dispatchMock, - loadConfigMock, - readAllowFromStoreMock, - updateLastRouteMock, - upsertPairingRequestMock, -} from "./monitor.tool-result.test-harness.js"; -import { createDiscordMessageHandler } from "./monitor/message-handler.js"; -import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; -import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; - -type Config = ReturnType; - -const BASE_CFG: Config = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: "/tmp/openclaw", - }, - }, - messages: { - inbound: { debounceMs: 0 }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, -}; + captureNextDispatchCtx, + type Config, + createGuildHandler, + createGuildMessageEvent, + createGuildTextClient, + createMentionRequiredGuildConfig, + createThreadChannel, + createThreadClient, + createThreadEvent, + resetDiscordToolResultHarness, +} from "./monitor.tool-result.test-helpers.js"; beforeEach(() => { - __resetDiscordChannelInfoCacheForTest(); - updateLastRouteMock.mockClear(); - dispatchMock.mockClear().mockImplementation(async ({ dispatcher }) => { - dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; - }); - readAllowFromStoreMock.mockClear().mockResolvedValue([]); - upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); - loadConfigMock.mockClear().mockReturnValue(BASE_CFG); + resetDiscordToolResultHarness(); }); -function createHandlerBaseConfig(cfg: Config): Parameters[0] { - return { - 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, - threadBindings: createNoopThreadBindingManager("default"), - }; -} - async function createHandler(cfg: Config) { - loadConfigMock.mockReturnValue(cfg); - return createDiscordMessageHandler({ - ...createHandlerBaseConfig(cfg), - guildEntries: cfg.channels?.discord?.guilds, - }); + return createGuildHandler({ cfg }); } -function createGuildTextClient() { +function createOpenGuildConfig( + channels: Record, + extra: Partial = {}, +): Config { return { - fetchChannel: vi.fn().mockResolvedValue({ - id: "c1", - type: ChannelType.GuildText, - name: "general", - }), - rest: { get: vi.fn() }, - } as unknown as Client; -} - -function createGuildMessageEvent(params: { - messageId: string; - content: string; - messagePatch?: Record; - eventPatch?: Record; -}) { - const messageBase = { - timestamp: new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - }; - return { - message: { - id: params.messageId, - content: params.content, - channelId: "c1", - ...messageBase, - author: { id: "u1", bot: false, username: "Ada" }, - ...params.messagePatch, - }, - author: { id: "u1", bot: false, username: "Ada" }, - member: { nickname: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - ...params.eventPatch, - }; -} - -function createThreadChannel(params: { includeStarter?: boolean; type?: ChannelType } = {}) { - return { - id: "t1", - type: params.type ?? ChannelType.PublicThread, - name: "thread-name", - parentId: params.type === ChannelType.PublicThread ? "forum-1" : "p1", - parent: { - id: params.type === ChannelType.PublicThread ? "forum-1" : "p1", - name: params.type === ChannelType.PublicThread ? "support" : "general", - }, - isThread: () => true, - ...(params.includeStarter - ? { - fetchStarterMessage: async () => ({ - content: "starter message", - author: { tag: "Alice#1", username: "Alice" }, - createdTimestamp: Date.now(), - }), - } - : {}), - }; -} - -function createThreadClient( - params: { - fetchChannel?: ReturnType; - restGet?: ReturnType; - } = {}, -) { - return { - fetchChannel: - params.fetchChannel ?? - vi - .fn() - .mockResolvedValueOnce({ - id: "t1", - type: ChannelType.PublicThread, - name: "thread-name", - parentId: "p1", - ownerId: "owner-1", - }) - .mockResolvedValueOnce({ - id: "p1", - type: ChannelType.GuildText, - name: "general", - }), - rest: { - get: - params.restGet ?? - vi.fn().mockResolvedValue({ - content: "starter message", - author: { id: "u1", username: "Alice", discriminator: "0001" }, - timestamp: new Date().toISOString(), - }), - }, - } as unknown as Client; -} - -function createThreadEvent(messageId: string, channelId = "t1") { - return { - message: { - id: messageId, - content: "thread hello", - channelId, - 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", - }; -} - -function createMentionRequiredGuildConfig(overrides?: Partial): Config { - return { - ...BASE_CFG, + ...createMentionRequiredGuildConfig(), + ...extra, channels: { discord: { dm: { enabled: true, policy: "open" }, groupPolicy: "open", guilds: { "*": { - requireMention: true, - channels: { c1: { allow: true } }, + requireMention: false, + channels, }, }, }, }, - ...overrides, } as Config; } -function captureNextDispatchCtx< - T extends { - SessionKey?: string; - ParentSessionKey?: string; - ThreadStarterBody?: string; - ThreadLabel?: string; - WasMentioned?: boolean; - }, ->(): () => T | undefined { - let capturedCtx: T | undefined; - dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { - capturedCtx = ctx as T; - dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { final: 1 } }; - }); - return () => capturedCtx; -} - describe("discord tool result dispatch", () => { it("accepts guild messages when mentionPatterns match", async () => { const cfg = createMentionRequiredGuildConfig({ @@ -289,21 +101,7 @@ describe("discord tool result dispatch", () => { ThreadStarterBody?: string; ThreadLabel?: string; }>(); - const cfg = { - ...createMentionRequiredGuildConfig(), - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { - "*": { - requireMention: false, - channels: { p1: { allow: true } }, - }, - }, - }, - }, - } as Config; + const cfg = createOpenGuildConfig({ p1: { allow: true } }); const handler = await createHandler(cfg); const client = createThreadClient({ @@ -325,23 +123,9 @@ describe("discord tool result dispatch", () => { it("skips thread starter context when disabled", async () => { const getCapturedCtx = captureNextDispatchCtx<{ ThreadStarterBody?: string }>(); - const cfg = { - ...createMentionRequiredGuildConfig(), - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { - "*": { - requireMention: false, - channels: { - p1: { allow: true, includeThreadStarter: false }, - }, - }, - }, - }, - }, - } as Config; + const cfg = createOpenGuildConfig({ + p1: { allow: true, includeThreadStarter: false }, + }); const handler = await createHandler(cfg); const client = createThreadClient(); @@ -359,21 +143,7 @@ describe("discord tool result dispatch", () => { ThreadStarterBody?: string; ThreadLabel?: string; }>(); - const cfg = { - ...createMentionRequiredGuildConfig(), - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { - "*": { - requireMention: false, - channels: { "forum-1": { allow: true } }, - }, - }, - }, - }, - } as Config; + const cfg = createOpenGuildConfig({ "forum-1": { allow: true } }); const fetchChannel = vi .fn() @@ -411,22 +181,10 @@ describe("discord tool result dispatch", () => { SessionKey?: string; ParentSessionKey?: string; }>(); - const cfg = { - ...createMentionRequiredGuildConfig(), - bindings: [{ agentId: "support", match: { channel: "discord", guildId: "g1" } }], - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - groupPolicy: "open", - guilds: { - "*": { - requireMention: false, - channels: { p1: { allow: true } }, - }, - }, - }, - }, - } as Config; + const cfg = createOpenGuildConfig( + { p1: { allow: true } }, + { bindings: [{ agentId: "support", match: { channel: "discord", guildId: "g1" } }] }, + ); const handler = await createHandler(cfg); const client = createThreadClient(); diff --git a/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts index fc0dba0c0a0..79cf61da3ab 100644 --- a/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -1,159 +1,26 @@ -import type { Client } from "@buape/carbon"; import { MessageType } from "@buape/carbon"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js"; import { dispatchMock, - loadConfigMock, - readAllowFromStoreMock, sendMock, - updateLastRouteMock, upsertPairingRequestMock, } from "./monitor.tool-result.test-harness.js"; +import { + BASE_CFG, + createCategoryGuildClient, + createCategoryGuildEvent, + createCategoryGuildHandler, + createDmClient, + createDmHandler, + type Config, + resetDiscordToolResultHarness, +} from "./monitor.tool-result.test-helpers.js"; -type Config = ReturnType; -let ChannelType: typeof import("@buape/carbon").ChannelType; -let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").createDiscordMessageHandler; -let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest; -let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager; - -beforeAll(async () => { - ({ ChannelType } = await import("@buape/carbon")); - ({ createDiscordMessageHandler } = await import("./monitor/message-handler.js")); - ({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js")); - ({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js")); +beforeEach(() => { + resetDiscordToolResultHarness(); }); -beforeEach(async () => { - __resetDiscordChannelInfoCacheForTest(); - sendMock.mockClear().mockResolvedValue(undefined); - updateLastRouteMock.mockClear(); - dispatchMock.mockClear().mockImplementation(async ({ dispatcher }) => { - dispatcher.sendFinalReply({ text: "hi" }); - return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; - }); - readAllowFromStoreMock.mockClear().mockResolvedValue([]); - upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); - loadConfigMock.mockClear().mockReturnValue(BASE_CFG); -}); - -const BASE_CFG: Config = { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-5" }, - workspace: "/tmp/openclaw", - }, - }, - messages: { - inbound: { debounceMs: 0 }, - }, - session: { store: "/tmp/openclaw-sessions.json" }, -}; - -const CATEGORY_GUILD_CFG = { - ...BASE_CFG, - channels: { - discord: { - dm: { enabled: true, policy: "open" }, - guilds: { - "*": { - requireMention: false, - channels: { c1: { allow: true } }, - }, - }, - }, - }, -} satisfies Config; - -function createHandlerBaseConfig( - cfg: Config, - runtimeError?: (err: unknown) => void, -): Parameters[0] { - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "token", - runtime: { - log: vi.fn(), - error: runtimeError ?? 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, - threadBindings: createNoopThreadBindingManager("default"), - }; -} - -async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) { - loadConfigMock.mockReturnValue(opts.cfg); - return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError)); -} - -function createDmClient() { - return { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.DM, - name: "dm", - }), - } as unknown as Client; -} - -async function createCategoryGuildHandler(runtimeError?: (err: unknown) => void) { - loadConfigMock.mockReturnValue(CATEGORY_GUILD_CFG); - return createDiscordMessageHandler({ - ...createHandlerBaseConfig(CATEGORY_GUILD_CFG, runtimeError), - guildEntries: { - "*": { requireMention: false, channels: { c1: { allow: true } } }, - }, - }); -} - -function createCategoryGuildClient() { - return { - fetchChannel: vi.fn().mockResolvedValue({ - type: ChannelType.GuildText, - name: "general", - parentId: "category-1", - }), - rest: { get: vi.fn() }, - } as unknown as Client; -} - -function createCategoryGuildEvent(params: { - messageId: string; - timestamp?: string; - author: Record; -}) { - return { - message: { - id: params.messageId, - content: "hello", - channelId: "c1", - timestamp: params.timestamp ?? new Date().toISOString(), - type: MessageType.Default, - attachments: [], - embeds: [], - mentionedEveryone: false, - mentionedUsers: [], - mentionedRoles: [], - author: params.author, - }, - author: params.author, - member: { displayName: "Ada" }, - guild: { id: "g1", name: "Guild" }, - guild_id: "g1", - }; -} - describe("discord tool result dispatch", () => { it("uses channel id allowlists for non-thread channels with categories", async () => { let capturedCtx: { SessionKey?: string } | undefined; diff --git a/extensions/discord/src/monitor.tool-result.test-helpers.ts b/extensions/discord/src/monitor.tool-result.test-helpers.ts new file mode 100644 index 00000000000..beaa21e07ba --- /dev/null +++ b/extensions/discord/src/monitor.tool-result.test-helpers.ts @@ -0,0 +1,326 @@ +import type { Client } from "@buape/carbon"; +import { ChannelType, MessageType } from "@buape/carbon"; +import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; +import { vi } from "vitest"; +import { + dispatchMock, + loadConfigMock, + readAllowFromStoreMock, + sendMock, + updateLastRouteMock, + upsertPairingRequestMock, +} from "./monitor.tool-result.test-harness.js"; +import { createDiscordMessageHandler } from "./monitor/message-handler.js"; +import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; +import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; + +export type Config = ReturnType; + +export const BASE_CFG: Config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-5" }, + workspace: "/tmp/openclaw", + }, + }, + messages: { + inbound: { debounceMs: 0 }, + }, + session: { store: "/tmp/openclaw-sessions.json" }, +}; + +export const CATEGORY_GUILD_CFG = { + ...BASE_CFG, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + guilds: { + "*": { + requireMention: false, + channels: { c1: { allow: true } }, + }, + }, + }, + }, +} satisfies Config; + +export function resetDiscordToolResultHarness() { + __resetDiscordChannelInfoCacheForTest(); + sendMock.mockClear().mockResolvedValue(undefined); + updateLastRouteMock.mockClear(); + dispatchMock.mockClear().mockImplementation(async ({ dispatcher }) => { + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } }; + }); + readAllowFromStoreMock.mockClear().mockResolvedValue([]); + upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); + loadConfigMock.mockClear().mockReturnValue(BASE_CFG); +} + +export function createHandlerBaseConfig( + cfg: Config, + runtimeError?: (err: unknown) => void, +): Parameters[0] { + return { + cfg, + discordConfig: cfg.channels?.discord, + accountId: "default", + token: "token", + runtime: { + log: vi.fn(), + error: runtimeError ?? 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, + threadBindings: createNoopThreadBindingManager("default"), + }; +} + +export async function createDmHandler(params: { + cfg: Config; + runtimeError?: (err: unknown) => void; +}) { + loadConfigMock.mockReturnValue(params.cfg); + return createDiscordMessageHandler(createHandlerBaseConfig(params.cfg, params.runtimeError)); +} + +export async function createGuildHandler(params: { + cfg: Config; + guildEntries?: Parameters[0]["guildEntries"]; + runtimeError?: (err: unknown) => void; +}) { + loadConfigMock.mockReturnValue(params.cfg); + return createDiscordMessageHandler({ + ...createHandlerBaseConfig(params.cfg, params.runtimeError), + guildEntries: + params.guildEntries ?? + (params.cfg.channels?.discord?.guilds as Parameters< + typeof createDiscordMessageHandler + >[0]["guildEntries"]), + }); +} + +export function createDmClient() { + return { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.DM, + name: "dm", + }), + } as unknown as Client; +} + +export async function createCategoryGuildHandler(runtimeError?: (err: unknown) => void) { + return createGuildHandler({ + cfg: CATEGORY_GUILD_CFG, + guildEntries: { + "*": { requireMention: false, channels: { c1: { allow: true } } }, + }, + runtimeError, + }); +} + +export function createCategoryGuildClient() { + return { + fetchChannel: vi.fn().mockResolvedValue({ + type: ChannelType.GuildText, + name: "general", + parentId: "category-1", + }), + rest: { get: vi.fn() }, + } as unknown as Client; +} + +export function createCategoryGuildEvent(params: { + messageId: string; + timestamp?: string; + author: Record; +}) { + return { + message: { + id: params.messageId, + content: "hello", + channelId: "c1", + timestamp: params.timestamp ?? new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + author: params.author, + }, + author: params.author, + member: { displayName: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + }; +} + +export function createGuildTextClient() { + return { + fetchChannel: vi.fn().mockResolvedValue({ + id: "c1", + type: ChannelType.GuildText, + name: "general", + }), + rest: { get: vi.fn() }, + } as unknown as Client; +} + +export function createGuildMessageEvent(params: { + messageId: string; + content: string; + messagePatch?: Record; + eventPatch?: Record; +}) { + const messageBase = { + timestamp: new Date().toISOString(), + type: MessageType.Default, + attachments: [], + embeds: [], + mentionedEveryone: false, + mentionedUsers: [], + mentionedRoles: [], + }; + return { + message: { + id: params.messageId, + content: params.content, + channelId: "c1", + ...messageBase, + author: { id: "u1", bot: false, username: "Ada" }, + ...params.messagePatch, + }, + author: { id: "u1", bot: false, username: "Ada" }, + member: { nickname: "Ada" }, + guild: { id: "g1", name: "Guild" }, + guild_id: "g1", + ...params.eventPatch, + }; +} + +export function createThreadChannel(params: { includeStarter?: boolean; type?: ChannelType } = {}) { + return { + id: "t1", + type: params.type ?? ChannelType.PublicThread, + name: "thread-name", + parentId: params.type === ChannelType.PublicThread ? "forum-1" : "p1", + parent: { + id: params.type === ChannelType.PublicThread ? "forum-1" : "p1", + name: params.type === ChannelType.PublicThread ? "support" : "general", + }, + isThread: () => true, + ...(params.includeStarter + ? { + fetchStarterMessage: async () => ({ + content: "starter message", + author: { tag: "Alice#1", username: "Alice" }, + createdTimestamp: Date.now(), + }), + } + : {}), + }; +} + +export function createThreadClient( + params: { + fetchChannel?: ReturnType; + restGet?: ReturnType; + } = {}, +) { + return { + fetchChannel: + params.fetchChannel ?? + vi + .fn() + .mockResolvedValueOnce({ + id: "t1", + type: ChannelType.PublicThread, + name: "thread-name", + parentId: "p1", + ownerId: "owner-1", + }) + .mockResolvedValueOnce({ + id: "p1", + type: ChannelType.GuildText, + name: "general", + }), + rest: { + get: + params.restGet ?? + vi.fn().mockResolvedValue({ + content: "starter message", + author: { id: "u1", username: "Alice", discriminator: "0001" }, + timestamp: new Date().toISOString(), + }), + }, + } as unknown as Client; +} + +export function createThreadEvent(messageId: string, channelId = "t1") { + return { + message: { + id: messageId, + content: "thread hello", + channelId, + 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", + }; +} + +export function createMentionRequiredGuildConfig(overrides?: Partial): Config { + return { + ...BASE_CFG, + channels: { + discord: { + dm: { enabled: true, policy: "open" }, + groupPolicy: "open", + guilds: { + "*": { + requireMention: true, + channels: { c1: { allow: true } }, + }, + }, + }, + }, + ...overrides, + } as Config; +} + +export function captureNextDispatchCtx< + T extends { + SessionKey?: string; + ParentSessionKey?: string; + ThreadStarterBody?: string; + ThreadLabel?: string; + WasMentioned?: boolean; + }, +>(): () => T | undefined { + let capturedCtx: T | undefined; + dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => { + capturedCtx = ctx as T; + dispatcher.sendFinalReply({ text: "hi" }); + return { queuedFinal: true, counts: { final: 1 } }; + }); + return () => capturedCtx; +} diff --git a/extensions/discord/src/monitor/inbound-context.test-helpers.ts b/extensions/discord/src/monitor/inbound-context.test-helpers.ts new file mode 100644 index 00000000000..349b70981f8 --- /dev/null +++ b/extensions/discord/src/monitor/inbound-context.test-helpers.ts @@ -0,0 +1,37 @@ +import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime"; +import { buildDiscordInboundAccessContext } from "./inbound-context.js"; + +export function buildFinalizedDiscordDirectInboundContext() { + const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({ + channelConfig: null, + guildInfo: null, + sender: { id: "U1", name: "Alice", tag: "alice" }, + isGuild: false, + }); + + return finalizeInboundContext({ + Body: "hi", + BodyForAgent: "hi", + RawBody: "hi", + CommandBody: "hi", + From: "discord:U1", + To: "user:U1", + SessionKey: "agent:main:discord:direct:u1", + AccountId: "default", + ChatType: "direct", + ConversationLabel: "Alice", + SenderName: "Alice", + SenderId: "U1", + SenderUsername: "alice", + GroupSystemPrompt: groupSystemPrompt, + OwnerAllowFrom: ownerAllowFrom, + UntrustedContext: untrustedContext, + Provider: "discord", + Surface: "discord", + WasMentioned: false, + MessageSid: "m1", + CommandAuthorized: true, + OriginatingChannel: "discord", + OriginatingTo: "user:U1", + }); +} diff --git a/extensions/discord/src/monitor/message-handler.inbound-context.test.ts b/extensions/discord/src/monitor/message-handler.inbound-context.test.ts index 4d965d13602..1e7ef0c0e16 100644 --- a/extensions/discord/src/monitor/message-handler.inbound-context.test.ts +++ b/extensions/discord/src/monitor/message-handler.inbound-context.test.ts @@ -2,42 +2,11 @@ import { describe, expect, it } from "vitest"; import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; +import { buildFinalizedDiscordDirectInboundContext } from "./inbound-context.test-helpers.js"; describe("discord processDiscordMessage inbound context", () => { it("builds a finalized direct-message MsgContext shape", () => { - const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = - buildDiscordInboundAccessContext({ - channelConfig: null, - guildInfo: null, - sender: { id: "U1", name: "Alice", tag: "alice" }, - isGuild: false, - }); - - const ctx = finalizeInboundContext({ - Body: "hi", - BodyForAgent: "hi", - RawBody: "hi", - CommandBody: "hi", - From: "discord:U1", - To: "user:U1", - SessionKey: "agent:main:discord:direct:u1", - AccountId: "default", - ChatType: "direct", - ConversationLabel: "Alice", - SenderName: "Alice", - SenderId: "U1", - SenderUsername: "alice", - GroupSystemPrompt: groupSystemPrompt, - OwnerAllowFrom: ownerAllowFrom, - UntrustedContext: untrustedContext, - Provider: "discord", - Surface: "discord", - WasMentioned: false, - MessageSid: "m1", - CommandAuthorized: true, - OriginatingChannel: "discord", - OriginatingTo: "user:U1", - }); + const ctx = buildFinalizedDiscordDirectInboundContext(); expectInboundContextContract(ctx); }); diff --git a/extensions/discord/src/outbound-adapter.interactive-order.test.ts b/extensions/discord/src/outbound-adapter.interactive-order.test.ts index 1e761e0cc98..80cd34f96eb 100644 --- a/extensions/discord/src/outbound-adapter.interactive-order.test.ts +++ b/extensions/discord/src/outbound-adapter.interactive-order.test.ts @@ -1,32 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDiscordOutboundHoisted, - createDiscordSendModuleMock, - createDiscordThreadBindingsModuleMock, + installDiscordOutboundModuleSpies, resetDiscordOutboundMocks, } from "./outbound-adapter.test-harness.js"; const hoisted = createDiscordOutboundHoisted(); - -const sendModule = await import("./send.js"); -const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule); -vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(mockedSendModule.sendMessageDiscord); -vi.spyOn(sendModule, "sendDiscordComponentMessage").mockImplementation( - mockedSendModule.sendDiscordComponentMessage, -); -vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord); -vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation( - mockedSendModule.sendWebhookMessageDiscord, -); - -const threadBindingsModule = await import("./monitor/thread-bindings.js"); -const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock( - hoisted, - async () => threadBindingsModule, -); -vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation( - mockedThreadBindingsModule.getThreadBindingManager, -); +await installDiscordOutboundModuleSpies(hoisted); const { discordOutbound } = await import("./outbound-adapter.js"); diff --git a/extensions/discord/src/outbound-adapter.test-harness.ts b/extensions/discord/src/outbound-adapter.test-harness.ts index 2562863c68d..e9f43bd097f 100644 --- a/extensions/discord/src/outbound-adapter.test-harness.ts +++ b/extensions/discord/src/outbound-adapter.test-harness.ts @@ -53,6 +53,30 @@ export async function createDiscordThreadBindingsModuleMock( }; } +export async function installDiscordOutboundModuleSpies(hoisted: DiscordOutboundHoisted) { + const sendModule = await import("./send.js"); + const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule); + vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation( + mockedSendModule.sendMessageDiscord, + ); + vi.spyOn(sendModule, "sendDiscordComponentMessage").mockImplementation( + mockedSendModule.sendDiscordComponentMessage, + ); + vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord); + vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation( + mockedSendModule.sendWebhookMessageDiscord, + ); + + const threadBindingsModule = await import("./monitor/thread-bindings.js"); + const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock( + hoisted, + async () => threadBindingsModule, + ); + vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation( + mockedThreadBindingsModule.getThreadBindingManager, + ); +} + export function resetDiscordOutboundMocks(hoisted: DiscordOutboundHoisted) { hoisted.sendMessageDiscordMock.mockReset().mockResolvedValue({ messageId: "msg-1", diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index d12b34a038c..dbc71f9b467 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -1,34 +1,14 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createDiscordOutboundHoisted, - createDiscordSendModuleMock, - createDiscordThreadBindingsModuleMock, expectDiscordThreadBotSend, + installDiscordOutboundModuleSpies, mockDiscordBoundThreadManager, resetDiscordOutboundMocks, } from "./outbound-adapter.test-harness.js"; const hoisted = createDiscordOutboundHoisted(); - -const sendModule = await import("./send.js"); -const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule); -vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(mockedSendModule.sendMessageDiscord); -vi.spyOn(sendModule, "sendDiscordComponentMessage").mockImplementation( - mockedSendModule.sendDiscordComponentMessage, -); -vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord); -vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation( - mockedSendModule.sendWebhookMessageDiscord, -); - -const threadBindingsModule = await import("./monitor/thread-bindings.js"); -const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock( - hoisted, - async () => threadBindingsModule, -); -vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation( - mockedThreadBindingsModule.getThreadBindingManager, -); +await installDiscordOutboundModuleSpies(hoisted); let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget; let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound; diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts index 6fe8ec020ce..3b54d199d2f 100644 --- a/src/channels/plugins/contracts/inbound.contract.test.ts +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildDiscordInboundAccessContext } from "../../../../extensions/discord/src/monitor/inbound-context.js"; +import { buildFinalizedDiscordDirectInboundContext } from "../../../../extensions/discord/src/monitor/inbound-context.test-helpers.js"; import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js"; import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js"; import { withTempHome } from "../../../../test/helpers/temp-home.js"; @@ -114,39 +114,7 @@ describe("channel inbound contract", () => { }); it("keeps Discord inbound context finalized", () => { - const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = - buildDiscordInboundAccessContext({ - channelConfig: null, - guildInfo: null, - sender: { id: "U1", name: "Alice", tag: "alice" }, - isGuild: false, - }); - - const ctx = finalizeInboundContext({ - Body: "hi", - BodyForAgent: "hi", - RawBody: "hi", - CommandBody: "hi", - From: "discord:U1", - To: "user:U1", - SessionKey: "agent:main:discord:direct:u1", - AccountId: "default", - ChatType: "direct", - ConversationLabel: "Alice", - SenderName: "Alice", - SenderId: "U1", - SenderUsername: "alice", - GroupSystemPrompt: groupSystemPrompt, - OwnerAllowFrom: ownerAllowFrom, - UntrustedContext: untrustedContext, - Provider: "discord", - Surface: "discord", - WasMentioned: false, - MessageSid: "m1", - CommandAuthorized: true, - OriginatingChannel: "discord", - OriginatingTo: "user:U1", - }); + const ctx = buildFinalizedDiscordDirectInboundContext(); expectChannelInboundContextContract(ctx); });