From 43b084e5fa2ef0ddf973235a4e580232872fdcd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 19:00:39 +0100 Subject: [PATCH] refactor(discord): split message and binding helpers --- extensions/discord/api.ts | 13 - extensions/discord/src/api-barrel.test.ts | 74 ++ .../discord/src/monitor/message-forwarded.ts | 106 +++ .../monitor/message-handler.process.test.ts | 73 +- .../discord/src/monitor/message-media.ts | 507 ++++++++++++ .../discord/src/monitor/message-text.ts | 123 +++ .../discord/src/monitor/message-utils.ts | 751 +----------------- .../discord/src/monitor/model-picker.ts | 707 +---------------- .../discord/src/monitor/model-picker.view.ts | 695 ++++++++++++++++ .../src/monitor/thread-bindings.manager.ts | 213 +---- .../thread-bindings.session-adapter.ts | 229 ++++++ extensions/discord/src/public-api.test.ts | 185 ----- 12 files changed, 1816 insertions(+), 1860 deletions(-) create mode 100644 extensions/discord/src/api-barrel.test.ts create mode 100644 extensions/discord/src/monitor/message-forwarded.ts create mode 100644 extensions/discord/src/monitor/message-media.ts create mode 100644 extensions/discord/src/monitor/message-text.ts create mode 100644 extensions/discord/src/monitor/model-picker.view.ts create mode 100644 extensions/discord/src/monitor/thread-bindings.session-adapter.ts delete mode 100644 extensions/discord/src/public-api.test.ts diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index d52383bf34a..46726ab9c75 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -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, diff --git a/extensions/discord/src/api-barrel.test.ts b/extensions/discord/src/api-barrel.test.ts new file mode 100644 index 00000000000..e8e2899da92 --- /dev/null +++ b/extensions/discord/src/api-barrel.test.ts @@ -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 { + const source = ts.createSourceFile( + API_SOURCE_PATH, + readFileSync(API_SOURCE_PATH, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const names = new Set(); + 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); + } + }); +}); diff --git a/extensions/discord/src/monitor/message-forwarded.ts b/extensions/discord/src/monitor/message-forwarded.ts new file mode 100644 index 00000000000..990e60a4c27 --- /dev/null +++ b/extensions/discord/src/monitor/message-forwarded.ts @@ -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; +} diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index 38ff39aee70..08576babf14 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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 { - await processDiscordMessage(ctx as any); +async function runProcessDiscordMessage(ctx: DiscordMessagePreflightContext): Promise { + await processDiscordMessage(ctx); } async function runInPartialStreamMode(): Promise { @@ -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); }); diff --git a/extensions/discord/src/monitor/message-media.ts b/extensions/discord/src/monitor/message-media.ts new file mode 100644 index 00000000000..382377f5473 --- /dev/null +++ b/extensions/discord/src/monitor/message-media.ts @@ -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 { + 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 { + 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 { + 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 | 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(() => {}); + } + throw error; + }); + + try { + if (!params.totalTimeoutMs) { + return await fetchPromise; + } + const timeoutPromise = new Promise((_, 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: "", + }); + 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: "", + }); + } + } + } +} + +function inferPlaceholder(attachment: APIAttachment): string { + const mime = attachment.content_type ?? ""; + if (mime.startsWith("image/")) { + return ""; + } + if (mime.startsWith("video/")) { + return ""; + } + if (mime.startsWith("audio/")) { + return ""; + } + if (hasDiscordVoiceAttachmentFields(attachment)) { + return ""; + } + if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) { + return ""; + } + return ""; +} + +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 ? "" : ""; + 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 ` (${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); +} diff --git a/extensions/discord/src/monitor/message-text.ts b/extensions/discord/src/monitor/message-text.ts new file mode 100644 index 00000000000..1978e3927d2 --- /dev/null +++ b/extensions/discord/src/monitor/message-text.ts @@ -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 || ""; +} diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts index 53f1c08358d..3e40d577750 100644 --- a/extensions/discord/src/monitor/message-utils.ts +++ b/extensions/discord/src/monitor/message-utils.ts @@ -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 { - 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 { - 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 { - 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 | 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(() => {}); - } - throw error; - }); - - try { - if (!params.totalTimeoutMs) { - return await fetchPromise; - } - const timeoutPromise = new Promise((_, 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: "", - }); - 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: "", - }); - } - } - } -} - -function inferPlaceholder(attachment: APIAttachment): string { - const mime = attachment.content_type ?? ""; - if (mime.startsWith("image/")) { - return ""; - } - if (mime.startsWith("video/")) { - return ""; - } - if (mime.startsWith("audio/")) { - return ""; - } - if (hasDiscordVoiceAttachmentFields(attachment)) { - return ""; - } - if (isDiscordAudioAttachmentFileName(attachment.filename ?? attachment.url)) { - return ""; - } - return ""; -} - -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 ? "" : ""; - 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 ` (${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"; diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts index 2c89a23d45e..bfb59477af6 100644 --- a/extensions/discord/src/monitor/model-picker.ts +++ b/extensions/discord/src/monitor/model-picker.ts @@ -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