Initial Code

This commit is contained in:
2025-11-23 13:22:13 -05:00
parent b16d4adfd2
commit c3e52d6a03
96 changed files with 7088 additions and 135 deletions

87
src/libs/AnswerGrader.ts Normal file
View File

@@ -0,0 +1,87 @@
import { QuestionData } from "./QuestionGenerator";
export interface GradeResult {
comparison_analysis: string;
is_correct: boolean;
score: number;
feedback: string;
}
export default class AnswerGrader {
private ollamaUrl: string;
private ollamaModel: string;
constructor() {
this.ollamaUrl = process.env.OLLAMA_URL || "http://192.168.1.214:11434";
this.ollamaModel = process.env.OLLAMA_MODEL || "llama3.2:latest";
}
async gradeAnswer(questionData: QuestionData, userAnswer: string): Promise<GradeResult> {
const prompt = `
You are a strict academic Teaching Assistant. Your job is to compare a Student's Answer to the Official Reference Key.
--- QUESTION METADATA ---
Subject: ${questionData.subject}
Difficulty: ${questionData.difficulty_rating}
Topic: ${questionData.topic}
--- OFFICIAL REFERENCE ANSWER (TRUTH) ---
${questionData.reference_answer}
--- STUDENT ANSWER ---
"${userAnswer}"
--- INSTRUCTIONS ---
1. Compare the Student Answer to the Reference Answer.
2. Ignore minor formatting differences (e.g., "0.5" vs "1/2", or "joules" vs "J").
3. If the question asks for a calculation, check if the numbers match (allow 1% tolerance).
4. If the question is conceptual, check if the key concepts in the Reference are present in the Student Answer.
5. Provide your response in JSON format with these fields:
- comparison_analysis: string (brief analysis of differences)
- is_correct: boolean
- score: number (0-100)
- feedback: string (constructive feedback for the student)
Output ONLY valid JSON, no markdown formatting.
`;
try {
const response = await fetch(`${this.ollamaUrl}/api/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: this.ollamaModel,
prompt: prompt,
stream: false,
format: "json"
}),
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.statusText}`);
}
const data = await response.json();
const responseText = data.response;
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("No JSON found in response");
}
const grade = JSON.parse(jsonMatch[0]) as GradeResult;
return grade;
} catch (error) {
console.error("Grading error:", error);
return {
comparison_analysis: "Error occurred during grading",
is_correct: false,
score: 0,
feedback: "An error occurred while grading your answer. Please try again.",
};
}
}
}

212
src/libs/BotAI.ts Normal file
View File

@@ -0,0 +1,212 @@
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
import fs from 'fs';
const MODEL = process.env.AI_MODEL || 'gemini-2.5-flash';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_API_KEY) {
throw new Error('GEMINI_API_KEY environment variable is required');
}
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const BASE_PROMPT = fs.readFileSync('./config/prompt.txt', 'utf8');
const SYSTEM_PROMPT = `${BASE_PROMPT}\n\nYou are currently helping {user_name}. The date is {Date} and it's {time} EST. Remember to always output valid JSON.`;
interface ChatEntry {
role: 'user' | 'assistant' | 'system';
content: string;
}
interface Chat {
entries: ChatEntry[];
}
class User {
public id: string;
public name: string;
public chat: Chat;
constructor(id: string, name: string) {
this.id = id;
this.name = name;
this.chat = { entries: [] };
}
addChatEntry(role: 'user' | 'assistant' | 'system', content: string): void {
this.chat.entries.push({ role, content });
}
formatBasePrompt(): string {
return SYSTEM_PROMPT.replace('{user_name}', this.name)
.replace('{Date}', new Date().toLocaleDateString())
.replace('{time}', new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' }));
}
getChatEntries(): ChatEntry[] {
let typstMD: any[] = [];
fs.readdirSync('./config/md').forEach(file => {
if (file.endsWith('.md')) {
const content = fs.readFileSync(`./config/md/${file}`, 'utf8');
typstMD.push({ role: 'system', content });
}
});
let entries: ChatEntry[] = [
{ role: 'system', content: this.formatBasePrompt() },
...typstMD,
...this.chat.entries
];
if (entries.length > 17) this.clearChat(); // Increased to account for additional system message
return entries;
}
loadChat(): void {
const path = `./storage/chats/${this.id}.json`;
try {
if (fs.existsSync(path)) {
const data = fs.readFileSync(path, 'utf8');
this.chat = JSON.parse(data);
}
} catch (error) {
console.error(`Error loading chat for user ${this.id}:`, error);
this.chat = { entries: [] };
}
}
saveChat(): void {
const path = `./storage/chats/${this.id}.json`;
try {
const dir = './storage/chats';
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(path, JSON.stringify(this.chat, null, 2));
} catch (error) {
console.error(`Error saving chat for user ${this.id}:`, error);
}
}
clearChat(): void {
this.chat.entries = [];
}
}
export class BotAI {
private users: Map<string, User>;
constructor() {
this.users = new Map();
this.loadUsers();
}
loadUsers(): void {
const chatsDir = './storage/chats';
try {
if (!fs.existsSync(chatsDir)) {
fs.mkdirSync(chatsDir, { recursive: true });
return;
}
const files = fs.readdirSync(chatsDir);
for (const file of files) {
if (file.endsWith('.json')) {
const userId = file.replace('.json', '');
try {
const user = new User(userId, 'Unknown User');
user.loadChat();
this.users.set(userId, user);
} catch (error) {
console.error(`Error loading user ${userId}:`, error);
}
}
}
console.log(`Loaded ${this.users.size} users from storage`);
} catch (error) {
console.error('Error loading users:', error);
}
}
getUser(id: string): User | undefined {
return this.users.get(id);
}
createUser(id: string, name: string): User {
const user = new User(id, name);
this.users.set(id, user);
return user;
}
async generateText(userId: string, username: string, prompt: string): Promise<{ content: string, typst: string }> {
let user = this.getUser(userId) || this.createUser(userId, username);
user.name = username;
try {
const model = genAI.getGenerativeModel({
model: MODEL,
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: SchemaType.OBJECT,
properties: {
content: { type: SchemaType.STRING },
typst: { type: SchemaType.STRING }
},
required: ["content", "typst"]
}
}
});
// Build system prompt with typst examples
let systemPrompt = user.formatBasePrompt();
// fs.readdirSync('./config/md').forEach(file => {
// if (file.endsWith('.md')) {
// const content = fs.readFileSync(`./config/md/${file}`, 'utf8');
// systemPrompt += '\n\n' + content;
// }
// });
const result = await model.generateContent(systemPrompt + '\n\nUser request: ' + prompt);
const response = await result.response;
const rawText = response.text();
console.log('Raw AI response:', rawText);
let botMessage = rawText;
let parsed: { content?: string, typst?: string } = {};
try {
parsed = JSON.parse(botMessage);
} catch {
botMessage = botMessage
.replace(/"([^"]*)"([^"]*)"([^"]*)"/g, '"$1\\"$2\\"$3"')
.replace(/\n(?=\s*[^"}])/g, '\\n')
.replace(/,(\s*[}\]])/g, '$1')
.replace(/"([^"\\]*(\\.[^"\\]*)*)"?$/g, '"$1"');
try {
parsed = JSON.parse(botMessage);
} catch {
parsed = { content: botMessage, typst: '' };
}
}
user.addChatEntry('assistant', JSON.stringify(parsed));
user.saveChat();
return {
content: typeof parsed.content === 'string' ? parsed.content : botMessage,
typst: typeof parsed.typst === 'string' ? parsed.typst : ''
};
} catch (error) {
console.error('Error generating text:', error);
throw error;
}
}
}

