feat: add embed resolver with async resolution and batch fetching
This commit is contained in:
@@ -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<string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, (typeof schema.embeds.$inferSelect)[]> {
|
||||
const result = new Map<string, (typeof schema.embeds.$inferSelect)[]>();
|
||||
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<string, (typeof schema.embeds.$inferSelect)[]> {
|
||||
const result = new Map<string, (typeof schema.embeds.$inferSelect)[]>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user