diff --git a/packages/server/src/utils/embedResolver.ts b/packages/server/src/utils/embedResolver.ts new file mode 100644 index 00000000..6afdf3b8 --- /dev/null +++ b/packages/server/src/utils/embedResolver.ts @@ -0,0 +1,217 @@ +import { eq, inArray } from 'drizzle-orm'; +import type { Embed } from '@backspace/shared'; +import { getDb, schema } from '../db/index.js'; +import { generateSnowflake } from './snowflake.js'; +import { classifyUrl } from './embedClassifier.js'; +import { fetchUrlMetadata } from './metadataFetcher.js'; +import { connectionManager } from '../ws/handler.js'; + +const MAX_EMBEDS_PER_MESSAGE = 5; + +// ─── URL Extraction ──────────────────────────────────────────────────────── + +export function extractUrls(content: string | null): string[] { + if (!content) return []; + + const matches = content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/g); + if (!matches) return []; + + // Deduplicate while preserving order + const seen = new Set(); + const unique: string[] = []; + for (const url of matches) { + if (!seen.has(url)) { + seen.add(url); + unique.push(url); + } + } + + return unique.slice(0, MAX_EMBEDS_PER_MESSAGE); +} + +// ─── Row → Embed Conversion ──────────────────────────────────────────────── + +export function embedRowToEmbed(row: typeof schema.embeds.$inferSelect): Embed { + return { + id: row.id, + messageId: row.messageId, + dmMessageId: row.dmMessageId, + url: row.url, + embedType: row.embedType as Embed['embedType'], + provider: (row.provider ?? null) as Embed['provider'], + title: row.title ?? null, + description: row.description ?? null, + image: row.image ?? null, + embedUrl: row.embedUrl ?? null, + width: row.width ?? null, + height: row.height ?? null, + color: row.color ?? null, + createdAt: row.createdAt, + }; +} + +// ─── Embed Resolution ───────────────────────────────────────────────────── + +export async function resolveEmbeds( + messageId: string, + content: string | null, + channelId: string, + isDm: boolean, + spaceId: string | null, +): Promise { + const urls = extractUrls(content); + if (urls.length === 0) return; + + const db = getDb(); + const now = Date.now(); + const resolvedEmbeds: Embed[] = []; + + for (const url of urls) { + try { + const classification = classifyUrl(url); + + let title: string | null = null; + let description: string | null = null; + let image: string | null = null; + let siteName: string | null = null; + + if (classification.embedType === 'image') { + // Direct image URL — no fetch needed, use the URL as the image source + image = url; + } else if (classification.needsMetadataFetch) { + const metadata = await fetchUrlMetadata(url); + if (metadata) { + title = metadata.title; + description = metadata.description; + image = metadata.image; + siteName = metadata.siteName; + } + + // For generic embeds, skip if we couldn't extract a title + if (classification.embedType === 'generic' && !title) { + continue; + } + } + + const embedId = generateSnowflake(); + const row: typeof schema.embeds.$inferInsert = { + id: embedId, + messageId: isDm ? null : messageId, + dmMessageId: isDm ? messageId : null, + url, + embedType: classification.embedType, + provider: classification.provider, + title, + description, + image, + embedUrl: classification.embedUrl, + width: null, + height: null, + color: null, + createdAt: now, + }; + + db.insert(schema.embeds).values(row).run(); + + resolvedEmbeds.push(embedRowToEmbed(row as typeof schema.embeds.$inferSelect)); + } catch { + // One URL failure must not block the rest + continue; + } + } + + if (resolvedEmbeds.length === 0) return; + + if (isDm) { + connectionManager.sendToDmMembers(channelId, { + type: 'dm_embeds_resolved', + messageId, + dmChannelId: channelId, + embeds: resolvedEmbeds, + }); + } else { + if (!spaceId) return; + connectionManager.sendToChannel(spaceId, channelId, { + type: 'embeds_resolved', + messageId, + channelId, + embeds: resolvedEmbeds, + }); + } +} + +// ─── Re-resolution (on message edit) ────────────────────────────────────── + +export async function reResolveEmbeds( + messageId: string, + content: string | null, + channelId: string, + isDm: boolean, + spaceId: string | null, +): Promise { + const db = getDb(); + + if (isDm) { + db.delete(schema.embeds) + .where(eq(schema.embeds.dmMessageId, messageId)) + .run(); + } else { + db.delete(schema.embeds) + .where(eq(schema.embeds.messageId, messageId)) + .run(); + } + + await resolveEmbeds(messageId, content, channelId, isDm, spaceId); +} + +// ─── Batch Fetching ──────────────────────────────────────────────────────── + +export function fetchEmbedsForMessages( + messageIds: string[], +): Map { + const result = new Map(); + if (messageIds.length === 0) return result; + + const db = getDb(); + const rows = db.select() + .from(schema.embeds) + .where(inArray(schema.embeds.messageId, messageIds)) + .all(); + + for (const row of rows) { + if (!row.messageId) continue; + const existing = result.get(row.messageId); + if (existing) { + existing.push(row); + } else { + result.set(row.messageId, [row]); + } + } + + return result; +} + +export function fetchDmEmbedsForMessages( + dmMessageIds: string[], +): Map { + const result = new Map(); + if (dmMessageIds.length === 0) return result; + + const db = getDb(); + const rows = db.select() + .from(schema.embeds) + .where(inArray(schema.embeds.dmMessageId, dmMessageIds)) + .all(); + + for (const row of rows) { + if (!row.dmMessageId) continue; + const existing = result.get(row.dmMessageId); + if (existing) { + existing.push(row); + } else { + result.set(row.dmMessageId, [row]); + } + } + + return result; +}