Initial commit
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# Discord Configuration
|
||||
DISCORD_TOKEN=DISCORD-TOKEN-HERE
|
||||
DISCORD_CLIENT_ID=DISCORD-CLIENT-ID
|
||||
INCIDENT_WEBHOOK_URL=INCIDENT-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=DB-USER
|
||||
DATABASE_PASSWORD=DB-PASSWORD
|
||||
DATABASE_NAME=DB-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.*
|
||||
|
||||
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
12
.idea/Civita.iml
generated
Normal file
12
.idea/Civita.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Civita.iml" filepath="$PROJECT_DIR$/.idea/Civita.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
LICENSE
Normal file
9
LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
Copyright 2025 OptimiDev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
That's all
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<img alt="Civita Banner" src="https://github.com/user-attachments/assets/86e0fc72-9d74-4e21-a911-cd5d2a556f90" />
|
||||
|
||||
Civita is a **Discord moderation and AI-powered bot** built to make communities safe, fun, and engaging.
|
||||
With smart **profanity filters**, an **AI assistant**, and an automated **Question of the Day (QOTD)** system powered by AI, Civita combines powerful moderation with next-gen AI interaction.
|
||||
|
||||
---
|
||||
[](https://forthebadge.com) [](https://forthebadge.com)
|
||||
--
|
||||
## What civita means?
|
||||
On a good day Civita means “city,” “state,” or “citizenship.” from Latin, on
|
||||
a bad day, it means Idiotic Truckload of Shit
|
||||
|
||||
## ✨ Features
|
||||
- 🔧 **Moderation Tools**
|
||||
- Kick, ban, mute, warn
|
||||
- Automated profanity filtering
|
||||
- Reaction-based reporting
|
||||
- 🤖 **AI Assistant**
|
||||
- Chat with Civita using AI
|
||||
- Smart responses and server-friendly interaction
|
||||
- 📝 **AI-Powered QOTD**
|
||||
- Automatically generates interesting questions
|
||||
- Keeps your community engaged daily
|
||||
- 📢 **Community Tools**
|
||||
- Welcome/leave messages
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
- [Node.JS](https://nodejs.org/en)
|
||||
- [PnPm](https://pnpm.io/) (or NPM but not recommended)
|
||||
- A [Discord Bot Token](https://discord.com/developers/applications)
|
||||
- An Gemini API key
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://git.optimihost.com/NaChlorid/Civita.git
|
||||
cd Civita
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run the bot
|
||||
pnpm run start
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
1. Rename `.example.env` to `.env`
|
||||
2. Fill in your bot token, API keys, and preferences
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
https://civita.optimihost.com/docs
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
* Fork the repo
|
||||
* Create a feature branch
|
||||
* Submit a pull request
|
||||
|
||||
Please follow the [Security Policy](SECURITY.md) when reporting vulnerabilities.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for supported versions and reporting guidelines.
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
|
||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
---
|
||||
|
||||
OPEN
|
||||
SOURCE
|
||||
IS LOVE
|
||||
28
SECURITY.md
Normal file
28
SECURITY.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
We actively support the latest version of the bot and provide security fixes for the previous stable release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
If you discover a security vulnerability in the bot, please report it responsibly. We ask that you **do not publicly disclose** the issue until it has been resolved.
|
||||
|
||||
### How to Report
|
||||
Send a detailed report to **[alexalexandramueller@gmx.de]** including:
|
||||
- Steps to reproduce the issue
|
||||
- Expected vs. actual behavior
|
||||
- Any relevant logs or screenshots
|
||||
|
||||
We will respond within **48 hours**.
|
||||
|
||||
## Security Updates
|
||||
Security updates will be published as new releases. Always ensure you are using the latest version to stay protected.
|
||||
|
||||
## Security Best Practices for Bot Users
|
||||
- **Never share your bot token publicly.** If it is compromised, regenerate it immediately.
|
||||
- **Limit permissions** of the bot to only what is necessary.
|
||||
- **Regularly update dependencies** to patch known vulnerabilities.
|
||||
- Monitor your bot's environment for suspicious activity.
|
||||
|
||||
## Acknowledgments
|
||||
We appreciate all security researchers who help keep this bot safe. You will be credited (unless you request anonymity) in the release notes of the security update.
|
||||
|
||||
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();
|
||||
}
|
||||
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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": {
|
||||
"@discordjs/builders": "^1.12.2",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.0",
|
||||
"@google/generative-ai": "^0.20.0",
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"leo-profanity": "^1.7.0",
|
||||
"mariadb": "^3.3.1",
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "^22.8.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
1316
pnpm-lock.yaml
generated
Normal file
1316
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@discordjs/opus'
|
||||
- ffmpeg-static
|
||||
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;
|
||||
}
|
||||
|
||||
58
src/commands/exportmemory.ts
Normal file
58
src/commands/exportmemory.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { SlashCommandBuilder } from 'discord.js';
|
||||
import { loadUserMemory } from '../ai/gemini.js';
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName('exportmemory')
|
||||
.setDescription('Request an export of your AI chat memory. You will receive it via DM when ready.');
|
||||
|
||||
export async function execute(interaction: any) {
|
||||
try {
|
||||
// Reply immediately so Discord doesn’t time out
|
||||
await interaction.reply({
|
||||
content: 'Our data farmers got the request and have started to collect your messages. I will DM you when it\'s ready!',
|
||||
ephemeral: true,
|
||||
});
|
||||
|
||||
// Store user object safely
|
||||
const user = interaction.user;
|
||||
const guildId = interaction.guild.id;
|
||||
const userId = user.id;
|
||||
|
||||
// Do the export asynchronously
|
||||
(async () => {
|
||||
try {
|
||||
let memory = await loadUserMemory(guildId, userId);
|
||||
|
||||
if (!memory || memory.length === 0) {
|
||||
await user.send('You have no AI memory to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit memory size to last 500 messages
|
||||
memory = memory.slice(-500);
|
||||
|
||||
const json = JSON.stringify(memory, null, 2);
|
||||
const fileName = `civita_memory_${userId}.json`;
|
||||
|
||||
await user.send({
|
||||
content: 'Here is your exported AI memory:',
|
||||
files: [{ attachment: Buffer.from(json), name: fileName }],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error exporting memory DM:', err);
|
||||
try {
|
||||
await user.send('❌ Failed to export your memory. Please try again later.');
|
||||
} catch {}
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
console.error('Error handling /exportmemory command:', err);
|
||||
try {
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.editReply('❌ Something went wrong with your memory export request.');
|
||||
} else {
|
||||
await interaction.reply({ content: '❌ Something went wrong.', ephemeral: true });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
25
src/config/env.ts
Normal file
25
src/config/env.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 || '',
|
||||
incidentWebhookUrl: process.env.INCIDE_WEBHOOK_URL || '',
|
||||
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 config(in .env config file): ${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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
src/deploy-commands.ts
Normal file
34
src/deploy-commands.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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';
|
||||
import * as exportmemory from './commands/exportmemory.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(),
|
||||
(exportmemory.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
68
src/index.ts
Normal file
68
src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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';
|
||||
import * as exportmemory from './commands/exportmemory.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);
|
||||
commands.set('exportmemory', exportmemory.execute);
|
||||
|
||||
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