refactor(discord): split message and binding helpers

This commit is contained in:
Peter Steinberger
2026-04-29 19:00:39 +01:00
parent efefba2db1
commit 43b084e5fa
12 changed files with 1816 additions and 1860 deletions

View File

@@ -39,15 +39,6 @@ export {
export { resolveOpenProviderRuntimeGroupPolicy as resolveDiscordRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
export { collectDiscordStatusIssues } from "./src/status-issues.js";
// Deprecated compatibility surface for existing @openclaw/discord/api.js consumers.
type HandleDiscordMessageAction =
typeof import("./src/actions/handle-action.js").handleDiscordMessageAction;
export const handleDiscordMessageAction: HandleDiscordMessageAction = (async (...args) => {
const { handleDiscordMessageAction: run } = await import("./src/actions/handle-action.js");
return run(...args);
}) as HandleDiscordMessageAction;
export {
buildDiscordComponentCustomId,
buildDiscordComponentMessageFlags,
@@ -82,10 +73,6 @@ export {
type DiscordModalFieldSpec,
type DiscordModalSpec,
} from "./src/components.js";
export {
parseDiscordComponentCustomIdForInteraction as parseDiscordComponentCustomIdForCarbon,
parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForCarbon,
} from "./src/component-custom-id.js";
export {
getDiscordExecApprovalApprovers,
isDiscordExecApprovalApprover,

View File

@@ -0,0 +1,74 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
function collectExportedNames(): Set<string> {
const source = ts.createSourceFile(
API_SOURCE_PATH,
readFileSync(API_SOURCE_PATH, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const names = new Set<string>();
for (const statement of source.statements) {
if (
ts.isVariableStatement(statement) &&
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (!ts.isExportDeclaration(statement) || !statement.exportClause) {
continue;
}
if (ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
names.add(element.name.text);
}
}
}
return names;
}
describe("discord API barrel", () => {
it("exports current internal entrypoints", () => {
const exportedNames = collectExportedNames();
for (const exportName of [
"discordPlugin",
"discordSetupPlugin",
"buildDiscordComponentCustomId",
"parseDiscordComponentCustomIdForInteraction",
"parseDiscordModalCustomIdForInteraction",
"fetchDiscordApplicationSummary",
"DiscordSendResult",
]) {
expect(exportedNames).toContain(exportName);
}
});
itOnSupportedNode("links runtime exports used by bundled Discord wiring", async () => {
const api = await import("../api.js");
for (const exportName of [
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
"buildDiscordComponentMessageFlags",
"createDiscordFormModal",
"handleDiscordSubagentSpawning",
"listEnabledDiscordAccounts",
"resolveDiscordRuntimeGroupPolicy",
"tryHandleDiscordMessageActionGuildAdmin",
]) {
expect(api).toHaveProperty(exportName);
}
});
});

View File

@@ -0,0 +1,106 @@
import type { APIAttachment, APIStickerItem } from "discord-api-types/v10";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { Message } from "../internal/discord.js";
export type DiscordSnapshotAuthor = {
id?: string | null;
username?: string | null;
discriminator?: string | null;
global_name?: string | null;
name?: string | null;
};
export type DiscordSnapshotMessage = {
content?: string | null;
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
attachments?: APIAttachment[] | null;
stickers?: APIStickerItem[] | null;
sticker_items?: APIStickerItem[] | null;
author?: DiscordSnapshotAuthor | null;
};
export type DiscordMessageSnapshot = {
message?: DiscordSnapshotMessage | null;
};
const FORWARD_MESSAGE_REFERENCE_TYPE = 1;
export function normalizeDiscordStickerItems(value: unknown): APIStickerItem[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(entry): entry is APIStickerItem =>
Boolean(entry) &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string",
);
}
export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] {
const stickers = (message as { stickers?: unknown }).stickers;
const normalized = normalizeDiscordStickerItems(stickers);
if (normalized.length > 0) {
return normalized;
}
const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } })
.rawData;
return normalizeDiscordStickerItems(rawData?.sticker_items ?? rawData?.stickers);
}
export function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] {
return normalizeDiscordStickerItems(snapshot.stickers ?? snapshot.sticker_items);
}
export function hasDiscordMessageStickers(message: Message): boolean {
return resolveDiscordMessageStickers(message).length > 0;
}
export function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
return normalizeDiscordMessageSnapshots(
rawData?.message_snapshots ??
(message as { message_snapshots?: unknown }).message_snapshots ??
(message as { messageSnapshots?: unknown }).messageSnapshots,
);
}
export function normalizeDiscordMessageSnapshots(snapshots: unknown): DiscordMessageSnapshot[] {
if (!Array.isArray(snapshots)) {
return [];
}
return snapshots.filter(
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
);
}
export function resolveDiscordReferencedForwardMessage(message: Message): Message | null {
const referenceType = message.messageReference?.type;
return Number(referenceType) === FORWARD_MESSAGE_REFERENCE_TYPE
? message.referencedMessage
: null;
}
export function formatDiscordSnapshotAuthor(
author: DiscordSnapshotAuthor | null | undefined,
): string | undefined {
if (!author) {
return undefined;
}
const globalName = normalizeOptionalString(author.global_name) ?? undefined;
const username = normalizeOptionalString(author.username) ?? undefined;
const name = normalizeOptionalString(author.name) ?? undefined;
const discriminator = normalizeOptionalString(author.discriminator) ?? undefined;
const base = globalName || username || name;
if (username && discriminator && discriminator !== "0") {
return `@${username}#${discriminator}`;
}
if (base) {
return `@${base}`;
}
if (author.id) {
return `@${author.id}`;
}
return undefined;
}

View File

