diff --git a/src/discord/monitor/message-handler.bot-self-filter.test.ts b/src/discord/monitor/message-handler.bot-self-filter.test.ts index 7f5b2276987..4358301b92d 100644 --- a/src/discord/monitor/message-handler.bot-self-filter.test.ts +++ b/src/discord/monitor/message-handler.bot-self-filter.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + DEFAULT_DISCORD_BOT_USER_ID, + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); @@ -15,53 +18,12 @@ vi.mock("./message-handler.process.js", () => ({ const { createDiscordMessageHandler } = await import("./message-handler.js"); -const BOT_USER_ID = "bot-123"; - -function createHandlerParams(overrides?: Partial<{ botUserId: string }>) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: overrides?.botUserId ?? BOT_USER_ID, - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - }; -} - function createMessageData(authorId: string, channelId = "ch-1") { return { - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, message: { id: "msg-1", - author: { id: authorId, bot: authorId === BOT_USER_ID }, + author: { id: authorId, bot: authorId === DEFAULT_DISCORD_BOT_USER_ID }, content: "hello", channel_id: channelId, }, @@ -70,26 +32,7 @@ function createMessageData(authorId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { - return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, - }; + return createDiscordPreflightContext(channelId); } describe("createDiscordMessageHandler bot-self filter", () => { @@ -97,10 +40,10 @@ describe("createDiscordMessageHandler bot-self filter", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( - handler(createMessageData(BOT_USER_ID) as never, {} as never), + handler(createMessageData(DEFAULT_DISCORD_BOT_USER_ID) as never, {} as never), ).resolves.toBeUndefined(); expect(preflightDiscordMessageMock).not.toHaveBeenCalled(); @@ -115,7 +58,7 @@ describe("createDiscordMessageHandler bot-self filter", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect( handler(createMessageData("user-456") as never, {} as never), diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 45fbfeee278..9301aad473d 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../config/types.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordHandlerParams, + createDiscordPreflightContext, +} from "./message-handler.test-helpers.js"; const preflightDiscordMessageMock = vi.hoisted(() => vi.fn()); const processDiscordMessageMock = vi.hoisted(() => vi.fn()); @@ -24,52 +26,6 @@ function createDeferred() { return { promise, resolve }; } -function createHandlerParams(overrides?: { - setStatus?: (patch: Record) => void; - abortSignal?: AbortSignal; - workerRunTimeoutMs?: number; -}) { - const cfg: OpenClawConfig = { - channels: { - discord: { - enabled: true, - token: "test-token", - groupPolicy: "allowlist", - }, - }, - messages: { - inbound: { - debounceMs: 0, - }, - }, - }; - return { - cfg, - discordConfig: cfg.channels?.discord, - accountId: "default", - token: "test-token", - runtime: { - log: vi.fn(), - error: vi.fn(), - exit: (code: number): never => { - throw new Error(`exit ${code}`); - }, - }, - botUserId: "bot-123", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 10_000, - textLimit: 2_000, - replyToMode: "off" as const, - dmEnabled: true, - groupDmEnabled: false, - threadBindings: createNoopThreadBindingManager("default"), - setStatus: overrides?.setStatus, - abortSignal: overrides?.abortSignal, - workerRunTimeoutMs: overrides?.workerRunTimeoutMs, - }; -} - function createMessageData(messageId: string, channelId = "ch-1") { return { channel_id: channelId, @@ -85,25 +41,43 @@ function createMessageData(messageId: string, channelId = "ch-1") { } function createPreflightContext(channelId = "ch-1") { + return createDiscordPreflightContext(channelId); +} + +async function createLifecycleStopScenario(params: { + createHandler: (status: ReturnType) => { + handler: (data: never, opts: never) => Promise; + stop: () => void; + }; +}) { + const runInFlight = createDeferred(); + processDiscordMessageMock.mockImplementation(async () => { + await runInFlight.promise; + }); + preflightDiscordMessageMock.mockImplementation( + async (contextParams: { data: { channel_id: string } }) => + createPreflightContext(contextParams.data.channel_id), + ); + + const setStatus = vi.fn(); + const { handler, stop } = params.createHandler(setStatus); + + await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); + await vi.waitFor(() => { + expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + }); + + const callsBeforeStop = setStatus.mock.calls.length; + stop(); + return { - data: { - channel_id: channelId, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, + setStatus, + callsBeforeStop, + finish: async () => { + runInFlight.resolve(); + await runInFlight.promise; + await Promise.resolve(); }, - message: { - id: `msg-${channelId}`, - channel_id: channelId, - attachments: [], - }, - route: { - sessionKey: `agent:main:discord:channel:${channelId}`, - }, - baseSessionKey: `agent:main:discord:channel:${channelId}`, - messageChannelId: channelId, }; } @@ -113,7 +87,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processDiscordMessageMock.mockReset(); const setStatus = vi.fn(); - createDiscordMessageHandler(createHandlerParams({ setStatus })); + createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); expect(setStatus).toHaveBeenCalledWith( expect.objectContaining({ @@ -142,7 +116,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -205,7 +179,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 50 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); const handler = createDiscordMessageHandler(params); await expect( @@ -256,7 +230,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const params = createHandlerParams({ workerRunTimeoutMs: 0 }); + const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); const handler = createDiscordMessageHandler(params); await expect( @@ -305,7 +279,7 @@ describe("createDiscordMessageHandler queue behavior", () => { try { const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect( handler(createMessageData("m-1") as never, {} as never), ).resolves.toBeUndefined(); @@ -342,67 +316,35 @@ describe("createDiscordMessageHandler queue behavior", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const abortController = new AbortController(); - const handler = createDiscordMessageHandler( - createHandlerParams({ setStatus, abortSignal: abortController.signal }), - ); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const abortController = new AbortController(); + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status, abortSignal: abortController.signal }), + ); + return { handler, stop: () => abortController.abort() }; + }, }); - const callsBeforeAbort = setStatus.mock.calls.length; - abortController.abort(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeAbort); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("stops status publishing after handler deactivation", async () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); - const runInFlight = createDeferred(); - processDiscordMessageMock.mockImplementation(async () => { - await runInFlight.promise; - }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); - - await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); - - await vi.waitFor(() => { - expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); + const { setStatus, callsBeforeStop, finish } = await createLifecycleStopScenario({ + createHandler: (status) => { + const handler = createDiscordMessageHandler( + createDiscordHandlerParams({ setStatus: status }), + ); + return { handler, stop: () => handler.deactivate() }; + }, }); - const callsBeforeDeactivate = setStatus.mock.calls.length; - handler.deactivate(); - - runInFlight.resolve(); - await runInFlight.promise; - await Promise.resolve(); - - expect(setStatus.mock.calls.length).toBe(callsBeforeDeactivate); + await finish(); + expect(setStatus.mock.calls.length).toBe(callsBeforeStop); }); it("skips queued runs that have not started yet after deactivation", async () => { @@ -420,7 +362,7 @@ describe("createDiscordMessageHandler queue behavior", () => { createPreflightContext(params.data.channel_id), ); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await vi.waitFor(() => { expect(processDiscordMessageMock).toHaveBeenCalledTimes(1); @@ -460,7 +402,7 @@ describe("createDiscordMessageHandler queue behavior", () => { processedMessageIds.push(ctx.messageId ?? "unknown"); }); - const handler = createDiscordMessageHandler(createHandlerParams()); + const handler = createDiscordMessageHandler(createDiscordHandlerParams()); const sequentialDispatch = (async () => { await handler(createMessageData("m-1") as never, {} as never); @@ -499,7 +441,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createHandlerParams({ setStatus })); + const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); diff --git a/src/discord/monitor/message-handler.test-helpers.ts b/src/discord/monitor/message-handler.test-helpers.ts new file mode 100644 index 00000000000..3faf3ca856a --- /dev/null +++ b/src/discord/monitor/message-handler.test-helpers.ts @@ -0,0 +1,75 @@ +import { vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export const DEFAULT_DISCORD_BOT_USER_ID = "bot-123"; + +export function createDiscordHandlerParams(overrides?: { + botUserId?: string; + setStatus?: (patch: Record) => void; + abortSignal?: AbortSignal; + workerRunTimeoutMs?: number; +}) { + const cfg: OpenClawConfig = { + channels: { + discord: { + enabled: true, + token: "test-token", + groupPolicy: "allowlist", + }, + }, + messages: { + inbound: { + debounceMs: 0, + }, + }, + }; + return { + cfg, + discordConfig: cfg.channels?.discord, + accountId: "default", + token: "test-token", + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }, + botUserId: overrides?.botUserId ?? DEFAULT_DISCORD_BOT_USER_ID, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 10_000, + textLimit: 2_000, + replyToMode: "off" as const, + dmEnabled: true, + groupDmEnabled: false, + threadBindings: createNoopThreadBindingManager("default"), + setStatus: overrides?.setStatus, + abortSignal: overrides?.abortSignal, + workerRunTimeoutMs: overrides?.workerRunTimeoutMs, + }; +} + +export function createDiscordPreflightContext(channelId = "ch-1") { + return { + data: { + channel_id: channelId, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + }, + message: { + id: `msg-${channelId}`, + channel_id: channelId, + attachments: [], + }, + route: { + sessionKey: `agent:main:discord:channel:${channelId}`, + }, + baseSessionKey: `agent:main:discord:channel:${channelId}`, + messageChannelId: channelId, + }; +}