mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 21:10:54 +00:00
638 lines
19 KiB
TypeScript
638 lines
19 KiB
TypeScript
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",
|
|
];
|
|
|
|
// 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,
|
|
};
|
|
|
|
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)}`);
|
|
// Preserve attachment context even when remote fetch is blocked/fails.
|
|
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);
|
|
}
|