From d74aebfda7aa71659aeaa2436f5ec212f9925b19 Mon Sep 17 00:00:00 2001 From: Chris Ham <431647+greenham@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:02:27 -0700 Subject: [PATCH] Add SQLite database for dynamic guild management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - SQLite database with better-sqlite3 for guild configurations - Auto-registration when bot joins new guilds with welcome messages - Soft delete system preserves settings when bot is removed - Dynamic configuration via /config slash command with subcommands - Automatic migration from config.json to database on first run - Support for scheduled events with timezone preservation Technical Implementation: - Node.js 20 for better SQLite compatibility in Docker - Full Debian base image with npm for reliable native module compilation - Database persistence via Docker volume (./data) - Hybrid configuration system (database primary, file fallback) - JSON storage for complex schedule objects with timezone support Database Schema: - guilds table with soft delete (is_active flag) - scheduled_events table with JSON schedule storage - bot_config table for global settings - Auto-initialization and seeding from existing config Admin Features: - /config show - View current server settings - /config subcommands - Update prefix, volume, features, etc. - Administrator permissions required for configuration changes - Graceful handling of missing or malformed data 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .gitignore | 1 + .nvmrc | 2 +- Dockerfile | 29 +- README.md | 3 +- docker-compose.yml | 3 + package.json | 20 +- pnpm-lock.yaml | 206 ++++++++++++- src/commands/slash/config.js | 182 ++++++++++++ src/config/config.js | 125 +++++++- src/index.js | 151 +++++++++- src/services/databaseService.js | 492 +++++++++++++++++++++++++++++++ src/services/schedulerService.js | 18 +- 12 files changed, 1157 insertions(+), 75 deletions(-) create mode 100644 src/commands/slash/config.js create mode 100644 src/services/databaseService.js diff --git a/.gitignore b/.gitignore index 79244e8..77f162f 100755 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ Temporary Items # Ignores node_modules logs +data/ start.bat tokens.json diff --git a/.nvmrc b/.nvmrc index 8fdd954..2edeafb 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 \ No newline at end of file +20 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e574f19..e0e0b04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,13 @@ -# Build stage -FROM node:22-alpine AS builder +# Use Node 20 LTS with full Debian for better compatibility +FROM node:20 WORKDIR /app -# Copy package files -COPY package*.json pnpm-lock.yaml* ./ +# Copy package files (npm will work better for native modules in Docker) +COPY package*.json ./ -# Install latest pnpm and build dependencies (including ffmpeg for audio processing) -RUN npm install -g pnpm@latest && \ - apk add --no-cache python3 make g++ ffmpeg opus-dev - -# Install dependencies (including native modules that need compilation) -RUN pnpm install --force - -# Production stage -FROM node:22-alpine - -WORKDIR /app - -# Install latest pnpm and runtime dependencies -RUN npm install -g pnpm@latest && \ - apk add --no-cache ffmpeg opus - -# Copy package files and installed dependencies from builder -COPY package*.json pnpm-lock.yaml* ./ -COPY --from=builder /app/node_modules ./node_modules +# Install dependencies using npm (no security restrictions like pnpm) +RUN npm install --production # Copy application code COPY . . diff --git a/README.md b/README.md index 5f66424..88332b5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ pnpm build && pnpm up ``` **Benefits of Docker Compose:** + - Update `config.json`, `sfx/`, and `conf/` files without rebuilding the image - Automatic restart on failure - Easy log management @@ -137,7 +138,7 @@ pnpm image:run ``` !funfact # Random fun fact !funfact 42 # Specific fun fact #42 -!hamfact # Random ham radio fact +!hamfact # Random ham fact !dance # ASCII dance animation ``` diff --git a/docker-compose.yml b/docker-compose.yml index d515134..e60db88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: # Sound effects directory (read-only) - ./sfx:/app/sfx:ro + # Database persistence + - ./data:/app/data + # Optional: Mount logs directory if you want persistent logs # - ./logs:/app/logs diff --git a/package.json b/package.json index 843058d..59ea806 100644 --- a/package.json +++ b/package.json @@ -4,27 +4,29 @@ "description": "", "main": "src/index.js", "dependencies": { - "axios": "^1.11.0", - "discord.js": "^14.21.0", - "@discordjs/voice": "^0.18.0", "@discordjs/opus": "^0.9.0", - "opusscript": "^0.1.1", + "@discordjs/voice": "^0.18.0", + "axios": "^1.11.0", + "better-sqlite3": "^11.10.0", + "discord.js": "^14.21.0", "ffmpeg-static": "^5.2.0", "node-schedule": "^2.1.1", + "opusscript": "^0.1.1", "sodium-native": "^4.3.3" }, "devDependencies": { - "@types/node": "^22.0.0", - "nodemon": "^3.1.9" + "@types/node": "^22.17.2", + "nodemon": "^3.1.10" }, "scripts": { - "start": "node src/index.js", "dev": "nodemon src/index.js", - "up": "docker compose up -d", - "down": "docker compose down", + "start": "docker compose up -d", + "start:logs": "pnpm start && pnpm logs", + "stop": "docker compose down", "build": "docker compose build", "restart": "docker compose restart", "logs": "docker compose logs -f", + "boom": "pnpm stop && pnpm build && pnpm start", "image:build": "docker build -t ghbot:${VERSION:-latest} .", "image:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6402770..cdb6449 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,13 @@ dependencies: version: 0.9.0 '@discordjs/voice': specifier: ^0.18.0 - version: 0.18.0(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0) + version: 0.18.0(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0)(opusscript@0.1.1) axios: specifier: ^1.11.0 version: 1.11.0 + better-sqlite3: + specifier: ^11.10.0 + version: 11.10.0 discord.js: specifier: ^14.21.0 version: 14.21.0 @@ -23,16 +26,19 @@ dependencies: node-schedule: specifier: ^2.1.1 version: 2.1.1 + opusscript: + specifier: ^0.1.1 + version: 0.1.1 sodium-native: specifier: ^4.3.3 version: 4.3.3 devDependencies: '@types/node': - specifier: ^22.0.0 + specifier: ^22.17.2 version: 22.17.2 nodemon: - specifier: ^3.1.9 + specifier: ^3.1.10 version: 3.1.10 packages: @@ -127,13 +133,13 @@ packages: engines: {node: '>=18'} dev: false - /@discordjs/voice@0.18.0(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0): + /@discordjs/voice@0.18.0(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0)(opusscript@0.1.1): resolution: {integrity: sha512-BvX6+VJE5/vhD9azV9vrZEt9hL1G+GlOdsQaVl5iv9n87fkXjf3cSwllhR3GdaUC8m6dqT8umXIWtn3yCu4afg==} engines: {node: '>=18'} dependencies: '@types/ws': 8.18.1 discord-api-types: 0.37.120 - prism-media: 1.3.5(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0) + prism-media: 1.3.5(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0)(opusscript@0.1.1) tslib: 2.8.1 ws: 8.18.3 transitivePeerDependencies: @@ -303,11 +309,37 @@ packages: bare-path: 3.0.0 dev: false + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: false + + /better-sqlite3@11.10.0: + resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + requiresBuild: true + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + dev: false + /binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} dev: true + /bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + dependencies: + file-uri-to-path: 1.0.0 + dev: false + + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: false + /brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} dependencies: @@ -325,6 +357,13 @@ packages: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: false + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + /call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -352,6 +391,10 @@ packages: fsevents: 2.3.3 dev: true + /chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + dev: false + /chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -405,6 +448,18 @@ packages: ms: 2.1.3 supports-color: 5.5.0 + /decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dependencies: + mimic-response: 3.1.0 + dev: false + + /deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dev: false + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -462,6 +517,12 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: false + /end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + dependencies: + once: 1.4.0 + dev: false + /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -494,6 +555,11 @@ packages: hasown: 2.0.2 dev: false + /expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: false @@ -511,6 +577,10 @@ packages: - supports-color dev: false + /file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + dev: false + /fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -539,6 +609,10 @@ packages: mime-types: 2.1.35 dev: false + /fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + dev: false + /fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -602,6 +676,10 @@ packages: es-object-atoms: 1.1.1 dev: false + /github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -669,6 +747,10 @@ packages: - supports-color dev: false + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: false + /ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} dev: true @@ -685,6 +767,10 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: false + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -759,11 +845,20 @@ packages: mime-db: 1.52.0 dev: false + /mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + dev: false + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.12 + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + /minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -784,6 +879,10 @@ packages: yallist: 4.0.0 dev: false + /mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + dev: false + /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -793,6 +892,17 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + dev: false + + /node-abi@3.75.0: + resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} + engines: {node: '>=10'} + dependencies: + semver: 7.7.2 + dev: false + /node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} dev: false @@ -869,6 +979,10 @@ packages: wrappy: 1.0.2 dev: false + /opusscript@0.1.1: + resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==} + dev: false + /parse-cache-control@1.0.1: resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} dev: false @@ -883,7 +997,26 @@ packages: engines: {node: '>=8.6'} dev: true - /prism-media@1.3.5(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0): + /prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + dependencies: + detect-libc: 2.0.4 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.75.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.3 + tunnel-agent: 0.6.0 + dev: false + + /prism-media@1.3.5(@discordjs/opus@0.9.0)(ffmpeg-static@5.2.0)(opusscript@0.1.1): resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==} peerDependencies: '@discordjs/opus': '>=0.8.0 <1.0.0' @@ -902,6 +1035,7 @@ packages: dependencies: '@discordjs/opus': 0.9.0 ffmpeg-static: 5.2.0 + opusscript: 0.1.1 dev: false /progress@2.0.3: @@ -917,6 +1051,23 @@ packages: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} dev: true + /pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + dev: false + + /rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + dev: false + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -971,6 +1122,18 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: false + /simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + dev: false + + /simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + dev: false + /simple-update-notifier@2.0.0: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} @@ -1010,12 +1173,37 @@ packages: ansi-regex: 5.0.1 dev: false + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: false + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} dependencies: has-flag: 3.0.0 + /tar-fs@2.1.3: + resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + dev: false + + /tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: false + /tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -1052,6 +1240,12 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: false + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} dev: false diff --git a/src/commands/slash/config.js b/src/commands/slash/config.js new file mode 100644 index 0000000..11dadaa --- /dev/null +++ b/src/commands/slash/config.js @@ -0,0 +1,182 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); +const configManager = require('../../config/config'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('config') + .setDescription('Manage server configuration') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('show') + .setDescription('Show current server configuration') + ) + .addSubcommand(subcommand => + subcommand + .setName('prefix') + .setDescription('Set the command prefix') + .addStringOption(option => + option.setName('new_prefix') + .setDescription('The new command prefix') + .setRequired(true) + .setMaxLength(5) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('sfx') + .setDescription('Enable or disable sound effects') + .addBooleanOption(option => + option.setName('enabled') + .setDescription('Enable sound effects') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('volume') + .setDescription('Set sound effects volume') + .addNumberOption(option => + option.setName('level') + .setDescription('Volume level (0.1 to 1.0)') + .setRequired(true) + .setMinValue(0.1) + .setMaxValue(1.0) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('funfacts') + .setDescription('Enable or disable fun facts') + .addBooleanOption(option => + option.setName('enabled') + .setDescription('Enable fun facts') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('hamfacts') + .setDescription('Enable or disable ham facts') + .addBooleanOption(option => + option.setName('enabled') + .setDescription('Enable ham facts') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('sfxchannels') + .setDescription('Set allowed channels for sound effects (regex pattern)') + .addStringOption(option => + option.setName('pattern') + .setDescription('Channel name pattern (leave empty to allow all channels)') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('roles') + .setDescription('Set roles that users can self-assign') + .addStringOption(option => + option.setName('pattern') + .setDescription('Role pattern (pipe-separated, e.g., "streamer|vip|member")') + .setRequired(false) + ) + ), + + async execute(interaction, guildConfig) { + const subcommand = interaction.options.getSubcommand(); + const databaseService = configManager.databaseService; + + if (!databaseService) { + return interaction.reply({ + content: '❌ Database service not available.', + ephemeral: true + }); + } + + if (subcommand === 'show') { + const embed = new EmbedBuilder() + .setTitle(`⚙️ Configuration for ${interaction.guild.name}`) + .setColor(0x21c629) + .addFields([ + { name: 'Prefix', value: `\`${guildConfig.prefix}\``, inline: true }, + { name: 'SFX Enabled', value: guildConfig.enableSfx ? '✅ Yes' : '❌ No', inline: true }, + { name: 'SFX Volume', value: `${Math.round(guildConfig.sfxVolume * 100)}%`, inline: true }, + { name: 'Fun Facts', value: guildConfig.enableFunFacts ? '✅ Enabled' : '❌ Disabled', inline: true }, + { name: 'Ham Facts', value: guildConfig.enableHamFacts ? '✅ Enabled' : '❌ Disabled', inline: true }, + { name: 'Allowed SFX Channels', value: guildConfig.allowedSfxChannels || 'All channels', inline: false }, + { name: 'Allowed Roles', value: guildConfig.allowedRolesForRequest || 'None configured', inline: false }, + ]) + .setFooter({ text: 'Use /config commands to modify settings' }); + + return interaction.reply({ embeds: [embed] }); + } + + // Handle configuration updates + const newConfig = { ...guildConfig }; + let updateMessage = ''; + + switch (subcommand) { + case 'prefix': + const newPrefix = interaction.options.getString('new_prefix'); + newConfig.prefix = newPrefix; + updateMessage = `Command prefix updated to \`${newPrefix}\``; + break; + + case 'sfx': + const sfxEnabled = interaction.options.getBoolean('enabled'); + newConfig.enableSfx = sfxEnabled; + updateMessage = `Sound effects ${sfxEnabled ? 'enabled' : 'disabled'}`; + break; + + case 'volume': + const volume = interaction.options.getNumber('level'); + newConfig.sfxVolume = volume; + updateMessage = `SFX volume set to ${Math.round(volume * 100)}%`; + break; + + case 'funfacts': + const funfactsEnabled = interaction.options.getBoolean('enabled'); + newConfig.enableFunFacts = funfactsEnabled; + updateMessage = `Fun facts ${funfactsEnabled ? 'enabled' : 'disabled'}`; + break; + + case 'hamfacts': + const hamfactsEnabled = interaction.options.getBoolean('enabled'); + newConfig.enableHamFacts = hamfactsEnabled; + updateMessage = `Ham facts ${hamfactsEnabled ? 'enabled' : 'disabled'}`; + break; + + case 'sfxchannels': + const channelPattern = interaction.options.getString('pattern'); + newConfig.allowedSfxChannels = channelPattern || null; + updateMessage = channelPattern + ? `SFX channels restricted to pattern: \`${channelPattern}\`` + : 'SFX allowed in all channels'; + break; + + case 'roles': + const rolePattern = interaction.options.getString('pattern'); + newConfig.allowedRolesForRequest = rolePattern || null; + updateMessage = rolePattern + ? `Self-assignable roles set to: \`${rolePattern}\`` + : 'Self-assignable roles cleared'; + break; + } + + // Update configuration in database + databaseService.upsertGuildConfig(newConfig); + + const embed = new EmbedBuilder() + .setTitle('✅ Configuration Updated') + .setColor(0x00ff00) + .setDescription(updateMessage) + .setFooter({ text: 'Use /config show to see all settings' }); + + await interaction.reply({ embeds: [embed] }); + + console.log(`Configuration updated for ${interaction.guild.name}: ${subcommand} by @${interaction.user.username}`); + } +}; \ No newline at end of file diff --git a/src/config/config.js b/src/config/config.js index 92ee0d0..bffa04a 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -1,26 +1,121 @@ const fs = require("fs"); const path = require("path"); -// Load config from root directory -const configPath = path.join(__dirname, "..", "..", "config.json"); -const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - -// Validate required config fields -function validateConfig(config) { - if (!config.discord?.token) { - throw new Error("Discord token is required in config.json"); +// Dynamic config that combines file-based config with database +class ConfigManager { + constructor() { + this.fileConfig = this.loadFileConfig(); + this.databaseService = null; // Will be injected } - if (!config.discord?.guilds || !Array.isArray(config.discord.guilds)) { - throw new Error("Discord guilds configuration is required"); + /** + * Load static configuration from file + */ + loadFileConfig() { + const configPath = path.join(__dirname, "..", "..", "config.json"); + + if (!fs.existsSync(configPath)) { + console.warn("config.json not found, using environment variables only"); + return { + discord: { + token: process.env.DISCORD_TOKEN, + adminUserId: process.env.ADMIN_USER_ID, + } + }; + } + + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + + // Validate required fields + if (!config.discord?.token && !process.env.DISCORD_TOKEN) { + throw new Error("Discord token is required in config.json or DISCORD_TOKEN environment variable"); + } + + return config; } - // Ensure guilds is an array (supporting both old object format and new array format) - if (!Array.isArray(config.discord.guilds)) { - config.discord.guilds = Object.values(config.discord.guilds); + /** + * Inject database service (to avoid circular dependency) + */ + setDatabaseService(databaseService) { + this.databaseService = databaseService; } - return config; + /** + * Get bot configuration (combines file and database) + */ + getBotConfig() { + const fileConfig = this.fileConfig; + const dbConfig = this.databaseService ? this.databaseService.getBotConfiguration() : {}; + + return { + // Use file config as fallback, database as primary + botName: dbConfig.botName || fileConfig.botName || 'GHBot', + debug: dbConfig.debug !== undefined ? dbConfig.debug : (fileConfig.debug || false), + discord: { + token: fileConfig.discord?.token || process.env.DISCORD_TOKEN, + adminUserId: dbConfig.adminUserId || fileConfig.discord?.adminUserId || process.env.ADMIN_USER_ID, + activities: dbConfig.activities || fileConfig.discord?.activities || ['Playing sounds', 'Serving facts'], + blacklistedUsers: dbConfig.blacklistedUsers || fileConfig.discord?.blacklistedUsers || [], + master: fileConfig.discord?.master !== false, // Default to true + } + }; + } + + /** + * Get guild configuration (from database primarily, file as fallback) + */ + getGuildConfig(guildId) { + if (this.databaseService) { + const dbConfig = this.databaseService.getGuildConfig(guildId); + if (dbConfig) { + return dbConfig; + } + } + + // Fallback to file config for backward compatibility + if (this.fileConfig.discord?.guilds) { + const guilds = Array.isArray(this.fileConfig.discord.guilds) + ? this.fileConfig.discord.guilds + : Object.values(this.fileConfig.discord.guilds); + + return guilds.find(g => g.id === guildId); + } + + // Return default config for new guilds + return { + id: guildId, + name: 'Unknown Guild', + internalName: 'Unknown Guild', + prefix: '!', + enableSfx: true, + allowedSfxChannels: null, + sfxVolume: 0.5, + enableFunFacts: true, + enableHamFacts: true, + allowedRolesForRequest: null, + }; + } + + /** + * Get all guild configurations + */ + getAllGuildConfigs() { + if (this.databaseService) { + return this.databaseService.getAllGuildConfigs(); + } + + // Fallback to file config + if (this.fileConfig.discord?.guilds) { + const guilds = Array.isArray(this.fileConfig.discord.guilds) + ? this.fileConfig.discord.guilds + : Object.values(this.fileConfig.discord.guilds); + + return guilds; + } + + return []; + } } -module.exports = validateConfig(config); +module.exports = new ConfigManager(); diff --git a/src/index.js b/src/index.js index cff335d..e3373e2 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,7 @@ const { } = require("discord.js"); const { generateDependencyReport } = require("@discordjs/voice"); const intents = require("./config/intents"); -const config = require("./config/config"); +const configManager = require("./config/config"); const { randElement } = require("./utils/helpers"); // Log audio dependencies status @@ -21,6 +21,13 @@ const client = new Client({ intents }); // Services const commandLoader = require("./services/commandLoader"); const schedulerService = require("./services/schedulerService"); +const databaseService = require("./services/databaseService"); + +// Inject database service into config manager +configManager.setDatabaseService(databaseService); + +// Get bot configuration +const config = configManager.getBotConfig(); // Command handlers const prefixCommands = require("./commands/prefix"); @@ -59,7 +66,8 @@ async function registerSlashCommands() { const commands = slashCommands.getSlashCommandDefinitions(); // Register commands for each guild - for (const guild of config.discord.guilds) { + const guildConfigs = configManager.getAllGuildConfigs(); + for (const guild of guildConfigs) { await rest.put( Routes.applicationGuildCommands(client.user.id, guild.id), { body: commands } @@ -93,7 +101,7 @@ client.once(Events.ClientReady, async () => { await registerSlashCommands(); // Initialize scheduled events - schedulerService.initialize(client, config); + schedulerService.initialize(client, configManager); }); // Message handler for prefix commands @@ -104,10 +112,8 @@ client.on(Events.MessageCreate, async (message) => { // Ignore DMs if not configured if (!message.guild) return; - // Check if guild is configured - const guildConfig = config.discord.guilds.find( - (g) => g.id === message.guild.id - ); + // Get guild configuration from database/file + const guildConfig = configManager.getGuildConfig(message.guild.id); if (!guildConfig) return; // Check blacklist @@ -174,10 +180,8 @@ client.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand() && !interaction.isAutocomplete()) return; - // Get guild config - const guildConfig = config.discord.guilds.find( - (g) => g.id === interaction.guild.id - ); + // Get guild configuration from database/file + const guildConfig = configManager.getGuildConfig(interaction.guild.id); if (!guildConfig) return; try { @@ -204,12 +208,129 @@ client.on(Events.InteractionCreate, async (interaction) => { } }); +// Handle bot being added to a new guild +client.on(Events.GuildCreate, async (guild) => { + console.log(`🎉 Bot added to new guild: ${guild.name} (${guild.id})`); + + // Check if this guild previously existed but was removed + const existingGuild = databaseService.getGuildConfigIncludingInactive(guild.id); + + if (existingGuild && !existingGuild.isActive) { + // Reactivate existing guild with previous settings + const guildConfig = { + id: guild.id, + name: guild.name, + internalName: guild.name, + }; + + databaseService.upsertGuildConfig(guildConfig, true); // true = reactivation + + // Send welcome back message + try { + const channel = guild.channels.cache.find( + (ch) => + ch.type === 0 && // Text channel + ch.permissionsFor(guild.members.me).has(["SendMessages", "ViewChannel"]) + ); + + if (channel) { + await channel.send({ + embeds: [ + { + title: "🎉 Welcome back to GHBot!", + description: `Great to see you again! Your previous configuration has been restored. + +**Your settings are preserved:** +• Command prefix: \`${existingGuild.prefix}\` +• Sound effects: ${existingGuild.enableSfx ? '✅ Enabled' : '❌ Disabled'} +• Volume: ${Math.round(existingGuild.sfxVolume * 100)}% + +Use \`/config show\` to view all settings or \`/config\` commands to modify them.`, + color: 0x00ff00, + footer: { text: "All your previous settings have been restored!" }, + }, + ], + }); + } + } catch (error) { + console.error("Error sending welcome back message:", error); + } + + return; + } + + // Auto-register new guild with default settings + const guildConfig = { + id: guild.id, + name: guild.name, + internalName: guild.name, + prefix: "!", + enableSfx: true, + allowedSfxChannels: null, // Allow in all channels by default + sfxVolume: 0.5, + enableFunFacts: true, + enableHamFacts: true, + allowedRolesForRequest: null, + }; + + databaseService.upsertGuildConfig(guildConfig); + + // Send welcome message to first available text channel + try { + const channel = guild.channels.cache.find( + (ch) => + ch.type === 0 && // Text channel + ch.permissionsFor(guild.members.me).has(["SendMessages", "ViewChannel"]) + ); + + if (channel) { + await channel.send({ + embeds: [ + { + title: "🎵 GHBot has joined the server!", + description: `Thanks for adding me! I'm a sound effects bot with the following features: + +**Commands:** +• \`!sfx \` - Play sound effects (prefix command) +• \`/sfx\` - Play sound effects with autocomplete (slash command) +• \`!funfact\` - Get random fun facts +• \`!hamfact\` - Get ham facts +• \`!dance\` - ASCII dance animation +• \`!join\` / \`!leave\` - Voice channel controls + +**Setup:** +1. Add sound files (.mp3/.wav) to your server +2. Use \`!config\` to customize settings +3. Set up allowed channels for sound effects + +Get started with \`!sfx\` to see available sounds!`, + color: 0x21c629, + footer: { text: "Use !help for more information" }, + }, + ], + }); + } + } catch (error) { + console.error("Error sending welcome message:", error); + } +}); + +// Handle bot being removed from a guild +client.on(Events.GuildDelete, (guild) => { + console.log(`👋 Bot removed from guild: ${guild.name} (${guild.id})`); + + // Soft delete guild configuration (can be restored if they re-add the bot) + const deleted = databaseService.softDeleteGuildConfig(guild.id); + + if (deleted) { + console.log(`Guild ${guild.name} configuration preserved for potential re-invite`); + } +}); + // Handle new guild members client.on(Events.GuildMemberAdd, (member) => { - // Check if guild is configured - const guildConfig = config.discord.guilds.find( - (g) => g.id === member.guild.id - ); + // Get guild configuration + const guildConfig = configManager.getGuildConfig(member.guild.id); if (!guildConfig) return; console.log( diff --git a/src/services/databaseService.js b/src/services/databaseService.js new file mode 100644 index 0000000..57577c2 --- /dev/null +++ b/src/services/databaseService.js @@ -0,0 +1,492 @@ +const Database = require('better-sqlite3'); +const path = require('path'); + +class DatabaseService { + constructor() { + // Store database in data directory + const dbPath = path.join(__dirname, '..', '..', 'data', 'ghbot.db'); + this.db = new Database(dbPath); + + // Enable WAL mode for better concurrent access + this.db.pragma('journal_mode = WAL'); + + this.initializeTables(); + } + + /** + * Initialize database tables + */ + initializeTables() { + // Guild configurations table + this.db.exec(` + CREATE TABLE IF NOT EXISTS guilds ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + internal_name TEXT, + prefix TEXT DEFAULT '!', + enable_sfx BOOLEAN DEFAULT true, + allowed_sfx_channels TEXT, + sfx_volume REAL DEFAULT 0.5, + enable_fun_facts BOOLEAN DEFAULT true, + enable_ham_facts BOOLEAN DEFAULT true, + allowed_roles_for_request TEXT, + is_active BOOLEAN DEFAULT true, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + removed_at DATETIME + ) + `); + + // Scheduled events table + this.db.exec(` + CREATE TABLE IF NOT EXISTS scheduled_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + event_id TEXT NOT NULL, + schedule TEXT NOT NULL, + channel_id TEXT, + message TEXT, + ping_role_id TEXT, + enabled BOOLEAN DEFAULT true, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (guild_id) REFERENCES guilds (id) ON DELETE CASCADE, + UNIQUE(guild_id, event_id) + ) + `); + + // Bot configuration table (for global settings) + this.db.exec(` + CREATE TABLE IF NOT EXISTS bot_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Insert default bot config if not exists + this.db.exec(` + INSERT OR IGNORE INTO bot_config (key, value) VALUES + ('bot_name', 'GHBot'), + ('debug', 'false'), + ('admin_user_id', ''), + ('activities', '["Playing sounds", "Serving facts"]'), + ('blacklisted_users', '[]') + `); + + console.log('Database tables initialized'); + + // Prepare statements after tables are created + this.prepareStatements(); + + // Run migrations after statements are prepared + this.runMigrations(); + } + + /** + * Run database migrations + */ + runMigrations() { + // Check if we need to seed from config file + const guildCount = this.db.prepare('SELECT COUNT(*) as count FROM guilds').get().count; + + if (guildCount === 0) { + console.log('No guilds found in database, checking for config file to seed...'); + this.seedFromConfigFile(); + } + } + + /** + * Seed database with guilds from config file + */ + seedFromConfigFile() { + try { + const fs = require('fs'); + const path = require('path'); + + const configPath = path.join(__dirname, '..', '..', 'config.json'); + + if (!fs.existsSync(configPath)) { + console.log('No config.json file found, skipping seed'); + return; + } + + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + if (!config.discord?.guilds) { + console.log('No guilds found in config.json, skipping seed'); + return; + } + + // Handle both array and object formats for backward compatibility + const guilds = Array.isArray(config.discord.guilds) + ? config.discord.guilds + : Object.values(config.discord.guilds); + + let seededCount = 0; + + for (const guild of guilds) { + if (!guild.id) { + console.warn('Skipping guild with missing ID:', guild); + continue; + } + + // Convert old config format to new format + const guildConfig = { + id: guild.id, + name: guild.internalName || guild.name || 'Unknown Guild', + internalName: guild.internalName || guild.name || 'Unknown Guild', + prefix: guild.prefix || '!', + enableSfx: guild.enableSfx !== false, + allowedSfxChannels: guild.allowedSfxChannels || null, + sfxVolume: guild.sfxVolume || 0.5, + enableFunFacts: guild.enableFunFacts !== false, + enableHamFacts: guild.enableHamFacts !== false, + allowedRolesForRequest: guild.allowedRolesForRequest || null, + }; + + // Insert guild configuration + this.upsertGuildConfig(guildConfig); + + // Insert scheduled events if they exist + if (guild.scheduledEvents && Array.isArray(guild.scheduledEvents)) { + for (const event of guild.scheduledEvents) { + if (event.id && event.schedule) { + try { + console.log(`Importing scheduled event: ${event.id} for guild ${guild.id}`); + this.addScheduledEvent(guild.id, event); + } catch (error) { + console.warn(`Skipping scheduled event ${event.id} for guild ${guild.id}:`, error.message); + console.warn('Event object:', JSON.stringify(event, null, 2)); + } + } + } + } + + seededCount++; + } + + console.log(`✅ Successfully seeded database with ${seededCount} guild(s) from config.json`); + + // Update bot configuration in database from file config + if (config.botName) { + this.setBotConfig('bot_name', config.botName); + } + if (config.debug !== undefined) { + this.setBotConfig('debug', config.debug.toString()); + } + if (config.discord?.adminUserId) { + this.setBotConfig('admin_user_id', config.discord.adminUserId); + } + if (config.discord?.activities && Array.isArray(config.discord.activities)) { + this.setBotConfig('activities', JSON.stringify(config.discord.activities)); + } + if (config.discord?.blacklistedUsers && Array.isArray(config.discord.blacklistedUsers)) { + this.setBotConfig('blacklisted_users', JSON.stringify(config.discord.blacklistedUsers)); + } + + console.log('✅ Bot configuration updated from config.json'); + + } catch (error) { + console.error('Error seeding database from config file:', error); + } + } + + /** + * Prepare SQL statements for better performance + */ + prepareStatements() { + this.statements = { + // Guild operations + getGuild: this.db.prepare('SELECT * FROM guilds WHERE id = ? AND is_active = true'), + getAllGuilds: this.db.prepare('SELECT * FROM guilds WHERE is_active = true'), + insertGuild: this.db.prepare(` + INSERT OR REPLACE INTO guilds + (id, name, internal_name, prefix, enable_sfx, allowed_sfx_channels, sfx_volume, + enable_fun_facts, enable_ham_facts, allowed_roles_for_request, is_active, updated_at, removed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, true, CURRENT_TIMESTAMP, NULL) + `), + updateGuild: this.db.prepare(` + UPDATE guilds SET + name = ?, internal_name = ?, prefix = ?, enable_sfx = ?, + allowed_sfx_channels = ?, sfx_volume = ?, enable_fun_facts = ?, + enable_ham_facts = ?, allowed_roles_for_request = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND is_active = true + `), + softDeleteGuild: this.db.prepare(` + UPDATE guilds SET is_active = false, removed_at = CURRENT_TIMESTAMP + WHERE id = ? + `), + reactivateGuild: this.db.prepare(` + UPDATE guilds SET is_active = true, removed_at = NULL, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `), + hardDeleteGuild: this.db.prepare('DELETE FROM guilds WHERE id = ?'), + + // Scheduled events + getScheduledEvents: this.db.prepare('SELECT * FROM scheduled_events WHERE guild_id = ? AND enabled = true'), + insertScheduledEvent: this.db.prepare(` + INSERT OR REPLACE INTO scheduled_events + (guild_id, event_id, schedule, channel_id, message, ping_role_id, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), + deleteScheduledEvent: this.db.prepare('DELETE FROM scheduled_events WHERE guild_id = ? AND event_id = ?'), + + // Bot config + getBotConfig: this.db.prepare('SELECT value FROM bot_config WHERE key = ?'), + setBotConfig: this.db.prepare(` + INSERT OR REPLACE INTO bot_config (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `), + }; + } + + /** + * Get guild configuration + * @param {string} guildId + * @returns {Object|null} + */ + getGuildConfig(guildId) { + const guild = this.statements.getGuild.get(guildId); + if (!guild) return null; + + return { + id: guild.id, + name: guild.name, + internalName: guild.internal_name, + prefix: guild.prefix, + enableSfx: Boolean(guild.enable_sfx), + allowedSfxChannels: guild.allowed_sfx_channels, + sfxVolume: guild.sfx_volume, + enableFunFacts: Boolean(guild.enable_fun_facts), + enableHamFacts: Boolean(guild.enable_ham_facts), + allowedRolesForRequest: guild.allowed_roles_for_request, + }; + } + + /** + * Get all guild configurations + * @returns {Array} + */ + getAllGuildConfigs() { + const guilds = this.statements.getAllGuilds.all(); + return guilds.map(guild => ({ + id: guild.id, + name: guild.name, + internalName: guild.internal_name, + prefix: guild.prefix, + enableSfx: Boolean(guild.enable_sfx), + allowedSfxChannels: guild.allowed_sfx_channels, + sfxVolume: guild.sfx_volume, + enableFunFacts: Boolean(guild.enable_fun_facts), + enableHamFacts: Boolean(guild.enable_ham_facts), + allowedRolesForRequest: guild.allowed_roles_for_request, + })); + } + + /** + * Add or update guild configuration + * @param {Object} guildConfig + * @param {boolean} isReactivation - Whether this is reactivating an existing guild + */ + upsertGuildConfig(guildConfig, isReactivation = false) { + if (isReactivation) { + // Check if guild exists but is inactive + const existingGuild = this.db.prepare('SELECT * FROM guilds WHERE id = ?').get(guildConfig.id); + if (existingGuild && !existingGuild.is_active) { + // Reactivate existing guild and update its info + this.statements.reactivateGuild.run(guildConfig.id); + // Update the guild info + this.statements.updateGuild.run( + guildConfig.name, + guildConfig.internalName || guildConfig.name, + existingGuild.prefix, // Keep existing prefix + existingGuild.enable_sfx ? 1 : 0, // Keep existing settings + existingGuild.allowed_sfx_channels, + existingGuild.sfx_volume, + existingGuild.enable_fun_facts ? 1 : 0, + existingGuild.enable_ham_facts ? 1 : 0, + existingGuild.allowed_roles_for_request, + guildConfig.id + ); + console.log(`Guild reactivated with existing configuration: ${guildConfig.name} (${guildConfig.id})`); + return; + } + } + + // Insert new guild or replace completely + this.statements.insertGuild.run( + guildConfig.id, + guildConfig.name, + guildConfig.internalName || guildConfig.name, + guildConfig.prefix || '!', + guildConfig.enableSfx !== false ? 1 : 0, + guildConfig.allowedSfxChannels || null, + guildConfig.sfxVolume || 0.5, + guildConfig.enableFunFacts !== false ? 1 : 0, + guildConfig.enableHamFacts !== false ? 1 : 0, + guildConfig.allowedRolesForRequest || null + ); + + console.log(`Guild configuration saved: ${guildConfig.name} (${guildConfig.id})`); + } + + /** + * Soft delete guild configuration (can be restored) + * @param {string} guildId + */ + softDeleteGuildConfig(guildId) { + const result = this.statements.softDeleteGuild.run(guildId); + if (result.changes > 0) { + console.log(`Guild configuration soft-deleted: ${guildId}`); + } + return result.changes > 0; + } + + /** + * Hard delete guild configuration (permanent) + * @param {string} guildId + */ + hardDeleteGuildConfig(guildId) { + const result = this.statements.hardDeleteGuild.run(guildId); + if (result.changes > 0) { + console.log(`Guild configuration permanently deleted: ${guildId}`); + } + return result.changes > 0; + } + + /** + * Check if guild exists (including inactive) + * @param {string} guildId + * @returns {Object|null} + */ + getGuildConfigIncludingInactive(guildId) { + const guild = this.db.prepare('SELECT * FROM guilds WHERE id = ?').get(guildId); + if (!guild) return null; + + return { + id: guild.id, + name: guild.name, + internalName: guild.internal_name, + prefix: guild.prefix, + enableSfx: Boolean(guild.enable_sfx), + allowedSfxChannels: guild.allowed_sfx_channels, + sfxVolume: guild.sfx_volume, + enableFunFacts: Boolean(guild.enable_fun_facts), + enableHamFacts: Boolean(guild.enable_ham_facts), + allowedRolesForRequest: guild.allowed_roles_for_request, + isActive: Boolean(guild.is_active), + removedAt: guild.removed_at, + }; + } + + /** + * Get scheduled events for a guild + * @param {string} guildId + * @returns {Array} + */ + getScheduledEvents(guildId) { + const events = this.statements.getScheduledEvents.all(guildId); + + // Parse schedule strings back to objects/strings for node-schedule + return events.map(event => ({ + ...event, + schedule: this.parseSchedule(event.schedule) + })); + } + + /** + * Parse schedule string back to object or cron string + * @param {string} scheduleString + * @returns {Object|string} + */ + parseSchedule(scheduleString) { + try { + // Try to parse as JSON (object format) + return JSON.parse(scheduleString); + } catch { + // If it fails, it's probably a cron string + return scheduleString; + } + } + + /** + * Add scheduled event + * @param {string} guildId + * @param {Object} event + */ + addScheduledEvent(guildId, event) { + // Store schedule as JSON string to preserve object format and timezone + const scheduleString = typeof event.schedule === 'string' + ? event.schedule + : JSON.stringify(event.schedule); + + this.statements.insertScheduledEvent.run( + guildId, + event.id, + scheduleString, + event.channelId || null, + event.message || null, + event.pingRoleId || null, + event.enabled !== false ? 1 : 0 + ); + } + + /** + * Remove scheduled event + * @param {string} guildId + * @param {string} eventId + */ + removeScheduledEvent(guildId, eventId) { + this.statements.deleteScheduledEvent.run(guildId, eventId); + } + + /** + * Get bot configuration value + * @param {string} key + * @returns {string|null} + */ + getBotConfig(key) { + const result = this.statements.getBotConfig.get(key); + return result ? result.value : null; + } + + /** + * Set bot configuration value + * @param {string} key + * @param {string} value + */ + setBotConfig(key, value) { + this.statements.setBotConfig.run(key, value); + } + + /** + * Get parsed bot configuration + * @returns {Object} + */ + getBotConfiguration() { + const botName = this.getBotConfig('bot_name') || 'GHBot'; + const debug = this.getBotConfig('debug') === 'true'; + const adminUserId = this.getBotConfig('admin_user_id') || ''; + const activities = JSON.parse(this.getBotConfig('activities') || '[]'); + const blacklistedUsers = JSON.parse(this.getBotConfig('blacklisted_users') || '[]'); + + return { + botName, + debug, + adminUserId, + activities, + blacklistedUsers, + }; + } + + /** + * Close database connection + */ + close() { + if (this.db) { + this.db.close(); + } + } +} + +module.exports = new DatabaseService(); \ No newline at end of file diff --git a/src/services/schedulerService.js b/src/services/schedulerService.js index 4f78058..0364e41 100644 --- a/src/services/schedulerService.js +++ b/src/services/schedulerService.js @@ -8,12 +8,14 @@ class SchedulerService { /** * Initialize scheduled events for all guilds * @param {Client} client - * @param {Object} config + * @param {ConfigManager} configManager */ - async initialize(client, config) { + async initialize(client, configManager) { console.log('Initializing scheduled events...'); - for (const guildConfig of config.discord.guilds) { + const guildConfigs = configManager.getAllGuildConfigs(); + + for (const guildConfig of guildConfigs) { try { const guild = await client.guilds.fetch(guildConfig.id); if (!guild) { @@ -21,11 +23,17 @@ class SchedulerService { continue; } - if (!guildConfig.scheduledEvents || guildConfig.scheduledEvents.length === 0) { + // Get scheduled events from database + const databaseService = configManager.databaseService; + if (!databaseService) continue; + + const scheduledEvents = databaseService.getScheduledEvents(guildConfig.id); + + if (!scheduledEvents || scheduledEvents.length === 0) { continue; } - for (const event of guildConfig.scheduledEvents) { + for (const event of scheduledEvents) { await this.scheduleEvent(guild, event, guildConfig); } } catch (error) {