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

View File

@@ -0,0 +1,110 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, PermissionFlagsBits } from "discord.js";
import User from "../../models/User";
import Team from "../../models/Team";
import Database from "../../libs/Database";
export default class AdminStatsCommand extends BotCommand {
constructor() {
super("serverstats", "View detailed statistics (Admin only)", "/serverstats");
this.data.addStringOption(option =>
option.setName("type")
.setDescription("Statistics type")
.addChoices(
{ name: "Most Active Users", value: "active" },
{ name: "Top Point Earners", value: "points" },
{ name: "Team Activity", value: "teams" },
{ name: "Overall Summary", value: "summary" }
)
.setRequired(true)
);
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const db = Database.getInstance();
if (!db.isConnected()) {
return interaction.reply({ content: "❌ Database not connected. Points system unavailable.", flags: MessageFlags.Ephemeral });
}
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const type = interaction.options.getString("type", true);
if (type === "active") {
const users = await User.find().sort({ lastActive: -1 }).limit(15).exec();
const embed = new EmbedBuilder()
.setTitle("📊 Most Active Users")
.setColor(0x22c55e)
.setDescription(users.map((u, i) =>
`**${i + 1}.** <@${u.userId}> - Last active <t:${Math.floor(u.lastActive.getTime() / 1000)}:R>\n` +
` Questions: ${u.dailyQuestionsCompleted + u.weeklyQuestionsCompleted} | Points: ${u.points}`
).join("\n\n"))
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
} else if (type === "points") {
const users = await User.find().sort({ points: -1 }).limit(15).exec();
const embed = new EmbedBuilder()
.setTitle("💰 Top Point Earners")
.setColor(0xfacc15)
.setDescription(users.map((u, i) =>
`**${i + 1}.** <@${u.userId}> - **${u.points}** points\n` +
` Daily: ${u.dailyQuestionsCompleted} | Weekly: ${u.weeklyQuestionsCompleted}`
).join("\n\n"))
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
} else if (type === "teams") {
const teams = await Team.find().sort({ memberCount: -1 }).exec();
const embed = new EmbedBuilder()
.setTitle("👥 Team Activity")
.setColor(0x60a5fa)
.setDescription(teams.map((t, i) =>
`**${i + 1}.** ${t.name} (Leader: <@${t.leaderId}>)\n` +
` Members: ${t.memberCount} | Points: ${t.points} (Adjusted: ${t.adjustedPoints})`
).join("\n\n"))
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
} else if (type === "summary") {
const totalUsers = await User.countDocuments();
const totalTeams = await Team.countDocuments();
const totalPoints = await User.aggregate([{ $group: { _id: null, total: { $sum: "$points" } } }]);
const totalDaily = await User.aggregate([{ $group: { _id: null, total: { $sum: "$dailyQuestionsCompleted" } } }]);
const totalWeekly = await User.aggregate([{ $group: { _id: null, total: { $sum: "$weeklyQuestionsCompleted" } } }]);
const usersWithTeams = await User.countDocuments({ teamId: { $ne: null } });
const embed = new EmbedBuilder()
.setTitle("📈 Overall Summary")
.setColor(0x9333ea)
.addFields(
{ name: "Total Users", value: totalUsers.toString(), inline: true },
{ name: "Total Teams", value: totalTeams.toString(), inline: true },
{ name: "Users in Teams", value: `${usersWithTeams}/${totalUsers}`, inline: true },
{ name: "Total Points Earned", value: (totalPoints[0]?.total || 0).toString(), inline: true },
{ name: "Daily Questions Completed", value: (totalDaily[0]?.total || 0).toString(), inline: true },
{ name: "Weekly Questions Completed", value: (totalWeekly[0]?.total || 0).toString(), inline: true }
)
.setFooter({ text: "System Statistics" })
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
}
} catch (error) {
console.error("Error in admin-stats command:", error);
return interaction.editReply({ content: "❌ An error occurred." });
}
}
}

View File