23
src/libs/BotAction.ts Normal file
View File

@@ -0,0 +1,23 @@
import { MessageFlags } from "discord.js";
import BotClient from "./BotClient";
export default class BotAction {
id: string;
enabled: boolean = true;
constructor(id: string, enabled: boolean = true) {
this.id = id;
this.enabled = enabled;
}
getId() {
return this.id;
}
isEnabled() {
return this.enabled;
}
async execute(Discord: any, client: BotClient, interaction: any): Promise<any> {
interaction.reply({ content: "This Action is not Implemented Yet!", flags: MessageFlags.Ephemeral });
}
}

52
src/libs/BotClient.ts Normal file
View File

@@ -0,0 +1,52 @@
import { Client, Collection } from "discord.js";
import Distube from "distube";
import { SpotifyPlugin } from "@distube/spotify";
import { SoundCloudPlugin } from "@distube/soundcloud";
import { YtDlpPlugin } from "@distube/yt-dlp";
import BotCommand from "./BotCommand";
import Logger from "./Logger";
import Formatter from "./Formatter";
import BotAction from "./BotAction";
import Storage from "./Storage";
import { BotAI } from "./BotAI";
import Config from "./Config";
import Typst from "./Typst";
export default class BotClient extends Client {
commands: Collection<string, BotCommand>;
action: Collection<string, BotAction>
logger: Logger;
formatter: Formatter;
distube: any;
storage: Storage;
// ai: BotAI;
config: Config;
typst: Typst;
constructor(props: any) {
super(props);
this.logger = new Logger();
this.formatter = new Formatter();
this.commands = new Collection();
this.action = new Collection();
this.storage = new Storage();
this.config = new Config();
// this.ai = new BotAI();
this.typst = new Typst();
this.distube = new Distube(this, {
nsfw: true,
plugins: [
new SpotifyPlugin({
api: { clientId: process.env.spotID, clientSecret: process.env.spotKey }
}),
new SoundCloudPlugin(),
new YtDlpPlugin()
]
});
}
}

38
src/libs/BotCommand.ts Normal file
View File

@@ -0,0 +1,38 @@
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
import BotClient from "./BotClient";
export default class BotCommand {
name: string
enabled: boolean
use: string
data: SlashCommandBuilder
constructor(name: string, description: string, use: string, enabled: boolean = true) {
this.name = name;
this.use = use;
this.enabled = enabled;
this.data = new SlashCommandBuilder();
this.data.setName(name);
this.data.setDescription(description);
}
getName(): string {
return this.name;
}
getUse(): string {
return this.use;
}
isEnabled(): boolean {
return this.enabled;
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
await interaction.reply({ content: "This Command is not Implemented Yet!", flags: MessageFlags.Ephemeral })
}
}

81
src/libs/Config.ts Normal file
View File

@@ -0,0 +1,81 @@
import fs from 'fs';
interface Role {
id: string;
name: string;
}
interface RolesConfig {
helperroles: Role[];
educationroles: Role[];
languageroles: Role[];
locationroles: Role[];
pingroles: Role[];
}
interface Birthday {
day: number;
month: number;
timezone?: string;
}
interface BirthdaysConfig {
[userId: string]: Birthday;
}
interface ReplyTextConfig {
[key: string]: string;
}
export default class Config {
private roles: RolesConfig | null;
private replyText: ReplyTextConfig | null;
private bdays: BirthdaysConfig | null;
constructor() {
this.roles = null;
this.replyText = null;
this.bdays = null;
this.loadConfig();
}
async loadConfig(): Promise<void> {
let rawdata: Buffer;
// Load the roles.json file
rawdata = fs.readFileSync('./config/roles.json');
this.roles = JSON.parse(rawdata.toString());
rawdata = fs.readFileSync('./config/replytext.json');
this.replyText = JSON.parse(rawdata.toString());
rawdata = fs.readFileSync('./storage/bdays.json');
this.bdays = JSON.parse(rawdata.toString());
}
getRoles(): RolesConfig | null { return this.roles; }
getHelperRoles(): Role[] { return this.roles?.helperroles || []; }
getEducationRoles(): Role[] { return this.roles?.educationroles || []; }
getLanguageRoles(): Role[] { return this.roles?.languageroles || []; }
getLocationRoles(): Role[] { return this.roles?.locationroles || []; }
getPingRoles(): Role[] { return this.roles?.pingroles || []; }
getReplyText(): ReplyTextConfig | null { return this.replyText; }
getBdays(): BirthdaysConfig | null { return this.bdays; }
getBday(userId: string): Birthday | null {
return this.bdays?.[userId] || null;
}
setBday(userId: string, bday: Birthday): void {
if (!this.bdays) this.bdays = {};
this.bdays[userId] = bday;
fs.writeFileSync('./storage/bdays.json', JSON.stringify(this.bdays, null, 2));
}
removeBday(userId: string): void {
if (this.bdays?.[userId]) {
delete this.bdays[userId];
fs.writeFileSync('./storage/bdays.json', JSON.stringify(this.bdays, null, 2));
}
}
}

46
src/libs/Database.ts Normal file
View File

@@ -0,0 +1,46 @@
import mongoose from "mongoose";
export default class Database {
private static instance: Database;
private connected: boolean = false;
private constructor() {}
public static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
public async connect(): Promise<void> {
if (this.connected) return;
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) {
console.warn("[Database] MONGODB_URI not set. Points system disabled.");
return;
}
try {
await mongoose.connect(mongoUri);
this.connected = true;
console.log("[Database] Connected to MongoDB");
} catch (error) {
console.error("[Database] Connection failed:", error);
this.connected = false;
}
}
public isConnected(): boolean {
return this.connected && mongoose.connection.readyState === 1;
}
public async disconnect(): Promise<void> {
if (this.connected) {
await mongoose.disconnect();
this.connected = false;
console.log("[Database] Disconnected from MongoDB");
}
}
}

133
src/libs/Formatter.ts Normal file
View File

