145 lines
5.4 KiB
TypeScript
145 lines
5.4 KiB
TypeScript
/*
|
|
* Slopbot scrape backend: discord.js messages to the core message shape.
|
|
*
|
|
* The mapping is duck-typed so tests run without a bot token. The live shape was
|
|
* checked against the discord.js 14 typings; a live check is still owed at C8.
|
|
*/
|
|
|
|
import type { FetchPage, ScrapeBackend } from "../src/engine.js";
|
|
import type { CoreAttachment, CoreEmbed, CoreMessage, CoreSticker, MediaMessage } from "../src/types.js";
|
|
|
|
/** Discord message flag for a voice message. */
|
|
const VOICE_MESSAGE_FLAG = 1 << 13;
|
|
|
|
/** The minimal fetcher the backend needs. The real one wraps a discord.js client. */
|
|
export interface SlopbotFetcher {
|
|
fetchMessages(channelId: string, options: { limit: number; before?: string }): Promise<unknown[]>;
|
|
}
|
|
|
|
/** discord.js Collections are iterable and have `.values()`; tests use Maps and arrays. */
|
|
function collectionValues(value: any): any[] {
|
|
if (!value) return [];
|
|
if (Array.isArray(value)) return value;
|
|
if (typeof value.values === "function") return [...value.values()];
|
|
return [];
|
|
}
|
|
|
|
function mapAttachments(message: any): CoreAttachment[] {
|
|
return collectionValues(message?.attachments).map((attachment: any) => ({
|
|
id: attachment?.id ?? null,
|
|
filename: attachment?.name ?? attachment?.filename ?? "",
|
|
url: attachment?.url ?? null,
|
|
contentType: attachment?.contentType ?? attachment?.content_type ?? null,
|
|
size: attachment?.size ?? null,
|
|
width: attachment?.width ?? null,
|
|
height: attachment?.height ?? null,
|
|
durationSecs: attachment?.duration ?? attachment?.durationSecs ?? attachment?.duration_secs ?? null
|
|
}));
|
|
}
|
|
|
|
/** Dimensions of the first embed part that has them: video, image, then thumbnail. */
|
|
function embedDataSize(data: any) {
|
|
for (const part of [data?.video, data?.image, data?.thumbnail]) {
|
|
if (part && Number.isFinite(part.width) && Number.isFinite(part.height)) {
|
|
return { width: part.width, height: part.height };
|
|
}
|
|
}
|
|
return { width: null, height: null };
|
|
}
|
|
|
|
function mapEmbeds(message: any): CoreEmbed[] {
|
|
const assetUrl = (value: any) => value?.url ?? (typeof value === "string" ? value : null);
|
|
|
|
return collectionValues(message?.embeds).map((embed: any) => {
|
|
const data = embed?.data ?? embed ?? {};
|
|
const size = embedDataSize(data);
|
|
return {
|
|
type: data.type ?? null,
|
|
url: data.url ?? null,
|
|
title: data.title ?? null,
|
|
provider: data.provider?.name ?? null,
|
|
videoUrl: assetUrl(data.video),
|
|
imageUrl: assetUrl(data.image),
|
|
thumbnailUrl: assetUrl(data.thumbnail),
|
|
width: size.width,
|
|
height: size.height
|
|
};
|
|
});
|
|
}
|
|
|
|
function mapStickers(message: any): CoreSticker[] {
|
|
return collectionValues(message?.stickers).map((sticker: any) => ({
|
|
id: sticker?.id ?? null,
|
|
name: sticker?.name ?? null,
|
|
formatType: sticker?.format ?? null
|
|
}));
|
|
}
|
|
|
|
function isVoiceMessage(message: any): boolean {
|
|
const flags = message?.flags;
|
|
if (typeof flags?.has === "function") return flags.has(VOICE_MESSAGE_FLAG);
|
|
return Boolean(Number(flags ?? 0) & VOICE_MESSAGE_FLAG);
|
|
}
|
|
|
|
function createdAt(message: any): string | null {
|
|
if (message?.createdAt instanceof Date) return message.createdAt.toISOString();
|
|
if (typeof message?.timestamp === "string") return message.timestamp;
|
|
return null;
|
|
}
|
|
|
|
function mapSnapshot(snapshot: any): MediaMessage & { content: string; timestamp: string | null } {
|
|
const source = snapshot?.message ?? snapshot;
|
|
|
|
return {
|
|
content: source?.content ?? "",
|
|
timestamp: createdAt(source),
|
|
attachments: mapAttachments(source),
|
|
embeds: mapEmbeds(source),
|
|
stickerItems: mapStickers(source),
|
|
voiceMessage: isVoiceMessage(source)
|
|
};
|
|
}
|
|
|
|
/** Map one discord.js message to the core message shape. */
|
|
export function mapSlopbotMessage(message: any): CoreMessage {
|
|
const reference = message?.reference;
|
|
const edited = message?.editedAt ?? message?.editedTimestamp ?? null;
|
|
|
|
return {
|
|
id: String(message?.id ?? ""),
|
|
author: {
|
|
id: String(message?.author?.id ?? ""),
|
|
username: message?.author?.username ?? "",
|
|
avatar: message?.author?.avatar ?? null
|
|
},
|
|
content: message?.content ?? "",
|
|
timestamp: createdAt(message) ?? "",
|
|
editedAt: edited instanceof Date ? edited.toISOString() : null,
|
|
replyTo: reference?.messageId
|
|
? { id: String(reference.messageId), authorId: null, authorName: null }
|
|
: null,
|
|
attachments: mapAttachments(message),
|
|
embeds: mapEmbeds(message),
|
|
stickerItems: mapStickers(message),
|
|
forwarded: collectionValues(message?.messageSnapshots).map(mapSnapshot),
|
|
voiceMessage: isVoiceMessage(message)
|
|
};
|
|
}
|
|
|
|
export class SlopbotBackend implements ScrapeBackend {
|
|
readonly name = "slopbot" as const;
|
|
|
|
constructor(private readonly fetcher: SlopbotFetcher) {}
|
|
|
|
async fetchPage(channelId: string, before: string | null, limit: number): Promise<FetchPage> {
|
|
const messages = await this.fetcher.fetchMessages(channelId, {
|
|
limit,
|
|
...(before ? { before } : {})
|
|
});
|
|
|
|
const mapped = messages.map(mapSlopbotMessage).filter(message => message.id.length > 0);
|
|
mapped.sort((a, b) => (BigInt(a.id) < BigInt(b.id) ? -1 : 1));
|
|
return { messages: mapped };
|
|
}
|
|
}
|