@@ -0,0 +1,245 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, PermissionFlagsBits, MessageFlags } from "discord.js";
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
export default class GuildGrowthCommand extends BotCommand {
constructor() {
super("growth", "Generates a chart of guild member growth over time", "/growth");
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
this.data.addStringOption(option =>
option.setName("range")
.setDescription("Time range to show")
.addChoices(
{ name: "30 days", value: "30" },
{ name: "90 days", value: "90" },
{ name: "180 days", value: "180" },
{ name: "365 days", value: "365" },
{ name: "All time", value: "all" }
)
.setRequired(false)
);
this.data.addStringOption(option =>
option.setName("granularity")
.setDescription("Bucket granularity for the chart")
.addChoices(
{ name: "Daily", value: "daily" },
{ name: "Weekly", value: "weekly" },
{ name: "Monthly", value: "monthly" }
)
.setRequired(false)
);
this.data.addBooleanOption(option =>
option.setName("refresh")
.setDescription("Force refresh member cache from Discord")
.setRequired(false)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const guild = interaction.guild;
if (!guild) {
return interaction.reply({ content: "This command must be run in a guild.", flags: MessageFlags.Ephemeral });
}
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const refresh = interaction.options.getBoolean("refresh") || false;
const granularity = (interaction.options.getString("granularity") || "monthly") as string;
const members = await client.storage.fetchGuildMembers(client, guild.id, refresh);
const joinCounts = new Map<string, number>();
let earliest: Date | null = null;
let latest: Date | null = null;
members.forEach(member => {
const j = member.joinedAt;
if (!j) return;
const day = j.toISOString().slice(0, 10);
joinCounts.set(day, (joinCounts.get(day) || 0) + 1);
if (!earliest || j < earliest) earliest = j;
if (!latest || j > latest) latest = j;
});
if (!earliest || !latest) {
return interaction.editReply({ content: "No join data available for this guild." });
}
const labels: string[] = [];
const data: number[] = [];
const e = earliest as Date;
const l = latest as Date;
const rangeOpt = interaction.options.getString("range") || "365";
let start: Date;
if (rangeOpt === "all") {
start = new Date(Date.UTC(e.getUTCFullYear(), e.getUTCMonth(), e.getUTCDate()));
} else {
const days = Math.max(1, parseInt(rangeOpt, 10) || 365);
const desired = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const desiredUTC = new Date(Date.UTC(desired.getUTCFullYear(), desired.getUTCMonth(), desired.getUTCDate()));
const earliestUTC = new Date(Date.UTC(e.getUTCFullYear(), e.getUTCMonth(), e.getUTCDate()));
start = desiredUTC > earliestUTC ? desiredUTC : earliestUTC;
}
const end = new Date(Date.UTC(l.getUTCFullYear(), l.getUTCMonth(), l.getUTCDate()));
const dailyCum = new Map<string, number>();
let running = 0;
for (let cursor = new Date(start); cursor <= end; cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const dayStr = cursor.toISOString().slice(0, 10);
running += (joinCounts.get(dayStr) || 0);
dailyCum.set(dayStr, running);
}
const getCumUpTo = (dateStr: string) => {
if (dailyCum.has(dateStr)) return dailyCum.get(dateStr) || 0;
const keys = Array.from(dailyCum.keys()).sort();
let res = 0;
for (const k of keys) {
if (k > dateStr) break;
res = dailyCum.get(k) || 0;
}
return res;
};
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
if (granularity === "daily") {
const totalDays = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
const step = Math.max(1, Math.ceil(totalDays / 12));
for (let d = 0; d < totalDays; d += step) {
const dt = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + d));
const label = dt.toISOString().slice(0, 10);
const cum = getCumUpTo(label);
labels.push(label);
data.push(cum);
}
} else if (granularity === "weekly") {
const totalDays = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1;
const totalWeeks = Math.ceil(totalDays / 7);
const step = Math.max(1, Math.ceil(totalWeeks / 12));
for (let w = 0; w < totalWeeks; w += step) {
const weekStart = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate() + w * 7));
const weekEnd = new Date(Date.UTC(weekStart.getUTCFullYear(), weekStart.getUTCMonth(), weekStart.getUTCDate() + 6));
const label = `${MONTHS[weekStart.getUTCMonth()]} ${weekStart.getUTCDate()}, ${weekStart.getUTCFullYear()}`;
const monthEndStr = weekEnd.toISOString().slice(0, 10);
const cum = getCumUpTo(monthEndStr);
labels.push(label);
data.push(cum);
}
} else {
const monthDiff = (a: Date, b: Date) => (b.getUTCFullYear() - a.getUTCFullYear()) * 12 + (b.getUTCMonth() - a.getUTCMonth());
const totalMonths = monthDiff(start, end) + 1;
const step = Math.max(1, Math.ceil(totalMonths / 12));
for (let m = 0; m < totalMonths; m += step) {
const dt = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + m, 1));
const year = dt.getUTCFullYear();
const month = dt.getUTCMonth();
const label = `${MONTHS[month]} ${year}`;
const monthEnd = new Date(Date.UTC(year, month + 1, 0));
const monthEndStr = monthEnd.toISOString().slice(0, 10);
const cum = getCumUpTo(monthEndStr);
labels.push(label);
data.push(cum);
}
}
const width = 1200;
const height = 600;
const canvasBackground = "#0b1220";
const chartJSNodeCanvas = new ChartJSNodeCanvas({ width, height, backgroundColour: canvasBackground });
let trendData: number[] = [];
if (data.length >= 2) {
const n = data.length;
const xs = data.map((_, i) => i);
const ys = data;
const sumX = xs.reduce((a, b) => a + b, 0);
const sumY = ys.reduce((a, b) => a + b, 0);
const sumXX = xs.reduce((a, b) => a + b * b, 0);
const sumXY = xs.reduce((a, b, i) => a + b * ys[i], 0);
const denom = n * sumXX - sumX * sumX;
if (Math.abs(denom) < 1e-12) {
trendData = ys.slice();
} else {
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
trendData = xs.map(x => intercept + slope * x);
}
} else {
trendData = data.slice();
}
const configuration: any = {
type: "line",
data: {
labels,
datasets: [
{
label: "Members",
data,
fill: true,
borderColor: "#60a5fa",
backgroundColor: "rgba(96,165,250,0.12)",
tension: 0.2,
pointRadius: 0,
},
{
label: "Trend",
data: trendData,
fill: false,
borderColor: "#facc15",
borderDash: [6, 6],
tension: 0.1,
pointRadius: 0,
},
],
},
options: {
plugins: {
legend: { labels: { color: "#e6eef8" } },
title: { display: true, text: `Server Growth — ${guild.name}`, color: "#e6eef8", font: { size: 16 } },
},
scales: {
x: {
ticks: { maxRotation: 45, minRotation: 0, color: "#cbd5e1" },
grid: { color: "rgba(255,255,255,0.04)" },
},
y: {
beginAtZero: true,
ticks: { color: "#cbd5e1" },
grid: { color: "rgba(255,255,255,0.04)" },
},
},
backgroundColor: canvasBackground,
responsive: false,
},
};
try {
const buffer = await chartJSNodeCanvas.renderToBuffer(configuration);
const attachment = new Discord.AttachmentBuilder(buffer, { name: "guild-growth.png" });
await interaction.editReply({ content: `Guild growth for **${guild.name}** (members: ${guild.memberCount})`, files: [attachment] });
} catch (err) {
console.error("Error rendering guild growth chart:", err);
await interaction.editReply({ content: "Failed to generate chart. See logs for details." });
}
} catch (err) {
console.error("Error executing growth command:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while generating the chart. Check logs for details." });
} else {
await interaction.reply({ content: "An error occurred while generating the chart. Check logs for details.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,87 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, PermissionFlagsBits, MessageFlags, TextChannel } from "discord.js";
export default class PingHelpCommand extends BotCommand {
private helperChannelID: string;
constructor() {
super("pinghelp", "Pings the helper role", "/pinghelp");
this.helperChannelID = "1310993025958150166";
this.data.addRoleOption(option =>
option.setName("role")
.setDescription("The role to ping")
.setRequired(true)
);
this.data.addUserOption(option =>
option.setName("user")
.setDescription("The user to ping")
.setRequired(true)
);
this.data.addStringOption(option =>
option.setName("messageid")
.setDescription("The message ID to ping")
.setRequired(false)
);
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const role = interaction.options.getRole("role");
const user = interaction.options.getUser("user");
const messageId = interaction.options.getString("messageid");
if (!role) {
return interaction.reply({
content: "You must specify a role to ping.",
flags: MessageFlags.Ephemeral
});
}
const helpType = role.name.split(" ")[0];
const helperChannel = await interaction.guild?.channels.fetch(this.helperChannelID) as TextChannel;
if (!helperChannel) {
return interaction.reply({
content: "Helper channel not found.",
flags: MessageFlags.Ephemeral
});
}
if (messageId) {
const message = await interaction.channel?.messages.fetch(messageId).catch(() => null);
if (!message) {
return interaction.reply({
content: "Invalid message ID provided.",
flags: MessageFlags.Ephemeral
});
}
const messageLink = message.url;
helperChannel.send({
content: `${role} **\`${user?.username}\`** needs help => ${messageLink}. Please assist with their ${helpType} question.`,
});
return interaction.reply({
content: "Helper has been pinged!",
flags: MessageFlags.Ephemeral
});
} else {
helperChannel.send({
content: `${role} **\`${user?.username}\`** needs help => ${interaction.channel}. Please assist them.`,
});
return interaction.reply({
content: "Helper has been pinged!",
flags: MessageFlags.Ephemeral
});
}
}
}

View File

@@ -0,0 +1,118 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, PermissionFlagsBits } from "discord.js";
import QuestionScheduler from "../../libs/QuestionScheduler";
export default class RegenerateQuestionCommand extends BotCommand {
constructor() {
super("regenerate", "Regenerate a question for a specific subject and period (Admin only)", "/regenerate");
this.data.addStringOption(option =>
option.setName("period")
.setDescription("Question period")
.addChoices(
{ name: "Daily", value: "daily" },
{ name: "Weekly", value: "weekly" }
)
.setRequired(true)
);
this.data.addStringOption(option =>
option.setName("subject")
.setDescription("Choose a subject")
.addChoices(
{ name: "Mathematics", value: "mathematics" },
{ name: "Physics", value: "physics" },
{ name: "Chemistry", value: "chemistry" },
{ name: "Organic Chemistry", value: "organic chemistry" },
{ name: "Biology", value: "biology" },
{ name: "Computer Science", value: "computer science" },
{ name: "Engineering", value: "engineering" }
)
.setRequired(true)
);
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const period = interaction.options.getString("period", true) as "daily" | "weekly";
const subject = interaction.options.getString("subject", true);
const scheduler = new QuestionScheduler();
await scheduler.initialize();
const embed = new EmbedBuilder()
.setTitle("🔄 Regenerating Question...")
.setDescription(`Generating new ${period} question for **${subject}**...`)
.setColor(0x60a5fa)
.setTimestamp();
await interaction.editReply({ embeds: [embed] });
// Force regenerate by clearing cache for this period
const regenerated = await scheduler.forceRegenerateQuestion(subject, period);
if (!regenerated) {
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Regeneration Failed")
.setDescription(`Failed to regenerate ${period} question for **${subject}**. Check console logs for details.`)
.setColor(0xef4444)
.setTimestamp();
return interaction.editReply({ embeds: [errorEmbed] });
}
const successEmbed = new EmbedBuilder()
.setTitle("✅ Question Regenerated")
.setDescription(`Successfully regenerated ${period} question for **${subject}**!`)
.addFields(
{ name: "Topic", value: regenerated.topic, inline: true },
{ name: "Difficulty", value: regenerated.difficulty_rating, inline: true },
{ name: "Question ID", value: regenerated.id, inline: false }
)
.setColor(0x22c55e)
.setFooter({ text: `Users can now access the new question via /${period}` })
.setTimestamp();
await interaction.editReply({ embeds: [successEmbed] });
// Log to logs channel
const logsChannelId = process.env.LOGS_CHANNEL_ID;
if (logsChannelId) {
try {
const logsChannel = await client.channels.fetch(logsChannelId);
if (logsChannel?.isTextBased()) {
const logEmbed = new EmbedBuilder()
.setTitle("🔄 Question Regenerated")
.setColor(0x60a5fa)
.addFields(
{ name: "Period", value: period, inline: true },
{ name: "Subject", value: subject, inline: true },
{ name: "Admin", value: `<@${interaction.user.id}>`, inline: true },
{ name: "New Topic", value: regenerated.topic, inline: false },
{ name: "Question ID", value: regenerated.id, inline: false }
)
.setTimestamp();
await (logsChannel as any).send({ embeds: [logEmbed] });
}
} catch (logErr) {
console.error("Failed to send log to logs channel:", logErr);
}
}
} catch (error) {
console.error("Error in regenerate command:", error);
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Error")
.setDescription("An unexpected error occurred while regenerating the question.")
.setColor(0xef4444)
.setTimestamp();
return interaction.editReply({ embeds: [errorEmbed] });
}
}
}

View File

@@ -0,0 +1,210 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, PermissionFlagsBits, MessageFlags, TextChannel } from "discord.js";
export default class SelectRolesCommand extends BotCommand {
constructor() {
super("selectroles", "Post the Embeds for roles", "/selectroles");
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
}
async languageRolesExecute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<void> {
const selectionMenu = new Discord.StringSelectMenuBuilder()
.setCustomId('select-roles-language')
.setPlaceholder('Select the roles you want to add')
.setMinValues(0)
.setMaxValues(client.config.getLanguageRoles().length);
client.config.getLanguageRoles().forEach((role: any) => {
const option = new Discord.StringSelectMenuOptionBuilder()
.setLabel(role.name)
.setValue(role.id)
.setDescription(`If you can speak ${role.name}`)
.setEmoji(role.emoji);
selectionMenu.addOptions(option);
});
const row = new Discord.ActionRowBuilder()
.addComponents(selectionMenu);
const embed = new Discord.EmbedBuilder()
.setColor("#2b2d31")
.setTitle("Select the language roles that apply to you")
.setDescription(`
:flag_es: - If you can speak Spanish.
:flag_fr: - If you can speak French.
:flag_de: - If you can speak German.
:flag_cn: - If you can speak Chinese.
:flag_ru: - If you can speak Russian.
:flag_il: - If you can speak Hebrew.
:flag_sa: - If you can speak Arabic.
:flag_in: - If you can speak Hindi.
:flag_it: - If you can speak Italian.
:flag_pt: - If you can speak Portuguese.
`)
.setFooter({ text: "You can select multiple roles" })
.setTimestamp();
await (interaction.channel as TextChannel)?.send({ embeds: [embed], components: [row] });
}
async educationLevelRoles(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<void> {
const selectionMenu = new Discord.StringSelectMenuBuilder()
.setCustomId('select-roles-education')
.setPlaceholder('Select the roles you want to add')
.setMinValues(0)
.setMaxValues(client.config.getEducationRoles().length);
client.config.getEducationRoles().forEach((role: any) => {
const option = new Discord.StringSelectMenuOptionBuilder()
.setLabel(role.name)
.setValue(role.id)
.setDescription(`If you are a ${role.name}`)
.setEmoji(role.emoji);
selectionMenu.addOptions(option);
});
const row = new Discord.ActionRowBuilder()
.addComponents(selectionMenu);
const embed = new Discord.EmbedBuilder()
.setColor("#2b2d31")
.setTitle("Select the education roles that apply to you")
.setDescription(`
:school: - If you are a High School Student
:mortar_board: - If you are a Undergraduate Student
👨‍🎓 - If you are a Postgraduate
👨‍🔬 - If you are a PhD Student
👨‍🏫 - If you are a Doctorate
`)
.setFooter({ text: "You can select multiple roles" })
.setTimestamp();
await (interaction.channel as TextChannel)?.send({ embeds: [embed], components: [row] });
}
async sendHelperRoles(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<void> {
const selectionMenu = new Discord.StringSelectMenuBuilder()
.setCustomId('select-roles-helper')
.setPlaceholder('Select the roles you want to add')
.setMinValues(0)
.setMaxValues(client.config.getHelperRoles().length);
client.config.getHelperRoles().forEach((role: any) => {
const option = new Discord.StringSelectMenuOptionBuilder()
.setLabel(role.name)
.setValue(role.id)
.setDescription(role.description)
.setEmoji(role.emoji);
selectionMenu.addOptions(option);
});
const row = new Discord.ActionRowBuilder()
.addComponents(selectionMenu);
const embed = new Discord.EmbedBuilder()
.setColor("#2b2d31")
.setTitle("Select the roles you want to add")
.setDescription(`
:test_tube: - If you can help with Chemistry.
:atom: - If you can help with Physics.
:infinity: - If you can help with Math.
:dna: - If you can help with Biology.
:man_technologist: - If you can help with Programming.
:student: - I'm just a Student, hoping to learn.
`)
.setFooter({ text: "You can select multiple roles" })
.setTimestamp();
await (interaction.channel as TextChannel)?.send({ embeds: [embed], components: [row] });
}
async sendLocationRoles(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<void> {
const selectionMenu = new Discord.StringSelectMenuBuilder()
.setCustomId('select-roles-location')
.setPlaceholder('Select the roles you want to add')
.setMinValues(0)
.setMaxValues(client.config.getLocationRoles().length);
client.config.getLocationRoles().forEach((role: any) => {
const option = new Discord.StringSelectMenuOptionBuilder()
.setLabel(role.name)
.setValue(role.id)
.setDescription(role.description)
.setEmoji(role.emoji);
selectionMenu.addOptions(option);
});
const row = new Discord.ActionRowBuilder()
.addComponents(selectionMenu);
const embed = new Discord.EmbedBuilder()
.setColor("#2b2d31")
.setTitle("Select the location roles that apply to you")
.setDescription(`
:earth_americas: - If you're from North America.
:earth_americas: - If you're from South America.
:earth_africa: - If you're from Europe.
:earth_asia: - If you're from Asia.
:earth_africa: - If you're from Africa.
:earth_asia: - If you're from Oceania.
`)
.setFooter({ text: "You can select multiple roles" })
.setTimestamp();
await (interaction.channel as TextChannel)?.send({ embeds: [embed], components: [row] });
}
async sendPingRoles(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<void> {
const selectionMenu = new Discord.StringSelectMenuBuilder()
.setCustomId('select-roles-ping')
.setPlaceholder('Select the roles you want to add')
.setMinValues(0)
.setMaxValues(client.config.getPingRoles().length);
client.config.getPingRoles().forEach((role: any) => {
const option = new Discord.StringSelectMenuOptionBuilder()
.setLabel(role.name)
.setValue(role.id)
.setDescription(role.description)
.setEmoji(role.emoji);
selectionMenu.addOptions(option);
});
const row = new Discord.ActionRowBuilder()
.addComponents(selectionMenu);
let description = "";
client.config.getPingRoles().forEach((role: any) => {
description += `${role.emoji} - ${role.description}\n`;
});
const embed = new Discord.EmbedBuilder()
.setColor("#2b2d31")
.setTitle("Select the ping roles that apply to you")
.setDescription(description)
.setFooter({ text: "You can select multiple roles" })
.setTimestamp();
await (interaction.channel as TextChannel)?.send({ embeds: [embed], components: [row] });
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
await interaction.reply({ content: `⏳ One Moment Please. . .`, flags: MessageFlags.Ephemeral });
await this.educationLevelRoles(Discord, client, interaction);
await this.sendHelperRoles(Discord, client, interaction);
await this.languageRolesExecute(Discord, client, interaction);
await this.sendLocationRoles(Discord, client, interaction);
await this.sendPingRoles(Discord, client, interaction);
return await interaction.deleteReply();
}
}

View File

@@ -0,0 +1,181 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, PermissionFlagsBits } from "discord.js";
import Team from "../../models/Team";
import User from "../../models/User";
import Database from "../../libs/Database";
import PointsManager from "../../libs/PointsManager";
export default class TeamManageCommand extends BotCommand {
constructor() {
super("team", "Create, edit, or delete teams (Admin only)", "/team");
this.data.addSubcommand(subcommand =>
subcommand.setName("create")
.setDescription("Create a new team")
.addStringOption(option =>
option.setName("name")
.setDescription("Team name")
.setRequired(true))
.addUserOption(option =>
option.setName("leader")
.setDescription("Team leader")
.setRequired(true))
.addStringOption(option =>
option.setName("description")
.setDescription("Team description")
.setRequired(false))
);
this.data.addSubcommand(subcommand =>
subcommand.setName("delete")
.setDescription("Delete a team")
.addStringOption(option =>
option.setName("name")
.setDescription("Team name")
.setRequired(true))
);
this.data.addSubcommand(subcommand =>
subcommand.setName("change-leader")
.setDescription("Change team leader")
.addStringOption(option =>
option.setName("name")
.setDescription("Team name")
.setRequired(true))
.addUserOption(option =>
option.setName("new-leader")
.setDescription("New team leader")
.setRequired(true))
);
this.data.addSubcommand(subcommand =>
subcommand.setName("list")
.setDescription("List all teams")
);
this.data.addSubcommand(subcommand =>
subcommand.setName("change-user-team")
.setDescription("Change a user's team (only during first week of month)")
.addUserOption(option =>
option.setName("user")
.setDescription("User to move")
.setRequired(true))
.addStringOption(option =>
option.setName("team")
.setDescription("Team name")
.setRequired(true))
);
this.data.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const db = Database.getInstance();
if (!db.isConnected()) {
return interaction.reply({ content: "❌ Database not connected. Points system unavailable.", flags: MessageFlags.Ephemeral });
}
const subcommand = interaction.options.getSubcommand();
try {
if (subcommand === "create") {
const name = interaction.options.getString("name", true);
const leader = interaction.options.getUser("leader", true);
const description = interaction.options.getString("description") || "";
const existing = await Team.findOne({ name });
if (existing) {
return interaction.reply({ content: `❌ Team **${name}** already exists.`, flags: MessageFlags.Ephemeral });
}
const team = new Team({
name,
leaderId: leader.id,
description,
points: 0,
memberCount: 0
});
await team.save();
const embed = new EmbedBuilder()
.setTitle("✅ Team Created")
.setColor(0x22c55e)
.addFields(
{ name: "Team Name", value: name, inline: true },
{ name: "Leader", value: `<@${leader.id}>`, inline: true },
{ name: "Description", value: description || "None", inline: false }
)
.setTimestamp();
return interaction.reply({ embeds: [embed] });
} else if (subcommand === "delete") {
const name = interaction.options.getString("name", true);
const team = await Team.findOne({ name });
if (!team) {
return interaction.reply({ content: `❌ Team **${name}** not found.`, flags: MessageFlags.Ephemeral });
}
await User.updateMany({ teamId: team._id }, { $set: { teamId: null } });
await Team.deleteOne({ _id: team._id });
return interaction.reply({ content: `✅ Team **${name}** has been deleted.`, flags: MessageFlags.Ephemeral });
} else if (subcommand === "change-leader") {
const name = interaction.options.getString("name", true);
const newLeader = interaction.options.getUser("new-leader", true);
const team = await Team.findOne({ name });
if (!team) {
return interaction.reply({ content: `❌ Team **${name}** not found.`, flags: MessageFlags.Ephemeral });
}
team.leaderId = newLeader.id;
await team.save();
return interaction.reply({ content: `✅ Team **${name}** leader changed to <@${newLeader.id}>.`, flags: MessageFlags.Ephemeral });
} else if (subcommand === "list") {
const teams = await Team.find().sort({ points: -1 }).exec();
if (teams.length === 0) {
return interaction.reply({ content: "No teams exist yet.", flags: MessageFlags.Ephemeral });
}
const embed = new EmbedBuilder()
.setTitle("📋 All Teams")
.setColor(0x60a5fa)
.setDescription(teams.map((t, i) =>
`**${i + 1}.** ${t.name} - Leader: <@${t.leaderId}>\n` +
` Members: ${t.memberCount} | Points: ${t.points} (Adjusted: ${t.adjustedPoints})`
).join("\n\n"))
.setTimestamp();
return interaction.reply({ embeds: [embed] });
} else if (subcommand === "change-user-team") {
const user = interaction.options.getUser("user", true);
const teamName = interaction.options.getString("team", true);
const team = await Team.findOne({ name: teamName });
if (!team) {
return interaction.reply({ content: `❌ Team **${teamName}** not found.`, flags: MessageFlags.Ephemeral });
}
const result = await PointsManager.joinTeam(user.id, user.username, team._id.toString(), true);
if (result.success) {
return interaction.reply({ content: `✅ <@${user.id}> has been moved to team **${teamName}**.`, flags: MessageFlags.Ephemeral });
} else {
return interaction.reply({ content: `${result.message}`, flags: MessageFlags.Ephemeral });
}
}
} catch (error) {
console.error("Error in team-manage command:", error);
return interaction.reply({ content: "❌ An error occurred.", flags: MessageFlags.Ephemeral });
}
}
}

View File

@@ -0,0 +1,17 @@
import BotClient from "../../libs/BotClient";
import BotCommand from "../../libs/BotCommand";
export default class StatusCommand extends BotCommand {
constructor() {
super("test", "Test Execute Command", "/test");
}
override async execute(Discord: any, client: BotClient, interaction: any) {
await interaction.deferReply();
await interaction.deleteReply();
// await interaction.reply({ content: "" });
}
}

36
src/commands/core/help.ts Normal file
View File

@@ -0,0 +1,36 @@
import { ChatInputCommandInteraction, GuildMember, MessageFlags, PermissionFlagsBits } from "discord.js";
import BotClient from "../../libs/BotClient";
import BotCommand from "../../libs/BotCommand";
export default class HelpCommand extends BotCommand {
constructor() {
super("help", "Help command", "/help");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction) {
let member = interaction.member as GuildMember;
const isAdmin = (member.permissions.has(PermissionFlagsBits.Administrator) || member.permissions.has(PermissionFlagsBits.ManageGuild));
let embed = new Discord.EmbedBuilder()
.setTitle("Help")
.setColor("Aqua")
.setTimestamp();
let description = "Here are the available commands:";
let modDescription = "Here are the available mod commands:";
client.commands.forEach(command => {
let commandData = command.data.default_member_permissions;
if(commandData && isAdmin) {
modDescription += `\n**${command.getName()}** - \`${command.getUse()}\` - ${command.data.description}`;
} else {
description += `\n**${command.getName()}** - \`${command.getUse()}\` - ${command.data.description}`;
}
});
embed.setDescription(isAdmin ? `${description}\n\n${modDescription}` : description);
await interaction.reply({ embeds: [embed], flags: [MessageFlags.Ephemeral] });
}
}

View File

@@ -0,0 +1,39 @@
import { MessageFlags } from "discord.js";
import BotClient from "../../libs/BotClient";
import BotCommand from "../../libs/BotCommand";
export default class StatusCommand extends BotCommand {
constructor() {
super("status", "Bot Status Command", "/status");
}
override async execute(Discord: any, client: BotClient, interaction: any) {
let totalSeconds = ((client.uptime || 0) / 1000);
let days = Math.floor(totalSeconds / 86400);
totalSeconds %= 86400;
let hours = Math.floor(totalSeconds / 3600);
totalSeconds %= 3600;
let minutes = Math.floor(totalSeconds / 60);
let seconds = Math.floor(totalSeconds % 60);
let obj = {
bot: {
name: client?.user?.username,
icon: client?.user?.avatarURL(),
stats: "Version: `" + `4.0.0` + "`\n" +
"💻 **Client Latency**: `" + `${Date.now() - interaction.createdTimestamp}ms` + "`\n" +
"📊 **API Latency**: `" + `${Math.round(client.ws.ping)}ms` + "`\n" +
"📶 **Ping**: `" + `${client.ws.ping}ms` + "`\n" +
":file_cabinet:**Memory**: `" + `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)}mb` + "`"
},
uptime: `${days}d ${hours}h ${minutes}m ${seconds}s`,
interaction
}
const embed = client.formatter.buildEmbed("./responses/command/status.yaml", obj);
return interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
}

View File

@@ -0,0 +1,32 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class DisconnectCommand extends BotCommand {
constructor() {
super("disconnect", "Disconnect Command", "/disconnect");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if(queue) {
client.distube.stop(interaction);
client.distube.voices.leave(interaction.guild);
} else {
client.distube.voices.leave(interaction.guild);
}
const embed = new Discord.EmbedBuilder()
.setDescription(`Successfully **Disconnected** from the voice channel.`)
.setFooter({ text: `Request by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() });
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,34 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class JumpCommand extends BotCommand {
constructor() {
super("jump", "Jump to a specific song in queue", "/jump");
this.data.addNumberOption(option =>
option.setName('position')
.setDescription('Position in queue to jump to')
.setRequired(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
const position = interaction.options.getNumber('position')!;
try {
await queue.jump(position - 1); // Convert to 0-based index
return await interaction.reply({ content: `⏭️ | Jumped to position ${position} in queue` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,40 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class LoopCommand extends BotCommand {
constructor() {
super("loop", "Loop Command", "/loop");
this.data.addStringOption(option =>
option.setName('mode')
.setDescription('Loop mode: off, song, queue')
.setRequired(true)
.addChoices(
{ name: 'Off', value: '0' },
{ name: 'Song', value: '1' },
{ name: 'Queue', value: '2' }
)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
const mode = parseInt(interaction.options.getString('mode')!);
try {
queue.setRepeatMode(mode);
const modeText = mode === 0 ? "Off" : mode === 1 ? "Song" : "Queue";
return await interaction.reply({ content: `🔁 | Loop mode set to: \`${modeText}\`` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,72 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class NowPlayingCommand extends BotCommand {
constructor() {
super("nowplaying", "Now Playing Command", "/nowplaying");
}
private progressbar(total: number, current: number, size: number, line: string, slider: string): string {
if (current > total) {
const bar = line.repeat(size + 2);
return bar;
} else {
const percentage = current / total;
const progress = Math.round((size * percentage));
const emptyProgress = size - progress;
const progressText = line.repeat(progress).replace(/.$/, slider);
const emptyProgressText = line.repeat(emptyProgress);
const bar = progressText + emptyProgressText;
return bar;
}
}
private convertTime(duration: number): string {
let seconds = parseInt(((duration / 1000) % 60).toString());
let minutes = parseInt(((duration / (1000 * 60)) % 60).toString());
let hours = parseInt(((duration / (1000 * 60 * 60)) % 24).toString());
const hoursStr = (hours < 10) ? "0" + hours : hours.toString();
const minutesStr = (minutes < 10) ? "0" + minutes : minutes.toString();
const secondsStr = (seconds < 10) ? "0" + seconds : seconds.toString();
if (duration < 3600000) {
return minutesStr + ":" + secondsStr;
} else {
return hoursStr + ":" + minutesStr + ":" + secondsStr;
}
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
const song = queue.songs[0];
const currentTime = queue.currentTime;
const totalTime = song.duration * 1000;
const progress = this.progressbar(totalTime, currentTime, 15, "▬", "🔵");
const currentTimeStr = this.convertTime(currentTime);
const totalTimeStr = this.convertTime(totalTime);
const embed = new Discord.EmbedBuilder()
.setTitle("Now Playing")
.setDescription(`**[${song.name}](${song.url})**`)
.addFields(
{ name: "Duration", value: `\`${currentTimeStr}\` ${progress} \`${totalTimeStr}\``, inline: false },
{ name: "Requested by", value: song.user.toString(), inline: true },
{ name: "Volume", value: `${queue.volume}%`, inline: true }
)
.setThumbnail(song.thumbnail)
.setColor("Blue");
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,30 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class PauseCommand extends BotCommand {
constructor() {
super("pause", "Pause Command", "/pause");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if(!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
if(queue.paused) return await interaction.reply({ content: `❌ | The queue has been paused.` });
client.distube.pause(interaction);
const embed = new Discord.EmbedBuilder()
.setDescription(`⏸ | Successfully **Paused** a song.`)
.setFooter({ text: `Request by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() });
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,35 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember, MessageFlags } from "discord.js";
export default class PlayCommand extends BotCommand {
constructor() {
super("play", "Play Command", "/play");
this.data.addStringOption(option =>
option.setName('song')
.setDescription('name or link of Music')
.setRequired(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const song = interaction.options.getString('song');
if(song != null) {
client.distube.play(member.voice.channel, song, {
member: member,
textChannel: interaction.channel
});
return await interaction.reply({ content: `Searching for ${song} . . .`, flags: MessageFlags.Ephemeral });
}
return await interaction.reply({ content: `You must provide a song to play`, flags: MessageFlags.Ephemeral });
}
}

View File

@@ -0,0 +1,27 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class PreviousCommand extends BotCommand {
constructor() {
super("previous", "Play previous song", "/previous");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
try {
const song = await queue.previous();
return await interaction.reply({ content: `⏮️ | Playing previous song: ${song.name}` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,82 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class QueueCommand extends BotCommand {
constructor() {
super("queue", "Queue Command", "/queue");
}
private queueStatus(queue: any): string {
const volume = queue.volume;
const filter = queue.filter || "Off";
const loop = queue.repeatMode ? queue.repeatMode === 2 ? "All Queue" : "This Song" : "Off";
const autoplay = queue.autoplay ? "On" : "Off";
let status: string;
if (filter !== "Off" && loop === "Off" && autoplay === "Off") {
status = `Filter: ${filter}`;
}
else if (filter !== "Off" && loop !== "Off" && autoplay === "Off") {
status = `Filter: ${filter} | Loop: ${loop}`;
}
else if (filter !== "Off" && loop !== "Off" && autoplay !== "Off") {
status = `Filter: ${filter} | Loop: ${loop} | Autoplay: ${autoplay}`;
}
else if (filter === "Off" && loop !== "Off" && autoplay !== "Off") {
status = `Loop: ${loop} | Autoplay: ${autoplay}`;
}
else if (filter === "Off" && loop === "Off" && autoplay !== "Off") {
status = `Autoplay: ${autoplay}`;
}
else {
status = "";
}
if (!status) {
return `Volume: ${volume}%`;
} else {
return `Volume: ${volume}% | ${status}`;
}
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
const songs = queue.songs;
if (songs.length === 0) return await interaction.reply({ content: "❌ | The queue is empty!" });
const currentSong = songs[0];
const upcomingSongs = songs.slice(1, 11); // Show up to 10 upcoming songs
let queueString = `**Now Playing:**\n🎵 [${currentSong.name}](${currentSong.url}) - \`${currentSong.formattedDuration}\`\n\n`;
if (upcomingSongs.length > 0) {
queueString += "**Up Next:**\n";
upcomingSongs.forEach((song: any, index: number) => {
queueString += `\`${index + 1}.\` [${song.name}](${song.url}) - \`${song.formattedDuration}\`\n`;
});
if (songs.length > 11) {
queueString += `\n*...and ${songs.length - 11} more songs*`;
}
}
const embed = new Discord.EmbedBuilder()
.setTitle("Music Queue")
.setDescription(queueString)
.setFooter({ text: this.queueStatus(queue) })
.setColor("Blue")
.setTimestamp();
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,30 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class ResumeCommand extends BotCommand {
constructor() {
super("resume", "Resume Command", "/resume");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if(!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
if(!queue.paused) return await interaction.reply({ content: `❌ | The queue is being played.` });
client.distube.resume(interaction);
const embed = new Discord.EmbedBuilder()
.setDescription(`▶ | Successfully **Resumed** a song.`)
.setFooter({ text: `Request by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() });
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,45 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class SeekCommand extends BotCommand {
constructor() {
super("seek", "Seek to a specific time in the current song", "/seek");
this.data.addStringOption(option =>
option.setName('time')
.setDescription('Time to seek to (e.g., 1:30 or 90)')
.setRequired(true)
);
}
private parseTime(timeString: string): number {
// Handle MM:SS format
if (timeString.includes(':')) {
const parts = timeString.split(':').map(part => parseInt(part));
return parts.reduce((acc, time) => (60 * acc) + time) * 1000; // Convert to milliseconds
}
// Handle seconds format
return parseInt(timeString) * 1000;
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
const timeString = interaction.options.getString('time')!;
try {
const timeMs = this.parseTime(timeString);
await queue.seek(timeMs);
return await interaction.reply({ content: `⏩ | Seeked to ${timeString}` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,27 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class SkipCommand extends BotCommand {
constructor() {
super("skip", "Skip a song or track", "/skip");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
try {
const song = await queue.skip();
return await interaction.reply({ content: `✅ | Skipped! Now playing:\n${song.name}` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,28 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class StopCommand extends BotCommand {
constructor() {
super("stop", "Stop Command", "/stop");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if(!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
client.distube.stop(interaction);
const embed = new Discord.EmbedBuilder()
.setDescription(` Successfully **Stopped** the music.`)
.setFooter({ text: `Request by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() });
return await interaction.reply({ embeds: [embed] });
}
}

View File

@@ -0,0 +1,32 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember } from "discord.js";
export default class VolumeCommand extends BotCommand {
constructor() {
super("volume", "Volume Command", "/volume");
this.data.addNumberOption(option =>
option.setName('number').setDescription('Volume Level').setRequired(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const volume = interaction.options.getNumber('number');
const member = interaction.member as GuildMember;
if(!member?.voice?.channel) {
return await interaction.reply({ content: "❌ | You need to be in a voice channel!" });
}
const queue = client.distube.getQueue(interaction);
if (!queue) return await interaction.reply({ content: `❌ | There is no music playing!` });
try {
queue.setVolume(volume);
return await interaction.reply({ content: `:white_check_mark: | Volume set to \`${volume?.toString()}\`` });
} catch (e) {
return await interaction.reply({ content: `${e}` });
}
}
}

View File

@@ -0,0 +1,252 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags } from "discord.js";
export default class BirthdayCommand extends BotCommand {
constructor() {
super("bday", "Birthday Command", "/bday");
this.data.addSubcommand(subcommand =>
subcommand.setName("set")
.setDescription("Set your birthday")
.addNumberOption(option =>
option.setName("day")
.setDescription("Your birthday day (1-31)")
.setRequired(true)
)
.addNumberOption(option =>
option.setName("month")
.setDescription("Your birthday month (1-12)")
.setRequired(true)
)
.addStringOption(option =>
option.setName("timezone")
.setDescription("Your timezone (e.g., 'EST', GMT+2, etc.)")
.setRequired(false)
)
);
this.data.addSubcommand(subcommand =>
subcommand.setName("get")
.setDescription("Get your birthday")
.addUserOption(option =>
option.setName("user")
.setDescription("The user whose birthday you want to get")
.setRequired(false)
)
);
this.data.addSubcommand(subcommand =>
subcommand.setName("remove")
.setDescription("Remove your birthday")
);
this.data.addSubcommand(subcommand =>
subcommand.setName("list")
.setDescription("List all birthdays")
);
}
dateToEpooch(day: number, month: number, timezone?: string): number {
const now = new Date();
const currentYear = now.getFullYear();
let birthdayDate = new Date(currentYear, month - 1, day, 0, 0, 0, 0);
if (birthdayDate < now) {
birthdayDate = new Date(currentYear + 1, month - 1, day, 0, 0, 0, 0);
}
if (timezone) {
const offsetHours = this.getTimezoneOffset(timezone);
birthdayDate.setHours(birthdayDate.getHours() - offsetHours);
}
return Math.floor(birthdayDate.getTime() / 1000);
}
getTimezoneOffset(timezone: string): number {
const tz = timezone.toLowerCase();
// Handle GMT+/-N format
const gmtMatch = tz.match(/^gmt([+-])(\d+)$/);
if (gmtMatch) {
const sign = gmtMatch[1] === '+' ? 1 : -1;
const hours = parseInt(gmtMatch[2]);
return sign * hours;
}
// Handle UTC+/-N format
const utcMatch = tz.match(/^utc([+-])(\d+)$/);
if (utcMatch) {
const sign = utcMatch[1] === '+' ? 1 : -1;
const hours = parseInt(utcMatch[2]);
return sign * hours;
}
// Predefined timezone offsets (in hours from UTC)
const offsets: { [key: string]: number } = {
"est": -5, // Eastern Standard Time
"edt": -4, // Eastern Daylight Time
"cst": -6, // Central Standard Time
"cdt": -5, // Central Daylight Time
"mst": -7, // Mountain Standard Time
"mdt": -6, // Mountain Daylight Time
"pst": -8, // Pacific Standard Time
"pdt": -7, // Pacific Daylight Time
"gmt": 0, // Greenwich Mean Time
"utc": 0 // Coordinated Universal Time
};
return offsets[tz] || 0; // Default to UTC if not found
}
async setCommand(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const day = interaction.options.getNumber("day")!;
const month = interaction.options.getNumber("month")!;
let timezone = interaction.options.getString("timezone");
timezone = timezone ? timezone.trim() : null;
timezone = timezone ? timezone.toLowerCase() : null;
// Validate timezone format to lowercase alphanumeric and special characters
if (timezone && !/^[a-zA-Z0-9+_-]+$/.test(timezone)) {
return interaction.reply({
content: "Invalid timezone format. Please use alphanumeric characters, plus (+), underscore (_), or hyphen (-).",
flags: MessageFlags.Ephemeral
});
}
if (isNaN(day) || isNaN(month)) {
return interaction.reply({
content: "Invalid date provided. Please ensure the day and month are numbers.",
flags: MessageFlags.Ephemeral
});
}
if (day < 1 || day > 31 || month < 1 || month > 12) {
return interaction.reply({
content: "Invalid date provided. Please ensure the day is between 1-31 and the month is between 1-12.",
flags: MessageFlags.Ephemeral
});
}
client.config.setBday(interaction.user.id, { day, month, timezone: timezone || undefined });
return interaction.reply({
content: `Your birthday has been set to ${day}/${month} ${timezone ? `in timezone ${timezone}` : ''}.\nYour next birthday will be on <t:${this.dateToEpooch(day, month, timezone || undefined)}:D>.`,
flags: MessageFlags.Ephemeral
});
}
async getCommand(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const user = interaction.options.getUser("user") || interaction.user;
const bday = client.config.getBday(user.id);
if (!bday) {
return interaction.reply({
content: `\`${user.username}\` has not set a birthday.`,
flags: MessageFlags.Ephemeral
});
}
const timezone = bday.timezone ? ` in timezone ${bday.timezone}` : '';
return interaction.reply({
content: `
\`${user.username}\`'s birthday is set to ${bday.month}/${bday.day}${timezone}.\nYour next birthday will be on <t:${this.dateToEpooch(bday.day, bday.month, bday.timezone)}:D>.
`,
flags: MessageFlags.Ephemeral
});
}
async removeCommand(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const user = interaction.user;
const bday = client.config.getBday(user.id);
if (!bday) {
return interaction.reply({
content: `\`${user.username}\`, you have not set a birthday to remove.`,
flags: MessageFlags.Ephemeral
});
}
client.config.removeBday(user.id);
return interaction.reply({
content: `\`${user.username}\`, your birthday has been removed.`,
flags: MessageFlags.Ephemeral
});
}
async listCommand(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const bdays = client.config.getBdays();
if (!bdays || Object.keys(bdays).length === 0) {
return interaction.reply({
content: "No birthdays have been set yet.",
flags: MessageFlags.Ephemeral
});
}
// Upcoming birthdays
let response = "";
const today = new Date();
const upcoming = Object.entries(bdays).map(([userId, bday]) => {
const nextBirthday = new Date(today.getFullYear(), bday.month - 1, bday.day);
if (nextBirthday < today) {
nextBirthday.setFullYear(today.getFullYear() + 1); // Move to next year if birthday has passed
}
return { userId, bday, nextBirthday };
}).sort((a, b) => a.nextBirthday.getTime() - b.nextBirthday.getTime());
// Filter out birthdays that are more than 30 days away
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setDate(today.getDate() + 30);
const filteredUpcoming = upcoming.filter(b => b.nextBirthday <= thirtyDaysFromNow);
if (filteredUpcoming.length === 0) {
response = "No upcoming birthdays within the next 30 days.";
return interaction.reply({
content: response,
flags: MessageFlags.Ephemeral
});
}
for (const { userId, bday, nextBirthday } of upcoming) {
const user = await client.users.fetch(userId);
response += `\`${user.username}\`: <t:${Math.floor(nextBirthday.getTime() / 1000)}:D>\n`;
}
if (upcoming.length === 0) {
response = "No upcoming birthdays.";
}
const embed = new Discord.EmbedBuilder()
.setTitle("Upcoming Birthdays")
.setDescription(response)
.setColor("Blue")
.setTimestamp();
return interaction.reply({
embeds: [embed],
flags: MessageFlags.Ephemeral
});
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const subcommand = interaction.options.getSubcommand();
switch (subcommand) {
case "set":
return this.setCommand(Discord, client, interaction);
case "get":
return this.getCommand(Discord, client, interaction);
case "remove":
return this.removeCommand(Discord, client, interaction);
case "list":
return this.listCommand(Discord, client, interaction);
default:
return interaction.reply({
content: "Unknown subcommand.",
flags: MessageFlags.Ephemeral
});
}
}
}

121
src/commands/users/daily.ts Normal file
View File

@@ -0,0 +1,121 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
import QuestionScheduler from "../../libs/QuestionScheduler";
import AnswerGrader from "../../libs/AnswerGrader";
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
export default class DailyCommand extends BotCommand {
constructor() {
super("daily", "Answer today's daily STEM question", "/daily");
this.data.addStringOption(option =>
option.setName("subject")
.setDescription("Choose a subject")
.addChoices(
{ name: "Mathematics", value: "mathematics" },
{ name: "Physics", value: "physics" },
{ name: "Chemistry", value: "chemistry" },
{ name: "Organic Chemistry", value: "organic chemistry" },
{ name: "Biology", value: "biology" },
{ name: "Computer Science", value: "computer science" },
{ name: "Engineering", value: "engineering" }
)
.setRequired(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const subject = interaction.options.getString("subject", true);
const scheduler = new QuestionScheduler();
await scheduler.initialize();
const question = await scheduler.getQuestionForPeriod(subject, "daily");
if (!question) {
return interaction.editReply({ content: `Failed to load today's ${subject} question. Please try again later.` });
}
const hasAnswered = await scheduler.hasUserAnswered(interaction.user.id, question.id);
if (hasAnswered) {
return interaction.editReply({ content: "You've already submitted an answer for today's question! Check back tomorrow." });
}
const imagePath = await client.typst.renderToImage(question.typst_source);
if (!imagePath) {
// Log error to logs channel
const logsChannelId = process.env.LOGS_CHANNEL_ID;
if (logsChannelId) {
try {
const logsChannel = await client.channels.fetch(logsChannelId) as TextChannel;
if (logsChannel?.isTextBased()) {
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Typst Render Error - Daily Question")
.setColor(0xef4444)
.addFields(
{ name: "Subject", value: subject, inline: true },
{ name: "Question ID", value: question.id, inline: true },
{ name: "Topic", value: question.topic, inline: false },
{ name: "User", value: `<@${interaction.user.id}>`, inline: true },
{ name: "Error", value: "Failed to render Typst image", inline: false }
)
.setTimestamp();
await logsChannel.send({ embeds: [errorEmbed] });
}
} catch (logErr) {
console.error("Failed to send error to logs channel:", logErr);
}
}
return interaction.editReply({
content: `❌ An error occurred while generating the question image. This has been reported to administrators.`
});
}
const embed = new EmbedBuilder()
.setTitle(`📅 Daily ${subject} Question`)
.setDescription(`**Topic:** ${question.topic}\n**Difficulty:** ${question.difficulty_rating}`)
.setColor(0x60a5fa)
.setImage(`attachment://daily_${subject}.png`)
.setFooter({ text: `Resets at 12 AM local time` })
.setTimestamp();
const attachment = new AttachmentBuilder(imagePath, { name: `daily_${subject}.png` });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(`daily_answer_${question.id}`)
.setLabel("Submit Answer")
.setStyle(ButtonStyle.Primary)
.setEmoji("✍️"),
new ButtonBuilder()
.setCustomId(`daily_report_${question.id}`)
.setLabel("Report Issue")
.setStyle(ButtonStyle.Danger)
.setEmoji("⚠️")
);
await interaction.editReply({
embeds: [embed],
files: [attachment],
components: [row],
});
} catch (err) {
console.error("Error executing daily command:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,61 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder } from "discord.js";
import Team from "../../models/Team";
import PointsManager from "../../libs/PointsManager";
import Database from "../../libs/Database";
export default class JoinTeamCommand extends BotCommand {
constructor() {
super("jointeam", "Join a team", "/jointeam");
this.data.addStringOption(option =>
option.setName("team")
.setDescription("Team name to join")
.setRequired(true)
.setAutocomplete(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const db = Database.getInstance();
if (!db.isConnected()) {
return interaction.reply({ content: "❌ Database not connected. Points system unavailable.", flags: MessageFlags.Ephemeral });
}
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const teamName = interaction.options.getString("team", true);
const team = await Team.findOne({ name: teamName });
if (!team) {
return interaction.editReply({ content: `❌ Team **${teamName}** not found.` });
}
const result = await PointsManager.joinTeam(interaction.user.id, interaction.user.username, team._id.toString());
if (result.success) {
const embed = new EmbedBuilder()
.setTitle("✅ Team Joined!")
.setColor(0x22c55e)
.setDescription(result.message)
.addFields(
{ name: "Team", value: team.name, inline: true },
{ name: "Members", value: team.memberCount.toString(), inline: true },
{ name: "Team Points", value: `${team.adjustedPoints}`, inline: true }
)
.setFooter({ text: "Earn points for your team by completing questions!" })
.setTimestamp();
return interaction.editReply({ content: null, embeds: [embed] });
} else {
return interaction.editReply({ content: `${result.message}` });
}
} catch (error) {
console.error("Error in join-team command:", error);
return interaction.editReply({ content: "❌ An error occurred while joining the team." });
}
}
}

View File

@@ -0,0 +1,95 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, PermissionFlagsBits } from "discord.js";
import PointsManager from "../../libs/PointsManager";
import Database from "../../libs/Database";
export default class LeaderboardCommand extends BotCommand {
constructor() {
super("leaderboard", "View user or team leaderboards", "/leaderboard");
this.data.addStringOption(option =>
option.setName("type")
.setDescription("Leaderboard type")
.addChoices(
{ name: "Users", value: "users" },
{ name: "Teams", value: "teams" }
)
.setRequired(true)
);
this.data.addIntegerOption(option =>
option.setName("limit")
.setDescription("Number of entries to show (default: 10)")
.setMinValue(5)
.setMaxValue(25)
.setRequired(false)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const db = Database.getInstance();
if (!db.isConnected()) {
return interaction.reply({ content: "❌ Database not connected. Points system unavailable.", flags: MessageFlags.Ephemeral });
}
try {
await interaction.deferReply();
const type = interaction.options.getString("type", true);
const limit = interaction.options.getInteger("limit") || 10;
if (type === "users") {
const users = await PointsManager.getLeaderboard(limit);
if (users.length === 0) {
return interaction.editReply({ content: "No users found in the leaderboard yet." });
}
const embed = new EmbedBuilder()
.setTitle("🏆 User Leaderboard")
.setColor(0xfacc15)
.setDescription(users.map((u, i) => {
const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `**${i + 1}.**`;
return `${medal} <@${u.userId}> - **${u.points}** points\n` +
` 📊 Daily: ${u.dailyQuestionsCompleted} | Weekly: ${u.weeklyQuestionsCompleted}`;
}).join("\n\n"))
.setFooter({ text: "Complete questions to earn points!" })
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
} else {
const teams = await PointsManager.getTeamLeaderboard(limit);
if (teams.length === 0) {
return interaction.editReply({ content: "No teams found in the leaderboard yet." });
}
const isAdmin = interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild) || false;
const embed = new EmbedBuilder()
.setTitle("🏆 Team Leaderboard")
.setColor(0x60a5fa)
.setDescription(teams.map((t, i) => {
const medal = i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `**${i + 1}.**`;
if (isAdmin) {
return `${medal} **${t.name}** - **${t.adjustedPoints}** points (adjusted)\n` +
` Members: ${t.memberCount} | Raw Points: ${t.points}`;
} else {
return `${medal} **${t.name}** - **${t.adjustedPoints}** points\n` +
` Members: ${t.memberCount}`;
}
}).join("\n\n"))
.setFooter({ text: "Smaller teams get bonus multipliers!" })
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
}
} catch (error) {
console.error("Error in leaderboard command:", error);
return interaction.editReply({ content: "❌ An error occurred." });
}
}
}

View File

@@ -0,0 +1,32 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction } from "discord.js";
export default class ReportCommand extends BotCommand {
constructor() {
super("report", "Report Command", "/report");
this.data.addUserOption(option => option.setName("user").setDescription("User to report").setRequired(true));
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const user = interaction.options.getUser('user');
const modal = new Discord.ModalBuilder()
.setCustomId(`report-${user?.id}`)
.setTitle("Report User")
const reasonInput = new Discord.TextInputBuilder()
.setCustomId("reasonInput")
.setLabel("Enter a reason")
.setStyle(Discord.TextInputStyle.Paragraph)
.setPlaceholder('Please be specific and include any evidence.\nCopy and paste any messages or message links.')
.setRequired(true);
const firstActionRow = new Discord.ActionRowBuilder().addComponents(reasonInput);
modal.addComponents(firstActionRow);
return await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,45 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, GuildMember, ThreadChannel } from "discord.js";
export default class SolvedCommand extends BotCommand {
constructor() {
super("solved", "Solved Command", "/solved");
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
await interaction.reply({ content: `⏳ One Moment Please. . .` });
const channel = interaction.channel as ThreadChannel;
const isThreadOwner = channel?.ownerId === interaction.user.id;
const StaffRoles = ["1310700035842768908", "1311937894306414632"];
const member = interaction.member as GuildMember;
const isStaff = member?.roles?.cache?.some(role => StaffRoles.includes(role.id));
if(!isThreadOwner && !isStaff) {
return await interaction.editReply({ content: "You do not have permission to use this command." });
}
const availableTags = (channel?.parent as any)?.availableTags;
const channelTags = channel?.appliedTags;
if (!availableTags || !channelTags) {
return await interaction.editReply({ content: "This command can only be used in thread channels." });
}
const solvedTag = availableTags.find((tag: any) => tag.name === "Solved");
if (!solvedTag) {
return await interaction.editReply({ content: "No 'Solved' tag found in this forum." });
}
if(!channelTags.includes(solvedTag.id)) {
await channel?.setAppliedTags([...channelTags, solvedTag.id]);
return await interaction.editReply({ content: "This channel has been marked `Solved`" });
} else {
await channel?.setAppliedTags(channelTags.filter((tag: string) => tag !== solvedTag.id));
return await interaction.editReply({ content: "This channel has been unmarked `Solved`" });
}
}
}

View File

@@ -0,0 +1,60 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder } from "discord.js";
import PointsManager from "../../libs/PointsManager";
import Database from "../../libs/Database";
export default class StatsCommand extends BotCommand {
constructor() {
super("stats", "View your stats or another user's stats", "/stats");
this.data.addUserOption(option =>
option.setName("user")
.setDescription("User to view stats for (leave empty for yourself)")
.setRequired(false)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
const db = Database.getInstance();
if (!db.isConnected()) {
return interaction.reply({ content: "❌ Database not connected. Points system unavailable.", flags: MessageFlags.Ephemeral });
}
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const targetUser = interaction.options.getUser("user") || interaction.user;
const userStats = await PointsManager.getUserStats(targetUser.id);
if (!userStats) {
return interaction.editReply({
content: `${targetUser.id === interaction.user.id ? "You haven't" : `<@${targetUser.id}> hasn't`} completed any questions yet.`
});
}
const teamInfo = userStats.teamId ? (userStats.teamId as any).name : "No team";
const embed = new EmbedBuilder()
.setTitle(`📊 Stats for ${targetUser.username}`)
.setColor(0x60a5fa)
.setThumbnail(targetUser.displayAvatarURL())
.addFields(
{ name: "Points", value: `**${userStats.points}**`, inline: true },
{ name: "Team", value: teamInfo, inline: true },
{ name: "Joined", value: `<t:${Math.floor(userStats.joinedAt.getTime() / 1000)}:R>`, inline: true },
{ name: "Daily Questions", value: userStats.dailyQuestionsCompleted.toString(), inline: true },
{ name: "Weekly Questions", value: userStats.weeklyQuestionsCompleted.toString(), inline: true },
{ name: "Last Active", value: `<t:${Math.floor(userStats.lastActive.getTime() / 1000)}:R>`, inline: true }
)
.setFooter({ text: "Keep solving to climb the leaderboard!" })
.setTimestamp();
return interaction.editReply({ embeds: [embed] });
} catch (error) {
console.error("Error in stats command:", error);
return interaction.editReply({ content: "❌ An error occurred." });
}
}
}

View File

@@ -0,0 +1,121 @@
import BotCommand from "../../libs/BotCommand";
import BotClient from "../../libs/BotClient";
import { ChatInputCommandInteraction, MessageFlags, EmbedBuilder, AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, TextChannel } from "discord.js";
import QuestionScheduler from "../../libs/QuestionScheduler";
import AnswerGrader from "../../libs/AnswerGrader";
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Biology", "Computer Science", "Engineering"];
export default class WeeklyCommand extends BotCommand {
constructor() {
super("weekly", "Answer this week's weekly STEM question", "/weekly");
this.data.addStringOption(option =>
option.setName("subject")
.setDescription("Choose a subject")
.addChoices(
{ name: "Mathematics", value: "mathematics" },
{ name: "Physics", value: "physics" },
{ name: "Chemistry", value: "chemistry" },
{ name: "Organic Chemistry", value: "organic chemistry" },
{ name: "Biology", value: "biology" },
{ name: "Computer Science", value: "computer science" },
{ name: "Engineering", value: "engineering" }
)
.setRequired(true)
);
}
override async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const subject = interaction.options.getString("subject", true);
const scheduler = new QuestionScheduler();
await scheduler.initialize();
const question = await scheduler.getQuestionForPeriod(subject, "weekly");
if (!question) {
return interaction.editReply({ content: `Failed to load this week's ${subject} question. Please try again later.` });
}
const hasAnswered = await scheduler.hasUserAnswered(interaction.user.id, question.id);
if (hasAnswered) {
return interaction.editReply({ content: "You've already submitted an answer for this week's question! Check back next Sunday." });
}
const imagePath = await client.typst.renderToImage(question.typst_source);
if (!imagePath) {
// Log error to logs channel
const logsChannelId = process.env.LOGS_CHANNEL_ID;
if (logsChannelId) {
try {
const logsChannel = await client.channels.fetch(logsChannelId) as TextChannel;
if (logsChannel?.isTextBased()) {
const errorEmbed = new EmbedBuilder()
.setTitle("❌ Typst Render Error - Weekly Question")
.setColor(0xef4444)
.addFields(
{ name: "Subject", value: subject, inline: true },
{ name: "Question ID", value: question.id, inline: true },
{ name: "Topic", value: question.topic, inline: false },
{ name: "User", value: `<@${interaction.user.id}>`, inline: true },
{ name: "Error", value: "Failed to render Typst image", inline: false }
)
.setTimestamp();
await logsChannel.send({ embeds: [errorEmbed] });
}
} catch (logErr) {
console.error("Failed to send error to logs channel:", logErr);
}
}
return interaction.editReply({
content: `❌ An error occurred while generating the question image. This has been reported to administrators.`
});
}
const embed = new EmbedBuilder()
.setTitle(`🎓 Weekly ${subject} Question (PhD Level)`)
.setDescription(`**Topic:** ${question.topic}\n**Difficulty:** ${question.difficulty_rating}`)
.setColor(0xfacc15)
.setImage(`attachment://weekly_${subject}.png`)
.setFooter({ text: `Resets at 12 AM Sunday` })
.setTimestamp();
const attachment = new AttachmentBuilder(imagePath, { name: `weekly_${subject}.png` });
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(
new ButtonBuilder()
.setCustomId(`weekly_answer_${question.id}`)
.setLabel("Submit Answer")
.setStyle(ButtonStyle.Primary)
.setEmoji("✍️"),
new ButtonBuilder()
.setCustomId(`weekly_report_${question.id}`)
.setLabel("Report Issue")
.setStyle(ButtonStyle.Danger)
.setEmoji("⚠️")
);
await interaction.editReply({
embeds: [embed],
files: [attachment],
components: [row],
});
} catch (err) {
console.error("Error executing weekly command:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,19 @@
import { post } from "../../../handlers/command_handler";
import BotClient from "../../../libs/BotClient";
import ms from "ms";
export default async(Discord: any, client: BotClient) => {
client.user?.setPresence({ activities: [{ name: '/help' }] });
try {
post(client);
setInterval(() => {
client.emit("birthdayCheck");
}, ms('30m'));
} catch (err) {
console.log(err);
} finally {
console.log(`Logged in as ${client.user?.tag}!`);
}
}

View File

@@ -0,0 +1,76 @@
import fs from 'fs';
import BotClient from "../../../libs/BotClient";
export default async(Discord: any, client: BotClient, message: any) => {
// let content = message.content;
// if (message.channel && typeof message.channel.sendTyping === 'function') {
// try {
// await message.channel.sendTyping();
// } catch (e) {
// console.error('Error sending typing indicator:', e);
// }
// }
// try {
// const { content: messageContent, typst } = await client.ai.generateText(message.author.id, message.author.username, content);
// const userId = message.author.id;
// const typPath = `./storage/typst/message_${userId}.typ`;
// const pdfPath = `./storage/typst/message_${userId}.pdf`;
// try {
// fs.writeFileSync(typPath, typst || messageContent);
// } catch (err) {
// console.error('Error writing typst file:', err);
// await message.reply({
// content: "Failed to write Typst file.",
// allowedMentions: { repliedUser: true }
// });
// return;
// }
// const { execSync } = require('child_process');
// let pdfBuffer = null;
// let hasPDF = false;
// try {
// execSync(`typst compile "${typPath}" "${pdfPath}"`);
// if (fs.existsSync(pdfPath)) {
// try {
// pdfBuffer = fs.readFileSync(pdfPath);
// hasPDF = true;
// } catch (readErr) {
// console.error('Error reading PDF file:', readErr);
// }
// }
// } catch (e) {
// console.error('Typst compile error:', e);
// await message.reply({
// content: "Failed to compile Typst to PDF.",
// allowedMentions: { repliedUser: true }
// });
// return;
// }
// const messageOptions: any = {
// content: messageContent,
// allowedMentions: { repliedUser: true }
// };
// if (hasPDF && pdfBuffer) {
// messageOptions.files = [
// {
// attachment: pdfBuffer,
// name: `message_${userId}.pdf`
// }
// ];
// }
// console.log('Sending message with options:', {
// contentLength: messageContent.length,
// hasFiles: !!messageOptions.files,
// fileCount: messageOptions.files ? messageOptions.files.length : 0
// });
// await message.reply(messageOptions);
// } catch (error) {
// console.error('Error in AI chat:', error);
// await message.reply({
// content: "I'm having trouble processing your request right now. Please try again later.",
// allowedMentions: { repliedUser: true }
// });
// }
};

View File

@@ -0,0 +1,115 @@
import BotClient from "../../../libs/BotClient";
const birthdayRole = "1385648190551888014";
const chemistryGuildID = "1310183749320835173";
function getTimezoneOffset(timezone: string): number {
const tz = timezone.toLowerCase();
// Handle GMT+/-N format
const gmtMatch = tz.match(/^gmt([+-])(\d+)$/);
if (gmtMatch) {
const sign = gmtMatch[1] === '+' ? 1 : -1;
const hours = parseInt(gmtMatch[2]);
return sign * hours;
}
// Handle UTC+/-N format
const utcMatch = tz.match(/^utc([+-])(\d+)$/);
if (utcMatch) {
const sign = utcMatch[1] === '+' ? 1 : -1;
const hours = parseInt(utcMatch[2]);
return sign * hours;
}
// Predefined timezone offsets (in hours from UTC)
const offsets: { [key: string]: number } = {
"est": -5, // Eastern Standard Time
"edt": -4, // Eastern Daylight Time
"cst": -6, // Central Standard Time
"cdt": -5, // Central Daylight Time
"mst": -7, // Mountain Standard Time
"mdt": -6, // Mountain Daylight Time
"pst": -8, // Pacific Standard Time
"pdt": -7, // Pacific Daylight Time
"gmt": 0, // Greenwich Mean Time
"utc": 0 // Coordinated Universal Time
};
return offsets[tz] || 0; // Default to UTC if not found
}
function getTodayInTimezone(timezone?: string): Date {
const now = new Date();
if (!timezone) {
const today = new Date(now);
today.setHours(0, 0, 0, 0);
return today;
}
const offsetHours = getTimezoneOffset(timezone);
const userTime = new Date(now.getTime() + (offsetHours * 60 * 60 * 1000));
userTime.setUTCHours(0, 0, 0, 0);
return userTime;
}
export default async(Discord: any, client: BotClient) => {
let bdays = client.config.getBdays();
if (!bdays || Object.keys(bdays).length === 0) return;
let guild = client.guilds.cache.get(chemistryGuildID);
if (!guild) return console.error("Guild not found");
let birthdayRoleObj = guild.roles.cache.get(birthdayRole);
if (!birthdayRoleObj) return console.error("Birthday role not found");
for (let userId in bdays) {
let bday = bdays[userId];
if (!bday || !bday.day || !bday.month) {
console.warn(`Invalid birthday data for user ${userId}`);
continue;
}
const { day, month, timezone } = bday;
const todayInUserTimezone = getTodayInTimezone(timezone);
const isBirthday = (
todayInUserTimezone.getUTCDate() === day &&
todayInUserTimezone.getUTCMonth() === month - 1
);
let member = await guild.members.fetch(userId);
if (!member) {
console.warn(`Member with ID ${userId} not found in the guild.`);
continue;
}
if (isBirthday) {
if (!member.roles.cache.has(birthdayRole)) {
try {
console.log(`🎉 It's ${member.user.tag}'s birthday!`);
await member.roles.add(birthdayRoleObj);
console.log(`✅ Added birthday role to ${member.user.tag}`);
} catch (error) {
console.error(`❌ Failed to add birthday role to ${member.user.tag}:`, error);
}
}
} else {
if (member.roles.cache.has(birthdayRole)) {
try {
await member.roles.remove(birthdayRoleObj);
console.log(`🗑️ Removed birthday role from ${member.user.tag}`);
} catch (error) {
console.error(`❌ Failed to remove birthday role from ${member.user.tag}:`, error);
}
}
}
}
}

View File

@@ -0,0 +1,50 @@
import BotClient from "../../../libs/BotClient";
export default async (Discord: any, client: BotClient, message: any) => {
try {
if (message?.author?.bot) return;
const match = message.content?.match(/```typ\s*\n([\s\S]*?)\n```/i);
if (!match) return;
const typSource = match[1];
const messageId = message.id?.toString();
if (messageId && client.typst.hasMessage(messageId)) return;
try { if (message.channel?.sendTyping) await message.channel.sendTyping(); } catch (e) {}
const res = await client.typst.compile(typSource, { cleanup: true });
if (res.error) {
await message.reply({ content: `Typst compile error: ${res.error}`, allowedMentions: { repliedUser: true } });
return;
}
const buffers = res.buffers ?? [];
if (buffers.length === 0) {
await message.reply({ content: 'Compilation finished but no PNG files were produced.', allowedMentions: { repliedUser: true } });
return;
}
const filesToSend = buffers.map((b: Buffer, i: number) => ({ attachment: b, name: `typ_${messageId ?? Date.now()}_${i+1}.png` }));
const makeDeleteRow = () => {
const btn = new Discord.ButtonBuilder().setCustomId('typst_delete').setLabel('Delete').setStyle(Discord.ButtonStyle.Danger);
return new Discord.ActionRowBuilder().addComponents(btn);
};
const replyOpts = { content: `Here's your Typst render (${filesToSend.length} page(s)):`, files: filesToSend, components: [makeDeleteRow()], allowedMentions: { repliedUser: true } };
const replyMsg = await message.reply(replyOpts);
if (messageId && replyMsg?.id) {
client.typst.addMessage(messageId, replyMsg.id, message.author?.id);
setTimeout(async () => {
try {
client.typst.removeMessage(messageId);
try { await replyMsg.edit({ components: [] }); } catch (e) {}
} catch (e) {}
}, 2 * 60 * 1000);
}
} catch (err) {
console.error('typst message handler error:', err);
}
};

View File

@@ -0,0 +1,6 @@
import BotClient from "../../../libs/BotClient";
export default async(Discord: any, client: BotClient, member: any) => {
let memberRole = await member.guild.roles.cache.find((role: any) => role.id === '1310840470037069936');
member.roles.add(memberRole);
}

View File

@@ -0,0 +1,54 @@
import BotClient from "../../../libs/BotClient";
import { PermissionFlagsBits } from "discord.js";
import { MessageFlags } from "discord.js";
import Team from "../../../models/Team";
export default async(Discord: any, client: BotClient, interaction: any) => {
if(interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) {
client.action.forEach((value: any, key: string) => {
if(interaction.customId.includes(key)) return value.execute(Discord, client, interaction);
});
};
if (interaction.isAutocomplete()) {
if (interaction.commandName === "jointeam") {
const focusedValue = interaction.options.getFocused().toLowerCase();
const teams = await Team.find().exec();
const filtered = teams
.filter(t => t.name.toLowerCase().includes(focusedValue))
.slice(0, 25)
.map(t => ({ name: t.name, value: t.name }));
return interaction.respond(filtered);
}
}
if (interaction.isChatInputCommand()) {
if(process.env.isDev == "true" && !(interaction.member.permissions.has(PermissionFlagsBits.ManageGuild) || interaction.member.permissions.has(PermissionFlagsBits.Administrator))) {
return interaction.reply({ content: `:x: - You are not allowed to use this bot in a development environment`, flags: MessageFlags.Ephemeral });
}
let command = client.commands.get(interaction.commandName);
if(!command) return;
if(!command.isEnabled()) return interaction.reply({ content: `:x: - This Command is not Enabled`, flags: MessageFlags.Ephemeral });
try {
client.logger.log(`&2Command Executed: &f${interaction.commandName} - &5${interaction.user.username}`);
await command.execute(Discord, client, interaction);
} catch(error) {
console.log(error);
client.logger.log(`An error occured while executing the command: ${interaction.commandName}`);
client.logger.log(`${error}`);
const embed = client.formatter.buildEmbed("./responses/error.yaml");
return interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
};
if(interaction.isCommand()) {
if(!interaction.isChatInputCommand()) client.logger.log(`&2Interaction created: &f${interaction.commandName} - &5${interaction.user.username}`);
};
}

View File

@@ -0,0 +1,39 @@
import BotClient from "../../../libs/BotClient";
import ms from "ms";
let cooldown = false;
export default async(Discord: any, client: BotClient, message: any) => {
if(message.channel.id == "1311034475727028305") {
if(message.author.bot) return;
message.react(message.guild.emojis.cache.get('1310753663001563156'))
}
if(message.author.bot) return;
// typst code block -> emit typst event
try {
const hasTypst = /```typ\s*\n([\s\S]*?)\n```/i.test(message.content || "");
if(hasTypst) {
client.emit("typst", message);
return;
}
} catch (e) {
console.error('Error checking for typst block:', e);
}
const replyText = client.config.getReplyText();
if(replyText) {
for(const [key, value] of Object.entries(replyText)) {
if(message.content == key && !cooldown) {
cooldown = true;
setTimeout(() => {
cooldown = false;
}, ms("10s"));
return message.reply(value);
}
}
}
}

View File

@@ -0,0 +1,69 @@
import BotClient from "../../../libs/BotClient";
export default async(Discord: any, client: BotClient, oldMessage: any, newMessage: any) => {
try {
if (!newMessage || newMessage.author?.bot) return;
const match = newMessage.content?.match(/```typ\s*\n([\s\S]*?)\n```/i);
if (!match) return;
const userMsgId = newMessage.id?.toString();
if (!userMsgId) return;
if (!client.typst.hasMessage(userMsgId)) return;
const typSource = match[1];
const res = await client.typst.compile(typSource, { cleanup: true });
if (res.error) {
const botReplyId = client.typst.getResponse(userMsgId);
if (botReplyId && newMessage.channel?.messages) {
try {
const botMsg = await newMessage.channel.messages.fetch(botReplyId);
if (botMsg) await botMsg.edit({ content: `Typst compile error: ${res.error}` });
} catch (e) {}
}
return;
}
if (!res.buffers || res.buffers.length === 0) return;
const filesToSend = res.buffers.map((b: Buffer, i: number) => ({ attachment: b, name: `typ_${userMsgId}_${i+1}.png` }));
const makeDeleteRow = () => {
const deleteBtn = new Discord.ButtonBuilder().setCustomId('typst_delete').setLabel('Delete').setStyle(Discord.ButtonStyle.Danger);
return new Discord.ActionRowBuilder().addComponents(deleteBtn);
};
const replyOptions = { content: `Here's your updated Typst render (${filesToSend.length} page(s)):`, files: filesToSend, components: [makeDeleteRow()], allowedMentions: { repliedUser: true } };
const botReplyId = client.typst.getResponse(userMsgId);
let sent: any = null;
if (botReplyId && newMessage.channel?.messages) {
try {
const botMsg = await newMessage.channel.messages.fetch(botReplyId);
if (botMsg) {
try { await botMsg.delete(); } catch (e) {}
sent = await newMessage.reply(replyOptions);
}
} catch (e) {
try { sent = await newMessage.reply(replyOptions); } catch (e) {}
}
} else {
try { sent = await newMessage.reply(replyOptions); } catch (e) {}
}
if (sent && sent.id) {
client.typst.addMessage(userMsgId, sent.id, newMessage.author?.id);
setTimeout(async () => {
try {
client.typst.removeMessage(userMsgId);
try { await sent.edit({ components: [] }); } catch (e) {}
} catch (e) {}
}, 2 * 60 * 1000);
}
} catch (err) {
console.error('messageUpdate typst handler error:', err);
}
}

View File

@@ -0,0 +1,202 @@
import BotClient from "../../../libs/BotClient";
import ms from "ms";
async function sendTimeOutMessage(client: BotClient, threadChannel: any, roleID: string, category: string) {
let SolvedTag = await threadChannel.parent.availableTags.find((tag: any) => tag.name === "Solved");
if(threadChannel.appliedTags.includes(SolvedTag.id)) return;
let threadMembers = await threadChannel.guildMembers;
let helperRoles = client.config.getHelperRoles();
helperRoles = helperRoles.filter((role: any) => role.name !== "Student");
helperRoles = helperRoles.map((role: any) => role.id);
let helperInChannel = false;
threadMembers.forEach(async(threadMember: any) => {
if(threadMember.user.id == threadChannel.ownerId) return;
let member = await threadChannel.guild.members.fetch(threadMember.user.id);
let memberRoles = await member.roles.cache.map((role: any) => role.id);
memberRoles.forEach((role: any) => {
if (helperRoles.includes(role)) {
helperInChannel = true;
}
});
});
setTimeout(async() => {
if(helperInChannel) return;
let threadOwner = await threadChannel.guild.members.fetch(threadChannel.ownerId);
let helperChannel = await threadChannel.guild.channels.cache.find((channel: any) => channel.id === "1310993025958150166");
await helperChannel.send({
content: `<@&${roleID}> **${threadOwner.displayName}** has created ${threadChannel.url}. Please help the user with their ${category} question.`
});
}, ms("5s"));
}
async function mathHelpChannel(Discord: any, client: BotClient, threadChannel: any) {
const confirm = new Discord.ButtonBuilder()
.setCustomId('btn-rule-agree')
.setLabel('I Agree')
.setStyle(Discord.ButtonStyle.Success);
const threadEmbed = new Discord.EmbedBuilder()
.setColor("Red")
.setTitle("Math Help")
.setDescription(`
Make sure to follow the rules and guidelines of the server. <#1310725282357055518>
If you need help with a math problem, please provide the problem and any work you have done so far.
If you need help with a concept, please provide the concept you need help with.
Remember to be respectful and patient with others.
Ask your question and someone will help you as soon as possible.
Click the button below to confirm that you have read and understood the rules.
`)
.setTimestamp()
const row = new Discord.ActionRowBuilder().addComponents(confirm);
setTimeout(() => { threadChannel.send({ embeds: [threadEmbed], components: [row] }); }, ms("2s"));
setTimeout(async() => {
await sendTimeOutMessage(client, threadChannel, "1310718557016948788", "math");
}, ms("30m"));
}
async function programmingHelpChannel(Discord: any, client: BotClient, threadChannel: any) {
const confirm = new Discord.ButtonBuilder()
.setCustomId('btn-rule-agree')
.setLabel('I Agree')
.setStyle(Discord.ButtonStyle.Success);
const threadEmbed = new Discord.EmbedBuilder()
.setColor("Red")
.setTitle("Programming Help")
.setDescription(`
Make sure to follow the rules and guidelines of the server. <#1310725282357055518>
If you need help with a programming problem, please provide the problem and any code you have written so far.
If you need help with a concept, please provide the concept you need help with.
Remember to be respectful and patient with others.
Ask your question and someone will help you as soon as possible.
Click the button below to confirm that you have read and understood the rules.
`)
.setTimestamp()
const row = new Discord.ActionRowBuilder().addComponents(confirm);
setTimeout(() => { threadChannel.send({ embeds: [threadEmbed], components: [row] }); }, ms("2s"));
setTimeout(async() => {
await sendTimeOutMessage(client, threadChannel, "1310720256561381457", "programming");
}, ms("30m"));
}
async function chemistryHelpChannel(Discord: any, client: BotClient, threadChannel: any) {
const confirm = new Discord.ButtonBuilder()
.setCustomId('btn-rule-agree')
.setLabel('I Agree')
.setStyle(Discord.ButtonStyle.Success);
const threadEmbed = new Discord.EmbedBuilder()
.setColor("Red")
.setTitle("Chemistry Help")
.setDescription(`
Make sure to follow the rules and guidelines of the server. <#1310725282357055518>
If you need help with a chemistry problem, please provide the problem and any work you have done so far.
If you need help with a concept, please provide the concept you need help with.
Remember to be respectful and patient with others.
Ask your question and someone will help you as soon as possible.
Click the button below to confirm that you have read and understood the rules.
`)
.setTimestamp()
const row = new Discord.ActionRowBuilder().addComponents(confirm);
setTimeout(() => { threadChannel.send({ embeds: [threadEmbed], components: [row] }); }, ms("2s"));
setTimeout(async() => {
await sendTimeOutMessage(client, threadChannel, "1310715870821220497", "chemistry");
}, ms("30m"));
}
async function physicsHelpChannel(Discord: any, client: BotClient, threadChannel: any) {
const confirm = new Discord.ButtonBuilder()
.setCustomId('btn-rule-agree')
.setLabel('I Agree')
.setStyle(Discord.ButtonStyle.Success);
const threadEmbed = new Discord.EmbedBuilder()
.setColor("Red")
.setTitle("Physics Help")
.setDescription(`
Make sure to follow the rules and guidelines of the server. <#1310725282357055518>
If you need help with a physics problem, please provide the problem and any work you have done so far.
If you need help with a concept, please provide the concept you need help with.
Remember to be respectful and patient with others.
Ask your question and someone will help you as soon as possible.
Click the button below to confirm that you have read and understood the rules.
`)
.setTimestamp()
const row = new Discord.ActionRowBuilder().addComponents(confirm);
setTimeout(() => { threadChannel.send({ embeds: [threadEmbed], components: [row] }); }, ms("2s"));
setTimeout(async() => {
await sendTimeOutMessage(client, threadChannel, "1310717935588868206", "physics");
}, ms("30m"));
}
async function biologyHelpChannel(Discord: any, client: BotClient, threadChannel: any) {
const confirm = new Discord.ButtonBuilder()
.setCustomId('btn-rule-agree')
.setLabel('I Agree')
.setStyle(Discord.ButtonStyle.Success);
const threadEmbed = new Discord.EmbedBuilder()
.setColor("Red")
.setTitle("Biology Help")
.setDescription(`
Make sure to follow the rules and guidelines of the server. <#1310725282357055518>
If you need help with a biology problem, please provide the problem and any work you have done so far.
If you need help with a concept, please provide the concept you need help with.
Remember to be respectful and patient with others.
Ask your question and someone will help you as soon as possible.
Click the button below to confirm that you have read and understood the rules.
`)
.setTimestamp()
const row = new Discord.ActionRowBuilder().addComponents(confirm);
setTimeout(() => { threadChannel.send({ embeds: [threadEmbed], components: [row] }); }, ms("2s"));
setTimeout(async() => {
await sendTimeOutMessage(client, threadChannel, "1310719093996785705", "biology");
}, ms("30m"));
}
export default async(Discord: any, client: BotClient, threadChannel: any) => {
let parent = threadChannel.parent;
switch (parent.name) {
case "math-help":
await mathHelpChannel(Discord, client, threadChannel);
break;
case "programming-help":
await programmingHelpChannel(Discord, client, threadChannel);
break;
case "high-school-chemistry":
await chemistryHelpChannel(Discord, client, threadChannel);
break;
case "general-chemistry":
await chemistryHelpChannel(Discord, client, threadChannel);
break;
case "physics-help":
await physicsHelpChannel(Discord, client, threadChannel);
break;
case "biology-help":
await biologyHelpChannel(Discord, client, threadChannel);
break;
default:
break;
}
}

View File

@@ -0,0 +1,11 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient, queue: any, song: any) => {
let addSong = new Discord.EmbedBuilder()
.setDescription(` Your Song has been added \n[${song.name}](${song.url}) - \`[${song.formattedDuration}]\``)
.setThumbnail(song.thumbnail)
await queue.textChannel.send({ embeds: [addSong] })
}

View File

@@ -0,0 +1,10 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient, queue: any) => {
const embed = new Discord.EmbedBuilder()
.setDescription(`✅ | **Leave** the voice channel.\nThank you for using ${client?.user?.username}!`)
.setFooter({ text: client?.user?.username, iconURL: client?.user?.displayAvatarURL() });
queue.textChannel.send({ embeds: [embed] });
}

View File

@@ -0,0 +1,11 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient, error: any, queue: any, song: any) => {
console.log(error);
const embed = new Discord.EmbedBuilder()
.setDescription(`❌ | There was an Error\n ${error}`)
queue.textChannel.send({ embeds: [embed] })
}

View File

@@ -0,0 +1,10 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient, queue: any) => {
let playSong = new Discord.EmbedBuilder()
.setDescription(`No more song in queue`)
queue.textChannel.send({ embeds: [playSong] })
}

View File

@@ -0,0 +1,7 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient) => {
// SOMETHING HERE
}

View File

@@ -0,0 +1,12 @@
import BotClient from "../../libs/BotClient";
export default async(Discord: any, client: BotClient, queue: any, song: any) => {
let playSong = new Discord.EmbedBuilder()
.setThumbnail(song.thumbnail)
.setDescription(`Song Playing \n[${song.name}](${song.url}) - \`[${song.formattedDuration}]\``)
.setFooter({ text: `Request by ${song.user.tag}`, iconURL: song.user.displayAvatarURL() });
queue.textChannel.send({ embeds: [playSong] })
}

View File

@@ -0,0 +1,38 @@
import fs from "fs";
import path from "path";
import { REST, Routes, SlashCommandBuilder } from "discord.js";
import BotClient from "../libs/BotClient";
import BotCommand from "../libs/BotCommand";
// @ts-ignore
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
export default (Discord: any, client: BotClient) => {
fs.readdirSync(path.join(__dirname, "../commands")).forEach((dir: string) => {
fs.readdirSync(path.join(__dirname, `../commands/${dir}`)).forEach( async(file: string) => {
let command: BotCommand = new (await( await import(path.join(__dirname, `../commands/${dir}/${file}`)))).default();
client.commands.set(command.name, command);
});
});
}
export async function post(client: BotClient) {
let botCommands: Array<SlashCommandBuilder> = [];
client.commands.forEach((value, key) => {
botCommands.push(value.data);
});
try {
client.guilds.cache.forEach(async (guild) => {
await rest.put(
// @ts-ignore
Routes.applicationGuildCommands(client.user.id, guild.id),
{ body: botCommands }
);
client.logger.log(`&bPosted (/) Commands for &f${guild.name}`);
});
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,52 @@
import fs from "fs";
import path from "path";
import ms from "ms";
import BotClient from "../libs/BotClient";
let botEvents: Array<string> = [];
const wait = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay));
function isEvent(file: string): boolean {
return file.endsWith(".ts") || file.endsWith(".js");
}
function ScanDir(Discord: any, client: BotClient, dir: string) {
fs.readdirSync(dir).forEach((file: string) => {
if(isEvent(file)) return loadEvent(Discord, client, `${dir}/${file}`);
else return ScanDir(Discord, client, `${dir}/${file}`);
});
}
function ScanDistubeDir(Discord: any, client: BotClient, dir: string) {
fs.readdirSync(dir).forEach((file: string) => {
if(isEvent(file)) return loadDistube(Discord, client, `${dir}/${file}`);
else return ScanDistubeDir(Discord, client, `${dir}/${file}`);
});
}
async function loadEvent(Discord: any, client: BotClient, file: string): Promise<void> {
const event = await import(file);
let event_name: any = file.split("/");
event_name = event_name[event_name.length - 1].split(".")[0];
client.on(event_name, event.default.bind(null, Discord, client));
botEvents.push(event_name);
}
async function loadDistube(Discord: any, client: BotClient, file: string): Promise<void> {
const event = await import(file);
let event_name: any = file.split("/");
event_name = event_name[event_name.length - 1].split(".")[0];
client.distube.on(event_name, event.default.bind(null, Discord, client));
botEvents.push(`${event_name}`);
}
export default async(Discord: any, client: BotClient) => {
ScanDir(Discord, client, path.join(__dirname, `../events/bot`));
ScanDistubeDir(Discord, client, path.join(__dirname, `../events/distube`));
await wait(ms("5s"));
botEvents.forEach((event: string) => {
client.logger.log(`&6${event} &a- Event Loaded`);
});
}

View File

@@ -0,0 +1,31 @@
import fs from "fs";
import path from "path";
import ms from "ms";
import BotClient from "../libs/BotClient";
const wait = (delay: number) => new Promise((resolve) => setTimeout(resolve, delay));
function isAction(file: string) {
return file.endsWith(".ts") || file.endsWith(".js");
}
function ScanDir(Discord: any, client: BotClient, dir: any) {
fs.readdirSync(dir).forEach((file) => {
if(isAction(file)) return loadAction(Discord, client, `${dir}/${file}`);
else return ScanDir(Discord, client, `${dir}/${file}`);
});
}
async function loadAction(Discord: any, client: BotClient, file: any) {
const action = new (await import(file)).default();
client.action.set(action.getId(), action);
}
export default async(Discord: any, client: BotClient) => {
ScanDir(Discord, client, path.join(__dirname, `../interactions/`));
await wait(ms("5s"));
client.logger.log(`${client.action.size} - Actions Loaded`);
}

43
src/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import * as Discord from "discord.js";
import BotClient from "./libs/BotClient";
import Database from "./libs/Database";
dotenv.config();
const client = new BotClient({
partials: [Discord.Partials.Message, Discord.Partials.Channel, Discord.Partials.Reaction],
intents: [
Discord.GatewayIntentBits.Guilds,
Discord.GatewayIntentBits.GuildMembers,
Discord.GatewayIntentBits.DirectMessages,
Discord.GatewayIntentBits.GuildMessages,
Discord.GatewayIntentBits.GuildVoiceStates,
Discord.GatewayIntentBits.MessageContent,
],
allowedMentions: { parse: ['users', 'roles'], repliedUser: true }
});
(async() => {
// Initialize database connection
const db = Database.getInstance();
await db.connect();
fs.readdirSync(path.join(__dirname, "./handlers")).forEach( async(file: string) => {
await (await import(path.join(__dirname, `./handlers/${file}`))).default(Discord, client);
});
})();
client.login(process.env.DISCORD_TOKEN).then(() => client.logger.log("&aBot Online!")).catch(() => console.log(new Error("Invalid Discord Bot Token Provided!")));
// PROCESS
process.on('uncaughtException', (err) => { client.logger.log("&4" + err); });
process.on('unhandledRejection', (err) => { client.logger.log("&4" + err); });
process.on('SIGINT', async () => {
const db = Database.getInstance();
await db.disconnect();
process.exit(0);
});

View File

@@ -0,0 +1,31 @@
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalActionRowComponentBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
export default class DailyAnswer extends BotAction {
constructor() {
super("daily_answer_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
const questionId = interaction.customId.split("_").slice(2).join("_");
const modal = new ModalBuilder()
.setCustomId(`daily_submit_${questionId}`)
.setTitle("Submit Your Answer");
const answerInput = new TextInputBuilder()
.setCustomId("answer")
.setLabel("Your Answer")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Enter your detailed answer here...")
.setRequired(true)
.setMaxLength(2000);
const row = new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(answerInput);
modal.addComponents(row);
await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,31 @@
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalActionRowComponentBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
export default class DailyReport extends BotAction {
constructor() {
super("daily_report_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
const questionId = interaction.customId.split("_").slice(2).join("_");
const modal = new ModalBuilder()
.setCustomId(`daily_report_submit_${questionId}`)
.setTitle("Report Question Issue");
const issueInput = new TextInputBuilder()
.setCustomId("issue")
.setLabel("What's wrong with this question?")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Describe the issue (e.g., unclear wording, incorrect answer, etc.)")
.setRequired(true)
.setMaxLength(1000);
const row = new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(issueInput);
modal.addComponents(row);
await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,72 @@
import { MessageFlags, EmbedBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import { promises as fs } from "fs";
import path from "path";
export default class DailyReportSubmit extends BotAction {
constructor() {
super("daily_report_submit_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const questionId = interaction.customId.split("_").slice(3).join("_");
const issue = interaction.fields.getTextInputValue("issue");
const reportData = {
questionId,
reportedBy: {
id: interaction.user.id,
username: interaction.user.username,
},
issue,
timestamp: new Date().toISOString(),
};
const reportsDir = path.join(process.cwd(), "data");
const reportsFile = path.join(reportsDir, "question_reports.json");
await fs.mkdir(reportsDir, { recursive: true });
let reports = [];
try {
const data = await fs.readFile(reportsFile, "utf-8");
reports = JSON.parse(data.toString());
} catch {
// File doesn't exist yet
}
reports.push(reportData);
await fs.writeFile(reportsFile, JSON.stringify(reports, null, 2));
const embed = new EmbedBuilder()
.setTitle("✅ Report Submitted")
.setDescription("Thank you for reporting this issue! Our team will review it shortly.")
.setColor(0x22c55e)
.addFields(
{ name: "Question ID", value: questionId, inline: true },
{ name: "Report Time", value: new Date().toLocaleString(), inline: true }
)
.setFooter({ text: "Your feedback helps improve the quality of questions" })
.setTimestamp();
await interaction.editReply({
embeds: [embed],
});
} catch (err) {
console.error("Error in DailyReportSubmit:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while submitting your report. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,104 @@
import { MessageFlags, EmbedBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import QuestionScheduler from "../libs/QuestionScheduler";
import AnswerGrader from "../libs/AnswerGrader";
import PointsManager from "../libs/PointsManager";
export default class DailySubmit extends BotAction {
constructor() {
super("daily_submit_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const questionId = interaction.customId.split("_").slice(2).join("_");
const userAnswer = interaction.fields.getTextInputValue("answer");
const scheduler = new QuestionScheduler();
await scheduler.initialize();
const hasAnswered = await scheduler.hasUserAnswered(interaction.user.id, questionId);
if (hasAnswered) {
await interaction.editReply({ content: "You've already submitted an answer for this question!" });
return;
}
const history = await scheduler.getHistory();
const question = history.find((q: any) => q.id === questionId);
if (!question) {
await interaction.editReply({ content: "Question not found. Please try again." });
return;
}
await interaction.editReply({ content: "✅ Answer submitted! Sending to grader..." });
const grader = new AnswerGrader();
const grade = await grader.gradeAnswer(question, userAnswer);
await scheduler.saveSubmission({
userId: interaction.user.id,
username: interaction.user.username,
questionId: question.id,
answer: userAnswer,
gradeResult: grade,
timestamp: new Date().toISOString(),
});
const color = grade.is_correct ? 0x22c55e : 0xef4444;
const verdict = grade.is_correct ? "✅ CORRECT" : "❌ INCORRECT";
const score = await scheduler.getUserScore(interaction.user.id, "daily");
let pointsInfo = "";
if (grade.is_correct) {
const pointsResult = await PointsManager.awardPoints(interaction.user.id, interaction.user.username, "daily");
if (pointsResult) {
pointsInfo = `\n**Points Earned:** +2 points (Total: ${pointsResult.userPoints})`;
if (pointsResult.teamPoints !== undefined) {
pointsInfo += `\n**Team Points:** ${pointsResult.teamPoints}`;
}
}
}
const embed = new EmbedBuilder()
.setTitle(verdict)
.setColor(color)
.addFields(
{ name: "Subject", value: question.subject, inline: true },
{ name: "Topic", value: question.topic, inline: true },
{ name: "Difficulty", value: question.difficulty_rating, inline: true },
{ name: "Your Daily Score", value: `${score.correct}/${score.total} correct today${pointsInfo}`, inline: false }
)
.setFooter({ text: "Come back tomorrow for a new question!" })
.setTimestamp();
try {
const dmChannel = await interaction.user.createDM();
await dmChannel.send({ embeds: [embed] });
console.log(`[DM] Successfully sent daily grade result to ${interaction.user.username} (${interaction.user.id})`);
} catch (dmError) {
console.log(`[DM] Failed to DM ${interaction.user.username} (${interaction.user.id}):`, dmError);
await interaction.followUp({
content: "⚠️ I couldn't DM you the results. Here they are:",
embeds: [embed],
flags: MessageFlags.Ephemeral
});
}
} catch (err) {
console.error("Error in DailySubmit:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while grading. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,43 @@
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import { MessageFlags } from "discord.js";
export default class ReportUserAction extends BotAction {
constructor() {
super("report");
}
async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
const userID = interaction.customId?.split("-")[1];
if (!userID) {
await interaction.reply({ content: "❌ Invalid interaction data.", flags: MessageFlags.Ephemeral });
return;
}
const user = await client.users.fetch(userID).catch(() => null);
if (!user) {
await interaction.reply({ content: "❌ User not found.", flags: MessageFlags.Ephemeral });
return;
}
const reason = interaction.fields?.getTextInputValue('reasonInput') ?? "(no reason provided)";
const obj = {
reported: `<@${user.id}> (` + "`" + `${user.id}` + "`" + `)`,
reason,
reporter: interaction.user?.username ?? interaction.user?.tag ?? String(interaction.user?.id ?? "unknown"),
};
const embed = client.formatter.buildEmbed("./responses/user/report_user.yaml", obj);
const channel = client.channels.cache.get("1310725282357055521");
if (!channel) {
client.logger?.error?.("ReportUserAction: target channel not found or invalid.");
await interaction.reply({ content: "❌ Could not send report. Please contact an administrator.", flags: MessageFlags.Ephemeral });
return;
}
await (channel as any).send({ content: "<@&1310700035842768908>", embeds: [embed] });
await interaction.reply({ content: "User has been reported. Thank You.", flags: MessageFlags.Ephemeral });
}
}

View File

@@ -0,0 +1,187 @@
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import { MessageFlags } from "discord.js";
export default class SelectionRoles extends BotAction {
constructor() {
super("select-roles");
}
async helperSelector(Discord: any, client: BotClient, interaction: any) {
const guildMember = await interaction.guild.members.fetch(interaction.user.id);
if (!interaction.values || !Array.isArray(interaction.values)) return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
for (const value of interaction.values) {
const role = interaction.guild.roles.cache.get(value) || interaction.guild.roles.cache.find((r: any) => r.id === value);
if (!role) {
console.log(`Role not found: ${value}`);
return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
}
if (guildMember.roles.cache.has(role.id)) continue;
await guildMember.roles.add(role.id);
}
for (const role of client.config.getHelperRoles()) {
if (!interaction.values.includes(role.id)) {
const guildRole = interaction.guild.roles.cache.get(role.id) || interaction.guild.roles.cache.find((r: any) => r.id === role.id);
if (!guildRole) return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
if (!guildMember.roles.cache.has(guildRole.id)) continue;
await guildMember.roles.remove(guildRole.id);
}
}
await interaction.editReply({ content: `✅ Roles added successfully.`, flags: MessageFlags.Ephemeral });
}
async educationSelector(Discord: any, client: BotClient, interaction: any) {
const guildMember = await interaction.guild.members.fetch(interaction.user.id);
if (!interaction.values || !Array.isArray(interaction.values)) return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
for (const value of interaction.values) {
const role = interaction.guild.roles.cache.get(value) || interaction.guild.roles.cache.find((r: any) => r.id === value);
if (!role) {
console.log(`Role not found: ${value}`);
return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
}
if (guildMember.roles.cache.has(role.id)) continue;
await guildMember.roles.add(role.id);
}
for (const role of client.config.getEducationRoles()) {
if (!interaction.values.includes(role.id)) {
const guildRole = interaction.guild.roles.cache.get(role.id) || interaction.guild.roles.cache.find((r: any) => r.id === role.id);
if (!guildRole) return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
if (!guildMember.roles.cache.has(guildRole.id)) continue;
await guildMember.roles.remove(guildRole.id);
}
}
await interaction.editReply({ content: `✅ Roles added successfully.`, flags: MessageFlags.Ephemeral });
}
async languageSelector(Discord: any, client: BotClient, interaction: any) {
const guildMember = await interaction.guild.members.fetch(interaction.user.id);
if (!interaction.values || !Array.isArray(interaction.values)) return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
for (const value of interaction.values) {
const role = interaction.guild.roles.cache.get(value) || interaction.guild.roles.cache.find((r: any) => r.id === value);
if (!role) {
console.log(`Role not found: ${value}`);
return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
}
if (guildMember.roles.cache.has(role.id)) continue;
await guildMember.roles.add(role.id);
}
for (const role of client.config.getLanguageRoles()) {
if (!interaction.values.includes(role.id)) {
const guildRole = interaction.guild.roles.cache.get(role.id) || interaction.guild.roles.cache.find((r: any) => r.id === role.id);
if (!guildRole) return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
if (!guildMember.roles.cache.has(guildRole.id)) continue;
await guildMember.roles.remove(guildRole.id);
}
}
await interaction.editReply({ content: `✅ Roles added successfully.`, flags: MessageFlags.Ephemeral });
}
async locationSelector(Discord: any, client: BotClient, interaction: any) {
const guildMember = await interaction.guild.members.fetch(interaction.user.id);
if (!interaction.values || !Array.isArray(interaction.values)) return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
for (const value of interaction.values) {
const role = interaction.guild.roles.cache.get(value) || interaction.guild.roles.cache.find((r: any) => r.id === value);
if (!role) {
console.log(`Role not found: ${value}`);
return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
}
if (guildMember.roles.cache.has(role.id)) continue;
await guildMember.roles.add(role.id);
}
for (const role of client.config.getLocationRoles()) {
if (!interaction.values.includes(role.id)) {
const guildRole = interaction.guild.roles.cache.get(role.id) || interaction.guild.roles.cache.find((r: any) => r.id === role.id);
if (!guildRole) return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
if (!guildMember.roles.cache.has(guildRole.id)) continue;
await guildMember.roles.remove(guildRole.id);
}
}
await interaction.editReply({ content: `✅ Roles added successfully.`, flags: MessageFlags.Ephemeral });
}
async pingSelector(Discord: any, client: BotClient, interaction: any) {
const guildMember = await interaction.guild.members.fetch(interaction.user.id);
if (!interaction.values || !Array.isArray(interaction.values)) return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
for (const value of interaction.values) {
const role = interaction.guild.roles.cache.get(value) || interaction.guild.roles.cache.find((r: any) => r.id === value);
if (!role) {
console.log(`Role not found: ${value}`);
return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
}
if (guildMember.roles.cache.has(role.id)) continue;
await guildMember.roles.add(role.id);
}
for (const role of client.config.getPingRoles()) {
if (!interaction.values.includes(role.id)) {
const guildRole = interaction.guild.roles.cache.get(role.id) || interaction.guild.roles.cache.find((r: any) => r.id === role.id);
if (!guildRole) return interaction.editReply({ content: `❌ Role not found.`, flags: MessageFlags.Ephemeral });
if (!guildMember.roles.cache.has(guildRole.id)) continue;
await guildMember.roles.remove(guildRole.id);
}
}
await interaction.editReply({ content: `✅ Roles added successfully.`, flags: MessageFlags.Ephemeral });
}
async execute(Discord: any, client: BotClient, interaction: any) {
await interaction.reply({ content: `⏳ One Moment Please. . .`, flags: MessageFlags.Ephemeral });
const type = interaction.customId?.split('-')[2];
switch(type) {
case 'helper':
this.helperSelector(Discord, client, interaction);
break;
case 'education':
this.educationSelector(Discord, client, interaction);
break;
case 'language':
this.languageSelector(Discord, client, interaction);
break;
case 'location':
this.locationSelector(Discord, client, interaction);
break;
case 'ping':
this.pingSelector(Discord, client, interaction);
break;
default:
return interaction.editReply({ content: `❌ Invalid selection.`, flags: MessageFlags.Ephemeral });
}
}
}

View File

@@ -0,0 +1,40 @@
import { MessageFlags } from "discord.js";
import BotAction from "../libs/BotAction";
import type BotClient from "../libs/BotClient";
export default class TypstAction extends BotAction {
constructor() {
super("typst_delete", true);
}
override async execute(Discord: any, client: BotClient, interaction: any): Promise<any> {
try {
if (interaction.isButton() && typeof interaction.customId === 'string' && interaction.customId === 'typst_delete') {
const replyId = interaction.message?.id;
if (!replyId) return;
const found = client.typst.findByReply(replyId);
const ownerId = found?.ownerId;
const requesterId = interaction.user?.id;
const member = interaction.member;
const isAdmin = member?.permissions?.has && member.permissions.has && (member.permissions.has(Discord.PermissionFlagsBits?.Administrator) || member.permissions.has(Discord.PermissionFlagsBits?.ManageGuild));
if (requesterId === ownerId || isAdmin) {
try { await interaction.message.delete(); } catch (e) {}
if (found?.userMessage) client.typst.removeMessage(found.userMessage);
try { await interaction.reply({ content: 'Deleted output.', flags: MessageFlags.Ephemeral }); } catch (e) {}
return;
} else {
try { await interaction.reply({ content: 'You are not allowed to delete this output.', flags: Discord.MessageFlags.Ephemeral }); } catch (e) {}
return;
}
}
} catch (e) {
console.error('Typst action error:', e);
}
return;
}
}

View File

@@ -0,0 +1,31 @@
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalActionRowComponentBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
export default class WeeklyAnswer extends BotAction {
constructor() {
super("weekly_answer_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
const questionId = interaction.customId.split("_").slice(2).join("_");
const modal = new ModalBuilder()
.setCustomId(`weekly_submit_${questionId}`)
.setTitle("Submit Your Answer");
const answerInput = new TextInputBuilder()
.setCustomId("answer")
.setLabel("Your Answer")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Enter your detailed answer here...")
.setRequired(true)
.setMaxLength(2000);
const row = new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(answerInput);
modal.addComponents(row);
await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,31 @@
import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ModalActionRowComponentBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
export default class WeeklyReport extends BotAction {
constructor() {
super("weekly_report_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
const questionId = interaction.customId.split("_").slice(2).join("_");
const modal = new ModalBuilder()
.setCustomId(`weekly_report_submit_${questionId}`)
.setTitle("Report Question Issue");
const issueInput = new TextInputBuilder()
.setCustomId("issue")
.setLabel("What's wrong with this question?")
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder("Describe the issue (e.g., unclear wording, incorrect answer, etc.)")
.setRequired(true)
.setMaxLength(1000);
const row = new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(issueInput);
modal.addComponents(row);
await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,72 @@
import { MessageFlags, EmbedBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import { promises as fs } from "fs";
import path from "path";
export default class WeeklyReportSubmit extends BotAction {
constructor() {
super("weekly_report_submit_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const questionId = interaction.customId.split("_").slice(3).join("_");
const issue = interaction.fields.getTextInputValue("issue");
const reportData = {
questionId,
reportedBy: {
id: interaction.user.id,
username: interaction.user.username,
},
issue,
timestamp: new Date().toISOString(),
};
const reportsDir = path.join(process.cwd(), "data");
const reportsFile = path.join(reportsDir, "question_reports.json");
await fs.mkdir(reportsDir, { recursive: true });
let reports = [];
try {
const data = await fs.readFile(reportsFile, "utf-8");
reports = JSON.parse(data.toString());
} catch {
// File doesn't exist yet
}
reports.push(reportData);
await fs.writeFile(reportsFile, JSON.stringify(reports, null, 2));
const embed = new EmbedBuilder()
.setTitle("✅ Report Submitted")
.setDescription("Thank you for reporting this issue! Our team will review it shortly.")
.setColor(0x22c55e)
.addFields(
{ name: "Question ID", value: questionId, inline: true },
{ name: "Report Time", value: new Date().toLocaleString(), inline: true }
)
.setFooter({ text: "Your feedback helps improve the quality of questions" })
.setTimestamp();
await interaction.editReply({
embeds: [embed],
});
} catch (err) {
console.error("Error in WeeklyReportSubmit:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while submitting your report. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

View File

@@ -0,0 +1,104 @@
import { MessageFlags, EmbedBuilder } from "discord.js";
import BotAction from "../libs/BotAction";
import BotClient from "../libs/BotClient";
import QuestionScheduler from "../libs/QuestionScheduler";
import AnswerGrader from "../libs/AnswerGrader";
import PointsManager from "../libs/PointsManager";
export default class WeeklySubmit extends BotAction {
constructor() {
super("weekly_submit_");
}
public async execute(Discord: any, client: BotClient, interaction: any): Promise<void> {
try {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const questionId = interaction.customId.split("_").slice(2).join("_");
const userAnswer = interaction.fields.getTextInputValue("answer");
const scheduler = new QuestionScheduler();
await scheduler.initialize();
const hasAnswered = await scheduler.hasUserAnswered(interaction.user.id, questionId);
if (hasAnswered) {
await interaction.editReply({ content: "You've already submitted an answer for this question!" });
return;
}
const history = await scheduler.getHistory();
const question = history.find((q: any) => q.id === questionId);
if (!question) {
await interaction.editReply({ content: "Question not found. Please try again." });
return;
}
await interaction.editReply({ content: "✅ Answer submitted! Sending to grader..." });
const grader = new AnswerGrader();
const grade = await grader.gradeAnswer(question, userAnswer);
await scheduler.saveSubmission({
userId: interaction.user.id,
username: interaction.user.username,
questionId: question.id,
answer: userAnswer,
gradeResult: grade,
timestamp: new Date().toISOString(),
});
const color = grade.is_correct ? 0x22c55e : 0xef4444;
const verdict = grade.is_correct ? "✅ CORRECT" : "❌ INCORRECT";
const score = await scheduler.getUserScore(interaction.user.id, "weekly");
let pointsInfo = "";
if (grade.is_correct) {
const pointsResult = await PointsManager.awardPoints(interaction.user.id, interaction.user.username, "weekly");
if (pointsResult) {
pointsInfo = `\n**Points Earned:** +10 points (Total: ${pointsResult.userPoints})`;
if (pointsResult.teamPoints !== undefined) {
pointsInfo += `\n**Team Points:** ${pointsResult.teamPoints}`;
}
}
}
const embed = new EmbedBuilder()
.setTitle(verdict)
.setColor(color)
.addFields(
{ name: "Subject", value: question.subject, inline: true },
{ name: "Topic", value: question.topic, inline: true },
{ name: "Difficulty", value: question.difficulty_rating, inline: true },
{ name: "Your Weekly Score", value: `${score.correct}/${score.total} correct this week${pointsInfo}`, inline: false }
)
.setFooter({ text: "Come back next week for a new challenge!" })
.setTimestamp();
try {
const dmChannel = await interaction.user.createDM();
await dmChannel.send({ embeds: [embed] });
console.log(`[DM] Successfully sent weekly grade result to ${interaction.user.username} (${interaction.user.id})`);
} catch (dmError) {
console.log(`[DM] Failed to DM ${interaction.user.username} (${interaction.user.id}):`, dmError);
await interaction.followUp({
content: "⚠️ I couldn't DM you the results. Here they are:",
embeds: [embed],
flags: MessageFlags.Ephemeral
});
}
} catch (err) {
console.error("Error in WeeklySubmit:", err);
try {
if (interaction.deferred || interaction.replied) {
await interaction.editReply({ content: "An error occurred while grading. Please try again later." });
} else {
await interaction.reply({ content: "An error occurred. Please try again later.", flags: MessageFlags.Ephemeral });
}
} catch (e) {
console.error("Error sending error reply:", e);
}
}
}
}

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];
}
}

33
src/models/Question.ts Normal file
View File

@@ -0,0 +1,33 @@
import mongoose, { Schema, Document } from "mongoose";
export interface IQuestion extends Document {
id: string;
subject: string;
frequency: "daily" | "weekly";
topic: string;
difficulty_rating: string;
typst_source: string;
reference_answer: string;
timestamp: Date;
image_path?: string;
createdAt: Date;
}
const QuestionSchema: Schema = new Schema({
id: { type: String, required: true, unique: true, index: true },
subject: { type: String, required: true, index: true },
frequency: { type: String, enum: ["daily", "weekly"], required: true, index: true },
topic: { type: String, required: true },
difficulty_rating: { type: String, required: true },
typst_source: { type: String, required: true },
reference_answer: { type: String, required: true },
timestamp: { type: Date, required: true },
image_path: { type: String },
createdAt: { type: Date, default: Date.now, index: true }
});
// Index for retrieving recent questions by subject and frequency
QuestionSchema.index({ subject: 1, frequency: 1, createdAt: -1 });
QuestionSchema.index({ createdAt: -1 });
export default mongoose.model<IQuestion>("Question", QuestionSchema);

View File

@@ -0,0 +1,24 @@
import mongoose, { Schema, Document } from "mongoose";
export interface IScheduledQuestion extends Document {
cacheKey: string;
questionId: string;
subject: string;
period: "daily" | "weekly";
periodKey: string;
createdAt: Date;
}
const ScheduledQuestionSchema: Schema = new Schema({
cacheKey: { type: String, required: true, unique: true, index: true },
questionId: { type: String, required: true, ref: "Question" },
subject: { type: String, required: true, index: true },
period: { type: String, enum: ["daily", "weekly"], required: true, index: true },
periodKey: { type: String, required: true, index: true },
createdAt: { type: Date, default: Date.now }
});
// Compound index for efficient lookups
ScheduledQuestionSchema.index({ subject: 1, period: 1, periodKey: 1 });
export default mongoose.model<IScheduledQuestion>("ScheduledQuestion", ScheduledQuestionSchema);

35
src/models/Submission.ts Normal file
View File

@@ -0,0 +1,35 @@
import mongoose, { Schema, Document } from "mongoose";
export interface ISubmission extends Document {
userId: string;
username: string;
questionId: string;
answer: string;
gradeResult: {
comparison_analysis?: string;
is_correct: boolean;
score?: number;
feedback?: string;
};
timestamp: Date;
}
const SubmissionSchema: Schema = new Schema({
userId: { type: String, required: true, index: true },
username: { type: String, required: true },
questionId: { type: String, required: true, ref: "Question", index: true },
answer: { type: String, required: true },
gradeResult: {
comparison_analysis: { type: String },
is_correct: { type: Boolean, required: true },
score: { type: Number },
feedback: { type: String }
},
timestamp: { type: Date, default: Date.now, index: true }
});
// Compound indexes for efficient queries
SubmissionSchema.index({ userId: 1, questionId: 1 });
SubmissionSchema.index({ userId: 1, timestamp: -1 });
export default mongoose.model<ISubmission>("Submission", SubmissionSchema);

43
src/models/Team.ts Normal file
View File

@@ -0,0 +1,43 @@
import mongoose, { Schema, Document } from "mongoose";
export interface ITeam extends Document {
name: string;
description?: string;
leaderId: string;
points: number;
memberCount: number;
createdAt: Date;
adjustedPoints: number;
}
const TeamSchema = new Schema<ITeam>({
name: { type: String, required: true, unique: true, index: true },
description: { type: String, default: "" },
leaderId: { type: String, required: true },
points: { type: Number, default: 0 },
memberCount: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
adjustedPoints: { type: Number, default: 0 }
});
TeamSchema.index({ points: -1 });
TeamSchema.index({ adjustedPoints: -1 });
TeamSchema.methods.calculateAdjustedPoints = function() {
if (this.memberCount === 0) {
this.adjustedPoints = 0;
return 0;
}
// Adjustment factor: smaller teams get bonus multiplier
// 1-5 members: 1.5x, 6-10: 1.3x, 11-15: 1.1x, 16+: 1.0x
let multiplier = 1.0;
if (this.memberCount <= 5) multiplier = 1.5;
else if (this.memberCount <= 10) multiplier = 1.3;
else if (this.memberCount <= 15) multiplier = 1.1;
this.adjustedPoints = Math.round(this.points * multiplier);
return this.adjustedPoints;
};
export default mongoose.model<ITeam>("Team", TeamSchema);

28
src/models/User.ts Normal file
View File

@@ -0,0 +1,28 @@
import mongoose, { Schema, Document } from "mongoose";
export interface IUser extends Document {
userId: string;
username: string;
points: number;
teamId?: mongoose.Types.ObjectId;
dailyQuestionsCompleted: number;
weeklyQuestionsCompleted: number;
lastActive: Date;
joinedAt: Date;
}
const UserSchema = new Schema<IUser>({
userId: { type: String, required: true, unique: true, index: true },
username: { type: String, required: true },
points: { type: Number, default: 0 },
teamId: { type: Schema.Types.ObjectId, ref: "Team", default: null },
dailyQuestionsCompleted: { type: Number, default: 0 },
weeklyQuestionsCompleted: { type: Number, default: 0 },
lastActive: { type: Date, default: Date.now },
joinedAt: { type: Date, default: Date.now }
});
UserSchema.index({ points: -1 });
UserSchema.index({ lastActive: -1 });
export default mongoose.model<IUser>("User", UserSchema);

0
src/test.ts Normal file
View File