diff --git a/package.json b/package.json index 026fa78..09d45b4 100644 --- a/package.json +++ b/package.json @@ -18,20 +18,20 @@ "@distube/yt-dlp": "^2.0.1", "@google/generative-ai": "^0.24.1", "@langchain/google-genai": "^1.0.3", - "bufferutil": "^4.0.9", + "bufferutil": "^4.1.0", "chartjs-node-canvas": "^5.0.0", - "discord.js": "^14.22.1", - "distube": "^5.0.7", + "discord.js": "^14.26.4", + "distube": "^5.2.3", "dotenv": "^16.6.1", - "ffmpeg-static": "^5.2.0", - "js-yaml": "^4.1.0", - "libsodium-wrappers": "^0.7.15", - "mongoose": "^9.0.0", + "ffmpeg-static": "^5.3.0", + "js-yaml": "^4.1.1", + "libsodium-wrappers": "^0.7.16", + "mongoose": "^9.6.2", "ms": "^2.1.3", "opusscript": "^0.1.1", "sodium": "^3.0.2", "sodium-native": "^4.3.3", - "utf-8-validate": "^6.0.5", + "utf-8-validate": "^6.0.6", "zlib-sync": "^0.1.10" }, "devDependencies": { diff --git a/src/commands/users/daily.ts b/src/commands/users/daily.ts index 2c54c4d..2b5b387 100644 --- a/src/commands/users/daily.ts +++ b/src/commands/users/daily.ts @@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand"; import BotClient from "../../libs/BotClient"; import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js"; import QuestionScheduler from "../../libs/QuestionScheduler"; -import AnswerGrader from "../../libs/AnswerGrader"; const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"]; @@ -45,12 +44,12 @@ export default class DailyCommand extends BotCommand { return interaction.editReply({ content: "You've already submitted an answer for today's question! Check back tomorrow." }); } - const imagePath = await client.typst.renderToImage(question.typst_source, { + const imageBuffer = await client.typst.renderToImage(question.typst_source, { topic: question.topic, difficulty: question.difficulty_rating }); - - if (!imagePath) { + + if (!imageBuffer) { // Log error to logs channel const logsChannelId = process.env.LOGS_CHANNEL_ID; if (logsChannelId) { @@ -89,7 +88,7 @@ export default class DailyCommand extends BotCommand { .setFooter({ text: `Resets at 12 AM local time` }) .setTimestamp(); - const attachment = new AttachmentBuilder(imagePath, { name: `daily_${safeSubject}.png` }); + const attachment = new AttachmentBuilder(imageBuffer, { name: `daily_${safeSubject}.png` }); const row = new ActionRowBuilder() .addComponents( diff --git a/src/commands/users/weekly.ts b/src/commands/users/weekly.ts index 6d3d6ee..f86dd90 100644 --- a/src/commands/users/weekly.ts +++ b/src/commands/users/weekly.ts @@ -2,7 +2,6 @@ import BotCommand from "../../libs/BotCommand"; import BotClient from "../../libs/BotClient"; import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js"; import QuestionScheduler from "../../libs/QuestionScheduler"; -import AnswerGrader from "../../libs/AnswerGrader"; const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"]; @@ -45,12 +44,12 @@ export default class WeeklyCommand extends BotCommand { return interaction.editReply({ content: "You've already submitted an answer for this week's question! Check back next Sunday." }); } - const imagePath = await client.typst.renderToImage(question.typst_source, { + const imageBuffer = await client.typst.renderToImage(question.typst_source, { topic: question.topic, difficulty: question.difficulty_rating }); - - if (!imagePath) { + + if (!imageBuffer) { // Log error to logs channel const logsChannelId = process.env.LOGS_CHANNEL_ID; if (logsChannelId) { @@ -89,7 +88,7 @@ export default class WeeklyCommand extends BotCommand { .setFooter({ text: `Resets at 12 AM Sunday` }) .setTimestamp(); - const attachment = new AttachmentBuilder(imagePath, { name: `weekly_${safeSubject}.png` }); + const attachment = new AttachmentBuilder(imageBuffer, { name: `weekly_${safeSubject}.png` }); const row = new ActionRowBuilder() .addComponents( diff --git a/src/events/bot/client/clientReady.ts b/src/events/bot/client/clientReady.ts index 764d5e7..1c3cd5c 100644 --- a/src/events/bot/client/clientReady.ts +++ b/src/events/bot/client/clientReady.ts @@ -11,14 +11,6 @@ export default async(Discord: any, client: BotClient) => { client.emit("birthdayCheck"); }, ms('30m')); - // Clean up old typst files daily - setInterval(() => { - client.emit("typstCleanup"); - }, ms('24h')); - - // Run cleanup once on startup - client.emit("typstCleanup"); - } catch (err) { console.log(err); } finally { diff --git a/src/events/bot/custom/typstCleanup.ts b/src/events/bot/custom/typstCleanup.ts index c0cf4ec..604c41f 100644 --- a/src/events/bot/custom/typstCleanup.ts +++ b/src/events/bot/custom/typstCleanup.ts @@ -1,20 +1 @@ -import BotClient from "../../../libs/BotClient"; - -export default async(Discord: any, client: BotClient) => { - console.log("[TypstCleanup] Running cleanup for old typst files..."); - - // Delete files older than 1 day (after the question day passes) - const result = await client.typst.cleanupOldFiles(1); - - if (result.deleted > 0) { - console.log(`[TypstCleanup] ✅ Successfully cleaned up ${result.deleted} old typst file(s)`); - } - - if (result.errors > 0) { - console.error(`[TypstCleanup] ⚠️ Encountered ${result.errors} error(s) during cleanup`); - } - - if (result.deleted === 0 && result.errors === 0) { - console.log("[TypstCleanup] No old files to clean up"); - } -} +export default async() => {}; diff --git a/src/libs/Typst.ts b/src/libs/Typst.ts index a75da95..6c61fef 100644 --- a/src/libs/Typst.ts +++ b/src/libs/Typst.ts @@ -1,12 +1,11 @@ -import fs from 'fs'; -import path from 'path'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; - -const execFileAsync = promisify(execFile); +// Default page settings prepended to all compile() calls so the API never falls back to A4. +// Users can override with their own #set page() — Typst's last rule wins. +const DEFAULT_PAGE = `#set page(width: auto, height: auto, margin: 8pt, fill: white)\n`; export default class Typst { messages: Map; + private readonly apiUrl = "https://typ.sirblob.co/v1/render"; + constructor() { this.messages = new Map(); } @@ -23,76 +22,36 @@ export default class Typst { return null; } - async compile(code: string, options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string; files?: string[]; typPath?: string }> { - const storageDir = path.resolve('./storage/typst'); - await fs.promises.mkdir(storageDir, { recursive: true }).catch(() => {}); - - const uid = `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; - const baseName = `typ_${uid}`; - const typPath = path.join(storageDir, `${baseName}.typ`); - const pngTemplate = path.join(storageDir, `${baseName}{p}.png`); - const pngSingle = path.join(storageDir, `${baseName}.png`); - const cleanup = options?.cleanup !== undefined ? options!.cleanup : true; - - try { - await fs.promises.writeFile(typPath, code, 'utf8'); - } catch (writeErr: any) { - return { error: `Failed to write typ file: ${String(writeErr)}` }; - } - - let compiledToTemplate = false; - try { - await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngTemplate], { timeout: 30000 }); - compiledToTemplate = true; - } catch (multiErr: any) { - try { - await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngSingle], { timeout: 30000 }); - compiledToTemplate = false; - } catch (singleErr: any) { - if (cleanup) await fs.promises.unlink(typPath).catch(() => {}); - const msg = (multiErr && (multiErr.stderr || multiErr.message)) || (singleErr && (singleErr.stderr || singleErr.message)) || 'Unknown typst error'; - return { error: `Typst compilation failed: ${String(msg)}` }; - } - } - - let pngFiles: string[] = []; - try { - const all = await fs.promises.readdir(storageDir); - if (compiledToTemplate) pngFiles = all.filter(f => f.startsWith(baseName) && f.endsWith('.png')).sort(); - else if (all.includes(`${baseName}.png`)) pngFiles = [`${baseName}.png`]; - } catch (readErr: any) { - if (cleanup) await fs.promises.unlink(typPath).catch(() => {}); - return { error: `Failed to read output PNG files: ${String(readErr)}` }; - } - - if (pngFiles.length === 0) { - await fs.promises.unlink(typPath).catch(() => {}); - return { error: 'Compilation finished but no PNG files were produced' }; - } - - const fullPaths = pngFiles.map(f => path.join(storageDir, f)); - const buffers: Buffer[] = await Promise.all(fullPaths.map(async p => { - try { return await fs.promises.readFile(p); } catch (e) { return null as any; } - })); - - const validBuffers = buffers.filter(Boolean) as Buffer[]; - - if (cleanup) { - await fs.promises.unlink(typPath).catch(() => {}); - await Promise.all(fullPaths.map(f => fs.promises.unlink(f).catch(() => {}))); - } - - if (validBuffers.length === 0) return { error: 'No PNG buffers could be read from generated output' }; - - const result: { buffers?: Buffer[]; files?: string[]; typPath?: string } = { buffers: validBuffers }; - if (!cleanup) { - result.files = fullPaths; - result.typPath = typPath; - } - return result; + private get apiKey(): string { + const key = process.env.TYPSTDRIVE_API_KEY; + if (!key) throw new Error("TYPSTDRIVE_API_KEY environment variable is missing."); + return key; } - async renderToImage(typstCode: string, metadata?: { topic?: string; difficulty?: string }): Promise { + async compile(code: string, _options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string }> { + try { + const response = await fetch(this.apiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ code: DEFAULT_PAGE + code, format: "png" }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + return { error: `TypstDrive API error ${response.status}: ${text}` }; + } + + const arrayBuffer = await response.arrayBuffer(); + return { buffers: [Buffer.from(arrayBuffer)] }; + } catch (error: any) { + return { error: `TypstDrive request failed: ${error?.message ?? String(error)}` }; + } + } + + async renderToImage(typstCode: string, metadata?: { topic?: string; difficulty?: string }): Promise { const metadataHeader = metadata ? ` #align(center)[ #block( @@ -100,7 +59,7 @@ export default class Typst { inset: 10pt, radius: 4pt, [ - #text(weight: "bold", size: 14pt)[Topic: ${metadata.topic || "N/A"}] \ + #text(weight: "bold", size: 14pt)[Topic: ${metadata.topic || "N/A"}] \\ #text(size: 12pt, fill: rgb("#64748b"))[Difficulty: ${metadata.difficulty || "N/A"}] ] ) @@ -109,8 +68,10 @@ export default class Typst { #v(0.8em) ` : ''; + // 500pt wide keeps equations readable; height: auto sizes to content. + // This #set page overrides the DEFAULT_PAGE prepended in compile(). const styledCode = ` -#set page(width: 210mm, height: auto, margin: (x: 20pt, y: 25pt), fill: white) +#set page(width: 500pt, height: auto, margin: 12pt, fill: white) #set text(size: 16pt, font: "New Computer Modern") #set par(justify: true, leading: 0.65em) #set block(spacing: 1.2em) @@ -118,58 +79,12 @@ export default class Typst { ${metadataHeader}${typstCode} `; - const result = await this.compile(styledCode, { cleanup: false }); - - if (result.error || !result.files || result.files.length === 0) { + const result = await this.compile(styledCode); + if (result.error || !result.buffers || result.buffers.length === 0) { console.error("Typst render error:", result.error); return null; } - return result.files[0]; + return result.buffers[0]; } - - async cleanupOldFiles(daysOld: number = 1): Promise<{ deleted: number; errors: number }> { - const storageDir = path.resolve('./storage/typst'); - let deleted = 0; - let errors = 0; - - try { - await fs.promises.access(storageDir); - } catch { - console.log('[Typst] Storage directory does not exist, nothing to clean'); - return { deleted: 0, errors: 0 }; - } - - try { - const files = await fs.promises.readdir(storageDir); - const now = Date.now(); - const threshold = daysOld * 24 * 60 * 60 * 1000; // Convert days to milliseconds - - for (const file of files) { - const filePath = path.join(storageDir, file); - - try { - const stats = await fs.promises.stat(filePath); - const fileAge = now - stats.mtimeMs; - - if (fileAge > threshold) { - await fs.promises.unlink(filePath); - deleted++; - console.log(`[Typst] Deleted old file: ${file}`); - } - } catch (err) { - console.error(`[Typst] Error processing file ${file}:`, err); - errors++; - } - } - - console.log(`[Typst] Cleanup complete: ${deleted} files deleted, ${errors} errors`); - } catch (err) { - console.error('[Typst] Error during cleanup:', err); - errors++; - } - - return { deleted, errors }; - } - -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 9d12bf8..b821f83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "target": "es2016", "module": "commonjs", - "outDir": "./dist", + "rootDir": "./src", + "outDir": "./dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true,