287 lines
8.5 KiB
TypeScript
287 lines
8.5 KiB
TypeScript
import type { ChannelType, Client, Message } from "@buape/carbon";
|
|
import type { APIAttachment } from "discord-api-types/v10";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { fetchRemoteMedia } from "../../media/fetch.js";
|
|
import { saveMediaBuffer } from "../../media/store.js";
|
|
|
|
export type DiscordMediaInfo = {
|
|
path: string;
|
|
contentType?: string;
|
|
placeholder: string;
|
|
};
|
|
|
|
export type DiscordChannelInfo = {
|
|
type: ChannelType;
|
|
name?: string;
|
|
topic?: string;
|
|
parentId?: string;
|
|
ownerId?: string;
|
|
};
|
|
|
|
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;
|
|
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 }
|
|
>();
|
|
|
|
export function __resetDiscordChannelInfoCacheForTest() {
|
|
DISCORD_CHANNEL_INFO_CACHE.clear();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
export async function resolveMediaList(
|
|
message: Message,
|
|
maxBytes: number,
|
|
): Promise<DiscordMediaInfo[]> {
|
|
const attachments = message.attachments ?? [];
|
|
if (attachments.length === 0) {
|
|
return [];
|
|
}
|
|
const out: DiscordMediaInfo[] = [];
|
|
for (const attachment of attachments) {
|
|
try {
|
|
const fetched = await fetchRemoteMedia({
|
|
url: attachment.url,
|
|
filePathHint: attachment.filename ?? attachment.url,
|
|
});
|
|
const saved = await saveMediaBuffer(
|
|
fetched.buffer,
|
|
fetched.contentType ?? attachment.content_type,
|
|
"inbound",
|
|
maxBytes,
|
|
);
|
|
out.push({
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: inferPlaceholder(attachment),
|
|
});
|
|
} catch (err) {
|
|
const id = attachment.id ?? attachment.url;
|
|
logVerbose(`discord: failed to download attachment ${id}: ${String(err)}`);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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})`;
|
|
}
|
|
|
|
export function resolveDiscordMessageText(
|
|
message: Message,
|
|
options?: { fallbackText?: string; includeForwarded?: boolean },
|
|
): string {
|
|
const baseText =
|
|
message.content?.trim() ||
|
|
buildDiscordAttachmentPlaceholder(message.attachments) ||
|
|
message.embeds?.[0]?.description ||
|
|
options?.fallbackText?.trim() ||
|
|
"";
|
|
if (!options?.includeForwarded) {
|
|
return baseText;
|
|
}
|
|
const forwardedText = resolveDiscordForwardedMessagesText(message);
|
|
if (!forwardedText) {
|
|
return baseText;
|
|
}
|
|
if (!baseText) {
|
|
return forwardedText;
|
|
}
|
|
return `${baseText}\n${forwardedText}`;
|
|
}
|
|
|
|
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 = buildDiscordAttachmentPlaceholder(snapshot.attachments ?? undefined);
|
|
const embed = snapshot.embeds?.[0];
|
|
const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
|
|
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[];
|
|
} {
|
|
const first = mediaList[0];
|
|
const mediaPaths = mediaList.map((media) => media.path);
|
|
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
return {
|
|
MediaPath: first?.path,
|
|
MediaType: first?.contentType,
|
|
MediaUrl: first?.path,
|
|
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
};
|
|
}
|