@@ -0,0 +1,133 @@
import { EmbedBuilder } from "discord.js";
import fs from "fs";
import yaml from "js-yaml";
export default class Formatter {
obj: any;
constructor() {
this.obj = null;
}
readYaml(path: string) {
try {
const fileContents = fs.readFileSync(path, 'utf8');
return yaml.load(fileContents);
} catch (e) {
console.log(e);
}
}
jsonPathToValue(jsonData: any, path: string) {
if (!(jsonData instanceof Object) || typeof (path) === "undefined") {
throw "InvalidArgumentException(jsonData:" + jsonData + ", path:" + path + ")";
}
path = path.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
path = path.replace(/^\./, ''); // strip a leading dot
var pathArray = path.split('.');
for (var i = 0, n = pathArray.length; i < n; ++i) {
var key = pathArray[i];
if (key && key in jsonData) {
if (jsonData[key] !== null) {
jsonData = jsonData[key];
} else {
return null;
}
} else {
return key;
}
}
return jsonData;
}
trimBrackets(input: any) {
let start = false, end = false;
let final = input.split('');
while(start == false) {
if(final[0] != '{') {
final.shift();
} else {
start = true;
}
}
while(end == false) {
if(final[final.length - 1] != '}') {
final.pop();
} else {
end = true;
}
}
return final.join('');
}
parseString(input: any) {
let result = input;
input.split(' ').forEach(async(word: any) => {
if(!word.includes('{') && !word.includes('}')) return;
let key = this.trimBrackets(word);
if(!key.startsWith('{') && !key.endsWith('}')) return;
key = key.replace('{', '').replace('}', '').split('_');
if(key[0] == 'role') result = result.replace(`{${key[0]}_${key[1]}}`, "<@&" + key[1] + ">");
if(key[0] == 'user') result = result.replace(`{${key[0]}_${key[1]}}`, "<@" + key[1] + ">");
if(key[0] == 'channel') result = result.replace(`{${key[0]}_${key[1]}}`, "<#" + key[1] + ">");
if(this.obj != null) {
if(this.obj[key[0]] != null) result = result.replace(`{${key.join("_")}}`, this.jsonPathToValue(this.obj, key.join(".")));
}
});
return result;
}
parseObject(input: any) {
for (const [key, value] of Object.entries(input)) {
if (typeof input[key] == "string") input[key] = this.parseString(value);
if (typeof input[key] == "object") input[key] = this.parseObject(input[key]);
}
return input;
}
buildEmbed(path: string, obj: any = {}): EmbedBuilder {
obj = this.format(path, obj);
let embed = new EmbedBuilder()
if(obj.title) embed.setTitle(obj.title);
if(obj.description) embed.setDescription(obj.description);
if(obj.color) embed.setColor(obj.color);
if(obj.url) embed.setURL(obj.url);
if(obj.image) embed.setImage(obj.image);
if(obj.thumbnail) embed.setThumbnail(obj.thumbnail);
if(obj.timestamp) embed.setTimestamp();
if(obj.author) {
let name = obj.author.name || null;
let url = obj.author.url || null;
let iconURL = obj.author.iconURL || null;
embed.setAuthor({ name: name, url: url, iconURL: iconURL });
}
if(obj.footer) {
let text = obj.footer.text || null;
let iconURL = obj.footer.iconURL || null;
embed.setFooter({ text: text, iconURL: iconURL });
}
if(obj.fields) {
obj.fields.forEach((field: any) => {
embed.addFields({ name: field.name, value: field.value, inline: field.inline || false });
});
}
return embed;
}
/**
*
* @param path The path to the yaml file
* @param obj Json object to replace the placeholders
* @returns The formatted object
*/
format(path: string, obj: any = {}) {
let result = null;
try {
this.obj = obj;
result = this.parseObject(this.readYaml(path))
} finally {
this.obj = null;
}
return result;
}
}

101
src/libs/Logger.ts Normal file
View File

@@ -0,0 +1,101 @@
export default class Logger {
colorize(inputString: string) {
const minecraftColorCodes: any = {
'0': '\x1b[30m', // Black
'1': '\x1b[34m', // Dark Blue
'2': '\x1b[32m', // Dark Green
'3': '\x1b[36m', // Dark Aqua
'4': '\x1b[31m', // Dark Red
'5': '\x1b[35m', // Purple
'6': '\x1b[33m', // Gold
'7': '\x1b[37m', // Gray
'8': '\x1b[90m', // Dark Gray
'9': '\x1b[94m', // Blue
'a': '\x1b[92m', // Green
'b': '\x1b[96m', // Aqua
'c': '\x1b[91m', // Red
'd': '\x1b[95m', // Light Purple
'e': '\x1b[93m', // Yellow
'f': '\x1b[97m', // White
'k': '\x1b[5m', // Obfuscated
'l': '\x1b[1m', // Bold
'm': '\x1b[9m', // Strikethrough
'n': '\x1b[4m', // Underline
'o': '\x1b[3m', // Italic
'r': '\x1b[0m' // Reset
};
const parts = inputString.split('&');
let outputString = parts[0];
for (let i = 1; i < parts.length; i++) {
const colorCode = parts[i]?.[0];
const restOfString = parts[i]?.slice(1);
if (colorCode && minecraftColorCodes.hasOwnProperty(colorCode)) {
outputString += minecraftColorCodes[colorCode] + restOfString;
} else {
outputString += '&' + parts[i];
}
}
return outputString;
}
decolorize(inputString: string) {
const minecraftColorCodes: any = {
'0': '',
'1': '',
'2': '',
'3': '',
'4': '',
'5': '',
'6': '',
'7': '',
'8': '',
'9': '',
'a': '',
'b': '',
'c': '',
'd': '',
'e': '',
'f': '',
'k': '',
'l': '',
'm': '',
'n': '',
'o': '',
'r': '',
};
const parts = inputString.split('&');
let outputString = parts[0];
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
const colorCode = part[0];
const restOfString = part.slice(1);
if (colorCode && minecraftColorCodes.hasOwnProperty(colorCode)) {
outputString += minecraftColorCodes[colorCode] + restOfString;
} else {
outputString += '&' + parts[i];
}
}
return outputString;
}
log(input: string) {
console.log(this.colorize(input + "&r"));
}
error(input: string) {
console.error(this.colorize("&4" + input + "&r"));
}
}

150
src/libs/PointsManager.ts Normal file
View File

