Files
cobble/src/harness/media/read-media.ts
T
tben d3357cb7df Stop storing raw file bytes in media.text_content
`text_content` is an extracted-text cache, but nothing stopped it from
holding a dump of bytes:

- `fileToText` ended its switch with `default: return buffer.toString("utf-8")`
  and defaulted a missing extension to "txt", so any binary was decoded.
- Both extraction callers gated on `media_type == "document"`, and
  `attachmentFileKind` classes everything non-image/audio/video as a
  document: an SVG, a zip, a spreadsheet, a .bin.
- Nothing capped the length, so a 20 MB attachment could leave 20 MB of
  text in the database.
- `storeChatAttachments` copied the text onto a second `media` row, and
  `SELECT m.*` pulled the column into page props and API responses.

On a real install an SVG held 6852 characters of markup and a
`.designcraft` file held 142 KB of raw bytes.

Changes:

- `file-to-text.ts` lists the text extensions explicitly and returns
  undefined for anything else; no extension is no text.
- `can-extract-text.ts` is the one allowlist (reusing
  `BotTextFileExtensions`), used by both upload routes and by
  `uploadAndRecordMedia`; `extension-of.ts` is shared with the storage
  catalog instead of duplicated.
- `BotAttachmentTextStoreMaxChars` (200k) caps the column via
  `cap-text-content.ts`.
- `store-chat-attachments.ts` no longer copies the text; ingestion
  re-reads the bytes once.
- `media-columns.ts` selects every media column but `text_content`, which
  now has to be asked for (`grab-messages-media`'s reply path and
  ingestion do); the media CRUD routes sanitize rows they write.
- `ingest-bot-file.ts` decides on size alone, since a cached slice made
  an oversized file look already read.
- `bun run media:prune-text` cleans rows that were already stored
  (rerunnable, with a VACUUM hint).

Verified on the dev database: both bad rows cleared (146 KB), rerun
reports nothing to do.
2026-10-23 05:22:04 +01:00

110 lines
3.2 KiB
TypeScript

import { BotTextFileExtensions } from "@/src/dict/bot-file-dict";
import fileToText from "@/src/functions/backend/db/users/media/file-to-text";
import AppError from "@/src/utils/app-error";
import transcribeAudio from "../audio/transcribe-audio";
import readImage from "./read-image";
type Params = {
data: Uint8Array;
/**
* e.g. `image/png`, `audio/webm`, `application/pdf`.
*/
media_type: string;
/**
* File name, used for its extension when the type is vague.
*/
name?: string;
signal?: AbortSignal;
/**
* Reads with this bot's models when it has its own.
*/
bot_id?: number | string;
/**
* The workspace the call is in: its models win over the bot's.
*/
workspace_id?: number | string | null;
};
/**
* One list, the same one the local extractor reads: keeping a second copy
* here is how a type got read by one and silently missed by the other.
*/
const DocumentExtensions: readonly string[] = BotTextFileExtensions;
const TypeExtensions: Record<string, string> = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"docx",
"text/plain": "txt",
"text/markdown": "md",
"text/csv": "csv",
"application/json": "json",
};
/**
* Reads any attachment into text for the text harness: documents are
* extracted on this machine, images go to the media reading harness and
* audio to the transcription harness.
*/
export default async function readMedia({
data,
media_type,
name,
signal,
bot_id,
workspace_id,
}: Params): Promise<string> {
const type = media_type.toLowerCase().split(";")[0]!.trim();
const ext = (
name?.includes(".")
? name.split(".").pop()
: TypeExtensions[type] || type.split("/").pop()
)?.toLowerCase() || "";
if (type.startsWith("image/")) {
return await readImage({
url: `data:${type};base64,${Buffer.from(data).toString("base64")}`,
signal,
bot_id,
workspace_id,
});
}
if (type.startsWith("audio/")) {
return await transcribeAudio({
audio: { data, format: audioFormat({ type, ext }) },
signal,
bot_id,
workspace_id,
});
}
if (type.startsWith("text/") || DocumentExtensions.includes(ext)) {
const text = await fileToText({
file_stream: Buffer.from(data),
file_type: ext,
});
if (!text?.trim()) {
throw new AppError(
ext == "pdf"
? `This PDF has no text in it (it may be a scan). Send its pages as images instead.`
: `This file has no text in it.`,
);
}
return text;
}
throw new AppError(`Bots can't read ${ext ? `.${ext}` : "this kind of"} files yet.`);
}
function audioFormat({ type, ext }: { type: string; ext: string }) {
if (type.includes("mpeg") || ext == "mp3") return "mp3";
if (type.includes("mp4") || ext == "m4a") return "m4a";
if (type.includes("wav")) return "wav";
if (type.includes("ogg")) return "ogg";
if (type.includes("flac")) return "flac";
return ext || "webm";
}