@@ -1,6 +1,7 @@
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn<
@@ -262,7 +263,7 @@ function createNoQueuedDispatchResult() {
async function processStreamOffDiscordMessage() {
const ctx = await createBaseContext({ discordConfig: { streamMode: "off" } });
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
}
beforeAll(async () => {
@@ -346,8 +347,8 @@ function getLastDispatchReplyOptions(): DispatchInboundParams["replyOptions"] |
return params?.replyOptions;
}
async function runProcessDiscordMessage(ctx: unknown): Promise<void> {
await processDiscordMessage(ctx as any);
async function runProcessDiscordMessage(ctx: DiscordMessagePreflightContext): Promise<void> {
await processDiscordMessage(ctx);
}
async function runInPartialStreamMode(): Promise<void> {
@@ -447,7 +448,7 @@ describe("processDiscordMessage ack reactions", () => {
effectiveWasMentioned: false,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
});
@@ -466,7 +467,7 @@ describe("processDiscordMessage ack reactions", () => {
},
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expectReactAckCallAt(0, "👀", {
accountId: "ops",
@@ -486,7 +487,7 @@ describe("processDiscordMessage ack reactions", () => {
effectiveWasMentioned: true,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expectReactAckCallAt(0, "👀", {
channelId: "fallback-channel",
@@ -537,7 +538,7 @@ describe("processDiscordMessage ack reactions", () => {
const ctx = await createAutomaticSourceDeliveryContext();
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
const emojis = getReactionEmojis();
expect(emojis).toContain("👀");
@@ -558,7 +559,7 @@ describe("processDiscordMessage ack reactions", () => {
});
const ctx = await createAutomaticSourceDeliveryContext();
const runPromise = processDiscordMessage(ctx as any);
const runPromise = runProcessDiscordMessage(ctx);
await vi.advanceTimersByTimeAsync(30_001);
releaseDispatch();
@@ -592,7 +593,7 @@ describe("processDiscordMessage ack reactions", () => {
},
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
const emojis = getReactionEmojis();
expect(emojis).toContain("🟦");
@@ -645,7 +646,7 @@ describe("processDiscordMessage ack reactions", () => {
},
});
const runPromise = processDiscordMessage(ctx as any);
const runPromise = runProcessDiscordMessage(ctx);
await vi.advanceTimersByTimeAsync(2_500);
await vi.runAllTimersAsync();
await runPromise;
@@ -673,7 +674,7 @@ describe("processDiscordMessage ack reactions", () => {
},
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
await vi.waitFor(() => expect(sendMocks.removeReactionDiscord).toHaveBeenCalled());
expectRemoveAckCallAt(0, "👀", {
@@ -738,7 +739,7 @@ describe("processDiscordMessage session routing", () => {
mediaMaxBytes: 1024 * 1024,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastDispatchCtx()).toMatchObject({
BodyForAgent: "hello from discord voice",
@@ -760,7 +761,7 @@ describe("processDiscordMessage session routing", () => {
messageChannelId: "dm1",
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastRouteUpdate()).toEqual({
sessionKey: "agent:main:discord:direct:u1",
@@ -776,7 +777,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastRouteUpdate()).toEqual({
sessionKey: "agent:main:discord:channel:c1",
@@ -794,7 +795,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()).toMatchObject({
sourceReplyDeliveryMode: "message_tool_only",
@@ -821,7 +822,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
@@ -829,18 +830,18 @@ describe("processDiscordMessage session routing", () => {
});
it("defaults guild replies to message-tool-only source delivery", async () => {
await processDiscordMessage(
(await createBaseContext({
await runProcessDiscordMessage(
await createBaseContext({
shouldRequireMention: true,
effectiveWasMentioned: true,
route: BASE_CHANNEL_ROUTE,
})) as any,
}),
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
dispatchInboundMessage.mockClear();
await processDiscordMessage(
(await createBaseContext({
await runProcessDiscordMessage(
await createBaseContext({
shouldRequireMention: true,
effectiveWasMentioned: true,
cfg: {
@@ -852,15 +853,15 @@ describe("processDiscordMessage session routing", () => {
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
})) as any,
}),
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
dispatchInboundMessage.mockClear();
await processDiscordMessage(
(await createBaseContext({
await runProcessDiscordMessage(
await createBaseContext({
...createDirectMessageContextOverrides(),
})) as any,
}),
);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("automatic");
});
@@ -891,7 +892,7 @@ describe("processDiscordMessage session routing", () => {
route: BASE_CHANNEL_ROUTE,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastDispatchCtx()).toMatchObject({
SessionKey: "agent:main:subagent:child",
@@ -924,7 +925,7 @@ describe("processDiscordMessage session routing", () => {
discordConfig: { thread: { inheritParent: false } },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(getLastDispatchCtx()).toMatchObject({
SessionKey: "agent:main:discord:channel:thread-1",
@@ -946,7 +947,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig,
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
}
async function createBlockModeContext() {
@@ -1007,7 +1008,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "partial" },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(editMessageDiscord).toHaveBeenCalledWith(
"c1",
@@ -1033,7 +1034,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
@@ -1053,7 +1054,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(draftStream.flush).not.toHaveBeenCalled();
expect(draftStream.discardPending).toHaveBeenCalledTimes(1);
@@ -1076,7 +1077,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "partial", maxLinesPerMessage: 5 },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(draftStream.flush).not.toHaveBeenCalled();
expect(draftStream.discardPending).toHaveBeenCalledTimes(1);
@@ -1105,7 +1106,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "off" },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).not.toHaveBeenCalled();
@@ -1128,7 +1129,7 @@ describe("processDiscordMessage draft streaming", () => {
const ctx = await createBlockModeContext();
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["Hello", "HelloWorld"]);
@@ -1148,7 +1149,7 @@ describe("processDiscordMessage draft streaming", () => {
discordConfig: { streamMode: "partial" },
});
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Hello world");
});
@@ -1164,7 +1165,7 @@ describe("processDiscordMessage draft streaming", () => {
const ctx = await createBlockModeContext();
await processDiscordMessage(ctx as any);
await runProcessDiscordMessage(ctx);
expect(draftStream.forceNewMessage).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,507 @@
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
import {
fetchRemoteMedia,
saveMediaBuffer,
type FetchLike,
} from "openclaw/plugin-sdk/media-runtime";
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import type { Message } from "../internal/discord.js";
import {
resolveDiscordMessageSnapshots,
resolveDiscordMessageStickers,
resolveDiscordReferencedForwardMessage,
resolveDiscordSnapshotStickers,
} from "./message-forwarded.js";
import { mergeAbortSignals } from "./timeouts.js";
const DISCORD_CDN_HOSTNAMES = [
"cdn.discordapp.com",
"media.discordapp.net",
"*.discordapp.com",
"*.discordapp.net",
];
// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges.
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
hostnameAllowlist: DISCORD_CDN_HOSTNAMES,
allowRfc2544BenchmarkRange: true,
};
const AUDIO_ATTACHMENT_EXTENSIONS = new Set([
".aac",
".caf",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".opus",
".wav",
]);
const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
export type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
export type DiscordMediaResolveOptions = {
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
};
type DiscordStickerAssetCandidate = {
url: string;
fileName: string;
};
function isDiscordAudioAttachmentFileName(fileName?: string | null): boolean {
const ext = getFileExtension(fileName);
return Boolean(ext && AUDIO_ATTACHMENT_EXTENSIONS.has(ext));
}
function hasDiscordVoiceAttachmentFields(attachment: APIAttachment): boolean {
return typeof attachment.duration_secs === "number" || typeof attachment.waveform === "string";
}
function mergeHostnameList(...lists: Array<string[] | undefined>): string[] | undefined {
const merged = lists
.flatMap((list) => list ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (merged.length === 0) {
return undefined;
}
return Array.from(new Set(merged));
}
function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy {
if (!policy) {
return DISCORD_MEDIA_SSRF_POLICY;
}
const hostnameAllowlist = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist,
policy.hostnameAllowlist,
);
const allowedHostnames = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.allowedHostnames,
policy.allowedHostnames,
);
return {
...DISCORD_MEDIA_SSRF_POLICY,
...policy,
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
allowRfc2544BenchmarkRange:
Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) ||
Boolean(policy.allowRfc2544BenchmarkRange),
};
}
export async function resolveMediaList(
message: Message,
maxBytes: number,
options?: DiscordMediaResolveOptions,
): Promise<DiscordMediaInfo[]> {
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
await appendResolvedMediaFromAttachments({
attachments: message.attachments ?? [],
maxBytes,
out,
errorPrefix: "discord: failed to download attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(message),
maxBytes,
out,
errorPrefix: "discord: failed to download sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
return out;
}
export async function resolveForwardedMediaList(
message: Message,
maxBytes: number,
options?: DiscordMediaResolveOptions,
): Promise<DiscordMediaInfo[]> {
const snapshots = resolveDiscordMessageSnapshots(message);
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
if (snapshots.length > 0) {
for (const snapshot of snapshots) {
await appendResolvedMediaFromAttachments({
attachments: snapshot.message?.attachments,
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
}
return out;
}
const referencedForward = resolveDiscordReferencedForwardMessage(message);
if (!referencedForward) {
return out;
}
await appendResolvedMediaFromAttachments({
attachments: referencedForward.attachments,
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(referencedForward),
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
return out;
}
async function fetchDiscordMedia(params: {
url: string;
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const fetchPromise = fetchRemoteMedia({
url: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
...(signal ? { requestInit: { signal } } : {}),
}).catch((error) => {
if (timedOut) {
return new Promise<never>(() => {});
}
throw error;
});
try {
if (!params.totalTimeoutMs) {
return await fetchPromise;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
timeoutAbortController?.abort();
reject(new Error(`discord media download timed out after ${params.totalTimeoutMs}ms`));
}, params.totalTimeoutMs);
timeoutHandle.unref?.();
});
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function appendResolvedMediaFromAttachments(params: {
attachments?: APIAttachment[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
const attachments = params.attachments;
if (!attachments || attachments.length === 0) {
return;
}
for (const attachment of attachments) {
const attachmentUrl = normalizeOptionalString(attachment.url);
if (!attachmentUrl) {
logVerbose(
`${params.errorPrefix} ${attachment.id ?? attachment.filename ?? "attachment"}: missing url`,
);
continue;
}
try {
const fetched = await fetchDiscordMedia({
url: attachmentUrl,
filePathHint: attachment.filename ?? attachmentUrl,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
});
} catch (err) {
const id = attachment.id ?? attachmentUrl;
logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`);
params.out.push({
path: attachmentUrl,
contentType: attachment.content_type,
placeholder: inferPlaceholder(attachment),
});
}
}
}
function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] {
const baseName = sticker.name?.trim() || `sticker-${sticker.id}`;
switch (sticker.format_type) {
case StickerFormatType.GIF:
return [
{ url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`, fileName: `${baseName}.gif` },
];
case StickerFormatType.Lottie:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`,
fileName: `${baseName}.png`,
},
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`,
fileName: `${baseName}.json`,
},
];
case StickerFormatType.APNG:
case StickerFormatType.PNG:
default:
return [
{ url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`, fileName: `${baseName}.png` },
];
}
}
function formatStickerError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
try {
return JSON.stringify(err) ?? "unknown error";
} catch {
return "unknown error";
}
}
function inferStickerContentType(sticker: APIStickerItem): string | undefined {
switch (sticker.format_type) {
case StickerFormatType.GIF:
return "image/gif";
case StickerFormatType.APNG:
case StickerFormatType.Lottie:
case StickerFormatType.PNG:
return "image/png";
default:
return undefined;
}
}
async function appendResolvedMediaFromStickers(params: {
stickers?: APIStickerItem[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
const stickers = params.stickers;
if (!stickers || stickers.length === 0) {
return;
}
for (const sticker of stickers) {
const candidates = resolveStickerAssetCandidates(sticker);
let lastError: unknown;
for (const candidate of candidates) {
try {
const fetched = await fetchDiscordMedia({
url: candidate.url,
filePathHint: candidate.fileName,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
});
lastError = null;
break;
} catch (err) {
lastError = err;
}
}
if (lastError) {
logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`);
const fallback = candidates[0];
if (fallback) {
params.out.push({
path: fallback.url,
contentType: inferStickerContentType(sticker),
placeholder: "<media:sticker>",
});
}
}
}
}
function inferPlaceholder(attachment: APIAttachment): string {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) {
return "<media:image>";
}
if (mime.startsWith("video/")) {
return "<media:video>";
}
if (mime.startsWith("audio/")) {
return "<media:audio>";
}
if (hasDiscordVoiceAttachmentFields(attachment)) {
return "<media:audio>";
}
if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) {
return "<media:audio>";
}
return "<media:document>";
}
function isImageAttachment(attachment: APIAttachment): boolean {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) {
return true;
}
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
if (!name) {
return false;
}
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
}
function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): string {
if (!attachments || attachments.length === 0) {
return "";
}
const count = attachments.length;
const allImages = attachments.every(isImageAttachment);
const label = allImages ? "image" : "file";
const suffix = count === 1 ? label : `${label}s`;
const tag = allImages ? "<media:image>" : "<media:document>";
return `${tag} (${count} ${suffix})`;
}
function buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string {
if (!stickers || stickers.length === 0) {
return "";
}
const count = stickers.length;
const label = count === 1 ? "sticker" : "stickers";
return `<media:sticker> (${count} ${label})`;
}
export function buildDiscordMediaPlaceholder(params: {
attachments?: APIAttachment[];
stickers?: APIStickerItem[];
}): string {
const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments);
const stickerText = buildDiscordStickerPlaceholder(params.stickers);
if (attachmentText && stickerText) {
return `${attachmentText}\n${stickerText}`;
}
return attachmentText || stickerText || "";
}
export function buildDiscordMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
return buildMediaPayload(mediaList);
}

View File

@@ -0,0 +1,123 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { Message } from "../internal/discord.js";
import {
formatDiscordSnapshotAuthor,
normalizeDiscordMessageSnapshots,
resolveDiscordMessageSnapshots,
resolveDiscordMessageStickers,
resolveDiscordReferencedForwardMessage,
resolveDiscordSnapshotStickers,
type DiscordSnapshotMessage,
} from "./message-forwarded.js";
import { buildDiscordMediaPlaceholder } from "./message-media.js";
export function resolveDiscordEmbedText(
embed?: { title?: string | null; description?: string | null } | null,
): string {
const title = normalizeOptionalString(embed?.title) ?? "";
const description = normalizeOptionalString(embed?.description) ?? "";
if (title && description) {
return `${title}\n${description}`;
}
return title || description || "";
}
export function resolveDiscordMessageText(
message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean },
): string {
const embedText = resolveDiscordEmbedText(
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
null,
);
const rawText =
normalizeOptionalString(message.content) ||
buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
stickers: resolveDiscordMessageStickers(message),
}) ||
embedText ||
normalizeOptionalString(options?.fallbackText) ||
"";
const baseText = resolveDiscordMentions(rawText, message);
if (!options?.includeForwarded) {
return baseText;
}
const forwardedText = resolveDiscordForwardedMessagesText(message);
if (!forwardedText) {
return baseText;
}
if (!baseText) {
return forwardedText;
}
return `${baseText}\n${forwardedText}`;
}
function resolveDiscordMentions(text: string, message: Message): string {
if (!text.includes("<")) {
return text;
}
const mentions = message.mentionedUsers ?? [];
if (!Array.isArray(mentions) || mentions.length === 0) {
return text;
}
let out = text;
for (const user of mentions) {
const label = user.globalName || user.username;
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
}
return out;
}
function resolveDiscordForwardedMessagesText(message: Message): string {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length > 0) {
return resolveDiscordForwardedMessagesTextFromSnapshots(snapshots);
}
const referencedForward = resolveDiscordReferencedForwardMessage(message);
if (!referencedForward) {
return "";
}
const referencedText = resolveDiscordMessageText(referencedForward);
if (!referencedText) {
return "";
}
const authorLabel = formatDiscordSnapshotAuthor(referencedForward.author);
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
return `${heading}\n${referencedText}`;
}
export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string {
const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots)
.map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message))
.filter((entry): entry is string => Boolean(entry));
if (forwardedBlocks.length === 0) {
return "";
}
return forwardedBlocks.join("\n\n");
}
function buildDiscordForwardedMessageBlock(
snapshotMessage: DiscordSnapshotMessage | null | undefined,
): string | null {
if (!snapshotMessage) {
return null;
}
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
if (!text) {
return null;
}
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
return `${heading}\n${text}`;
}
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {
const content = normalizeOptionalString(snapshot.content) ?? "";
const attachmentText = buildDiscordMediaPlaceholder({
attachments: snapshot.attachments ?? undefined,
stickers: resolveDiscordSnapshotStickers(snapshot),
});
const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
return content || attachmentText || embedText || "";
}

View File

@@ -1,731 +1,32 @@
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import { getFileExtension } from "openclaw/plugin-sdk/media-mime";
import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import type { Message } from "../internal/discord.js";
export {
__resetDiscordChannelInfoCacheForTest,
resolveDiscordChannelInfo,
type DiscordChannelInfoClient,
resolveDiscordMessageChannelId,
type DiscordChannelInfo,
type DiscordChannelInfoClient,
} from "./message-channel-info.js";
import { mergeAbortSignals } from "./timeouts.js";
const DISCORD_CDN_HOSTNAMES = [
"cdn.discordapp.com",
"media.discordapp.net",
"*.discordapp.com",
"*.discordapp.net",
];
// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges.
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
hostnameAllowlist: DISCORD_CDN_HOSTNAMES,
allowRfc2544BenchmarkRange: true,
};
const AUDIO_ATTACHMENT_EXTENSIONS = new Set([
".aac",
".caf",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".opus",
".wav",
]);
function isDiscordAudioAttachmentFileName(fileName?: string | null): boolean {
const ext = getFileExtension(fileName);
return Boolean(ext && AUDIO_ATTACHMENT_EXTENSIONS.has(ext));
}
function hasDiscordVoiceAttachmentFields(attachment: APIAttachment): boolean {
return typeof attachment.duration_secs === "number" || typeof attachment.waveform === "string";
}
function mergeHostnameList(...lists: Array<string[] | undefined>): string[] | undefined {
const merged = lists
.flatMap((list) => list ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (merged.length === 0) {
return undefined;
}
return Array.from(new Set(merged));
}
function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy {
if (!policy) {
return DISCORD_MEDIA_SSRF_POLICY;
}
const hostnameAllowlist = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist,
policy.hostnameAllowlist,
);
const allowedHostnames = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.allowedHostnames,
policy.allowedHostnames,
);
return {
...DISCORD_MEDIA_SSRF_POLICY,
...policy,
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
allowRfc2544BenchmarkRange:
Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) ||
Boolean(policy.allowRfc2544BenchmarkRange),
};
}
export type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
type DiscordMediaResolveOptions = {
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
};
type DiscordSnapshotAuthor = {
id?: string | null;
username?: string | null;
discriminator?: string | null;
global_name?: string | null;
name?: string | null;
};
type DiscordSnapshotMessage = {
content?: string | null;
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
attachments?: APIAttachment[] | null;
stickers?: APIStickerItem[] | null;
sticker_items?: APIStickerItem[] | null;
author?: DiscordSnapshotAuthor | null;
};
const FORWARD_MESSAGE_REFERENCE_TYPE = 1;
type DiscordMessageSnapshot = {
message?: DiscordSnapshotMessage | null;
};
const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
function normalizeStickerItems(value: unknown): APIStickerItem[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(entry): entry is APIStickerItem =>
Boolean(entry) &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string",
);
}
export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] {
const stickers = (message as { stickers?: unknown }).stickers;
const normalized = normalizeStickerItems(stickers);
if (normalized.length > 0) {
return normalized;
}
const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } })
.rawData;
return normalizeStickerItems(rawData?.sticker_items ?? rawData?.stickers);
}
function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] {
return normalizeStickerItems(snapshot.stickers ?? snapshot.sticker_items);
}
export function hasDiscordMessageStickers(message: Message): boolean {
return resolveDiscordMessageStickers(message).length > 0;
}
export async function resolveMediaList(
message: Message,
maxBytes: number,
options?: DiscordMediaResolveOptions,
): Promise<DiscordMediaInfo[]> {
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
await appendResolvedMediaFromAttachments({
attachments: message.attachments ?? [],
maxBytes,
out,
errorPrefix: "discord: failed to download attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(message),
maxBytes,
out,
errorPrefix: "discord: failed to download sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
return out;
}
export async function resolveForwardedMediaList(
message: Message,
maxBytes: number,
options?: DiscordMediaResolveOptions,
): Promise<DiscordMediaInfo[]> {
const snapshots = resolveDiscordMessageSnapshots(message);
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(options?.ssrfPolicy);
if (snapshots.length > 0) {
for (const snapshot of snapshots) {
await appendResolvedMediaFromAttachments({
attachments: snapshot.message?.attachments,
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
}
return out;
}
const referencedForward = resolveDiscordReferencedForwardMessage(message);
if (!referencedForward) {
return out;
}
await appendResolvedMediaFromAttachments({
attachments: referencedForward.attachments,
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(referencedForward),
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl: options?.fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
readIdleTimeoutMs: options?.readIdleTimeoutMs,
totalTimeoutMs: options?.totalTimeoutMs,
abortSignal: options?.abortSignal,
});
return out;
}
async function fetchDiscordMedia(params: {
url: string;
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
// `totalTimeoutMs` is enforced per individual attachment or sticker fetch.
// The caller abort signal remains the outer bound for the message.
const timeoutAbortController = params.totalTimeoutMs ? new AbortController() : undefined;
const signal = mergeAbortSignals([params.abortSignal, timeoutAbortController?.signal]);
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const fetchPromise = fetchRemoteMedia({
url: params.url,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
...(signal ? { requestInit: { signal } } : {}),
}).catch((error) => {
if (timedOut) {
// After the timeout wins the race we abort the underlying fetch and keep
// this branch pending so the later AbortError does not surface as an
// unhandled rejection after Promise.race has already settled.
return new Promise<never>(() => {});
}
throw error;
});
try {
if (!params.totalTimeoutMs) {
return await fetchPromise;
}
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
timeoutAbortController?.abort();
reject(new Error(`discord media download timed out after ${params.totalTimeoutMs}ms`));
}, params.totalTimeoutMs);
timeoutHandle.unref?.();
});
return await Promise.race([fetchPromise, timeoutPromise]);
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
async function appendResolvedMediaFromAttachments(params: {
attachments?: APIAttachment[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
const attachments = params.attachments;
if (!attachments || attachments.length === 0) {
return;
}
for (const attachment of attachments) {
const attachmentUrl = normalizeOptionalString(attachment.url);
if (!attachmentUrl) {
logVerbose(
`${params.errorPrefix} ${attachment.id ?? attachment.filename ?? "attachment"}: missing url`,
);
continue;
}
try {
const fetched = await fetchDiscordMedia({
url: attachmentUrl,
filePathHint: attachment.filename ?? attachmentUrl,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
});
} catch (err) {
const id = attachment.id ?? attachmentUrl;
logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`);
// Preserve attachment context even when remote fetch is blocked/fails.
params.out.push({
path: attachmentUrl,
contentType: attachment.content_type,
placeholder: inferPlaceholder(attachment),
});
}
}
}
type DiscordStickerAssetCandidate = {
url: string;
fileName: string;
};
function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] {
const baseName = sticker.name?.trim() || `sticker-${sticker.id}`;
switch (sticker.format_type) {
case StickerFormatType.GIF:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`,
fileName: `${baseName}.gif`,
},
];
case StickerFormatType.Lottie:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`,
fileName: `${baseName}.png`,
},
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`,
fileName: `${baseName}.json`,
},
];
case StickerFormatType.APNG:
case StickerFormatType.PNG:
default:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`,
fileName: `${baseName}.png`,
},
];
}
}
function formatStickerError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
try {
return JSON.stringify(err) ?? "unknown error";
} catch {
return "unknown error";
}
}
function inferStickerContentType(sticker: APIStickerItem): string | undefined {
switch (sticker.format_type) {
case StickerFormatType.GIF:
return "image/gif";
case StickerFormatType.APNG:
case StickerFormatType.Lottie:
case StickerFormatType.PNG:
return "image/png";
default:
return undefined;
}
}
async function appendResolvedMediaFromStickers(params: {
stickers?: APIStickerItem[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
readIdleTimeoutMs?: number;
totalTimeoutMs?: number;
abortSignal?: AbortSignal;
}) {
const stickers = params.stickers;
if (!stickers || stickers.length === 0) {
return;
}
for (const sticker of stickers) {
const candidates = resolveStickerAssetCandidates(sticker);
let lastError: unknown;
for (const candidate of candidates) {
try {
const fetched = await fetchDiscordMedia({
url: candidate.url,
filePathHint: candidate.fileName,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
readIdleTimeoutMs: params.readIdleTimeoutMs,
totalTimeoutMs: params.totalTimeoutMs,
abortSignal: params.abortSignal,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
});
lastError = null;
break;
} catch (err) {
lastError = err;
}
}
if (lastError) {
logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`);
const fallback = candidates[0];
if (fallback) {
params.out.push({
path: fallback.url,
contentType: inferStickerContentType(sticker),
placeholder: "<media:sticker>",
});
}
}
}
}
function inferPlaceholder(attachment: APIAttachment): string {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) {
return "<media:image>";
}
if (mime.startsWith("video/")) {
return "<media:video>";
}
if (mime.startsWith("audio/")) {
return "<media:audio>";
}
if (hasDiscordVoiceAttachmentFields(attachment)) {
return "<media:audio>";
}
if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) {
return "<media:audio>";
}
return "<media:document>";
}
function isImageAttachment(attachment: APIAttachment): boolean {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) {
return true;
}
const name = normalizeLowercaseStringOrEmpty(attachment.filename);
if (!name) {
return false;
}
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
}
function buildDiscordAttachmentPlaceholder(attachments?: APIAttachment[]): string {
if (!attachments || attachments.length === 0) {
return "";
}
const count = attachments.length;
const allImages = attachments.every(isImageAttachment);
const label = allImages ? "image" : "file";
const suffix = count === 1 ? label : `${label}s`;
const tag = allImages ? "<media:image>" : "<media:document>";
return `${tag} (${count} ${suffix})`;
}
function buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string {
if (!stickers || stickers.length === 0) {
return "";
}
const count = stickers.length;
const label = count === 1 ? "sticker" : "stickers";
return `<media:sticker> (${count} ${label})`;
}
function buildDiscordMediaPlaceholder(params: {
attachments?: APIAttachment[];
stickers?: APIStickerItem[];
}): string {
const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments);
const stickerText = buildDiscordStickerPlaceholder(params.stickers);
if (attachmentText && stickerText) {
return `${attachmentText}\n${stickerText}`;
}
return attachmentText || stickerText || "";
}
export function resolveDiscordEmbedText(
embed?: { title?: string | null; description?: string | null } | null,
): string {
const title = normalizeOptionalString(embed?.title) ?? "";
const description = normalizeOptionalString(embed?.description) ?? "";
if (title && description) {
return `${title}\n${description}`;
}
return title || description || "";
}
export function resolveDiscordMessageText(
message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean },
): string {
const embedText = resolveDiscordEmbedText(
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
null,
);
const rawText =
normalizeOptionalString(message.content) ||
buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
stickers: resolveDiscordMessageStickers(message),
}) ||
embedText ||
normalizeOptionalString(options?.fallbackText) ||
"";
const baseText = resolveDiscordMentions(rawText, message);
if (!options?.includeForwarded) {
return baseText;
}
const forwardedText = resolveDiscordForwardedMessagesText(message);
if (!forwardedText) {
return baseText;
}
if (!baseText) {
return forwardedText;
}
return `${baseText}\n${forwardedText}`;
}
function resolveDiscordMentions(text: string, message: Message): string {
if (!text.includes("<")) {
return text;
}
const mentions = message.mentionedUsers ?? [];
if (!Array.isArray(mentions) || mentions.length === 0) {
return text;
}
let out = text;
for (const user of mentions) {
const label = user.globalName || user.username;
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
}
return out;
}
function resolveDiscordForwardedMessagesText(message: Message): string {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length > 0) {
return resolveDiscordForwardedMessagesTextFromSnapshots(snapshots);
}
const referencedForward = resolveDiscordReferencedForwardMessage(message);
if (!referencedForward) {
return "";
}
const referencedText = resolveDiscordMessageText(referencedForward);
if (!referencedText) {
return "";
}
const authorLabel = formatDiscordSnapshotAuthor(referencedForward.author);
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
return `${heading}\n${referencedText}`;
}
function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] {
const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData;
return normalizeDiscordMessageSnapshots(
rawData?.message_snapshots ??
(message as { message_snapshots?: unknown }).message_snapshots ??
(message as { messageSnapshots?: unknown }).messageSnapshots,
);
}
function normalizeDiscordMessageSnapshots(snapshots: unknown): DiscordMessageSnapshot[] {
if (!Array.isArray(snapshots)) {
return [];
}
return snapshots.filter(
(entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object",
);
}
export function resolveDiscordForwardedMessagesTextFromSnapshots(snapshots: unknown): string {
const forwardedBlocks = normalizeDiscordMessageSnapshots(snapshots)
.map((snapshot) => buildDiscordForwardedMessageBlock(snapshot.message))
.filter((entry): entry is string => Boolean(entry));
if (forwardedBlocks.length === 0) {
return "";
}
return forwardedBlocks.join("\n\n");
}
function buildDiscordForwardedMessageBlock(
snapshotMessage: DiscordSnapshotMessage | null | undefined,
): string | null {
if (!snapshotMessage) {
return null;
}
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
if (!text) {
return null;
}
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
const heading = authorLabel ? `[Forwarded message from ${authorLabel}]` : "[Forwarded message]";
return `${heading}\n${text}`;
}
function resolveDiscordReferencedForwardMessage(message: Message): Message | null {
const referenceType = message.messageReference?.type;
return Number(referenceType) === FORWARD_MESSAGE_REFERENCE_TYPE
? message.referencedMessage
: null;
}
function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string {
const content = normalizeOptionalString(snapshot.content) ?? "";
const attachmentText = buildDiscordMediaPlaceholder({
attachments: snapshot.attachments ?? undefined,
stickers: resolveDiscordSnapshotStickers(snapshot),
});
const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
return content || attachmentText || embedText || "";
}
function formatDiscordSnapshotAuthor(
author: DiscordSnapshotAuthor | null | undefined,
): string | undefined {
if (!author) {
return undefined;
}
const globalName = author.global_name ?? undefined;
const username = author.username ?? undefined;
const name = author.name ?? undefined;
const discriminator = author.discriminator ?? undefined;
const base = globalName || username || name;
if (username && discriminator && discriminator !== "0") {
return `@${username}#${discriminator}`;
}
if (base) {
return `@${base}`;
}
if (author.id) {
return `@${author.id}`;
}
return undefined;
}
export function buildDiscordMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
return buildMediaPayload(mediaList);
}
export {
hasDiscordMessageStickers,
normalizeDiscordMessageSnapshots,
normalizeDiscordStickerItems,
resolveDiscordMessageSnapshots,
resolveDiscordMessageStickers,
resolveDiscordReferencedForwardMessage,
resolveDiscordSnapshotStickers,
type DiscordMessageSnapshot,
type DiscordSnapshotAuthor,
type DiscordSnapshotMessage,
} from "./message-forwarded.js";
export {
buildDiscordMediaPayload,
buildDiscordMediaPlaceholder,
resolveForwardedMediaList,
resolveMediaList,
type DiscordMediaInfo,
type DiscordMediaResolveOptions,
} from "./message-media.js";
export {
resolveDiscordEmbedText,
resolveDiscordForwardedMessagesTextFromSnapshots,
resolveDiscordMessageText,
} from "./message-text.js";

View File

@@ -1,17 +1,3 @@
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
Button,
Container,
Row,
Separator,
StringSelectMenu,
TextDisplay,
type MessagePayloadObject,
type TopLevelComponents,
} from "../internal/discord.js";
export {
buildDiscordModelPickerCustomId,
buildDiscordModelPickerProviderItems,
@@ -29,18 +15,6 @@ export {
parseDiscordModelPickerCustomId,
parseDiscordModelPickerData,
} from "./model-picker.state.js";
import {
buildDiscordModelPickerCustomId,
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW,
getDiscordModelPickerModelPage,
getDiscordModelPickerProviderPage,
normalizeModelPickerPage,
type DiscordModelPickerCommandContext,
type DiscordModelPickerLayout,
type DiscordModelPickerModelPage,
type DiscordModelPickerPage,
type DiscordModelPickerProviderItem,
} from "./model-picker.state.js";
export type {
DiscordModelPickerAction,
DiscordModelPickerCommandContext,
@@ -50,672 +24,15 @@ export type {
DiscordModelPickerState,
DiscordModelPickerView,
} from "./model-picker.state.js";
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
type DiscordModelPickerButtonOptions = {
label: string;
customId: string;
style?: ButtonStyle;
disabled?: boolean;
};
type DiscordModelPickerCurrentModelRef = {
provider: string;
model: string;
};
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
type DiscordModelPickerRenderShellParams = {
layout: DiscordModelPickerLayout;
title: string;
detailLines: string[];
rows: DiscordModelPickerRow[];
footer?: string;
/** Text shown after the divider but before the interactive rows. */
preRowText?: string;
/** Extra rows appended after the main rows, preceded by a divider. */
trailingRows?: DiscordModelPickerRow[];
};
export type DiscordModelPickerRenderedView = {
layout: DiscordModelPickerLayout;
content?: string;
components: TopLevelComponents[];
};
export type DiscordModelPickerProviderViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
page?: number;
currentModel?: string;
layout?: DiscordModelPickerLayout;
};
export type DiscordModelPickerModelViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
provider: string;
page?: number;
providerPage?: number;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
currentRuntime?: string;
pendingRuntime?: string;
quickModels?: string[];
layout?: DiscordModelPickerLayout;
};
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
const trimmed = raw?.trim();
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
if (!match) {
return null;
}
const provider = normalizeProviderId(match[1]);
// Preserve the model suffix exactly as entered after "/" so select defaults
// continue to mirror the stored ref for Discord interactions.
const model = match[2];
if (!provider || !model) {
return null;
}
return { provider, model };
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = parseCurrentModelRef(currentModel);
if (!parsed) {
return "Current model: default";
}
return `Current model: ${parsed.provider}/${parsed.model}`;
}
function formatProviderButtonLabel(provider: string): string {
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
return provider;
}
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}`;
}
function chunkProvidersForRows(
items: DiscordModelPickerProviderItem[],
): DiscordModelPickerProviderItem[][] {
if (items.length === 0) {
return [];
}
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
const minPerRow = Math.floor(items.length / rowCount);
const rowsWithExtraItem = items.length % rowCount;
const counts = Array.from({ length: rowCount }, (_, index) =>
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
);
const rows: DiscordModelPickerProviderItem[][] = [];
let cursor = 0;
for (const count of counts) {
rows.push(items.slice(cursor, cursor + count));
cursor += count;
}
return rows;
}
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
class DiscordModelPickerButton extends Button {
label = params.label;
customId = params.customId;
style = params.style ?? ButtonStyle.Secondary;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerButton();
}
function createModelSelect(params: {
customId: string;
options: APISelectMenuOption[];
placeholder?: string;
disabled?: boolean;
}): StringSelectMenu {
class DiscordModelPickerSelect extends StringSelectMenu {
customId = params.customId;
options = params.options;
minValues = 1;
maxValues = 1;
placeholder = params.placeholder;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerSelect();
}
function getRuntimeChoices(params: {
data: ModelsProviderData;
provider: string;
}): Array<{ id: string; label: string; description?: string }> {
return (
params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider)) ?? [
{
id: "pi",
label: "OpenClaw Pi Default",
description: "Use the built-in OpenClaw Pi runtime.",
},
]
);
}
function resolveSelectedRuntime(params: {
data: ModelsProviderData;
provider: string;
currentRuntime?: string;
pendingRuntime?: string;
}): string {
const choices = getRuntimeChoices({ data: params.data, provider: params.provider });
const allowed = new Set(choices.map((choice) => choice.id));
const pending = params.pendingRuntime?.trim();
if (pending && allowed.has(pending)) {
return pending;
}
const current = params.currentRuntime?.trim();
return current && allowed.has(current) ? current : "pi";
}
function buildRenderedShell(
params: DiscordModelPickerRenderShellParams,
): DiscordModelPickerRenderedView {
if (params.layout === "classic") {
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
return {
layout: "classic",
content: lines.join("\n"),
components: params.rows,
};
}
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
new TextDisplay(`## ${params.title}`),
];
if (params.detailLines.length > 0) {
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
}
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
if (params.preRowText) {
containerComponents.push(new TextDisplay(params.preRowText));
}
containerComponents.push(...params.rows);
if (params.trailingRows && params.trailingRows.length > 0) {
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
containerComponents.push(...params.trailingRows);
}
if (params.footer) {
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
}
const container = new Container(containerComponents);
return {
layout: "v2",
components: [container],
};
}
function buildProviderRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
currentProvider?: string;
}): Row<Button>[] {
const rows = chunkProvidersForRows(params.page.items).map(
(providers) =>
new Row(
providers.map((provider) => {
const style =
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
return createModelPickerButton({
label: formatProviderButtonLabel(provider.id),
style,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: provider.id,
page: params.page.page,
userId: params.userId,
}),
});
}),
),
);
return rows;
}
function buildModelRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
providerPage: number;
modelPage: DiscordModelPickerModelPage;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
currentRuntime?: string;
pendingRuntime?: string;
quickModels?: string[];
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
const rows: DiscordModelPickerRow[] = [];
const hasQuickModels = (params.quickModels ?? []).length > 0;
const providerPage = getDiscordModelPickerProviderPage({
data: params.data,
page: params.providerPage,
});
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
label: provider.id,
value: provider.id,
default: provider.id === params.modelPage.provider,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: params.modelPage.provider,
page: providerPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: providerOptions,
placeholder: "Select provider",
}),
]),
);
const runtimeChoices = getRuntimeChoices({
data: params.data,
provider: params.modelPage.provider,
});
const selectedRuntime = resolveSelectedRuntime({
data: params.data,
provider: params.modelPage.provider,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
});
const normalizedCurrentRuntime = params.currentRuntime?.trim();
const shouldCarryRuntime =
runtimeChoices.length > 1 ||
(Boolean(normalizedCurrentRuntime) &&
normalizedCurrentRuntime !== "auto" &&
normalizedCurrentRuntime !== "pi" &&
normalizedCurrentRuntime !== "default");
const stateRuntime = shouldCarryRuntime ? selectedRuntime : undefined;
if (runtimeChoices.length > 1) {
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "runtime",
view: "models",
provider: params.modelPage.provider,
runtime: selectedRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
options: runtimeChoices.map((choice) => {
const option: APISelectMenuOption = {
label: choice.label,
value: choice.id,
default: choice.id === selectedRuntime,
};
if (choice.description) {
option.description = choice.description;
}
return option;
}),
placeholder: "Select runtime",
}),
]),
);
}
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
label: model,
value: model,
default: selectedModelRef
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
: false,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "model",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: modelOptions,
placeholder: `Select ${params.modelPage.provider} model`,
}),
]),
);
const resolvedDefault = params.data.resolvedDefault;
const shouldDisableReset =
Boolean(parsedCurrentModel) &&
parsedCurrentModel?.provider === resolvedDefault.provider &&
parsedCurrentModel?.model === resolvedDefault.model;
const hasPendingSelection =
Boolean(parsedPendingModel) &&
parsedPendingModel?.provider === params.modelPage.provider &&
typeof params.pendingModelIndex === "number" &&
params.pendingModelIndex > 0;
const buttonRowItems: Button[] = [
createModelPickerButton({
label: "Cancel",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "cancel",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
createModelPickerButton({
label: "Reset to default",
style: ButtonStyle.Secondary,
disabled: shouldDisableReset,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "reset",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
];
if (hasQuickModels) {
buttonRowItems.push(
createModelPickerButton({
label: "Recents",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "recents",
view: "recents",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
);
}
buttonRowItems.push(
createModelPickerButton({
label: "Submit",
style: ButtonStyle.Primary,
disabled: !hasPendingSelection,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
}),
);
return { rows, buttonRow: new Row(buttonRowItems) };
}
export function renderDiscordModelPickerProvidersView(
params: DiscordModelPickerProviderViewParams,
): DiscordModelPickerRenderedView {
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
const parsedCurrent = parseCurrentModelRef(params.currentModel);
const rows = buildProviderRows({
command: params.command,
userId: params.userId,
page,
currentProvider: parsedCurrent?.provider,
});
const detailLines = [
formatCurrentModelLine(params.currentModel),
`Select a provider (${page.totalItems} available).`,
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines,
rows,
footer: `All ${page.totalItems} providers shown`,
});
}
export function renderDiscordModelPickerModelsView(
params: DiscordModelPickerModelViewParams,
): DiscordModelPickerRenderedView {
const providerPage = normalizeModelPickerPage(params.providerPage);
const modelPage = getDiscordModelPickerModelPage({
data: params.data,
provider: params.provider,
page: params.page,
});
if (!modelPage) {
const rows: Row<Button>[] = [
new Row([
createModelPickerButton({
label: "Back",
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "providers",
page: providerPage,
userId: params.userId,
}),
}),
]),
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [
formatCurrentModelLine(params.currentModel),
`Provider not found: ${normalizeProviderId(params.provider)}`,
],
rows,
footer: "Choose a different provider.",
});
}
const { rows, buttonRow } = buildModelRows({
command: params.command,
userId: params.userId,
data: params.data,
providerPage,
modelPage,
currentModel: params.currentModel,
pendingModel: params.pendingModel,
pendingModelIndex: params.pendingModelIndex,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
quickModels: params.quickModels,
});
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const pendingLine = params.pendingModel
? `Selected: ${params.pendingModel} · runtime ${resolveSelectedRuntime({
data: params.data,
provider: modelPage.provider,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
})} (press Submit)`
: "Select a model, then press Submit.";
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
preRowText: pendingLine,
rows,
trailingRows: [buttonRow],
});
}
export type DiscordModelPickerRecentsViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
quickModels: string[];
currentModel?: string;
provider?: string;
page?: number;
providerPage?: number;
layout?: DiscordModelPickerLayout;
};
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
const maxLen = 80;
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
if (label.length <= maxLen) {
return label;
}
const trimmed = suffix
? `${modelRef.slice(0, maxLen - suffix.length - 2)}${suffix}`
: `${modelRef.slice(0, maxLen - 1)}`;
return trimmed;
}
export function renderDiscordModelPickerRecentsView(
params: DiscordModelPickerRecentsViewParams,
): DiscordModelPickerRenderedView {
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const rows: DiscordModelPickerRow[] = [];
// Dedupe: filter recents that match the default model.
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
// Default model button — slot 1.
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: 1,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
// Recent model buttons — slot 2+.
for (let i = 0; i < dedupedQuickModels.length; i++) {
const modelRef = dedupedQuickModels[i];
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(modelRef),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: i + 2,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
}
// Back button after a divider (via trailingRows).
const backRow: Row<Button> = new Row([
createModelPickerButton({
label: "Back",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "models",
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]);
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Recents",
detailLines: [
"Models you've previously selected appear here.",
formatCurrentModelLine(params.currentModel),
],
preRowText: "Tap a model to switch.",
rows,
trailingRows: [backRow],
});
}
export function toDiscordModelPickerMessagePayload(
view: DiscordModelPickerRenderedView,
): MessagePayloadObject {
if (view.layout === "classic") {
return {
content: view.content,
components: view.components,
};
}
return {
components: view.components,
};
}
export {
renderDiscordModelPickerModelsView,
renderDiscordModelPickerProvidersView,
renderDiscordModelPickerRecentsView,
toDiscordModelPickerMessagePayload,
} from "./model-picker.view.js";
export type {
DiscordModelPickerModelViewParams,
DiscordModelPickerProviderViewParams,
DiscordModelPickerRecentsViewParams,
DiscordModelPickerRenderedView,
} from "./model-picker.view.js";

