From a39e57a1bdab560da689ec8566f0831838d1c400 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:36:10 -0500 Subject: [PATCH] fix(ci): repair discord harness regressions --- extensions/discord/src/message-tool-schema.ts | 2 +- .../src/monitor/agent-components-helpers.ts | 11 +- .../src/monitor/exec-approvals.test.ts | 152 ++++++++++++++++++ .../discord/src/monitor/exec-approvals.ts | 41 ++--- .../discord/src/monitor/gateway-plugin.ts | 89 +++++++--- .../discord/src/monitor/inbound-worker.ts | 15 +- .../message-handler.module-test-helpers.ts | 28 ++-- ...age-handler.preflight.acp-bindings.test.ts | 9 +- .../src/monitor/message-handler.preflight.ts | 22 +-- .../src/monitor/message-handler.queue.test.ts | 21 +-- .../discord/src/monitor/message-handler.ts | 17 +- .../monitor/monitor.agent-components.test.ts | 13 ++ .../src/monitor/provider.proxy.test.ts | 53 +++++- .../extensions/configured-binding-runtime.ts | 5 +- .../extensions/discord-component-runtime.ts | 42 +++++ 15 files changed, 422 insertions(+), 98 deletions(-) diff --git a/extensions/discord/src/message-tool-schema.ts b/extensions/discord/src/message-tool-schema.ts index 14dbcdcc4bc..0ad9c87480d 100644 --- a/extensions/discord/src/message-tool-schema.ts +++ b/extensions/discord/src/message-tool-schema.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { stringEnum } from "openclaw/plugin-sdk/agent-runtime"; +import { stringEnum } from "openclaw/plugin-sdk/core"; const discordComponentEmojiSchema = Type.Object({ name: Type.String(), diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts index b7c247d1f07..4ab7fa047f3 100644 --- a/extensions/discord/src/monitor/agent-components-helpers.ts +++ b/extensions/discord/src/monitor/agent-components-helpers.ts @@ -15,13 +15,10 @@ import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/com import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; -import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime"; +import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - readStoreAllowFromForDmPolicy, - resolvePinnedMainDmOwnerFromAllowlist, -} from "openclaw/plugin-sdk/security-runtime"; +import * as securityRuntime from "openclaw/plugin-sdk/security-runtime"; import { logError } from "openclaw/plugin-sdk/text-runtime"; import { createDiscordFormModal, @@ -474,7 +471,7 @@ async function ensureDmComponentAuthorized(params: { return false; } - const storeAllowFrom = await readStoreAllowFromForDmPolicy({ + const storeAllowFrom = await securityRuntime.readStoreAllowFromForDmPolicy({ provider: "discord", accountId: ctx.accountId, dmPolicy, @@ -488,7 +485,7 @@ async function ensureDmComponentAuthorized(params: { const pairingResult = await createChannelPairingChallengeIssuer({ channel: "discord", upsertPairingRequest: async ({ id, meta }) => - await upsertChannelPairingRequest({ + await conversationRuntime.upsertChannelPairingRequest({ channel: "discord", id, accountId: ctx.accountId, diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index 3660c91ba8e..2c3e7cc07fd 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -126,6 +126,93 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => { }; }); +vi.mock("../../../../src/gateway/operator-approvals-client.js", () => ({ + createOperatorApprovalsGatewayClient: async (params: { + config?: unknown; + gatewayUrl?: string; + clientDisplayName?: string; + onEvent?: unknown; + onHelloOk?: unknown; + onConnectError?: unknown; + onClose?: unknown; + }) => { + mockCreateOperatorApprovalsGatewayClient(params); + const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); + const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; + const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; + const auth = await mockResolveGatewayConnectionAuth({ + config: params.config, + env: process.env, + ...(urlOverrideSource + ? { + urlOverride: gatewayUrl, + urlOverrideSource, + } + : {}), + }); + const clientParams = { + url: gatewayUrl, + token: auth?.token, + password: auth?.password, + clientName: "gateway-client", + clientDisplayName: params.clientDisplayName, + mode: "backend", + scopes: ["operator.approvals"], + onEvent: params.onEvent, + onHelloOk: params.onHelloOk, + onConnectError: params.onConnectError, + onClose: params.onClose, + }; + gatewayClientParams.push(clientParams); + mockGatewayClientCtor(clientParams); + return { + start: gatewayClientStarts, + stop: gatewayClientStops, + request: gatewayClientRequests, + }; + }, +})); + +vi.mock("../../../../src/gateway/client.js", () => ({ + GatewayClient: class { + params: Record; + constructor(params: Record) { + this.params = params; + gatewayClientParams.push(params); + mockGatewayClientCtor(params); + } + start() { + gatewayClientStarts(); + } + stop() { + gatewayClientStops(); + } + async request() { + return gatewayClientRequests(); + } + }, +})); + +vi.mock("../../../../src/gateway/connection-auth.js", () => ({ + resolveGatewayConnectionAuth: (params: { + config?: unknown; + env: NodeJS.ProcessEnv; + urlOverride?: string; + urlOverrideSource?: "cli" | "env"; + }) => mockResolveGatewayConnectionAuth(params), +})); + +vi.mock("../client.js", () => ({ + createDiscordClient: () => ({ + rest: { + post: mockRestPost, + patch: mockRestPatch, + delete: mockRestDelete, + }, + request: (_fn: () => Promise, _label: string) => _fn(), + }), +})); + vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => { const actual = await importOriginal(); return { @@ -152,6 +239,66 @@ type DiscordExecApprovalHandlerInstance = InstanceType< type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest; type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext; +function createTestingDeps() { + return { + createGatewayClient: async (params: { + config?: unknown; + gatewayUrl?: string; + clientDisplayName?: string; + onEvent?: unknown; + onHelloOk?: unknown; + onConnectError?: unknown; + onClose?: unknown; + }) => { + mockCreateOperatorApprovalsGatewayClient(params); + const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim(); + const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789"; + const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined; + const auth = await mockResolveGatewayConnectionAuth({ + config: params.config, + env: process.env, + ...(urlOverrideSource + ? { + urlOverride: gatewayUrl, + urlOverrideSource, + } + : {}), + }); + const clientParams = { + url: gatewayUrl, + token: auth?.token, + password: auth?.password, + clientName: "gateway-client", + clientDisplayName: params.clientDisplayName, + mode: "backend", + scopes: ["operator.approvals"], + onEvent: params.onEvent, + onHelloOk: params.onHelloOk, + onConnectError: params.onConnectError, + onClose: params.onClose, + }; + gatewayClientParams.push(clientParams); + mockGatewayClientCtor(clientParams); + return { + start: gatewayClientStarts, + stop: gatewayClientStops, + request: gatewayClientRequests, + } as unknown as InstanceType< + typeof import("../../../../src/gateway/client.js").GatewayClient + >; + }, + createDiscordClient: () => ({ + rest: { + post: mockRestPost, + patch: mockRestPatch, + delete: mockRestDelete, + }, + request: (_fn: () => Promise, _label: string) => _fn(), + token: "test-token", + }), + }; +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function createHandler(config: DiscordExecApprovalConfig, accountId = "default") { @@ -160,6 +307,7 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") accountId, config, cfg: { session: { store: STORE_PATH } }, + __testing: createTestingDeps(), }); } @@ -785,6 +933,7 @@ describe("DiscordExecApprovalHandler gateway auth", () => { auth: { mode: "token", token: "shared-gateway-token" }, }, }, + __testing: createTestingDeps(), }); await handler.start(); @@ -811,6 +960,7 @@ describe("DiscordExecApprovalHandler gateway auth", () => { auth: { mode: "token" }, }, }, + __testing: createTestingDeps(), }); try { @@ -978,6 +1128,7 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { gatewayUrl: "wss://override.example/ws", config: { enabled: true, approvers: ["123"] }, cfg: { session: { store: STORE_PATH } }, + __testing: createTestingDeps(), }); await expectGatewayAuthStart({ @@ -1000,6 +1151,7 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { accountId: "default", config: { enabled: true, approvers: ["123"] }, cfg: { session: { store: STORE_PATH } }, + __testing: createTestingDeps(), }); await expectGatewayAuthStart({ diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index c30d0c082e9..9f8e5582e76 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -13,9 +13,8 @@ import { ButtonStyle, Routes } from "discord-api-types/v10"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime"; -import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; -import { createOperatorApprovalsGatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; import type { EventFrame } from "openclaw/plugin-sdk/gateway-runtime"; +import * as gatewayRuntime from "openclaw/plugin-sdk/gateway-runtime"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { getExecApprovalApproverDmNoticeText } from "openclaw/plugin-sdk/infra-runtime"; import type { @@ -31,7 +30,7 @@ import { import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime"; import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime"; -import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; +import * as sendShared from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; const EXEC_APPROVAL_KEY = "execapproval"; @@ -365,10 +364,14 @@ export type DiscordExecApprovalHandlerOpts = { cfg: OpenClawConfig; runtime?: RuntimeEnv; onResolve?: (id: string, decision: ExecApprovalDecision) => Promise; + __testing?: { + createGatewayClient?: typeof gatewayRuntime.createOperatorApprovalsGatewayClient; + createDiscordClient?: typeof sendShared.createDiscordClient; + }; }; export class DiscordExecApprovalHandler { - private gatewayClient: GatewayClient | null = null; + private gatewayClient: gatewayRuntime.GatewayClient | null = null; private pending = new Map(); private requestCache = new Map(); private opts: DiscordExecApprovalHandlerOpts; @@ -448,7 +451,10 @@ export class DiscordExecApprovalHandler { logDebug("discord exec approvals: starting handler"); - this.gatewayClient = await createOperatorApprovalsGatewayClient({ + this.gatewayClient = await ( + this.opts.__testing?.createGatewayClient ?? + gatewayRuntime.createOperatorApprovalsGatewayClient + )({ config: this.opts.cfg, gatewayUrl: this.opts.gatewayUrl, clientDisplayName: "Discord Exec Approvals", @@ -505,10 +511,9 @@ export class DiscordExecApprovalHandler { this.requestCache.set(request.id, request); - const { rest, request: discordRequest } = createDiscordClient( - { token: this.opts.token, accountId: this.opts.accountId }, - this.opts.cfg, - ); + const { rest, request: discordRequest } = ( + this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient + )({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg); const actionRow = new ExecApprovalActionRow(request.id); const container = createExecApprovalRequestContainer({ @@ -518,7 +523,7 @@ export class DiscordExecApprovalHandler { actionRow, }); const payload = buildExecApprovalPayload(container); - const body = stripUndefinedFields(serializePayload(payload)); + const body = sendShared.stripUndefinedFields(serializePayload(payload)); const target = this.opts.config.target ?? "dm"; const sendToDm = target === "dm" || target === "both"; @@ -727,10 +732,9 @@ export class DiscordExecApprovalHandler { } try { - const { rest, request: discordRequest } = createDiscordClient( - { token: this.opts.token, accountId: this.opts.accountId }, - this.opts.cfg, - ); + const { rest, request: discordRequest } = ( + this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient + )({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg); await discordRequest( () => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise, @@ -748,16 +752,15 @@ export class DiscordExecApprovalHandler { container: DiscordUiContainer, ): Promise { try { - const { rest, request: discordRequest } = createDiscordClient( - { token: this.opts.token, accountId: this.opts.accountId }, - this.opts.cfg, - ); + const { rest, request: discordRequest } = ( + this.opts.__testing?.createDiscordClient ?? sendShared.createDiscordClient + )({ token: this.opts.token, accountId: this.opts.accountId }, this.opts.cfg); const payload = buildExecApprovalPayload(container); await discordRequest( () => rest.patch(Routes.channelMessage(channelId, messageId), { - body: stripUndefinedFields(serializePayload(payload)), + body: sendShared.stripUndefinedFields(serializePayload(payload)), }), "update-approval", ); diff --git a/extensions/discord/src/monitor/gateway-plugin.ts b/extensions/discord/src/monitor/gateway-plugin.ts index 5acab8d5339..acd752f3253 100644 --- a/extensions/discord/src/monitor/gateway-plugin.ts +++ b/extensions/discord/src/monitor/gateway-plugin.ts @@ -1,11 +1,11 @@ -import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"; +import * as carbonGateway from "@buape/carbon/gateway"; import type { APIGatewayBotInfo } from "discord-api-types/v10"; -import { HttpsProxyAgent } from "https-proxy-agent"; +import * as httpsProxyAgent from "https-proxy-agent"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { ProxyAgent, fetch as undiciFetch } from "undici"; -import WebSocket from "ws"; +import * as undici from "undici"; +import * as ws from "ws"; const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; @@ -26,18 +26,18 @@ export function resolveDiscordGatewayIntents( intentsConfig?: import("openclaw/plugin-sdk/config-runtime").DiscordIntentsConfig, ): number { let intents = - GatewayIntents.Guilds | - GatewayIntents.GuildMessages | - GatewayIntents.MessageContent | - GatewayIntents.DirectMessages | - GatewayIntents.GuildMessageReactions | - GatewayIntents.DirectMessageReactions | - GatewayIntents.GuildVoiceStates; + carbonGateway.GatewayIntents.Guilds | + carbonGateway.GatewayIntents.GuildMessages | + carbonGateway.GatewayIntents.MessageContent | + carbonGateway.GatewayIntents.DirectMessages | + carbonGateway.GatewayIntents.GuildMessageReactions | + carbonGateway.GatewayIntents.DirectMessageReactions | + carbonGateway.GatewayIntents.GuildVoiceStates; if (intentsConfig?.presence) { - intents |= GatewayIntents.GuildPresences; + intents |= carbonGateway.GatewayIntents.GuildPresences; } if (intentsConfig?.guildMembers) { - intents |= GatewayIntents.GuildMembers; + intents |= carbonGateway.GatewayIntents.GuildMembers; } return intents; } @@ -226,17 +226,26 @@ function createGatewayPlugin(params: { }; fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; - wsAgent?: HttpsProxyAgent; + wsAgent?: InstanceType>; runtime?: RuntimeEnv; -}): GatewayPlugin { - class SafeGatewayPlugin extends GatewayPlugin { + testing?: { + registerClient?: ( + plugin: carbonGateway.GatewayPlugin, + client: Parameters[0], + ) => Promise; + webSocketCtor?: new (url: string, options?: { agent?: unknown }) => unknown; + }; +}): carbonGateway.GatewayPlugin { + class SafeGatewayPlugin extends carbonGateway.GatewayPlugin { private gatewayInfoUsedFallback = false; constructor() { super(params.options); } - override async registerClient(client: Parameters[0]) { + override async registerClient( + client: Parameters[0], + ) { if (!this.gatewayInfo || this.gatewayInfoUsedFallback) { const resolved = await fetchDiscordGatewayInfoWithTimeout({ token: client.options.token, @@ -251,6 +260,10 @@ function createGatewayPlugin(params: { this.gatewayInfo = resolved.info; this.gatewayInfoUsedFallback = resolved.usedFallback; } + if (params.testing?.registerClient) { + await params.testing.registerClient(this, client); + return; + } return super.registerClient(client); } @@ -258,7 +271,8 @@ function createGatewayPlugin(params: { if (!params.wsAgent) { return super.createWebSocket(url); } - return new WebSocket(url, { agent: params.wsAgent }); + const WebSocketCtor = params.testing?.webSocketCtor ?? ws.default; + return new WebSocketCtor(url, { agent: params.wsAgent }); } } @@ -268,7 +282,17 @@ function createGatewayPlugin(params: { export function createDiscordGatewayPlugin(params: { discordConfig: DiscordAccountConfig; runtime: RuntimeEnv; -}): GatewayPlugin { + __testing?: { + HttpsProxyAgentCtor?: typeof httpsProxyAgent.HttpsProxyAgent; + ProxyAgentCtor?: typeof undici.ProxyAgent; + undiciFetch?: typeof undici.fetch; + webSocketCtor?: new (url: string, options?: { agent?: unknown }) => unknown; + registerClient?: ( + plugin: carbonGateway.GatewayPlugin, + client: Parameters[0], + ) => Promise; + }; +}): carbonGateway.GatewayPlugin { const intents = resolveDiscordGatewayIntents(params.discordConfig?.intents); const proxy = params.discordConfig?.proxy?.trim(); const options = { @@ -282,21 +306,36 @@ export function createDiscordGatewayPlugin(params: { options, fetchImpl: (input, init) => fetch(input, init as RequestInit), runtime: params.runtime, + testing: params.__testing + ? { + registerClient: params.__testing.registerClient, + webSocketCtor: params.__testing.webSocketCtor, + } + : undefined, }); } try { - const wsAgent = new HttpsProxyAgent(proxy); - const fetchAgent = new ProxyAgent(proxy); + const HttpsProxyAgentCtor = + params.__testing?.HttpsProxyAgentCtor ?? httpsProxyAgent.HttpsProxyAgent; + const ProxyAgentCtor = params.__testing?.ProxyAgentCtor ?? undici.ProxyAgent; + const wsAgent = new HttpsProxyAgentCtor(proxy); + const fetchAgent = new ProxyAgentCtor(proxy); params.runtime.log?.("discord: gateway proxy enabled"); return createGatewayPlugin({ options, - fetchImpl: (input, init) => undiciFetch(input, init), + fetchImpl: (input, init) => (params.__testing?.undiciFetch ?? undici.fetch)(input, init), fetchInit: { dispatcher: fetchAgent }, wsAgent, runtime: params.runtime, + testing: params.__testing + ? { + registerClient: params.__testing.registerClient, + webSocketCtor: params.__testing.webSocketCtor, + } + : undefined, }); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); @@ -304,6 +343,12 @@ export function createDiscordGatewayPlugin(params: { options, fetchImpl: (input, init) => fetch(input, init as RequestInit), runtime: params.runtime, + testing: params.__testing + ? { + registerClient: params.__testing.registerClient, + webSocketCtor: params.__testing.webSocketCtor, + } + : undefined, }); } } diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index e7dc8e6adc8..918cdcd382a 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -15,6 +15,7 @@ type DiscordInboundWorkerParams = { setStatus?: DiscordMonitorStatusSink; abortSignal?: AbortSignal; runTimeoutMs?: number; + __testing?: DiscordInboundWorkerTestingHooks; }; export type DiscordInboundWorker = { @@ -22,6 +23,11 @@ export type DiscordInboundWorker = { deactivate: () => void; }; +export type DiscordInboundWorkerTestingHooks = { + processDiscordMessage?: typeof processDiscordMessage; + deliverDiscordReply?: typeof deliverDiscordReply; +}; + function formatDiscordRunContextSuffix(job: DiscordInboundJob): string { const channelId = job.payload.messageChannelId?.trim(); const messageId = job.payload.data?.message?.id?.trim(); @@ -40,15 +46,17 @@ async function processDiscordInboundJob(params: { runtime: RuntimeEnv; lifecycleSignal?: AbortSignal; runTimeoutMs?: number; + testing?: DiscordInboundWorkerTestingHooks; }) { const timeoutMs = normalizeDiscordInboundWorkerTimeoutMs(params.runTimeoutMs); const contextSuffix = formatDiscordRunContextSuffix(params.job); let finalReplyStarted = false; let createdThreadId: string | undefined; let sessionKey: string | undefined; + const processDiscordMessageImpl = params.testing?.processDiscordMessage ?? processDiscordMessage; await runDiscordTaskWithTimeout({ run: async (abortSignal) => { - await processDiscordMessage(materializeDiscordInboundJob(params.job, abortSignal), { + await processDiscordMessageImpl(materializeDiscordInboundJob(params.job, abortSignal), { onFinalReplyStart: () => { finalReplyStarted = true; }, @@ -81,6 +89,7 @@ async function processDiscordInboundJob(params: { contextSuffix, createdThreadId, sessionKey, + deliverDiscordReplyImpl: params.testing?.deliverDiscordReply, }); }, onErrorAfterTimeout: (error) => { @@ -97,6 +106,7 @@ async function sendDiscordInboundWorkerTimeoutReply(params: { contextSuffix: string; createdThreadId?: string; sessionKey?: string; + deliverDiscordReplyImpl?: typeof deliverDiscordReply; }) { const messageChannelId = params.job.payload.messageChannelId?.trim(); const messageId = params.job.payload.message?.id?.trim(); @@ -119,7 +129,7 @@ async function sendDiscordInboundWorkerTimeoutReply(params: { }); try { - await deliverDiscordReply({ + await (params.deliverDiscordReplyImpl ?? deliverDiscordReply)({ cfg: params.job.payload.cfg, replies: [{ text: "Discord inbound worker timed out.", isError: true }], target: deliveryPlan.deliverTarget, @@ -171,6 +181,7 @@ export function createDiscordInboundWorker( runtime: params.runtime, lifecycleSignal: params.abortSignal, runTimeoutMs: params.runTimeoutMs, + testing: params.__testing, }); } finally { runState.onRunEnd(); diff --git a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts index 8f264174032..66fbeeb0367 100644 --- a/extensions/discord/src/monitor/message-handler.module-test-helpers.ts +++ b/extensions/discord/src/monitor/message-handler.module-test-helpers.ts @@ -5,16 +5,20 @@ export const preflightDiscordMessageMock: MockFn = vi.fn(); export const processDiscordMessageMock: MockFn = vi.fn(); export const deliverDiscordReplyMock: MockFn = vi.fn(async () => undefined); -vi.mock("./message-handler.preflight.js", () => ({ - preflightDiscordMessage: preflightDiscordMessageMock, -})); +const { createDiscordMessageHandler: createRealDiscordMessageHandler } = + await import("./message-handler.js"); -vi.mock("./message-handler.process.js", () => ({ - processDiscordMessage: processDiscordMessageMock, -})); - -vi.mock("./reply-delivery.js", () => ({ - deliverDiscordReply: deliverDiscordReplyMock, -})); - -export const { createDiscordMessageHandler } = await import("./message-handler.js"); +export function createDiscordMessageHandler( + ...args: Parameters +) { + const [params] = args; + return createRealDiscordMessageHandler({ + ...params, + __testing: { + ...params.__testing, + preflightDiscordMessage: preflightDiscordMessageMock, + processDiscordMessage: processDiscordMessageMock, + deliverDiscordReply: deliverDiscordReplyMock, + }, + }); +} diff --git a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts index b3e8f0f0f9b..43e814447dd 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,10 +1,11 @@ +import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../../test/helpers/extensions/configured-binding-runtime.js"; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); -vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { +vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOriginal) => { return await createConfiguredBindingConversationRuntimeModuleMock( { ensureConfiguredBindingRouteReadyMock, @@ -229,6 +230,12 @@ describe("preflightDiscordMessage configured ACP bindings", () => { resolveConfiguredBindingRouteMock.mockReset(); resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredDiscordRoute()); ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); + vi.spyOn(conversationRuntime, "resolveConfiguredBindingRoute").mockImplementation( + resolveConfiguredBindingRouteMock, + ); + vi.spyOn(conversationRuntime, "ensureConfiguredBindingRouteReady").mockImplementation( + ensureConfiguredBindingRouteReadyMock, + ); }); it("does not initialize configured ACP bindings for rejected messages", async () => { diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 55822830cd5..46c87291a21 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -12,16 +12,8 @@ import { hasControlCommand } from "openclaw/plugin-sdk/command-auth"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; -import { - ensureConfiguredBindingRouteReady, - resolveConfiguredBindingRoute, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { - getSessionBindingService, - type SessionBindingRecord, -} from "openclaw/plugin-sdk/conversation-runtime"; -import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; -import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; +import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime"; +import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime"; import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -358,7 +350,7 @@ export async function preflightDiscordMessage( try { await sendMessageDiscord( `user:${author.id}`, - buildPairingReply({ + conversationRuntime.buildPairingReply({ channel: "discord", idLine: `Your Discord user id: ${author.id}`, code, @@ -454,7 +446,7 @@ export async function preflightDiscordMessage( const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId; let threadBinding: SessionBindingRecord | undefined; threadBinding = - getSessionBindingService().resolveByConversation({ + conversationRuntime.getSessionBindingService().resolveByConversation({ channel: "discord", accountId: params.accountId, conversationId: bindingConversationId, @@ -462,7 +454,7 @@ export async function preflightDiscordMessage( }) ?? undefined; const configuredRoute = threadBinding == null - ? resolveConfiguredBindingRoute({ + ? conversationRuntime.resolveConfiguredBindingRoute({ cfg: freshCfg, route, conversation: { @@ -488,7 +480,7 @@ export async function preflightDiscordMessage( logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`); return null; } - const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding) + const boundSessionKey = conversationRuntime.isPluginOwnedSessionBindingRecord(threadBinding) ? "" : threadBinding?.targetSessionKey?.trim(); const effectiveRoute = resolveDiscordEffectiveRoute({ @@ -870,7 +862,7 @@ export async function preflightDiscordMessage( return null; } if (configuredBinding) { - const ensured = await ensureConfiguredBindingRouteReady({ + const ensured = await conversationRuntime.ensureConfiguredBindingRouteReady({ cfg: freshCfg, bindingResolution: configuredBinding, }); diff --git a/extensions/discord/src/monitor/message-handler.queue.test.ts b/extensions/discord/src/monitor/message-handler.queue.test.ts index 0aea16f89ec..61f02ab5c6a 100644 --- a/extensions/discord/src/monitor/message-handler.queue.test.ts +++ b/extensions/discord/src/monitor/message-handler.queue.test.ts @@ -1,12 +1,14 @@ -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { + createDiscordMessageHandler, + preflightDiscordMessageMock, + processDiscordMessageMock, + deliverDiscordReplyMock, +} from "./message-handler.module-test-helpers.js"; import { createDiscordHandlerParams, createDiscordPreflightContext, } from "./message-handler.test-helpers.js"; -let createDiscordMessageHandler: typeof import("./message-handler.module-test-helpers.js").createDiscordMessageHandler; -let preflightDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").preflightDiscordMessageMock; -let processDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").processDiscordMessageMock; -let deliverDiscordReplyMock: typeof import("./message-handler.module-test-helpers.js").deliverDiscordReplyMock; const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn()); type SetStatusFn = (patch: Record) => void; @@ -164,15 +166,6 @@ async function createLifecycleStopScenario(params: { } describe("createDiscordMessageHandler queue behavior", () => { - beforeAll(async () => { - ({ - createDiscordMessageHandler, - preflightDiscordMessageMock, - processDiscordMessageMock, - deliverDiscordReplyMock, - } = await import("./message-handler.module-test-helpers.js")); - }); - it("resets busy counters when the handler is created", () => { preflightDiscordMessageMock.mockReset(); processDiscordMessageMock.mockReset(); diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts index ca4defad51e..9407d0a4034 100644 --- a/extensions/discord/src/monitor/message-handler.ts +++ b/extensions/discord/src/monitor/message-handler.ts @@ -7,7 +7,10 @@ import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/confi import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime"; import { danger } from "openclaw/plugin-sdk/runtime-env"; import { buildDiscordInboundJob } from "./inbound-job.js"; -import { createDiscordInboundWorker } from "./inbound-worker.js"; +import { + createDiscordInboundWorker, + type DiscordInboundWorkerTestingHooks, +} from "./inbound-worker.js"; import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; @@ -25,6 +28,11 @@ type DiscordMessageHandlerParams = Omit< setStatus?: DiscordMonitorStatusSink; abortSignal?: AbortSignal; workerRunTimeoutMs?: number; + __testing?: DiscordMessageHandlerTestingHooks; +}; + +type DiscordMessageHandlerTestingHooks = DiscordInboundWorkerTestingHooks & { + preflightDiscordMessage?: typeof preflightDiscordMessage; }; export type DiscordMessageHandlerWithLifecycle = DiscordMessageHandler & { @@ -64,11 +72,14 @@ export function createDiscordMessageHandler( params.discordConfig?.ackReactionScope ?? params.cfg.messages?.ackReactionScope ?? "group-mentions"; + const preflightDiscordMessageImpl = + params.__testing?.preflightDiscordMessage ?? preflightDiscordMessage; const inboundWorker = createDiscordInboundWorker({ runtime: params.runtime, setStatus: params.setStatus, abortSignal: params.abortSignal, runTimeoutMs: params.workerRunTimeoutMs, + __testing: params.__testing, }); const recentInboundMessages = createDedupeCache({ ttlMs: RECENT_DISCORD_MESSAGE_TTL_MS, @@ -122,7 +133,7 @@ export function createDiscordMessageHandler( return; } if (entries.length === 1) { - const ctx = await preflightDiscordMessage({ + const ctx = await preflightDiscordMessageImpl({ ...params, ackReactionScope, groupPolicy, @@ -154,7 +165,7 @@ export function createDiscordMessageHandler( ...last.data, message: syntheticMessage, }; - const ctx = await preflightDiscordMessage({ + const ctx = await preflightDiscordMessageImpl({ ...params, ackReactionScope, groupPolicy, diff --git a/extensions/discord/src/monitor/monitor.agent-components.test.ts b/extensions/discord/src/monitor/monitor.agent-components.test.ts index 983fa11ca64..d55b7887abf 100644 --- a/extensions/discord/src/monitor/monitor.agent-components.test.ts +++ b/extensions/discord/src/monitor/monitor.agent-components.test.ts @@ -1,7 +1,9 @@ import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import * as conversationRuntime from "openclaw/plugin-sdk/conversation-runtime"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import * as securityRuntime from "openclaw/plugin-sdk/security-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.ts"; import { @@ -61,6 +63,17 @@ describe("agent components", () => { beforeEach(() => { resetDiscordComponentRuntimeMocks(); resetSystemEventsForTest(); + vi.spyOn(securityRuntime, "readStoreAllowFromForDmPolicy").mockImplementation( + async (params) => { + if (params.shouldRead === false || params.dmPolicy === "allowlist") { + return []; + } + return await readAllowFromStoreMock(params.provider, params.accountId); + }, + ); + vi.spyOn(conversationRuntime, "upsertChannelPairingRequest").mockImplementation( + upsertPairingRequestMock, + ); }); it("sends pairing reply when DM sender is not allowlisted", async () => { diff --git a/extensions/discord/src/monitor/provider.proxy.test.ts b/extensions/discord/src/monitor/provider.proxy.test.ts index f8e9f52c198..dde71cea491 100644 --- a/extensions/discord/src/monitor/provider.proxy.test.ts +++ b/extensions/discord/src/monitor/provider.proxy.test.ts @@ -82,6 +82,11 @@ vi.mock("@buape/carbon/gateway", () => ({ GatewayPlugin, })); +vi.mock("@buape/carbon/dist/src/plugins/gateway/index.js", () => ({ + GatewayIntents, + GatewayPlugin, +})); + vi.mock("https-proxy-agent", () => ({ HttpsProxyAgent, })); @@ -126,10 +131,16 @@ describe("createDiscordGatewayPlugin", () => { async function registerGatewayClient(plugin: unknown) { await ( plugin as { - registerClient: (client: { options: { token: string } }) => Promise; + registerClient: (client: { + options: { token: string }; + registerListener: typeof baseRegisterClientSpy; + unregisterListener: ReturnType; + }) => Promise; } ).registerClient({ options: { token: "token-123" }, + registerListener: baseRegisterClientSpy, + unregisterListener: vi.fn(), }); } @@ -232,6 +243,26 @@ describe("createDiscordGatewayPlugin", () => { const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, runtime, + __testing: { + HttpsProxyAgentCtor: HttpsProxyAgent as typeof import("https-proxy-agent").HttpsProxyAgent, + ProxyAgentCtor: class { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + restProxyAgentSpy(proxyUrl); + } + } as unknown as typeof import("undici").ProxyAgent, + undiciFetch: undiciFetchMock, + webSocketCtor: class { + constructor(url: string, options?: { agent?: unknown }) { + webSocketSpy(url, options); + } + } as unknown as new (url: string, options?: { agent?: unknown }) => unknown, + registerClient: async (_plugin, client) => { + baseRegisterClientSpy(client); + }, + }, }); expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype); @@ -267,6 +298,26 @@ describe("createDiscordGatewayPlugin", () => { const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, runtime, + __testing: { + HttpsProxyAgentCtor: HttpsProxyAgent as typeof import("https-proxy-agent").HttpsProxyAgent, + ProxyAgentCtor: class { + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + undiciProxyAgentSpy(proxyUrl); + restProxyAgentSpy(proxyUrl); + } + } as unknown as typeof import("undici").ProxyAgent, + undiciFetch: undiciFetchMock, + webSocketCtor: class { + constructor(url: string, options?: { agent?: unknown }) { + webSocketSpy(url, options); + } + } as unknown as new (url: string, options?: { agent?: unknown }) => unknown, + registerClient: async (_plugin, client) => { + baseRegisterClientSpy(client); + }, + }, }); await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); diff --git a/test/helpers/extensions/configured-binding-runtime.ts b/test/helpers/extensions/configured-binding-runtime.ts index e37206a0d83..2f13367e230 100644 --- a/test/helpers/extensions/configured-binding-runtime.ts +++ b/test/helpers/extensions/configured-binding-runtime.ts @@ -3,7 +3,10 @@ export async function createConfiguredBindingConversationRuntimeModuleMock( ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown; resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown; }, - importOriginal: () => Promise, + importOriginal: () => Promise<{ + ensureConfiguredBindingRouteReady: (...args: unknown[]) => unknown; + resolveConfiguredBindingRoute: (...args: unknown[]) => unknown; + }>, ) { const actual = await importOriginal(); return { diff --git a/test/helpers/extensions/discord-component-runtime.ts b/test/helpers/extensions/discord-component-runtime.ts index a5191d915d4..a529e3ea23c 100644 --- a/test/helpers/extensions/discord-component-runtime.ts +++ b/test/helpers/extensions/discord-component-runtime.ts @@ -48,8 +48,50 @@ vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { }; }); +vi.mock("openclaw/plugin-sdk/security-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readStoreAllowFromForDmPolicy: async (params: { + provider: string; + accountId: string; + dmPolicy?: string | null; + shouldRead?: boolean | null; + }) => { + if (params.shouldRead === false || params.dmPolicy === "allowlist") { + return []; + } + return await readAllowFromStoreMock(params.provider, params.accountId); + }, + }; +}); + vi.mock("openclaw/plugin-sdk/conversation-runtime", createConversationRuntimeMock); vi.mock("openclaw/plugin-sdk/conversation-runtime.js", createConversationRuntimeMock); +vi.mock("../../../src/pairing/pairing-store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), + }; +}); +vi.mock("../../../src/security/dm-policy-shared.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readStoreAllowFromForDmPolicy: async (params: { + provider: string; + accountId: string; + dmPolicy?: string | null; + shouldRead?: boolean | null; + }) => { + if (params.shouldRead === false || params.dmPolicy === "allowlist") { + return []; + } + return await readAllowFromStoreMock(params.provider, params.accountId); + }, + }; +}); export function resetDiscordComponentRuntimeMocks() { readAllowFromStoreMock.mockClear().mockResolvedValue([]);