Initial Code
This commit is contained in:
87
src/libs/AnswerGrader.ts
Normal file
87
src/libs/AnswerGrader.ts
Normal 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
212
src/libs/BotAI.ts
Normal 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
23
src/libs/BotAction.ts
Normal 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
52
src/libs/BotClient.ts
Normal 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
38
src/libs/BotCommand.ts
Normal 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
81
src/libs/Config.ts
Normal 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
46
src/libs/Database.ts
Normal 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
133
src/libs/Formatter.ts
Normal 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
101
src/libs/Logger.ts
Normal 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
150
src/libs/PointsManager.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
455
src/libs/QuestionGenerator.ts
Normal file
455
src/libs/QuestionGenerator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/libs/QuestionScheduler.ts
Normal file
238
src/libs/QuestionScheduler.ts
Normal 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
94
src/libs/Storage.ts
Normal 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
115
src/libs/Typst.ts
Normal 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];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user