View File

@@ -0,0 +1,695 @@
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
Button,
Container,
Row,
Separator,
StringSelectMenu,
TextDisplay,
type MessagePayloadObject,
type TopLevelComponents,
} from "../internal/discord.js";
import {
buildDiscordModelPickerCustomId,
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW,
getDiscordModelPickerModelPage,
getDiscordModelPickerProviderPage,
normalizeModelPickerPage,
type DiscordModelPickerCommandContext,
type DiscordModelPickerLayout,
type DiscordModelPickerModelPage,
type DiscordModelPickerPage,
type DiscordModelPickerProviderItem,
} from "./model-picker.state.js";
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
type DiscordModelPickerButtonOptions = {
label: string;
customId: string;
style?: ButtonStyle;
disabled?: boolean;
};
type DiscordModelPickerCurrentModelRef = {
provider: string;
model: string;
};
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
type DiscordModelPickerRenderShellParams = {
layout: DiscordModelPickerLayout;
title: string;
detailLines: string[];
rows: DiscordModelPickerRow[];
footer?: string;
/** Text shown after the divider but before the interactive rows. */
preRowText?: string;
/** Extra rows appended after the main rows, preceded by a divider. */
trailingRows?: DiscordModelPickerRow[];
};
export type DiscordModelPickerRenderedView = {
layout: DiscordModelPickerLayout;
content?: string;
components: TopLevelComponents[];
};
export type DiscordModelPickerProviderViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
page?: number;
currentModel?: string;
layout?: DiscordModelPickerLayout;
};
export type DiscordModelPickerModelViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
provider: string;
page?: number;
providerPage?: number;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
currentRuntime?: string;
pendingRuntime?: string;
quickModels?: string[];
layout?: DiscordModelPickerLayout;
};
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
const trimmed = raw?.trim();
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
if (!match) {
return null;
}
const provider = normalizeProviderId(match[1]);
// Preserve the model suffix exactly as entered after "/" so select defaults
// continue to mirror the stored ref for Discord interactions.
const model = match[2];
if (!provider || !model) {
return null;
}
return { provider, model };
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = parseCurrentModelRef(currentModel);
if (!parsed) {
return "Current model: default";
}
return `Current model: ${parsed.provider}/${parsed.model}`;
}
function formatProviderButtonLabel(provider: string): string {
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
return provider;
}
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}`;
}
function chunkProvidersForRows(
items: DiscordModelPickerProviderItem[],
): DiscordModelPickerProviderItem[][] {
if (items.length === 0) {
return [];
}
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
const minPerRow = Math.floor(items.length / rowCount);
const rowsWithExtraItem = items.length % rowCount;
const counts = Array.from({ length: rowCount }, (_, index) =>
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
);
const rows: DiscordModelPickerProviderItem[][] = [];
let cursor = 0;
for (const count of counts) {
rows.push(items.slice(cursor, cursor + count));
cursor += count;
}
return rows;
}
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
class DiscordModelPickerButton extends Button {
label = params.label;
customId = params.customId;
style = params.style ?? ButtonStyle.Secondary;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerButton();
}
function createModelSelect(params: {
customId: string;
options: APISelectMenuOption[];
placeholder?: string;
disabled?: boolean;
}): StringSelectMenu {
class DiscordModelPickerSelect extends StringSelectMenu {
customId = params.customId;
options = params.options;
minValues = 1;
maxValues = 1;
placeholder = params.placeholder;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerSelect();
}
function getRuntimeChoices(params: {
data: ModelsProviderData;
provider: string;
}): Array<{ id: string; label: string; description?: string }> {
return (
params.data.runtimeChoicesByProvider?.get(normalizeProviderId(params.provider)) ?? [
{
id: "pi",
label: "OpenClaw Pi Default",
description: "Use the built-in OpenClaw Pi runtime.",
},
]
);
}
function resolveSelectedRuntime(params: {
data: ModelsProviderData;
provider: string;
currentRuntime?: string;
pendingRuntime?: string;
}): string {
const choices = getRuntimeChoices({ data: params.data, provider: params.provider });
const allowed = new Set(choices.map((choice) => choice.id));
const pending = params.pendingRuntime?.trim();
if (pending && allowed.has(pending)) {
return pending;
}
const current = params.currentRuntime?.trim();
return current && allowed.has(current) ? current : "pi";
}
function buildRenderedShell(
params: DiscordModelPickerRenderShellParams,
): DiscordModelPickerRenderedView {
if (params.layout === "classic") {
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
return {
layout: "classic",
content: lines.join("\n"),
components: params.rows,
};
}
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
new TextDisplay(`## ${params.title}`),
];
if (params.detailLines.length > 0) {
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
}
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
if (params.preRowText) {
containerComponents.push(new TextDisplay(params.preRowText));
}
containerComponents.push(...params.rows);
if (params.trailingRows && params.trailingRows.length > 0) {
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
containerComponents.push(...params.trailingRows);
}
if (params.footer) {
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
}
const container = new Container(containerComponents);
return {
layout: "v2",
components: [container],
};
}
function buildProviderRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
currentProvider?: string;
}): Row<Button>[] {
const rows = chunkProvidersForRows(params.page.items).map(
(providers) =>
new Row(
providers.map((provider) => {
const style =
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
return createModelPickerButton({
label: formatProviderButtonLabel(provider.id),
style,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: provider.id,
page: params.page.page,
userId: params.userId,
}),
});
}),
),
);
return rows;
}
function buildModelRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
providerPage: number;
modelPage: DiscordModelPickerModelPage;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
currentRuntime?: string;
pendingRuntime?: string;
quickModels?: string[];
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
const rows: DiscordModelPickerRow[] = [];
const hasQuickModels = (params.quickModels ?? []).length > 0;
const providerPage = getDiscordModelPickerProviderPage({
data: params.data,
page: params.providerPage,
});
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
label: provider.id,
value: provider.id,
default: provider.id === params.modelPage.provider,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: params.modelPage.provider,
page: providerPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: providerOptions,
placeholder: "Select provider",
}),
]),
);
const runtimeChoices = getRuntimeChoices({
data: params.data,
provider: params.modelPage.provider,
});
const selectedRuntime = resolveSelectedRuntime({
data: params.data,
provider: params.modelPage.provider,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
});
const normalizedCurrentRuntime = params.currentRuntime?.trim();
const shouldCarryRuntime =
runtimeChoices.length > 1 ||
(Boolean(normalizedCurrentRuntime) &&
normalizedCurrentRuntime !== "auto" &&
normalizedCurrentRuntime !== "pi" &&
normalizedCurrentRuntime !== "default");
const stateRuntime = shouldCarryRuntime ? selectedRuntime : undefined;
if (runtimeChoices.length > 1) {
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "runtime",
view: "models",
provider: params.modelPage.provider,
runtime: selectedRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
options: runtimeChoices.map((choice) => {
const option: APISelectMenuOption = {
label: choice.label,
value: choice.id,
default: choice.id === selectedRuntime,
};
if (choice.description) {
option.description = choice.description;
}
return option;
}),
placeholder: "Select runtime",
}),
]),
);
}
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
label: model,
value: model,
default: selectedModelRef
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
: false,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "model",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: modelOptions,
placeholder: `Select ${params.modelPage.provider} model`,
}),
]),
);
const resolvedDefault = params.data.resolvedDefault;
const shouldDisableReset =
Boolean(parsedCurrentModel) &&
parsedCurrentModel?.provider === resolvedDefault.provider &&
parsedCurrentModel?.model === resolvedDefault.model;
const hasPendingSelection =
Boolean(parsedPendingModel) &&
parsedPendingModel?.provider === params.modelPage.provider &&
typeof params.pendingModelIndex === "number" &&
params.pendingModelIndex > 0;
const buttonRowItems: Button[] = [
createModelPickerButton({
label: "Cancel",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "cancel",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
createModelPickerButton({
label: "Reset to default",
style: ButtonStyle.Secondary,
disabled: shouldDisableReset,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "reset",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
];
if (hasQuickModels) {
buttonRowItems.push(
createModelPickerButton({
label: "Recents",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "recents",
view: "recents",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
);
}
buttonRowItems.push(
createModelPickerButton({
label: "Submit",
style: ButtonStyle.Primary,
disabled: !hasPendingSelection,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "models",
provider: params.modelPage.provider,
runtime: stateRuntime,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
}),
);
return { rows, buttonRow: new Row(buttonRowItems) };
}
export function renderDiscordModelPickerProvidersView(
params: DiscordModelPickerProviderViewParams,
): DiscordModelPickerRenderedView {
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
const parsedCurrent = parseCurrentModelRef(params.currentModel);
const rows = buildProviderRows({
command: params.command,
userId: params.userId,
page,
currentProvider: parsedCurrent?.provider,
});
const detailLines = [
formatCurrentModelLine(params.currentModel),
`Select a provider (${page.totalItems} available).`,
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines,
rows,
footer: `All ${page.totalItems} providers shown`,
});
}
export function renderDiscordModelPickerModelsView(
params: DiscordModelPickerModelViewParams,
): DiscordModelPickerRenderedView {
const providerPage = normalizeModelPickerPage(params.providerPage);
const modelPage = getDiscordModelPickerModelPage({
data: params.data,
provider: params.provider,
page: params.page,
});
if (!modelPage) {
const rows: Row<Button>[] = [
new Row([
createModelPickerButton({
label: "Back",
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "providers",
page: providerPage,
userId: params.userId,
}),
}),
]),
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [
formatCurrentModelLine(params.currentModel),
`Provider not found: ${normalizeProviderId(params.provider)}`,
],
rows,
footer: "Choose a different provider.",
});
}
const { rows, buttonRow } = buildModelRows({
command: params.command,
userId: params.userId,
data: params.data,
providerPage,
modelPage,
currentModel: params.currentModel,
pendingModel: params.pendingModel,
pendingModelIndex: params.pendingModelIndex,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
quickModels: params.quickModels,
});
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const pendingLine = params.pendingModel
? `Selected: ${params.pendingModel} · runtime ${resolveSelectedRuntime({
data: params.data,
provider: modelPage.provider,
currentRuntime: params.currentRuntime,
pendingRuntime: params.pendingRuntime,
})} (press Submit)`
: "Select a model, then press Submit.";
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
preRowText: pendingLine,
rows,
trailingRows: [buttonRow],
});
}
export type DiscordModelPickerRecentsViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
quickModels: string[];
currentModel?: string;
provider?: string;
page?: number;
providerPage?: number;
layout?: DiscordModelPickerLayout;
};
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
const maxLen = 80;
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
if (label.length <= maxLen) {
return label;
}
const trimmed = suffix
? `${modelRef.slice(0, maxLen - suffix.length - 2)}${suffix}`
: `${modelRef.slice(0, maxLen - 1)}`;
return trimmed;
}
export function renderDiscordModelPickerRecentsView(
params: DiscordModelPickerRecentsViewParams,
): DiscordModelPickerRenderedView {
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const rows: DiscordModelPickerRow[] = [];
// Dedupe: filter recents that match the default model.
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
// Default model button — slot 1.
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: 1,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
// Recent model buttons — slot 2+.
for (let i = 0; i < dedupedQuickModels.length; i++) {
const modelRef = dedupedQuickModels[i];
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(modelRef),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: i + 2,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
}
// Back button after a divider (via trailingRows).
const backRow: Row<Button> = new Row([
createModelPickerButton({
label: "Back",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "models",
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]);
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Recents",
detailLines: [
"Models you've previously selected appear here.",
formatCurrentModelLine(params.currentModel),
],
preRowText: "Tap a model to switch.",
rows,
trailingRows: [backRow],
});
}
export function toDiscordModelPickerMessagePayload(
view: DiscordModelPickerRenderedView,
): MessagePayloadObject {
if (view.layout === "classic") {
return {
content: view.content,
components: view.components,
};
}
return {
components: view.components,
};
}

