mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 09:11:11 +00:00
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:
committed by
GitHub
parent
e63e49b1b0
commit
af3d796e19
@@ -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: {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -45,7 +45,7 @@ type RestSchedulerLaneOptions = {
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type RestSchedulerOptions = {
|
||||
type RestSchedulerOptions = {
|
||||
lanes: Record<RequestPriority, RestSchedulerLaneOptions>;
|
||||
maxConcurrency: number;
|
||||
maxQueueSize: number;
|
||||
|
||||
@@ -62,7 +62,7 @@ export type RequestData = {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type QueuedRequest = {
|
||||
type QueuedRequest = {
|
||||
method: string;
|
||||
path: string;
|
||||
data?: RequestData;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -71,7 +71,7 @@ function resolveDiscordComponentChatType(interactionCtx: ComponentInteractionCon
|
||||
return "channel";
|
||||
}
|
||||
|
||||
export function resolveDiscordComponentOriginatingTo(
|
||||
function resolveDiscordComponentOriginatingTo(
|
||||
interactionCtx: Pick<ComponentInteractionContext, "isDirectMessage" | "userId" | "channelId">,
|
||||
) {
|
||||
return resolveDiscordConversationIdentity({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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<string, string[]> = {};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ({}));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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_");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type DiscordChannelRuntime = {
|
||||
sendMessageDiscord?: typeof import("./send.js").sendMessageDiscord;
|
||||
};
|
||||
|
||||
export type DiscordRuntime = PluginRuntime & {
|
||||
type DiscordRuntime = PluginRuntime & {
|
||||
channel: PluginRuntime["channel"] & {
|
||||
discord?: DiscordChannelRuntime;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ function createRateLimitError(
|
||||
return new RateLimitErrorCtor(response, body, fallbackRequest);
|
||||
}
|
||||
|
||||
export type VoiceMessageMetadata = {
|
||||
type VoiceMessageMetadata = {
|
||||
durationSecs: number;
|
||||
waveform: string; // base64 encoded
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user