Fix issues
This commit is contained in:
11
.env
Normal file
11
.env
Normal file
@@ -0,0 +1,11 @@
|
||||
DISCORD_TOKEN=MTQwMjE5MTc5ODc3NTk3NTk1Ng.GGIL1u.GnWcnODoLDaHcsWBn1NkMTktST6Fltb6S9nnDQ
|
||||
GEMINI_API_KEY=AIzaSyA7wLJrk49zwmiKq2oSjoH5-mY0R9QMnLw
|
||||
DISCORD_CLIENT_ID=1402191798775975956
|
||||
INCIDENT_WEBHOOK_URL=https://discord.com/api/webhooks/1426851551670042714/Yct5Ws3VYTosAQNpy7gyRL90J9C8XC3rTL-kdSKDKFHlO11x7HoDNVvdbakLICteNe4Z
|
||||
|
||||
DATABASE_HOST=127.0.0.1
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=civita
|
||||
DATABASE_PASSWORD=November10!?DE
|
||||
DATABASE_NAME=civita
|
||||
|
||||
30
.example.env
Normal file
30
.example.env
Normal file
@@ -0,0 +1,30 @@
|
||||
# Discord Configuration
|
||||
DISCORD_TOKEN=DISCORD_TOKEN
|
||||
DISCORD_CLIENT_ID=DISCORD_APPLICATION_ID
|
||||
INCIDENT_WEBHOOK_URL=WEBHOOK_URL
|
||||
|
||||
# Hint: You can get your Discord Token on
|
||||
# https://discord.dev
|
||||
|
||||
|
||||
# Gemini Configuration
|
||||
GEMINI_API_KEY=GEMINI_KEY
|
||||
|
||||
# Hint: You can make a Gemini API key on
|
||||
# https://aistudio.google.com/api-keys
|
||||
|
||||
|
||||
# Database Configs
|
||||
DATABASE_HOST=127.0.0.1
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=DATABASE_USER
|
||||
DATABASE_PASSWORD=DATABASE_PASSWORD
|
||||
DATABASE_NAME=DATABASE_NAME
|
||||
|
||||
# You can use following databases:
|
||||
# -> MariaDB
|
||||
# -> MySQL
|
||||
#
|
||||
# ...but you CANNOT USE other databases like:
|
||||
# -> Postgres
|
||||
# -> SQLite
|
||||
139
.gitignore
vendored
Normal file
139
.gitignore
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
|
||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
## Civita Discord Bot
|
||||
|
||||
Modular Discord.js v14 bot with MariaDB storage, Gemini AI, profanity filter, XP system, and slash commands.
|
||||
|
||||
### Requirements
|
||||
- Node.js 18.17+
|
||||
- pnpm 8+
|
||||
- MariaDB 10.5+
|
||||
|
||||
### Environment
|
||||
Create a `.env` file in the project root with:
|
||||
|
||||
```
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CLIENT_ID=
|
||||
GEMINI_API_KEY=
|
||||
DATABASE_HOST=127.0.0.1
|
||||
DATABASE_PORT=3306
|
||||
DATABASE_USER=root
|
||||
DATABASE_PASSWORD=changeme
|
||||
DATABASE_NAME=civita
|
||||
```
|
||||
|
||||
### Install
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Deploy Commands
|
||||
```bash
|
||||
pnpm deploy
|
||||
```
|
||||
|
||||
### Run (dev)
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Features
|
||||
- `/setup` to enable/disable: moderation, ai, logs, qotd, profanity_filter
|
||||
- `/leaderboard`, `/profile`
|
||||
- `/ban`, `/kick`, `/timeout`
|
||||
- Profanity filter (toggle via `/setup`)
|
||||
- Bot answers when mentioned if AI enabled
|
||||
- Daily QOTD if enabled and channel set
|
||||
- Status updates to: watching (N) servers
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "civita-discord-bot",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.json && node dist/index.js",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"deploy": "tsc -p tsconfig.json && node dist/deploy-commands.js",
|
||||
"lint": "echo 'no linter configured'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.20.0",
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"leo-profanity": "^1.7.0",
|
||||
"mariadb": "^3.3.1",
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.6",
|
||||
"@types/ms": "^0.7.34",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
833
pnpm-lock.yaml
generated
Normal file
833
pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,833 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@google/generative-ai':
|
||||
specifier: ^0.20.0
|
||||
version: 0.20.0
|
||||
discord.js:
|
||||
specifier: ^14.16.3
|
||||
version: 14.22.1
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.6.1
|
||||
leo-profanity:
|
||||
specifier: ^1.7.0
|
||||
version: 1.8.0
|
||||
mariadb:
|
||||
specifier: ^3.3.1
|
||||
version: 3.4.5
|
||||
ms:
|
||||
specifier: ^2.1.3
|
||||
version: 2.1.3
|
||||
devDependencies:
|
||||
'@types/ms':
|
||||
specifier: ^0.7.34
|
||||
version: 0.7.34
|
||||
'@types/node':
|
||||
specifier: ^22.8.6
|
||||
version: 22.18.8
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.18.8)(typescript@5.9.3)
|
||||
ts-node-dev:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(@types/node@22.18.8)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@discordjs/builders@1.11.3':
|
||||
resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@1.5.3':
|
||||
resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/collection@2.1.1':
|
||||
resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/formatters@0.6.1':
|
||||
resolution: {integrity: sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@discordjs/rest@2.6.0':
|
||||
resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/util@1.1.1':
|
||||
resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
|
||||
'@google/generative-ai@0.20.0':
|
||||
resolution: {integrity: sha512-uJQNDr1sihvBJ9w8B0ESpNdX9aEueAMXgwnTuhTo+LnI7DD0M1KHnOWzxb2l6cM1rRHzvkdgJNNfeybcqg7uVg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@sapphire/async-queue@1.5.5':
|
||||
resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
|
||||
engines: {node: '>=v16'}
|
||||
|
||||
'@sapphire/snowflake@3.5.3':
|
||||
resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
'@tsconfig/node10@1.0.11':
|
||||
resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==}
|
||||
|
||||
'@tsconfig/node12@1.0.11':
|
||||
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
|
||||
|
||||
'@tsconfig/node14@1.0.3':
|
||||
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
|
||||
|
||||
'@tsconfig/node16@1.0.4':
|
||||
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/ms@0.7.34':
|
||||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
|
||||
|
||||
'@types/node@22.18.8':
|
||||
resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==}
|
||||
|
||||
'@types/node@24.7.0':
|
||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||
|
||||
'@types/strip-bom@3.0.0':
|
||||
resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==}
|
||||
|
||||
'@types/strip-json-comments@0.0.30':
|
||||
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7':
|
||||
resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==}
|
||||
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
arg@4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
diff@4.0.2:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
discord-api-types@0.38.29:
|
||||
resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
|
||||
|
||||
discord.js@14.22.1:
|
||||
resolution: {integrity: sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dynamic-dedupe@0.3.0:
|
||||
resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
french-badwords-list@1.0.7:
|
||||
resolution: {integrity: sha512-H1ziKs2PJh2+UXZ9oCGJ/rRQpsI9NBykGf2Sc7WaKaj1OnWFuBXfsvANTdRcfVmOghGQaUmRyZ1hJOPbDpy04Q==}
|
||||
|
||||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-extglob@2.1.1:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
leo-profanity@1.8.0:
|
||||
resolution: {integrity: sha512-TATupbZhT3oFCtsiEXzgXcLvW6cDPhDtN+0yQAibjwBdSw3WJFLKM0DZ18eEW8KeFTT10x8gCP+DDZz0cI4Bfg==}
|
||||
|
||||
lodash.snakecase@4.1.1:
|
||||
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
magic-bytes.js@1.12.1:
|
||||
resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==}
|
||||
|
||||
make-error@1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
|
||||
mariadb@3.4.5:
|
||||
resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
normalize-path@3.0.0:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
path-is-absolute@1.0.1:
|
||||
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
picomatch@2.3.1:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
resolve@1.22.10:
|
||||
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
rimraf@2.7.1:
|
||||
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
russian-bad-words@0.5.0:
|
||||
resolution: {integrity: sha512-euNvEYki6iYYpkNbeudW+lEMMYGEmN7EBwVF8ezlbv0bZoQpVYB7W10cCeUIGV7Ed50sJynLQ0c559q5iI0ejQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
|
||||
source-map@0.6.1:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tree-kill@1.2.2:
|
||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||
hasBin: true
|
||||
|
||||
ts-mixer@6.0.4:
|
||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||
|
||||
ts-node-dev@2.0.0:
|
||||
resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
node-notifier: '*'
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
node-notifier:
|
||||
optional: true
|
||||
|
||||
ts-node@10.9.2:
|
||||
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@swc/core': '>=1.2.50'
|
||||
'@swc/wasm': '>=1.2.50'
|
||||
'@types/node': '*'
|
||||
typescript: '>=2.7'
|
||||
peerDependenciesMeta:
|
||||
'@swc/core':
|
||||
optional: true
|
||||
'@swc/wasm':
|
||||
optional: true
|
||||
|
||||
tsconfig@7.0.0:
|
||||
resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
undici-types@7.14.0:
|
||||
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
|
||||
|
||||
undici@6.21.3:
|
||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
yn@3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@discordjs/builders@1.11.3':
|
||||
dependencies:
|
||||
'@discordjs/formatters': 0.6.1
|
||||
'@discordjs/util': 1.1.1
|
||||
'@sapphire/shapeshift': 4.0.0
|
||||
discord-api-types: 0.38.29
|
||||
fast-deep-equal: 3.1.3
|
||||
ts-mixer: 6.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
'@discordjs/collection@1.5.3': {}
|
||||
|
||||
'@discordjs/collection@2.1.1': {}
|
||||
|
||||
'@discordjs/formatters@0.6.1':
|
||||
dependencies:
|
||||
discord-api-types: 0.38.29
|
||||
|
||||
'@discordjs/rest@2.6.0':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/util': 1.1.1
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@sapphire/snowflake': 3.5.3
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.29
|
||||
magic-bytes.js: 1.12.1
|
||||
tslib: 2.8.1
|
||||
undici: 6.21.3
|
||||
|
||||
'@discordjs/util@1.1.1': {}
|
||||
|
||||
'@discordjs/ws@1.2.3':
|
||||
dependencies:
|
||||
'@discordjs/collection': 2.1.1
|
||||
'@discordjs/rest': 2.6.0
|
||||
'@discordjs/util': 1.1.1
|
||||
'@sapphire/async-queue': 1.5.5
|
||||
'@types/ws': 8.18.1
|
||||
'@vladfrangu/async_event_emitter': 2.4.7
|
||||
discord-api-types: 0.38.29
|
||||
tslib: 2.8.1
|
||||
ws: 8.18.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@google/generative-ai@0.20.0': {}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@sapphire/async-queue@1.5.5': {}
|
||||
|
||||
'@sapphire/shapeshift@4.0.0':
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash: 4.17.21
|
||||
|
||||
'@sapphire/snowflake@3.5.3': {}
|
||||
|
||||
'@tsconfig/node10@1.0.11': {}
|
||||
|
||||
'@tsconfig/node12@1.0.11': {}
|
||||
|
||||
'@tsconfig/node14@1.0.3': {}
|
||||
|
||||
'@tsconfig/node16@1.0.4': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/ms@0.7.34': {}
|
||||
|
||||
'@types/node@22.18.8':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.7.0':
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
|
||||
'@types/strip-bom@3.0.0': {}
|
||||
|
||||
'@types/strip-json-comments@0.0.30': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.18.8
|
||||
|
||||
'@vladfrangu/async_event_emitter@2.4.7': {}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
diff@4.0.2: {}
|
||||
|
||||
discord-api-types@0.38.29: {}
|
||||
|
||||
discord.js@14.22.1:
|
||||
dependencies:
|
||||
'@discordjs/builders': 1.11.3
|
||||
'@discordjs/collection': 1.5.3
|
||||
'@discordjs/formatters': 0.6.1
|
||||
'@discordjs/rest': 2.6.0
|
||||
'@discordjs/util': 1.1.1
|
||||
'@discordjs/ws': 1.2.3
|
||||
'@sapphire/snowflake': 3.5.3
|
||||
discord-api-types: 0.38.29
|
||||
fast-deep-equal: 3.1.3
|
||||
lodash.snakecase: 4.1.1
|
||||
magic-bytes.js: 1.12.1
|
||||
tslib: 2.8.1
|
||||
undici: 6.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
dynamic-dedupe@0.3.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
french-badwords-list@1.0.7:
|
||||
optional: true
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@7.2.3:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.2
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
inflight@1.0.6:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
wrappy: 1.0.2
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
leo-profanity@1.8.0:
|
||||
optionalDependencies:
|
||||
french-badwords-list: 1.0.7
|
||||
russian-bad-words: 0.5.0
|
||||
|
||||
lodash.snakecase@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
magic-bytes.js@1.12.1: {}
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
mariadb@3.4.5:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
'@types/node': 24.7.0
|
||||
denque: 2.1.0
|
||||
iconv-lite: 0.6.3
|
||||
lru-cache: 10.4.3
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
resolve@1.22.10:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
rimraf@2.7.1:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
russian-bad-words@0.5.0:
|
||||
optional: true
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
|
||||
source-map@0.6.1: {}
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
tree-kill@1.2.2: {}
|
||||
|
||||
ts-mixer@6.0.4: {}
|
||||
|
||||
ts-node-dev@2.0.0(@types/node@22.18.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
chokidar: 3.6.0
|
||||
dynamic-dedupe: 0.3.0
|
||||
minimist: 1.2.8
|
||||
mkdirp: 1.0.4
|
||||
resolve: 1.22.10
|
||||
rimraf: 2.7.1
|
||||
source-map-support: 0.5.21
|
||||
tree-kill: 1.2.2
|
||||
ts-node: 10.9.2(@types/node@22.18.8)(typescript@5.9.3)
|
||||
tsconfig: 7.0.0
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- '@swc/wasm'
|
||||
- '@types/node'
|
||||
|
||||
ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.11
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 22.18.8
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
arg: 4.1.3
|
||||
create-require: 1.1.1
|
||||
diff: 4.0.2
|
||||
make-error: 1.3.6
|
||||
typescript: 5.9.3
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
|
||||
tsconfig@7.0.0:
|
||||
dependencies:
|
||||
'@types/strip-bom': 3.0.0
|
||||
'@types/strip-json-comments': 0.0.30
|
||||
strip-bom: 3.0.0
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@7.14.0: {}
|
||||
|
||||
undici@6.21.3: {}
|
||||
|
||||
v8-compile-cache-lib@3.0.1: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yn@3.1.1: {}
|
||||
130
src/ai/gemini.ts
Normal file
130
src/ai/gemini.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { env } from '../config/env.js';
|
||||
import { withConn } from '../db/pool.js';
|
||||
import filter from 'leo-profanity';
|
||||
|
||||
filter.loadDictionary('en');
|
||||
|
||||
let client: GoogleGenerativeAI | null = null;
|
||||
|
||||
export function getGemini() {
|
||||
if (!client) {
|
||||
client = new GoogleGenerativeAI(env.geminiApiKey);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async function sendIncidentReport(userId: string, guildId: string, question: string, answer: string) {
|
||||
if (!env.incidentWebhookUrl) return;
|
||||
await fetch(env.incidentWebhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: 'Civita Incident Reporter',
|
||||
embeds: [
|
||||
{
|
||||
title: 'AI Profanity Incident',
|
||||
color: 0xff0000,
|
||||
fields: [
|
||||
{ name: 'Guild ID', value: guildId, inline: true },
|
||||
{ name: 'User ID', value: userId, inline: true },
|
||||
{ name: 'User Question', value: question.slice(0, 1024) || 'N/A' },
|
||||
{ name: 'AI Attempted Answer', value: answer.slice(0, 1024) || 'N/A' },
|
||||
],
|
||||
footer: { text: 'If you think this is a bug, open an issue on https://git.optimihost.com/NaChlorid/Civita' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
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}`);
|
||||
const answer = res.response.text().trim();
|
||||
|
||||
if (filter.check(answer)) {
|
||||
await sendIncidentReport('unknown', 'unknown', question, answer);
|
||||
return "Message blocked due to inappropriate content. The incident was reported, You think that's an issue? Report it on https://git.optimihost.com/NaChlorid/Civita ";
|
||||
}
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
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 and family-friendly.' },
|
||||
...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();
|
||||
|
||||
if (filter.check(answer)) {
|
||||
await sendIncidentReport(userId, guildId, question, answer);
|
||||
return "Message blocked due to inappropriate content. The incident was reported, You think that's an issue? Report it to https://git.optimihost.com/NaChlorid/Civita";
|
||||
}
|
||||
|
||||
const next: MemoryMessage[] = [
|
||||
...history,
|
||||
{ role: 'user', content: question },
|
||||
{ role: 'assistant', content: answer },
|
||||
];
|
||||
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: [] } });
|
||||
}
|
||||
|
||||
|
||||
67
src/commands/moderation.ts
Normal file
67
src/commands/moderation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ChatInputCommandInteraction, 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: ChatInputCommandInteraction) {
|
||||
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: ChatInputCommandInteraction) {
|
||||
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: ChatInputCommandInteraction) {
|
||||
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: [] } });
|
||||
}
|
||||
|
||||
|
||||
40
src/commands/profile.ts
Normal file
40
src/commands/profile.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChatInputCommandInteraction, SlashCommandBuilder, User, 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: ChatInputCommandInteraction) {
|
||||
if (!interaction.guildId) return interaction.reply({ content: 'Guild only command.', ephemeral: true });
|
||||
const target = (interaction.options.getUser('user') || interaction.user) as 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', [interaction.guildId, target.id]);
|
||||
const xp = (rows[0]?.xp as number | undefined) || 0;
|
||||
const rankRows = await conn.query('SELECT COUNT(*) AS ahead FROM user_xp WHERE guild_id = ? AND xp > ?', [interaction.guildId, xp]);
|
||||
const rank = (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';
|
||||
|
||||
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user