From 01ee7ab67a72f3169bba3d43ad96e95ef621ff1e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:41:53 +0100 Subject: [PATCH] feat: wire embeds into all message query and creation paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate embed infrastructure into the complete message flow: - messages.ts: batch-fetch embeds in GET, resolve on POST, re-resolve on PATCH - dm.ts: same pattern for DM messages with isDm=true - search.ts: include embeds in all 4 search/around endpoints - events.ts: embed resolution in WS message create/edit for both space and DM - Fix embedClassifier.ts type errors (regex match undefined → null) - Add embeds: [] to all inline MessageWithUser/DmMessageWithUser constructions - Add embeds: [] to chatStore optimistic message --- packages/server/src/routes/dm.ts | 28 ++++++++++++++++-- packages/server/src/routes/messages.ts | 26 +++++++++++++++-- packages/server/src/routes/search.ts | 15 +++++++--- packages/server/src/utils/embedClassifier.ts | 6 ++-- packages/server/src/ws/events.ts | 30 +++++++++++++++++++- packages/web/src/stores/chatStore.ts | 1 + 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index c3995fd9..2b3226c0 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -16,9 +16,11 @@ import { type PaginatedQuery, type Attachment, type Reaction, + type Embed, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { fetchDmEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; /** * Batch-fetch reactions for a set of DM message IDs. @@ -67,6 +69,7 @@ export function buildDmMessageWithUser( attachmentRows: (typeof schema.attachments.$inferSelect)[], reactions: Reaction[] = [], replyTo: DmMessageWithUser | null = null, + embedRows: (typeof schema.embeds.$inferSelect)[] = [], ): DmMessageWithUser { return { id: message.id, @@ -87,6 +90,7 @@ export function buildDmMessageWithUser( thumbnailFilename: a.thumbnailFilename ?? null, createdAt: a.createdAt, })), + embeds: embedRows.map(e => embedRowToEmbed(e)), reactions, replyTo, }; @@ -108,6 +112,11 @@ export function getDmMessageWithUser(dmMessageId: string): DmMessageWithUser | n .where(eq(schema.attachments.dmMessageId, dmMessageId)) .all(); + const embedRows = db.select() + .from(schema.embeds) + .where(eq(schema.embeds.dmMessageId, dmMessageId)) + .all(); + const reactionsMap = fetchDmReactionsForMessages([dmMessageId]); const reactions = reactionsMap.get(dmMessageId) ?? []; @@ -127,13 +136,14 @@ export function getDmMessageWithUser(dmMessageId: string): DmMessageWithUser | n createdAt: replyMsg.createdAt, user: sanitizeUser(replyUser), attachments: [], + embeds: [], reactions: [], }; } } } - return buildDmMessageWithUser(message, user, attachmentRows, reactions, replyTo); + return buildDmMessageWithUser(message, user, attachmentRows, reactions, replyTo, embedRows); } /** @@ -830,6 +840,9 @@ export async function dmRoutes(app: FastifyInstance): Promise { // Batch fetch reactions const reactionsMap = fetchDmReactionsForMessages(messageIds); + // Batch fetch embeds + const embedMap = fetchDmEmbedsForMessages(messageIds); + // Batch fetch reply-to messages const replyToIds = messageRows .map(m => m.replyToId) @@ -860,6 +873,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { createdAt: rm.createdAt, user: sanitizeUser(rUser), attachments: [], + embeds: [], reactions: [], }); } @@ -871,7 +885,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is DmMessageWithUser => m !== null); @@ -953,6 +967,11 @@ export async function dmRoutes(app: FastifyInstance): Promise { // Broadcast to all DM members (including those who closed the channel) broadcastDmMessage(id, message); + // Resolve embeds asynchronously after responding + setImmediate(() => { + resolveEmbeds(messageId, content?.trim() || null, id, true, null).catch(() => {}); + }); + return reply.code(201).send(message); }); @@ -1006,6 +1025,11 @@ export async function dmRoutes(app: FastifyInstance): Promise { }); } + // Re-resolve embeds asynchronously after responding + setImmediate(() => { + reResolveEmbeds(id, content.trim(), msg.dmChannelId, true, null).catch(() => {}); + }); + return reply.code(200).send(updated); }); diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index bba922d1..6d1a2485 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -12,9 +12,11 @@ import { type PaginatedQuery, type MessageWithUser, type Reaction, + type Embed, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; /** * Fetch reactions for a set of message IDs. @@ -114,6 +116,7 @@ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSel size: a.size, createdAt: a.createdAt, })), + embeds: [], reactions: [], replyTo: null, }); @@ -127,6 +130,7 @@ export function buildMessageWithUser( attachmentRows: (typeof schema.attachments.$inferSelect)[], reactions: Reaction[] = [], replyTo: MessageWithUser | null = null, + embedRows: (typeof schema.embeds.$inferSelect)[] = [], ): MessageWithUser { return { id: message.id, @@ -147,6 +151,7 @@ export function buildMessageWithUser( thumbnailFilename: a.thumbnailFilename ?? null, createdAt: a.createdAt, })), + embeds: embedRows.map(e => embedRowToEmbed(e)), reactions, replyTo, }; @@ -224,6 +229,9 @@ export async function messageRoutes(app: FastifyInstance): Promise { // Batch fetch reactions for all messages const reactionsMap = fetchReactionsForMessages(messageIds); + // Batch fetch embeds for all messages + const embedMap = fetchEmbedsForMessages(messageIds); + // Batch fetch reply-to messages const replyToMap = fetchReplyToMessages(messageRows); @@ -233,7 +241,7 @@ export async function messageRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is MessageWithUser => m !== null); @@ -348,6 +356,11 @@ export async function messageRoutes(app: FastifyInstance): Promise { message: messageWithUser, }); + // Resolve embeds asynchronously after responding + setImmediate(() => { + resolveEmbeds(messageId, content?.trim() || null, id, false, spaceId).catch(() => {}); + }); + return reply.code(201).send(messageWithUser); }); @@ -397,16 +410,18 @@ export async function messageRoutes(app: FastifyInstance): Promise { .where(eq(schema.attachments.messageId, id)) .all(); - // Hydrate reactions and reply-to + // Hydrate reactions, embeds, and reply-to const reactionsMap = fetchReactionsForMessages([id]); const reactions = reactionsMap.get(id) ?? []; + const embedMap = fetchEmbedsForMessages([id]); + const embedRows = embedMap.get(id) ?? []; let replyTo: MessageWithUser | null = null; if (updatedMessage.replyToId) { const replyToMap = fetchReplyToMessages([updatedMessage]); replyTo = replyToMap.get(updatedMessage.replyToId) ?? null; } - const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo); + const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo, embedRows); // Broadcast edit const spaceId = getChannelSpaceId(message.channelId); @@ -415,6 +430,11 @@ export async function messageRoutes(app: FastifyInstance): Promise { type: 'message_updated', message: messageWithUser, }); + + // Re-resolve embeds asynchronously after responding + setImmediate(() => { + reResolveEmbeds(id, content.trim(), message.channelId, false, spaceId).catch(() => {}); + }); } return reply.code(200).send(messageWithUser); diff --git a/packages/server/src/routes/search.ts b/packages/server/src/routes/search.ts index 475fd3eb..3d678bf1 100644 --- a/packages/server/src/routes/search.ts +++ b/packages/server/src/routes/search.ts @@ -7,6 +7,7 @@ import { fetchReactionsForMessages, fetchReplyToMessages, buildMessageWithUser } import { fetchDmReactionsForMessages, buildDmMessageWithUser } from './dm.js'; import { sanitizeUser } from '../utils/sanitize.js'; import type { MessageWithUser, DmMessageWithUser } from '@backspace/shared'; +import { fetchEmbedsForMessages, fetchDmEmbedsForMessages } from '../utils/embedResolver.js'; interface SearchQuery { q?: string; @@ -143,6 +144,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { } const reactionsMap = fetchReactionsForMessages(messageIds); + const embedMap = fetchEmbedsForMessages(messageIds); const replyToMap = fetchReplyToMessages(messageRows); const results: MessageWithUser[] = messageRows @@ -151,7 +153,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is MessageWithUser => m !== null); @@ -269,6 +271,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { } const reactionsMap = fetchDmReactionsForMessages(messageIds); + const embedMap = fetchDmEmbedsForMessages(messageIds); // Fetch reply-to messages for DMs const replyToIds = messageRows @@ -299,6 +302,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { createdAt: rm.createdAt, user: sanitizeUser(rUser), attachments: [], + embeds: [], reactions: [], }); } @@ -310,7 +314,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is DmMessageWithUser => m !== null); @@ -405,6 +409,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { } const reactionsMap = fetchReactionsForMessages(msgIds); + const embedMap = fetchEmbedsForMessages(msgIds); const replyToMap = fetchReplyToMessages(uniqueRows); const messages: MessageWithUser[] = uniqueRows @@ -413,7 +418,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is MessageWithUser => m !== null); @@ -498,6 +503,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { } const reactionsMap = fetchDmReactionsForMessages(msgIds); + const embedMap = fetchDmEmbedsForMessages(msgIds); const replyToIds = uniqueRows .map(m => m.replyToId) @@ -527,6 +533,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { createdAt: rm.createdAt, user: sanitizeUser(rUser), attachments: [], + embeds: [], reactions: [], }); } @@ -538,7 +545,7 @@ export async function searchRoutes(app: FastifyInstance): Promise { if (!user) return null; const reactions = reactionsMap.get(m.id) ?? []; const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null; - return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo); + return buildDmMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo, embedMap.get(m.id) ?? []); }) .filter((m): m is DmMessageWithUser => m !== null); diff --git a/packages/server/src/utils/embedClassifier.ts b/packages/server/src/utils/embedClassifier.ts index 65d845f0..fefb25c2 100644 --- a/packages/server/src/utils/embedClassifier.ts +++ b/packages/server/src/utils/embedClassifier.ts @@ -25,15 +25,15 @@ function extractYouTubeId(url: URL): string | null { // /shorts/ID const shortsMatch = path.match(/^\/shorts\/([A-Za-z0-9_-]+)/); - if (shortsMatch) return shortsMatch[1]; + if (shortsMatch) return shortsMatch[1] ?? null; // /embed/ID const embedMatch = path.match(/^\/embed\/([A-Za-z0-9_-]+)/); - if (embedMatch) return embedMatch[1]; + if (embedMatch) return embedMatch[1] ?? null; // /v/ID (legacy) const vMatch = path.match(/^\/v\/([A-Za-z0-9_-]+)/); - if (vMatch) return vMatch[1]; + if (vMatch) return vMatch[1] ?? null; } return null; diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 08422344..b9b37348 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -5,9 +5,10 @@ import { connectionManager } from './handler.js'; import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js'; import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js'; import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js'; -import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared'; +import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser, type Embed } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; +import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; /** * Re-evaluate SPEAK permission for all participants in voice channels @@ -76,6 +77,11 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { createdAt: r.createdAt, })); + const embedRows = db.select() + .from(schema.embeds) + .where(eq(schema.embeds.messageId, messageId)) + .all(); + let replyTo: MessageWithUser | null = null; if (message.replyToId) { // Simple fetch for replyTo (one level deep to avoid recursion loops) @@ -93,6 +99,7 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { createdAt: replyMsg.createdAt, user: sanitizeUser(replyUser), attachments: [], // Don't fetch attachments for replies to save bandwidth + embeds: [], reactions: [], // Don't fetch reactions for replies }; } @@ -109,6 +116,7 @@ function getMessageWithUser(messageId: string): MessageWithUser | null { createdAt: message.createdAt, user: sanitizeUser(user), attachments, + embeds: embedRows.map(e => embedRowToEmbed(e)), reactions, replyTo, }; @@ -257,6 +265,11 @@ function handleMessageCreate(event: Record, userId: string): vo type: 'message_created', message: messageWithUser, }); + + // Resolve embeds asynchronously + setImmediate(() => { + resolveEmbeds(messageId, content.trim(), channelId, false, spaceId).catch(() => {}); + }); } } @@ -306,6 +319,11 @@ function handleMessageEdit(event: Record, userId: string): void type: 'message_updated', message: updatedMessage, }); + + // Re-resolve embeds asynchronously + setImmediate(() => { + reResolveEmbeds(messageId, content.trim(), message.channelId, false, spaceId).catch(() => {}); + }); } } @@ -763,6 +781,11 @@ function handleDmMessageCreate(event: Record, userId: string): // Broadcast to all DM members (including those who closed the channel) broadcastDmMessage(dmChannelId, dmMessage); + + // Resolve embeds asynchronously + setImmediate(() => { + resolveEmbeds(messageId, hasContent ? content!.trim() : null, dmChannelId, true, null).catch(() => {}); + }); } function handleDmTypingStart(event: Record, userId: string, username: string): void { @@ -853,6 +876,11 @@ function handleDmMessageEdit(event: Record, userId: string): vo message: updated, }); } + + // Re-resolve embeds asynchronously + setImmediate(() => { + reResolveEmbeds(messageId, content.trim(), msg.dmChannelId, true, null).catch(() => {}); + }); } function handleDmMessageDelete(event: Record, userId: string): void { diff --git a/packages/web/src/stores/chatStore.ts b/packages/web/src/stores/chatStore.ts index 3ab653c2..15697a3e 100644 --- a/packages/web/src/stores/chatStore.ts +++ b/packages/web/src/stores/chatStore.ts @@ -267,6 +267,7 @@ export const useChatStore = create((set, get) => ({ createdAt: Date.now(), user: currentUser, attachments: [], + embeds: [], reactions: [], replyTo: get().replyTo ?? undefined, };