Add SQLite database for dynamic guild management

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 <noreply@anthropic.com>
This commit is contained in:
Chris Ham
2025-08-16 14:02:27 -07:00
parent 9661ba92d5
commit d74aebfda7
12 changed files with 1157 additions and 75 deletions

1
.gitignore vendored
View File

@@ -49,6 +49,7 @@ Temporary Items
# Ignores
node_modules
logs
data/
start.bat
tokens.json

2
.nvmrc
View File

@@ -1 +1 @@
22
20

View File

@@ -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 . .

View File

@@ -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
```

View File

@@ -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

View File

@@ -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}"
},

206
pnpm-lock.yaml generated
View File

@@ -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

View File

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

View File

@@ -1,26 +1,121 @@
const fs = require("fs");
const path = require("path");
// Load config from root directory
// Dynamic config that combines file-based config with database
class ConfigManager {
constructor() {
this.fileConfig = this.loadFileConfig();
this.databaseService = null; // Will be injected
}
/**
* 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 config fields
function validateConfig(config) {
if (!config.discord?.token) {
throw new Error("Discord token is required in config.json");
}
if (!config.discord?.guilds || !Array.isArray(config.discord.guilds)) {
throw new Error("Discord guilds configuration is required");
}
// 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);
// 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;
}
module.exports = validateConfig(config);
/**
* Inject database service (to avoid circular dependency)
*/
setDatabaseService(databaseService) {
this.databaseService = databaseService;
}
/**
* 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 = new ConfigManager();

View File

@@ -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 <sound>\` - 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(

View File

@@ -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();

View File

@@ -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) {