refactor(discord): trim internal exports (#107714)

* refactor(discord): trim internal exports

* refactor(discord): use canonical approval helper

* chore(deadcode): refresh export baseline
This commit is contained in:
Peter Steinberger
2026-07-14 11:34:02 -07:00
committed by GitHub
parent e63e49b1b0
commit af3d796e19
59 changed files with 372 additions and 505 deletions

View File

@@ -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<string, unknown>) {
fs.writeFileSync(STORE_PATH, `${JSON.stringify(store, null, 2)}\n`, "utf8");
clearSessionStoreCacheForTest();
@@ -34,22 +36,30 @@ function writeStore(store: Record<string, unknown>) {
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: {

View File

@@ -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<typeof createDiscordApprovalCapability> | undefined;
export function getDiscordApprovalCapability() {

View File

@@ -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,

View File

@@ -83,7 +83,7 @@ function listConfiguredGuildChannelKeys(
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
export function collectDiscordAuditChannelIdsForGuilds(
function collectDiscordAuditChannelIdsForGuilds(
guilds: Record<string, DiscordGuildEntry> | undefined,
) {
const keys = listConfiguredGuildChannelKeys(guilds);

View File

@@ -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);
});

View File

@@ -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<Parameters<typeof chunkDiscordTextWithMode>[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", () => {

View File

@@ -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);

View File

@@ -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<Client["registerListener"]>[0];
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;

View File

@@ -30,12 +30,12 @@ export abstract class Plugin {
onRequest?(req: Request, ctx: Context): Promise<Response | undefined> | Response | undefined;
}
export type AnyListener = {
type AnyListener = {
type: string;
handle(data: unknown, client: Client): Promise<void> | void;
};
export interface ClientOptions {
interface ClientOptions {
baseUrl: string;
clientId: string;
deploySecret?: string;

View File

@@ -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" }),
);

View File

@@ -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;

View File

@@ -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<typeof RestScheduler>[0];
function createOptions(overrides: Partial<RestSchedulerOptions> = {}): RestSchedulerOptions {
return {
lanes: {

View File

@@ -45,7 +45,7 @@ type RestSchedulerLaneOptions = {
weight: number;
};
export type RestSchedulerOptions = {
type RestSchedulerOptions = {
lanes: Record<RequestPriority, RestSchedulerLaneOptions>;
maxConcurrency: number;
maxQueueSize: number;

View File

@@ -62,7 +62,7 @@ export type RequestData = {
headers?: Record<string, string>;
};
export type QueuedRequest = {
type QueuedRequest = {
method: string;
path: string;
data?: RequestData;

View File

@@ -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<typeof Client>[0];
type RequestQuery = Parameters<RequestClient["get"]>[1];
type RestMock = Partial<Record<"get" | "post" | "patch" | "put" | "delete", Mock>>;
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();

View File

@@ -8,7 +8,7 @@ import type {
export { getDiscordGatewayEmitter } from "./monitor/gateway-supervisor.js";
export type WaitForDiscordGatewayStopParams = {
type WaitForDiscordGatewayStopParams = {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;
gatewaySupervisor?: Pick<DiscordGatewaySupervisor, "attachLifecycle" | "detachLifecycle">;

View File

@@ -71,7 +71,7 @@ function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionCon
return "channel";
}
export function resolveDiscordComponentOriginatingTo(
function resolveDiscordComponentOriginatingTo(
interactionCtx: Pick<ComponentInteractionContext, "isDirectMessage" | "userId" | "channelId">,
) {
return resolveDiscordConversationIdentity({

View File

@@ -25,22 +25,22 @@ function bindDiscordComponentControl<T extends BaseMessageInteractiveComponent>(
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,
);

View File

@@ -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<typeof createDiscordComponentButton>[0];
const context = {} as Parameters<DiscordComponentFactory>[0];
return [
asWildcardComponent(createDiscordComponentButton(context)),
asWildcardComponent(createDiscordComponentStringSelect(context)),

View File

@@ -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) {

View File

@@ -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<string, number> }) {
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", () => {

View File

@@ -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"

View File

@@ -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");

View File

@@ -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");
}

View File

@@ -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<NonNullable<OpenClawConfig["channels"]>["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" }),
});

View File

@@ -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;

View File

@@ -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("<html><title>Error 1015</title><body>rate limited</body></html>", {
status: 429,
headers: { "content-type": "text/html" },
}),
timeoutMs: 1_000,
}).catch((err: unknown) => err);
const runtime = {
log: vi.fn(),

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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<typeof vi.fn> }): unknown {
@@ -28,24 +28,44 @@ function firstErrorArg(runtime: { error: ReturnType<typeof vi.fn> }): 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);

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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<typeof setDiscordRuntime>[0];
const tempDirs: string[] = [];
async function createStateEnv(): Promise<NodeJS.ProcessEnv> {
@@ -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<unknown>("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",
},

View File

@@ -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);

View File

@@ -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<string, string> = {};
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 [];
}

View File

@@ -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(/^120 \(20\)$/);
expect(buckets[1]?.id).toBe("21-30");
expect(buckets[1]?.label).toMatch(/^2130 \(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<string, string[]> = {};

View File

@@ -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({

View File

@@ -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<string, unknown> | 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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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<typeof createDiscordCommandArgFallbackButton>[0]["ctx"];

View File

@@ -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";

View File

@@ -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<typeof waitForDiscordGatewayStop>[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();

View File

@@ -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 {

View File

@@ -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<typeof setDiscordRuntime>[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) => ({}));

View File

@@ -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: {

View File

@@ -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_");
});
});

View File

@@ -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) {

View File

@@ -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");
}

View File

@@ -14,7 +14,7 @@ export function buildDiscordUnresolvedResults<T extends { input: string; resolve
return entries.map(buildResult);
}
export function findDiscordGuildByName(
function findDiscordGuildByName(
guilds: DiscordGuildSummary[],
input: string,
): DiscordGuildSummary | undefined {

View File

@@ -1,11 +1,7 @@
// Discord tests cover retry plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { RateLimitError } from "./internal/discord.js";
import {
createDiscordRetryRunner,
isRetryableDiscordPreConnectError,
isRetryableDiscordTransientError,
} from "./retry.js";
import { createDiscordRetryRunner } from "./retry.js";
const ZERO_DELAY_RETRY = { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 };
@@ -28,7 +24,7 @@ function createRateLimitError(retryAfter = 0): RateLimitError {
});
}
describe("isRetryableDiscordTransientError", () => {
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);
});
});

View File

@@ -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;
}

View File

@@ -7,7 +7,7 @@ type DiscordChannelRuntime = {
sendMessageDiscord?: typeof import("./send.js").sendMessageDiscord;
};
export type DiscordRuntime = PluginRuntime & {
type DiscordRuntime = PluginRuntime & {
channel: PluginRuntime["channel"] & {
discord?: DiscordChannelRuntime;
};

View File

@@ -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<typeof import("./voice-message.js").getVoiceMessageMetadata>
>;
const GUARDED_FETCH_TEST_TIMEOUT_MS = 250;

View File

@@ -81,7 +81,7 @@ function createRateLimitError(
return new RateLimitErrorCtor(response, body, fallbackRequest);
}
export type VoiceMessageMetadata = {
type VoiceMessageMetadata = {
durationSecs: number;
waveform: string; // base64 encoded
};

View File

@@ -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`,
);

View File

@@ -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.",

View File

@@ -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",