@@ -0,0 +1,150 @@
import User, { IUser } from "../models/User";
import Team, { ITeam } from "../models/Team";
import Database from "./Database";
export default class PointsManager {
private static DAILY_POINTS = 2;
private static WEEKLY_POINTS = 10;
public static async awardPoints(userId: string, username: string, type: "daily" | "weekly"): Promise<{ userPoints: number; teamPoints?: number } | null> {
const db = Database.getInstance();
if (!db.isConnected()) return null;
const points = type === "daily" ? this.DAILY_POINTS : this.WEEKLY_POINTS;
try {
let user = await User.findOne({ userId });
if (!user) {
user = new User({
userId,
username,
points: 0,
dailyQuestionsCompleted: 0,
weeklyQuestionsCompleted: 0
});
}
user.points += points;
user.username = username;
user.lastActive = new Date();
if (type === "daily") {
user.dailyQuestionsCompleted += 1;
} else {
user.weeklyQuestionsCompleted += 1;
}
await user.save();
let teamPoints: number | undefined;
if (user.teamId) {
const team = await Team.findById(user.teamId);
if (team) {
team.points += points;
(team as any).calculateAdjustedPoints();
await team.save();
teamPoints = team.adjustedPoints;
}
}
return { userPoints: user.points, teamPoints };
} catch (error) {
console.error("[PointsManager] Error awarding points:", error);
return null;
}
}
public static async getUserStats(userId: string): Promise<IUser | null> {
const db = Database.getInstance();
if (!db.isConnected()) return null;
try {
return await User.findOne({ userId }).populate("teamId");
} catch (error) {
console.error("[PointsManager] Error fetching user stats:", error);
return null;
}
}
public static async getLeaderboard(limit: number = 10): Promise<IUser[]> {
const db = Database.getInstance();
if (!db.isConnected()) return [];
try {
return await User.find().sort({ points: -1 }).limit(limit).exec();
} catch (error) {
console.error("[PointsManager] Error fetching leaderboard:", error);
return [];
}
}
public static async getTeamLeaderboard(limit: number = 10): Promise<ITeam[]> {
const db = Database.getInstance();
if (!db.isConnected()) return [];
try {
const teams = await Team.find().exec();
teams.forEach(team => (team as any).calculateAdjustedPoints());
await Promise.all(teams.map(t => t.save()));
return teams.sort((a, b) => b.adjustedPoints - a.adjustedPoints).slice(0, limit);
} catch (error) {
console.error("[PointsManager] Error fetching team leaderboard:", error);
return [];
}
}
public static canChangeTeam(): boolean {
const now = new Date();
const dayOfMonth = now.getDate();
return dayOfMonth <= 7;
}
public static async joinTeam(userId: string, username: string, teamId: string, requireTimeCheck: boolean = false): Promise<{ success: boolean; message: string }> {
const db = Database.getInstance();
if (!db.isConnected()) return { success: false, message: "Database not connected" };
if (requireTimeCheck && !this.canChangeTeam()) {
return { success: false, message: "You can only change teams during the first week of the month (1st-7th)" };
}
try {
let user = await User.findOne({ userId });
if (!user) {
user = new User({ userId, username, points: 0 });
}
const team = await Team.findById(teamId);
if (!team) {
return { success: false, message: "Team not found" };
}
const oldTeamId = user.teamId;
if (oldTeamId && oldTeamId.toString() === teamId) {
return { success: false, message: "You're already in this team" };
}
if (oldTeamId) {
const oldTeam = await Team.findById(oldTeamId);
if (oldTeam) {
oldTeam.memberCount = Math.max(0, oldTeam.memberCount - 1);
(oldTeam as any).calculateAdjustedPoints();
await oldTeam.save();
}
}
user.teamId = team._id as any;
await user.save();
team.memberCount += 1;
(team as any).calculateAdjustedPoints();
await team.save();
return { success: true, message: `Successfully joined team **${team.name}**!` };
} catch (error) {
console.error("[PointsManager] Error joining team:", error);
return { success: false, message: "An error occurred while joining the team" };
}
}
}

View File

