Fix issues
This commit is contained in:
		
							
								
								
									
										62
									
								
								dist/ai/gemini.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								dist/ai/gemini.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import { GoogleGenerativeAI } from '@google/generative-ai'; | ||||
| import { env } from '../config/env.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| let client = null; | ||||
| export function getGemini() { | ||||
|     if (!client) { | ||||
|         client = new GoogleGenerativeAI(env.geminiApiKey); | ||||
|     } | ||||
|     return client; | ||||
| } | ||||
| export async function generateQOTD() { | ||||
|     const model = getGemini().getGenerativeModel({ model: 'gemini-2.5-flash' }); | ||||
|     const prompt = 'Generate a single, engaging, family-friendly “Question of the Day” suitable for a Discord community. Keep it under 25 words. No preface or explanation, only the question.'; | ||||
|     const res = await model.generateContent(prompt); | ||||
|     const text = res.response.text().trim(); | ||||
|     return text.replace(/^"|"$/g, ''); | ||||
| } | ||||
| export async function chatAnswer(question) { | ||||
|     const model = getGemini().getGenerativeModel({ model: 'gemini-2.5-flash' }); | ||||
|     const res = await model.generateContent(`Answer concisely and helpfully for a Discord chat:\n${question}`); | ||||
|     return res.response.text().trim(); | ||||
| } | ||||
| export async function loadUserMemory(guildId, userId) { | ||||
|     const rows = await withConn((conn) => conn.query('SELECT messages, updated_at FROM user_ai_memory WHERE guild_id = ? AND user_id = ? LIMIT 1', [guildId, userId])); | ||||
|     const raw = rows[0]?.messages; | ||||
|     const updatedAt = rows[0]?.updated_at; | ||||
|     if (!raw) | ||||
|         return []; | ||||
|     // Auto-reset if inactive for 3 days | ||||
|     if (updatedAt && Date.now() - new Date(updatedAt).getTime() > 3 * 24 * 60 * 60 * 1000) { | ||||
|         await withConn((conn) => conn.query('DELETE FROM user_ai_memory WHERE guild_id = ? AND user_id = ?', [guildId, userId])); | ||||
|         return []; | ||||
|     } | ||||
|     try { | ||||
|         return Array.isArray(raw) ? raw : JSON.parse(raw); | ||||
|     } | ||||
|     catch { | ||||
|         return []; | ||||
|     } | ||||
| } | ||||
| export async function saveUserMemory(guildId, userId, history) { | ||||
|     const json = JSON.stringify(history.slice(-20)); | ||||
|     await withConn((conn) => conn.query('INSERT INTO user_ai_memory (guild_id, user_id, messages) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE messages = VALUES(messages)', [guildId, userId, json])); | ||||
| } | ||||
| export async function chatAnswerWithMemory(guildId, userId, question) { | ||||
|     const history = await loadUserMemory(guildId, userId); | ||||
|     const model = getGemini().getGenerativeModel({ model: 'gemini-2.5-flash' }); | ||||
|     const prompt = [ | ||||
|         { role: 'user', content: 'You are a helpful Discord assistant. Be concise.' }, | ||||
|         ...history, | ||||
|         { role: 'user', content: question } | ||||
|     ]; | ||||
|     const res = await model.generateContent(prompt.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`).join('\n')); | ||||
|     const answer = res.response.text().trim(); | ||||
|     const next = [ | ||||
|         ...history, | ||||
|         { role: 'user', content: question }, | ||||
|         { role: 'assistant', content: answer } | ||||
|     ]; | ||||
|     await saveUserMemory(guildId, userId, next); | ||||
|     return answer; | ||||
| } | ||||
							
								
								
									
										33
									
								
								dist/commands/leaderboard.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								dist/commands/leaderboard.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| export const data = new SlashCommandBuilder() | ||||
|     .setName('leaderboard') | ||||
|     .setDescription('Show top users by XP'); | ||||
| export async function execute(interaction) { | ||||
|     if (!interaction.guildId) | ||||
|         return interaction.reply({ content: 'Guild only command.', ephemeral: true }); | ||||
|     const rows = await withConn(async (conn) => { | ||||
|         return conn.query('SELECT user_id, xp FROM user_xp WHERE guild_id = ? ORDER BY xp DESC LIMIT 10', [interaction.guildId]); | ||||
|     }); | ||||
|     const list = rows; | ||||
|     if (!list.length) | ||||
|         return interaction.reply('No XP data yet. Start chatting!'); | ||||
|     const medals = ['🥇', '🥈', '🥉']; | ||||
|     const XP_PER_LEVEL = 500; | ||||
|     const desc = list | ||||
|         .map((r, i) => { | ||||
|         const rank = i + 1; | ||||
|         const level = Math.floor((r.xp || 0) / XP_PER_LEVEL); | ||||
|         const tag = `<@${r.user_id}>`; | ||||
|         const medal = medals[i] || `#${rank}`; | ||||
|         return `${medal} ${tag} — ${r.xp} XP (Lvl ${level})`; | ||||
|     }) | ||||
|         .join('\n'); | ||||
|     const embed = new EmbedBuilder() | ||||
|         .setTitle('Server Leaderboard') | ||||
|         .setColor(0xFEE75C) | ||||
|         .setDescription(desc) | ||||
|         .setFooter({ text: interaction.guild?.name || 'Leaderboard' }) | ||||
|         .setTimestamp(); | ||||
|     await interaction.reply({ embeds: [embed], allowedMentions: { parse: [] } }); | ||||
| } | ||||
							
								
								
									
										64
									
								
								dist/commands/moderation.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								dist/commands/moderation.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import { PermissionFlagsBits, SlashCommandBuilder, time, userMention } from 'discord.js'; | ||||
