Add bots files handling logic

This commit is contained in:
2026-10-21 17:56:09 +01:00
parent b0af4db1bf
commit d17a4a7bdd
97 changed files with 5843 additions and 41 deletions
+185
View File
@@ -0,0 +1,185 @@
import { z } from "zod";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
import enqueueFileIngest from "@/src/file-ingest/enqueue-file-ingest";
import recordBotFile from "@/src/functions/backend/db/bot-files/record-bot-file";
import type { HarnessTool } from "../../types";
import {
describeScope,
fileRef,
findNamedScope,
formatBytes,
type StorageToolContext,
} from "./storage-tool-context";
type Args = {
path: string;
title?: string;
content: string;
attach?: boolean;
scope?: string;
};
const PageWidth = 595.28;
const PageHeight = 841.89;
const Margin = 56;
const BodySize = 11;
const Leading = 15;
/**
* Turns text into a PDF (headings, paragraphs and bullets) and saves it in
* the bot's files. Made in the app with `pdf-lib`, so it needs no
* computer.
*/
export default function makePdfTool(context: StorageToolContext): HarnessTool<Args> {
const own = context.scopes[0];
return {
name: "make_pdf",
description: `Make a PDF from text (markdown: "# " headings, "- " bullets, blank lines between paragraphs) and save it in your files. Send it to the user with "attach".`,
schema: z.object({
path: z.string().min(1).max(300).describe(`e.g. "reports/summary.pdf"`),
title: z.string().max(200).optional(),
content: z.string().min(1).max(100_000),
attach: z.boolean().optional(),
scope: z.string().optional(),
}),
run: async ({ args }) => {
const named = args.scope
? findNamedScope({ scopes: context.scopes, name: args.scope })
: own;
if (!named) {
return `You can't save there. Your directories: ${context.scopes
.map((s) => `${s.scope}:${s.owner_id}`)
.join(", ")}.`;
}
const bytes = await renderPdf({ title: args.title, content: args.content });
const path = args.path.toLowerCase().endsWith(".pdf") ? args.path : `${args.path}.pdf`;
const file = await recordBotFile({
bot_id: context.bot_id,
scope: named.scope,
owner_id: named.owner_id,
path,
data: bytes,
purpose: "pdf",
source: "tool",
mime_type: "application/pdf",
user_id: context.user_id,
});
await enqueueFileIngest({ file_id: file.id! });
if (args.attach && file.media_id) {
context.onAttach?.({ media_id: Number(file.media_id), name: file.name || "" });
}
return `Made ${file.path} (${formatBytes({ bytes: bytes.byteLength })}) in ${describeScope(named)}. ${fileRef({ file_id: file.id! })}`;
},
};
}
/**
* Lays the text out on A4 pages. Fonts are the PDF standard ones, so
* nothing has to be embedded.
*/
async function renderPdf({ title, content }: { title?: string; content: string }) {
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
let page = pdf.addPage([PageWidth, PageHeight]);
let y = PageHeight - Margin;
const newPage = () => {
page = pdf.addPage([PageWidth, PageHeight]);
y = PageHeight - Margin;
};
const write = ({
text,
size,
use_bold,
indent = 0,
spacing = 0,
}: {
text: string;
size: number;
use_bold?: boolean;
indent?: number;
spacing?: number;
}) => {
const used = use_bold ? bold : font;
const width = PageWidth - Margin * 2 - indent;
const words = text.split(/\s+/);
let line = "";
const emit = (value: string) => {
if (y - size < Margin) newPage();
page.drawText(value, {
x: Margin + indent,
y: y - size,
size,
font: used,
color: rgb(0.09, 0.09, 0.11),
});
y -= size + 4;
};
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (used.widthOfTextAtSize(candidate, size) > width && line) {
emit(line);
line = word;
} else {
line = candidate;
}
}
if (line) emit(line);
y -= spacing;
};
if (title) {
write({ text: title, size: 20, use_bold: true, spacing: 10 });
}
for (const block of content.replace(/\r\n/g, "\n").split(/\n{2,}/)) {
const trimmed = block.trim();
if (!trimmed) continue;
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
const level = heading[1]!.length;
write({
text: heading[2]!,
size: level == 1 ? 16 : level == 2 ? 14 : 12,
use_bold: true,
spacing: 8,
});
continue;
}
for (const line of trimmed.split("\n")) {
const bullet = line.match(/^\s*[-*+]\s+(.*)$/);
if (bullet) {
write({ text: `- ${bullet[1]}`, size: BodySize, indent: 12 });
} else {
write({ text: line.trim(), size: BodySize });
}
}
y -= Leading / 2;
}
return new Uint8Array(await pdf.save());
}