@@ -0,0 +1,455 @@
import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai";
import { promises as fs } from "fs";
import path from "path";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import Question, { IQuestion } from "../models/Question";
import Database from "./Database";
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Organic Chemistry", "Biology", "Computer Science", "Engineering"];
export interface QuestionData {
id: string;
subject: string;
frequency: "daily" | "weekly";
topic: string;
difficulty_rating: string;
typst_source: string;
reference_answer: string;
timestamp: string;
image_path?: string;
}
export default class QuestionGenerator {
private genAI: GoogleGenerativeAI;
private model: string;
private outputDir: string;
private chatModel: ChatGoogleGenerativeAI;
constructor() {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY environment variable is missing.");
}
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = process.env.AI_MODEL || "gemini-2.5-flash";
this.outputDir = path.join(process.cwd(), "data", "generated_problems");
this.chatModel = new ChatGoogleGenerativeAI({
apiKey: apiKey,
model: this.model,
temperature: 0.9,
});
}
async initialize() {
await fs.mkdir(this.outputDir, { recursive: true });
}
private getSubjectTypstGuide(subjects: string[]): string {
const guides: Record<string, string> = {
"Mathematics": `
**MATHEMATICS TYPST GUIDE:**
- Variables: \`#let x = $5$;\` (semicolon required)
- Functions: \`$f(x)$\`, \`$sin(theta)$\`, \`$ln(x)$\`
- Multiple variables: ALWAYS add spaces: \`$x y$\` NOT \`$xy$\` (xy = one variable)
- CORRECT: \`$2x y$\`, \`$a b c$\`, \`$x^2 y z$\`
- WRONG: \`$2xy$\` (Typst reads as one variable "xy")
- Subscripts: \`$x_1$\`, \`$x_(i+1)$\` (use parentheses for complex subscripts)
- Matrices: Use \`mat()\`: \`$mat(1,2;3,4)$\` for 2×2 matrix
- Vectors: \`$vec(x,y,z)$\` or \`$arrow(v)$\`
- Limits: \`$lim_(x -> oo)$\`, \`$sum_(i=1)^n$\`
- Integrals: \`$integral_0^1 f(x) dif x$\` (use \`dif\` for dx)
- Greek: \`$alpha$\`, \`$beta$\`, \`$theta$\`, \`$pi$\`
- NEVER: \`$x_i+1$\` (use parentheses: \`$x_(i+1)$\`)
- NEVER: Strings in subscripts like \`$sigma_("text")$\` (use \`$sigma_"text"$\`)
- NEVER: Adjacent variables without spaces like \`$xy$\`, \`$abc$\``,
"Physics": `
**PHYSICS TYPST GUIDE:**
- All variables in math mode: \`$F = m a$\` (note space between m and a)
- Multiple variables: MUST have spaces: \`$m v$\`, \`$F d$\` NOT \`$mv$\`, \`$Fd$\`
- Units as text: \`$9.8 "m/s"^2$\`, \`$5 "kg"$\`, \`$100 "N"$\`
- Vectors: \`$arrow(F)$\`, \`$arrow(v)$\`, \`$hat(x)$\` for unit vectors
- Dot product: \`$arrow(a) dot arrow(b)$\`
- Cross product: \`$arrow(a) times arrow(b)$\`
- Subscripts: \`$v_0$\`, \`$F_"net"$\`, \`$E_"kinetic"$\`
- Greek: \`$omega$\`, \`$theta$\`, \`$phi$\`, \`$lambda$\`
- Constants: \`$g = 9.8 "m/s"^2$\`, \`$c = 3 times 10^8 "m/s"$\`
- NEVER: \`$F_net$\` without quotes (use \`$F_"net"$\`)
- NEVER: Complex subscripts without parentheses
- NEVER: Adjacent variables without spaces like \`$mv$\`, \`$xy$\``,
"Chemistry": `
**CHEMISTRY TYPST GUIDE:**
- Chemical formulas: ALL multi-letter elements need quotes
- \`$"H"_2"O"$\`, \`$"CO"_2$\`, \`$"NaCl"$\`, \`$"CH"_4$\`
- \`$"Ca"("OH")_2$\`, \`$"Fe"_2"O"_3$\`
- Single elements: \`$"H"$\`, \`$"C"$\`, \`$"N"$\`, \`$"O"$\`
- Charges: \`$"H"^+$\`, \`$"OH"^-$\`, \`$"Ca"^(2+)$\`, \`$"SO"_4^(2-)$\`
- States: \`$"H"_2"O"(l)$\`, \`$"CO"_2(g)$\`, \`$"NaCl"(s)$\`
- Arrows: \`$arrow.r$\` or \`$-->$\` for reactions
- Equilibrium: \`$arrow.l.r$\` or \`$<-->$\`
- Concentration: \`$["H"^+] = 0.1 "M"$\`
- NEVER: \`$H_2O$\` or \`$CO_2$\` (needs quotes)
- NEVER: \`$NaCl$\` without quotes`,
"Organic Chemistry": `
**ORGANIC CHEMISTRY TYPST GUIDE:**
- All molecules need quotes: \`$"CH"_3"CH"_2"OH"$\`
- Functional groups: \`$"COOH"$\`, \`$"NH"_2$\`, \`$"OH"$\`
- Benzene ring: Describe in text, then use \`$"C"_6"H"_6$\`
- Naming: Keep as regular text outside math mode
- Stereochemistry: \`$(R)$\`, \`$(S)$\`, \`$E$\`, \`$Z$\`
- Mechanisms: Use arrows: \`$arrow.r$\`, \`$arrow.curve$\`
- Charges: \`$delta^+$\`, \`$delta^-$\`
- IUPAC names: Regular text (not in $ $)
- NEVER: \`$CH_3$\` (use \`$"CH"_3$\`)`,
"Biology": `
**BIOLOGY TYPST GUIDE:**
- Species names: _Italics_ outside math: \`_E. coli_\` or \`_Homo sapiens_\`
- Genes/proteins: Regular text or math: \`$"ATP"$\`, \`$"DNA"$\`, \`$"NADH"$\`
- Concentrations: \`$["Ca"^(2+)] = 1 "mM"$\`
- pH: \`$"pH" = 7.4$\`
- Equations: \`$"C"_6"H"_(12)"O"_6 + 6"O"_2 arrow.r 6"CO"_2 + 6"H"_2"O"$\`
- Ratios: \`$3:1$\` or \`$9:3:3:1$\`
- Units: \`$"mg/mL"$\`, \`$mu"m"$\` (mu for micro)
- NEVER: Unquoted chemical formulas`,
"Computer Science": `
**COMPUTER SCIENCE TYPST GUIDE:**
- Code snippets: Use code blocks with backticks: \`\`\`python ... \`\`\`
- Algorithms: Use lists with \`+\` or \`-\`
- Big O: \`$O(n)$\`, \`$O(n log n)$\`, \`$Theta(n^2)$\`
- Math notation: \`$sum_(i=1)^n i$\`, \`$log_2 n$\`
- Boolean: \`$and$\`, \`$or$\`, \`$not$\`
- Sets: \`$\\{1,2,3\\}$\`, \`$A union B$\`, \`$A sect B$\`
- Logic: \`$forall$\`, \`$exists$\`, \`$in$\`, \`$subset.eq$\`
- Variables in math: \`$x_i$\`, \`$a_n$\`
- Keep actual code outside math mode`,
"Engineering": `
**ENGINEERING TYPST GUIDE:**
- Variables with units: \`$F = 100 "N"$\`, \`$sigma = 50 "MPa"$\`
- Multiple variables: MUST have spaces: \`$F A$\`, \`$L h$\` NOT \`$FA$\`, \`$Lh$\`
- Subscripts for properties: \`$sigma_"yield"$\`, \`$E_"young"$\`
- Greek symbols: \`$sigma$\`, \`$tau$\`, \`$epsilon$\`, \`$rho$\`
- Stress/strain: \`$sigma = F/A$\`, \`$epsilon = Delta L / L_0$\`
- Vectors: \`$arrow(F)$\`, \`$arrow(M)$\`
- Moments: \`$M_x$\`, \`$M_"max"$\`
- Units: \`$"Pa"$\`, \`$"MPa"$\`, \`$"kN"$\`, \`$"mm"$\`
- NEVER: \`$sigma_(allow, a)$\` (use \`$sigma_"allow,a"$\` with quotes)
- NEVER: Complex subscripts without quotes
- NEVER: Adjacent variables without spaces like \`$FA$\`, \`$xy$\``
};
return subjects.map((s: string) => {
const normalized = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
return guides[normalized] || guides["Mathematics"];
}).join("\n");
}
async getHistory(limit: number = 100): Promise<QuestionData[]> {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionGenerator] Database not connected, returning empty history");
return [];
}
try {
const questions = await Question.find()
.sort({ createdAt: -1 })
.limit(limit)
.lean()
.exec();
return questions.map(q => ({
id: q.id,
subject: q.subject,
frequency: q.frequency as "daily" | "weekly",
topic: q.topic,
difficulty_rating: q.difficulty_rating,
typst_source: q.typst_source,
reference_answer: q.reference_answer,
timestamp: q.timestamp.toISOString(),
image_path: q.image_path
}));
} catch (error) {
console.error("[QuestionGenerator] Error fetching history from DB:", error);
return [];
}
}
async saveHistory(newQuestions: QuestionData[]) {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionGenerator] Database not connected, cannot save questions");
return;
}
try {
const questionDocs = newQuestions.map(q => ({
id: q.id,
subject: q.subject,
frequency: q.frequency,
topic: q.topic,
difficulty_rating: q.difficulty_rating,
typst_source: q.typst_source,
reference_answer: q.reference_answer,
timestamp: new Date(q.timestamp),
image_path: q.image_path
}));
await Question.insertMany(questionDocs, { ordered: false }).catch(err => {
// Ignore duplicate key errors
if (err.code !== 11000) throw err;
});
console.log(`[QuestionGenerator] Saved ${newQuestions.length} questions to database`);
} catch (error) {
console.error("[QuestionGenerator] Error saving questions to DB:", error);
}
}
private async getContextualHistory(frequency: "daily" | "weekly", subject?: string): Promise<string> {
const db = Database.getInstance();
if (!db.isConnected()) return "No previous questions available.";
try {
const query: any = { frequency };
if (subject) query.subject = subject;
const recentQuestions = await Question.find(query)
.sort({ createdAt: -1 })
.limit(30)
.select('subject topic difficulty_rating createdAt')
.lean()
.exec();
if (recentQuestions.length === 0) {
return "No previous questions found for this category.";
}
const context = recentQuestions.map((q, i) =>
`${i + 1}. [${q.subject}] ${q.topic} (${q.difficulty_rating}) - Generated ${new Date(q.createdAt).toLocaleDateString()}`
).join("\n");
return `Recent ${frequency} questions (last 30):\n${context}`;
} catch (error) {
console.error("[QuestionGenerator] Error fetching contextual history:", error);
return "Error retrieving question history.";
}
}
async generateProblems(frequency: "daily" | "weekly", specificSubject?: string): Promise<QuestionData[]> {
// Get contextual history from database
const contextualHistory = await this.getContextualHistory(frequency, specificSubject);
const subjectsToGenerate = specificSubject ? [specificSubject] : SUBJECTS;
const subjectGuides = this.getSubjectTypstGuide(subjectsToGenerate);
const model = this.genAI.getGenerativeModel({
model: this.model,
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
subject: { type: SchemaType.STRING },
frequency: { type: SchemaType.STRING },
topic: { type: SchemaType.STRING },
difficulty_rating: { type: SchemaType.STRING },
typst_source: { type: SchemaType.STRING },
reference_answer: { type: SchemaType.STRING },
},
required: ["subject", "frequency", "topic", "difficulty_rating", "typst_source", "reference_answer"],
},
},
},
});
const difficultyDesc = frequency === "daily"
? "Easy undergraduate level (freshman/sophomore) requiring detailed calculations and showing work. Maximum 2 parts (e.g., Part a and Part b) that test foundational concepts."
: "Advanced undergraduate level (junior/senior year) requiring deeper theoretical understanding and complex problem-solving. Maximum 2 parts that synthesize multiple upper-level concepts.";
const prompt = `
Generate rigorous STEM practice problems in **Typst** code format.
SUBJECTS: ${subjectsToGenerate.join(", ")}
FREQUENCY: ${frequency}
QUESTION HISTORY CONTEXT (avoid repeating these topics):
${contextualHistory}
IMPORTANT: Review the question history above and generate NEW topics that haven't been covered recently. Avoid repeating the same concepts or problem types.
${subjectGuides}
CRITICAL TYPST SYNTAX RULES - FOLLOW EXACTLY:
**BEFORE YOU GENERATE:** Validate your output against these regex patterns to catch errors:
1. Check for variables without spaces: \`/\$[0-9]*[a-z][a-z]+/g\` should NOT match (e.g., \`$xy$\`, \`$2ab$\` are INVALID)
2. Check subscript parentheses: \`/_\("\\w+"\)/g\` should NOT match (e.g., \`$x_("text")$\` is INVALID, use \`$x_"text"$\`)
3. Check chemical formulas: \`/\$[A-Z][a-z]?(?![_"])(?!\s)/g\` should require quotes (e.g., \`$NaCl$\` is INVALID, use \`$"NaCl"$\`)
4. Check semicolons: \`/#let\\s+\\w+\\s*=\\s*\\\$[^;]*\\\$/g\` should NOT match (must end with \`;\`)
5. Check set commands: \`/#set (text|page)/g\` should NOT match (font/page declarations forbidden)
**CORE SYNTAX RULES:**
1. **Variables:** Must have semicolon after closing $ (e.g., \`#let x = $5$;\` NOT \`#let x = $5$ kg\`)
2. **Math mode:** Use \`$...$\` for ALL equations. For chemical formulas use \`$"NiCl"_2$\` (quotes for multi-letter variables)
3. **SPACES BETWEEN VARIABLES:** When writing multiple single-letter variables, ADD SPACES:
- CORRECT: \`$x y$\`, \`$2x y$\`, \`$a b c$\`, \`$6x y$\`, \`$m v^2$\`, \`$a b + c d$\`
- WRONG: \`$xy$\`, \`$2xy$\`, \`$abc$\`, \`$mv$\`, \`$ab+cd$\` (Typst reads these as ONE variable name)
- EXCEPTION: Function names like \`$sin$\`, \`$cos$\`, \`$ln$\` are okay without spaces
4. **Text in math:** Use quotes like \`$"text"$\` or \`$upright("text")$\`
5. **Subscripts/Superscripts:** \`$x_2$\`, \`$x^2$\`, \`$10^(-27)$\` (use parentheses for negative exponents)
6. **Complex subscripts:** ALWAYS use parentheses: \`$x_(i+1)$\` NOT \`$x_i+1$\`
7. **Subscript text:** Use quotes directly WITHOUT parentheses: \`$sigma_"yield"$\` NOT \`$sigma_("yield")$\`
8. **Multi-char subscripts:** Always wrap: \`$T_"max"$\`, \`$F_"net"$\`, \`$v_"initial"$\`
9. **Lists:** Use \`- Item\` or \`+ Item\`. Never use single quotes.
10. **Units:** Write as text: \`$5 "kg"$\`, \`$10 "m/s"^2$\`, \`$25 "MPa"$\`
11. **Chemical formulas:** EVERY multi-letter element needs quotes:
- \`$"H"_2"O"$\`, \`$"CO"_2$\`, \`$"NaCl"$\`, \`$"Ca"("OH")_2$\`, \`$"Fe"_2"O"_3$\`
- Single letters can skip quotes: \`$"H"^+$\`, but safer to use quotes always
12. **Greek letters:** \`$alpha$\`, \`$beta$\`, \`$Delta$\`, \`$theta$\`, \`$omega$\` (no backslash)
13. **Operators:** \`$times$\`, \`$div$\`, \`$sum$\`, \`$integral$\`, \`$arrow.r$\`, \`$arrow.l.r$\`
14. **Fractions:** \`$1/2$\` or \`$(a+b)/(c+d)$\`
15. **Parentheses in math:** \`$(x+y)$\`, \`$[0, 1]$\`, \`$\\{x | x > 0\\}$\` (escape braces)
16. **NO FONT/PAGE DECLARATIONS:** Never use \`#set text(font: ...)\` or \`#set page(...)\`
VALID EXAMPLES:
\`\`\`typst
#let mass = $5$;
#let velocity = $10 "m/s"$;
#let sigma_"yield" = $250 "MPa"$;
#let pressure_"max" = $100 "kPa"$;
Calculate the energy: $E = 1/2 m v^2$ (note space between m and v)
Kinetic energy formula: $K E = 1/2 m v^2$
Function: $f(x, y) = x^3 - 6x y + 8y^3$ (space between x and y)
Polynomial: $p(x) = a x^3 + b x^2 + c x + d$ (spaces between all variables)
The compound $"H"_2"SO"_4$ reacts with $"NaOH"$ to form $"Na"_2"SO"_4$ and $"H"_2"O"$.
Ionic equation: $"Ca"^(2+) + "CO"_3^(2-) arrow.r "CaCO"_3(s)$
Subscript with expression: $x_(i+1) = x_i + 1$
Multiple subscripts: $T_"initial" = 298 "K"$ and $T_"final" = 373 "K"$
- Part (a): Find the derivative of $f(x)$
- Part (b): Evaluate at $x = 2$
- Part (c): Determine if $x y < 0$ (note space)
\`\`\`
INVALID (DO NOT USE):
- \`#set text(font: "Linux Libertine")\` (never declare fonts)
- \`#set page(width: ...)\` (never set page properties)
- \`#let x = $5$ kg\` (no text after closing $, must end with semicolon)
- \`$xy$\` when you mean x times y (use \`$x y$\` with space)
- \`$6xy$\` (use \`$6x y$\` with space between variables)
- \`$mv^2$\` (use \`$m v^2$\` with space between m and v)
- \`$ab + cd$\` (use \`$a b + c d$\` with spaces)
- \`$sigma_("yield")$\` (quotes should be direct WITHOUT parentheses: \`$sigma_"yield"$\`)
- \`$x_i+1$\` (use parentheses for expressions: \`$x_(i+1)$\`)
- \`$NiCl_2$\` (multi-letter needs quotes: \`$"NiCl"_2$\`)
- \`$H_2O$\` (needs quotes: \`$"H"_2"O"$\`)
- \`$CO2$\` (needs quotes and underscore: \`$"CO"_2$\`)
- \`$F_net$\` (text subscript needs quotes: \`$F_"net"$\`)
- \`$KE$\` when meaning kinetic energy (use \`$K E$\` with space or write out)
**SELF-VALIDATION CHECKLIST (before submitting):**
□ All multi-letter variables between single letters have spaces (e.g., \`$x y$\` not \`$xy$\`)
□ All #let statements end with semicolon
□ All text subscripts use quotes WITHOUT parentheses (e.g., \`$x_"text"$\`)
□ All chemical formulas have quotes on multi-letter elements
□ No #set text() or #set page() declarations
□ All units are quoted (e.g., \`$5 "kg"$\`)
□ Complex subscripts use parentheses (e.g., \`$x_(i+1)$\`)
REQUIREMENTS:
1. **Typst Source:** Follow the syntax rules above EXACTLY. Validate against the regex patterns and checklist.
2. **Reference Answer:** Provide the *exact* correct answer and the steps to derive it.
3. **Difficulty:** ${difficultyDesc}
4. **Question Structure:** MAXIMUM 2 parts per question (Part a and Part b). Do NOT create Part c, Part d, etc.
5. **Quantity:** One '${frequency}' question per subject (${subjectsToGenerate.length} total).
`;
const maxRetries = 3;
let lastError;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
if (attempt > 0) {
const delay = Math.pow(2, attempt) * 1000;
console.log(`[Gemini API] Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms delay`);
await new Promise(resolve => setTimeout(resolve, delay));
}
console.log(`[Gemini API] Sending request to ${this.model} for ${frequency} question generation (${subjectsToGenerate.length} subjects: ${subjectsToGenerate.join(", ")})`);
const result = await model.generateContent(prompt);
console.log(`[Gemini API] Response received successfully`);
const problems = JSON.parse(result.response.text()) as Omit<QuestionData, "id" | "timestamp">[];
const questionsWithMeta: QuestionData[] = problems.map(p => ({
...p,
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
}));
await this.saveHistory(questionsWithMeta);
return questionsWithMeta;
} catch (error: any) {
lastError = error;
console.error(`[Gemini API] Error on attempt ${attempt + 1}:`, error?.message || error);
if (error?.status === 429) {
console.log(`[Gemini API] Rate limit hit. Retrying...`);
continue;
}
break;
}
}
console.error("[Gemini API] All retry attempts failed:", lastError);
return [];
}
getOutputDir(): string {
return this.outputDir;
}
async getQuestionById(id: string): Promise<QuestionData | null> {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionGenerator] Database not connected, cannot fetch question");
return null;
}
try {
const question = await Question.findOne({ id }).lean().exec();
if (!question) return null;
return {
id: question.id,
subject: question.subject,
frequency: question.frequency as "daily" | "weekly",
topic: question.topic,
difficulty_rating: question.difficulty_rating,
typst_source: question.typst_source,
reference_answer: question.reference_answer,
timestamp: question.timestamp.toISOString(),
image_path: question.image_path
};
} catch (error) {
console.error("[QuestionGenerator] Error fetching question by ID:", error);
return null;
}
}
}

