fix(discord): require runtime config in helpers

This commit is contained in:
Peter Steinberger
2026-04-25 00:09:41 +01:00
parent beefcda68f
commit 5d9941c36d
43 changed files with 290 additions and 243 deletions

View File

@@ -98,7 +98,7 @@ export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg?: OpenClawConfig,
cfg: OpenClawConfig,
options?: { mediaLocalRoots?: readonly string[] },
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");

View File

@@ -118,11 +118,11 @@ export async function handleDiscordMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg: OpenClawConfig,
options?: {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
cfg?: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
const resolveChannelId = () =>
discordMessagingActionRuntime.resolveDiscordChannelId(

View File

@@ -54,7 +54,7 @@ export async function handleDiscordModerationAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg?: OpenClawConfig,
cfg: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
if (!isDiscordModerationAction(action)) {
throw new Error(`Unknown action: ${action}`);

View File

@@ -89,13 +89,13 @@ function handleMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
cfg: OpenClawConfig = DISCORD_TEST_CFG,
options?: {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
cfg: OpenClawConfig = DISCORD_TEST_CFG,
) {
return handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
return handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options);
}
function handleGuildAction(
@@ -178,7 +178,6 @@ describe("handleDiscordMessagingAction", () => {
emoji: "✅",
},
enableAllActions,
undefined,
{
channels: {
discord: {
@@ -363,7 +362,7 @@ describe("handleDiscordMessagingAction", () => {
},
},
} as OpenClawConfig;
await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, {}, cfg);
await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, cfg);
expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg });
});
@@ -397,7 +396,6 @@ describe("handleDiscordMessagingAction", () => {
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
{},
cfg,
);
expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
@@ -471,6 +469,7 @@ describe("handleDiscordMessagingAction", () => {
mediaUrl: "/tmp/image.png",
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
);
expect(sendMessageDiscord).toHaveBeenCalledWith(
@@ -496,6 +495,7 @@ describe("handleDiscordMessagingAction", () => {
components: {},
},
enableAllActions,
DISCORD_TEST_CFG,
{ mediaLocalRoots: ["/tmp/agent-root"] },
);

View File

@@ -66,7 +66,7 @@ export async function handleDiscordAction(
const isActionEnabled = createDiscordActionGate({ cfg, accountId });
if (messagingActions.has(action)) {
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
return await handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options);
}
if (guildActions.has(action)) {
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);

View File

@@ -358,10 +358,11 @@ async function updateMessage(params: {
container: DiscordUiContainer;
}): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
const { rest, request: discordRequest } = createDiscordClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
const payload = buildExecApprovalPayload(params.container);
await discordRequest(
() =>
@@ -389,10 +390,11 @@ async function finalizeMessage(params: {
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: params.token, accountId: params.accountId },
params.cfg,
);
const { rest, request: discordRequest } = createDiscordClient({
cfg: params.cfg,
token: params.token,
accountId: params.accountId,
});
await discordRequest(
() => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise<void>,
"delete-approval",
@@ -517,10 +519,11 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd
},
};
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
const { rest, request: discordRequest } = createDiscordClient({
cfg,
);
token: resolved.context.token,
accountId: resolved.accountId,
});
const userId = plannedTarget.target.to;
const dmChannel = (await discordRequest(
() =>
@@ -553,10 +556,11 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd
if (!resolved) {
return null;
}
const { rest, request: discordRequest } = createDiscordClient(
{ token: resolved.context.token, accountId: resolved.accountId },
const { rest, request: discordRequest } = createDiscordClient({
cfg,
);
token: resolved.context.token,
accountId: resolved.accountId,
});
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(preparedTarget.discordChannelId), {

View File

@@ -1,6 +1,7 @@
import type {
DiscordGuildChannelConfig,
DiscordGuildEntry,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
@@ -76,13 +77,14 @@ export function collectDiscordAuditChannelIdsForGuilds(
}
export async function auditDiscordChannelPermissionsWithFetcher(params: {
cfg: OpenClawConfig;
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
fetchChannelPermissions: (
channelId: string,
params: { token: string; accountId?: string },
params: { cfg: OpenClawConfig; token: string; accountId?: string },
) => Promise<{
permissions: string[];
}>;
@@ -105,6 +107,7 @@ export async function auditDiscordChannelPermissionsWithFetcher(params: {
for (const channelId of params.channelIds) {
try {
const perms = await params.fetchChannelPermissions(channelId, {
cfg: params.cfg,
token,
accountId: params.accountId ?? undefined,
});

View File

@@ -56,6 +56,7 @@ describe("discord audit", () => {
});
const audit = await auditDiscordChannelPermissionsWithFetcher({
cfg,
token: "t",
accountId: "default",
channelIds: collected.channelIds,

View File

@@ -19,6 +19,7 @@ export function collectDiscordAuditChannelIds(params: {
}
export async function auditDiscordChannelPermissions(params: {
cfg: OpenClawConfig;
token: string;
accountId?: string | null;
channelIds: string[];

View File

@@ -621,10 +621,23 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
],
};
}
const statusCfg: OpenClawConfig = {
channels: {
discord: {
accounts: {
[account.accountId]: {
...account.config,
token,
},
},
},
},
};
try {
const perms = await (
await loadDiscordSendModule()
).fetchChannelPermissionsDiscord(parsedTarget.id, {
cfg: statusCfg,
token,
accountId: account.accountId ?? undefined,
});
@@ -681,6 +694,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
};
}
const audit = await auditDiscordChannelPermissions({
cfg,
token: botToken,
accountId: account.accountId,
channelIds,

View File

@@ -35,7 +35,7 @@ describe("createDiscordRestClient proxy support", () => {
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({}, cfg);
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
customFetch?: typeof fetch;
options?: { fetch?: typeof fetch };
@@ -54,7 +54,7 @@ describe("createDiscordRestClient proxy support", () => {
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({}, cfg);
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
@@ -72,7 +72,7 @@ describe("createDiscordRestClient proxy support", () => {
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({}, cfg);
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
@@ -91,7 +91,7 @@ describe("createDiscordRestClient proxy support", () => {
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({}, cfg);
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};
@@ -110,7 +110,7 @@ describe("createDiscordRestClient proxy support", () => {
},
} as OpenClawConfig;
const { rest } = createDiscordRestClient({}, cfg);
const { rest } = createDiscordRestClient({ cfg });
const requestClient = rest as unknown as {
options?: { fetch?: typeof fetch };
};

View File

@@ -19,13 +19,7 @@ describe("createDiscordRestClient", () => {
},
} as OpenClawConfig;
const result = createDiscordRestClient(
{
token: "Bot explicit-token",
rest: fakeRest,
},
cfg,
);
const result = createDiscordRestClient({ cfg, token: "Bot explicit-token", rest: fakeRest });
expect(result.token).toBe("explicit-token");
expect(result.rest).toBe(fakeRest);
@@ -52,14 +46,12 @@ describe("createDiscordRestClient", () => {
},
} as OpenClawConfig;
const result = createDiscordRestClient(
{
accountId: "ops",
token: "Bot explicit-account-token",
rest: fakeRest,
},
const result = createDiscordRestClient({
cfg,
);
accountId: "ops",
token: "Bot explicit-account-token",
rest: fakeRest,
});
expect(result.token).toBe("explicit-account-token");
expect(result.account.accountId).toBe("ops");
@@ -79,13 +71,6 @@ describe("createDiscordRestClient", () => {
},
} as OpenClawConfig;
expect(() =>
createDiscordRestClient(
{
rest: fakeRest,
},
cfg,
),
).toThrow(/unresolved SecretRef/i);
expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i);
});
});

View File

@@ -16,7 +16,7 @@ import type { DiscordRuntimeAccountContext } from "./send.types.js";
import { normalizeDiscordToken } from "./token.js";
export type DiscordClientOpts = {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
token?: string;
accountId?: string;
rest?: RequestClient;
@@ -36,16 +36,9 @@ export function createDiscordRuntimeAccountContext(params: {
export function resolveDiscordClientAccountContext(
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
cfg?: OpenClawConfig,
runtime?: Pick<RuntimeEnv, "error">,
) {
const config = opts.cfg ?? cfg;
if (!config) {
throw new Error(
"Discord client requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const resolvedCfg = requireRuntimeConfig(config, "Discord client");
const resolvedCfg = requireRuntimeConfig(opts.cfg, "Discord client");
const account = resolveAccountWithoutToken({
cfg: resolvedCfg,
accountId: opts.accountId,
@@ -69,10 +62,9 @@ function resolveToken(params: { accountId: string; fallbackToken?: string }) {
export function resolveDiscordProxyFetch(
opts: Pick<DiscordClientOpts, "cfg" | "accountId">,
cfg?: OpenClawConfig,
runtime?: Pick<RuntimeEnv, "error">,
): typeof fetch | undefined {
return resolveDiscordClientAccountContext(opts, cfg, runtime).proxyFetch;
return resolveDiscordClientAccountContext(opts, runtime).proxyFetch;
}
function resolveRest(
@@ -110,9 +102,9 @@ function resolveAccountWithoutToken(params: {
};
}
export function createDiscordRestClient(opts: DiscordClientOpts, cfg?: OpenClawConfig) {
export function createDiscordRestClient(opts: DiscordClientOpts) {
const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token");
const proxyContext = resolveDiscordClientAccountContext(opts, cfg);
const proxyContext = resolveDiscordClientAccountContext(opts);
const resolvedCfg = proxyContext.cfg;
const account = explicitToken
? proxyContext.account
@@ -127,11 +119,12 @@ export function createDiscordRestClient(opts: DiscordClientOpts, cfg?: OpenClawC
return { token, rest, account };
}
export function createDiscordClient(
opts: DiscordClientOpts,
cfg?: OpenClawConfig,
): { token: string; rest: RequestClient; request: RetryRunner } {
const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg);
export function createDiscordClient(opts: DiscordClientOpts): {
token: string;
rest: RequestClient;
request: RetryRunner;
} {
const { token, rest, account } = createDiscordRestClient(opts);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
@@ -141,5 +134,5 @@ export function createDiscordClient(
}
export function resolveDiscordRest(opts: DiscordClientOpts) {
return createDiscordRestClient(opts, opts.cfg).rest;
return createDiscordRestClient(opts).rest;
}

View File

@@ -4,7 +4,7 @@ import { resolveDiscordDraftStreamingChunking } from "./draft-chunking.js";
describe("resolveDiscordDraftStreamingChunking", () => {
it("returns sane defaults when discord draft chunking is unset", () => {
expect(resolveDiscordDraftStreamingChunking(undefined)).toEqual({
expect(resolveDiscordDraftStreamingChunking({} as OpenClawConfig)).toEqual({
minChars: 200,
maxChars: 800,
breakPreference: "paragraph",

View File

@@ -9,7 +9,7 @@ const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200;
const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800;
export function resolveDiscordDraftStreamingChunking(
cfg: OpenClawConfig | undefined,
cfg: OpenClawConfig,
accountId?: string | null,
): {
minChars: number;

View File

@@ -1244,6 +1244,7 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => {
it("returns true for recently unbound thread webhook echoes", async () => {
const manager = createThreadBindingManager({
cfg: DEFAULT_PREFLIGHT_CFG,
accountId: "default",
persist: false,
enableSweeper: false,

View File

@@ -683,6 +683,7 @@ describe("processDiscordMessage session routing", () => {
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
const threadBindings = createThreadBindingManager({
cfg: {} as import("openclaw/plugin-sdk/config-runtime").OpenClawConfig,
accountId: "default",
persist: false,
enableSweeper: false,

View File

@@ -826,6 +826,8 @@ export async function processDiscordMessage(
}
notifyFinalReplyStart();
await editMessageDiscord(deliverChannelId, previewMessageId, edit, {
cfg,
accountId,
rest: deliveryRest,
});
},

View File

@@ -1,5 +1,6 @@
import type { Client } from "@buape/carbon";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import { beforeEach, describe, expect, it } from "vitest";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
@@ -23,6 +24,8 @@ import {
resolveDiscordReplyDeliveryPlan,
} from "./threading.js";
const DEFAULT_CFG = {} as OpenClawConfig;
describe("resolveDiscordOwnerAllowFrom", () => {
it("returns undefined when no allowlist is configured", () => {
const result = resolveDiscordOwnerAllowFrom({
@@ -432,6 +435,7 @@ describe("maybeCreateDiscordAutoThread", () => {
threadChannel: null,
baseText: "hello",
combinedBody: "hello",
cfg: DEFAULT_CFG,
};
}
@@ -489,6 +493,7 @@ describe("resolveDiscordAutoThreadReplyPlan", () => {
threadChannel: overrides?.threadChannel ?? null,
baseText: "hello",
combinedBody: "hello",
cfg: DEFAULT_CFG,
replyToMode: "all" as const,
agentId: "agent",
channel: "discord" as const,

View File

@@ -13,6 +13,7 @@ const sendDiscordTextMock = vi.hoisted(() => vi.fn());
const buildDiscordSendErrorMock = vi.hoisted(() =>
vi.fn<(err: unknown, ctx?: unknown) => Promise<unknown>>(async (err: unknown) => err),
);
const DEFAULT_CFG = {} as OpenClawConfig;
const retryAsyncMock = vi.hoisted(() =>
vi.fn(
async (
@@ -100,6 +101,7 @@ describe("deliverDiscordReply", () => {
}> = {},
) => {
const threadBindings = createThreadBindingManager({
cfg: DEFAULT_CFG,
accountId: "default",
persist: false,
enableSweeper: false,

View File

@@ -9,6 +9,7 @@ const DEFAULT_SEND_RESULT = {
messageId: "msg-1",
channelId: "thread-1",
};
const DEFAULT_CFG = {} as OpenClawConfig;
const restGet = vi.fn<(...args: unknown[]) => Promise<unknown>>();
const sendMessageDiscord = vi.fn<typeof discordSendModule.sendMessageDiscord>();
@@ -30,6 +31,17 @@ beforeAll(async () => {
await import("./thread-bindings.discord-api.js"));
});
function resolveTestChannelIdForBinding(
params: Omit<Parameters<typeof resolveChannelIdForBinding>[0], "cfg"> & {
cfg?: OpenClawConfig;
},
) {
return resolveChannelIdForBinding({
cfg: DEFAULT_CFG,
...params,
});
}
describe("resolveChannelIdForBinding", () => {
beforeEach(() => {
vi.restoreAllMocks();
@@ -59,7 +71,7 @@ describe("resolveChannelIdForBinding", () => {
});
it("returns explicit channelId without resolving route", async () => {
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
channelId: "channel-explicit",
@@ -71,7 +83,7 @@ describe("resolveChannelIdForBinding", () => {
});
it("normalizes prefixed explicit channelId without resolving route", async () => {
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
channelId: "channel:123456789012345678",
@@ -88,7 +100,7 @@ describe("resolveChannelIdForBinding", () => {
type: ChannelType.GuildText,
});
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "channel:123456789012345678",
});
@@ -106,7 +118,7 @@ describe("resolveChannelIdForBinding", () => {
parent_id: "channel-parent",
});
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "thread-1",
});
@@ -124,14 +136,16 @@ describe("resolveChannelIdForBinding", () => {
parent_id: "channel-parent",
});
await resolveChannelIdForBinding({
await resolveTestChannelIdForBinding({
cfg,
accountId: "default",
threadId: "thread-1",
});
const createDiscordRestClientCalls = createDiscordRestClient.mock.calls as unknown[][];
expect(createDiscordRestClientCalls[0]?.[1]).toBe(cfg);
expect(
(createDiscordRestClientCalls[0]?.[0] as { cfg?: OpenClawConfig } | undefined)?.cfg,
).toBe(cfg);
});
it("keeps non-thread channel id even when parent_id exists", async () => {
@@ -141,7 +155,7 @@ describe("resolveChannelIdForBinding", () => {
parent_id: "category-1",
});
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "channel-text",
});
@@ -156,7 +170,7 @@ describe("resolveChannelIdForBinding", () => {
parent_id: "category-1",
});
const resolved = await resolveChannelIdForBinding({
const resolved = await resolveTestChannelIdForBinding({
accountId: "default",
threadId: "forum-1",
});

View File

@@ -173,19 +173,17 @@ export async function maybeSendBindingMessage(params: {
}
export async function createWebhookForChannel(params: {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
accountId: string;
token?: string;
channelId: string;
}): Promise<{ webhookId?: string; webhookToken?: string }> {
try {
const rest = createDiscordRestClient(
{
accountId: params.accountId,
token: params.token,
},
params.cfg,
).rest;
const rest = createDiscordRestClient({
cfg: params.cfg,
accountId: params.accountId,
token: params.token,
}).rest;
const created = (await rest.post(Routes.channelWebhooks(params.channelId), {
body: {
name: "OpenClaw Agents",
@@ -240,7 +238,7 @@ export function findReusableWebhook(params: { accountId: string; channelId: stri
}
export async function resolveChannelIdForBinding(params: {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
accountId: string;
token?: string;
threadId: string;
@@ -255,13 +253,11 @@ export async function resolveChannelIdForBinding(params: {
return null;
}
try {
const rest = createDiscordRestClient(
{
accountId: params.accountId,
token: params.token,
},
params.cfg,
).rest;
const rest = createDiscordRestClient({
cfg: params.cfg,
accountId: params.accountId,
token: params.token,
}).rest;
const channel = (await rest.get(Routes.channel(lookupThreadId))) as {
id?: string;
type?: number;
@@ -291,7 +287,7 @@ export async function resolveChannelIdForBinding(params: {
}
export async function createThreadForBinding(params: {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
accountId: string;
token?: string;
channelId: string;

View File

@@ -70,6 +70,17 @@ const discordClientModule = await import("../client.js");
const discordThreadBindingApi = await import("./thread-bindings.discord-api.js");
const acpRuntime = await import("openclaw/plugin-sdk/acp-runtime");
function createTestThreadBindingManager(
params: Omit<Parameters<typeof createThreadBindingManager>[0], "cfg"> & {
cfg?: OpenClawConfig;
},
) {
return createThreadBindingManager({
cfg: {} as OpenClawConfig,
...params,
});
}
describe("thread binding lifecycle", () => {
beforeEach(() => {
__testing.resetThreadBindingsForTests();
@@ -200,7 +211,7 @@ describe("thread binding lifecycle", () => {
});
const createDefaultSweeperManager = () =>
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -257,7 +268,7 @@ describe("thread binding lifecycle", () => {
it("auto-unfocuses idle-expired bindings and sends inactivity message", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
cfg: {} as OpenClawConfig,
persist: false,
@@ -297,7 +308,7 @@ describe("thread binding lifecycle", () => {
it("auto-unfocuses max-age-expired bindings and sends max-age message", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
cfg: {} as OpenClawConfig,
persist: false,
@@ -378,7 +389,7 @@ describe("thread binding lifecycle", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -423,7 +434,7 @@ describe("thread binding lifecycle", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -464,7 +475,7 @@ describe("thread binding lifecycle", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -519,7 +530,7 @@ describe("thread binding lifecycle", () => {
it("keeps binding when idle timeout is disabled per session key", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -561,7 +572,7 @@ describe("thread binding lifecycle", () => {
it("keeps a binding when activity is touched during the same sweep pass", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -626,7 +637,7 @@ describe("thread binding lifecycle", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -667,7 +678,7 @@ describe("thread binding lifecycle", () => {
try {
__testing.resetThreadBindingsForTests();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: true,
enableSweeper: false,
@@ -690,7 +701,7 @@ describe("thread binding lifecycle", () => {
manager.touchThread({ threadId: "thread-1" });
__testing.resetThreadBindingsForTests();
const reloaded = createThreadBindingManager({
const reloaded = createTestThreadBindingManager({
accountId: "default",
persist: true,
enableSweeper: false,
@@ -719,7 +730,7 @@ describe("thread binding lifecycle", () => {
});
it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -756,7 +767,7 @@ describe("thread binding lifecycle", () => {
});
it("creates a new thread when spawning from an already bound thread", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -798,7 +809,7 @@ describe("thread binding lifecycle", () => {
});
it("resolves parent channel when thread target is passed via to without threadId", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -838,7 +849,7 @@ describe("thread binding lifecycle", () => {
const cfg = {
channels: { discord: { token: "tok" } },
} as OpenClawConfig;
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "runtime",
token: "runtime-token",
cfg,
@@ -894,7 +905,7 @@ describe("thread binding lifecycle", () => {
const refreshedCfg = {
channels: { discord: { token: "refreshed-token" } },
} as OpenClawConfig;
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "runtime",
token: "runtime-token",
cfg: startupCfg,
@@ -945,7 +956,7 @@ describe("thread binding lifecycle", () => {
});
it("refreshes manager token when an existing manager is reused", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "runtime",
token: "token-old",
persist: false,
@@ -953,7 +964,7 @@ describe("thread binding lifecycle", () => {
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "runtime",
token: "token-new",
persist: false,
@@ -987,7 +998,7 @@ describe("thread binding lifecycle", () => {
});
it("normalizes prefixed parentConversationId before creating child thread bindings", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1032,7 +1043,7 @@ describe("thread binding lifecycle", () => {
});
it("preserves prefixed current channel conversation ids as binding keys", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1086,7 +1097,7 @@ describe("thread binding lifecycle", () => {
});
it("binds current Discord DMs as direct conversation bindings", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1137,7 +1148,7 @@ describe("thread binding lifecycle", () => {
});
it("preserves direct-binding metadata when rebinding the same conversation", async () => {
createThreadBindingManager({
createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1198,14 +1209,14 @@ describe("thread binding lifecycle", () => {
});
it("keeps overlapping thread ids isolated per account", async () => {
const a = createThreadBindingManager({
const a = createTestThreadBindingManager({
accountId: "a",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const b = createThreadBindingManager({
const b = createTestThreadBindingManager({
accountId: "b",
persist: false,
enableSweeper: false,
@@ -1243,7 +1254,7 @@ describe("thread binding lifecycle", () => {
});
it("removes stale ACP bindings during startup reconciliation", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1326,7 +1337,7 @@ describe("thread binding lifecycle", () => {
});
it("keeps ACP bindings when session store reads fail during startup reconciliation", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1370,7 +1381,7 @@ describe("thread binding lifecycle", () => {
});
it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1411,7 +1422,7 @@ describe("thread binding lifecycle", () => {
});
it("removes ACP bindings when health probe marks running session as stale", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1455,7 +1466,7 @@ describe("thread binding lifecycle", () => {
});
it("keeps running ACP bindings when health probe is uncertain", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1503,7 +1514,7 @@ describe("thread binding lifecycle", () => {
});
it("keeps ACP bindings in stored error state when no explicit stale probe verdict exists", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1550,7 +1561,7 @@ describe("thread binding lifecycle", () => {
});
it("starts ACP health probes in parallel during startup reconciliation", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1626,7 +1637,7 @@ describe("thread binding lifecycle", () => {
});
it("caps ACP startup health probe concurrency", async () => {
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
@@ -1745,7 +1756,7 @@ describe("thread binding lifecycle", () => {
"utf-8",
);
const manager = createThreadBindingManager({
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,

View File

@@ -185,17 +185,15 @@ function toSessionBindingRecord(
};
}
export function createThreadBindingManager(
params: {
accountId?: string;
token?: string;
cfg?: OpenClawConfig;
persist?: boolean;
enableSweeper?: boolean;
idleTimeoutMs?: number;
maxAgeMs?: number;
} = {},
): ThreadBindingManager {
export function createThreadBindingManager(params: {
accountId?: string;
token?: string;
cfg: OpenClawConfig;
persist?: boolean;
enableSweeper?: boolean;
idleTimeoutMs?: number;
maxAgeMs?: number;
}): ThreadBindingManager {
ensureBindingsLoaded();
const accountId = normalizeAccountId(params.accountId);
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
@@ -280,13 +278,11 @@ export function createThreadBindingManager(
if (!rest) {
try {
const cfg = resolveCurrentCfg();
rest = createDiscordRestClient(
{
accountId,
token: resolveCurrentToken(),
},
rest = createDiscordRestClient({
cfg,
).rest;
accountId,
token: resolveCurrentToken(),
}).rest;
} catch {
return;
}

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeEach, describe, expect, it } from "vitest";
import {
__testing as threadBindingsTesting,
@@ -23,6 +24,7 @@ describe("thread binding manager state", () => {
const viaJiti = await loadThreadBindingsViaAlternateLoader();
createThreadBindingManager({
cfg: {} as OpenClawConfig,
accountId: "work",
persist: false,
enableSweeper: false,

View File

@@ -12,6 +12,7 @@ vi.mock("./thread-title.js", () => ({
}));
let maybeCreateDiscordAutoThread: MaybeCreateDiscordAutoThreadFn;
const DEFAULT_CFG = {} as OpenClawConfig;
const postMock = vi.fn();
const getMock = vi.fn();
@@ -37,6 +38,7 @@ function createBaseParams(
channelType: ChannelType.GuildText,
baseText: "test",
combinedBody: "test",
cfg: DEFAULT_CFG,
...overrides,
};
}

View File

@@ -450,7 +450,7 @@ type MaybeCreateDiscordAutoThreadParams = {
channelDescription?: string;
baseText: string;
combinedBody: string;
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
agentId?: string;
};
@@ -459,7 +459,7 @@ export async function resolveDiscordAutoThreadReplyPlan(
replyToMode: ReplyToMode;
agentId: string;
channel: string;
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
threadParentInheritanceEnabled?: boolean;
},
): Promise<DiscordAutoThreadReplyPlan> {

View File

@@ -8,7 +8,7 @@ import type { ResolvedDiscordAccount } from "./accounts.js";
export function resolveDiscordProxyUrl(
account: Pick<ResolvedDiscordAccount, "config">,
cfg?: OpenClawConfig,
cfg: OpenClawConfig,
): string | undefined {
const accountProxy = account.config.proxy?.trim();
if (accountProxy) {
@@ -31,7 +31,7 @@ export function resolveDiscordProxyFetchByUrl(
export function resolveDiscordProxyFetchForAccount(
account: Pick<ResolvedDiscordAccount, "config">,
cfg?: OpenClawConfig,
cfg: OpenClawConfig,
runtime?: Pick<RuntimeEnv, "error">,
): typeof fetch | undefined {
return resolveDiscordProxyFetchByUrl(resolveDiscordProxyUrl(account, cfg), runtime);

View File

@@ -15,8 +15,8 @@ type DiscordRecipient =
export async function parseAndResolveRecipient(
raw: string,
cfg: OpenClawConfig,
accountId?: string,
cfg?: OpenClawConfig,
parseOptions: DiscordTargetParseOptions = {},
): Promise<DiscordRecipient> {
if (!cfg) {

View File

@@ -11,7 +11,7 @@ import type {
export async function createChannelDiscord(
payload: DiscordChannelCreate,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
@@ -39,7 +39,7 @@ export async function createChannelDiscord(
export async function editChannelDiscord(
payload: DiscordChannelEdit,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {};
@@ -84,13 +84,13 @@ export async function editChannelDiscord(
})) as APIChannel;
}
export async function deleteChannelDiscord(channelId: string, opts: DiscordReactOpts = {}) {
export async function deleteChannelDiscord(channelId: string, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channel(channelId));
return { ok: true, channelId };
}
export async function moveChannelDiscord(payload: DiscordChannelMove, opts: DiscordReactOpts = {}) {
export async function moveChannelDiscord(payload: DiscordChannelMove, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
const body: Array<Record<string, unknown>> = [
{
@@ -105,7 +105,7 @@ export async function moveChannelDiscord(payload: DiscordChannelMove, opts: Disc
export async function setChannelPermissionDiscord(
payload: DiscordChannelPermissionSet,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
@@ -124,7 +124,7 @@ export async function setChannelPermissionDiscord(
export async function removeChannelPermissionDiscord(
channelId: string,
targetId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
await rest.delete(`/channels/${channelId}/permissions/${targetId}`);

View File

@@ -265,8 +265,8 @@ export async function sendDiscordComponentMessage(
const cfg = requireRuntimeConfig(opts.cfg, "Discord component send");
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { token, rest, request } = createDiscordClient({ ...opts, cfg });
const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
const channelType = await resolveDiscordChannelType(rest, channelId);
@@ -325,8 +325,8 @@ export async function editDiscordComponentMessage(
): Promise<DiscordSendResult> {
const cfg = requireRuntimeConfig(opts.cfg, "Discord component edit");
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { token, rest, request } = createDiscordClient({ ...opts, cfg });
const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
const { body, buildResult } = await buildDiscordComponentPayload({
spec,

View File

@@ -5,12 +5,12 @@ import { normalizeEmojiName, resolveDiscordRest } from "./send.shared.js";
import type { DiscordEmojiUpload, DiscordReactOpts, DiscordStickerUpload } from "./send.types.js";
import { DISCORD_MAX_EMOJI_BYTES, DISCORD_MAX_STICKER_BYTES } from "./send.types.js";
export async function listGuildEmojisDiscord(guildId: string, opts: DiscordReactOpts = {}) {
export async function listGuildEmojisDiscord(guildId: string, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
return await rest.get(Routes.guildEmojis(guildId));
}
export async function uploadEmojiDiscord(payload: DiscordEmojiUpload, opts: DiscordReactOpts = {}) {
export async function uploadEmojiDiscord(payload: DiscordEmojiUpload, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
const media = await loadWebMediaRaw(payload.mediaUrl, DISCORD_MAX_EMOJI_BYTES);
const contentType = normalizeOptionalLowercaseString(media.contentType);
@@ -31,10 +31,7 @@ export async function uploadEmojiDiscord(payload: DiscordEmojiUpload, opts: Disc
});
}
export async function uploadStickerDiscord(
payload: DiscordStickerUpload,
opts: DiscordReactOpts = {},
) {
export async function uploadStickerDiscord(payload: DiscordStickerUpload, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
const media = await loadWebMediaRaw(payload.mediaUrl, DISCORD_MAX_STICKER_BYTES);
const contentType = normalizeOptionalLowercaseString(media.contentType);

View File

@@ -21,7 +21,7 @@ import { DISCORD_MAX_EVENT_COVER_BYTES } from "./send.types.js";
export async function fetchMemberInfoDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIGuildMember> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.guildMember(guildId, userId))) as APIGuildMember;
@@ -29,19 +29,19 @@ export async function fetchMemberInfoDiscord(
export async function fetchRoleInfoDiscord(
guildId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIRole[]> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.guildRoles(guildId))) as APIRole[];
}
export async function addRoleDiscord(payload: DiscordRoleChange, opts: DiscordReactOpts = {}) {
export async function addRoleDiscord(payload: DiscordRoleChange, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
await rest.put(Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId));
return { ok: true };
}
export async function removeRoleDiscord(payload: DiscordRoleChange, opts: DiscordReactOpts = {}) {
export async function removeRoleDiscord(payload: DiscordRoleChange, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.guildMemberRole(payload.guildId, payload.userId, payload.roleId));
return { ok: true };
@@ -49,7 +49,7 @@ export async function removeRoleDiscord(payload: DiscordRoleChange, opts: Discor
export async function fetchChannelInfoDiscord(
channelId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.channel(channelId))) as APIChannel;
@@ -57,7 +57,7 @@ export async function fetchChannelInfoDiscord(
export async function listGuildChannelsDiscord(
guildId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIChannel[]> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.guildChannels(guildId))) as APIChannel[];
@@ -66,7 +66,7 @@ export async function listGuildChannelsDiscord(
export async function fetchVoiceStatusDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIVoiceState> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.guildVoiceState(guildId, userId))) as APIVoiceState;
@@ -74,7 +74,7 @@ export async function fetchVoiceStatusDiscord(
export async function listScheduledEventsDiscord(
guildId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIGuildScheduledEvent[]> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.guildScheduledEvents(guildId))) as APIGuildScheduledEvent[];
@@ -102,7 +102,7 @@ export async function resolveEventCoverImage(
export async function createScheduledEventDiscord(
guildId: string,
payload: RESTPostAPIGuildScheduledEventJSONBody,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIGuildScheduledEvent> {
const rest = resolveDiscordRest(opts);
return (await rest.post(Routes.guildScheduledEvents(guildId), {
@@ -112,7 +112,7 @@ export async function createScheduledEventDiscord(
export async function timeoutMemberDiscord(
payload: DiscordTimeoutTarget,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIGuildMember> {
const rest = resolveDiscordRest(opts);
let until = payload.until;
@@ -128,10 +128,7 @@ export async function timeoutMemberDiscord(
})) as APIGuildMember;
}
export async function kickMemberDiscord(
payload: DiscordModerationTarget,
opts: DiscordReactOpts = {},
) {
export async function kickMemberDiscord(payload: DiscordModerationTarget, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.guildMember(payload.guildId, payload.userId), {
headers: payload.reason
@@ -143,7 +140,7 @@ export async function kickMemberDiscord(
export async function banMemberDiscord(
payload: DiscordModerationTarget & { deleteMessageDays?: number },
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
const deleteMessageDays =

View File

@@ -13,7 +13,7 @@ import type {
export async function readMessagesDiscord(
channelId: string,
query: DiscordMessageQuery = {},
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIMessage[]> {
const rest = resolveDiscordRest(opts);
const limit =
@@ -39,7 +39,7 @@ export async function readMessagesDiscord(
export async function fetchMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIMessage> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.channelMessage(channelId, messageId))) as APIMessage;
@@ -49,7 +49,7 @@ export async function editMessageDiscord(
channelId: string,
messageId: string,
payload: DiscordMessageEdit,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIMessage> {
const rest = resolveDiscordRest(opts);
return (await rest.patch(Routes.channelMessage(channelId, messageId), {
@@ -60,7 +60,7 @@ export async function editMessageDiscord(
export async function deleteMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channelMessage(channelId, messageId));
@@ -70,7 +70,7 @@ export async function deleteMessageDiscord(
export async function pinMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
await rest.put(Routes.channelPin(channelId, messageId));
@@ -80,7 +80,7 @@ export async function pinMessageDiscord(
export async function unpinMessageDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channelPin(channelId, messageId));
@@ -89,7 +89,7 @@ export async function unpinMessageDiscord(
export async function listPinsDiscord(
channelId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<APIMessage[]> {
const rest = resolveDiscordRest(opts);
return (await rest.get(Routes.channelPins(channelId))) as APIMessage[];
@@ -98,7 +98,7 @@ export async function listPinsDiscord(
export async function createThreadDiscord(
channelId: string,
payload: DiscordThreadCreate,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = { name: payload.name };
@@ -150,7 +150,7 @@ export async function createThreadDiscord(
return thread;
}
export async function listThreadsDiscord(payload: DiscordThreadList, opts: DiscordReactOpts = {}) {
export async function listThreadsDiscord(payload: DiscordThreadList, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
if (payload.includeArchived) {
if (!payload.channelId) {
@@ -168,10 +168,7 @@ export async function listThreadsDiscord(payload: DiscordThreadList, opts: Disco
return await rest.get(Routes.guildActiveThreads(payload.guildId));
}
export async function searchMessagesDiscord(
query: DiscordSearchQuery,
opts: DiscordReactOpts = {},
) {
export async function searchMessagesDiscord(query: DiscordSearchQuery, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
const params = new URLSearchParams();
params.set("content", query.content);

View File

@@ -129,8 +129,8 @@ async function resolveDiscordSendTarget(
opts: DiscordSendOpts,
): Promise<{ rest: RequestClient; request: DiscordClientRequest; channelId: string }> {
const cfg = requireRuntimeConfig(opts.cfg, "Discord send target resolution");
const { rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { rest, request } = createDiscordClient({ ...opts, cfg });
const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
return { rest, request, channelId };
}
@@ -159,8 +159,8 @@ export async function sendMessageDiscord(
const textWithMentions = rewriteDiscordKnownMentions(textWithTables, {
accountId: accountInfo.accountId,
});
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { token, rest, request } = createDiscordClient({ ...opts, cfg });
const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
// Forum/Media channels reject POST /messages; auto-create a thread post instead.
@@ -543,19 +543,18 @@ export async function sendVoiceMessageDiscord(
let token: string | undefined;
let rest: RequestClient | undefined;
let channelId: string | undefined;
let cfg: OpenClawConfig | undefined;
const cfg = requireRuntimeConfig(opts.cfg, "Discord voice send");
try {
cfg = requireRuntimeConfig(opts.cfg, "Discord voice send");
const accountInfo = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const client = createDiscordClient(opts, cfg);
const client = createDiscordClient({ ...opts, cfg });
token = client.token;
rest = client.rest;
const request = client.request;
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const recipient = await parseAndResolveRecipient(to, cfg, opts.accountId);
channelId = (await resolveChannelId(rest, recipient, request)).channelId;
// Convert to OGG/Opus if needed
@@ -589,7 +588,7 @@ export async function sendVoiceMessageDiscord(
return toDiscordSendResult(result, channelId);
} catch (err) {
if (channelId && rest && token && cfg) {
if (channelId && rest && token) {
throw await buildDiscordSendError(err, {
channelId,
cfg,

View File

@@ -1,10 +1,12 @@
import type { RequestClient } from "@buape/carbon";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const mockRest = vi.hoisted(() => ({
get: vi.fn(),
}));
const DISCORD_TEST_OPTS = { cfg: {} as OpenClawConfig };
vi.mock("./client.js", () => ({
resolveDiscordRest: () => mockRest as unknown as RequestClient,
@@ -59,7 +61,11 @@ describe("discord guild permission authorization", () => {
it("returns null when user is not a guild member", async () => {
mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
const result = await fetchMemberGuildPermissionsDiscord(
"guild-1",
"user-1",
DISCORD_TEST_OPTS,
);
expect(result).toBeNull();
});
@@ -72,7 +78,11 @@ describe("discord guild permission authorization", () => {
memberRoles: ["role-mod"],
});
const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
const result = await fetchMemberGuildPermissionsDiscord(
"guild-1",
"user-1",
DISCORD_TEST_OPTS,
);
expect(result).not.toBeNull();
expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe(
true,
@@ -93,9 +103,12 @@ describe("discord guild permission authorization", () => {
memberRoles: ["role-mod"],
});
const result = await hasAnyGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
]);
const result = await hasAnyGuildPermissionDiscord(
"guild-1",
"user-1",
[PermissionFlagsBits.KickMembers],
DISCORD_TEST_OPTS,
);
expect(result).toBe(true);
});
@@ -111,9 +124,12 @@ describe("discord guild permission authorization", () => {
memberRoles: ["role-admin"],
});
const result = await hasAnyGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
]);
const result = await hasAnyGuildPermissionDiscord(
"guild-1",
"user-1",
[PermissionFlagsBits.KickMembers],
DISCORD_TEST_OPTS,
);
expect(result).toBe(true);
});
@@ -123,10 +139,12 @@ describe("discord guild permission authorization", () => {
memberRoles: [],
});
const result = await hasAnyGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.BanMembers,
PermissionFlagsBits.KickMembers,
]);
const result = await hasAnyGuildPermissionDiscord(
"guild-1",
"user-1",
[PermissionFlagsBits.BanMembers, PermissionFlagsBits.KickMembers],
DISCORD_TEST_OPTS,
);
expect(result).toBe(false);
});
});
@@ -141,10 +159,12 @@ describe("discord guild permission authorization", () => {
memberRoles: ["role-mod"],
});
const result = await hasAllGuildPermissionsDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
PermissionFlagsBits.BanMembers,
]);
const result = await hasAllGuildPermissionsDiscord(
"guild-1",
"user-1",
[PermissionFlagsBits.KickMembers, PermissionFlagsBits.BanMembers],
DISCORD_TEST_OPTS,
);
expect(result).toBe(false);
});
@@ -157,10 +177,12 @@ describe("discord guild permission authorization", () => {
memberRoles: ["role-admin"],
});
const result = await hasAllGuildPermissionsDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
PermissionFlagsBits.BanMembers,
]);
const result = await hasAllGuildPermissionsDiscord(
"guild-1",
"user-1",
[PermissionFlagsBits.KickMembers, PermissionFlagsBits.BanMembers],
DISCORD_TEST_OPTS,
);
expect(result).toBe(true);
});
});

View File

@@ -60,7 +60,7 @@ async function fetchBotUserId(rest: RequestClient) {
export async function fetchMemberGuildPermissionsDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<bigint | null> {
const rest = resolveDiscordRest(opts);
try {
@@ -96,7 +96,7 @@ async function hasGuildPermissionsDiscord(
userId: string,
requiredPermissions: bigint[],
check: (permissions: bigint, requiredPermissions: bigint[]) => boolean,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<boolean> {
const permissions = await fetchMemberGuildPermissionsDiscord(guildId, userId, opts);
if (permissions === null) {
@@ -115,7 +115,7 @@ export async function hasAnyGuildPermissionDiscord(
guildId: string,
userId: string,
requiredPermissions: bigint[],
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<boolean> {
return await hasGuildPermissionsDiscord(
guildId,
@@ -134,7 +134,7 @@ export async function hasAllGuildPermissionsDiscord(
guildId: string,
userId: string,
requiredPermissions: bigint[],
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<boolean> {
return await hasGuildPermissionsDiscord(
guildId,
@@ -153,7 +153,7 @@ export const hasGuildPermissionDiscord = hasAnyGuildPermissionDiscord;
export async function fetchChannelPermissionsDiscord(
channelId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<DiscordPermissionsSummary> {
const rest = resolveDiscordRest(opts);
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel;

View File

@@ -13,7 +13,7 @@ import type {
} from "./send.types.js";
function createDiscordReactionRuntimeClient(opts: DiscordReactionRuntimeContext) {
return createDiscordClient(opts, opts.cfg);
return createDiscordClient(opts);
}
function resolveDiscordReactionClient(opts: DiscordReactOpts) {
@@ -23,7 +23,7 @@ function resolveDiscordReactionClient(opts: DiscordReactOpts) {
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Discord reactions");
return createDiscordClient(opts, cfg);
return createDiscordClient({ ...opts, cfg });
}
function isDiscordReactionRuntimeContext(
@@ -36,7 +36,7 @@ export async function reactMessageDiscord(
channelId: string,
messageId: string,
emoji: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const { rest, request } = isDiscordReactionRuntimeContext(opts)
? createDiscordReactionRuntimeClient(opts)
@@ -53,7 +53,7 @@ export async function removeReactionDiscord(
channelId: string,
messageId: string,
emoji: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
) {
const { rest } = isDiscordReactionRuntimeContext(opts)
? createDiscordReactionRuntimeClient(opts)
@@ -66,7 +66,7 @@ export async function removeReactionDiscord(
export async function removeOwnReactionsDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts = {},
opts: DiscordReactOpts,
): Promise<{ ok: true; removed: string[] }> {
const { rest } = isDiscordReactionRuntimeContext(opts)
? createDiscordReactionRuntimeClient(opts)
@@ -99,7 +99,7 @@ export async function removeOwnReactionsDiscord(
export async function fetchReactionsDiscord(
channelId: string,
messageId: string,
opts: DiscordReactOpts & { limit?: number } = {},
opts: DiscordReactOpts & { limit?: number },
): Promise<DiscordReactionSummary[]> {
const { rest } = isDiscordReactionRuntimeContext(opts)
? createDiscordReactionRuntimeClient(opts)

View File

@@ -234,10 +234,10 @@ async function resolveDiscordTargetChannelId(
opts: DiscordClientOpts & { cfg: OpenClawConfig },
): Promise<{ channelId: string; dm?: boolean }> {
const cfg = requireRuntimeConfig(opts.cfg, "Discord target channel resolution");
const recipient = await parseAndResolveRecipient(raw, opts.accountId, cfg, {
const recipient = await parseAndResolveRecipient(raw, cfg, opts.accountId, {
defaultKind: "channel",
});
const { rest, request } = createDiscordClient(opts, cfg);
const { rest, request } = createDiscordClient(opts);
return await resolveChannelId(rest, recipient, request);
}

View File

@@ -37,7 +37,7 @@ export type DiscordRuntimeAccountContext = {
};
export type DiscordReactOpts = {
cfg?: OpenClawConfig;
cfg: OpenClawConfig;
accountId?: string;
token?: string;
rest?: RequestClient;

View File

@@ -1,8 +1,10 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveDiscordRestMock = vi.hoisted(() => vi.fn());
const DEFAULT_CFG = {} as OpenClawConfig;
vi.mock("./client.js", () => ({
resolveDiscordRest: resolveDiscordRestMock,
@@ -25,9 +27,9 @@ describe("sendTypingDiscord", () => {
post,
} as unknown as RequestClient);
const result = await sendTypingDiscord("12345", { accountId: "ops" });
const result = await sendTypingDiscord("12345", { cfg: DEFAULT_CFG, accountId: "ops" });
expect(resolveDiscordRestMock).toHaveBeenCalledWith({ accountId: "ops" });
expect(resolveDiscordRestMock).toHaveBeenCalledWith({ cfg: DEFAULT_CFG, accountId: "ops" });
expect(post).toHaveBeenCalledWith(Routes.channelTyping("12345"));
expect(result).toEqual({ ok: true, channelId: "12345" });
});

View File

@@ -2,7 +2,7 @@ import { Routes } from "discord-api-types/v10";
import { resolveDiscordRest } from "./client.js";
import type { DiscordReactOpts } from "./send.types.js";
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) {
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts) {
const rest = resolveDiscordRest(opts);
await rest.post(Routes.channelTyping(channelId));
return { ok: true, channelId };

View File

@@ -19,7 +19,7 @@ export function normalizeDiscordToken(raw: unknown, path: string): string | unde
}
export function resolveDiscordToken(
cfg?: OpenClawConfig,
cfg: OpenClawConfig,
opts: { accountId?: string | null; envToken?: string | null } = {},
): DiscordTokenResolution {
const accountId = normalizeAccountId(opts.accountId);