| import ms from 'ms'; | ||||
| function base() { | ||||
|     return new SlashCommandBuilder() | ||||
|         .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers | PermissionFlagsBits.KickMembers | PermissionFlagsBits.ModerateMembers); | ||||
| } | ||||
| export const banData = base() | ||||
|     .setName('ban') | ||||
|     .setDescription('Ban a user') | ||||
|     .addUserOption(o => o.setName('user').setDescription('User to ban').setRequired(true)) | ||||
|     .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); | ||||
| export async function banExecute(interaction) { | ||||
|     if (!interaction.guild) | ||||
|         return interaction.reply({ content: 'Guild only.', ephemeral: true }); | ||||
|     const user = interaction.options.getUser('user', true); | ||||
|     const reason = interaction.options.getString('reason') || 'No reason provided'; | ||||
|     const member = await interaction.guild.members.fetch(user.id).catch(() => null); | ||||
|     if (!member) | ||||
|         return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); | ||||
|     await user.send(`You have been banned from ${interaction.guild.name}. Reason: ${reason}`).catch(() => null); | ||||
|     await member.ban({ reason }); | ||||
|     await interaction.reply({ content: `Banned ${userMention(user.id)}.`, allowedMentions: { parse: [] } }); | ||||
| } | ||||
| export const kickData = base() | ||||
|     .setName('kick') | ||||
|     .setDescription('Kick a user') | ||||
|     .addUserOption(o => o.setName('user').setDescription('User to kick').setRequired(true)) | ||||
|     .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); | ||||
| export async function kickExecute(interaction) { | ||||
|     if (!interaction.guild) | ||||
|         return interaction.reply({ content: 'Guild only.', ephemeral: true }); | ||||
|     const user = interaction.options.getUser('user', true); | ||||
|     const reason = interaction.options.getString('reason') || 'No reason provided'; | ||||
|     const member = await interaction.guild.members.fetch(user.id).catch(() => null); | ||||
|     if (!member) | ||||
|         return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); | ||||
|     await user.send(`You have been kicked from ${interaction.guild.name}. Reason: ${reason}`).catch(() => null); | ||||
|     await member.kick(reason); | ||||
|     await interaction.reply({ content: `Kicked ${userMention(user.id)}.`, allowedMentions: { parse: [] } }); | ||||
| } | ||||
| export const timeoutData = base() | ||||
|     .setName('timeout') | ||||
|     .setDescription('Timeout a user') | ||||
|     .addUserOption(o => o.setName('user').setDescription('User to timeout').setRequired(true)) | ||||
|     .addStringOption(o => o.setName('duration').setDescription('Duration (e.g. 10m, 1h)').setRequired(true)) | ||||
|     .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); | ||||
| export async function timeoutExecute(interaction) { | ||||
|     if (!interaction.guild) | ||||
|         return interaction.reply({ content: 'Guild only.', ephemeral: true }); | ||||
|     const user = interaction.options.getUser('user', true); | ||||
|     const duration = interaction.options.getString('duration', true); | ||||
|     const msDur = ms(duration); | ||||
|     if (!msDur || msDur < 1000 || msDur > 28 * 24 * 60 * 60 * 1000) { | ||||
|         return interaction.reply({ content: 'Invalid duration. Use between 1s and 28d.', ephemeral: true }); | ||||
|     } | ||||
|     const reason = interaction.options.getString('reason') || 'No reason provided'; | ||||
|     const member = await interaction.guild.members.fetch(user.id).catch(() => null); | ||||
|     if (!member) | ||||
|         return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); | ||||
|     const until = new Date(Date.now() + msDur); | ||||
|     await user.send(`You have been timed out in ${interaction.guild.name} until ${time(until, 'F')} UTC. Reason: ${reason}`).catch(() => null); | ||||
|     await member.timeout(msDur, reason); | ||||
|     await interaction.reply({ content: `Timed out ${userMention(user.id)} until ${time(until, 'F')} UTC.`, allowedMentions: { parse: [] } }); | ||||
| } | ||||
							
								
								
									
										68
									
								
								dist/commands/profile.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								dist/commands/profile.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