View File

@@ -0,0 +1,238 @@
import { promises as fs } from "fs";
import path from "path";
import QuestionGenerator, { QuestionData } from "./QuestionGenerator";
import ScheduledQuestion from "../models/ScheduledQuestion";
import Submission from "../models/Submission";
import Database from "./Database";
interface UserSubmission {
userId: string;
username: string;
questionId: string;
answer: string;
gradeResult: any;
timestamp: string;
}
export default class QuestionScheduler {
private dataDir: string;
private generator: QuestionGenerator;
constructor() {
this.dataDir = path.join(process.cwd(), "data");
this.generator = new QuestionGenerator();
}
async initialize() {
await this.generator.initialize();
await fs.mkdir(this.dataDir, { recursive: true });
}
async getHistory(): Promise<QuestionData[]> {
return await this.generator.getHistory();
}
private getPeriodKey(period: "daily" | "weekly"): string {
const now = new Date();
if (period === "daily") {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
} else {
const startOfYear = new Date(now.getFullYear(), 0, 1);
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
return `${now.getFullYear()}-W${String(weekNumber).padStart(2, '0')}`;
}
}
async getQuestionForPeriod(subject: string, period: "daily" | "weekly"): Promise<QuestionData | null> {
const periodKey = this.getPeriodKey(period);
const cacheKey = `${subject}-${period}-${periodKey}`;
const db = Database.getInstance();
if (db.isConnected()) {
try {
// Check MongoDB cache
const cached = await ScheduledQuestion.findOne({ cacheKey }).lean().exec();
if (cached && cached.periodKey === periodKey) {
// Fetch the full question data
const question = await this.generator.getQuestionById(cached.questionId);
if (question) return question;
}
} catch (error) {
console.error("[QuestionScheduler] Error checking MongoDB cache:", error);
}
}
// Generate new question
const questions = await this.generator.generateProblems(period, subject);
const subjectQuestion = questions.find(q =>
q.subject.toLowerCase() === subject.toLowerCase() &&
q.frequency === period
);
if (!subjectQuestion) {
return null;
}
// Cache in MongoDB
if (db.isConnected()) {
try {
await ScheduledQuestion.findOneAndUpdate(
{ cacheKey },
{
cacheKey,
questionId: subjectQuestion.id,
subject,
period,
periodKey
},
{ upsert: true, new: true }
);
} catch (error) {
console.error("[QuestionScheduler] Error caching to MongoDB:", error);
}
}
return subjectQuestion;
}
async forceRegenerateQuestion(subject: string, period: "daily" | "weekly"): Promise<QuestionData | null> {
const periodKey = this.getPeriodKey(period);
const cacheKey = `${subject}-${period}-${periodKey}`;
const db = Database.getInstance();
// Clear the cached question from MongoDB
if (db.isConnected()) {
try {
await ScheduledQuestion.deleteOne({ cacheKey });
} catch (error) {
console.error("[QuestionScheduler] Error clearing MongoDB cache:", error);
}
}
// Generate new question
const questions = await this.generator.generateProblems(period, subject);
const subjectQuestion = questions.find(q =>
q.subject.toLowerCase() === subject.toLowerCase() &&
q.frequency === period
);
if (!subjectQuestion) {
return null;
}
// Cache the new question in MongoDB
if (db.isConnected()) {
try {
await ScheduledQuestion.create({
cacheKey,
questionId: subjectQuestion.id,
subject,
period,
periodKey
});
} catch (error) {
console.error("[QuestionScheduler] Error caching new question:", error);
}
}
return subjectQuestion;
}
async getUserSubmissions(): Promise<UserSubmission[]> {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionScheduler] Database not connected");
return [];
}
try {
const submissions = await Submission.find().lean().exec();
return submissions.map(s => ({
userId: s.userId,
username: s.username,
questionId: s.questionId,
answer: s.answer,
gradeResult: s.gradeResult,
timestamp: s.timestamp.toISOString()
}));
} catch (error) {
console.error("[QuestionScheduler] Error fetching submissions:", error);
return [];
}
}
async saveSubmission(submission: UserSubmission) {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionScheduler] Database not connected, cannot save submission");
return;
}
try {
await Submission.create({
userId: submission.userId,
username: submission.username,
questionId: submission.questionId,
answer: submission.answer,
gradeResult: submission.gradeResult,
timestamp: new Date(submission.timestamp)
});
} catch (error) {
console.error("[QuestionScheduler] Error saving submission:", error);
}
}
async hasUserAnswered(userId: string, questionId: string): Promise<boolean> {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionScheduler] Database not connected");
return false;
}
try {
const submission = await Submission.findOne({ userId, questionId }).exec();
return !!submission;
} catch (error) {
console.error("[QuestionScheduler] Error checking submission:", error);
return false;
}
}
async getUserScore(userId: string, period: "daily" | "weekly", periodKey?: string): Promise<{ correct: number; total: number }> {
const db = Database.getInstance();
if (!db.isConnected()) {
console.warn("[QuestionScheduler] Database not connected");
return { correct: 0, total: 0 };
}
try {
const targetPeriodKey = periodKey || this.getPeriodKey(period);
// Get relevant question IDs for this period
const scheduledQuestions = await ScheduledQuestion.find({
period,
periodKey: targetPeriodKey
}).select('questionId').lean().exec();
const relevantQuestionIds = scheduledQuestions.map(sq => sq.questionId);
if (relevantQuestionIds.length === 0) {
return { correct: 0, total: 0 };
}
// Get user submissions for these questions
const userSubmissions = await Submission.find({
userId,
questionId: { $in: relevantQuestionIds }
}).lean().exec();
const correct = userSubmissions.filter(s => s.gradeResult?.is_correct).length;
return { correct, total: userSubmissions.length };
} catch (error) {
console.error("[QuestionScheduler] Error calculating user score:", error);
return { correct: 0, total: 0 };
}
}
}