View File

@@ -1,10 +1,6 @@
import {
registerSessionBindingAdapter,
resolveThreadBindingConversationIdFromBindingId,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
import {
@@ -15,7 +11,6 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { createDiscordRestClient } from "../client.js";
import { getChannel } from "../internal/discord.js";
import { resolveDiscordChannelId } from "../target-parsing.js";
import {
createThreadForBinding,
createWebhookForChannel,
@@ -30,6 +25,7 @@ import {
resolveThreadBindingFarewellText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
import { createThreadBindingSessionAdapter } from "./thread-bindings.session-adapter.js";
import {
BINDINGS_BY_THREAD_ID,
forgetThreadBindingToken,
@@ -68,18 +64,6 @@ function registerManager(manager: ThreadBindingManager) {
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
}
function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return undefined;
}
try {
return resolveDiscordChannelId(trimmed);
} catch {
return undefined;
}
}
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
if (existing === manager) {
@@ -89,25 +73,6 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) {
const SWEEPERS_BY_ACCOUNT_ID = new Map<string, () => Promise<void>>();
function resolveEffectiveBindingExpiresAt(params: {
record: ThreadBindingRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): number | undefined {
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: params.record,
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
});
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: params.record,
defaultMaxAgeMs: params.defaultMaxAgeMs,
});
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
}
return inactivityExpiresAt ?? maxAgeExpiresAt;
}
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
const accountId = normalizeAccountId(accountIdRaw);
return {
@@ -126,65 +91,11 @@ function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
};
}
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
return raw === "subagent" ? "subagent" : "acp";
}
function isDirectConversationBindingId(value?: string | null): boolean {
const trimmed = normalizeOptionalString(value);
return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed));
}
function toSessionBindingRecord(
record: ThreadBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const bindingId =
resolveBindingRecordKey({
accountId: record.accountId,
threadId: record.threadId,
}) ?? `${record.accountId}:${record.threadId}`;
return {
bindingId,
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "discord",
accountId: record.accountId,
conversationId: record.threadId,
parentConversationId: record.channelId,
},
status: "active",
boundAt: record.boundAt,
expiresAt: resolveEffectiveBindingExpiresAt({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
metadata: {
agentId: record.agentId,
label: record.label,
webhookId: record.webhookId,
webhookToken: record.webhookToken,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
}),
maxAgeMs: resolveThreadBindingMaxAgeMs({
record,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
...record.metadata,
},
};
}
export function createThreadBindingManager(params: {
accountId?: string;
token?: string;
@@ -606,123 +517,13 @@ export function createThreadBindingManager(params: {
}
}
const sessionBindingAdapter: SessionBindingAdapter = {
channel: "discord",
const sessionBindingAdapter = createThreadBindingSessionAdapter({
accountId,
capabilities: {
placements: ["current", "child"],
},
bind: async (input) => {
if (input.conversation.channel !== "discord") {
return null;
}
const targetSessionKey = input.targetSessionKey.trim();
if (!targetSessionKey) {
return null;
}
const conversationId = normalizeOptionalString(input.conversation.conversationId) ?? "";
const placement = input.placement === "child" ? "child" : "current";
const metadata = input.metadata ?? {};
const label = normalizeOptionalString(metadata.label);
const threadName =
typeof metadata.threadName === "string"
? normalizeOptionalString(metadata.threadName)
: undefined;
const introText =
typeof metadata.introText === "string"
? normalizeOptionalString(metadata.introText)
: undefined;
const boundBy =
typeof metadata.boundBy === "string"
? normalizeOptionalString(metadata.boundBy)
: undefined;
const agentId =
typeof metadata.agentId === "string"
? normalizeOptionalString(metadata.agentId)
: undefined;
let threadId: string | undefined;
let channelId: string | undefined;
let createThread = false;
if (placement === "child") {
createThread = true;
channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId);
if (!channelId && conversationId) {
const cfg = resolveCurrentCfg();
channelId =
(await resolveChannelIdForBinding({
cfg,
accountId,
token: resolveCurrentToken(),
threadId: conversationId,
})) ?? undefined;
}
} else {
threadId = conversationId || undefined;
}
const bound = await manager.bindTarget({
threadId,
channelId,
createThread,
threadName,
targetKind: toThreadBindingTargetKind(input.targetKind),
targetSessionKey,
agentId,
label,
boundBy,
introText,
metadata,
});
return bound
? toSessionBindingRecord(bound, {
idleTimeoutMs,
maxAgeMs,
})
: null;
},
listBySession: (targetSessionKey) =>
manager
.listBySessionKey(targetSessionKey)
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
resolveByConversation: (ref) => {
if (ref.channel !== "discord") {
return null;
}
const binding = manager.getByThreadId(ref.conversationId);
return binding ? toSessionBindingRecord(binding, { idleTimeoutMs, maxAgeMs }) : null;
},
touch: (bindingId, at) => {
const threadId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId,
});
if (!threadId) {
return;
}
manager.touchThread({ threadId, at, persist: true });
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
const removed = manager.unbindBySessionKey({
targetSessionKey: input.targetSessionKey,
reason: input.reason,
});
return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
}
const threadId = resolveThreadBindingConversationIdFromBindingId({
accountId,
bindingId: input.bindingId,
});
if (!threadId) {
return [];
}
const removed = manager.unbindThread({
threadId,
reason: input.reason,
});
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
},
};
manager,
defaults: { idleTimeoutMs, maxAgeMs },
resolveCurrentCfg,
resolveCurrentToken,
});
registerSessionBindingAdapter(sessionBindingAdapter);

