summary refs log tree commit diff
path: root/src/api/util/utility
diff options
context:
space:
mode:
Diffstat (limited to 'src/api/util/utility')
-rw-r--r--src/api/util/utility/EmbedHandlers.ts161
1 files changed, 160 insertions, 1 deletions
diff --git a/src/api/util/utility/EmbedHandlers.ts b/src/api/util/utility/EmbedHandlers.ts

index 77d0c8f0..da6a7429 100644 --- a/src/api/util/utility/EmbedHandlers.ts +++ b/src/api/util/utility/EmbedHandlers.ts
@@ -16,12 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { Config } from "@spacebar/util"; +import { arrayDistinctBy, arrayGroupBy, arrayRemove, Config, EmbedCache, emitEvent, Message, MessageUpdateEvent, normalizeUrl } from "@spacebar/util"; import { Embed, EmbedImage, EmbedType } from "@spacebar/schemas"; import * as cheerio from "cheerio"; import crypto from "crypto"; import { yellow } from "picocolors"; import probe from "probe-image-size"; +import { FindOptionsWhere, In } from "typeorm"; export const DEFAULT_FETCH_OPTIONS: RequestInit = { redirect: "follow", @@ -517,3 +518,161 @@ export const EmbedHandlers: { }; }, }; + +const LINK_REGEX = /<?https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)>?/g; + +export function getMessageContentUrls(message: Message) { + const content = message.content?.replace(/ *`[^)]*` */g, ""); // remove markdown + + return content?.match(LINK_REGEX) ?? []; +} + +export async function dropDuplicateCacheEntries(entries: EmbedCache[]): Promise<EmbedCache[]> { + const grouped = Array.from(arrayGroupBy(entries, (e) => e.url).values()).map((g) => + g.toSorted((e1, e2) => { + let diff = e2.createdAt.getTime() - e1.createdAt.getTime(); + if (diff == 0) diff = Number(BigInt(e2.id) - BigInt(e1.id)); + return diff; + }), + ); + + const fullToDeleteIds: string[] = []; + for (const group of grouped) { + if (group.length <= 1) continue; + // console.log("[EmbedCache] Removing all but first from cache:", group); + // this might be backwards, sort always confuses me lol + const toDelete = group.slice(1); + const toDeleteIds = toDelete.map((x) => x.id); + fullToDeleteIds.push(...toDeleteIds); + console.warn("[EmbedCache] Removing duplicate IDs for", toDelete[0].url, " - ", toDeleteIds); + } + + await EmbedCache.delete({ id: In(fullToDeleteIds) } as FindOptionsWhere<EmbedCache>); + + // console.log("[EmbedCache] Cached embeds:", Array.from(grouped.map((x) => x[0].url))); + return Array.from(grouped.map((x) => x[0])); +} + +async function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// hack to make nodejs not die +function getSlowdownFactor(off: number) { + if (off < 10) return off; + if (off < 25) return 100 + off * 2; + if (off < 50) return 200 + off * 10; + if (off < 100) return 500 + off * 15; + if (off < 250) return 750 + off * 20; + return 1000 + off * 150; +} + +export async function getOrUpdateEmbedCache(urls: string[], cb?: (url: string, embeds: Embed[]) => Promise<void>): Promise<EmbedCache[]> { + urls = arrayDistinctBy(urls, (x) => x); + const embeds: EmbedCache[] = []; + + const cachedEmbeds = await dropDuplicateCacheEntries( + await EmbedCache.find({ + where: { + url: In(urls.map(normalizeUrl)), + }, + }), + ); + embeds.push(...cachedEmbeds); + cb?.( + "cached", + cachedEmbeds.map((e) => e.embed), + ); + + const urlsToGenerate = urls.filter((url) => { + return !cachedEmbeds.some((e) => e.url == normalizeUrl(url)); + }); + + if (urlsToGenerate.length > 0) console.log("[Embeds] Need to generate embeds for urls:", urlsToGenerate); + if (cachedEmbeds.length > 0) + console.log( + "[Embeds] Already had embeds for urls:", + cachedEmbeds.map((e) => e.url), + ); + + let off = 0; + const generatedEmbeds = await Promise.all( + urlsToGenerate.map(async (link) => { + await sleep(getSlowdownFactor(off++)); // ...or nodejs gets overwhelmed and times out + return await getOrUpdateEmbedCacheSingle(link, cb); + }), + ); + + embeds.push(...generatedEmbeds.filter((e): e is EmbedCache[] => e !== null).flat()); + + return embeds; +} + +async function getOrUpdateEmbedCacheSingle(link: string, cb?: (url: string, embeds: Embed[]) => Promise<void>): Promise<EmbedCache[] | null> { + const url = new URL(link); + const handler = url.hostname === new URL(Config.get().cdn.endpointPublic!).hostname ? EmbedHandlers["self"] : (EmbedHandlers[url.hostname] ?? EmbedHandlers["default"]); + const results: EmbedCache[] = []; + try { + let res = await handler(url); + if (!res) return null; + if (!Array.isArray(res)) res = [res]; + + for (const embed of res) { + // Cache with normalized URL + const cache = await EmbedCache.create({ + url: normalizeUrl(url.href), + embed: embed, + createdAt: new Date(), + }).save(); + results.push(cache); + console.log("[Embeds] Generated embed for", link); + } + await cb?.(link, res); + } catch (e) { + console.error(`[Embeds] Error while generating embed for ${link}`, e); + } + return results.length == 0 ? null : results; +} + +export async function fillMessageUrlEmbeds(message: Message) { + const linkMatches = getMessageContentUrls(message).filter((l) => !l.startsWith("<") && !l.endsWith(">")); + + // Filter out embeds that could be links, start from scratch + message.embeds = message.embeds.filter((embed) => embed.type === "rich"); + + if (linkMatches.length == 0) return message; + + const uniqueLinks: string[] = arrayDistinctBy(linkMatches, normalizeUrl); + + if (uniqueLinks.length === 0) { + // No valid unique links found, update message to remove old embeds + message.embeds = message.embeds?.filter((embed) => embed.type === "rich"); + await saveAndEmitMessageUpdate(message); + return message; + } + + // avoid a race condition updating the same row + let messageUpdateLock = saveAndEmitMessageUpdate(message); + await getOrUpdateEmbedCache(uniqueLinks, async (_, embeds) => { + if (message.embeds.length + embeds.length > Config.get().limits.message.maxEmbeds) return; + message.embeds.push(...embeds); + try { + await messageUpdateLock; + } catch { + /* empty */ + } + messageUpdateLock = saveAndEmitMessageUpdate(message); + }); + + await saveAndEmitMessageUpdate(message); + return message; +} + +async function saveAndEmitMessageUpdate(message: Message) { + await Message.update({ id: message.id, channel_id: message.channel_id }, { embeds: message.embeds }); + await emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: message.channel_id, + data: message.toJSON(), + } satisfies MessageUpdateEvent); +}