94
src/libs/Storage.ts Normal file
View File

@@ -0,0 +1,94 @@
import { Client, Collection, GuildMember, Message } from "discord.js";
import BotClient from "./BotClient";
type MemberCacheEntry = {
members: Collection<string, GuildMember>;
expiresAt: number;
};
export default class Storage {
db: any;
private memberCache: Map<string, MemberCacheEntry>;
private defaultTTL: number;
constructor() {
this.db = null;
this.memberCache = new Map();
this.defaultTTL = 5 * 60 * 1000; // 5 minutes
}
async fetchGuildMembers(client: BotClient | Client, guildId: string, forceRefresh: boolean = false, ttl?: number): Promise<Collection<string, GuildMember>> {
const now = Date.now();
const useTTL = typeof ttl === "number" ? ttl : this.defaultTTL;
if (!forceRefresh) {
const cached = this.memberCache.get(guildId);
if (cached && cached.expiresAt > now) {
return cached.members;
}
}
const guild = await client.guilds.fetch(guildId).catch(() => null);
if (!guild) return new Collection<string, GuildMember>();
const members = await guild.members.fetch().catch(() => new Collection<string, GuildMember>());
this.memberCache.set(guildId, { members, expiresAt: now + useTTL });
return members;
}
invalidateGuildMembers(guildId: string) {
this.memberCache.delete(guildId);
}
async getMessages(channel: any, options: { reverseArray?: boolean; userOnly?: boolean; botOnly?: boolean; pinnedOnly?: boolean; limitPages?: number; since?: Date } = {}): Promise<Message[]> {
const { reverseArray, userOnly, botOnly, pinnedOnly, limitPages, since } = options;
const messages: Message[] = [];
let lastID: string | undefined = undefined;
const maxPages = typeof limitPages === 'number' ? Math.max(1, limitPages) : 20;
for (let page = 0; page < maxPages; page++) {
const fetched: Collection<string, Message> = await channel.messages.fetch({ limit: 100, ...(lastID ? { before: lastID } : {}) }).catch(() => new Collection<string, Message>());
if (!fetched || fetched.size === 0) {
if (reverseArray) messages.reverse();
let out = messages as Message[];
if (userOnly) out = out.filter(m => !m.author.bot);
if (botOnly) out = out.filter(m => m.author.bot);
if (pinnedOnly) out = out.filter(m => m.pinned);
return out;
}
let stopEarly = false;
for (const msg of fetched.values()) {
if (since && msg.createdAt < since) {
stopEarly = true;
break;
}
messages.push(msg);
}
if (stopEarly) {
if (reverseArray) messages.reverse();
let out = messages as Message[];
if (userOnly) out = out.filter(m => !m.author.bot);
if (botOnly) out = out.filter(m => m.author.bot);
if (pinnedOnly) out = out.filter(m => m.pinned);
return out;
}
lastID = fetched.last()?.id;
if (!lastID) break;
}
if (reverseArray) messages.reverse();
let out = messages as Message[];
if (userOnly) out = out.filter(m => !m.author.bot);
if (botOnly) out = out.filter(m => m.author.bot);
if (pinnedOnly) out = out.filter(m => m.pinned);
return out;
}
}