View File

@@ -0,0 +1,229 @@
import {
resolveThreadBindingConversationIdFromBindingId,
type BindingTargetKind,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordChannelId } from "../target-parsing.js";
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
import { resolveBindingRecordKey } from "./thread-bindings.state.js";
import {
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
} from "./thread-bindings.state.js";
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.types.js";
type ThreadBindingDefaults = {
idleTimeoutMs: number;
maxAgeMs: number;
};
function normalizeChildBindingParentChannelId(raw?: string | null): string | undefined {
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return undefined;
}
try {
return resolveDiscordChannelId(trimmed);
} catch {
return undefined;
}
}
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
return raw === "subagent" ? "subagent" : "session";
}
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
return raw === "subagent" ? "subagent" : "acp";
}
function resolveEffectiveBindingExpiresAt(params: {
record: ThreadBindingRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): number | undefined {
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: params.record,
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
});
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: params.record,
defaultMaxAgeMs: params.defaultMaxAgeMs,
});
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
}
return inactivityExpiresAt ?? maxAgeExpiresAt;
}
export function toSessionBindingRecord(
record: ThreadBindingRecord,
defaults: ThreadBindingDefaults,
): SessionBindingRecord {
const bindingId =
resolveBindingRecordKey({
accountId: record.accountId,
threadId: record.threadId,
}) ?? `${record.accountId}:${record.threadId}`;
return {
bindingId,
targetSessionKey: record.targetSessionKey,
targetKind: toSessionBindingTargetKind(record.targetKind),
conversation: {
channel: "discord",
accountId: record.accountId,
conversationId: record.threadId,
parentConversationId: record.channelId,
},
status: "active",
boundAt: record.boundAt,
expiresAt: resolveEffectiveBindingExpiresAt({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
metadata: {
agentId: record.agentId,
label: record.label,
webhookId: record.webhookId,
webhookToken: record.webhookToken,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
}),
maxAgeMs: resolveThreadBindingMaxAgeMs({
record,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
...record.metadata,
},
};
}
export function createThreadBindingSessionAdapter(params: {
accountId: string;
manager: ThreadBindingManager;
defaults: ThreadBindingDefaults;
resolveCurrentCfg: () => OpenClawConfig;
resolveCurrentToken: () => string | undefined;
}): SessionBindingAdapter {
const toRecord = (entry: ThreadBindingRecord) => toSessionBindingRecord(entry, params.defaults);
return {
channel: "discord",
accountId: params.accountId,
capabilities: {
placements: ["current", "child"],
},
bind: async (input) => {
if (input.conversation.channel !== "discord") {
return null;
}
const targetSessionKey = input.targetSessionKey.trim();
if (!targetSessionKey) {
return null;
}
const conversationId = normalizeOptionalString(input.conversation.conversationId) ?? "";
const placement = input.placement === "child" ? "child" : "current";
const metadata = input.metadata ?? {};
const label = normalizeOptionalString(metadata.label);
const threadName =
typeof metadata.threadName === "string"
? normalizeOptionalString(metadata.threadName)
: undefined;
const introText =
typeof metadata.introText === "string"
? normalizeOptionalString(metadata.introText)
: undefined;
const boundBy =
typeof metadata.boundBy === "string"
? normalizeOptionalString(metadata.boundBy)
: undefined;
const agentId =
typeof metadata.agentId === "string"
? normalizeOptionalString(metadata.agentId)
: undefined;
let threadId: string | undefined;
let channelId: string | undefined;
let createThread = false;
if (placement === "child") {
createThread = true;
channelId = normalizeChildBindingParentChannelId(input.conversation.parentConversationId);
if (!channelId && conversationId) {
channelId =
(await resolveChannelIdForBinding({
cfg: params.resolveCurrentCfg(),
accountId: params.accountId,
token: params.resolveCurrentToken(),
threadId: conversationId,
})) ?? undefined;
}
} else {
threadId = conversationId || undefined;
}
const bound = await params.manager.bindTarget({
threadId,
channelId,
createThread,
threadName,
targetKind: toThreadBindingTargetKind(input.targetKind),
targetSessionKey,
agentId,
label,
boundBy,
introText,
metadata,
});
return bound ? toRecord(bound) : null;
},
listBySession: (targetSessionKey) =>
params.manager.listBySessionKey(targetSessionKey).map(toRecord),
resolveByConversation: (ref) => {
if (ref.channel !== "discord") {
return null;
}
const binding = params.manager.getByThreadId(ref.conversationId);
return binding ? toRecord(binding) : null;
},
touch: (bindingId, at) => {
const threadId = resolveThreadBindingConversationIdFromBindingId({
accountId: params.accountId,
bindingId,
});
if (!threadId) {
return;
}
params.manager.touchThread({ threadId, at, persist: true });
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
const removed = params.manager.unbindBySessionKey({
targetSessionKey: input.targetSessionKey,
reason: input.reason,
});
return removed.map(toRecord);
}
const threadId = resolveThreadBindingConversationIdFromBindingId({
accountId: params.accountId,
bindingId: input.bindingId,
});
if (!threadId) {
return [];
}
const removed = params.manager.unbindThread({
threadId,
reason: input.reason,
});
return removed ? [toRecord(removed)] : [];
},
};
}

View File

@@ -1,185 +0,0 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import {
buildDiscordComponentCustomId,
parseDiscordComponentCustomIdForInteraction,
} from "./component-custom-id.js";
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
const FORMER_PUBLIC_API_EXPORTS = [
"DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS",
"DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS",
"DISCORD_COMPONENT_ATTACHMENT_PREFIX",
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
"DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS",
"DISCORD_DEFAULT_LISTENER_TIMEOUT_MS",
"DISCORD_MODAL_CUSTOM_ID_KEY",
"DiscordApplicationSummary",
"DiscordComponentBlock",
"DiscordComponentBuildResult",
"DiscordComponentButtonSpec",
"DiscordComponentButtonStyle",
"DiscordComponentEntry",
"DiscordComponentMessageSpec",
"DiscordComponentModalFieldType",
"DiscordComponentSectionAccessory",
"DiscordComponentSelectOption",
"DiscordComponentSelectSpec",
"DiscordComponentSelectType",
"DiscordCredentialStatus",
"DiscordFormModal",
"DiscordInteractiveHandlerContext",
"DiscordInteractiveHandlerRegistration",
"DiscordModalEntry",
"DiscordModalFieldDefinition",
"DiscordModalFieldSpec",
"DiscordModalSpec",
"DiscordPluralKitConfig",
"DiscordPrivilegedIntentStatus",
"DiscordPrivilegedIntentsSummary",
"DiscordProbe",
"DiscordSendComponents",
"DiscordSendEmbeds",
"DiscordSendResult",
"DiscordTarget",
"DiscordTargetKind",
"DiscordTargetParseOptions",
"DiscordTokenResolution",
"InspectedDiscordAccount",
"PluralKitMemberInfo",
"PluralKitMessageInfo",
"PluralKitSystemInfo",
"ResolvedDiscordAccount",
"buildDiscordComponentCustomId",
"buildDiscordComponentMessage",
"buildDiscordComponentMessageFlags",
"buildDiscordInteractiveComponents",
"buildDiscordModalCustomId",
"collectDiscordSecurityAuditFindings",
"collectDiscordStatusIssues",
"createDiscordActionGate",
"createDiscordFormModal",
"discordPlugin",
"discordSetupPlugin",
"fetchDiscordApplicationId",
"fetchDiscordApplicationSummary",
"fetchPluralKitMessageInfo",
"formatDiscordComponentEventText",
"getDiscordExecApprovalApprovers",
"handleDiscordMessageAction",
"handleDiscordSubagentDeliveryTarget",
"handleDiscordSubagentEnded",
"handleDiscordSubagentSpawning",
"inspectDiscordAccount",
"isDiscordExecApprovalApprover",
"isDiscordExecApprovalClientEnabled",
"listDiscordAccountIds",
"listDiscordDirectoryGroupsFromConfig",
"listDiscordDirectoryPeersFromConfig",
"listEnabledDiscordAccounts",
"looksLikeDiscordTargetId",
"mergeDiscordAccountConfig",
"normalizeDiscordMessagingTarget",
"normalizeDiscordOutboundTarget",
"normalizeExplicitDiscordSessionKey",
"parseApplicationIdFromToken",
"parseDiscordComponentCustomId",
"parseDiscordComponentCustomIdForCarbon",
"parseDiscordComponentCustomIdForInteraction",
"parseDiscordModalCustomId",
"parseDiscordModalCustomIdForCarbon",
"parseDiscordModalCustomIdForInteraction",
"parseDiscordTarget",
"probeDiscord",
"readDiscordComponentSpec",
"resolveDefaultDiscordAccountId",
"resolveDiscordAccount",
"resolveDiscordAccountConfig",
"resolveDiscordChannelId",
"resolveDiscordComponentAttachmentName",
"resolveDiscordGroupRequireMention",
"resolveDiscordGroupToolPolicy",
"resolveDiscordMaxLinesPerMessage",
"resolveDiscordPrivilegedIntentsFromFlags",
"resolveDiscordRuntimeGroupPolicy",
"resolveDiscordTarget",
"shouldSuppressLocalDiscordExecApprovalPrompt",
"tryHandleDiscordMessageActionGuildAdmin",
] as const;
function collectExportedNames(): Set<string> {
const source = ts.createSourceFile(
API_SOURCE_PATH,
readFileSync(API_SOURCE_PATH, "utf8"),
ts.ScriptTarget.Latest,
true,
);
const names = new Set<string>();
for (const statement of source.statements) {
if (
ts.isVariableStatement(statement) &&
statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
names.add(declaration.name.text);
}
}
continue;
}
if (!ts.isExportDeclaration(statement) || !statement.exportClause) {
continue;
}
if (ts.isNamedExports(statement.exportClause)) {
for (const element of statement.exportClause.elements) {
names.add(element.name.text);
}
}
}
return names;
}
describe("discord public API barrel", () => {
it("keeps compatibility exports for existing @openclaw/discord/api.js consumers", () => {
const exportedNames = collectExportedNames();
for (const exportName of FORMER_PUBLIC_API_EXPORTS) {
expect(exportedNames).toContain(exportName);
}
});
itOnSupportedNode("links restored runtime compatibility exports", async () => {
const api = await import("../api.js");
for (const exportName of [
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
"buildDiscordComponentMessageFlags",
"createDiscordFormModal",
"handleDiscordSubagentSpawning",
"listEnabledDiscordAccounts",
"resolveDiscordRuntimeGroupPolicy",
"tryHandleDiscordMessageActionGuildAdmin",
]) {
expect(api).toHaveProperty(exportName);
}
});
it("keeps legacy Carbon component parser aliases aligned with interaction parsers", () => {
const exportedNames = collectExportedNames();
const customId = buildDiscordComponentCustomId({
componentId: "approve",
modalId: "details",
});
expect(exportedNames).toContain("parseDiscordComponentCustomIdForCarbon");
expect(parseDiscordComponentCustomIdForInteraction(customId)).toEqual({
key: "*",
data: { cid: "approve", mid: "details" },
});
});
});