|  | ||||
| export const data = new SlashCommandBuilder() | ||||
|   .setName('profile') | ||||
|   .setDescription('Show XP profile') | ||||
|   .addUserOption(o => | ||||
|     o.setName('user').setDescription('User to view') | ||||
|   ); | ||||
|  | ||||
| export async function execute(interaction) { | ||||
|   if (!interaction.guildId) | ||||
|     return interaction.reply({ | ||||
|       content: 'Guild only command.', | ||||
|       ephemeral: true, | ||||
|     }); | ||||
|  | ||||
|   const target = interaction.options.getUser('user') || interaction.user; | ||||
|  | ||||
|   const data = await withConn(async conn => { | ||||
|     const rows = await conn.query( | ||||
|       'SELECT xp FROM user_xp WHERE guild_id = ? AND user_id = ? LIMIT 1', | ||||
|       [BigInt(interaction.guildId), BigInt(target.id)] | ||||
|     ); | ||||
|  | ||||
|     const xp = Number(rows[0]?.xp ?? 0); | ||||
|  | ||||
|     const rankRows = await conn.query( | ||||
|       'SELECT COUNT(*) AS ahead FROM user_xp WHERE guild_id = ? AND xp > ?', | ||||
|       [BigInt(interaction.guildId), xp] | ||||
|     ); | ||||
|  | ||||
|     const rank = Number(rankRows[0]?.ahead ?? 0) + 1; | ||||
|  | ||||
|     return { xp, rank }; | ||||
|   }); | ||||
|  | ||||
|   const XP_PER_LEVEL = 500; // match visual example | ||||
|   const level = Math.floor(data.xp / XP_PER_LEVEL); | ||||
|   const currentLevelXp = data.xp % XP_PER_LEVEL; | ||||
|   const progress = Math.min( | ||||
|     100, | ||||
|     Math.round((currentLevelXp / XP_PER_LEVEL) * 1000) / 10 | ||||
|   ); | ||||
|  | ||||
|   const embed = new EmbedBuilder() | ||||
|     .setAuthor({ | ||||
|       name: `${target.username}'s Profile`, | ||||
|       iconURL: target.displayAvatarURL(), | ||||
|     }) | ||||
|     .setColor(0x5865f2) | ||||
|     .setDescription(`Progress to next level: **${progress}%**`) | ||||
|     .addFields( | ||||
|       { name: '🏅 Level', value: `${level}`, inline: true }, | ||||
|       { name: '💎 XP', value: `${currentLevelXp} / ${XP_PER_LEVEL}`, inline: true }, | ||||
|       { name: '🏆 Rank', value: `#${data.rank}`, inline: true } | ||||
|     ) | ||||
|     .setFooter({ | ||||
|       text: `Server: ${interaction.guild?.name || 'Unknown'}`, | ||||
|     }) | ||||
|     .setTimestamp(); | ||||
|  | ||||
|   await interaction.reply({ | ||||
|     embeds: [embed], | ||||
|     allowedMentions: { parse: [] }, | ||||
|   }); | ||||
| } | ||||
|  | ||||
							
								
								
									
										14
									
								
								dist/commands/resetmemory.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								dist/commands/resetmemory.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { SlashCommandBuilder } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| export const data = new SlashCommandBuilder() | ||||