115
src/libs/Typst.ts Normal file
View File

@@ -0,0 +1,115 @@
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export default class Typst {
messages: Map<string, { replyId: string; ownerId?: string }>;
constructor() {
this.messages = new Map();
}
addMessage(userMessage: string, responseMessage: string, ownerId?: string) { this.messages.set(userMessage, { replyId: responseMessage, ownerId }); }
removeMessage(userMessage: string) { this.messages.delete(userMessage); }
hasMessage(userMessage: string) { return this.messages.has(userMessage); }
getResponse(userMessage: string) { return this.messages.get(userMessage)?.replyId; }
getOwner(userMessage: string) { return this.messages.get(userMessage)?.ownerId; }
findByReply(replyId: string): { userMessage?: string; ownerId?: string } | null {
for (const [userMessage, data] of this.messages.entries()) {
if (data.replyId === replyId) return { userMessage, ownerId: data.ownerId };
}
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;
}
async renderToImage(typstCode: string): Promise<string | null> {
const styledCode = `
#set page(width: 210mm, height: auto, margin: (x: 20pt, y: 25pt), fill: white)
#set text(size: 16pt, font: "New Computer Modern")
#set par(justify: true, leading: 0.65em)
#set block(spacing: 1.2em)
${typstCode}
`;
const result = await this.compile(styledCode, { cleanup: false });
if (result.error || !result.files || result.files.length === 0) {
console.error("Typst render error:", result.error);
return null;
}
return result.files[0];
}
}