| import type { ChannelType, Client, Message } from "@buape/carbon"; |
| import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; |
| import { buildMediaPayload } from "../../channels/plugins/media-payload.js"; |
| import { logVerbose } from "../../globals.js"; |
| import type { SsrFPolicy } from "../../infra/net/ssrf.js"; |
| import { fetchRemoteMedia, type FetchLike } from "../../media/fetch.js"; |
| import { saveMediaBuffer } from "../../media/store.js"; |
|
|
| const DISCORD_CDN_HOSTNAMES = [ |
| "cdn.discordapp.com", |
| "media.discordapp.net", |
| "*.discordapp.com", |
| "*.discordapp.net", |
| ]; |
|
|
| |
| const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = { |
| hostnameAllowlist: DISCORD_CDN_HOSTNAMES, |
| allowRfc2544BenchmarkRange: true, |
| }; |
|
|
| 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; |
| }; |
|
|
| export type DiscordChannelInfo = { |
| type: ChannelType; |
| name?: string; |
| topic?: string; |
| parentId?: string; |
| ownerId?: string; |
| }; |
|
|
| type DiscordMessageWithChannelId = Message & { |
| channel_id?: unknown; |
| rawData?: { channel_id?: unknown }; |
| }; |
|
|
| 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; |
| }; |
|
|
| type DiscordMessageSnapshot = { |
| message?: DiscordSnapshotMessage | null; |
| }; |
|
|
| const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000; |
| const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000; |
| const DISCORD_CHANNEL_INFO_CACHE = new Map< |
| string, |
| { value: DiscordChannelInfo | null; expiresAt: number } |
| >(); |
| const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers"; |
|
|
| export function __resetDiscordChannelInfoCacheForTest() { |
| DISCORD_CHANNEL_INFO_CACHE.clear(); |
| } |
|
|
| function normalizeDiscordChannelId(value: unknown): string { |
| if (typeof value === "string") { |
| return value.trim(); |
| } |
| if (typeof value === "number" || typeof value === "bigint") { |
| return String(value).trim(); |
| } |
| return ""; |
| } |
|
|
| export function resolveDiscordMessageChannelId(params: { |
| message: Message; |
| eventChannelId?: string | number | null; |
| }): string { |
| const message = params.message as DiscordMessageWithChannelId; |
| return ( |
| normalizeDiscordChannelId(message.channelId) || |
| normalizeDiscordChannelId(message.channel_id) || |
| normalizeDiscordChannelId(message.rawData?.channel_id) || |
| normalizeDiscordChannelId(params.eventChannelId) |
| ); |
| } |
|
|
| export async function resolveDiscordChannelInfo( |
| client: Client, |
| channelId: string, |
| ): Promise<DiscordChannelInfo | null> { |
| const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId); |
| if (cached) { |
| if (cached.expiresAt > Date.now()) { |
| return cached.value; |
| } |
| DISCORD_CHANNEL_INFO_CACHE.delete(channelId); |
| } |
| try { |
| const channel = await client.fetchChannel(channelId); |
| if (!channel) { |
| DISCORD_CHANNEL_INFO_CACHE.set(channelId, { |
| value: null, |
| expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, |
| }); |
| return null; |
| } |
| const name = "name" in channel ? (channel.name ?? undefined) : undefined; |
| const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; |
| const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; |
| const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; |
| const payload: DiscordChannelInfo = { |
| type: channel.type, |
| name, |
| topic, |
| parentId, |
| ownerId, |
| }; |
| DISCORD_CHANNEL_INFO_CACHE.set(channelId, { |
| value: payload, |
| expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS, |
| }); |
| return payload; |
| } catch (err) { |
| logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); |
| DISCORD_CHANNEL_INFO_CACHE.set(channelId, { |
| value: null, |
| expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, |
| }); |
| return null; |
| } |
| } |
|
|
| 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, |
| fetchImpl?: FetchLike, |
| ssrfPolicy?: SsrFPolicy, |
| ): Promise<DiscordMediaInfo[]> { |
| const out: DiscordMediaInfo[] = []; |
| const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy); |
| await appendResolvedMediaFromAttachments({ |
| attachments: message.attachments ?? [], |
| maxBytes, |
| out, |
| errorPrefix: "discord: failed to download attachment", |
| fetchImpl, |
| ssrfPolicy: resolvedSsrFPolicy, |
| }); |
| await appendResolvedMediaFromStickers({ |
| stickers: resolveDiscordMessageStickers(message), |
| maxBytes, |
| out, |
| errorPrefix: "discord: failed to download sticker", |
| fetchImpl, |
| ssrfPolicy: resolvedSsrFPolicy, |
| }); |
| return out; |
| } |
|
|
| export async function resolveForwardedMediaList( |
| message: Message, |
| maxBytes: number, |
| fetchImpl?: FetchLike, |
| ssrfPolicy?: SsrFPolicy, |
| ): Promise<DiscordMediaInfo[]> { |
| const snapshots = resolveDiscordMessageSnapshots(message); |
| if (snapshots.length === 0) { |
| return []; |
| } |
| const out: DiscordMediaInfo[] = []; |
| const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy); |
| for (const snapshot of snapshots) { |
| await appendResolvedMediaFromAttachments({ |
| attachments: snapshot.message?.attachments, |
| maxBytes, |
| out, |
| errorPrefix: "discord: failed to download forwarded attachment", |
| fetchImpl, |
| ssrfPolicy: resolvedSsrFPolicy, |
| }); |
| await appendResolvedMediaFromStickers({ |
| stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [], |
| maxBytes, |
| out, |
| errorPrefix: "discord: failed to download forwarded sticker", |
| fetchImpl, |
| ssrfPolicy: resolvedSsrFPolicy, |
| }); |
| } |
| return out; |
| } |
|
|
| async function appendResolvedMediaFromAttachments(params: { |
| attachments?: APIAttachment[] | null; |
| maxBytes: number; |
| out: DiscordMediaInfo[]; |
| errorPrefix: string; |
| fetchImpl?: FetchLike; |
| ssrfPolicy?: SsrFPolicy; |
| }) { |
| const attachments = params.attachments; |
| if (!attachments || attachments.length === 0) { |
| return; |
| } |
| for (const attachment of attachments) { |
| try { |
| const fetched = await fetchRemoteMedia({ |
| url: attachment.url, |
| filePathHint: attachment.filename ?? attachment.url, |
| maxBytes: params.maxBytes, |
| fetchImpl: params.fetchImpl, |
| ssrfPolicy: params.ssrfPolicy, |
| }); |
| 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 ?? attachment.url; |
| logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`); |
| |
| params.out.push({ |
| path: attachment.url, |
| 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; |
| }) { |
| 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 fetchRemoteMedia({ |
| url: candidate.url, |
| filePathHint: candidate.fileName, |
| maxBytes: params.maxBytes, |
| fetchImpl: params.fetchImpl, |
| ssrfPolicy: params.ssrfPolicy, |
| }); |
| 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>"; |
| } |
| return "<media:document>"; |
| } |
|
|
| function isImageAttachment(attachment: APIAttachment): boolean { |
| const mime = attachment.content_type ?? ""; |
| if (mime.startsWith("image/")) { |
| return true; |
| } |
| const name = attachment.filename?.toLowerCase() ?? ""; |
| 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 = embed?.title?.trim() || ""; |
| const description = embed?.description?.trim() || ""; |
| 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 = |
| message.content?.trim() || |
| buildDiscordMediaPlaceholder({ |
| attachments: message.attachments ?? undefined, |
| stickers: resolveDiscordMessageStickers(message), |
| }) || |
| embedText || |
| options?.fallbackText?.trim() || |
| ""; |
| 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 ""; |
| } |
| const forwardedBlocks = snapshots |
| .map((snapshot) => { |
| const snapshotMessage = snapshot.message; |
| 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}`; |
| }) |
| .filter((entry): entry is string => Boolean(entry)); |
| if (forwardedBlocks.length === 0) { |
| return ""; |
| } |
| return forwardedBlocks.join("\n\n"); |
| } |
|
|
| function resolveDiscordMessageSnapshots(message: Message): DiscordMessageSnapshot[] { |
| const rawData = (message as { rawData?: { message_snapshots?: unknown } }).rawData; |
| const snapshots = |
| rawData?.message_snapshots ?? |
| (message as { message_snapshots?: unknown }).message_snapshots ?? |
| (message as { messageSnapshots?: unknown }).messageSnapshots; |
| if (!Array.isArray(snapshots)) { |
| return []; |
| } |
| return snapshots.filter( |
| (entry): entry is DiscordMessageSnapshot => Boolean(entry) && typeof entry === "object", |
| ); |
| } |
|
|
| function resolveDiscordSnapshotMessageText(snapshot: DiscordSnapshotMessage): string { |
| const content = snapshot.content?.trim() ?? ""; |
| 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); |
| } |
|
|