|     .setName('resetmemory') | ||||
|     .setDescription('Reset your AI chat memory (only affects you)'); | ||||
| export async function execute(interaction) { | ||||
|     if (!interaction.guildId) | ||||
|         return interaction.reply({ content: 'Guild only command.', ephemeral: true }); | ||||
|     const target = interaction.user; | ||||
|     await withConn(async (conn) => { | ||||
|         await conn.query('DELETE FROM user_ai_memory WHERE guild_id = ? AND user_id = ?', [interaction.guildId, target.id]); | ||||
|     }); | ||||
|     await interaction.reply({ content: 'Your AI memory has been fully reset.', ephemeral: true }); | ||||
| } | ||||
							
								
								
									
										71
									
								
								dist/commands/setup.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								dist/commands/setup.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import { SlashCommandBuilder, ChannelType, PermissionFlagsBits } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| export const data = new SlashCommandBuilder() | ||||
|     .setName('setup') | ||||
|     .setDescription('Enable/disable features or set channels for this server') | ||||
|     .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) | ||||
|     .addStringOption(o => o | ||||
|     .setName('feature') | ||||
|     .setDescription('Feature to configure') | ||||
|     .setRequired(true) | ||||
|     .addChoices({ name: 'moderation', value: 'moderation' }, { name: 'ai', value: 'ai' }, { name: 'logs', value: 'logs' }, { name: 'qotd', value: 'qotd' }, { name: 'profanity_filter', value: 'profanity_filter' })) | ||||
|     .addStringOption(o => o | ||||
|     .setName('value') | ||||
|     .setDescription('on|off for toggles; for logs: off|channel; for qotd: off|channel') | ||||
|     .setRequired(true) | ||||
|     .addChoices({ name: 'on', value: 'on' }, { name: 'off', value: 'off' }, { name: 'channel', value: 'channel' })) | ||||
|     .addChannelOption(o => o | ||||
|     .setName('channel') | ||||
|     .setDescription('Target channel when selecting channel mode') | ||||
|     .addChannelTypes(ChannelType.GuildText)); | ||||
| export async function execute(interaction) { | ||||
|     if (!interaction.guildId) | ||||
|         return interaction.reply({ content: 'Guild only command.', ephemeral: true }); | ||||
|     const feature = interaction.options.getString('feature', true); | ||||
|     const value = interaction.options.getString('value', true); | ||||
|     const channel = interaction.options.getChannel('channel'); | ||||
|     await withConn(async (conn) => { | ||||
|         await conn.query('INSERT IGNORE INTO guild_config (guild_id) VALUES (?)', [interaction.guildId]); | ||||
|         switch (feature) { | ||||
|             case 'moderation': | ||||
|             case 'ai': | ||||
|             case 'profanity_filter': { | ||||
|                 const column = feature === 'moderation' ? 'moderation_enabled' : feature === 'ai' ? 'ai_enabled' : 'profanity_filter_enabled'; | ||||
|                 const enabled = value === 'on' ? 1 : 0; | ||||
|                 await conn.query(`UPDATE guild_config SET ${column} = ? WHERE guild_id = ?`, [enabled, interaction.guildId]); | ||||
|                 await interaction.reply({ content: `${feature} set to ${value}.`, ephemeral: true }); | ||||
|                 break; | ||||
|             } | ||||
|             case 'logs': { | ||||
|                 if (value === 'channel') { | ||||
|                     if (!channel) | ||||
|                         return interaction.reply({ content: 'Please provide a text channel.', ephemeral: true }); | ||||
|                     await conn.query('UPDATE guild_config SET logs_enabled = 1, logs_channel_id = ? WHERE guild_id = ?', [channel.id, interaction.guildId]); | ||||
|                     await interaction.reply({ content: `Logs enabled in <#${channel.id}>.`, ephemeral: true }); | ||||
|                 } | ||||
|                 else { | ||||
|                     const enabled = value === 'on' ? 1 : 0; | ||||
|                     await conn.query('UPDATE guild_config SET logs_enabled = ?, logs_channel_id = NULL WHERE guild_id = ?', [enabled, interaction.guildId]); | ||||
|                     await interaction.reply({ content: `Logs ${value}.`, ephemeral: true }); | ||||
|                 } | ||||
|                 break; | ||||
|             } | ||||
|             case 'qotd': { | ||||
|                 if (value === 'channel') { | ||||
|                     if (!channel) | ||||
|                         return interaction.reply({ content: 'Please provide a text channel.', ephemeral: true }); | ||||
|                     await conn.query('UPDATE guild_config SET qotd_enabled = 1, qotd_channel_id = ? WHERE guild_id = ?', [channel.id, interaction.guildId]); | ||||
|                     await interaction.reply({ content: `QOTD enabled in <#${channel.id}>.`, ephemeral: true }); | ||||
|                 } | ||||
|                 else { | ||||
|                     const enabled = value === 'on' ? 1 : 0; | ||||
|                     await conn.query('UPDATE guild_config SET qotd_enabled = ?, qotd_channel_id = NULL WHERE guild_id = ?', [enabled, interaction.guildId]); | ||||
|                     await interaction.reply({ content: `QOTD ${value}.`, ephemeral: true }); | ||||
|                 } | ||||
|                 break; | ||||
|             } | ||||
|             default: | ||||
|                 await interaction.reply({ content: 'Unknown feature.', ephemeral: true }); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										20
									
								
								dist/config/env.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								dist/config/env.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import dotenv from 'dotenv'; | ||||
| dotenv.config(); | ||||
| export const env = { | ||||
|     discordToken: process.env.DISCORD_TOKEN || '', | ||||
|     discordClientId: process.env.DISCORD_CLIENT_ID || '', | ||||
|     geminiApiKey: process.env.GEMINI_API_KEY || '', | ||||
|     db: { | ||||
|         host: process.env.DATABASE_HOST || '127.0.0.1', | ||||
|         port: Number(process.env.DATABASE_PORT || 3306), | ||||
|         user: process.env.DATABASE_USER || 'root', | ||||
|         password: process.env.DATABASE_PASSWORD || '', | ||||
|         database: process.env.DATABASE_NAME || 'civita' | ||||
|     } | ||||
| }; | ||||
| export function requireEnv(name) { | ||||
|     const val = env[name]; | ||||
|     if (typeof val === 'string' && !val) { | ||||
|         throw new Error(`Missing required env: ${name}`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										39
									
								
								dist/db/migrate.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								dist/db/migrate.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { withConn } from './pool.js'; | ||||
| export async function migrate() { | ||||
|     await withConn(async (conn) => { | ||||
|         await conn.query(` | ||||
|       CREATE TABLE IF NOT EXISTS guild_config ( | ||||
|         guild_id VARCHAR(32) PRIMARY KEY, | ||||
|         moderation_enabled TINYINT(1) NOT NULL DEFAULT 1, | ||||
|         ai_enabled TINYINT(1) NOT NULL DEFAULT 0, | ||||
|         logs_enabled TINYINT(1) NOT NULL DEFAULT 0, | ||||
|         logs_channel_id VARCHAR(32) DEFAULT NULL, | ||||
|         qotd_enabled TINYINT(1) NOT NULL DEFAULT 0, | ||||
|         qotd_channel_id VARCHAR(32) DEFAULT NULL, | ||||
|         profanity_filter_enabled TINYINT(1) NOT NULL DEFAULT 1, | ||||
|         qotd_last_sent DATE DEFAULT NULL, | ||||
|         created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||||
|         updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | ||||
|       ); | ||||
|     `); | ||||
|         await conn.query(` | ||||
|       CREATE TABLE IF NOT EXISTS user_xp ( | ||||
|         guild_id VARCHAR(32) NOT NULL, | ||||
|         user_id VARCHAR(32) NOT NULL, | ||||
|         xp INT NOT NULL DEFAULT 0, | ||||
|         PRIMARY KEY (guild_id, user_id), | ||||
|         INDEX (guild_id), | ||||
|         INDEX (xp) | ||||
|       ); | ||||
|     `); | ||||
|         await conn.query(` | ||||
|       CREATE TABLE IF NOT EXISTS user_ai_memory ( | ||||
|         guild_id VARCHAR(32) NOT NULL, | ||||
|         user_id VARCHAR(32) NOT NULL, | ||||
|         messages JSON NOT NULL, | ||||
|         updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
|         PRIMARY KEY (guild_id, user_id) | ||||
|       ); | ||||
|     `); | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										20
									
								
								dist/db/pool.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								dist/db/pool.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import mariadb from 'mariadb'; | ||||
| import { env } from '../config/env.js'; | ||||
| export const pool = mariadb.createPool({ | ||||
|     host: env.db.host, | ||||
|     port: env.db.port, | ||||
|     user: env.db.user, | ||||
|     password: env.db.password, | ||||
|     database: env.db.database, | ||||
|     connectionLimit: 5, | ||||
|     multipleStatements: true | ||||
| }); | ||||
| export async function withConn(fn) { | ||||
|     const conn = await pool.getConnection(); | ||||
|     try { | ||||
|         return await fn(conn); | ||||
|     } | ||||
|     finally { | ||||
|         conn.end(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										26
									
								
								dist/deploy-commands.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								dist/deploy-commands.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| import { REST, Routes } from 'discord.js'; | ||||
| import { env } from './config/env.js'; | ||||
| import * as setup from './commands/setup.js'; | ||||
| import * as leaderboard from './commands/leaderboard.js'; | ||||
| import * as profile from './commands/profile.js'; | ||||
| import * as resetmemory from './commands/resetmemory.js'; | ||||
| import { banData, kickData, timeoutData } from './commands/moderation.js'; | ||||
| async function main() { | ||||
|     const commands = [ | ||||
|         setup.data.toJSON(), | ||||
|         leaderboard.data.toJSON(), | ||||
|         profile.data.toJSON(), | ||||
|         resetmemory.data.toJSON(), | ||||
|         banData.toJSON(), | ||||
|         kickData.toJSON(), | ||||
|         timeoutData.toJSON() | ||||
|     ]; | ||||
|     const rest = new REST({ version: '10' }).setToken(env.discordToken); | ||||
|     await rest.put(Routes.applicationCommands(env.discordClientId), { body: commands }); | ||||
|     // You can replace with Routes.applicationGuildCommands for quicker iteration per guild | ||||
|     console.log('Commands deployed.'); | ||||
| } | ||||
| main().catch((e) => { | ||||
|     console.error(e); | ||||
|     process.exit(1); | ||||
| }); | ||||
							
								
								
									
										31
									
								
								dist/events/guildBanKickLog.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								dist/events/guildBanKickLog.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import { Events } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| async function getLogsChannelId(guildId) { | ||||
|     const row = await withConn(async (conn) => { | ||||
|         const rows = await conn.query('SELECT logs_enabled, logs_channel_id FROM guild_config WHERE guild_id = ? LIMIT 1', [guildId]); | ||||
|         return rows[0]; | ||||
|     }); | ||||
|     if (!row?.logs_enabled || !row.logs_channel_id) | ||||
|         return null; | ||||
|     return row.logs_channel_id; | ||||
| } | ||||
| export function registerModerationLogs(client) { | ||||
|     client.on(Events.GuildBanAdd, async (ban) => { | ||||
|         const channelId = await getLogsChannelId(ban.guild.id); | ||||
|         if (!channelId) | ||||
|             return; | ||||
|         const channel = ban.guild.channels.cache.get(channelId); | ||||
|         if (!channel || !('send' in channel)) | ||||
|             return; | ||||
|         await channel.send(`User banned: <@${ban.user.id}>`); | ||||
|     }); | ||||
|     client.on(Events.GuildMemberRemove, async (member) => { | ||||
|         const channelId = await getLogsChannelId(member.guild.id); | ||||
|         if (!channelId) | ||||
|             return; | ||||
|         const channel = member.guild.channels.cache.get(channelId); | ||||
|         if (!channel || !('send' in channel)) | ||||
|             return; | ||||
|         await channel.send(`Member left or was kicked: <@${member.id}>`); | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										35
									
								
								dist/events/messageCreate.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								dist/events/messageCreate.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import { Events } from 'discord.js'; | ||||
| import { withConn } from '../db/pool.js'; | ||||
| import { chatAnswerWithMemory } from '../ai/gemini.js'; | ||||
| import filter from 'leo-profanity'; | ||||
| filter.loadDictionary('en'); | ||||
| export function registerMessageCreate(client) { | ||||
|     client.on(Events.MessageCreate, async (message) => { | ||||
|         if (!message.guild || message.author.bot) | ||||
|             return; | ||||
|         const guildId = message.guild.id; | ||||
|         const cfg = await withConn(async (conn) => { | ||||
|             const rows = await conn.query('SELECT ai_enabled, profanity_filter_enabled FROM guild_config WHERE guild_id = ? LIMIT 1', [guildId]); | ||||
|             return rows[0]; | ||||
|         }); | ||||
|         // Profanity filter | ||||
|         if (cfg?.profanity_filter_enabled) { | ||||
|             if (filter.check(message.content)) { | ||||
|                 await message.delete().catch(() => null); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|         // XP system: 10 XP per message | ||||
|         await withConn(async (conn) => { | ||||
|             await conn.query('INSERT INTO user_xp (guild_id, user_id, xp) VALUES (?, ?, 10) ON DUPLICATE KEY UPDATE xp = xp + 10', [guildId, message.author.id]); | ||||
|         }); | ||||
|         // Mention AI | ||||
|         if (cfg?.ai_enabled && message.mentions.has(client.user)) { | ||||
|             const question = message.content.replace(/<@!?\d+>/g, '').trim(); | ||||
|             if (!question) | ||||
|                 return; | ||||
|             const answer = await chatAnswerWithMemory(guildId, message.author.id, question).catch(() => 'Sorry, I could not process that right now.'); | ||||
|             await message.reply(answer); | ||||
|         } | ||||
|     }); | ||||
| } | ||||
							
								
								
									
										63
									
								
								dist/index.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								dist/index.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| import { Client, Collection, Events, GatewayIntentBits, PresenceUpdateStatus } from 'discord.js'; | ||||
| import { env, requireEnv } from './config/env.js'; | ||||
| import { migrate } from './db/migrate.js'; | ||||
| import { registerMessageCreate } from './events/messageCreate.js'; | ||||
| import { registerModerationLogs } from './events/guildBanKickLog.js'; | ||||
| import * as setup from './commands/setup.js'; | ||||
| import * as leaderboard from './commands/leaderboard.js'; | ||||
| import * as profile from './commands/profile.js'; | ||||
| import * as resetmemory from './commands/resetmemory.js'; | ||||
| import { banExecute, kickExecute, timeoutExecute } from './commands/moderation.js'; | ||||
| import { startQotdScheduler } from './scheduler/qotd.js'; | ||||
| requireEnv('discordToken'); | ||||
| const client = new Client({ | ||||
|     intents: [ | ||||
|         GatewayIntentBits.Guilds, | ||||
|         GatewayIntentBits.GuildMembers, | ||||
|         GatewayIntentBits.GuildMessages, | ||||
|         GatewayIntentBits.MessageContent | ||||
|     ] | ||||
| }); | ||||
| const commands = new Collection(); | ||||
| commands.set('setup', setup.execute); | ||||
| commands.set('leaderboard', leaderboard.execute); | ||||
| commands.set('profile', profile.execute); | ||||
| commands.set('resetmemory', resetmemory.execute); | ||||
| commands.set('ban', banExecute); | ||||
| commands.set('kick', kickExecute); | ||||
| commands.set('timeout', timeoutExecute); | ||||
| client.once(Events.ClientReady, async (c) => { | ||||
|     await migrate(); | ||||
|     registerMessageCreate(client); | ||||
|     registerModerationLogs(client); | ||||
|     startQotdScheduler(client); | ||||
|     const setPresence = () => { | ||||
|         const count = c.guilds.cache.size; | ||||
|         c.user.setPresence({ | ||||
|             status: PresenceUpdateStatus.Online, | ||||
|             activities: [{ name: `${count} servers`, type: 3 }] | ||||
|         }); | ||||
|     }; | ||||
|     setPresence(); | ||||
|     setInterval(setPresence, 60_000); | ||||
|     console.log(`Logged in as ${c.user.tag}`); | ||||
| }); | ||||
| client.on(Events.InteractionCreate, async (interaction) => { | ||||
|     if (!interaction.isChatInputCommand()) | ||||
|         return; | ||||
|     const fn = commands.get(interaction.commandName); | ||||
|     if (!fn) | ||||
|         return; | ||||
|     try { | ||||
|         await fn(interaction); | ||||
|     } | ||||
|     catch (e) { | ||||
|         const msg = 'Error while executing command.'; | ||||
|         if (interaction.deferred || interaction.replied) | ||||
|             await interaction.followUp({ content: msg, ephemeral: true }).catch(() => null); | ||||
|         else | ||||
|             await interaction.reply({ content: msg, ephemeral: true }).catch(() => null); | ||||
|         console.error(e); | ||||
|     } | ||||
| }); | ||||
| client.login(env.discordToken); | ||||
							
								
								
									
										39
									
								
								dist/scheduler/qotd.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								dist/scheduler/qotd.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { withConn } from '../db/pool.js'; | ||||
| import { generateQOTD } from '../ai/gemini.js'; | ||||
| export function startQotdScheduler(client) { | ||||
|     const run = async () => { | ||||
|         const today = new Date(); | ||||
|         const yyyyMmDd = today.toISOString().slice(0, 10); | ||||
|         const guilds = await withConn(async (conn) => { | ||||
|             return conn.query('SELECT guild_id, qotd_channel_id, qotd_last_sent FROM guild_config WHERE qotd_enabled = 1 AND qotd_channel_id IS NOT NULL'); | ||||
|         }); | ||||
|         for (const row of guilds) { | ||||
|             if (row.qotd_last_sent && row.qotd_last_sent.toISOString().slice(0, 10) === yyyyMmDd) | ||||
|                 continue; | ||||
|             const channel = client.channels.cache.get(row.qotd_channel_id); | ||||
|             if (!channel) | ||||
|                 continue; | ||||
|             try { | ||||
|                 const question = await generateQOTD(); | ||||
|                 await channel.send(`QOTD: ${question}`); | ||||
|                 await withConn((conn) => conn.query('UPDATE guild_config SET qotd_last_sent = ? WHERE guild_id = ?', [yyyyMmDd, row.guild_id])); | ||||
|             } | ||||
|             catch (err) { | ||||
|                 // ignore individual failures | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|     // Run daily at 09:00 UTC | ||||
|     const scheduleNext = () => { | ||||
|         const now = new Date(); | ||||
|         const next = new Date(); | ||||
|         next.setUTCHours(9, 0, 0, 0); | ||||
|         if (next <= now) | ||||
|             next.setUTCDate(next.getUTCDate() + 1); | ||||
|         setTimeout(async () => { | ||||
|             await run(); | ||||
|             scheduleNext(); | ||||
|         }, next.getTime() - now.getTime()); | ||||
|     }; | ||||
|     scheduleNext(); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user