Add the source code
This commit is contained in:
76
src/ai/gemini.ts
Normal file
76
src/ai/gemini.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { env } from '../config/env.js';
|
||||
import { withConn } from '../db/pool.js';
|
||||
|
||||
let client: GoogleGenerativeAI | null = null;
|
||||
|
||||
export function getGemini() {
|
||||
if (!client) {
|
||||
client = new GoogleGenerativeAI(env.geminiApiKey);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function generateQOTD(): Promise<string> {
|
||||
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: string): Promise<string> {
|
||||
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();
|
||||
}
|
||||
|
||||
type MemoryMessage = { role: 'user' | 'assistant'; content: string };
|
||||
|
||||
export async function loadUserMemory(guildId: string, userId: string): Promise<MemoryMessage[]> {
|
||||
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 as any;
|
||||
const updatedAt = rows[0]?.updated_at as Date | undefined;
|
||||
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 as MemoryMessage[] : JSON.parse(raw) as MemoryMessage[]; } catch { return []; }
|
||||
}
|
||||
|
||||
export async function saveUserMemory(guildId: string, userId: string, history: MemoryMessage[]): Promise<void> {
|
||||
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: string,
|
||||
userId: string,
|
||||
question: string
|
||||
): Promise<string> {
|
||||
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: MemoryMessage[] = [
|
||||
...history,
|
||||
{ role: 'user', content: question } as MemoryMessage,
|
||||
{ role: 'assistant', content: answer } as MemoryMessage
|
||||
];
|
||||
await saveUserMemory(guildId, userId, next);
|
||||
return answer;
|
||||
}
|
||||
|
||||
|
||||
38
src/commands/leaderboard.ts
Normal file
38
src/commands/leaderboard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ChatInputCommandInteraction, 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: ChatInputCommandInteraction) {
|
||||
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 as { user_id: string, xp: number }[];
|
||||
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: [] } });
|
||||
}
|
||||
|
||||
|
||||
76
src/commands/moderation.ts
Normal file
76
src/commands/moderation.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder, time, userMention } from 'discord.js';
|
||||
import ms from 'ms';
|
||||
|
||||
function base() {
|
||||
return new SlashCommandBuilder()
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator);
|
||||
}
|
||||
|
||||
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: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true });
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
return interaction.reply({ content: 'You need Administrator permission to use this command.', 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: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true });
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
return interaction.reply({ content: 'You need Administrator permission to use this command.', 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: ChatInputCommandInteraction) {
|
||||
if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true });
|
||||
if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) {
|
||||
return interaction.reply({ content: 'You need Administrator permission to use this command.', 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: [] } });
|
||||
}
|
||||
|
||||
|
||||
67
src/commands/profile.ts
Normal file
67
src/commands/profile.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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: [] },
|
||||
});
|
||||
}
|
||||
19
src/commands/resetmemory.ts
Normal file
19
src/commands/resetmemory.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ChatInputCommandInteraction, 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: ChatInputCommandInteraction) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
81
src/commands/setup.ts
Normal file
81
src/commands/setup.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ChatInputCommandInteraction, 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: ChatInputCommandInteraction) {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
24
src/config/env.ts
Normal file
24
src/config/env.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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: keyof typeof env | string): void {
|
||||
const val = (env as any)[name];
|
||||
if (typeof val === 'string' && !val) {
|
||||
throw new Error(`Missing required env: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
44
src/db/migrate.ts
Normal file
44
src/db/migrate.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { withConn } from './pool.js';
|
||||
|
||||
export async function migrate(): Promise<void> {
|
||||
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)
|
||||
);
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
23
src/db/pool.ts
Normal file
23
src/db/pool.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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<T>(fn: (conn: mariadb.PoolConnection) => Promise<T>): Promise<T> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
return await fn(conn);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
src/deploy-commands.ts
Normal file
32
src/deploy-commands.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { REST, Routes, SlashCommandBuilder } 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 as SlashCommandBuilder).toJSON(),
|
||||
(leaderboard.data as SlashCommandBuilder).toJSON(),
|
||||
(profile.data as SlashCommandBuilder).toJSON(),
|
||||
(resetmemory.data as SlashCommandBuilder).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
src/events/guildBanKickLog.ts
Normal file
31
src/events/guildBanKickLog.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Client, Events, GuildBan, GuildMember, PartialGuildMember } from 'discord.js';
|
||||
import { withConn } from '../db/pool.js';
|
||||
|
||||
async function getLogsChannelId(guildId: string): Promise<string | null> {
|
||||
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] as { logs_enabled?: number, logs_channel_id?: string } | undefined;
|
||||
});
|
||||
if (!row?.logs_enabled || !row.logs_channel_id) return null;
|
||||
return row.logs_channel_id;
|
||||
}
|
||||
|
||||
export function registerModerationLogs(client: Client) {
|
||||
client.on(Events.GuildBanAdd, async (ban: GuildBan) => {
|
||||
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 as any).send(`User banned: <@${ban.user.id}>`);
|
||||
});
|
||||
|
||||
client.on(Events.GuildMemberRemove, async (member: GuildMember | PartialGuildMember) => {
|
||||
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 as any).send(`Member left or was kicked: <@${member.id}>`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
41
src/events/messageCreate.ts
Normal file
41
src/events/messageCreate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Client, Events, Message } 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) {
|
||||
client.on(Events.MessageCreate, async (message: 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] as { ai_enabled?: number, profanity_filter_enabled?: number } | undefined;
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
66
src/index.ts
Normal file
66
src/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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<string, (i: any) => Promise<unknown>>();
|
||||
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);
|
||||
|
||||
|
||||
45
src/scheduler/qotd.ts
Normal file
45
src/scheduler/qotd.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Client, TextChannel } from 'discord.js';
|
||||
import { withConn } from '../db/pool.js';
|
||||
import { generateQOTD } from '../ai/gemini.js';
|
||||
|
||||
export function startQotdScheduler(client: Client): void {
|
||||
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 as any[]) {
|
||||
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) as TextChannel | undefined;
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
3
src/types/ambient.d.ts
vendored
Normal file
3
src/types/ambient.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'leo-profanity';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user