import { createHash } from "node:crypto"; import type { ArtifactStore } from "@rakazo/adapter-kit"; import type { Actor } from "@rakazo/contracts"; import { ATTACHMENT_MAX_COUNT } from "@rakazo/contracts"; import { AttachmentValidationError, decodeAttachmentBase64, messageBlockForArtifact, promptTextForAttachments, validateAttachmentMimeType, } from "@rakazo/core"; import { IsolationError, type PrismaClient } from "@rakazo/db"; /** * Base64 reads load the whole file into memory; larger media is served by * GET /api/artifacts/:id instead. */ export const ARTIFACT_INLINE_READ_MAX_BYTES = 25 * 1024 * 1024; function adapterContext(actor: Actor, botId: string, operationId: string) { return { operationId, traceId: operationId, spaceId: actor.spaceId, userId: actor.userId, botId, signal: new AbortController().signal, }; } export async function createOwnedArtifact( deps: { prisma: PrismaClient; artifacts: ArtifactStore; }, actor: Actor, input: { botId: string; groupId?: string; name: string; mimeType: string; contentBase64: string; }, ) { validateAttachmentMimeType(input.mimeType); const bytes = decodeAttachmentBase64(input.contentBase64); const context = adapterContext(actor, input.botId, `artifact-create:${input.botId}`); const stored = await deps.artifacts.put( { name: input.name, mimeType: input.mimeType, bytes }, context, ); const hash = createHash("sha256").update(bytes).digest("hex"); const row = await deps.prisma.artifact .create({ data: { spaceId: actor.spaceId, userId: actor.userId, botId: input.botId, groupId: input.groupId, name: input.name, mimeType: input.mimeType, size: bytes.byteLength, hash, storageKey: stored.id, }, }) .catch(async (error) => { await deps.artifacts.remove(stored.id, context).catch(() => undefined); throw error; }); return { id: row.id, botId: row.botId, groupId: row.groupId, runId: row.runId, name: row.name, mimeType: row.mimeType, size: row.size, createdAt: row.createdAt.toISOString(), }; } export async function getOwnedArtifact( deps: { prisma: PrismaClient; artifacts: ArtifactStore; }, actor: Actor, input: { botId: string; artifactId: string }, ) { const row = await deps.prisma.artifact.findFirst({ where: { id: input.artifactId, botId: input.botId, groupId: null, spaceId: actor.spaceId, userId: actor.userId, }, }); if (!row) throw new IsolationError(); return readArtifact(deps.artifacts, actor, row, input.botId); } export async function getSpaceArtifact( deps: { prisma: PrismaClient; artifacts: ArtifactStore; }, actor: Actor, input: { artifactId: string; groupId: string; contextBotId: string }, ) { const row = await deps.prisma.artifact.findFirst({ where: { id: input.artifactId, groupId: input.groupId, spaceId: actor.spaceId, userId: actor.userId, }, }); if (!row) throw new IsolationError(); return readArtifact(deps.artifacts, actor, row, input.contextBotId); } async function readArtifact( artifacts: ArtifactStore, actor: Actor, row: { id: string; botId: string | null; groupId: string | null; runId: string | null; storageKey: string; name: string; mimeType: string; size: number; createdAt: Date; }, contextBotId: string, ) { if (row.size > ARTIFACT_INLINE_READ_MAX_BYTES) { throw new AttachmentValidationError("This file is too large to load inline."); } const bytes = await artifacts.get( row.storageKey, adapterContext(actor, contextBotId, `artifact-get:${row.id}`), ); return { id: row.id, botId: row.botId, groupId: row.groupId, runId: row.runId, name: row.name, mimeType: row.mimeType, size: row.size, createdAt: row.createdAt.toISOString(), contentBase64: Buffer.from(bytes).toString("base64"), }; } type SendAttachmentRow = { id: string; name: string; mimeType: string; size: number; storageKey: string; }; function normalizeAttachmentIds(artifactIds: string[] | undefined) { const ids = [...new Set(artifactIds ?? [])]; if (ids.length > ATTACHMENT_MAX_COUNT) { throw new AttachmentValidationError(`At most ${ATTACHMENT_MAX_COUNT} attachments per message`); } return ids; } function toAttachmentResolution(ids: string[], rows: T[]) { if (rows.length !== ids.length) throw new IsolationError(); const byId = new Map(rows.map((row) => [row.id, row])); const artifacts = ids.map((id) => byId.get(id)!); const blocks = artifacts.map((row) => messageBlockForArtifact({ id: row.id, name: row.name, mimeType: row.mimeType, size: row.size, }), ); return { blocks, artifacts }; } export async function resolveSendAttachments( deps: { prisma: Pick }, actor: Actor, botId: string, artifactIds: string[] | undefined, ) { const ids = normalizeAttachmentIds(artifactIds); if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]); const rows = await deps.prisma.artifact.findMany({ where: { id: { in: ids }, botId, groupId: null, spaceId: actor.spaceId, userId: actor.userId, }, }); return toAttachmentResolution(ids, rows); } export async function resolveGroupSendAttachments( deps: { prisma: Pick }, actor: Actor, groupId: string, memberBotIds: string[], artifactIds: string[] | undefined, ) { const ids = normalizeAttachmentIds(artifactIds); if (!ids.length) return toAttachmentResolution(ids, [] as SendAttachmentRow[]); const rows = await deps.prisma.artifact.findMany({ where: { id: { in: ids }, spaceId: actor.spaceId, userId: actor.userId, OR: [ { groupId }, // Accept artifacts uploaded by a current member before group ownership // was persisted. Removing that member revokes this legacy fallback. { groupId: null, botId: { in: memberBotIds } }, ], }, }); return toAttachmentResolution(ids, rows); } export function buildUserMessageBlocks( text: string | undefined, attachmentBlocks: ReturnType[], ) { const blocks = []; const caption = text?.trim(); if (caption) blocks.push({ kind: "text" as const, text: caption }); blocks.push(...attachmentBlocks); return blocks; } export function buildSendPrompt( text: string | undefined, artifacts: Array<{ name: string; mimeType: string; size: number }>, connectorNames: string[] = [], ) { const prompt = promptTextForAttachments(text, artifacts); if (connectorNames.length === 0) return prompt; const marker = "Use these connectors if relevant:"; const existing = new RegExp(`^${marker} (.*)\\.$`, "m").exec(prompt); const names = [ ...new Set([ ...(existing?.[1] ?.split(",") .map((name) => name.trim()) .filter(Boolean) ?? []), ...connectorNames.map((name) => name.trim()).filter(Boolean), ]), ]; const line = `${marker} ${names.join(", ")}.`; if (existing) return prompt.replace(existing[0], () => line); return prompt ? `${prompt}\n\n${line}` : line; }