diff --git a/README.md b/README.md index 21b0d6b..73d020a 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,48 @@ -[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://stand-with-ukraine.pp.ua) +## Civita Discord Bot -# Civita +Modular Discord.js v14 bot with MariaDB storage, Gemini AI, profanity filter, XP system, and slash commands. -Civita is a multifunctional and lightweight Discord bot built with **TypeScript**. -It’s designed to be fast, modular, and easy to maintain β€” providing essential utilities, moderation tools, and fun features for your Discord community. +### Requirements +- Node.js 18.17+ +- pnpm 8+ +- MariaDB 10.5+ ---- - -## ✨ Features - -- 🧹 **Moderation** β€” Kick, ban, mute and manage your server with ease. -- πŸŽ‰ **Fun & Games** β€” Engage users with AI, QOTD(questions of the day), and other fun commands. -- βš™οΈ **Customizable** β€” Easily configurable settings via /setup command. -- πŸš€ **Lightweight & Fast** β€” Optimized with TypeScript for performance and maintainability. - ---- - -## 🧰 Tech Stack - -- [TypeScript](https://www.typescriptlang.org/) -- [Node.js](https://nodejs.org/) -- [Discord.js](https://discord.js.org/) -- [Google-GenAI](https://aistudio.google.com/) - ---- - -## πŸ“¦ Installation - -### Prerequisites -- Node.js v18 or later -- Discord Bot Token ([create one here](https://discord.com/developers/applications)) -- PnPm -- Google GenAI Key ([create one here](https://aistudio.google.com/api-keys)) - -### Setup - -```bash -# Clone the repository -git clone https://git.optimihost.com/NaChlorid/civita.git -cd civita - -# Install dependencies -pnpm install - -# Configure environment variables -cp .env.example .env -# Edit .env and add your Discord bot token - -# Build the project -pnpm run build - -# Start the bot -npx dist/index.js -```` - ---- - -## βš™οΈ Configuration - -Your `.env` file should look like this: - -```env -DISCORD_TOKEN=(DISCORD TOKEN) -GEMINI_API_KEY=(GOOGLE GENAI KEY) -DISCORD_CLIENT_ID=(DISCORD APPLICATION ID) +### Environment +Create a `.env` file in the project root with: +``` +DISCORD_TOKEN= +DISCORD_CLIENT_ID= +GEMINI_API_KEY= DATABASE_HOST=127.0.0.1 DATABASE_PORT=3306 -DATABASE_USER=(DATABASE USER) -DATABASE_PASSWORD=(DATABASE PASSWORD) -DATABASE_NAME=(DATABASE NAME) +DATABASE_USER=root +DATABASE_PASSWORD=changeme +DATABASE_NAME=civita ``` -You can also customize command prefixes, logging options, and more depending on your setup. - ---- - -## πŸ§‘β€πŸ’» Development - -To start a development environment: - +### Install ```bash -pnpm run dev +pnpm install ``` ---- - -## 🧱 Project Structure - -``` -civita -β”œβ”€β”€ package.json -β”œβ”€β”€ README.md -β”œβ”€β”€ src -β”‚Β Β  β”œβ”€β”€ ai -β”‚Β Β  β”‚Β Β  └── gemini.ts -β”‚Β Β  β”œβ”€β”€ commands -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ leaderboard.ts -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ moderation.ts -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ profile.ts -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ resetmemory.ts -β”‚Β Β  β”‚Β Β  └── setup.ts -β”‚Β Β  β”œβ”€β”€ config -β”‚Β Β  β”‚Β Β  └── env.ts -β”‚Β Β  β”œβ”€β”€ db -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ migrate.ts -β”‚Β Β  β”‚Β Β  └── pool.ts -β”‚Β Β  β”œβ”€β”€ deploy-commands.ts -β”‚Β Β  β”œβ”€β”€ events -β”‚Β Β  β”‚Β Β  β”œβ”€β”€ guildBanKickLog.ts -β”‚Β Β  β”‚Β Β  └── messageCreate.ts -β”‚Β Β  β”œβ”€β”€ index.ts -β”‚Β Β  β”œβ”€β”€ scheduler -β”‚Β Β  β”‚Β Β  └── qotd.ts -β”‚Β Β  └── types -β”‚Β Β  └── ambient.d.ts -└── tsconfig.json +### Deploy Commands +```bash +pnpm deploy ``` ---- +### Run (dev) +```bash +pnpm dev +``` -## 🧩 Contributing +### Features +- `/setup` to enable/disable: moderation, ai, logs, qotd, profanity_filter +- `/leaderboard`, `/profile` +- `/ban`, `/kick`, `/timeout` +- Profanity filter (toggle via `/setup`) +- Bot answers when mentioned if AI enabled +- Daily QOTD if enabled and channel set +- Status updates to: watching (N) servers -Contributions are welcome! -Feel free to fork this repository and submit a pull request. -Make sure your code follows the existing style and passes all lint checks before submitting. - ---- - -## πŸͺͺ License - -This project is licensed under the **MIT License**. -See the [LICENSE](LICENSE) file for details. - ---- - -## πŸ’¬ Support - -If you encounter issues or have suggestions, please open an issue on GitHub. -You can also reach out via the Discord server (if available). - ---- - -> Civita β€” simple, modular, and made to elevate your Discord experience. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1798cbb --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "civita-discord-bot", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=18.17.0" + }, + "scripts": { + "dev": "tsc -p tsconfig.json && node dist/index.js", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "deploy": "tsc -p tsconfig.json && node dist/deploy-commands.js", + "lint": "echo 'no linter configured'" + }, + "dependencies": { + "@google/generative-ai": "^0.20.0", + "discord.js": "^14.16.3", + "dotenv": "^16.4.5", + "leo-profanity": "^1.7.0", + "mariadb": "^3.3.1", + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/node": "^22.8.6", + "@types/ms": "^0.7.34", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.6.3" + } +} + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c68073f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,833 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@google/generative-ai': + specifier: ^0.20.0 + version: 0.20.0 + discord.js: + specifier: ^14.16.3 + version: 14.22.1 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + leo-profanity: + specifier: ^1.7.0 + version: 1.8.0 + mariadb: + specifier: ^3.3.1 + version: 3.4.5 + ms: + specifier: ^2.1.3 + version: 2.1.3 + devDependencies: + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 + '@types/node': + specifier: ^22.8.6 + version: 22.18.8 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@types/node@22.18.8)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@discordjs/builders@1.11.3': + resolution: {integrity: sha512-p3kf5eV49CJiRTfhtutUCeivSyQ/l2JlKodW1ZquRwwvlOWmG9+6jFShX6x8rUiYhnP6wKI96rgN/SXMy5e5aw==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.1': + resolution: {integrity: sha512-5cnX+tASiPCqCWtFcFslxBVUaCetB0thvM/JyavhbXInP1HJIEU+Qv/zMrnuwSsX3yWH2lVXNJZeDK3EiP4HHg==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.6.0': + resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==} + engines: {node: '>=18'} + + '@discordjs/util@1.1.1': + resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.3': + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} + engines: {node: '>=16.11.0'} + + '@google/generative-ai@0.20.0': + resolution: {integrity: sha512-uJQNDr1sihvBJ9w8B0ESpNdX9aEueAMXgwnTuhTo+LnI7DD0M1KHnOWzxb2l6cM1rRHzvkdgJNNfeybcqg7uVg==} + engines: {node: '>=18.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node@22.18.8': + resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==} + + '@types/node@24.7.0': + resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==} + + '@types/strip-bom@3.0.0': + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + + '@types/strip-json-comments@0.0.30': + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + discord-api-types@0.38.29: + resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==} + + discord.js@14.22.1: + resolution: {integrity: sha512-3k+Kisd/v570Jr68A1kNs7qVhNehDwDJAPe4DZ2Syt+/zobf9zEcuYFvsfIaAOgCa0BiHMfOOKQY4eYINl0z7w==} + engines: {node: '>=18'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + french-badwords-list@1.0.7: + resolution: {integrity: sha512-H1ziKs2PJh2+UXZ9oCGJ/rRQpsI9NBykGf2Sc7WaKaj1OnWFuBXfsvANTdRcfVmOghGQaUmRyZ1hJOPbDpy04Q==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + leo-profanity@1.8.0: + resolution: {integrity: sha512-TATupbZhT3oFCtsiEXzgXcLvW6cDPhDtN+0yQAibjwBdSw3WJFLKM0DZ18eEW8KeFTT10x8gCP+DDZz0cI4Bfg==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-bytes.js@1.12.1: + resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + mariadb@3.4.5: + resolution: {integrity: sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==} + engines: {node: '>= 14'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + russian-bad-words@0.5.0: + resolution: {integrity: sha512-euNvEYki6iYYpkNbeudW+lEMMYGEmN7EBwVF8ezlbv0bZoQpVYB7W10cCeUIGV7Ed50sJynLQ0c559q5iI0ejQ==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + + ts-node-dev@2.0.0: + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici-types@7.14.0: + resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + + undici@6.21.3: + resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} + engines: {node: '>=18.17'} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + +snapshots: + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@discordjs/builders@1.11.3': + dependencies: + '@discordjs/formatters': 0.6.1 + '@discordjs/util': 1.1.1 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.29 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.1': + dependencies: + discord-api-types: 0.38.29 + + '@discordjs/rest@2.6.0': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.3 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.29 + magic-bytes.js: 1.12.1 + tslib: 2.8.1 + undici: 6.21.3 + + '@discordjs/util@1.1.1': {} + + '@discordjs/ws@1.2.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.0 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.29 + tslib: 2.8.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@google/generative-ai@0.20.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.17.21 + + '@sapphire/snowflake@3.5.3': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/geojson@7946.0.16': {} + + '@types/ms@0.7.34': {} + + '@types/node@22.18.8': + dependencies: + undici-types: 6.21.0 + + '@types/node@24.7.0': + dependencies: + undici-types: 7.14.0 + + '@types/strip-bom@3.0.0': {} + + '@types/strip-json-comments@0.0.30': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.18.8 + + '@vladfrangu/async_event_emitter@2.4.7': {} + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-from@1.1.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + concat-map@0.0.1: {} + + create-require@1.1.1: {} + + denque@2.1.0: {} + + diff@4.0.2: {} + + discord-api-types@0.38.29: {} + + discord.js@14.22.1: + dependencies: + '@discordjs/builders': 1.11.3 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.1 + '@discordjs/rest': 2.6.0 + '@discordjs/util': 1.1.1 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.38.29 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + magic-bytes.js: 1.12.1 + tslib: 2.8.1 + undici: 6.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + dotenv@16.6.1: {} + + dynamic-dedupe@0.3.0: + dependencies: + xtend: 4.0.2 + + fast-deep-equal@3.1.3: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + french-badwords-list@1.0.7: + optional: true + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + leo-profanity@1.8.0: + optionalDependencies: + french-badwords-list: 1.0.7 + russian-bad-words: 0.5.0 + + lodash.snakecase@4.1.1: {} + + lodash@4.17.21: {} + + lru-cache@10.4.3: {} + + magic-bytes.js@1.12.1: {} + + make-error@1.3.6: {} + + mariadb@3.4.5: + dependencies: + '@types/geojson': 7946.0.16 + '@types/node': 24.7.0 + denque: 2.1.0 + iconv-lite: 0.6.3 + lru-cache: 10.4.3 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + mkdirp@1.0.4: {} + + ms@2.1.3: {} + + normalize-path@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + path-is-absolute@1.0.1: {} + + path-parse@1.0.7: {} + + picomatch@2.3.1: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + russian-bad-words@0.5.0: + optional: true + + safer-buffer@2.1.2: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + strip-bom@3.0.0: {} + + strip-json-comments@2.0.1: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tree-kill@1.2.2: {} + + ts-mixer@6.0.4: {} + + ts-node-dev@2.0.0(@types/node@22.18.8)(typescript@5.9.3): + dependencies: + chokidar: 3.6.0 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.10 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 10.9.2(@types/node@22.18.8)(typescript@5.9.3) + tsconfig: 7.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + + ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.18.8 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig@7.0.0: + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici-types@7.14.0: {} + + undici@6.21.3: {} + + v8-compile-cache-lib@3.0.1: {} + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + xtend@4.0.2: {} + + yn@3.1.1: {} diff --git a/src/ai/gemini.ts b/src/ai/gemini.ts new file mode 100644 index 0000000..580c844 --- /dev/null +++ b/src/ai/gemini.ts @@ -0,0 +1,76 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { env } from '../config/env.js'; +import { withConn } from '../db/pool.js'; + +let client: GoogleGenerativeAI | null = null; + +export function getGemini() { + if (!client) { + client = new GoogleGenerativeAI(env.geminiApiKey); + } + return client; +} + +export async function generateQOTD(): Promise { + 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 { + const model = getGemini().getGenerativeModel({ model: 'gemini-2.5-flash' }); + const res = await model.generateContent(`Answer concisely and helpfully for a Discord chat:\n${question}`); + return res.response.text().trim(); +} + +type MemoryMessage = { role: 'user' | 'assistant'; content: string }; + +export async function loadUserMemory(guildId: string, userId: string): Promise { + 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 { + 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 { + const history = await loadUserMemory(guildId, userId); + const model = getGemini().getGenerativeModel({ model: 'gemini-2.5-flash' }); + const prompt = [ + { role: 'user', content: 'You are a helpful Discord assistant. Be concise.' }, + ...history, + { role: 'user', content: question } + ]; + const res = await model.generateContent( + prompt.map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`).join('\n') + ); + const answer = res.response.text().trim(); + const next: MemoryMessage[] = [ + ...history, + { role: 'user', content: question } as MemoryMessage, + { role: 'assistant', content: answer } as MemoryMessage + ]; + await saveUserMemory(guildId, userId, next); + return answer; +} + + diff --git a/src/commands/leaderboard.ts b/src/commands/leaderboard.ts new file mode 100644 index 0000000..8c8cb03 --- /dev/null +++ b/src/commands/leaderboard.ts @@ -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: [] } }); +} + + diff --git a/src/commands/moderation.ts b/src/commands/moderation.ts new file mode 100644 index 0000000..4a64eac --- /dev/null +++ b/src/commands/moderation.ts @@ -0,0 +1,76 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder, time, userMention } from 'discord.js'; +import ms from 'ms'; + +function base() { + return new SlashCommandBuilder() + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); +} + +export const banData = base() + .setName('ban') + .setDescription('Ban a user') + .addUserOption(o => o.setName('user').setDescription('User to ban').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); + +export async function banExecute(interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true }); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to use this command.', ephemeral: true }); + } + const user = interaction.options.getUser('user', true); + const reason = interaction.options.getString('reason') || 'No reason provided'; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); + await user.send(`You have been banned from ${interaction.guild.name}. Reason: ${reason}`).catch(() => null); + await member.ban({ reason }); + await interaction.reply({ content: `Banned ${userMention(user.id)}.`, allowedMentions: { parse: [] } }); +} + +export const kickData = base() + .setName('kick') + .setDescription('Kick a user') + .addUserOption(o => o.setName('user').setDescription('User to kick').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); + +export async function kickExecute(interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true }); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to use this command.', ephemeral: true }); + } + const user = interaction.options.getUser('user', true); + const reason = interaction.options.getString('reason') || 'No reason provided'; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); + await user.send(`You have been kicked from ${interaction.guild.name}. Reason: ${reason}`).catch(() => null); + await member.kick(reason); + await interaction.reply({ content: `Kicked ${userMention(user.id)}.`, allowedMentions: { parse: [] } }); +} + +export const timeoutData = base() + .setName('timeout') + .setDescription('Timeout a user') + .addUserOption(o => o.setName('user').setDescription('User to timeout').setRequired(true)) + .addStringOption(o => o.setName('duration').setDescription('Duration (e.g. 10m, 1h)').setRequired(true)) + .addStringOption(o => o.setName('reason').setDescription('Reason').setRequired(false)); + +export async function timeoutExecute(interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.reply({ content: 'Guild only.', ephemeral: true }); + if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { + return interaction.reply({ content: 'You need Administrator permission to use this command.', ephemeral: true }); + } + const user = interaction.options.getUser('user', true); + const duration = interaction.options.getString('duration', true); + const msDur = ms(duration); + if (!msDur || msDur < 1000 || msDur > 28 * 24 * 60 * 60 * 1000) { + return interaction.reply({ content: 'Invalid duration. Use between 1s and 28d.', ephemeral: true }); + } + const reason = interaction.options.getString('reason') || 'No reason provided'; + const member = await interaction.guild.members.fetch(user.id).catch(() => null); + if (!member) return interaction.reply({ content: 'User not found in guild.', ephemeral: true }); + const until = new Date(Date.now() + msDur); + await user.send(`You have been timed out in ${interaction.guild.name} until ${time(until, 'F')} UTC. Reason: ${reason}`).catch(() => null); + await member.timeout(msDur, reason); + await interaction.reply({ content: `Timed out ${userMention(user.id)} until ${time(until, 'F')} UTC.`, allowedMentions: { parse: [] } }); +} + + diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..6b7c88a --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,67 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { withConn } from '../db/pool.js'; + +export const data = new SlashCommandBuilder() + .setName('profile') + .setDescription('Show XP profile') + .addUserOption(o => + o.setName('user').setDescription('User to view') + ); + +export async function execute(interaction) { + if (!interaction.guildId) + return interaction.reply({ + content: 'Guild only command.', + ephemeral: true, + }); + + const target = interaction.options.getUser('user') || interaction.user; + + const data = await withConn(async conn => { + const rows = await conn.query( + 'SELECT xp FROM user_xp WHERE guild_id = ? AND user_id = ? LIMIT 1', + [BigInt(interaction.guildId), BigInt(target.id)] + ); + + const xp = Number(rows[0]?.xp ?? 0); + + const rankRows = await conn.query( + 'SELECT COUNT(*) AS ahead FROM user_xp WHERE guild_id = ? AND xp > ?', + [BigInt(interaction.guildId), xp] + ); + + const rank = Number(rankRows[0]?.ahead ?? 0) + 1; + + return { xp, rank }; + }); + + const XP_PER_LEVEL = 500; // match visual example + const level = Math.floor(data.xp / XP_PER_LEVEL); + const currentLevelXp = data.xp % XP_PER_LEVEL; + const progress = Math.min( + 100, + Math.round((currentLevelXp / XP_PER_LEVEL) * 1000) / 10 + ); + + const embed = new EmbedBuilder() + .setAuthor({ + name: `${target.username}'s Profile`, + iconURL: target.displayAvatarURL(), + }) + .setColor(0x5865f2) + .setDescription(`Progress to next level: **${progress}%**`) + .addFields( + { name: 'πŸ… Level', value: `${level}`, inline: true }, + { name: 'πŸ’Ž XP', value: `${currentLevelXp} / ${XP_PER_LEVEL}`, inline: true }, + { name: 'πŸ† Rank', value: `#${data.rank}`, inline: true } + ) + .setFooter({ + text: `Server: ${interaction.guild?.name || 'Unknown'}`, + }) + .setTimestamp(); + + await interaction.reply({ + embeds: [embed], + allowedMentions: { parse: [] }, + }); +} diff --git a/src/commands/resetmemory.ts b/src/commands/resetmemory.ts new file mode 100644 index 0000000..d71c0e1 --- /dev/null +++ b/src/commands/resetmemory.ts @@ -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 }); +} + + diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..b98c62d --- /dev/null +++ b/src/commands/setup.ts @@ -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 }); + } + }); +} + + diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..d7636b0 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,24 @@ +import dotenv from 'dotenv'; +dotenv.config(); + +export const env = { + discordToken: process.env.DISCORD_TOKEN || '', + discordClientId: process.env.DISCORD_CLIENT_ID || '', + geminiApiKey: process.env.GEMINI_API_KEY || '', + db: { + host: process.env.DATABASE_HOST || '127.0.0.1', + port: Number(process.env.DATABASE_PORT || 3306), + user: process.env.DATABASE_USER || 'root', + password: process.env.DATABASE_PASSWORD || '', + database: process.env.DATABASE_NAME || 'civita' + } +}; + +export function requireEnv(name: keyof typeof env | string): void { + const val = (env as any)[name]; + if (typeof val === 'string' && !val) { + throw new Error(`Missing required env: ${name}`); + } +} + + diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..d84e1d7 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,44 @@ +import { withConn } from './pool.js'; + +export async function migrate(): Promise { + 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) + ); + `); + }); +} + + diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..8c04e2e --- /dev/null +++ b/src/db/pool.ts @@ -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(fn: (conn: mariadb.PoolConnection) => Promise): Promise { + const conn = await pool.getConnection(); + try { + return await fn(conn); + } finally { + conn.end(); + } +} + + diff --git a/src/deploy-commands.ts b/src/deploy-commands.ts new file mode 100644 index 0000000..4fb8a87 --- /dev/null +++ b/src/deploy-commands.ts @@ -0,0 +1,32 @@ +import { REST, Routes, SlashCommandBuilder } from 'discord.js'; +import { env } from './config/env.js'; +import * as setup from './commands/setup.js'; +import * as leaderboard from './commands/leaderboard.js'; +import * as profile from './commands/profile.js'; +import * as resetmemory from './commands/resetmemory.js'; +import { banData, kickData, timeoutData } from './commands/moderation.js'; + +async function main() { + const commands = [ + (setup.data as SlashCommandBuilder).toJSON(), + (leaderboard.data as SlashCommandBuilder).toJSON(), + (profile.data as SlashCommandBuilder).toJSON(), + (resetmemory.data as SlashCommandBuilder).toJSON(), + banData.toJSON(), + kickData.toJSON(), + timeoutData.toJSON() + ]; + + const rest = new REST({ version: '10' }).setToken(env.discordToken); + + await rest.put(Routes.applicationCommands(env.discordClientId), { body: commands }); + // You can replace with Routes.applicationGuildCommands for quicker iteration per guild + console.log('Commands deployed.'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); + + diff --git a/src/events/guildBanKickLog.ts b/src/events/guildBanKickLog.ts new file mode 100644 index 0000000..84018b5 --- /dev/null +++ b/src/events/guildBanKickLog.ts @@ -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 { + 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}>`); + }); +} + + diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..f0cf9c4 --- /dev/null +++ b/src/events/messageCreate.ts @@ -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); + } + }); +} + + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ac53975 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,66 @@ +import { Client, Collection, Events, GatewayIntentBits, PresenceUpdateStatus } from 'discord.js'; +import { env, requireEnv } from './config/env.js'; +import { migrate } from './db/migrate.js'; +import { registerMessageCreate } from './events/messageCreate.js'; +import { registerModerationLogs } from './events/guildBanKickLog.js'; +import * as setup from './commands/setup.js'; +import * as leaderboard from './commands/leaderboard.js'; +import * as profile from './commands/profile.js'; +import * as resetmemory from './commands/resetmemory.js'; +import { banExecute, kickExecute, timeoutExecute } from './commands/moderation.js'; +import { startQotdScheduler } from './scheduler/qotd.js'; + +requireEnv('discordToken'); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent + ] +}); + +const commands = new Collection Promise>(); +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); + + diff --git a/src/scheduler/qotd.ts b/src/scheduler/qotd.ts new file mode 100644 index 0000000..286ed14 --- /dev/null +++ b/src/scheduler/qotd.ts @@ -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(); +} + + diff --git a/src/types/ambient.d.ts b/src/types/ambient.d.ts new file mode 100644 index 0000000..cae1661 --- /dev/null +++ b/src/types/ambient.d.ts @@ -0,0 +1,3 @@ +declare module 'leo-profanity'; + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..577468d --- /dev/null +++ b/tsconfig.json @@ -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"] +} + +