Initial commit

This commit is contained in:
OptimiDev
2025-10-20 16:30:50 +02:00
commit c2e9af0a8a
43 changed files with 3035 additions and 0 deletions

30
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.
---
[![forthebadge](https://forthebadge.com/images/featured/featured-gluten-free.svg)](https://forthebadge.com) [![forthebadge](https://forthebadge.com/images/featured/featured-powered-by-electricity.svg)](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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- '@discordjs/opus'
- ffmpeg-static

130
src/ai/gemini.ts Normal file
View 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;
}

View 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 doesnt 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 {}
}
}

View 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: [] } });
}

View 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
View 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: [] } });
}

View 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
View 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
View 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
View 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
View 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
View 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);
});

View 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}>`);
});
}

View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
declare module 'leo-profanity';

18
tsconfig.json Normal file
View 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"]
}