Initial Code
This commit is contained in:
110
src/commands/admin/adminStats.ts
Normal file
110
src/commands/admin/adminStats.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
245
src/commands/admin/growth.ts
Normal file
245
src/commands/admin/growth.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/commands/admin/pinghelp.ts
Normal file
87
src/commands/admin/pinghelp.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/commands/admin/regenerate.ts
Normal file
118
src/commands/admin/regenerate.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
}
|
||||
210
src/commands/admin/selectroles.ts
Normal file
210
src/commands/admin/selectroles.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
181
src/commands/admin/teamManage.ts
Normal file
181
src/commands/admin/teamManage.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/commands/admin/test.ts
Normal file
17
src/commands/admin/test.ts
Normal 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
36
src/commands/core/help.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
39
src/commands/core/status.ts
Normal file
39
src/commands/core/status.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
32
src/commands/music/disconnect.ts
Normal file
32
src/commands/music/disconnect.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
34
src/commands/music/jump.ts
Normal file
34
src/commands/music/jump.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/commands/music/loop.ts
Normal file
40
src/commands/music/loop.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/commands/music/nowplaying.ts
Normal file
72
src/commands/music/nowplaying.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
30
src/commands/music/pause.ts
Normal file
30
src/commands/music/pause.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
35
src/commands/music/play.ts
Normal file
35
src/commands/music/play.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
27
src/commands/music/previous.ts
Normal file
27
src/commands/music/previous.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/commands/music/queue.ts
Normal file
82
src/commands/music/queue.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
30
src/commands/music/resume.ts
Normal file
30
src/commands/music/resume.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
45
src/commands/music/seek.ts
Normal file
45
src/commands/music/seek.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/commands/music/skip.ts
Normal file
27
src/commands/music/skip.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/commands/music/stop.ts
Normal file
28
src/commands/music/stop.ts
Normal 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] });
|
||||
}
|
||||
}
|
||||
32
src/commands/music/volume.ts
Normal file
32
src/commands/music/volume.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
252
src/commands/users/brithday.ts
Normal file
252
src/commands/users/brithday.ts
Normal 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
121
src/commands/users/daily.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/commands/users/joinTeam.ts
Normal file
61
src/commands/users/joinTeam.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/commands/users/leaderboard.ts
Normal file
95
src/commands/users/leaderboard.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/commands/users/report.ts
Normal file
32
src/commands/users/report.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
src/commands/users/solved.ts
Normal file
45
src/commands/users/solved.ts
Normal 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`" });
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/commands/users/stats.ts
Normal file
60
src/commands/users/stats.ts
Normal 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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/commands/users/weekly.ts
Normal file
121
src/commands/users/weekly.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/events/bot/client/clientReady.ts
Normal file
19
src/events/bot/client/clientReady.ts
Normal 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}!`);
|
||||
}
|
||||
}
|
||||
76
src/events/bot/custom/aichat.ts
Normal file
76
src/events/bot/custom/aichat.ts
Normal 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 }
|
||||
// });
|
||||
// }
|
||||
};
|
||||
115
src/events/bot/custom/birthdayCheck.ts
Normal file
115
src/events/bot/custom/birthdayCheck.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/events/bot/custom/typst.ts
Normal file
50
src/events/bot/custom/typst.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
6
src/events/bot/guild/guildMemberAdd.ts
Normal file
6
src/events/bot/guild/guildMemberAdd.ts
Normal 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);
|
||||
}
|
||||
54
src/events/bot/guild/interactionCreate.ts
Normal file
54
src/events/bot/guild/interactionCreate.ts
Normal 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}`);
|
||||
};
|
||||
}
|
||||
39
src/events/bot/guild/messageCreate.ts
Normal file
39
src/events/bot/guild/messageCreate.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
69
src/events/bot/guild/messageUpdate.ts
Normal file
69
src/events/bot/guild/messageUpdate.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
202
src/events/bot/guild/threadCreate.ts
Normal file
202
src/events/bot/guild/threadCreate.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/events/distube/addSong.ts
Normal file
11
src/events/distube/addSong.ts
Normal 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] })
|
||||
|
||||
}
|
||||
10
src/events/distube/disconnect.ts
Normal file
10
src/events/distube/disconnect.ts
Normal 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] });
|
||||
|
||||
}
|
||||
11
src/events/distube/error.ts
Normal file
11
src/events/distube/error.ts
Normal 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] })
|
||||
|
||||
}
|
||||
10
src/events/distube/finish.ts
Normal file
10
src/events/distube/finish.ts
Normal 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] })
|
||||
|
||||
}
|
||||
7
src/events/distube/placeholder.ts
Normal file
7
src/events/distube/placeholder.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
import BotClient from "../../libs/BotClient";
|
||||
|
||||
export default async(Discord: any, client: BotClient) => {
|
||||
|
||||
// SOMETHING HERE
|
||||
}
|
||||
12
src/events/distube/playSong.ts
Normal file
12
src/events/distube/playSong.ts
Normal 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] })
|
||||
|
||||
}
|
||||
38
src/handlers/command_handler.ts
Normal file
38
src/handlers/command_handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
src/handlers/event_handler.ts
Normal file
52
src/handlers/event_handler.ts
Normal 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`);
|
||||
});
|
||||
}
|
||||
31
src/handlers/interaction_handler.ts
Normal file
31
src/handlers/interaction_handler.ts
Normal 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
43
src/index.ts
Normal 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);
|
||||
});
|
||||
31
src/interactions/DailyAnswer.ts
Normal file
31
src/interactions/DailyAnswer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/interactions/DailyReport.ts
Normal file
31
src/interactions/DailyReport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
72
src/interactions/DailyReportSubmit.ts
Normal file
72
src/interactions/DailyReportSubmit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/interactions/DailySubmit.ts
Normal file
104
src/interactions/DailySubmit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/interactions/ReportUser.ts
Normal file
43
src/interactions/ReportUser.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
187
src/interactions/SelectRoles.ts
Normal file
187
src/interactions/SelectRoles.ts
Normal 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 });
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
40
src/interactions/TypstDelete.ts
Normal file
40
src/interactions/TypstDelete.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
src/interactions/WeeklyAnswer.ts
Normal file
31
src/interactions/WeeklyAnswer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/interactions/WeeklyReport.ts
Normal file
31
src/interactions/WeeklyReport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
72
src/interactions/WeeklyReportSubmit.ts
Normal file
72
src/interactions/WeeklyReportSubmit.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/interactions/WeeklySubmit.ts
Normal file
104
src/interactions/WeeklySubmit.ts
Normal 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
87
src/libs/AnswerGrader.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { QuestionData } from "./QuestionGenerator";
|
||||
|
||||
export interface GradeResult {
|
||||
comparison_analysis: string;
|
||||
is_correct: boolean;
|
||||
score: number;
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
export default class AnswerGrader {
|
||||
private ollamaUrl: string;
|
||||
private ollamaModel: string;
|
||||
|
||||
constructor() {
|
||||
this.ollamaUrl = process.env.OLLAMA_URL || "http://192.168.1.214:11434";
|
||||
this.ollamaModel = process.env.OLLAMA_MODEL || "llama3.2:latest";
|
||||
}
|
||||
|
||||
async gradeAnswer(questionData: QuestionData, userAnswer: string): Promise<GradeResult> {
|
||||
const prompt = `
|
||||
You are a strict academic Teaching Assistant. Your job is to compare a Student's Answer to the Official Reference Key.
|
||||
|
||||
--- QUESTION METADATA ---
|
||||
Subject: ${questionData.subject}
|
||||
Difficulty: ${questionData.difficulty_rating}
|
||||
Topic: ${questionData.topic}
|
||||
|
||||
--- OFFICIAL REFERENCE ANSWER (TRUTH) ---
|
||||
${questionData.reference_answer}
|
||||
|
||||
--- STUDENT ANSWER ---
|
||||
"${userAnswer}"
|
||||
|
||||
--- INSTRUCTIONS ---
|
||||
1. Compare the Student Answer to the Reference Answer.
|
||||
2. Ignore minor formatting differences (e.g., "0.5" vs "1/2", or "joules" vs "J").
|
||||
3. If the question asks for a calculation, check if the numbers match (allow 1% tolerance).
|
||||
4. If the question is conceptual, check if the key concepts in the Reference are present in the Student Answer.
|
||||
5. Provide your response in JSON format with these fields:
|
||||
- comparison_analysis: string (brief analysis of differences)
|
||||
- is_correct: boolean
|
||||
- score: number (0-100)
|
||||
- feedback: string (constructive feedback for the student)
|
||||
|
||||
Output ONLY valid JSON, no markdown formatting.
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.ollamaUrl}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.ollamaModel,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
format: "json"
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const responseText = data.response;
|
||||
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
throw new Error("No JSON found in response");
|
||||
}
|
||||
|
||||
const grade = JSON.parse(jsonMatch[0]) as GradeResult;
|
||||
return grade;
|
||||
} catch (error) {
|
||||
console.error("Grading error:", error);
|
||||
return {
|
||||
comparison_analysis: "Error occurred during grading",
|
||||
is_correct: false,
|
||||
score: 0,
|
||||
feedback: "An error occurred while grading your answer. Please try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/libs/BotAI.ts
Normal file
212
src/libs/BotAI.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
|
||||
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
|
||||
import fs from 'fs';
|
||||
|
||||
const MODEL = process.env.AI_MODEL || 'gemini-2.5-flash';
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
|
||||
|
||||
const BASE_PROMPT = fs.readFileSync('./config/prompt.txt', 'utf8');
|
||||
const SYSTEM_PROMPT = `${BASE_PROMPT}\n\nYou are currently helping {user_name}. The date is {Date} and it's {time} EST. Remember to always output valid JSON.`;
|
||||
|
||||
interface ChatEntry {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Chat {
|
||||
entries: ChatEntry[];
|
||||
}
|
||||
|
||||
class User {
|
||||
public id: string;
|
||||
public name: string;
|
||||
public chat: Chat;
|
||||
|
||||
constructor(id: string, name: string) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.chat = { entries: [] };
|
||||
}
|
||||
|
||||
addChatEntry(role: 'user' | 'assistant' | 'system', content: string): void {
|
||||
this.chat.entries.push({ role, content });
|
||||
}
|
||||
|
||||
formatBasePrompt(): string {
|
||||
return SYSTEM_PROMPT.replace('{user_name}', this.name)
|
||||
.replace('{Date}', new Date().toLocaleDateString())
|
||||
.replace('{time}', new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York' }));
|
||||
}
|
||||
|
||||
getChatEntries(): ChatEntry[] {
|
||||
let typstMD: any[] = [];
|
||||
|
||||
fs.readdirSync('./config/md').forEach(file => {
|
||||
if (file.endsWith('.md')) {
|
||||
const content = fs.readFileSync(`./config/md/${file}`, 'utf8');
|
||||
typstMD.push({ role: 'system', content });
|
||||
}
|
||||
});
|
||||
|
||||
let entries: ChatEntry[] = [
|
||||
{ role: 'system', content: this.formatBasePrompt() },
|
||||
...typstMD,
|
||||
...this.chat.entries
|
||||
];
|
||||
|
||||
if (entries.length > 17) this.clearChat(); // Increased to account for additional system message
|
||||
return entries;
|
||||
}
|
||||
|
||||
loadChat(): void {
|
||||
const path = `./storage/chats/${this.id}.json`;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
const data = fs.readFileSync(path, 'utf8');
|
||||
this.chat = JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading chat for user ${this.id}:`, error);
|
||||
this.chat = { entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
saveChat(): void {
|
||||
const path = `./storage/chats/${this.id}.json`;
|
||||
|
||||
try {
|
||||
const dir = './storage/chats';
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(path, JSON.stringify(this.chat, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`Error saving chat for user ${this.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
clearChat(): void {
|
||||
this.chat.entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class BotAI {
|
||||
private users: Map<string, User>;
|
||||
|
||||
constructor() {
|
||||
this.users = new Map();
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
loadUsers(): void {
|
||||
const chatsDir = './storage/chats';
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(chatsDir)) {
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(chatsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
const userId = file.replace('.json', '');
|
||||
|
||||
try {
|
||||
const user = new User(userId, 'Unknown User');
|
||||
user.loadChat();
|
||||
this.users.set(userId, user);
|
||||
} catch (error) {
|
||||
console.error(`Error loading user ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Loaded ${this.users.size} users from storage`);
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getUser(id: string): User | undefined {
|
||||
return this.users.get(id);
|
||||
}
|
||||
|
||||
createUser(id: string, name: string): User {
|
||||
const user = new User(id, name);
|
||||
this.users.set(id, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async generateText(userId: string, username: string, prompt: string): Promise<{ content: string, typst: string }> {
|
||||
let user = this.getUser(userId) || this.createUser(userId, username);
|
||||
user.name = username;
|
||||
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: MODEL,
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: {
|
||||
content: { type: SchemaType.STRING },
|
||||
typst: { type: SchemaType.STRING }
|
||||
},
|
||||
required: ["content", "typst"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build system prompt with typst examples
|
||||
let systemPrompt = user.formatBasePrompt();
|
||||
// fs.readdirSync('./config/md').forEach(file => {
|
||||
// if (file.endsWith('.md')) {
|
||||
// const content = fs.readFileSync(`./config/md/${file}`, 'utf8');
|
||||
// systemPrompt += '\n\n' + content;
|
||||
// }
|
||||
// });
|
||||
|
||||
const result = await model.generateContent(systemPrompt + '\n\nUser request: ' + prompt);
|
||||
const response = await result.response;
|
||||
const rawText = response.text();
|
||||
console.log('Raw AI response:', rawText);
|
||||
|
||||
let botMessage = rawText;
|
||||
let parsed: { content?: string, typst?: string } = {};
|
||||
try {
|
||||
parsed = JSON.parse(botMessage);
|
||||
} catch {
|
||||
botMessage = botMessage
|
||||
.replace(/"([^"]*)"([^"]*)"([^"]*)"/g, '"$1\\"$2\\"$3"')
|
||||
.replace(/\n(?=\s*[^"}])/g, '\\n')
|
||||
.replace(/,(\s*[}\]])/g, '$1')
|
||||
.replace(/"([^"\\]*(\\.[^"\\]*)*)"?$/g, '"$1"');
|
||||
try {
|
||||
parsed = JSON.parse(botMessage);
|
||||
} catch {
|
||||
parsed = { content: botMessage, typst: '' };
|
||||
}
|
||||
}
|
||||
|
||||
user.addChatEntry('assistant', JSON.stringify(parsed));
|
||||
user.saveChat();
|
||||
return {
|
||||
content: typeof parsed.content === 'string' ? parsed.content : botMessage,
|
||||
typst: typeof parsed.typst === 'string' ? parsed.typst : ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating text:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/libs/BotAction.ts
Normal file
23
src/libs/BotAction.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MessageFlags } from "discord.js";
|
||||
import BotClient from "./BotClient";
|
||||
|
||||
export default class BotAction {
|
||||
id: string;
|
||||
enabled: boolean = true;
|
||||
constructor(id: string, enabled: boolean = true) {
|
||||
this.id = id;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
async execute(Discord: any, client: BotClient, interaction: any): Promise<any> {
|
||||
interaction.reply({ content: "This Action is not Implemented Yet!", flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
52
src/libs/BotClient.ts
Normal file
52
src/libs/BotClient.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Client, Collection } from "discord.js";
|
||||
import Distube from "distube";
|
||||
|
||||
import { SpotifyPlugin } from "@distube/spotify";
|
||||
import { SoundCloudPlugin } from "@distube/soundcloud";
|
||||
import { YtDlpPlugin } from "@distube/yt-dlp";
|
||||
|
||||
import BotCommand from "./BotCommand";
|
||||
import Logger from "./Logger";
|
||||
import Formatter from "./Formatter";
|
||||
import BotAction from "./BotAction";
|
||||
import Storage from "./Storage";
|
||||
import { BotAI } from "./BotAI";
|
||||
import Config from "./Config";
|
||||
import Typst from "./Typst";
|
||||
|
||||
|
||||
export default class BotClient extends Client {
|
||||
commands: Collection<string, BotCommand>;
|
||||
action: Collection<string, BotAction>
|
||||
logger: Logger;
|
||||
formatter: Formatter;
|
||||
distube: any;
|
||||
storage: Storage;
|
||||
// ai: BotAI;
|
||||
config: Config;
|
||||
typst: Typst;
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.logger = new Logger();
|
||||
this.formatter = new Formatter();
|
||||
this.commands = new Collection();
|
||||
this.action = new Collection();
|
||||
this.storage = new Storage();
|
||||
|
||||
this.config = new Config();
|
||||
// this.ai = new BotAI();
|
||||
this.typst = new Typst();
|
||||
|
||||
this.distube = new Distube(this, {
|
||||
nsfw: true,
|
||||
plugins: [
|
||||
new SpotifyPlugin({
|
||||
api: { clientId: process.env.spotID, clientSecret: process.env.spotKey }
|
||||
}),
|
||||
new SoundCloudPlugin(),
|
||||
new YtDlpPlugin()
|
||||
]
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
38
src/libs/BotCommand.ts
Normal file
38
src/libs/BotCommand.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js";
|
||||
import BotClient from "./BotClient";
|
||||
|
||||
export default class BotCommand {
|
||||
name: string
|
||||
enabled: boolean
|
||||
use: string
|
||||
data: SlashCommandBuilder
|
||||
constructor(name: string, description: string, use: string, enabled: boolean = true) {
|
||||
this.name = name;
|
||||
this.use = use;
|
||||
this.enabled = enabled;
|
||||
this.data = new SlashCommandBuilder();
|
||||
this.data.setName(name);
|
||||
this.data.setDescription(description);
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getUse(): string {
|
||||
return this.use;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
async execute(Discord: any, client: BotClient, interaction: ChatInputCommandInteraction): Promise<any> {
|
||||
await interaction.reply({ content: "This Command is not Implemented Yet!", flags: MessageFlags.Ephemeral })
|
||||
}
|
||||
|
||||
}
|
||||
81
src/libs/Config.ts
Normal file
81
src/libs/Config.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import fs from 'fs';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RolesConfig {
|
||||
helperroles: Role[];
|
||||
educationroles: Role[];
|
||||
languageroles: Role[];
|
||||
locationroles: Role[];
|
||||
pingroles: Role[];
|
||||
}
|
||||
|
||||
interface Birthday {
|
||||
day: number;
|
||||
month: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
interface BirthdaysConfig {
|
||||
[userId: string]: Birthday;
|
||||
}
|
||||
|
||||
interface ReplyTextConfig {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export default class Config {
|
||||
private roles: RolesConfig | null;
|
||||
private replyText: ReplyTextConfig | null;
|
||||
private bdays: BirthdaysConfig | null;
|
||||
|
||||
constructor() {
|
||||
this.roles = null;
|
||||
this.replyText = null;
|
||||
this.bdays = null;
|
||||
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<void> {
|
||||
let rawdata: Buffer;
|
||||
|
||||
// Load the roles.json file
|
||||
rawdata = fs.readFileSync('./config/roles.json');
|
||||
this.roles = JSON.parse(rawdata.toString());
|
||||
|
||||
rawdata = fs.readFileSync('./config/replytext.json');
|
||||
this.replyText = JSON.parse(rawdata.toString());
|
||||
|
||||
rawdata = fs.readFileSync('./storage/bdays.json');
|
||||
this.bdays = JSON.parse(rawdata.toString());
|
||||
}
|
||||
|
||||
getRoles(): RolesConfig | null { return this.roles; }
|
||||
getHelperRoles(): Role[] { return this.roles?.helperroles || []; }
|
||||
getEducationRoles(): Role[] { return this.roles?.educationroles || []; }
|
||||
getLanguageRoles(): Role[] { return this.roles?.languageroles || []; }
|
||||
getLocationRoles(): Role[] { return this.roles?.locationroles || []; }
|
||||
getPingRoles(): Role[] { return this.roles?.pingroles || []; }
|
||||
|
||||
getReplyText(): ReplyTextConfig | null { return this.replyText; }
|
||||
|
||||
getBdays(): BirthdaysConfig | null { return this.bdays; }
|
||||
getBday(userId: string): Birthday | null {
|
||||
return this.bdays?.[userId] || null;
|
||||
}
|
||||
setBday(userId: string, bday: Birthday): void {
|
||||
if (!this.bdays) this.bdays = {};
|
||||
this.bdays[userId] = bday;
|
||||
fs.writeFileSync('./storage/bdays.json', JSON.stringify(this.bdays, null, 2));
|
||||
}
|
||||
removeBday(userId: string): void {
|
||||
if (this.bdays?.[userId]) {
|
||||
delete this.bdays[userId];
|
||||
fs.writeFileSync('./storage/bdays.json', JSON.stringify(this.bdays, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/libs/Database.ts
Normal file
46
src/libs/Database.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import mongoose from "mongoose";
|
||||
|
||||
export default class Database {
|
||||
private static instance: Database;
|
||||
private connected: boolean = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): Database {
|
||||
if (!Database.instance) {
|
||||
Database.instance = new Database();
|
||||
}
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connected) return;
|
||||
|
||||
const mongoUri = process.env.MONGODB_URI;
|
||||
if (!mongoUri) {
|
||||
console.warn("[Database] MONGODB_URI not set. Points system disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mongoose.connect(mongoUri);
|
||||
this.connected = true;
|
||||
console.log("[Database] Connected to MongoDB");
|
||||
} catch (error) {
|
||||
console.error("[Database] Connection failed:", error);
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connected && mongoose.connection.readyState === 1;
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
if (this.connected) {
|
||||
await mongoose.disconnect();
|
||||
this.connected = false;
|
||||
console.log("[Database] Disconnected from MongoDB");
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/libs/Formatter.ts
Normal file
133
src/libs/Formatter.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export default class Formatter {
|
||||
obj: any;
|
||||
constructor() {
|
||||
this.obj = null;
|
||||
}
|
||||
readYaml(path: string) {
|
||||
try {
|
||||
const fileContents = fs.readFileSync(path, 'utf8');
|
||||
return yaml.load(fileContents);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
jsonPathToValue(jsonData: any, path: string) {
|
||||
if (!(jsonData instanceof Object) || typeof (path) === "undefined") {
|
||||
throw "InvalidArgumentException(jsonData:" + jsonData + ", path:" + path + ")";
|
||||
}
|
||||
path = path.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
|
||||
path = path.replace(/^\./, ''); // strip a leading dot
|
||||
var pathArray = path.split('.');
|
||||
for (var i = 0, n = pathArray.length; i < n; ++i) {
|
||||
var key = pathArray[i];
|
||||
if (key && key in jsonData) {
|
||||
if (jsonData[key] !== null) {
|
||||
jsonData = jsonData[key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return jsonData;
|
||||
}
|
||||
trimBrackets(input: any) {
|
||||
let start = false, end = false;
|
||||
let final = input.split('');
|
||||
while(start == false) {
|
||||
if(final[0] != '{') {
|
||||
final.shift();
|
||||
} else {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
while(end == false) {
|
||||
if(final[final.length - 1] != '}') {
|
||||
final.pop();
|
||||
} else {
|
||||
end = true;
|
||||
}
|
||||
}
|
||||
return final.join('');
|
||||
}
|
||||
parseString(input: any) {
|
||||
let result = input;
|
||||
input.split(' ').forEach(async(word: any) => {
|
||||
if(!word.includes('{') && !word.includes('}')) return;
|
||||
let key = this.trimBrackets(word);
|
||||
if(!key.startsWith('{') && !key.endsWith('}')) return;
|
||||
key = key.replace('{', '').replace('}', '').split('_');
|
||||
if(key[0] == 'role') result = result.replace(`{${key[0]}_${key[1]}}`, "<@&" + key[1] + ">");
|
||||
if(key[0] == 'user') result = result.replace(`{${key[0]}_${key[1]}}`, "<@" + key[1] + ">");
|
||||
if(key[0] == 'channel') result = result.replace(`{${key[0]}_${key[1]}}`, "<#" + key[1] + ">");
|
||||
if(this.obj != null) {
|
||||
if(this.obj[key[0]] != null) result = result.replace(`{${key.join("_")}}`, this.jsonPathToValue(this.obj, key.join(".")));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
parseObject(input: any) {
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof input[key] == "string") input[key] = this.parseString(value);
|
||||
if (typeof input[key] == "object") input[key] = this.parseObject(input[key]);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
buildEmbed(path: string, obj: any = {}): EmbedBuilder {
|
||||
|
||||
obj = this.format(path, obj);
|
||||
|
||||
let embed = new EmbedBuilder()
|
||||
if(obj.title) embed.setTitle(obj.title);
|
||||
if(obj.description) embed.setDescription(obj.description);
|
||||
if(obj.color) embed.setColor(obj.color);
|
||||
if(obj.url) embed.setURL(obj.url);
|
||||
if(obj.image) embed.setImage(obj.image);
|
||||
if(obj.thumbnail) embed.setThumbnail(obj.thumbnail);
|
||||
if(obj.timestamp) embed.setTimestamp();
|
||||
|
||||
if(obj.author) {
|
||||
let name = obj.author.name || null;
|
||||
let url = obj.author.url || null;
|
||||
let iconURL = obj.author.iconURL || null;
|
||||
embed.setAuthor({ name: name, url: url, iconURL: iconURL });
|
||||
}
|
||||
|
||||
if(obj.footer) {
|
||||
let text = obj.footer.text || null;
|
||||
let iconURL = obj.footer.iconURL || null;
|
||||
embed.setFooter({ text: text, iconURL: iconURL });
|
||||
}
|
||||
|
||||
if(obj.fields) {
|
||||
obj.fields.forEach((field: any) => {
|
||||
embed.addFields({ name: field.name, value: field.value, inline: field.inline || false });
|
||||
});
|
||||
}
|
||||
return embed;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param path The path to the yaml file
|
||||
* @param obj Json object to replace the placeholders
|
||||
* @returns The formatted object
|
||||
*/
|
||||
format(path: string, obj: any = {}) {
|
||||
let result = null;
|
||||
try {
|
||||
this.obj = obj;
|
||||
result = this.parseObject(this.readYaml(path))
|
||||
} finally {
|
||||
this.obj = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
101
src/libs/Logger.ts
Normal file
101
src/libs/Logger.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
|
||||
export default class Logger {
|
||||
|
||||
colorize(inputString: string) {
|
||||
const minecraftColorCodes: any = {
|
||||
'0': '\x1b[30m', // Black
|
||||
'1': '\x1b[34m', // Dark Blue
|
||||
'2': '\x1b[32m', // Dark Green
|
||||
'3': '\x1b[36m', // Dark Aqua
|
||||
'4': '\x1b[31m', // Dark Red
|
||||
'5': '\x1b[35m', // Purple
|
||||
'6': '\x1b[33m', // Gold
|
||||
'7': '\x1b[37m', // Gray
|
||||
'8': '\x1b[90m', // Dark Gray
|
||||
'9': '\x1b[94m', // Blue
|
||||
'a': '\x1b[92m', // Green
|
||||
'b': '\x1b[96m', // Aqua
|
||||
'c': '\x1b[91m', // Red
|
||||
'd': '\x1b[95m', // Light Purple
|
||||
'e': '\x1b[93m', // Yellow
|
||||
'f': '\x1b[97m', // White
|
||||
'k': '\x1b[5m', // Obfuscated
|
||||
'l': '\x1b[1m', // Bold
|
||||
'm': '\x1b[9m', // Strikethrough
|
||||
'n': '\x1b[4m', // Underline
|
||||
'o': '\x1b[3m', // Italic
|
||||
'r': '\x1b[0m' // Reset
|
||||
};
|
||||
|
||||
const parts = inputString.split('&');
|
||||
let outputString = parts[0];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
|
||||
const colorCode = parts[i]?.[0];
|
||||
const restOfString = parts[i]?.slice(1);
|
||||
|
||||
if (colorCode && minecraftColorCodes.hasOwnProperty(colorCode)) {
|
||||
outputString += minecraftColorCodes[colorCode] + restOfString;
|
||||
} else {
|
||||
outputString += '&' + parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return outputString;
|
||||
}
|
||||
|
||||
decolorize(inputString: string) {
|
||||
const minecraftColorCodes: any = {
|
||||
'0': '',
|
||||
'1': '',
|
||||
'2': '',
|
||||
'3': '',
|
||||
'4': '',
|
||||
'5': '',
|
||||
'6': '',
|
||||
'7': '',
|
||||
'8': '',
|
||||
'9': '',
|
||||
'a': '',
|
||||
'b': '',
|
||||
'c': '',
|
||||
'd': '',
|
||||
'e': '',
|
||||
'f': '',
|
||||
'k': '',
|
||||
'l': '',
|
||||
'm': '',
|
||||
'n': '',
|
||||
'o': '',
|
||||
'r': '',
|
||||
};
|
||||
|
||||
const parts = inputString.split('&');
|
||||
let outputString = parts[0];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
|
||||
const colorCode = part[0];
|
||||
const restOfString = part.slice(1);
|
||||
|
||||
if (colorCode && minecraftColorCodes.hasOwnProperty(colorCode)) {
|
||||
outputString += minecraftColorCodes[colorCode] + restOfString;
|
||||
} else {
|
||||
outputString += '&' + parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return outputString;
|
||||
}
|
||||
|
||||
log(input: string) {
|
||||
console.log(this.colorize(input + "&r"));
|
||||
}
|
||||
|
||||
error(input: string) {
|
||||
console.error(this.colorize("&4" + input + "&r"));
|
||||
}
|
||||
}
|
||||
150
src/libs/PointsManager.ts
Normal file
150
src/libs/PointsManager.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import User, { IUser } from "../models/User";
|
||||
import Team, { ITeam } from "../models/Team";
|
||||
import Database from "./Database";
|
||||
|
||||
export default class PointsManager {
|
||||
private static DAILY_POINTS = 2;
|
||||
private static WEEKLY_POINTS = 10;
|
||||
|
||||
public static async awardPoints(userId: string, username: string, type: "daily" | "weekly"): Promise<{ userPoints: number; teamPoints?: number } | null> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return null;
|
||||
|
||||
const points = type === "daily" ? this.DAILY_POINTS : this.WEEKLY_POINTS;
|
||||
|
||||
try {
|
||||
let user = await User.findOne({ userId });
|
||||
|
||||
if (!user) {
|
||||
user = new User({
|
||||
userId,
|
||||
username,
|
||||
points: 0,
|
||||
dailyQuestionsCompleted: 0,
|
||||
weeklyQuestionsCompleted: 0
|
||||
});
|
||||
}
|
||||
|
||||
user.points += points;
|
||||
user.username = username;
|
||||
user.lastActive = new Date();
|
||||
|
||||
if (type === "daily") {
|
||||
user.dailyQuestionsCompleted += 1;
|
||||
} else {
|
||||
user.weeklyQuestionsCompleted += 1;
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
let teamPoints: number | undefined;
|
||||
if (user.teamId) {
|
||||
const team = await Team.findById(user.teamId);
|
||||
if (team) {
|
||||
team.points += points;
|
||||
(team as any).calculateAdjustedPoints();
|
||||
await team.save();
|
||||
teamPoints = team.adjustedPoints;
|
||||
}
|
||||
}
|
||||
|
||||
return { userPoints: user.points, teamPoints };
|
||||
} catch (error) {
|
||||
console.error("[PointsManager] Error awarding points:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getUserStats(userId: string): Promise<IUser | null> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return null;
|
||||
|
||||
try {
|
||||
return await User.findOne({ userId }).populate("teamId");
|
||||
} catch (error) {
|
||||
console.error("[PointsManager] Error fetching user stats:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getLeaderboard(limit: number = 10): Promise<IUser[]> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return [];
|
||||
|
||||
try {
|
||||
return await User.find().sort({ points: -1 }).limit(limit).exec();
|
||||
} catch (error) {
|
||||
console.error("[PointsManager] Error fetching leaderboard:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static async getTeamLeaderboard(limit: number = 10): Promise<ITeam[]> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return [];
|
||||
|
||||
try {
|
||||
const teams = await Team.find().exec();
|
||||
teams.forEach(team => (team as any).calculateAdjustedPoints());
|
||||
await Promise.all(teams.map(t => t.save()));
|
||||
|
||||
return teams.sort((a, b) => b.adjustedPoints - a.adjustedPoints).slice(0, limit);
|
||||
} catch (error) {
|
||||
console.error("[PointsManager] Error fetching team leaderboard:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public static canChangeTeam(): boolean {
|
||||
const now = new Date();
|
||||
const dayOfMonth = now.getDate();
|
||||
return dayOfMonth <= 7;
|
||||
}
|
||||
|
||||
public static async joinTeam(userId: string, username: string, teamId: string, requireTimeCheck: boolean = false): Promise<{ success: boolean; message: string }> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return { success: false, message: "Database not connected" };
|
||||
|
||||
if (requireTimeCheck && !this.canChangeTeam()) {
|
||||
return { success: false, message: "You can only change teams during the first week of the month (1st-7th)" };
|
||||
}
|
||||
|
||||
try {
|
||||
let user = await User.findOne({ userId });
|
||||
if (!user) {
|
||||
user = new User({ userId, username, points: 0 });
|
||||
}
|
||||
|
||||
const team = await Team.findById(teamId);
|
||||
if (!team) {
|
||||
return { success: false, message: "Team not found" };
|
||||
}
|
||||
|
||||
const oldTeamId = user.teamId;
|
||||
if (oldTeamId && oldTeamId.toString() === teamId) {
|
||||
return { success: false, message: "You're already in this team" };
|
||||
}
|
||||
|
||||
if (oldTeamId) {
|
||||
const oldTeam = await Team.findById(oldTeamId);
|
||||
if (oldTeam) {
|
||||
oldTeam.memberCount = Math.max(0, oldTeam.memberCount - 1);
|
||||
(oldTeam as any).calculateAdjustedPoints();
|
||||
await oldTeam.save();
|
||||
}
|
||||
}
|
||||
|
||||
user.teamId = team._id as any;
|
||||
await user.save();
|
||||
|
||||
team.memberCount += 1;
|
||||
(team as any).calculateAdjustedPoints();
|
||||
await team.save();
|
||||
|
||||
return { success: true, message: `Successfully joined team **${team.name}**!` };
|
||||
} catch (error) {
|
||||
console.error("[PointsManager] Error joining team:", error);
|
||||
return { success: false, message: "An error occurred while joining the team" };
|
||||
}
|
||||
}
|
||||
}
|
||||
455
src/libs/QuestionGenerator.ts
Normal file
455
src/libs/QuestionGenerator.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { GoogleGenerativeAI, SchemaType } from "@google/generative-ai";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import Question, { IQuestion } from "../models/Question";
|
||||
import Database from "./Database";
|
||||
|
||||
const SUBJECTS = ["Mathematics", "Physics", "Chemistry", "Organic Chemistry", "Biology", "Computer Science", "Engineering"];
|
||||
|
||||
export interface QuestionData {
|
||||
id: string;
|
||||
subject: string;
|
||||
frequency: "daily" | "weekly";
|
||||
topic: string;
|
||||
difficulty_rating: string;
|
||||
typst_source: string;
|
||||
reference_answer: string;
|
||||
timestamp: string;
|
||||
image_path?: string;
|
||||
}
|
||||
|
||||
export default class QuestionGenerator {
|
||||
private genAI: GoogleGenerativeAI;
|
||||
private model: string;
|
||||
private outputDir: string;
|
||||
private chatModel: ChatGoogleGenerativeAI;
|
||||
|
||||
constructor() {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY environment variable is missing.");
|
||||
}
|
||||
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = process.env.AI_MODEL || "gemini-2.5-flash";
|
||||
this.outputDir = path.join(process.cwd(), "data", "generated_problems");
|
||||
this.chatModel = new ChatGoogleGenerativeAI({
|
||||
apiKey: apiKey,
|
||||
model: this.model,
|
||||
temperature: 0.9,
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await fs.mkdir(this.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
private getSubjectTypstGuide(subjects: string[]): string {
|
||||
const guides: Record<string, string> = {
|
||||
"Mathematics": `
|
||||
**MATHEMATICS TYPST GUIDE:**
|
||||
- Variables: \`#let x = $5$;\` (semicolon required)
|
||||
- Functions: \`$f(x)$\`, \`$sin(theta)$\`, \`$ln(x)$\`
|
||||
- Multiple variables: ALWAYS add spaces: \`$x y$\` NOT \`$xy$\` (xy = one variable)
|
||||
- CORRECT: \`$2x y$\`, \`$a b c$\`, \`$x^2 y z$\`
|
||||
- WRONG: \`$2xy$\` (Typst reads as one variable "xy")
|
||||
- Subscripts: \`$x_1$\`, \`$x_(i+1)$\` (use parentheses for complex subscripts)
|
||||
- Matrices: Use \`mat()\`: \`$mat(1,2;3,4)$\` for 2×2 matrix
|
||||
- Vectors: \`$vec(x,y,z)$\` or \`$arrow(v)$\`
|
||||
- Limits: \`$lim_(x -> oo)$\`, \`$sum_(i=1)^n$\`
|
||||
- Integrals: \`$integral_0^1 f(x) dif x$\` (use \`dif\` for dx)
|
||||
- Greek: \`$alpha$\`, \`$beta$\`, \`$theta$\`, \`$pi$\`
|
||||
- NEVER: \`$x_i+1$\` (use parentheses: \`$x_(i+1)$\`)
|
||||
- NEVER: Strings in subscripts like \`$sigma_("text")$\` (use \`$sigma_"text"$\`)
|
||||
- NEVER: Adjacent variables without spaces like \`$xy$\`, \`$abc$\``,
|
||||
|
||||
"Physics": `
|
||||
**PHYSICS TYPST GUIDE:**
|
||||
- All variables in math mode: \`$F = m a$\` (note space between m and a)
|
||||
- Multiple variables: MUST have spaces: \`$m v$\`, \`$F d$\` NOT \`$mv$\`, \`$Fd$\`
|
||||
- Units as text: \`$9.8 "m/s"^2$\`, \`$5 "kg"$\`, \`$100 "N"$\`
|
||||
- Vectors: \`$arrow(F)$\`, \`$arrow(v)$\`, \`$hat(x)$\` for unit vectors
|
||||
- Dot product: \`$arrow(a) dot arrow(b)$\`
|
||||
- Cross product: \`$arrow(a) times arrow(b)$\`
|
||||
- Subscripts: \`$v_0$\`, \`$F_"net"$\`, \`$E_"kinetic"$\`
|
||||
- Greek: \`$omega$\`, \`$theta$\`, \`$phi$\`, \`$lambda$\`
|
||||
- Constants: \`$g = 9.8 "m/s"^2$\`, \`$c = 3 times 10^8 "m/s"$\`
|
||||
- NEVER: \`$F_net$\` without quotes (use \`$F_"net"$\`)
|
||||
- NEVER: Complex subscripts without parentheses
|
||||
- NEVER: Adjacent variables without spaces like \`$mv$\`, \`$xy$\``,
|
||||
|
||||
"Chemistry": `
|
||||
**CHEMISTRY TYPST GUIDE:**
|
||||
- Chemical formulas: ALL multi-letter elements need quotes
|
||||
- \`$"H"_2"O"$\`, \`$"CO"_2$\`, \`$"NaCl"$\`, \`$"CH"_4$\`
|
||||
- \`$"Ca"("OH")_2$\`, \`$"Fe"_2"O"_3$\`
|
||||
- Single elements: \`$"H"$\`, \`$"C"$\`, \`$"N"$\`, \`$"O"$\`
|
||||
- Charges: \`$"H"^+$\`, \`$"OH"^-$\`, \`$"Ca"^(2+)$\`, \`$"SO"_4^(2-)$\`
|
||||
- States: \`$"H"_2"O"(l)$\`, \`$"CO"_2(g)$\`, \`$"NaCl"(s)$\`
|
||||
- Arrows: \`$arrow.r$\` or \`$-->$\` for reactions
|
||||
- Equilibrium: \`$arrow.l.r$\` or \`$<-->$\`
|
||||
- Concentration: \`$["H"^+] = 0.1 "M"$\`
|
||||
- NEVER: \`$H_2O$\` or \`$CO_2$\` (needs quotes)
|
||||
- NEVER: \`$NaCl$\` without quotes`,
|
||||
|
||||
"Organic Chemistry": `
|
||||
**ORGANIC CHEMISTRY TYPST GUIDE:**
|
||||
- All molecules need quotes: \`$"CH"_3"CH"_2"OH"$\`
|
||||
- Functional groups: \`$"COOH"$\`, \`$"NH"_2$\`, \`$"OH"$\`
|
||||
- Benzene ring: Describe in text, then use \`$"C"_6"H"_6$\`
|
||||
- Naming: Keep as regular text outside math mode
|
||||
- Stereochemistry: \`$(R)$\`, \`$(S)$\`, \`$E$\`, \`$Z$\`
|
||||
- Mechanisms: Use arrows: \`$arrow.r$\`, \`$arrow.curve$\`
|
||||
- Charges: \`$delta^+$\`, \`$delta^-$\`
|
||||
- IUPAC names: Regular text (not in $ $)
|
||||
- NEVER: \`$CH_3$\` (use \`$"CH"_3$\`)`,
|
||||
|
||||
"Biology": `
|
||||
**BIOLOGY TYPST GUIDE:**
|
||||
- Species names: _Italics_ outside math: \`_E. coli_\` or \`_Homo sapiens_\`
|
||||
- Genes/proteins: Regular text or math: \`$"ATP"$\`, \`$"DNA"$\`, \`$"NADH"$\`
|
||||
- Concentrations: \`$["Ca"^(2+)] = 1 "mM"$\`
|
||||
- pH: \`$"pH" = 7.4$\`
|
||||
- Equations: \`$"C"_6"H"_(12)"O"_6 + 6"O"_2 arrow.r 6"CO"_2 + 6"H"_2"O"$\`
|
||||
- Ratios: \`$3:1$\` or \`$9:3:3:1$\`
|
||||
- Units: \`$"mg/mL"$\`, \`$mu"m"$\` (mu for micro)
|
||||
- NEVER: Unquoted chemical formulas`,
|
||||
|
||||
"Computer Science": `
|
||||
**COMPUTER SCIENCE TYPST GUIDE:**
|
||||
- Code snippets: Use code blocks with backticks: \`\`\`python ... \`\`\`
|
||||
- Algorithms: Use lists with \`+\` or \`-\`
|
||||
- Big O: \`$O(n)$\`, \`$O(n log n)$\`, \`$Theta(n^2)$\`
|
||||
- Math notation: \`$sum_(i=1)^n i$\`, \`$log_2 n$\`
|
||||
- Boolean: \`$and$\`, \`$or$\`, \`$not$\`
|
||||
- Sets: \`$\\{1,2,3\\}$\`, \`$A union B$\`, \`$A sect B$\`
|
||||
- Logic: \`$forall$\`, \`$exists$\`, \`$in$\`, \`$subset.eq$\`
|
||||
- Variables in math: \`$x_i$\`, \`$a_n$\`
|
||||
- Keep actual code outside math mode`,
|
||||
|
||||
"Engineering": `
|
||||
**ENGINEERING TYPST GUIDE:**
|
||||
- Variables with units: \`$F = 100 "N"$\`, \`$sigma = 50 "MPa"$\`
|
||||
- Multiple variables: MUST have spaces: \`$F A$\`, \`$L h$\` NOT \`$FA$\`, \`$Lh$\`
|
||||
- Subscripts for properties: \`$sigma_"yield"$\`, \`$E_"young"$\`
|
||||
- Greek symbols: \`$sigma$\`, \`$tau$\`, \`$epsilon$\`, \`$rho$\`
|
||||
- Stress/strain: \`$sigma = F/A$\`, \`$epsilon = Delta L / L_0$\`
|
||||
- Vectors: \`$arrow(F)$\`, \`$arrow(M)$\`
|
||||
- Moments: \`$M_x$\`, \`$M_"max"$\`
|
||||
- Units: \`$"Pa"$\`, \`$"MPa"$\`, \`$"kN"$\`, \`$"mm"$\`
|
||||
- NEVER: \`$sigma_(allow, a)$\` (use \`$sigma_"allow,a"$\` with quotes)
|
||||
- NEVER: Complex subscripts without quotes
|
||||
- NEVER: Adjacent variables without spaces like \`$FA$\`, \`$xy$\``
|
||||
};
|
||||
|
||||
return subjects.map((s: string) => {
|
||||
const normalized = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||
return guides[normalized] || guides["Mathematics"];
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async getHistory(limit: number = 100): Promise<QuestionData[]> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionGenerator] Database not connected, returning empty history");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const questions = await Question.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
return questions.map(q => ({
|
||||
id: q.id,
|
||||
subject: q.subject,
|
||||
frequency: q.frequency as "daily" | "weekly",
|
||||
topic: q.topic,
|
||||
difficulty_rating: q.difficulty_rating,
|
||||
typst_source: q.typst_source,
|
||||
reference_answer: q.reference_answer,
|
||||
timestamp: q.timestamp.toISOString(),
|
||||
image_path: q.image_path
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[QuestionGenerator] Error fetching history from DB:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async saveHistory(newQuestions: QuestionData[]) {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionGenerator] Database not connected, cannot save questions");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const questionDocs = newQuestions.map(q => ({
|
||||
id: q.id,
|
||||
subject: q.subject,
|
||||
frequency: q.frequency,
|
||||
topic: q.topic,
|
||||
difficulty_rating: q.difficulty_rating,
|
||||
typst_source: q.typst_source,
|
||||
reference_answer: q.reference_answer,
|
||||
timestamp: new Date(q.timestamp),
|
||||
image_path: q.image_path
|
||||
}));
|
||||
|
||||
await Question.insertMany(questionDocs, { ordered: false }).catch(err => {
|
||||
// Ignore duplicate key errors
|
||||
if (err.code !== 11000) throw err;
|
||||
});
|
||||
|
||||
console.log(`[QuestionGenerator] Saved ${newQuestions.length} questions to database`);
|
||||
} catch (error) {
|
||||
console.error("[QuestionGenerator] Error saving questions to DB:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getContextualHistory(frequency: "daily" | "weekly", subject?: string): Promise<string> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) return "No previous questions available.";
|
||||
|
||||
try {
|
||||
const query: any = { frequency };
|
||||
if (subject) query.subject = subject;
|
||||
|
||||
const recentQuestions = await Question.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(30)
|
||||
.select('subject topic difficulty_rating createdAt')
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
if (recentQuestions.length === 0) {
|
||||
return "No previous questions found for this category.";
|
||||
}
|
||||
|
||||
const context = recentQuestions.map((q, i) =>
|
||||
`${i + 1}. [${q.subject}] ${q.topic} (${q.difficulty_rating}) - Generated ${new Date(q.createdAt).toLocaleDateString()}`
|
||||
).join("\n");
|
||||
|
||||
return `Recent ${frequency} questions (last 30):\n${context}`;
|
||||
} catch (error) {
|
||||
console.error("[QuestionGenerator] Error fetching contextual history:", error);
|
||||
return "Error retrieving question history.";
|
||||
}
|
||||
}
|
||||
|
||||
async generateProblems(frequency: "daily" | "weekly", specificSubject?: string): Promise<QuestionData[]> {
|
||||
// Get contextual history from database
|
||||
const contextualHistory = await this.getContextualHistory(frequency, specificSubject);
|
||||
|
||||
const subjectsToGenerate = specificSubject ? [specificSubject] : SUBJECTS;
|
||||
|
||||
const subjectGuides = this.getSubjectTypstGuide(subjectsToGenerate);
|
||||
|
||||
const model = this.genAI.getGenerativeModel({
|
||||
model: this.model,
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: {
|
||||
type: SchemaType.ARRAY,
|
||||
items: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: {
|
||||
subject: { type: SchemaType.STRING },
|
||||
frequency: { type: SchemaType.STRING },
|
||||
topic: { type: SchemaType.STRING },
|
||||
difficulty_rating: { type: SchemaType.STRING },
|
||||
typst_source: { type: SchemaType.STRING },
|
||||
reference_answer: { type: SchemaType.STRING },
|
||||
},
|
||||
required: ["subject", "frequency", "topic", "difficulty_rating", "typst_source", "reference_answer"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const difficultyDesc = frequency === "daily"
|
||||
? "Easy undergraduate level (freshman/sophomore) requiring detailed calculations and showing work. Maximum 2 parts (e.g., Part a and Part b) that test foundational concepts."
|
||||
: "Advanced undergraduate level (junior/senior year) requiring deeper theoretical understanding and complex problem-solving. Maximum 2 parts that synthesize multiple upper-level concepts.";
|
||||
|
||||
const prompt = `
|
||||
Generate rigorous STEM practice problems in **Typst** code format.
|
||||
|
||||
SUBJECTS: ${subjectsToGenerate.join(", ")}
|
||||
FREQUENCY: ${frequency}
|
||||
|
||||
QUESTION HISTORY CONTEXT (avoid repeating these topics):
|
||||
${contextualHistory}
|
||||
|
||||
IMPORTANT: Review the question history above and generate NEW topics that haven't been covered recently. Avoid repeating the same concepts or problem types.
|
||||
|
||||
${subjectGuides}
|
||||
|
||||
CRITICAL TYPST SYNTAX RULES - FOLLOW EXACTLY:
|
||||
|
||||
**BEFORE YOU GENERATE:** Validate your output against these regex patterns to catch errors:
|
||||
1. Check for variables without spaces: \`/\$[0-9]*[a-z][a-z]+/g\` should NOT match (e.g., \`$xy$\`, \`$2ab$\` are INVALID)
|
||||
2. Check subscript parentheses: \`/_\("\\w+"\)/g\` should NOT match (e.g., \`$x_("text")$\` is INVALID, use \`$x_"text"$\`)
|
||||
3. Check chemical formulas: \`/\$[A-Z][a-z]?(?![_"])(?!\s)/g\` should require quotes (e.g., \`$NaCl$\` is INVALID, use \`$"NaCl"$\`)
|
||||
4. Check semicolons: \`/#let\\s+\\w+\\s*=\\s*\\\$[^;]*\\\$/g\` should NOT match (must end with \`;\`)
|
||||
5. Check set commands: \`/#set (text|page)/g\` should NOT match (font/page declarations forbidden)
|
||||
|
||||
**CORE SYNTAX RULES:**
|
||||
1. **Variables:** Must have semicolon after closing $ (e.g., \`#let x = $5$;\` NOT \`#let x = $5$ kg\`)
|
||||
2. **Math mode:** Use \`$...$\` for ALL equations. For chemical formulas use \`$"NiCl"_2$\` (quotes for multi-letter variables)
|
||||
3. **SPACES BETWEEN VARIABLES:** When writing multiple single-letter variables, ADD SPACES:
|
||||
- CORRECT: \`$x y$\`, \`$2x y$\`, \`$a b c$\`, \`$6x y$\`, \`$m v^2$\`, \`$a b + c d$\`
|
||||
- WRONG: \`$xy$\`, \`$2xy$\`, \`$abc$\`, \`$mv$\`, \`$ab+cd$\` (Typst reads these as ONE variable name)
|
||||
- EXCEPTION: Function names like \`$sin$\`, \`$cos$\`, \`$ln$\` are okay without spaces
|
||||
4. **Text in math:** Use quotes like \`$"text"$\` or \`$upright("text")$\`
|
||||
5. **Subscripts/Superscripts:** \`$x_2$\`, \`$x^2$\`, \`$10^(-27)$\` (use parentheses for negative exponents)
|
||||
6. **Complex subscripts:** ALWAYS use parentheses: \`$x_(i+1)$\` NOT \`$x_i+1$\`
|
||||
7. **Subscript text:** Use quotes directly WITHOUT parentheses: \`$sigma_"yield"$\` NOT \`$sigma_("yield")$\`
|
||||
8. **Multi-char subscripts:** Always wrap: \`$T_"max"$\`, \`$F_"net"$\`, \`$v_"initial"$\`
|
||||
9. **Lists:** Use \`- Item\` or \`+ Item\`. Never use single quotes.
|
||||
10. **Units:** Write as text: \`$5 "kg"$\`, \`$10 "m/s"^2$\`, \`$25 "MPa"$\`
|
||||
11. **Chemical formulas:** EVERY multi-letter element needs quotes:
|
||||
- \`$"H"_2"O"$\`, \`$"CO"_2$\`, \`$"NaCl"$\`, \`$"Ca"("OH")_2$\`, \`$"Fe"_2"O"_3$\`
|
||||
- Single letters can skip quotes: \`$"H"^+$\`, but safer to use quotes always
|
||||
12. **Greek letters:** \`$alpha$\`, \`$beta$\`, \`$Delta$\`, \`$theta$\`, \`$omega$\` (no backslash)
|
||||
13. **Operators:** \`$times$\`, \`$div$\`, \`$sum$\`, \`$integral$\`, \`$arrow.r$\`, \`$arrow.l.r$\`
|
||||
14. **Fractions:** \`$1/2$\` or \`$(a+b)/(c+d)$\`
|
||||
15. **Parentheses in math:** \`$(x+y)$\`, \`$[0, 1]$\`, \`$\\{x | x > 0\\}$\` (escape braces)
|
||||
16. **NO FONT/PAGE DECLARATIONS:** Never use \`#set text(font: ...)\` or \`#set page(...)\`
|
||||
|
||||
VALID EXAMPLES:
|
||||
\`\`\`typst
|
||||
#let mass = $5$;
|
||||
#let velocity = $10 "m/s"$;
|
||||
#let sigma_"yield" = $250 "MPa"$;
|
||||
#let pressure_"max" = $100 "kPa"$;
|
||||
|
||||
Calculate the energy: $E = 1/2 m v^2$ (note space between m and v)
|
||||
|
||||
Kinetic energy formula: $K E = 1/2 m v^2$
|
||||
|
||||
Function: $f(x, y) = x^3 - 6x y + 8y^3$ (space between x and y)
|
||||
|
||||
Polynomial: $p(x) = a x^3 + b x^2 + c x + d$ (spaces between all variables)
|
||||
|
||||
The compound $"H"_2"SO"_4$ reacts with $"NaOH"$ to form $"Na"_2"SO"_4$ and $"H"_2"O"$.
|
||||
|
||||
Ionic equation: $"Ca"^(2+) + "CO"_3^(2-) arrow.r "CaCO"_3(s)$
|
||||
|
||||
Subscript with expression: $x_(i+1) = x_i + 1$
|
||||
|
||||
Multiple subscripts: $T_"initial" = 298 "K"$ and $T_"final" = 373 "K"$
|
||||
|
||||
- Part (a): Find the derivative of $f(x)$
|
||||
- Part (b): Evaluate at $x = 2$
|
||||
- Part (c): Determine if $x y < 0$ (note space)
|
||||
\`\`\`
|
||||
|
||||
INVALID (DO NOT USE):
|
||||
- \`#set text(font: "Linux Libertine")\` (never declare fonts)
|
||||
- \`#set page(width: ...)\` (never set page properties)
|
||||
- \`#let x = $5$ kg\` (no text after closing $, must end with semicolon)
|
||||
- \`$xy$\` when you mean x times y (use \`$x y$\` with space)
|
||||
- \`$6xy$\` (use \`$6x y$\` with space between variables)
|
||||
- \`$mv^2$\` (use \`$m v^2$\` with space between m and v)
|
||||
- \`$ab + cd$\` (use \`$a b + c d$\` with spaces)
|
||||
- \`$sigma_("yield")$\` (quotes should be direct WITHOUT parentheses: \`$sigma_"yield"$\`)
|
||||
- \`$x_i+1$\` (use parentheses for expressions: \`$x_(i+1)$\`)
|
||||
- \`$NiCl_2$\` (multi-letter needs quotes: \`$"NiCl"_2$\`)
|
||||
- \`$H_2O$\` (needs quotes: \`$"H"_2"O"$\`)
|
||||
- \`$CO2$\` (needs quotes and underscore: \`$"CO"_2$\`)
|
||||
- \`$F_net$\` (text subscript needs quotes: \`$F_"net"$\`)
|
||||
- \`$KE$\` when meaning kinetic energy (use \`$K E$\` with space or write out)
|
||||
|
||||
**SELF-VALIDATION CHECKLIST (before submitting):**
|
||||
□ All multi-letter variables between single letters have spaces (e.g., \`$x y$\` not \`$xy$\`)
|
||||
□ All #let statements end with semicolon
|
||||
□ All text subscripts use quotes WITHOUT parentheses (e.g., \`$x_"text"$\`)
|
||||
□ All chemical formulas have quotes on multi-letter elements
|
||||
□ No #set text() or #set page() declarations
|
||||
□ All units are quoted (e.g., \`$5 "kg"$\`)
|
||||
□ Complex subscripts use parentheses (e.g., \`$x_(i+1)$\`)
|
||||
|
||||
REQUIREMENTS:
|
||||
1. **Typst Source:** Follow the syntax rules above EXACTLY. Validate against the regex patterns and checklist.
|
||||
2. **Reference Answer:** Provide the *exact* correct answer and the steps to derive it.
|
||||
3. **Difficulty:** ${difficultyDesc}
|
||||
4. **Question Structure:** MAXIMUM 2 parts per question (Part a and Part b). Do NOT create Part c, Part d, etc.
|
||||
5. **Quantity:** One '${frequency}' question per subject (${subjectsToGenerate.length} total).
|
||||
`;
|
||||
|
||||
const maxRetries = 3;
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
console.log(`[Gemini API] Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms delay`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
console.log(`[Gemini API] Sending request to ${this.model} for ${frequency} question generation (${subjectsToGenerate.length} subjects: ${subjectsToGenerate.join(", ")})`);
|
||||
const result = await model.generateContent(prompt);
|
||||
console.log(`[Gemini API] Response received successfully`);
|
||||
const problems = JSON.parse(result.response.text()) as Omit<QuestionData, "id" | "timestamp">[];
|
||||
|
||||
const questionsWithMeta: QuestionData[] = problems.map(p => ({
|
||||
...p,
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
await this.saveHistory(questionsWithMeta);
|
||||
return questionsWithMeta;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.error(`[Gemini API] Error on attempt ${attempt + 1}:`, error?.message || error);
|
||||
|
||||
if (error?.status === 429) {
|
||||
console.log(`[Gemini API] Rate limit hit. Retrying...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[Gemini API] All retry attempts failed:", lastError);
|
||||
return [];
|
||||
}
|
||||
|
||||
getOutputDir(): string {
|
||||
return this.outputDir;
|
||||
}
|
||||
|
||||
async getQuestionById(id: string): Promise<QuestionData | null> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionGenerator] Database not connected, cannot fetch question");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const question = await Question.findOne({ id }).lean().exec();
|
||||
if (!question) return null;
|
||||
|
||||
return {
|
||||
id: question.id,
|
||||
subject: question.subject,
|
||||
frequency: question.frequency as "daily" | "weekly",
|
||||
topic: question.topic,
|
||||
difficulty_rating: question.difficulty_rating,
|
||||
typst_source: question.typst_source,
|
||||
reference_answer: question.reference_answer,
|
||||
timestamp: question.timestamp.toISOString(),
|
||||
image_path: question.image_path
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[QuestionGenerator] Error fetching question by ID:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
src/libs/QuestionScheduler.ts
Normal file
238
src/libs/QuestionScheduler.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import QuestionGenerator, { QuestionData } from "./QuestionGenerator";
|
||||
import ScheduledQuestion from "../models/ScheduledQuestion";
|
||||
import Submission from "../models/Submission";
|
||||
import Database from "./Database";
|
||||
|
||||
interface UserSubmission {
|
||||
userId: string;
|
||||
username: string;
|
||||
questionId: string;
|
||||
answer: string;
|
||||
gradeResult: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default class QuestionScheduler {
|
||||
private dataDir: string;
|
||||
private generator: QuestionGenerator;
|
||||
|
||||
constructor() {
|
||||
this.dataDir = path.join(process.cwd(), "data");
|
||||
this.generator = new QuestionGenerator();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.generator.initialize();
|
||||
await fs.mkdir(this.dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
async getHistory(): Promise<QuestionData[]> {
|
||||
return await this.generator.getHistory();
|
||||
}
|
||||
|
||||
private getPeriodKey(period: "daily" | "weekly"): string {
|
||||
const now = new Date();
|
||||
if (period === "daily") {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
} else {
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
|
||||
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||
return `${now.getFullYear()}-W${String(weekNumber).padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getQuestionForPeriod(subject: string, period: "daily" | "weekly"): Promise<QuestionData | null> {
|
||||
const periodKey = this.getPeriodKey(period);
|
||||
const cacheKey = `${subject}-${period}-${periodKey}`;
|
||||
|
||||
const db = Database.getInstance();
|
||||
if (db.isConnected()) {
|
||||
try {
|
||||
// Check MongoDB cache
|
||||
const cached = await ScheduledQuestion.findOne({ cacheKey }).lean().exec();
|
||||
if (cached && cached.periodKey === periodKey) {
|
||||
// Fetch the full question data
|
||||
const question = await this.generator.getQuestionById(cached.questionId);
|
||||
if (question) return question;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error checking MongoDB cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new question
|
||||
const questions = await this.generator.generateProblems(period, subject);
|
||||
const subjectQuestion = questions.find(q =>
|
||||
q.subject.toLowerCase() === subject.toLowerCase() &&
|
||||
q.frequency === period
|
||||
);
|
||||
|
||||
if (!subjectQuestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache in MongoDB
|
||||
if (db.isConnected()) {
|
||||
try {
|
||||
await ScheduledQuestion.findOneAndUpdate(
|
||||
{ cacheKey },
|
||||
{
|
||||
cacheKey,
|
||||
questionId: subjectQuestion.id,
|
||||
subject,
|
||||
period,
|
||||
periodKey
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error caching to MongoDB:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return subjectQuestion;
|
||||
}
|
||||
|
||||
async forceRegenerateQuestion(subject: string, period: "daily" | "weekly"): Promise<QuestionData | null> {
|
||||
const periodKey = this.getPeriodKey(period);
|
||||
const cacheKey = `${subject}-${period}-${periodKey}`;
|
||||
|
||||
const db = Database.getInstance();
|
||||
|
||||
// Clear the cached question from MongoDB
|
||||
if (db.isConnected()) {
|
||||
try {
|
||||
await ScheduledQuestion.deleteOne({ cacheKey });
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error clearing MongoDB cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new question
|
||||
const questions = await this.generator.generateProblems(period, subject);
|
||||
const subjectQuestion = questions.find(q =>
|
||||
q.subject.toLowerCase() === subject.toLowerCase() &&
|
||||
q.frequency === period
|
||||
);
|
||||
|
||||
if (!subjectQuestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache the new question in MongoDB
|
||||
if (db.isConnected()) {
|
||||
try {
|
||||
await ScheduledQuestion.create({
|
||||
cacheKey,
|
||||
questionId: subjectQuestion.id,
|
||||
subject,
|
||||
period,
|
||||
periodKey
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error caching new question:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return subjectQuestion;
|
||||
}
|
||||
|
||||
async getUserSubmissions(): Promise<UserSubmission[]> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionScheduler] Database not connected");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const submissions = await Submission.find().lean().exec();
|
||||
return submissions.map(s => ({
|
||||
userId: s.userId,
|
||||
username: s.username,
|
||||
questionId: s.questionId,
|
||||
answer: s.answer,
|
||||
gradeResult: s.gradeResult,
|
||||
timestamp: s.timestamp.toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error fetching submissions:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async saveSubmission(submission: UserSubmission) {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionScheduler] Database not connected, cannot save submission");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Submission.create({
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
questionId: submission.questionId,
|
||||
answer: submission.answer,
|
||||
gradeResult: submission.gradeResult,
|
||||
timestamp: new Date(submission.timestamp)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error saving submission:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async hasUserAnswered(userId: string, questionId: string): Promise<boolean> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionScheduler] Database not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const submission = await Submission.findOne({ userId, questionId }).exec();
|
||||
return !!submission;
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error checking submission:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserScore(userId: string, period: "daily" | "weekly", periodKey?: string): Promise<{ correct: number; total: number }> {
|
||||
const db = Database.getInstance();
|
||||
if (!db.isConnected()) {
|
||||
console.warn("[QuestionScheduler] Database not connected");
|
||||
return { correct: 0, total: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const targetPeriodKey = periodKey || this.getPeriodKey(period);
|
||||
|
||||
// Get relevant question IDs for this period
|
||||
const scheduledQuestions = await ScheduledQuestion.find({
|
||||
period,
|
||||
periodKey: targetPeriodKey
|
||||
}).select('questionId').lean().exec();
|
||||
|
||||
const relevantQuestionIds = scheduledQuestions.map(sq => sq.questionId);
|
||||
|
||||
if (relevantQuestionIds.length === 0) {
|
||||
return { correct: 0, total: 0 };
|
||||
}
|
||||
|
||||
// Get user submissions for these questions
|
||||
const userSubmissions = await Submission.find({
|
||||
userId,
|
||||
questionId: { $in: relevantQuestionIds }
|
||||
}).lean().exec();
|
||||
|
||||
const correct = userSubmissions.filter(s => s.gradeResult?.is_correct).length;
|
||||
return { correct, total: userSubmissions.length };
|
||||
} catch (error) {
|
||||
console.error("[QuestionScheduler] Error calculating user score:", error);
|
||||
return { correct: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/libs/Storage.ts
Normal file
94
src/libs/Storage.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
import { Client, Collection, GuildMember, Message } from "discord.js";
|
||||
import BotClient from "./BotClient";
|
||||
|
||||
type MemberCacheEntry = {
|
||||
members: Collection<string, GuildMember>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
export default class Storage {
|
||||
db: any;
|
||||
private memberCache: Map<string, MemberCacheEntry>;
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.memberCache = new Map();
|
||||
|
||||
this.defaultTTL = 5 * 60 * 1000; // 5 minutes
|
||||
}
|
||||
|
||||
async fetchGuildMembers(client: BotClient | Client, guildId: string, forceRefresh: boolean = false, ttl?: number): Promise<Collection<string, GuildMember>> {
|
||||
const now = Date.now();
|
||||
const useTTL = typeof ttl === "number" ? ttl : this.defaultTTL;
|
||||
|
||||
if (!forceRefresh) {
|
||||
const cached = this.memberCache.get(guildId);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.members;
|
||||
}
|
||||
}
|
||||
|
||||
const guild = await client.guilds.fetch(guildId).catch(() => null);
|
||||
if (!guild) return new Collection<string, GuildMember>();
|
||||
|
||||
const members = await guild.members.fetch().catch(() => new Collection<string, GuildMember>());
|
||||
|
||||
this.memberCache.set(guildId, { members, expiresAt: now + useTTL });
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
invalidateGuildMembers(guildId: string) {
|
||||
this.memberCache.delete(guildId);
|
||||
}
|
||||
|
||||
|
||||
async getMessages(channel: any, options: { reverseArray?: boolean; userOnly?: boolean; botOnly?: boolean; pinnedOnly?: boolean; limitPages?: number; since?: Date } = {}): Promise<Message[]> {
|
||||
const { reverseArray, userOnly, botOnly, pinnedOnly, limitPages, since } = options;
|
||||
const messages: Message[] = [];
|
||||
let lastID: string | undefined = undefined;
|
||||
const maxPages = typeof limitPages === 'number' ? Math.max(1, limitPages) : 20;
|
||||
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const fetched: Collection<string, Message> = await channel.messages.fetch({ limit: 100, ...(lastID ? { before: lastID } : {}) }).catch(() => new Collection<string, Message>());
|
||||
if (!fetched || fetched.size === 0) {
|
||||
if (reverseArray) messages.reverse();
|
||||
let out = messages as Message[];
|
||||
if (userOnly) out = out.filter(m => !m.author.bot);
|
||||
if (botOnly) out = out.filter(m => m.author.bot);
|
||||
if (pinnedOnly) out = out.filter(m => m.pinned);
|
||||
return out;
|
||||
}
|
||||
|
||||
let stopEarly = false;
|
||||
for (const msg of fetched.values()) {
|
||||
if (since && msg.createdAt < since) {
|
||||
stopEarly = true;
|
||||
break;
|
||||
}
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
if (stopEarly) {
|
||||
if (reverseArray) messages.reverse();
|
||||
let out = messages as Message[];
|
||||
if (userOnly) out = out.filter(m => !m.author.bot);
|
||||
if (botOnly) out = out.filter(m => m.author.bot);
|
||||
if (pinnedOnly) out = out.filter(m => m.pinned);
|
||||
return out;
|
||||
}
|
||||
|
||||
lastID = fetched.last()?.id;
|
||||
if (!lastID) break;
|
||||
}
|
||||
|
||||
if (reverseArray) messages.reverse();
|
||||
let out = messages as Message[];
|
||||
if (userOnly) out = out.filter(m => !m.author.bot);
|
||||
if (botOnly) out = out.filter(m => m.author.bot);
|
||||
if (pinnedOnly) out = out.filter(m => m.pinned);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
115
src/libs/Typst.ts
Normal file
115
src/libs/Typst.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export default class Typst {
|
||||
messages: Map<string, { replyId: string; ownerId?: string }>;
|
||||
constructor() {
|
||||
this.messages = new Map();
|
||||
}
|
||||
|
||||
addMessage(userMessage: string, responseMessage: string, ownerId?: string) { this.messages.set(userMessage, { replyId: responseMessage, ownerId }); }
|
||||
removeMessage(userMessage: string) { this.messages.delete(userMessage); }
|
||||
hasMessage(userMessage: string) { return this.messages.has(userMessage); }
|
||||
getResponse(userMessage: string) { return this.messages.get(userMessage)?.replyId; }
|
||||
getOwner(userMessage: string) { return this.messages.get(userMessage)?.ownerId; }
|
||||
findByReply(replyId: string): { userMessage?: string; ownerId?: string } | null {
|
||||
for (const [userMessage, data] of this.messages.entries()) {
|
||||
if (data.replyId === replyId) return { userMessage, ownerId: data.ownerId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async compile(code: string, options?: { cleanup?: boolean }): Promise<{ buffers?: Buffer[]; error?: string; files?: string[]; typPath?: string }> {
|
||||
const storageDir = path.resolve('./storage/typst');
|
||||
await fs.promises.mkdir(storageDir, { recursive: true }).catch(() => {});
|
||||
|
||||
const uid = `${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const baseName = `typ_${uid}`;
|
||||
const typPath = path.join(storageDir, `${baseName}.typ`);
|
||||
const pngTemplate = path.join(storageDir, `${baseName}{p}.png`);
|
||||
const pngSingle = path.join(storageDir, `${baseName}.png`);
|
||||
const cleanup = options?.cleanup !== undefined ? options!.cleanup : true;
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(typPath, code, 'utf8');
|
||||
} catch (writeErr: any) {
|
||||
return { error: `Failed to write typ file: ${String(writeErr)}` };
|
||||
}
|
||||
|
||||
let compiledToTemplate = false;
|
||||
try {
|
||||
await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngTemplate], { timeout: 30000 });
|
||||
compiledToTemplate = true;
|
||||
} catch (multiErr: any) {
|
||||
try {
|
||||
await execFileAsync('typst', ['compile', '--format', 'png', '--ppi', '300', typPath, pngSingle], { timeout: 30000 });
|
||||
compiledToTemplate = false;
|
||||
} catch (singleErr: any) {
|
||||
if (cleanup) await fs.promises.unlink(typPath).catch(() => {});
|
||||
const msg = (multiErr && (multiErr.stderr || multiErr.message)) || (singleErr && (singleErr.stderr || singleErr.message)) || 'Unknown typst error';
|
||||
return { error: `Typst compilation failed: ${String(msg)}` };
|
||||
}
|
||||
}
|
||||
|
||||
let pngFiles: string[] = [];
|
||||
try {
|
||||
const all = await fs.promises.readdir(storageDir);
|
||||
if (compiledToTemplate) pngFiles = all.filter(f => f.startsWith(baseName) && f.endsWith('.png')).sort();
|
||||
else if (all.includes(`${baseName}.png`)) pngFiles = [`${baseName}.png`];
|
||||
} catch (readErr: any) {
|
||||
if (cleanup) await fs.promises.unlink(typPath).catch(() => {});
|
||||
return { error: `Failed to read output PNG files: ${String(readErr)}` };
|
||||
}
|
||||
|
||||
if (pngFiles.length === 0) {
|
||||
await fs.promises.unlink(typPath).catch(() => {});
|
||||
return { error: 'Compilation finished but no PNG files were produced' };
|
||||
}
|
||||
|
||||
const fullPaths = pngFiles.map(f => path.join(storageDir, f));
|
||||
const buffers: Buffer[] = await Promise.all(fullPaths.map(async p => {
|
||||
try { return await fs.promises.readFile(p); } catch (e) { return null as any; }
|
||||
}));
|
||||
|
||||
const validBuffers = buffers.filter(Boolean) as Buffer[];
|
||||
|
||||
if (cleanup) {
|
||||
await fs.promises.unlink(typPath).catch(() => {});
|
||||
await Promise.all(fullPaths.map(f => fs.promises.unlink(f).catch(() => {})));
|
||||
}
|
||||
|
||||
if (validBuffers.length === 0) return { error: 'No PNG buffers could be read from generated output' };
|
||||
|
||||
const result: { buffers?: Buffer[]; files?: string[]; typPath?: string } = { buffers: validBuffers };
|
||||
if (!cleanup) {
|
||||
result.files = fullPaths;
|
||||
result.typPath = typPath;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async renderToImage(typstCode: string): Promise<string | null> {
|
||||
const styledCode = `
|
||||
#set page(width: 210mm, height: auto, margin: (x: 20pt, y: 25pt), fill: white)
|
||||
#set text(size: 16pt, font: "New Computer Modern")
|
||||
#set par(justify: true, leading: 0.65em)
|
||||
#set block(spacing: 1.2em)
|
||||
|
||||
${typstCode}
|
||||
`;
|
||||
|
||||
const result = await this.compile(styledCode, { cleanup: false });
|
||||
|
||||
if (result.error || !result.files || result.files.length === 0) {
|
||||
console.error("Typst render error:", result.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.files[0];
|
||||
}
|
||||
|
||||
}
|
||||
33
src/models/Question.ts
Normal file
33
src/models/Question.ts
Normal 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);
|
||||
24
src/models/ScheduledQuestion.ts
Normal file
24
src/models/ScheduledQuestion.ts
Normal 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
35
src/models/Submission.ts
Normal 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
43
src/models/Team.ts
Normal 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
28
src/models/User.ts
Normal 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
0
src/test.ts
Normal file
Reference in New Issue
Block a user