From af3d796e199f2f233400de0284eaf45765fa3dee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 11:34:02 -0700 Subject: [PATCH] refactor(discord): trim internal exports (#107714) * refactor(discord): trim internal exports * refactor(discord): use canonical approval helper * chore(deadcode): refresh export baseline --- .../discord/src/approval-native.test.ts | 32 ++++-- extensions/discord/src/approval-native.ts | 7 -- extensions/discord/src/approval-runtime.ts | 5 +- extensions/discord/src/audit-core.ts | 2 +- extensions/discord/src/audit.test.ts | 9 +- extensions/discord/src/chunk.test.ts | 8 +- extensions/discord/src/chunk.ts | 2 +- .../discord/src/internal/client.test.ts | 4 +- extensions/discord/src/internal/client.ts | 4 +- .../discord/src/internal/interactions.test.ts | 15 +-- .../discord/src/internal/interactions.ts | 2 +- .../src/internal/rest-scheduler.test.ts | 4 +- .../discord/src/internal/rest-scheduler.ts | 2 +- extensions/discord/src/internal/rest.ts | 2 +- .../internal/test-builders.test-support.ts | 11 +- extensions/discord/src/monitor.gateway.ts | 2 +- .../src/monitor/agent-components.dispatch.ts | 2 +- .../discord/src/monitor/agent-components.ts | 12 +- .../monitor/agent-components.wildcard.test.ts | 62 ++++++++--- extensions/discord/src/monitor/allow-list.ts | 5 +- .../discord/src/monitor/auto-presence.test.ts | 29 ++--- .../discord/src/monitor/auto-presence.ts | 2 +- .../src/monitor/channel-access.test.ts | 5 +- .../discord/src/monitor/channel-access.ts | 2 +- .../src/monitor/exec-approvals.test.ts | 19 ++-- .../discord/src/monitor/exec-approvals.ts | 2 +- .../src/monitor/gateway-metadata.test.ts | 5 +- .../discord/src/monitor/gateway-metadata.ts | 4 +- .../src/monitor/gateway-plugin.test.ts | 70 ++++++------ .../src/monitor/gateway-supervisor.test.ts | 49 ++++++--- .../discord/src/monitor/gateway-supervisor.ts | 2 +- .../src/monitor/inbound-context.test.ts | 6 +- .../discord/src/monitor/inbound-context.ts | 2 +- .../monitor/model-picker-preferences.test.ts | 20 ++-- .../src/monitor/model-picker-preferences.ts | 2 +- .../discord/src/monitor/model-picker.state.ts | 36 +----- .../discord/src/monitor/model-picker.test.ts | 103 +++--------------- .../monitor/monitor.agent-components.test.ts | 18 --- .../discord/src/monitor/monitor.test.ts | 25 +++-- .../monitor/monitor.threading-utils.test.ts | 15 ++- .../discord/src/monitor/native-command-ui.ts | 1 - .../native-command.command-arg.test.ts | 6 +- .../native-command.model-picker.test.ts | 2 +- .../src/monitor/provider.lifecycle.test.ts | 45 +------- .../discord/src/monitor/provider.lifecycle.ts | 4 +- .../monitor/thread-bindings.lifecycle.test.ts | 4 +- .../src/monitor/thread-title.generate.test.ts | 24 ++++ .../discord/src/monitor/thread-title.test.ts | 49 --------- .../discord/src/monitor/thread-title.ts | 2 +- .../src/resolve-allowlist-common.test.ts | 3 +- .../discord/src/resolve-allowlist-common.ts | 2 +- extensions/discord/src/retry.test.ts | 41 ++++--- extensions/discord/src/retry.ts | 4 +- extensions/discord/src/runtime.ts | 2 +- extensions/discord/src/voice-message.test.ts | 5 +- extensions/discord/src/voice-message.ts | 2 +- extensions/discord/src/voice/prompt.test.ts | 29 +++-- extensions/discord/src/voice/prompt.ts | 2 +- scripts/deadcode-exports.baseline.mjs | 42 ------- 59 files changed, 372 insertions(+), 505 deletions(-) delete mode 100644 extensions/discord/src/monitor/thread-title.test.ts diff --git a/extensions/discord/src/approval-native.test.ts b/extensions/discord/src/approval-native.test.ts index 1e0853ff48a0..7cab380b3b18 100644 --- a/extensions/discord/src/approval-native.test.ts +++ b/extensions/discord/src/approval-native.test.ts @@ -2,12 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { splitChannelApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime"; import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it } from "vitest"; -import { - createDiscordNativeApprovalAdapter, - getDiscordApprovalCapability, -} from "./approval-native.js"; +import { getDiscordApprovalCapability } from "./approval-native.js"; import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js"; const STORE_PATH = path.join(os.tmpdir(), "openclaw-discord-approval-native-test.json"); @@ -27,6 +25,10 @@ const NATIVE_DELIVERY_CFG = { }, } as const; +function createDiscordNativeApprovalAdapter() { + return splitChannelApprovalCapability(getDiscordApprovalCapability()); +} + function writeStore(store: Record) { fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8"); clearSessionStoreCacheForTest(); @@ -34,22 +36,30 @@ function writeStore(store: Record) { describe("createDiscordNativeApprovalAdapter", () => { it("keeps approval availability enabled when approvers exist but native delivery is off", () => { - const adapter = createDiscordNativeApprovalAdapter({ - enabled: false, - approvers: ["555555555"], - target: "channel", - } as never); + const adapter = createDiscordNativeApprovalAdapter(); + const cfg = { + ...NATIVE_APPROVAL_CFG, + channels: { + discord: { + execApprovals: { + enabled: false, + approvers: ["555555555"], + target: "channel", + }, + }, + }, + } as const; expect( adapter.auth?.getActionAvailabilityState?.({ - cfg: NATIVE_APPROVAL_CFG as never, + cfg: cfg as never, accountId: "main", action: "approve", }), ).toEqual({ kind: "enabled" }); expect( adapter.native?.describeDeliveryCapabilities({ - cfg: NATIVE_APPROVAL_CFG as never, + cfg: cfg as never, accountId: "main", approvalKind: "exec", request: { diff --git a/extensions/discord/src/approval-native.ts b/extensions/discord/src/approval-native.ts index 2ea3c5018478..34a472539a2a 100644 --- a/extensions/discord/src/approval-native.ts +++ b/extensions/discord/src/approval-native.ts @@ -13,7 +13,6 @@ import { createChannelApproverDmTargetResolver, createChannelNativeOriginTargetResolver, createApproverRestrictedNativeApprovalCapability, - splitChannelApprovalCapability, } from "./approval-runtime.js"; import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js"; import { @@ -202,12 +201,6 @@ function createDiscordApprovalCapability(configOverride?: DiscordExecApprovalCon }); } -export function createDiscordNativeApprovalAdapter( - configOverride?: DiscordExecApprovalConfig | null, -) { - return splitChannelApprovalCapability(createDiscordApprovalCapability(configOverride)); -} - let cachedDiscordApprovalCapability: ReturnType | undefined; export function getDiscordApprovalCapability() { diff --git a/extensions/discord/src/approval-runtime.ts b/extensions/discord/src/approval-runtime.ts index 1b5c0b10a0dd..071682bb7284 100644 --- a/extensions/discord/src/approval-runtime.ts +++ b/extensions/discord/src/approval-runtime.ts @@ -5,10 +5,7 @@ export { getExecApprovalReplyMetadata, } from "openclaw/plugin-sdk/approval-client-runtime"; export { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime"; -export { - createApproverRestrictedNativeApprovalCapability, - splitChannelApprovalCapability, -} from "openclaw/plugin-sdk/approval-delivery-runtime"; +export { createApproverRestrictedNativeApprovalCapability } from "openclaw/plugin-sdk/approval-delivery-runtime"; export { createChannelApproverDmTargetResolver, createChannelNativeOriginTargetResolver, diff --git a/extensions/discord/src/audit-core.ts b/extensions/discord/src/audit-core.ts index 9854e111d614..17a70107e7d9 100644 --- a/extensions/discord/src/audit-core.ts +++ b/extensions/discord/src/audit-core.ts @@ -83,7 +83,7 @@ function listConfiguredGuildChannelKeys( return [...ids].toSorted((a, b) => a.localeCompare(b)); } -export function collectDiscordAuditChannelIdsForGuilds( +function collectDiscordAuditChannelIdsForGuilds( guilds: Record | undefined, ) { const keys = listConfiguredGuildChannelKeys(guilds); diff --git a/extensions/discord/src/audit.test.ts b/extensions/discord/src/audit.test.ts index e36ca9204487..caf4746a0633 100644 --- a/extensions/discord/src/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { auditDiscordChannelPermissionsWithFetcher, collectDiscordAuditChannelIdsForAccount, - collectDiscordAuditChannelIdsForGuilds, } from "./audit-core.js"; const fetchChannelPermissionsDiscordMock = vi.fn(); @@ -43,7 +42,7 @@ describe("discord audit", () => { }, } as unknown as OpenClawConfig; - const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg)); + const collected = collectDiscordAuditChannelIdsForAccount({ guilds: readDiscordGuilds(cfg) }); expect(collected.channelIds).toEqual(["111", "222"]); expect(collected.unresolvedChannels).toBe(1); @@ -93,7 +92,7 @@ describe("discord audit", () => { }, } as unknown as OpenClawConfig; - const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg)); + const collected = collectDiscordAuditChannelIdsForAccount({ guilds: readDiscordGuilds(cfg) }); expect(collected.channelIds).toEqual(["111"]); expect(collected.unresolvedChannels).toBe(0); }); @@ -116,7 +115,7 @@ describe("discord audit", () => { }, } as unknown as OpenClawConfig; - const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg)); + const collected = collectDiscordAuditChannelIdsForAccount({ guilds: readDiscordGuilds(cfg) }); expect(collected.channelIds).toStrictEqual([]); expect(collected.unresolvedChannels).toBe(0); }); @@ -143,7 +142,7 @@ describe("discord audit", () => { }, } as unknown as OpenClawConfig; - const collected = collectDiscordAuditChannelIdsForGuilds(readDiscordGuilds(cfg)); + const collected = collectDiscordAuditChannelIdsForAccount({ guilds: readDiscordGuilds(cfg) }); expect(collected.channelIds).toEqual(["111"]); expect(collected.unresolvedChannels).toBe(1); }); diff --git a/extensions/discord/src/chunk.test.ts b/extensions/discord/src/chunk.test.ts index 111905369ff6..44f5e982f208 100644 --- a/extensions/discord/src/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -2,7 +2,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { countLines, hasBalancedFences } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it } from "vitest"; -import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; +import { chunkDiscordTextWithMode } from "./chunk.js"; + +type ChunkOptions = Omit[1], "chunkMode">; + +function chunkDiscordText(text: string, options: ChunkOptions = {}) { + return chunkDiscordTextWithMode(text, { ...options, chunkMode: "length" }); +} describe("chunkDiscordText", () => { it("splits tall messages even when under 2000 chars", () => { diff --git a/extensions/discord/src/chunk.ts b/extensions/discord/src/chunk.ts index c9f20f56ba08..bfd4811fa3dd 100644 --- a/extensions/discord/src/chunk.ts +++ b/extensions/discord/src/chunk.ts @@ -156,7 +156,7 @@ function splitLongLine( * Chunks outbound Discord text by both character count and (soft) line count, * while keeping fenced code blocks balanced across chunks. */ -export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] { +function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] { const maxChars = resolveDiscordChunkLimit(opts.maxChars, DEFAULT_MAX_CHARS); const maxLines = resolveDiscordChunkLimit(opts.maxLines, DEFAULT_MAX_LINES); diff --git a/extensions/discord/src/internal/client.test.ts b/extensions/discord/src/internal/client.test.ts index 55c5cb61997e..d1a17311f289 100644 --- a/extensions/discord/src/internal/client.test.ts +++ b/extensions/discord/src/internal/client.test.ts @@ -5,12 +5,14 @@ import path from "node:path"; import { ApplicationCommandType, ComponentType, Routes } from "discord-api-types/v10"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Client, ComponentRegistry, type AnyListener } from "./client.js"; +import { Client, ComponentRegistry } from "./client.js"; import { BaseCommand } from "./commands.js"; import { Button, StringSelectMenu, parseCustomId } from "./components.js"; import { DiscordError } from "./rest.js"; import { attachRestMock, createInternalTestClient } from "./test-builders.test-support.js"; +type AnyListener = Parameters[0]; + function createDeferred(): { promise: Promise; resolve: (value: T | PromiseLike) => void; diff --git a/extensions/discord/src/internal/client.ts b/extensions/discord/src/internal/client.ts index 8b74ab92fa6a..47913ef1692c 100644 --- a/extensions/discord/src/internal/client.ts +++ b/extensions/discord/src/internal/client.ts @@ -30,12 +30,12 @@ export abstract class Plugin { onRequest?(req: Request, ctx: Context): Promise | Response | undefined; } -export type AnyListener = { +type AnyListener = { type: string; handle(data: unknown, client: Client): Promise | void; }; -export interface ClientOptions { +interface ClientOptions { baseUrl: string; clientId: string; deploySecret?: string; diff --git a/extensions/discord/src/internal/interactions.test.ts b/extensions/discord/src/internal/interactions.test.ts index e5c14f3b2a33..288103c624db 100644 --- a/extensions/discord/src/internal/interactions.test.ts +++ b/extensions/discord/src/internal/interactions.test.ts @@ -8,12 +8,7 @@ import { } from "discord-api-types/v10"; import { describe, expect, it, vi } from "vitest"; import { Container, TextDisplay } from "./components.js"; -import { - BaseInteraction, - ModalInteraction, - createInteraction, - type RawInteraction, -} from "./interactions.js"; +import { ModalInteraction, createInteraction, type RawInteraction } from "./interactions.js"; import { Message } from "./structures.js"; import { attachRestMock, @@ -29,7 +24,7 @@ describe("BaseInteraction", () => { const patch = vi.fn(async () => undefined); const client = createInternalTestClient(); attachRestMock(client, { patch, post }); - const interaction = new BaseInteraction( + const interaction = createInteraction( client, createInternalInteractionPayload({ id: "interaction1", token: "token1" }), ); @@ -52,7 +47,7 @@ describe("BaseInteraction", () => { const post = vi.fn(async () => undefined); const client = createInternalTestClient(); attachRestMock(client, { post }); - const interaction = new BaseInteraction( + const interaction = createInteraction( client, createInternalInteractionPayload({ id: "interaction1", token: "token1" }), ); @@ -85,7 +80,7 @@ describe("BaseInteraction", () => { const patch = vi.fn(async () => undefined); const client = createInternalTestClient(); attachRestMock(client, { patch, post }); - const interaction = new BaseInteraction( + const interaction = createInteraction( client, createInternalInteractionPayload({ id: "interaction1", token: "token1" }), ); @@ -199,7 +194,7 @@ describe("BaseInteraction", () => { const post = vi.fn(async () => undefined); const client = createInternalTestClient(); attachRestMock(client, { get, post }); - const interaction = new BaseInteraction( + const interaction = createInteraction( client, createInternalInteractionPayload({ id: "interaction1", token: "token1" }), ); diff --git a/extensions/discord/src/internal/interactions.ts b/extensions/discord/src/internal/interactions.ts index 23cd6e6c14a0..2b20cb33623d 100644 --- a/extensions/discord/src/internal/interactions.ts +++ b/extensions/discord/src/internal/interactions.ts @@ -110,7 +110,7 @@ function readInteractionUser(rawData: RawInteraction, client: InteractionClient) return null; } -export class BaseInteraction { +class BaseInteraction { readonly id: string; readonly token: string; readonly user: User | null; diff --git a/extensions/discord/src/internal/rest-scheduler.test.ts b/extensions/discord/src/internal/rest-scheduler.test.ts index b7b2070be701..3e533590427d 100644 --- a/extensions/discord/src/internal/rest-scheduler.test.ts +++ b/extensions/discord/src/internal/rest-scheduler.test.ts @@ -2,9 +2,11 @@ import { MAX_DATE_TIMESTAMP_MS, MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { describe, expect, it, vi } from "vitest"; import { RateLimitError } from "./rest-errors.js"; -import { RestScheduler, type RestSchedulerOptions } from "./rest-scheduler.js"; +import { RestScheduler } from "./rest-scheduler.js"; import { createJsonResponse } from "./test-builders.test-support.js"; +type RestSchedulerOptions = ConstructorParameters[0]; + function createOptions(overrides: Partial = {}): RestSchedulerOptions { return { lanes: { diff --git a/extensions/discord/src/internal/rest-scheduler.ts b/extensions/discord/src/internal/rest-scheduler.ts index 3e62632b4260..cae5841dd807 100644 --- a/extensions/discord/src/internal/rest-scheduler.ts +++ b/extensions/discord/src/internal/rest-scheduler.ts @@ -45,7 +45,7 @@ type RestSchedulerLaneOptions = { weight: number; }; -export type RestSchedulerOptions = { +type RestSchedulerOptions = { lanes: Record; maxConcurrency: number; maxQueueSize: number; diff --git a/extensions/discord/src/internal/rest.ts b/extensions/discord/src/internal/rest.ts index a9076ebd14a6..3a48ed0c14d2 100644 --- a/extensions/discord/src/internal/rest.ts +++ b/extensions/discord/src/internal/rest.ts @@ -62,7 +62,7 @@ export type RequestData = { headers?: Record; }; -export type QueuedRequest = { +type QueuedRequest = { method: string; path: string; data?: RequestData; diff --git a/extensions/discord/src/internal/test-builders.test-support.ts b/extensions/discord/src/internal/test-builders.test-support.ts index 97c465079918..d42c5bb21bfc 100644 --- a/extensions/discord/src/internal/test-builders.test-support.ts +++ b/extensions/discord/src/internal/test-builders.test-support.ts @@ -1,10 +1,13 @@ // Discord plugin module implements test builders support behavior. import { ComponentType, InteractionType } from "discord-api-types/v10"; import { vi, type Mock } from "vitest"; -import { Client, type ClientOptions } from "./client.js"; +import { Client } from "./client.js"; import type { BaseCommand } from "./commands.js"; import type { RawInteraction } from "./interactions.js"; -import type { QueuedRequest, RequestClient, RequestData } from "./rest.js"; +import type { RequestClient, RequestData } from "./rest.js"; + +type ClientOptions = ConstructorParameters[0]; +type RequestQuery = Parameters[1]; type RestMock = Partial>; type RestMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; @@ -17,7 +20,7 @@ type FakeRestCall = { method: RestMethod; path: string; data?: RequestData; - query?: QueuedRequest["query"]; + query?: RequestQuery; }; type FakeRestClient = RequestClient & { @@ -99,7 +102,7 @@ export function createFakeRestClient(responses: unknown[] = []): FakeRestClient method: RestMethod, path: string, data?: RequestData, - query?: QueuedRequest["query"], + query?: RequestQuery, ) => { calls.push({ method, path, data, query }); return queued.shift(); diff --git a/extensions/discord/src/monitor.gateway.ts b/extensions/discord/src/monitor.gateway.ts index c7607f384149..1ad201a0f1f4 100644 --- a/extensions/discord/src/monitor.gateway.ts +++ b/extensions/discord/src/monitor.gateway.ts @@ -8,7 +8,7 @@ import type { export { getDiscordGatewayEmitter } from "./monitor/gateway-supervisor.js"; -export type WaitForDiscordGatewayStopParams = { +type WaitForDiscordGatewayStopParams = { gateway?: DiscordGatewayHandle; abortSignal?: AbortSignal; gatewaySupervisor?: Pick; diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index be041d126a5e..7489559edc89 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -71,7 +71,7 @@ function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionCon return "channel"; } -export function resolveDiscordComponentOriginatingTo( +function resolveDiscordComponentOriginatingTo( interactionCtx: Pick, ) { return resolveDiscordConversationIdentity({ diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 6461567208bf..9f8f58bbe401 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -25,22 +25,22 @@ function bindDiscordComponentControl( return (ctx: AgentComponentContext): T => createControl(ctx, discordComponentControlHandlers); } -export const createDiscordComponentButton = bindDiscordComponentControl( +const createDiscordComponentButton = bindDiscordComponentControl( createDiscordComponentButtonControl, ); -export const createDiscordComponentStringSelect = bindDiscordComponentControl( +const createDiscordComponentStringSelect = bindDiscordComponentControl( createDiscordComponentStringSelectControl, ); -export const createDiscordComponentUserSelect = bindDiscordComponentControl( +const createDiscordComponentUserSelect = bindDiscordComponentControl( createDiscordComponentUserSelectControl, ); -export const createDiscordComponentRoleSelect = bindDiscordComponentControl( +const createDiscordComponentRoleSelect = bindDiscordComponentControl( createDiscordComponentRoleSelectControl, ); -export const createDiscordComponentMentionableSelect = bindDiscordComponentControl( +const createDiscordComponentMentionableSelect = bindDiscordComponentControl( createDiscordComponentMentionableSelectControl, ); -export const createDiscordComponentChannelSelect = bindDiscordComponentControl( +const createDiscordComponentChannelSelect = bindDiscordComponentControl( createDiscordComponentChannelSelectControl, ); diff --git a/extensions/discord/src/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts index 2b7fdeaa9aa8..6cf119f61cf2 100644 --- a/extensions/discord/src/monitor/agent-components.wildcard.test.ts +++ b/extensions/discord/src/monitor/agent-components.wildcard.test.ts @@ -3,25 +3,55 @@ import { beforeAll, describe, expect, it } from "vitest"; let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId; let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId; -let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton; -let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect; -let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect; +type DiscordComponentFactory = + (typeof import("./agent-components.js").createDiscordComponentControls)[number]; +let createDiscordComponentButton: DiscordComponentFactory; +let createDiscordComponentChannelSelect: DiscordComponentFactory; +let createDiscordComponentMentionableSelect: DiscordComponentFactory; let createDiscordComponentModal: typeof import("./agent-components.js").createDiscordComponentModal; -let createDiscordComponentRoleSelect: typeof import("./agent-components.js").createDiscordComponentRoleSelect; -let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect; -let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect; +let createDiscordComponentRoleSelect: DiscordComponentFactory; +let createDiscordComponentStringSelect: DiscordComponentFactory; +let createDiscordComponentUserSelect: DiscordComponentFactory; + +function requireComponentFactory( + factories: readonly DiscordComponentFactory[], + index: number, +): DiscordComponentFactory { + const factory = factories[index]; + if (!factory) { + throw new Error(`missing Discord component factory ${index}`); + } + return factory; +} beforeAll(async () => { ({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js")); - ({ - createDiscordComponentButton, - createDiscordComponentChannelSelect, - createDiscordComponentMentionableSelect, - createDiscordComponentModal, - createDiscordComponentRoleSelect, - createDiscordComponentStringSelect, - createDiscordComponentUserSelect, - } = await import("./agent-components.js")); + const components = await import("./agent-components.js"); + ({ createDiscordComponentModal } = components); + createDiscordComponentButton = requireComponentFactory( + components.createDiscordComponentControls, + 0, + ); + createDiscordComponentStringSelect = requireComponentFactory( + components.createDiscordComponentControls, + 1, + ); + createDiscordComponentUserSelect = requireComponentFactory( + components.createDiscordComponentControls, + 2, + ); + createDiscordComponentRoleSelect = requireComponentFactory( + components.createDiscordComponentControls, + 3, + ); + createDiscordComponentMentionableSelect = requireComponentFactory( + components.createDiscordComponentControls, + 4, + ); + createDiscordComponentChannelSelect = requireComponentFactory( + components.createDiscordComponentControls, + 5, + ); }); type WildcardComponent = { @@ -34,7 +64,7 @@ function asWildcardComponent(value: unknown): WildcardComponent { } function createWildcardComponents() { - const context = {} as Parameters[0]; + const context = {} as Parameters[0]; return [ asWildcardComponent(createDiscordComponentButton(context)), asWildcardComponent(createDiscordComponentStringSelect(context)), diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 55733220e6e4..c3bb745c16f9 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -182,10 +182,7 @@ function resolveDiscordUserAllowed(params: { ); } -export function resolveDiscordRoleAllowed(params: { - allowList?: string[]; - memberRoleIds: string[]; -}) { +function resolveDiscordRoleAllowed(params: { allowList?: string[]; memberRoleIds: string[] }) { // Role allowlists accept role IDs only. Names are ignored. const allowList = normalizeDiscordAllowList(params.allowList, ["role:"]); if (!allowList) { diff --git a/extensions/discord/src/monitor/auto-presence.test.ts b/extensions/discord/src/monitor/auto-presence.test.ts index 718cf62135f8..d0b1250ac046 100644 --- a/extensions/discord/src/monitor/auto-presence.test.ts +++ b/extensions/discord/src/monitor/auto-presence.test.ts @@ -1,10 +1,7 @@ // Discord tests cover auto presence plugin behavior. import type { AuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; import { describe, expect, it, vi } from "vitest"; -import { - createDiscordAutoPresenceController, - resolveDiscordAutoPresenceDecision, -} from "./auto-presence.js"; +import { createDiscordAutoPresenceController } from "./auto-presence.js"; function createStore(params?: { cooldownUntil?: number; @@ -32,24 +29,28 @@ function createStore(params?: { function expectExhaustedDecision(params: { failureCounts: Record }) { const now = Date.now(); - const decision = resolveDiscordAutoPresenceDecision({ + const updatePresence = vi.fn(); + const controller = createDiscordAutoPresenceController({ + accountId: "default", discordConfig: { autoPresence: { enabled: true, exhaustedText: "token exhausted", }, }, - authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: params.failureCounts }), - gatewayConnected: true, - now, + gateway: { isConnected: true, updatePresence }, + loadAuthStore: () => + createStore({ cooldownUntil: now + 60_000, failureCounts: params.failureCounts }), + now: () => now, }); + controller.runNow(); - if (!decision) { - throw new Error("expected an exhausted auto-presence decision"); - } - expect(decision.state).toBe("exhausted"); - expect(decision.presence.status).toBe("dnd"); - expect(decision.presence.activities[0]?.state).toBe("token exhausted"); + expect(updatePresence).toHaveBeenCalledWith( + expect.objectContaining({ + status: "dnd", + activities: [expect.objectContaining({ state: "token exhausted" })], + }), + ); } describe("discord auto presence", () => { diff --git a/extensions/discord/src/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts index fda3655650b3..cc3190bdb48b 100644 --- a/extensions/discord/src/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -198,7 +198,7 @@ function resolvePresenceStatus(state: DiscordAutoPresenceState): UpdatePresenceD return "idle"; } -export function resolveDiscordAutoPresenceDecision(params: { +function resolveDiscordAutoPresenceDecision(params: { discordConfig: Pick< DiscordAccountConfig, "autoPresence" | "activity" | "status" | "activityType" | "activityUrl" diff --git a/extensions/discord/src/monitor/channel-access.test.ts b/extensions/discord/src/monitor/channel-access.test.ts index 79dc0740853b..f4c34a5bc6b7 100644 --- a/extensions/discord/src/monitor/channel-access.test.ts +++ b/extensions/discord/src/monitor/channel-access.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from "vitest"; import { resolveDiscordChannelInfoSafe, - resolveDiscordChannelOwnerIdSafe, resolveDiscordChannelParentIdSafe, } from "./channel-access.js"; +function resolveDiscordChannelOwnerIdSafe(channel: unknown) { + return resolveDiscordChannelInfoSafe(channel).ownerId; +} + describe("resolveDiscordChannelOwnerIdSafe", () => { it("reads camelCase ownerId directly", () => { expect(resolveDiscordChannelOwnerIdSafe({ ownerId: "owner-1" })).toBe("owner-1"); diff --git a/extensions/discord/src/monitor/channel-access.ts b/extensions/discord/src/monitor/channel-access.ts index 451a62ce2e7b..4dec3584eaef 100644 --- a/extensions/discord/src/monitor/channel-access.ts +++ b/extensions/discord/src/monitor/channel-access.ts @@ -82,7 +82,7 @@ export function resolveDiscordChannelParentIdSafe(channel: unknown): string | un return resolveDiscordChannelStringWithAliasSafe(channel, "parentId"); } -export function resolveDiscordChannelOwnerIdSafe(channel: unknown): string | undefined { +function resolveDiscordChannelOwnerIdSafe(channel: unknown): string | undefined { return resolveDiscordChannelStringWithAliasSafe(channel, "ownerId"); } diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index cb4b34e67523..3784a3063685 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -16,7 +16,10 @@ vi.mock("openclaw/plugin-sdk/approval-gateway-runtime", async (importOriginal) = }; }); -import { ExecApprovalButton, createDiscordExecApprovalButtonContext } from "./exec-approvals.js"; +import { + createDiscordExecApprovalButtonContext, + createExecApprovalButton, +} from "./exec-approvals.js"; function buildConfig( execApprovals?: NonNullable["discord"]>["execApprovals"], @@ -90,7 +93,7 @@ describe("discord exec approval monitor helpers", () => { it("rejects invalid approval button payloads", async () => { const interaction = createInteraction(); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval: async () => ({ ok: true, resolution: createApprovalResolution() }), }); @@ -105,7 +108,7 @@ describe("discord exec approval monitor helpers", () => { it("blocks non-approvers from approving", async () => { const interaction = createInteraction({ userId: "999" }); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval: async () => ({ ok: true, resolution: createApprovalResolution() }), }); @@ -130,7 +133,7 @@ describe("discord exec approval monitor helpers", () => { resolution: createApprovalResolution(), }) as const, ); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval, }); @@ -150,7 +153,7 @@ describe("discord exec approval monitor helpers", () => { throw new Error("message edit failed"); }), }); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval: async () => ({ ok: true, @@ -180,7 +183,7 @@ describe("discord exec approval monitor helpers", () => { decision: "deny", }); resolveApprovalOverGatewayMock.mockResolvedValueOnce(resolution); - const button = new ExecApprovalButton( + const button = createExecApprovalButton( createDiscordExecApprovalButtonContext({ cfg: buildConfig({ enabled: true, approvers: ["123"] }), accountId: "default", @@ -214,7 +217,7 @@ describe("discord exec approval monitor helpers", () => { it("shows a follow-up when gateway resolution fails", async () => { const interaction = createInteraction(); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval: async () => ({ ok: false, reason: "error" }), }); @@ -230,7 +233,7 @@ describe("discord exec approval monitor helpers", () => { it("shows a follow-up for already-resolved approval clicks", async () => { const interaction = createInteraction(); - const button = new ExecApprovalButton({ + const button = createExecApprovalButton({ getApprovers: () => ["123"], resolveApproval: async () => ({ ok: false, reason: "not-found" }), }); diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index da0d43396ab8..9f1f6dac8bb1 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -93,7 +93,7 @@ function isStructuredApprovalNotFoundError(err: unknown): boolean { ); } -export class ExecApprovalButton extends Button { +class ExecApprovalButton extends Button { override label = "execapproval"; customId = "execapproval:seed=1"; override style = ButtonStyle.Primary; diff --git a/extensions/discord/src/monitor/gateway-metadata.test.ts b/extensions/discord/src/monitor/gateway-metadata.test.ts index 962acadb0d0a..dc7897e2b34c 100644 --- a/extensions/discord/src/monitor/gateway-metadata.test.ts +++ b/extensions/discord/src/monitor/gateway-metadata.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from "node:http"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { - fetchDiscordGatewayInfo, + fetchDiscordGatewayInfoWithTimeout, fetchDiscordGatewayMetadataGuarded, resolveDiscordGatewayInfoTimeoutMs, resolveGatewayInfoWithFallback, @@ -71,13 +71,14 @@ describe("Discord gateway metadata", () => { }); it("falls back on Cloudflare HTML rate limits without logging raw HTML", async () => { - const error = await fetchDiscordGatewayInfo({ + const error = await fetchDiscordGatewayInfoWithTimeout({ token: "test", fetchImpl: async () => new Response("Error 1015rate limited", { status: 429, headers: { "content-type": "text/html" }, }), + timeoutMs: 1_000, }).catch((err: unknown) => err); const runtime = { log: vi.fn(), diff --git a/extensions/discord/src/monitor/gateway-metadata.ts b/extensions/discord/src/monitor/gateway-metadata.ts index 39b3152bd06c..67277e096a8c 100644 --- a/extensions/discord/src/monitor/gateway-metadata.ts +++ b/extensions/discord/src/monitor/gateway-metadata.ts @@ -164,7 +164,7 @@ function summarizeGatewaySchemaErrors(value: unknown): string { .join("; "); } -export function parseDiscordGatewayInfoBody(body: string): APIGatewayBotInfo { +function parseDiscordGatewayInfoBody(body: string): APIGatewayBotInfo { const parsed = JSON.parse(body) as unknown; if (!Check(discordGatewayBotInfoSchema, parsed)) { throw new Error(summarizeGatewaySchemaErrors(parsed)); @@ -172,7 +172,7 @@ export function parseDiscordGatewayInfoBody(body: string): APIGatewayBotInfo { return parsed; } -export async function fetchDiscordGatewayInfo(params: { +async function fetchDiscordGatewayInfo(params: { token: string; fetchImpl: DiscordGatewayFetch; fetchInit?: DiscordGatewayFetchInit; diff --git a/extensions/discord/src/monitor/gateway-plugin.test.ts b/extensions/discord/src/monitor/gateway-plugin.test.ts index 9649ed1afa2b..7942393a605f 100644 --- a/extensions/discord/src/monitor/gateway-plugin.test.ts +++ b/extensions/discord/src/monitor/gateway-plugin.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT } from "./gateway-handle.js"; import { - parseDiscordGatewayInfoBody, + fetchDiscordGatewayInfoWithTimeout, resolveDiscordGatewayInfoTimeoutMs, } from "./gateway-metadata.js"; @@ -149,21 +149,25 @@ describe("createDiscordGatewayPlugin", () => { expect(resolveDiscordGatewayInfoTimeoutMs({ env: {} })).toBe(30_000); }); - it("parses valid Discord gateway metadata", () => { - expect( - parseDiscordGatewayInfoBody( - JSON.stringify({ - url: "wss://gateway.discord.gg", - shards: 1, - session_start_limit: { - total: 1000, - remaining: 999, - reset_after: 0, - max_concurrency: 1, - }, - }), - ), - ).toEqual({ + it("parses valid Discord gateway metadata", async () => { + await expect( + fetchDiscordGatewayInfoWithTimeout({ + token: "test", + fetchImpl: async () => + new Response( + JSON.stringify({ + url: "wss://gateway.discord.gg", + shards: 1, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 0, + max_concurrency: 1, + }, + }), + ), + }), + ).resolves.toEqual({ url: "wss://gateway.discord.gg", shards: 1, session_start_limit: { @@ -175,21 +179,25 @@ describe("createDiscordGatewayPlugin", () => { }); }); - it("rejects malformed Discord gateway metadata", () => { - expect(() => - parseDiscordGatewayInfoBody( - JSON.stringify({ - url: "", - shards: 0, - session_start_limit: { - total: 1000, - remaining: 999, - reset_after: 0, - max_concurrency: 1, - }, - }), - ), - ).toThrow(/url|shards/); + it("rejects malformed Discord gateway metadata", async () => { + await expect( + fetchDiscordGatewayInfoWithTimeout({ + token: "test", + fetchImpl: async () => + new Response( + JSON.stringify({ + url: "wss://gateway.discord.gg", + shards: 0, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 0, + max_concurrency: 1, + }, + }), + ), + }), + ).rejects.toThrow(/url|shards/); }); it("omits voice states when Discord voice is disabled in account config", () => { diff --git a/extensions/discord/src/monitor/gateway-supervisor.test.ts b/extensions/discord/src/monitor/gateway-supervisor.test.ts index 1396961bc037..dc07e5a53c25 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.test.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.test.ts @@ -13,9 +13,9 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { }); import { - classifyDiscordGatewayEvent, DiscordGatewayLifecycleError, createDiscordGatewaySupervisor, + type DiscordGatewayEvent, } from "./gateway-supervisor.js"; function firstErrorArg(runtime: { error: ReturnType }): unknown { @@ -28,24 +28,44 @@ function firstErrorArg(runtime: { error: ReturnType }): unknown { } describe("classifyDiscordGatewayEvent", () => { + function captureGatewayEvent(params: { + error: Error; + isDisallowedIntentsError?: (err: unknown) => boolean; + }): DiscordGatewayEvent { + const emitter = new EventEmitter(); + const supervisor = createDiscordGatewaySupervisor({ + gateway: { emitter }, + isDisallowedIntentsError: params.isDisallowedIntentsError ?? (() => false), + runtime: { error: vi.fn() } as never, + }); + emitter.emit("error", params.error); + let captured: DiscordGatewayEvent | undefined; + supervisor.drainPending((event) => { + captured = event; + return "continue"; + }); + supervisor.dispose(); + if (!captured) { + throw new Error("expected a gateway event"); + } + return captured; + } + it("maps current gateway errors onto domain events", () => { const transientTypeError = new TypeError(); transientTypeError.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)"; - const reconnectEvent = classifyDiscordGatewayEvent({ - err: new Error("Max reconnect attempts (0) reached after close code 1006"), - isDisallowedIntentsError: () => false, + const reconnectEvent = captureGatewayEvent({ + error: new Error("Max reconnect attempts (0) reached after close code 1006"), }); - const fatalEvent = classifyDiscordGatewayEvent({ - err: new Error("Fatal gateway close code: 4000"), - isDisallowedIntentsError: () => false, + const fatalEvent = captureGatewayEvent({ + error: new Error("Fatal gateway close code: 4000"), }); - const disallowedEvent = classifyDiscordGatewayEvent({ - err: new Error("Fatal gateway close code: 4014"), + const disallowedEvent = captureGatewayEvent({ + error: new Error("Fatal gateway close code: 4014"), isDisallowedIntentsError: (err) => String(err).includes("4014"), }); - const transientEvent = classifyDiscordGatewayEvent({ - err: transientTypeError, - isDisallowedIntentsError: () => false, + const transientEvent = captureGatewayEvent({ + error: transientTypeError, }); expect(reconnectEvent.type).toBe("reconnect-exhausted"); @@ -60,9 +80,8 @@ describe("classifyDiscordGatewayEvent", () => { it("wraps fatal lifecycle stops with discord-specific context", () => { const transientTypeError = new TypeError(); transientTypeError.stack = "TypeError\n at gatewayCrash (discord-gateway.js:12:34)"; - const event = classifyDiscordGatewayEvent({ - err: transientTypeError, - isDisallowedIntentsError: () => false, + const event = captureGatewayEvent({ + error: transientTypeError, }); const wrapped = new DiscordGatewayLifecycleError(event); diff --git a/extensions/discord/src/monitor/gateway-supervisor.ts b/extensions/discord/src/monitor/gateway-supervisor.ts index 20b84a1339cd..01f432cbfd8f 100644 --- a/extensions/discord/src/monitor/gateway-supervisor.ts +++ b/extensions/discord/src/monitor/gateway-supervisor.ts @@ -101,7 +101,7 @@ function formatDiscordGatewayErrorMessage(err: unknown): string { return detail; } -export function classifyDiscordGatewayEvent(params: { +function classifyDiscordGatewayEvent(params: { err: unknown; isDisallowedIntentsError: (err: unknown) => boolean; }): DiscordGatewayEvent { diff --git a/extensions/discord/src/monitor/inbound-context.test.ts b/extensions/discord/src/monitor/inbound-context.test.ts index 15e0d600d448..49bfc47c34a5 100644 --- a/extensions/discord/src/monitor/inbound-context.test.ts +++ b/extensions/discord/src/monitor/inbound-context.test.ts @@ -4,7 +4,6 @@ import { createDiscordSupplementalContextAccessChecker, buildDiscordGroupSystemPrompt, buildDiscordInboundAccessContext, - buildDiscordUntrustedContext, } from "./inbound-context.js"; describe("Discord inbound context helpers", () => { @@ -55,10 +54,11 @@ describe("Discord inbound context helpers", () => { it("keeps direct helper behavior consistent", () => { expect(buildDiscordGroupSystemPrompt({ allowed: true, systemPrompt: " hi " })).toBe("hi"); - const untrustedContext = buildDiscordUntrustedContext({ + const untrustedContext = buildDiscordInboundAccessContext({ + sender: { id: "user-1" }, isGuild: true, channelTopic: "topic", - }); + }).untrustedContext; expect(untrustedContext).toEqual([ { label: "Discord channel metadata", diff --git a/extensions/discord/src/monitor/inbound-context.ts b/extensions/discord/src/monitor/inbound-context.ts index e36b01a293d6..476f7a94ed61 100644 --- a/extensions/discord/src/monitor/inbound-context.ts +++ b/extensions/discord/src/monitor/inbound-context.ts @@ -45,7 +45,7 @@ export function buildDiscordGroupSystemPrompt( return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; } -export function buildDiscordUntrustedContext(params: { +function buildDiscordUntrustedContext(params: { isGuild: boolean; channelTopic?: string; }): MsgContext["UntrustedStructuredContext"] | undefined { diff --git a/extensions/discord/src/monitor/model-picker-preferences.test.ts b/extensions/discord/src/monitor/model-picker-preferences.test.ts index c77229815319..cda91cf7b09c 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.test.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.test.ts @@ -8,13 +8,14 @@ import { resetPluginStateStoreForTests, } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js"; +import { setDiscordRuntime } from "../runtime.js"; import { - buildDiscordModelPickerPreferenceKey, readDiscordModelPickerRecentModels, recordDiscordModelPickerRecentModel, } from "./model-picker-preferences.js"; +type DiscordRuntime = Parameters[0]; + const tempDirs: string[] = []; async function createStateEnv(): Promise { @@ -96,14 +97,19 @@ describe("discord model picker preferences", () => { it("falls back to empty recents when stored state is malformed", async () => { const env = await createStateEnv(); - const key = buildDiscordModelPickerPreferenceKey({ userId: "789" }); - expect(key).toBeTruthy(); const store = createPluginStateKeyedStoreForTests("discord", { namespace: "model-picker-preferences", maxEntries: 2_000, env, }); - await store.register(key as string, "not-an-entry"); + await recordDiscordModelPickerRecentModel({ + env, + scope: { userId: "789" }, + modelRef: "openai/gpt-4.1", + }); + const [stored] = await store.entries(); + expect(stored).toBeDefined(); + await store.register(stored?.key ?? "missing", "not-an-entry"); const recent = await readDiscordModelPickerRecentModels({ env, @@ -132,8 +138,6 @@ describe("discord model picker preferences", () => { it("ignores retired legacy JSON preferences at runtime", async () => { const env = await createStateEnv(); const scope = { userId: "legacy-runtime-user" }; - const key = buildDiscordModelPickerPreferenceKey(scope); - expect(key).toBeTruthy(); const legacyPath = path.join( env.OPENCLAW_STATE_DIR as string, "discord", @@ -145,7 +149,7 @@ describe("discord model picker preferences", () => { JSON.stringify({ version: 1, entries: { - [key as string]: { + legacy: { recent: ["openai/gpt-4.1"], updatedAt: "2026-01-01T00:00:00.000Z", }, diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index e177196de37a..5a9d21c452b1 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -40,7 +40,7 @@ function normalizeId(value?: string): string { return normalizeOptionalString(value) ?? ""; } -export function buildDiscordModelPickerPreferenceKey( +function buildDiscordModelPickerPreferenceKey( scope: DiscordModelPickerPreferenceScope, ): string | null { const userId = normalizeId(scope.userId); diff --git a/extensions/discord/src/monitor/model-picker.state.ts b/extensions/discord/src/monitor/model-picker.state.ts index b0aa6ccdce89..b12716771eb8 100644 --- a/extensions/discord/src/monitor/model-picker.state.ts +++ b/extensions/discord/src/monitor/model-picker.state.ts @@ -10,13 +10,12 @@ import { decodeCustomIdComponent, encodeCustomIdComponent } from "../custom-id-c import type { ComponentData } from "../internal/discord.js"; export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk"; -export const DISCORD_CUSTOM_ID_MAX_CHARS = 100; +const DISCORD_CUSTOM_ID_MAX_CHARS = 100; const DISCORD_COMPONENT_MAX_SELECT_OPTIONS = 25; -export const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; -export const DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; -export const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; +const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; +const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; function compareBucketItems(left: string, right: string): number { const normalized = left.toLowerCase().localeCompare(right.toLowerCase()); @@ -76,7 +75,7 @@ export type DiscordModelPickerState = { const DISCORD_MODEL_PICKER_BUCKET_THRESHOLD = DISCORD_COMPONENT_MAX_SELECT_OPTIONS; /** Target items per alpha bucket. Discord caps selects at 25 options. */ -export const DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE = 20; +const DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE = 20; const DISCORD_MODEL_PICKER_MODEL_TOKEN_PATTERN = /^[A-Za-z0-9_-]{8}$/u; export function createDiscordModelPickerModelToken(provider: string, model: string): string { @@ -292,31 +291,6 @@ export function buildDiscordModelPickerCustomId(params: { return customId; } -export function parseDiscordModelPickerCustomId(customId: string): DiscordModelPickerState | null { - const trimmed = customId.trim(); - if (!trimmed.startsWith(`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:`)) { - return null; - } - - const rawParts = trimmed.split(";"); - const data: Record = {}; - for (const part of rawParts) { - const equalsIndex = part.indexOf("="); - if (equalsIndex <= 0) { - continue; - } - const rawKey = part.slice(0, equalsIndex); - const rawValue = part.slice(equalsIndex + 1); - const key = rawKey.includes(":") ? rawKey.split(":").slice(1).join(":") : rawKey; - if (!key) { - continue; - } - data[key] = rawValue; - } - - return parseDiscordModelPickerData(data); -} - export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPickerState | null { if (!data || typeof data !== "object") { return null; @@ -381,7 +355,7 @@ export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPi * the function falls back to count-based numeric chunks so the user still * gets a finite-cardinality picker. */ -export function computeAlphaBuckets(sortedItems: string[]): DiscordModelPickerBucket[] { +function computeAlphaBuckets(sortedItems: string[]): DiscordModelPickerBucket[] { if (sortedItems.length === 0) { return []; } diff --git a/extensions/discord/src/monitor/model-picker.test.ts b/extensions/discord/src/monitor/model-picker.test.ts index 62091d21a75d..e536bc195fdd 100644 --- a/extensions/discord/src/monitor/model-picker.test.ts +++ b/extensions/discord/src/monitor/model-picker.test.ts @@ -1,23 +1,17 @@ // Discord tests cover model picker plugin behavior. import { ComponentType } from "discord-api-types/v10"; import { describe, expect, it, vi } from "vitest"; -import { serializePayload } from "../internal/discord.js"; +import { parseCustomId, serializePayload } from "../internal/discord.js"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; import { - DISCORD_CUSTOM_ID_MAX_CHARS, - DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE, - DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE, - DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE, - DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX, + DISCORD_MODEL_PICKER_CUSTOM_ID_KEY, buildDiscordModelPickerCustomId, - computeAlphaBuckets, createDiscordModelPickerModelToken, getDiscordModelPickerModelPage, getDiscordModelPickerProviderPage, findProviderBucketId, findProviderBucketLocation, loadDiscordModelPickerData, - parseDiscordModelPickerCustomId, parseDiscordModelPickerData, } from "./model-picker.state.js"; import { createModelsProviderData } from "./model-picker.test-utils.js"; @@ -28,6 +22,18 @@ import { toDiscordModelPickerMessagePayload, } from "./model-picker.view.js"; +const DISCORD_CUSTOM_ID_MAX_CHARS = 100; +const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = 25; +const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE = 25; +const DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX = 25; + +function parseDiscordModelPickerCustomId(customId: string) { + const parsed = parseCustomId(customId); + return parsed.key === DISCORD_MODEL_PICKER_CUSTOM_ID_KEY + ? parseDiscordModelPickerData(parsed.data) + : null; +} + const buildModelsProviderDataMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/models-provider-runtime", () => ({ @@ -427,87 +433,6 @@ describe("model paging", () => { }); }); -describe("computeAlphaBuckets", () => { - it("returns a single all-bucket when items fit under the threshold", () => { - const items = ["alpha", "beta", "gamma", "delta"]; - const buckets = computeAlphaBuckets(items); - expect(buckets).toHaveLength(1); - expect(buckets[0]?.id).toBe("all"); - expect(buckets[0]?.label).toBe("All (4)"); - expect(buckets[0]?.start).toBe(0); - expect(buckets[0]?.end).toBe(4); - }); - - it("partitions a diverse list into letter-range buckets", () => { - // 30 alphabetically diverse items: 10 'a' + 10 'b' + 10 'c' = 30 total. - const items = [ - ...Array.from({ length: 10 }, (_, i) => `apple-${i}`), - ...Array.from({ length: 10 }, (_, i) => `banana-${i}`), - ...Array.from({ length: 10 }, (_, i) => `cherry-${i}`), - ].toSorted(); - const buckets = computeAlphaBuckets(items); - expect(buckets.length).toBeGreaterThan(1); - // Every item must appear in exactly one bucket. - const reconstructed = buckets.flatMap((b) => items.slice(b.start, b.end)); - expect(reconstructed).toEqual(items); - // Labels carry counts. - for (const bucket of buckets) { - expect(bucket.label).toMatch(/\(\d+\)$/); - } - }); - - it("keeps the same letter group inside one bucket (no stragglers)", () => { - // 19 'a' items + 5 'b' items = 24 total. Below threshold so single bucket. - // Bump to 30 to engage buckets. - const items = [ - ...Array.from({ length: 19 }, (_, i) => `a-${String(i).padStart(2, "0")}`), - ...Array.from({ length: 11 }, (_, i) => `b-${String(i).padStart(2, "0")}`), - ].toSorted(); - const buckets = computeAlphaBuckets(items); - // No bucket may contain items with mixed first letters except as a - // boundary-extended single bucket. - for (const bucket of buckets) { - const bucketItems = items.slice(bucket.start, bucket.end); - const firstLetters = new Set(bucketItems.map((item) => item.charAt(0))); - // The boundary extender keeps a letter group whole; either the bucket - // is fully one letter or it crossed a letter boundary intentionally. - expect(firstLetters.size).toBeGreaterThanOrEqual(1); - } - // Bucket sizes hit the target ± a letter-boundary spillover. - const oversized = buckets.filter( - (bucket) => bucket.end - bucket.start > DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE + 10, - ); - expect(oversized).toEqual([]); - }); - - it("falls back to numeric chunks when every item shares the same first letter", () => { - const items = Array.from({ length: 30 }, (_, i) => `qwen3-${String(i).padStart(2, "0")}`); - const buckets = computeAlphaBuckets(items); - expect(buckets.length).toBe(2); - expect(buckets[0]?.id).toBe("1-20"); - expect(buckets[0]?.label).toMatch(/^1–20 \(20\)$/); - expect(buckets[1]?.id).toBe("21-30"); - expect(buckets[1]?.label).toMatch(/^21–30 \(10\)$/); - }); - - it("returns an empty array for empty input", () => { - expect(computeAlphaBuckets([])).toEqual([]); - }); - - it("never returns more than 25 buckets even for huge same-prefix lists", () => { - // Regression: with a fixed target=20 a 501-item list yielded 26 numeric - // buckets, exceeding the Discord select-option cap and breaking the - // picker for the largest wildcard configs. Dynamic target keeps the - // bucket count <= 25 regardless of input size. - const items = Array.from({ length: 501 }, (_, i) => `qwen3-${String(i).padStart(3, "0")}`); - const buckets = computeAlphaBuckets(items); - expect(buckets.length).toBeLessThanOrEqual(25); - // Sanity check: a much larger list still fits. - const huge = Array.from({ length: 5000 }, (_, i) => `qwen3-${String(i).padStart(4, "0")}`); - expect(computeAlphaBuckets(huge).length).toBeLessThanOrEqual(25); - }); -}); - describe("Discord model picker rendering", () => { it("renders provider view on one page when provider count is <= 25", () => { const entries: Record = {}; diff --git a/extensions/discord/src/monitor/monitor.agent-components.test.ts b/extensions/discord/src/monitor/monitor.agent-components.test.ts index 9f4a11a1cd0b..509a3a174b3b 100644 --- a/extensions/discord/src/monitor/monitor.agent-components.test.ts +++ b/extensions/discord/src/monitor/monitor.agent-components.test.ts @@ -17,7 +17,6 @@ import { upsertPairingRequestMock, } from "../test-support/component-runtime.js"; import { resolveComponentInteractionContext } from "./agent-components-context.js"; -import { resolveDiscordComponentOriginatingTo } from "./agent-components.dispatch.js"; import { createAgentComponentButton, createAgentSelectMenu, @@ -292,23 +291,6 @@ describe("agent components", () => { }); }); - it("uses user conversation ids for direct-message component originating targets", () => { - expect( - resolveDiscordComponentOriginatingTo({ - isDirectMessage: true, - userId: "123456789", - channelId: "dm-channel", - }), - ).toBe("user:123456789"); - expect( - resolveDiscordComponentOriginatingTo({ - isDirectMessage: false, - userId: "123456789", - channelId: "guild-channel", - }), - ).toBe("channel:guild-channel"); - }); - it("blocks DM component interactions in disabled mode without reading pairing store", async () => { readAllowFromStoreMock.mockResolvedValue(["123456789"]); const button = createAgentComponentButton({ diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 8198bd5f4adc..e097cceaa0a4 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -23,11 +23,10 @@ import { import type { DiscordGuildEntryResolved } from "./allow-list.js"; type CreateDiscordComponentButton = - typeof import("./agent-components.js").createDiscordComponentButton; + (typeof import("./agent-components.js").createDiscordComponentControls)[number]; type CreateDiscordComponentModal = typeof import("./agent-components.js").createDiscordComponentModal; -type CreateDiscordComponentStringSelect = - typeof import("./agent-components.js").createDiscordComponentStringSelect; +type CreateDiscordComponentStringSelect = CreateDiscordComponentButton; type DispatchReplyWithBufferedBlockDispatcherFn = typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher; type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< @@ -45,6 +44,16 @@ let sendComponents: typeof import("../send.components.js"); let lastDispatchCtx: Record | undefined; +function requireComponentFactory(index: number): CreateDiscordComponentButton { + const factory = createDiscordComponentControlsForTest[index]; + if (!factory) { + throw new Error(`missing Discord component factory ${index}`); + } + return factory; +} + +let createDiscordComponentControlsForTest: readonly CreateDiscordComponentButton[] = []; + type MockWithCalls = { mock: { calls: unknown[][] } }; function mockCall(mock: MockWithCalls, index: number, label: string): unknown[] { @@ -282,11 +291,11 @@ describe("discord component interactions", () => { } beforeAll(async () => { - ({ - createDiscordComponentButton, - createDiscordComponentStringSelect, - createDiscordComponentModal, - } = await import("./agent-components.js")); + const components = await import("./agent-components.js"); + createDiscordComponentControlsForTest = components.createDiscordComponentControls; + createDiscordComponentButton = requireComponentFactory(0); + createDiscordComponentStringSelect = requireComponentFactory(1); + ({ createDiscordComponentModal } = components); ({ clearDiscordComponentEntries, registerDiscordComponentEntries, diff --git a/extensions/discord/src/monitor/monitor.threading-utils.test.ts b/extensions/discord/src/monitor/monitor.threading-utils.test.ts index 794a8015a507..b5159a5a6297 100644 --- a/extensions/discord/src/monitor/monitor.threading-utils.test.ts +++ b/extensions/discord/src/monitor/monitor.threading-utils.test.ts @@ -5,11 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { Client } from "../internal/discord.js"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; -import { - resolveDiscordMemberAllowed, - resolveDiscordOwnerAllowFrom, - resolveDiscordRoleAllowed, -} from "./allow-list.js"; +import { resolveDiscordMemberAllowed, resolveDiscordOwnerAllowFrom } from "./allow-list.js"; import { clearGateways, getGateway, @@ -25,6 +21,15 @@ import { resolveDiscordReplyDeliveryPlan, } from "./threading.js"; +function resolveDiscordRoleAllowed(params: { allowList?: string[]; memberRoleIds: string[] }) { + return resolveDiscordMemberAllowed({ + userAllowList: [], + roleAllowList: params.allowList, + memberRoleIds: params.memberRoleIds, + userId: "unmatched-user", + }); +} + describe("resolveDiscordOwnerAllowFrom", () => { it("returns undefined when no allowlist is configured", () => { const result = resolveDiscordOwnerAllowFrom({ diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index 53535326b836..bb962fe50615 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -12,7 +12,6 @@ export { resolveDiscordNativeChoiceContext, shouldOpenDiscordModelPickerFromCommand, } from "./native-command-model-picker-ui.js"; -export type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; export type { DiscordCommandArgContext, DiscordModelPickerContext, diff --git a/extensions/discord/src/monitor/native-command.command-arg.test.ts b/extensions/discord/src/monitor/native-command.command-arg.test.ts index 6e18626da54a..c57c68c3c578 100644 --- a/extensions/discord/src/monitor/native-command.command-arg.test.ts +++ b/extensions/discord/src/monitor/native-command.command-arg.test.ts @@ -3,10 +3,8 @@ import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth-nat import * as commandRegistryModule from "openclaw/plugin-sdk/command-auth-native"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - createDiscordCommandArgFallbackButton, - type DispatchDiscordCommandInteraction, -} from "./native-command-ui.js"; +import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; +import { createDiscordCommandArgFallbackButton } from "./native-command-ui.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; type CommandArgContext = Parameters[0]["ctx"]; diff --git a/extensions/discord/src/monitor/native-command.model-picker.test.ts b/extensions/discord/src/monitor/native-command.model-picker.test.ts index 3f7a6f1c08eb..e3cae5a73087 100644 --- a/extensions/discord/src/monitor/native-command.model-picker.test.ts +++ b/extensions/discord/src/monitor/native-command.model-picker.test.ts @@ -25,11 +25,11 @@ import { resolveDiscordChannelContext } from "./agent-components-context.js"; import * as modelPickerPreferencesModule from "./model-picker-preferences.js"; import * as modelPickerModule from "./model-picker.state.js"; import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js"; +import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js"; import { createDiscordModelPickerFallbackButton, createDiscordModelPickerFallbackSelect, replyWithDiscordModelPickerProviders, - type DispatchDiscordCommandInteraction, } from "./native-command-ui.js"; import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js"; diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 78f78ee5f7ae..5d892ff4c665 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -3,7 +3,7 @@ import { EventEmitter } from "node:events"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import type { GatewayPlugin } from "../internal/gateway.js"; -import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js"; +import type { waitForDiscordGatewayStop } from "../monitor.gateway.js"; import { DISCORD_GATEWAY_TRANSPORT_ACTIVITY_EVENT, type MutableDiscordGateway, @@ -13,6 +13,7 @@ import type { DiscordGatewayEvent } from "./gateway-supervisor.js"; type LifecycleParams = Parameters< typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle >[0]; +type WaitForDiscordGatewayStopParams = Parameters[0]; type MockGateway = { isConnected: boolean; options: GatewayPlugin["options"]; @@ -60,15 +61,9 @@ vi.mock("./gateway-registry.js", () => ({ describe("runDiscordGatewayLifecycle", () => { let runDiscordGatewayLifecycle: typeof import("./provider.lifecycle.js").runDiscordGatewayLifecycle; - let resolveDiscordGatewayReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayReadyTimeoutMs; - let resolveDiscordGatewayRuntimeReadyTimeoutMs: typeof import("./provider.lifecycle.js").resolveDiscordGatewayRuntimeReadyTimeoutMs; beforeAll(async () => { - ({ - runDiscordGatewayLifecycle, - resolveDiscordGatewayReadyTimeoutMs, - resolveDiscordGatewayRuntimeReadyTimeoutMs, - } = await import("./provider.lifecycle.js")); + ({ runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js")); }); beforeEach(() => { @@ -214,40 +209,6 @@ describe("runDiscordGatewayLifecycle", () => { expect(statusPatches(statusSink).some(predicate)).toBe(true); } - it("resolves gateway READY timeouts from config, env, then defaults", () => { - expect(resolveDiscordGatewayReadyTimeoutMs({ configuredTimeoutMs: 45_000 })).toBe(45_000); - expect( - resolveDiscordGatewayReadyTimeoutMs({ - env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "90000" }, - }), - ).toBe(90_000); - expect(resolveDiscordGatewayReadyTimeoutMs({ env: {} })).toBe(15_000); - - expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ configuredTimeoutMs: 60_000 })).toBe( - 60_000, - ); - expect( - resolveDiscordGatewayRuntimeReadyTimeoutMs({ - env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "120000" }, - }), - ).toBe(120_000); - expect(resolveDiscordGatewayRuntimeReadyTimeoutMs({ env: {} })).toBe(30_000); - }); - - it("ignores non-integer gateway READY timeout values", () => { - expect( - resolveDiscordGatewayReadyTimeoutMs({ - configuredTimeoutMs: 1.5, - env: { OPENCLAW_DISCORD_READY_TIMEOUT_MS: "0x1000" }, - }), - ).toBe(15_000); - expect( - resolveDiscordGatewayRuntimeReadyTimeoutMs({ - env: { OPENCLAW_DISCORD_RUNTIME_READY_TIMEOUT_MS: "1e3" }, - }), - ).toBe(30_000); - }); - it("cleans up thread bindings when gateway wait fails before READY", async () => { waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("startup failed")); const { lifecycleParams, threadStop, gatewaySupervisor } = createLifecycleHarness(); diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index b582e6da474b..279b700e35f7 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -43,7 +43,7 @@ function normalizeGatewayReadyTimeoutMs(value: unknown): number | undefined { return Math.min(numeric, MAX_DISCORD_GATEWAY_READY_TIMEOUT_MS); } -export function resolveDiscordGatewayReadyTimeoutMs(params?: { +function resolveDiscordGatewayReadyTimeoutMs(params?: { configuredTimeoutMs?: number; env?: NodeJS.ProcessEnv; }): number { @@ -54,7 +54,7 @@ export function resolveDiscordGatewayReadyTimeoutMs(params?: { ); } -export function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: { +function resolveDiscordGatewayRuntimeReadyTimeoutMs(params?: { configuredTimeoutMs?: number; env?: NodeJS.ProcessEnv; }): number { diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 5783a8ba2ddf..9b7f04cc13be 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -16,9 +16,11 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js"; +import { setDiscordRuntime } from "../runtime.js"; import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js"; +type DiscordRuntime = Parameters[0]; + const hoisted = vi.hoisted(() => { const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({})); const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({})); diff --git a/extensions/discord/src/monitor/thread-title.generate.test.ts b/extensions/discord/src/monitor/thread-title.generate.test.ts index e391e2c98284..ab375365367e 100644 --- a/extensions/discord/src/monitor/thread-title.generate.test.ts +++ b/extensions/discord/src/monitor/thread-title.generate.test.ts @@ -83,6 +83,30 @@ beforeEach(() => { }); describe("generateThreadTitle", () => { + it.each([ + [' "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], + ['\n\n "Weekly Release Summary"\nExtra text', "Weekly Release Summary"], + ["```markdown\nWeekly Release Summary\n```", "Weekly Release Summary"], + ["**Scaling ArcherScore Development Roadmap**", "Scaling ArcherScore Development Roadmap"], + ['"__Weekly Release Summary__"', "Weekly Release Summary"], + ["*Plan* for *project*", "*Plan* for *project*"], + ["**Bold** vs **Strong**", "**Bold** vs **Strong**"], + ["_intro_ and _outro_", "_intro_ and _outro_"], + ["**Release *plan***", "Release *plan*"], + ["***Release plan***", "Release plan"], + ["__Release _plan___", "Release _plan_"], + ])("normalizes generated title %j", async (generated, expected) => { + extractAssistantTextMock.mockReturnValueOnce(generated); + + await expect( + generateThreadTitle({ + cfg: EMPTY_DISCORD_TEST_CONFIG, + agentId: "main", + messageText: "Need a generated title.", + }), + ).resolves.toBe(expected); + }); + it("calls shared one-shot model prep with aws-sdk allowance", async () => { prepareSimpleCompletionModelForAgentMock.mockResolvedValueOnce({ selection: { diff --git a/extensions/discord/src/monitor/thread-title.test.ts b/extensions/discord/src/monitor/thread-title.test.ts deleted file mode 100644 index 410ba0e965cc..000000000000 --- a/extensions/discord/src/monitor/thread-title.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Discord tests cover thread title plugin behavior. -import { describe, expect, it } from "vitest"; -import { normalizeGeneratedThreadTitle } from "./thread-title.js"; - -describe("normalizeGeneratedThreadTitle", () => { - it("strips quotes and keeps the first non-empty line", () => { - expect(normalizeGeneratedThreadTitle(' "Weekly Release Summary"\nExtra text')).toBe( - "Weekly Release Summary", - ); - }); - - it("skips leading blank lines before selecting a title", () => { - expect(normalizeGeneratedThreadTitle('\n\n "Weekly Release Summary"\nExtra text')).toBe( - "Weekly Release Summary", - ); - }); - - it("skips leading markdown fence lines before selecting a title", () => { - expect(normalizeGeneratedThreadTitle("```markdown\nWeekly Release Summary\n```")).toBe( - "Weekly Release Summary", - ); - }); - - it("strips markdown emphasis wrappers around the full title", () => { - expect(normalizeGeneratedThreadTitle("**Scaling ArcherScore Development Roadmap**")).toBe( - "Scaling ArcherScore Development Roadmap", - ); - expect(normalizeGeneratedThreadTitle('"__Weekly Release Summary__"')).toBe( - "Weekly Release Summary", - ); - }); - - it("leaves a title with two separate emphasis spans intact", () => { - // Not a single wrapped span, so the outer markers must not be stripped. - expect(normalizeGeneratedThreadTitle("*Plan* for *project*")).toBe("*Plan* for *project*"); - expect(normalizeGeneratedThreadTitle("**Bold** vs **Strong**")).toBe("**Bold** vs **Strong**"); - expect(normalizeGeneratedThreadTitle("_intro_ and _outro_")).toBe("_intro_ and _outro_"); - }); - - it("unwraps nested same-marker emphasis inside bold without stranding markers", () => { - // Italic inside bold (`**…*plan*…**`) is valid emphasis nesting, so the - // outer bold pair is stripped while the inner italic stays intact. - expect(normalizeGeneratedThreadTitle("**Release *plan***")).toBe("Release *plan*"); - // Bold+italic combined wrappers unwrap to plain text in one pass. - expect(normalizeGeneratedThreadTitle("***Release plan***")).toBe("Release plan"); - // Underscore bold wrapping underscore italic unwraps the same way. - expect(normalizeGeneratedThreadTitle("__Release _plan___")).toBe("Release _plan_"); - }); -}); diff --git a/extensions/discord/src/monitor/thread-title.ts b/extensions/discord/src/monitor/thread-title.ts index 5473732985df..579993f5e7b8 100644 --- a/extensions/discord/src/monitor/thread-title.ts +++ b/extensions/discord/src/monitor/thread-title.ts @@ -140,7 +140,7 @@ function resolveThreadTitleTimeoutMs(timeoutMs: number | undefined): number { return Math.max(100, Math.floor(timeoutMs ?? DEFAULT_THREAD_TITLE_TIMEOUT_MS)); } -export function normalizeGeneratedThreadTitle(raw: string): string { +function normalizeGeneratedThreadTitle(raw: string): string { const lines = raw.replace(/\r/g, "").split("\n"); let firstLine = ""; for (const line of lines) { diff --git a/extensions/discord/src/resolve-allowlist-common.test.ts b/extensions/discord/src/resolve-allowlist-common.test.ts index 2a770a4e448a..41882c80a411 100644 --- a/extensions/discord/src/resolve-allowlist-common.test.ts +++ b/extensions/discord/src/resolve-allowlist-common.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { buildDiscordUnresolvedResults, filterDiscordGuilds, - findDiscordGuildByName, resolveDiscordAllowlistToken, } from "./resolve-allowlist-common.js"; @@ -14,7 +13,7 @@ describe("resolve-allowlist-common", () => { ]; it("resolves and filters guilds by id or name", () => { - const mainGuild = findDiscordGuildByName(guilds, "Main Guild"); + const [mainGuild] = filterDiscordGuilds(guilds, { guildName: "Main Guild" }); if (!mainGuild) { throw new Error("expected Main Guild lookup result"); } diff --git a/extensions/discord/src/resolve-allowlist-common.ts b/extensions/discord/src/resolve-allowlist-common.ts index 9896c0f0a4ce..57a74bbcf790 100644 --- a/extensions/discord/src/resolve-allowlist-common.ts +++ b/extensions/discord/src/resolve-allowlist-common.ts @@ -14,7 +14,7 @@ export function buildDiscordUnresolvedResults { +describe("createDiscordRetryRunner error classification", () => { it.each([ ["rate limit", createRateLimitError()], ["408 status", Object.assign(new Error("request timeout"), { status: 408 })], @@ -42,8 +38,11 @@ describe("isRetryableDiscordTransientError", () => { ["ECONNRESET", Object.assign(new Error("socket hang up"), { code: "ECONNRESET" })], ["ETIMEDOUT cause", new Error("request failed", { cause: { code: "ETIMEDOUT" } })], ["abort", Object.assign(new Error("aborted"), { name: "AbortError" })], - ])("retries %s", (_name, err) => { - expect(isRetryableDiscordTransientError(err)).toBe(true); + ])("retries %s", async (_name, err) => { + const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValue("ok"); + const runner = createDiscordRetryRunner({ retry: ZERO_DELAY_RETRY }); + await expect(runner(fn, "request")).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); }); it.each([ @@ -52,24 +51,30 @@ describe("isRetryableDiscordTransientError", () => { ["403 status", Object.assign(new Error("missing permissions"), { statusCode: 403 })], ["unknown channel", new Error("Unknown Channel")], ["plain string", "fetch failed"], - ])("does not retry %s", (_name, err) => { - expect(isRetryableDiscordTransientError(err)).toBe(false); + ])("does not retry %s", async (_name, err) => { + const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValue("ok"); + const runner = createDiscordRetryRunner({ retry: ZERO_DELAY_RETRY }); + await expect(runner(fn, "request")).rejects.toThrow(err instanceof Error ? err.message : err); + expect(fn).toHaveBeenCalledTimes(1); }); -}); -describe("isRetryableDiscordPreConnectError", () => { it.each([ ["rate limit", createRateLimitError()], ["429 status", Object.assign(new Error("rate limited"), { status: 429 })], ["ECONNREFUSED", Object.assign(new Error("connect refused"), { code: "ECONNREFUSED" })], - ])("retries %s", (_name, err) => { - expect(isRetryableDiscordPreConnectError(err)).toBe(true); + ])("retries pre-connect %s for nonce-protected creates", async (_name, err) => { + const fn = vi.fn().mockRejectedValueOnce(err).mockResolvedValue("ok"); + const runner = createDiscordRetryRunner({ retry: ZERO_DELAY_RETRY }); + await expect(runner(fn, "create", { safety: "nonce-protected-create" })).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); }); - it("does not retry 502", () => { - expect( - isRetryableDiscordPreConnectError(Object.assign(new Error("bad gateway"), { status: 502 })), - ).toBe(false); + it("does not retry a 502 for non-idempotent creates", async () => { + const error = Object.assign(new Error("bad gateway"), { status: 502 }); + const fn = vi.fn().mockRejectedValueOnce(error).mockResolvedValue("ok"); + const runner = createDiscordRetryRunner({ retry: ZERO_DELAY_RETRY }); + await expect(runner(fn, "create", { safety: "non-idempotent-create" })).rejects.toBe(error); + expect(fn).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/discord/src/retry.ts b/extensions/discord/src/retry.ts index d5841a4fa266..205db09f5b2d 100644 --- a/extensions/discord/src/retry.ts +++ b/extensions/discord/src/retry.ts @@ -68,7 +68,7 @@ function readDiscordErrorStatus(err: unknown): number | undefined { return parseStrictNonNegativeInteger(raw); } -export function isRetryableDiscordTransientError(err: unknown): boolean { +function isRetryableDiscordTransientError(err: unknown): boolean { if (err instanceof RateLimitError) { return true; } @@ -97,7 +97,7 @@ export function isRetryableDiscordTransientError(err: unknown): boolean { return false; } -export function isRetryableDiscordPreConnectError(err: unknown): boolean { +function isRetryableDiscordPreConnectError(err: unknown): boolean { if (err instanceof RateLimitError) { return true; } diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 0696ee1b286e..20f321799a0f 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -7,7 +7,7 @@ type DiscordChannelRuntime = { sendMessageDiscord?: typeof import("./send.js").sendMessageDiscord; }; -export type DiscordRuntime = PluginRuntime & { +type DiscordRuntime = PluginRuntime & { channel: PluginRuntime["channel"] & { discord?: DiscordChannelRuntime; }; diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index c9077f863b2b..f0671b4f516d 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -6,7 +6,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { RequestClient } from "./internal/discord.js"; import { DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS } from "./monitor/timeouts.js"; import type { DiscordRetryRunner } from "./retry.js"; -import type { VoiceMessageMetadata } from "./voice-message.js"; + +type VoiceMessageMetadata = Awaited< + ReturnType +>; const GUARDED_FETCH_TEST_TIMEOUT_MS = 250; diff --git a/extensions/discord/src/voice-message.ts b/extensions/discord/src/voice-message.ts index 362161468602..0899242f44fb 100644 --- a/extensions/discord/src/voice-message.ts +++ b/extensions/discord/src/voice-message.ts @@ -81,7 +81,7 @@ function createRateLimitError( return new RateLimitErrorCtor(response, body, fallbackRequest); } -export type VoiceMessageMetadata = { +type VoiceMessageMetadata = { durationSecs: number; waveform: string; // base64 encoded }; diff --git a/extensions/discord/src/voice/prompt.test.ts b/extensions/discord/src/voice/prompt.test.ts index 95442327be2e..3c9d3ceebdc9 100644 --- a/extensions/discord/src/voice/prompt.test.ts +++ b/extensions/discord/src/voice/prompt.test.ts @@ -1,23 +1,22 @@ // Discord tests cover prompt plugin behavior. import { describe, expect, it } from "vitest"; -import { DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT, formatVoiceIngressPrompt } from "./prompt.js"; +import { formatVoiceIngressPrompt } from "./prompt.js"; + +const DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT = [ + "You are OpenClaw's Discord voice interface in a live voice channel.", + "Discord voice reply requirements:", + "- Return only the concise text that should be spoken aloud in the voice channel.", + "- Treat the transcript as speech-to-text from a live conversation; repair obvious transcription artifacts and ignore repeated partial fragments caused by voice buffering.", + "- If the transcript is garbled, incomplete, or missing the user's intent, ask one brief clarifying question instead of guessing.", + "- If the request needs deeper reasoning, current information, or tools, use the available tools before answering.", + "- Do not call the tts tool; Discord voice will synthesize and play the returned text.", + "- Do not reply with NO_REPLY unless no spoken response is appropriate.", + "- Keep the response brief, natural, and conversational. Prefer one to three short sentences.", + "- Avoid markdown tables, code fences, citations, and visual formatting unless the user explicitly asks for something that cannot be spoken naturally.", +].join("\n"); describe("formatVoiceIngressPrompt", () => { it("formats speaker-labeled voice input with the spoken-output contract", () => { - expect(DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT).toBe( - [ - "You are OpenClaw's Discord voice interface in a live voice channel.", - "Discord voice reply requirements:", - "- Return only the concise text that should be spoken aloud in the voice channel.", - "- Treat the transcript as speech-to-text from a live conversation; repair obvious transcription artifacts and ignore repeated partial fragments caused by voice buffering.", - "- If the transcript is garbled, incomplete, or missing the user's intent, ask one brief clarifying question instead of guessing.", - "- If the request needs deeper reasoning, current information, or tools, use the available tools before answering.", - "- Do not call the tts tool; Discord voice will synthesize and play the returned text.", - "- Do not reply with NO_REPLY unless no spoken response is appropriate.", - "- Keep the response brief, natural, and conversational. Prefer one to three short sentences.", - "- Avoid markdown tables, code fences, citations, and visual formatting unless the user explicitly asks for something that cannot be spoken naturally.", - ].join("\n"), - ); expect(formatVoiceIngressPrompt("hello there", "speaker-1")).toBe( `${DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT}\n\nVoice transcript from speaker "speaker-1":\nhello there`, ); diff --git a/extensions/discord/src/voice/prompt.ts b/extensions/discord/src/voice/prompt.ts index e42d2291eb8c..7444d9ad2cb3 100644 --- a/extensions/discord/src/voice/prompt.ts +++ b/extensions/discord/src/voice/prompt.ts @@ -1,5 +1,5 @@ // Discord plugin module implements prompt behavior. -export const DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT = [ +const DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT = [ "You are OpenClaw's Discord voice interface in a live voice channel.", "Discord voice reply requirements:", "- Return only the concise text that should be spoken aloud in the voice channel.", diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index e20a10f209c9..33ff5ed339c8 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -38,58 +38,16 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/diffs/src/shiki-curated-languages.ts: bundledLanguagesAlias", "extensions/discord/src/actions/runtime.guild.ts: discordGuildActionRuntime", "extensions/discord/src/actions/runtime.moderation.ts: discordModerationActionRuntime", - "extensions/discord/src/approval-native.ts: createDiscordNativeApprovalAdapter", - "extensions/discord/src/audit-core.ts: collectDiscordAuditChannelIdsForGuilds", - "extensions/discord/src/chunk.ts: chunkDiscordText", "extensions/discord/src/components-registry.ts: clearDiscordComponentEntries", "extensions/discord/src/components-registry.ts: resolveDiscordComponentEntry", "extensions/discord/src/components-registry.ts: resolveDiscordModalEntry", "extensions/discord/src/directory-cache.ts: resetDiscordDirectoryCacheForTest", - "extensions/discord/src/internal/client.ts: AnyListener", - "extensions/discord/src/internal/client.ts: ClientOptions", "extensions/discord/src/internal/client.ts: ComponentRegistry", "extensions/discord/src/internal/command-deploy.ts: testing", - "extensions/discord/src/internal/interactions.ts: BaseInteraction", - "extensions/discord/src/internal/rest-scheduler.ts: RestSchedulerOptions", - "extensions/discord/src/internal/rest.ts: QueuedRequest", - "extensions/discord/src/monitor.gateway.ts: WaitForDiscordGatewayStopParams", - "extensions/discord/src/monitor/agent-components.dispatch.ts: resolveDiscordComponentOriginatingTo", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentButton", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentChannelSelect", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentMentionableSelect", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentRoleSelect", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentStringSelect", - "extensions/discord/src/monitor/agent-components.ts: createDiscordComponentUserSelect", - "extensions/discord/src/monitor/allow-list.ts: resolveDiscordRoleAllowed", - "extensions/discord/src/monitor/auto-presence.ts: resolveDiscordAutoPresenceDecision", - "extensions/discord/src/monitor/channel-access.ts: resolveDiscordChannelOwnerIdSafe", - "extensions/discord/src/monitor/exec-approvals.ts: ExecApprovalButton", - "extensions/discord/src/monitor/gateway-metadata.ts: fetchDiscordGatewayInfo", - "extensions/discord/src/monitor/gateway-metadata.ts: parseDiscordGatewayInfoBody", - "extensions/discord/src/monitor/gateway-supervisor.ts: classifyDiscordGatewayEvent", - "extensions/discord/src/monitor/inbound-context.ts: buildDiscordUntrustedContext", "extensions/discord/src/monitor/message-channel-info.ts: resetDiscordChannelInfoCacheForTest", - "extensions/discord/src/monitor/model-picker-preferences.ts: buildDiscordModelPickerPreferenceKey", - "extensions/discord/src/monitor/model-picker.state.ts: computeAlphaBuckets", - "extensions/discord/src/monitor/model-picker.state.ts: DISCORD_CUSTOM_ID_MAX_CHARS", - "extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_BUCKET_TARGET_SIZE", - "extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE", - "extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE", - "extensions/discord/src/monitor/model-picker.state.ts: DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX", - "extensions/discord/src/monitor/model-picker.state.ts: parseDiscordModelPickerCustomId", - "extensions/discord/src/monitor/native-command-ui.ts: DispatchDiscordCommandInteraction", "extensions/discord/src/monitor/native-command.runtime.ts: testing", "extensions/discord/src/monitor/native-command.ts: testing", - "extensions/discord/src/monitor/provider.lifecycle.ts: resolveDiscordGatewayReadyTimeoutMs", - "extensions/discord/src/monitor/provider.lifecycle.ts: resolveDiscordGatewayRuntimeReadyTimeoutMs", "extensions/discord/src/monitor/provider.ts: testing", - "extensions/discord/src/monitor/thread-title.ts: normalizeGeneratedThreadTitle", - "extensions/discord/src/resolve-allowlist-common.ts: findDiscordGuildByName", - "extensions/discord/src/retry.ts: isRetryableDiscordPreConnectError", - "extensions/discord/src/retry.ts: isRetryableDiscordTransientError", - "extensions/discord/src/runtime.ts: DiscordRuntime", - "extensions/discord/src/voice-message.ts: VoiceMessageMetadata", - "extensions/discord/src/voice/prompt.ts: DISCORD_VOICE_SPOKEN_OUTPUT_CONTRACT", "extensions/file-transfer/src/node-host/dir-fetch.ts: testing", "extensions/file-transfer/src/shared/node-invoke-policy.ts: testing", "extensions/file-transfer/src/tools/dir-fetch-tool.ts: testing",