Modernize Discord bot to v14 and Node.js 22

Major upgrades and architectural improvements:
- Upgrade Discord.js from v12 to v14.21.0
- Upgrade Node.js from 14 to 22 LTS
- Switch to pnpm package manager
- Complete rewrite with modern Discord API patterns

New Features:
- Hybrid command system: prefix commands + slash commands
- /sfx slash command with autocomplete for sound discovery
- Modern @discordjs/voice integration for audio
- Improved voice connection management
- Enhanced logging for SFX commands
- Multi-stage Docker build for optimized images

Technical Improvements:
- Modular architecture with services and command handlers
- Proper intent management for Discord gateway
- Better error handling and logging
- Hot-reload capability maintained
- Environment variable support
- Optimized Docker container with Alpine Linux

Breaking Changes:
- Moved main entry from index.js to src/index.js
- Updated configuration structure for v14 compatibility
- Replaced deprecated voice APIs with @discordjs/voice
- Updated audio dependencies (opus, ffmpeg)

🤖 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 11:37:37 -07:00
parent 19c8f4fa85
commit 0ad4265bed
31 changed files with 2931 additions and 381 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.env.*
*.log
.DS_Store
Dockerfile
.dockerignore
CLAUDE.md
todo.todo

4
.gitignore vendored
View File

@@ -53,6 +53,8 @@ logs
start.bat
tokens.json
config.json
config.*json
!config.example.json
.env
*.todo

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

99
CLAUDE.md Normal file
View File

@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Discord bot built with discord.js v14 that provides sound effects (both prefix and slash commands), text commands, fun facts, and scheduled events functionality for Discord servers.
## Development Commands
### Running the Bot
```bash
# Install dependencies
pnpm install
# Direct Node.js execution (requires Node 22 LTS)
pnpm start # Production mode
pnpm dev # Development mode with auto-reload
# Docker commands
pnpm docker:build # Build Docker image
pnpm docker:run # Run Docker container with auto-restart
```
### Docker Management
```bash
# Stop and remove container
docker stop ghbot && docker rm ghbot
# View logs
docker logs ghbot -f
# Rebuild and restart
docker stop ghbot && docker rm ghbot && pnpm docker:build && pnpm docker:run
```
## Architecture & Key Components
### Core Structure
- **src/index.js**: Main bot entry point with Discord.js v14 client
- **src/config/**: Configuration management
- `config.js`: Config loader and validator
- `intents.js`: Discord gateway intents
- **src/commands/**:
- `prefix/`: Traditional prefix commands (!sfx, !funfact, etc.)
- `slash/`: Slash commands (/sfx with autocomplete)
- **src/services/**:
- `voiceService.js`: Voice connections using @discordjs/voice
- `commandLoader.js`: Static/Ankhbot command loader
- `sfxManager.js`: Sound effect file management
- `schedulerService.js`: Scheduled events handler
- **src/utils/**: Helper functions
- **config.json**: Bot configuration (copy from config.json.example)
### Command System
Commands are handled in priority order:
1. Native commands (defined in index.js commands object)
2. Static text commands (from conf/text_commands)
3. Ankhbot imported commands (from conf/ghbot.abcomg)
### Sound Effects System
- Sound files stored in `sfx/` directory as .mp3 or .wav
- Automatically discovered on startup and directory changes
- Requires voice channel connection and ffmpeg (included in Docker image)
- Volume and audio passes configurable per guild
### Configuration Files
- **conf/text_commands**: Pipe-delimited text commands with alias support
- **conf/funfacts & conf/hamfacts**: Line-separated fact collections
- **conf/snesgames.json**: SNES game database (purpose unclear from current usage)
### Scheduled Events
Configured per guild in config.json with cron-style scheduling using node-schedule. Events can:
- Send messages to specific channels
- Ping specific roles
- Run on cron schedules
### Docker Setup
Uses Node 22-alpine base image with ffmpeg for audio processing. The Dockerfile installs all dependencies and runs the bot with `node src/index.js`.
## Important Implementation Details
- Discord.js v14 with modern API patterns
- @discordjs/voice for audio playback
- Hybrid command system: prefix commands + slash command for SFX
- /sfx slash command with autocomplete for easy sound discovery
- Hot-reloads configuration files and commands without restart
- Supports multiple guilds with individual configurations
- Admin commands restricted by Discord user ID
- Blacklist system for blocking specific users
- Proper voice connection pooling and cleanup

View File

@@ -1,17 +1,33 @@
# Use the official Node.js image as the base image
FROM node:14
# Build stage
FROM node:22-alpine AS builder
# Set the working directory inside the container
WORKDIR /app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Copy package files
COPY package*.json pnpm-lock.yaml* ./
# Install the dependencies
RUN npm install
# 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
# Copy the rest of the application code to the working directory
# 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
# Copy application code
COPY . .
# Start the bot
CMD [ "node", "index.js" ]
CMD [ "node", "src/index.js" ]

View File

@@ -1,4 +1,2 @@
f|is for :frog:
ladeda|https://www.youtube.com/watch?v=V0HCZ4YGqbw
fine,ez,steve|https://www.youtube.com/watch?v=_c1NJQ0UP_Q
imelly|Didn't know this was a political channel. WTF, you don't have sites where you can vomit that garbage out for the peanut gallery? Not everyone holds your same position. Why do that? Don't assume everyone is a socialist. Knock it off. I didn't join here to listen to your political BS. I am not paying a subscription to listen to you people pontificate about your political hangups with normal people. Unsubscribed, unfollowed. -iMellyGurl
imelly|Didn't know this was a political channel. WTF, you don't have sites where you can vomit that garbage out for the peanut gallery? Not everyone holds your same position. Why do that? Don't assume everyone is a socialist. Knock it off. I didn't join here to listen to your political BS. I am not paying a subscription to listen to you people pontificate about your political hangups with normal people. Unsubscribed, unfollowed. -iMellyGurl

50
config.example.json Normal file
View File

@@ -0,0 +1,50 @@
{
"botName": "greenhambot",
"discord": {
"clientId": "YOUR DISCORD APP ID",
"token": "YOUR DISCORD APP TOKEN",
"adminUserId": "YOUR DISCORD USER ID",
"guilds": [
{
"internalName": "GUILD NAME",
"id": "GUILD ID",
"prefix": "!",
"enableSfx": true,
"allowedSfxChannels": "piped|list|of-valid-channels",
"sfxVolume": 0.5,
"passes": 2,
"enableFunFacts": true,
"enableHamFacts": true,
"scheduledEvents": [
{
"id": "rise-up-and-kick-a-little-ass",
"schedule": {
"hour": 7,
"minute": 30,
"tz": "America/Los_Angeles"
},
"channelId": "DISCORD CHANNEL ID",
"pingRoleId": "DISCORD ROLE ID",
"message": "I'm gonna rise up, I'm gonna kick a little ass, Gonna kick some ass in the USA, Gonna climb a mountain, Gonna sew a flag, Gonna fly on an Eagle, I'm gonna kick some butt, I'm gonna drive a big truck, I'm gonna rule this world, Gonna kick some ass, Gonna rise up, Kick a little ass"
}
]
},
{
"internalName": "SECOND GUILD NAME",
"id": "SECOND GUILD ID",
"prefix": "!",
"enableSfx": true,
"allowedSfxChannels": "piped|list|of-valid-channels",
"sfxVolume": 0.5,
"passes": 2,
"enableFunFacts": true,
"enableHamFacts": true
}
],
"activities": [
"that gum you like"
],
"blacklistedUsers": ["IGNORE COMMANDS FROM THESE DISCORD USER IDS"]
},
"debug": false
}

View File

@@ -1,37 +0,0 @@
{
"botName": "greenhambot",
"debug": false,
"discord": {
"token": "YOUR DISCORD APP TOKEN",
"adminID": "YOUR DISCORD USER ID",
"master": true,
"guilds": {
"GUILD ID": {
"internalName": "GUILD NAME",
"id": "GUILD ID",
"prefix": "!",
"enableSfx": true,
"allowedSfxChannels": "piped|list|of-valid-channels",
"sfxVolume": 0.5,
"passes": 2,
"enableFunFacts": true,
"enableHamFacts": true
},
"SECOND GUILD ID": {
"internalName": "SECOND GUILD NAME",
"id": "SECOND GUILD ID",
"prefix": "!",
"enableSfx": true,
"allowedSfxChannels": "piped|list|of-valid-channels",
"sfxVolume": 0.5,
"passes": 2,
"enableFunFacts": true,
"enableHamFacts": true
},
},
"activities": [
"that gum you like"
],
"blacklistedUsers": []
}
}

View File

@@ -319,7 +319,7 @@ function init(config) {
}
},
reboot: (msg, guildConfig) => {
if (msg.author.id == config.discord.adminID) process.exit(); // Requires a node module like Forever to work.
if (msg.author.id == config.discord.adminUserId) process.exit(); // Requires a node module like Forever to work.
},
};
@@ -533,8 +533,6 @@ process.on("unhandledRejection", console.error);
init(config);
Discord.Client.prototype.setRandomActivity = function () {
if (!config.discord.master) return;
let activity =
config.discord.activities.length > 0
? randElement(config.discord.activities)

317
package-lock.json generated
View File

@@ -1,317 +0,0 @@
{
"name": "ghbot",
"version": "1.6.9",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@discordjs/collection": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.1.6.tgz",
"integrity": "sha512-utRNxnd9kSS2qhyivo9lMlt5qgAUasH2gb7BEOn6p0efFh24gjGomHzWKMAPn2hEReOPQZCJaRKoURwRotKucQ=="
},
"@discordjs/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"requires": {
"event-target-shim": "^5.0.0"
}
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
},
"call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
"requires": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.1"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"cron-parser": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-3.5.0.tgz",
"integrity": "sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==",
"requires": {
"is-nan": "^1.3.2",
"luxon": "^1.26.0"
}
},
"define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"requires": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
}
},
"define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"requires": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"discord.js": {
"version": "12.5.3",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-12.5.3.tgz",
"integrity": "sha512-D3nkOa/pCkNyn6jLZnAiJApw2N9XrIsXUAdThf01i7yrEuqUmDGc7/CexVWwEcgbQR97XQ+mcnqJpmJ/92B4Aw==",
"requires": {
"@discordjs/collection": "^0.1.6",
"@discordjs/form-data": "^3.0.1",
"abort-controller": "^3.0.0",
"node-fetch": "^2.6.1",
"prism-media": "^1.2.9",
"setimmediate": "^1.0.5",
"tweetnacl": "^1.0.3",
"ws": "^7.4.4"
}
},
"es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"requires": {
"get-intrinsic": "^1.2.4"
}
},
"es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw=="
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
}
},
"gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"requires": {
"get-intrinsic": "^1.1.3"
}
},
"has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"requires": {
"es-define-property": "^1.0.0"
}
},
"has-proto": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q=="
},
"has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
},
"hasown": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz",
"integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==",
"requires": {
"function-bind": "^1.1.2"
}
},
"is-nan": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
"requires": {
"call-bind": "^1.0.0",
"define-properties": "^1.1.3"
}
},
"libsodium": {
"version": "0.7.13",
"resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.13.tgz",
"integrity": "sha512-mK8ju0fnrKXXfleL53vtp9xiPq5hKM0zbDQtcxQIsSmxNgSxqCj6R7Hl9PkrNe2j29T4yoDaF7DJLK9/i5iWUw=="
},
"libsodium-wrappers": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.9.tgz",
"integrity": "sha512-9HaAeBGk1nKTRFRHkt7nzxqCvnkWTjn1pdjKgcUnZxj0FyOP4CnhgFhMdrFfgNsukijBGyBLpP2m2uKT1vuWhQ==",
"requires": {
"libsodium": "^0.7.0"
}
},
"long-timeout": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz",
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="
},
"luxon": {
"version": "1.28.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz",
"integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw=="
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.52.0"
}
},
"node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"requires": {
"whatwg-url": "^5.0.0"
}
},
"node-schedule": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.0.0.tgz",
"integrity": "sha512-cHc9KEcfiuXxYDU+HjsBVo2FkWL1jRAUoczFoMIzRBpOA4p/NRHuuLs85AWOLgKsHtSPjN8csvwIxc2SqMv+CQ==",
"requires": {
"cron-parser": "^3.1.0",
"long-timeout": "0.1.1",
"sorted-array-functions": "^1.3.0"
}
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
},
"opusscript": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.8.tgz",
"integrity": "sha512-VSTi1aWFuCkRCVq+tx/BQ5q9fMnQ9pVZ3JU4UHKqTkf0ED3fKEPdr+gKAAl3IA2hj9rrP6iyq3hlcJq3HELtNQ=="
},
"prism-media": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/prism-media/-/prism-media-1.3.5.tgz",
"integrity": "sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA=="
},
"set-function-length": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz",
"integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==",
"requires": {
"define-data-property": "^1.1.2",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.3",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.1"
}
},
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"sorted-array-functions": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz",
"integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA=="
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"requires": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"ws": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz",
"integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q=="
}
}
}

View File

@@ -1,20 +1,28 @@
{
"name": "ghbot",
"version": "1.6.9",
"version": "2.0.0",
"description": "",
"main": "index.js",
"main": "src/index.js",
"dependencies": {
"axios": "^0.21.2",
"discord.js": "^12.5.3",
"libsodium-wrappers": "^0.7.9",
"node-schedule": "^2.0.0",
"opusscript": "^0.0.8"
"axios": "^1.11.0",
"discord.js": "^14.21.0",
"@discordjs/voice": "^0.18.0",
"@discordjs/opus": "^0.9.0",
"opusscript": "^0.1.1",
"ffmpeg-static": "^5.2.0",
"node-schedule": "^2.1.1",
"sodium-native": "^4.3.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"nodemon": "^3.1.9"
},
"devDependencies": {},
"scripts": {
"build": "docker build -t ghbot:latest .",
"start": "docker run -d --name ghbot ghbot:latest"
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"docker:build": "docker build -t ghbot:${VERSION:-latest} .",
"docker:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}"
},
"author": "",
"author": "https://github.com/greenham",
"license": "MIT"
}

1111
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
module.exports = {
name: 'dance',
description: 'Make the bot dance!',
async execute(message, args, guildConfig) {
await message.channel.send(
'*┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛ ┏(-_-)┓┏(-_-)┛┗(-_- )┓┗(-_-)┛┏(-_-)┛*'
);
}
};

View File

@@ -0,0 +1,79 @@
const { EmbedBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
class FunFactCommand {
constructor() {
this.funFactsPath = path.join(__dirname, '..', '..', '..', 'conf', 'funfacts');
this.funFacts = [];
this.loadFunFacts();
this.watchFile();
}
loadFunFacts() {
try {
if (!fs.existsSync(this.funFactsPath)) {
console.log('Fun facts file not found');
return;
}
const data = fs.readFileSync(this.funFactsPath, 'utf-8');
this.funFacts = data.split('\n').filter(line => line.trim().length > 0);
console.log(`Loaded ${this.funFacts.length} fun facts`);
} catch (error) {
console.error('Error loading fun facts:', error);
}
}
watchFile() {
if (fs.existsSync(this.funFactsPath)) {
fs.watchFile(this.funFactsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Fun facts file changed, reloading...');
this.loadFunFacts();
}
});
}
}
async execute(message, args, guildConfig) {
if (guildConfig.enableFunFacts === false) {
return;
}
if (this.funFacts.length === 0) {
return message.channel.send('No fun facts found!');
}
// Check if a specific fact number was requested
let factIndex;
const requestedNum = parseInt(args[0]);
if (!isNaN(requestedNum) && requestedNum > 0 && requestedNum <= this.funFacts.length) {
factIndex = requestedNum - 1;
} else {
factIndex = Math.floor(Math.random() * this.funFacts.length);
}
const displayNum = factIndex + 1;
const funFact = this.funFacts[factIndex];
const embed = new EmbedBuilder()
.setTitle(`FunFact #${displayNum}`)
.setColor(0x21c629)
.setDescription(funFact);
await message.channel.send({ embeds: [embed] });
}
}
const funFactCommand = new FunFactCommand();
module.exports = {
name: 'funfact',
description: 'Get a random fun fact',
async execute(message, args, guildConfig) {
await funFactCommand.execute(message, args, guildConfig);
}
};

View File

@@ -0,0 +1,79 @@
const { EmbedBuilder } = require('discord.js');
const fs = require('fs');
const path = require('path');
class HamFactCommand {
constructor() {
this.hamFactsPath = path.join(__dirname, '..', '..', '..', 'conf', 'hamfacts');
this.hamFacts = [];
this.loadHamFacts();
this.watchFile();
}
loadHamFacts() {
try {
if (!fs.existsSync(this.hamFactsPath)) {
console.log('Ham facts file not found');
return;
}
const data = fs.readFileSync(this.hamFactsPath, 'utf-8');
this.hamFacts = data.split('\n').filter(line => line.trim().length > 0);
console.log(`Loaded ${this.hamFacts.length} ham facts`);
} catch (error) {
console.error('Error loading ham facts:', error);
}
}
watchFile() {
if (fs.existsSync(this.hamFactsPath)) {
fs.watchFile(this.hamFactsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Ham facts file changed, reloading...');
this.loadHamFacts();
}
});
}
}
async execute(message, args, guildConfig) {
if (guildConfig.enableHamFacts === false) {
return;
}
if (this.hamFacts.length === 0) {
return message.channel.send('No ham facts found!');
}
// Check if a specific fact number was requested
let factIndex;
const requestedNum = parseInt(args[0]);
if (!isNaN(requestedNum) && requestedNum > 0 && requestedNum <= this.hamFacts.length) {
factIndex = requestedNum - 1;
} else {
factIndex = Math.floor(Math.random() * this.hamFacts.length);
}
const displayNum = factIndex + 1;
const hamFact = this.hamFacts[factIndex];
const embed = new EmbedBuilder()
.setTitle(`HamFact #${displayNum}`)
.setColor(0x21c629)
.setDescription(hamFact);
await message.channel.send({ embeds: [embed] });
}
}
const hamFactCommand = new HamFactCommand();
module.exports = {
name: 'hamfact',
description: 'Get a random ham fact',
async execute(message, args, guildConfig) {
await hamFactCommand.execute(message, args, guildConfig);
}
};

View File

@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
class PrefixCommandHandler {
constructor() {
this.commands = new Map();
this.loadCommands();
}
/**
* Load all prefix command modules
*/
loadCommands() {
const commandFiles = fs.readdirSync(__dirname)
.filter(file => file.endsWith('.js') && file !== 'index.js');
for (const file of commandFiles) {
const command = require(path.join(__dirname, file));
// Register command and any aliases
if (command.name) {
this.commands.set(command.name, command);
if (command.aliases && Array.isArray(command.aliases)) {
for (const alias of command.aliases) {
this.commands.set(alias, command);
}
}
}
}
console.log(`Loaded ${this.commands.size} prefix commands`);
}
/**
* Check if a command exists
* @param {string} commandName
* @returns {boolean}
*/
has(commandName) {
return this.commands.has(commandName);
}
/**
* Execute a command
* @param {string} commandName
* @param {Message} message
* @param {Array} args
* @param {Object} guildConfig
*/
async execute(commandName, message, args, guildConfig) {
const command = this.commands.get(commandName);
if (!command) {
return;
}
try {
await command.execute(message, args, guildConfig);
} catch (error) {
console.error(`Error executing prefix command ${commandName}:`, error);
throw error;
}
}
}
module.exports = new PrefixCommandHandler();

View File

@@ -0,0 +1,26 @@
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'join',
description: 'Make the bot join your voice channel',
async execute(message, args, guildConfig) {
// Check if user is in a voice channel
if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel first!');
}
// Check if already connected
if (voiceService.isConnected(message.guild.id)) {
return message.reply("I'm already in a voice channel!");
}
try {
await voiceService.join(message.member.voice.channel);
await message.react('✅');
} catch (error) {
console.error('Error joining voice channel:', error);
await message.reply("I couldn't connect to your voice channel. Make sure I have the proper permissions!");
}
}
};

View File

@@ -0,0 +1,16 @@
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'leave',
description: 'Make the bot leave the voice channel',
async execute(message, args, guildConfig) {
// Check if connected to a voice channel
if (!voiceService.isConnected(message.guild.id)) {
return message.reply("If ya don't eat your meat, ya can't have any pudding!");
}
voiceService.leave(message.guild.id);
await message.react('👋');
}
};

View File

@@ -0,0 +1,19 @@
const config = require("../../config/config");
module.exports = {
name: "reboot",
description: "Reboot the bot (admin only)",
async execute(message, args, guildConfig) {
// Check if user is the bot admin
if (message.author.id !== config.discord.adminUserId) {
return;
}
await message.reply("Rebooting...");
console.log(`Reboot requested by ${message.author.username}`);
// Exit the process - requires a process manager like PM2 or Docker restart policy
process.exit(0);
},
};

View File

@@ -0,0 +1,74 @@
module.exports = {
name: 'role',
description: 'Add or remove allowed roles',
async execute(message, args, guildConfig) {
// Check if there are allowed roles configured
if (!guildConfig.allowedRolesForRequest || guildConfig.allowedRolesForRequest.length === 0) {
return message.reply('No roles are currently allowed to be added/removed by members.');
}
// Show usage if no arguments
if (args.length === 0) {
return message.reply(
`Usage: ${guildConfig.prefix}role {add|remove} {${guildConfig.allowedRolesForRequest}}`
);
}
const action = args[0]?.toLowerCase();
const roleName = args.slice(1).join(' ');
// Validate action
if (!['add', 'remove'].includes(action)) {
return message.reply(
`You must use add/remove after the role command! *e.g. ${guildConfig.prefix}role add <rolename>*`
);
}
// Validate role name
if (!roleName) {
return message.reply(
`Usage: ${guildConfig.prefix}role {add|remove} {${guildConfig.allowedRolesForRequest}}`
);
}
// Check if role is in the allowed list
const allowedRoles = guildConfig.allowedRolesForRequest.split('|');
const roleRegex = new RegExp(guildConfig.allowedRolesForRequest, 'i');
if (!roleRegex.test(roleName)) {
return message.reply(
`**${roleName}** is not a valid role name! The roles allowed for request are: ${allowedRoles.join(', ')}`
);
}
// Find the role in the guild (case-sensitive search)
const role = message.guild.roles.cache.find(r =>
r.name.toLowerCase() === roleName.toLowerCase()
);
if (!role) {
return message.reply(`${roleName} is not a role on this server!`);
}
try {
if (action === 'add') {
await message.member.roles.add(role, 'User requested');
await message.react('👍');
console.log(`Added role ${role.name} to ${message.author.username}`);
} else if (action === 'remove') {
await message.member.roles.remove(role, 'User requested');
await message.react('👍');
console.log(`Removed role ${role.name} from ${message.author.username}`);
}
} catch (error) {
console.error(`Error managing role ${role.name}:`, error);
await message.react('⚠️');
// Send error message if we can't react
if (!message.reactions.cache.has('⚠️')) {
await message.reply('I encountered an error managing that role. Make sure I have the proper permissions!');
}
}
}
};

View File

@@ -0,0 +1,87 @@
const axios = require('axios');
const { chunkSubstr } = require('../../utils/helpers');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
module.exports = {
name: 'sfx',
description: 'Play a sound effect',
async execute(message, args, guildConfig) {
// Check if SFX is allowed in this channel
if (guildConfig.allowedSfxChannels) {
const allowedChannels = new RegExp(guildConfig.allowedSfxChannels);
if (!allowedChannels.test(message.channel.name)) {
return;
}
}
const sfxName = args[0];
// Log the SFX command
if (sfxName) {
console.log(
`SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}`
);
}
// If no SFX specified, show the list
if (!sfxName) {
try {
const response = await axios.get('https://rentry.co/ghbotsfx/raw');
// Break into chunks if message is too long
let chunks = [response.data];
if (response.data.length > 2000) {
chunks = chunkSubstr(response.data, Math.ceil(response.data.length / 2));
}
for (const chunk of chunks) {
await message.channel.send(chunk);
}
} catch (error) {
console.error('Error fetching SFX list:', error);
await message.reply('Could not fetch the SFX list.');
}
return;
}
// Check if SFX exists
if (!sfxManager.hasSFX(sfxName)) {
return message.reply('This sound effect does not exist!');
}
// Check if user is in a voice channel
if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel to use this command!');
}
try {
// Join the voice channel
await voiceService.join(message.member.voice.channel);
// Get the SFX file path
const sfxPath = sfxManager.getSFXPath(sfxName);
// Play the sound effect
await voiceService.play(
message.guild.id,
sfxPath,
{
volume: guildConfig.sfxVolume || 0.5
}
);
// Leave the voice channel after playing
setTimeout(() => {
voiceService.leave(message.guild.id);
}, 500);
console.log(`✅ Successfully played SFX '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing SFX '${sfxName}':`, error);
await message.reply("I couldn't play that sound effect. Make sure I have permission to join your voice channel!");
}
}
};

View File

@@ -0,0 +1,77 @@
const fs = require('fs');
const path = require('path');
class SlashCommandHandler {
constructor() {
this.commands = new Map();
this.loadCommands();
}
/**
* Load all slash command modules
*/
loadCommands() {
const commandFiles = fs.readdirSync(__dirname)
.filter(file => file.endsWith('.js') && file !== 'index.js');
for (const file of commandFiles) {
const command = require(path.join(__dirname, file));
if (command.data?.name) {
this.commands.set(command.data.name, command);
}
}
console.log(`Loaded ${this.commands.size} slash commands`);
}
/**
* Get slash command definitions for registration
* @returns {Array}
*/
getSlashCommandDefinitions() {
return Array.from(this.commands.values()).map(cmd => cmd.data.toJSON());
}
/**
* Execute a slash command
* @param {string} commandName
* @param {CommandInteraction} interaction
* @param {Object} guildConfig
*/
async execute(commandName, interaction, guildConfig) {
const command = this.commands.get(commandName);
if (!command) {
return;
}
try {
await command.execute(interaction, guildConfig);
} catch (error) {
console.error(`Error executing slash command ${commandName}:`, error);
throw error;
}
}
/**
* Handle autocomplete interactions
* @param {AutocompleteInteraction} interaction
* @param {Object} guildConfig
*/
async handleAutocomplete(interaction, guildConfig) {
const command = this.commands.get(interaction.commandName);
if (!command || !command.autocomplete) {
return;
}
try {
await command.autocomplete(interaction, guildConfig);
} catch (error) {
console.error(`Error handling autocomplete for ${interaction.commandName}:`, error);
}
}
}
module.exports = new SlashCommandHandler();

108
src/commands/slash/sfx.js Normal file
View File

@@ -0,0 +1,108 @@
const { SlashCommandBuilder } = require('discord.js');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
module.exports = {
data: new SlashCommandBuilder()
.setName('sfx')
.setDescription('Play a sound effect')
.addStringOption(option =>
option.setName('sound')
.setDescription('The sound effect to play')
.setRequired(true)
.setAutocomplete(true)
),
async execute(interaction, guildConfig) {
// Check if SFX is allowed in this channel
if (guildConfig.allowedSfxChannels) {
const allowedChannels = new RegExp(guildConfig.allowedSfxChannels);
if (!allowedChannels.test(interaction.channel.name)) {
return interaction.reply({
content: 'Sound effects are not allowed in this channel!',
ephemeral: true
});
}
}
const sfxName = interaction.options.getString('sound');
// Log the slash command SFX request
console.log(
`/sfx '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}`
);
// Check if SFX exists
if (!sfxManager.hasSFX(sfxName)) {
return interaction.reply({
content: 'This sound effect does not exist!',
ephemeral: true
});
}
// Check if user is in a voice channel
const member = interaction.member;
if (!member.voice.channel) {
return interaction.reply({
content: 'You need to be in a voice channel to use this command!',
ephemeral: true
});
}
// Defer the reply as joining voice might take a moment
await interaction.deferReply();
try {
// Join the voice channel
await voiceService.join(member.voice.channel);
// Get the SFX file path
const sfxPath = sfxManager.getSFXPath(sfxName);
// Play the sound effect
await voiceService.play(
interaction.guild.id,
sfxPath,
{
volume: guildConfig.sfxVolume || 0.5
}
);
// Update the reply
await interaction.editReply(`Playing sound effect: **${sfxName}**`);
// Leave the voice channel after playing
setTimeout(() => {
voiceService.leave(interaction.guild.id);
}, 500);
console.log(`✅ Successfully played /sfx '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing /sfx '${sfxName}':`, error);
await interaction.editReply({
content: "I couldn't play that sound effect. Make sure I have permission to join your voice channel!"
});
}
},
async autocomplete(interaction, guildConfig) {
const focusedValue = interaction.options.getFocused().toLowerCase();
// Get all SFX names
const choices = sfxManager.getSFXNames();
// Filter based on what the user has typed
const filtered = choices
.filter(choice => choice.toLowerCase().includes(focusedValue))
.slice(0, 25); // Discord limits autocomplete to 25 choices
// Respond with the filtered choices
await interaction.respond(
filtered.map(choice => ({
name: choice,
value: choice
}))
);
}
};

26
src/config/config.js Normal file
View File

@@ -0,0 +1,26 @@
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");
}
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);
}
return config;
}
module.exports = validateConfig(config);

10
src/config/intents.js Normal file
View File

@@ -0,0 +1,10 @@
const { GatewayIntentBits } = require('discord.js');
module.exports = [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent, // Required for prefix commands
GatewayIntentBits.GuildMembers // Required for role management
// GatewayIntentBits.GuildPresences - Requires special permission in Discord Developer Portal
];

253
src/index.js Normal file
View File

@@ -0,0 +1,253 @@
const {
Client,
Events,
EmbedBuilder,
REST,
Routes,
ActivityType,
} = require("discord.js");
const { generateDependencyReport } = require("@discordjs/voice");
const intents = require("./config/intents");
const config = require("./config/config");
const { randElement } = require("./utils/helpers");
// Log audio dependencies status
console.log("Audio Dependencies Status:");
console.log(generateDependencyReport());
// Initialize Discord client
const client = new Client({ intents });
// Services
const commandLoader = require("./services/commandLoader");
const schedulerService = require("./services/schedulerService");
// Command handlers
const prefixCommands = require("./commands/prefix");
const slashCommands = require("./commands/slash");
// Activity rotation
let activityInterval;
/**
* Set a random activity for the bot
*/
function setRandomActivity() {
const activity =
config.discord.activities?.length > 0
? randElement(config.discord.activities)
: "DESTROY ALL HUMANS";
console.log(`Setting Discord activity to: ${activity}`);
client.user.setActivity(activity, {
url: "https://twitch.tv/fgfm",
type: ActivityType.Streaming,
});
}
/**
* Register slash commands
*/
async function registerSlashCommands() {
const rest = new REST({ version: "10" }).setToken(config.discord.token);
try {
console.log("Started refreshing application (/) commands.");
// Get all slash command definitions
const commands = slashCommands.getSlashCommandDefinitions();
// Register commands for each guild
for (const guild of config.discord.guilds) {
await rest.put(
Routes.applicationGuildCommands(client.user.id, guild.id),
{ body: commands }
);
console.log(
`Registered slash commands for guild: ${guild.internalName || guild.id}`
);
}
console.log("Successfully reloaded application (/) commands.");
} catch (error) {
console.error("Error registering slash commands:", error);
}
}
// Client ready event
client.once(Events.ClientReady, async () => {
console.log(`${config.botName} is connected and ready!`);
console.log(`Logged in as ${client.user.tag}`);
console.log(`Serving ${client.guilds.cache.size} guild(s)`);
// Set initial activity
setRandomActivity();
// Rotate activity every hour
activityInterval = setInterval(() => {
setRandomActivity();
}, 3600 * 1000);
// Register slash commands
await registerSlashCommands();
// Initialize scheduled events
schedulerService.initialize(client, config);
});
// Message handler for prefix commands
client.on(Events.MessageCreate, async (message) => {
// Ignore bot messages
if (message.author.bot) return;
// 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
);
if (!guildConfig) return;
// Check blacklist
if (config.discord.blacklistedUsers?.includes(message.author.id)) return;
// Check for command prefix
if (!message.content.startsWith(guildConfig.prefix)) return;
// Parse command
const args = message.content
.slice(guildConfig.prefix.length)
.trim()
.split(/ +/);
const commandName = args.shift().toLowerCase();
console.log(
`Command '${commandName}' received in ${
guildConfig.internalName || message.guild.name
}#${message.channel.name} from @${message.author.username}`
);
try {
// Check for prefix commands
if (prefixCommands.has(commandName)) {
await prefixCommands.execute(commandName, message, args, guildConfig);
return;
}
// Check for static commands
if (commandLoader.hasStaticCommand(commandName)) {
const response = commandLoader.getStaticCommand(commandName);
const embed = new EmbedBuilder()
.setTitle(commandName)
.setColor(0x21c629)
.setDescription(response);
await message.channel.send({ embeds: [embed] });
return;
}
// Check for Ankhbot commands
if (commandLoader.hasAnkhbotCommand(commandName)) {
const response = commandLoader.getAnkhbotCommand(commandName);
const embed = new EmbedBuilder()
.setTitle(commandName)
.setColor(0x21c629)
.setDescription(response);
await message.channel.send({ embeds: [embed] });
return;
}
// Command not found - ignore silently
} catch (error) {
console.error(`Error executing command ${commandName}:`, error);
message
.reply("There was an error executing that command!")
.catch(console.error);
}
});
// Interaction handler for slash commands
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
);
if (!guildConfig) return;
try {
if (interaction.isAutocomplete()) {
await slashCommands.handleAutocomplete(interaction, guildConfig);
} else if (interaction.isChatInputCommand()) {
await slashCommands.execute(
interaction.commandName,
interaction,
guildConfig
);
}
} catch (error) {
console.error("Error handling interaction:", error);
if (interaction.isChatInputCommand() && !interaction.replied) {
await interaction
.reply({
content: "There was an error executing this command!",
ephemeral: true,
})
.catch(console.error);
}
}
});
// 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
);
if (!guildConfig) return;
console.log(
`A new member has joined '${member.guild.name}': ${member.displayName}`
);
});
// Handle guild becoming unavailable
client.on(Events.GuildUnavailable, (guild) => {
console.log(
`Guild '${guild.name}' is no longer available! Most likely due to server outage.`
);
});
// Debug logging
client.on("debug", (info) => {
if (config.debug === true) {
console.log(`[${new Date().toISOString()}] DEBUG: ${info}`);
}
});
// Error handling
client.on("error", console.error);
// Process error handling
process.on("unhandledRejection", console.error);
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("SIGTERM signal received, shutting down gracefully...");
if (activityInterval) {
clearInterval(activityInterval);
}
client.destroy();
process.exit(0);
});
// Login to Discord
client.login(config.discord.token);

View File

@@ -0,0 +1,166 @@
const fs = require('fs');
const path = require('path');
class CommandLoader {
constructor() {
this.staticCommands = {};
this.ankhbotCommands = {};
// Paths to command files
this.staticCommandsPath = path.join(__dirname, '..', '..', 'conf', 'text_commands');
this.ankhbotCommandsPath = path.join(__dirname, '..', '..', 'conf', 'ghbot.abcomg');
// Load commands initially
this.loadStaticCommands();
this.loadAnkhbotCommands();
// Watch for changes
this.watchFiles();
}
/**
* Load static text commands from file
*/
loadStaticCommands() {
try {
if (!fs.existsSync(this.staticCommandsPath)) {
console.log('Static commands file not found, skipping...');
return;
}
const data = fs.readFileSync(this.staticCommandsPath, 'utf-8');
const lines = data.toString().split('\n');
const commands = {};
lines.forEach(line => {
if (line.length > 0 && line.indexOf('|') !== -1) {
const parts = line.split('|');
// Check for aliases (comma-separated)
const aliases = parts[0].split(',');
aliases.forEach(cmd => {
commands[cmd.trim()] = parts[1];
});
}
});
this.staticCommands = commands;
console.log(`Loaded ${Object.keys(commands).length} static commands`);
} catch (error) {
console.error('Error loading static commands:', error);
}
}
/**
* Load Ankhbot commands from file
*/
loadAnkhbotCommands() {
try {
if (!fs.existsSync(this.ankhbotCommandsPath)) {
console.log('Ankhbot commands file not found, skipping...');
return;
}
const data = fs.readFileSync(this.ankhbotCommandsPath, 'utf-8');
// Try to parse as JSON first, fall back to eval if needed (for legacy format)
let commands;
try {
commands = JSON.parse(data);
} catch {
// Legacy format might use JavaScript object notation
// Create a safer evaluation context
const sandbox = { commands: null };
const script = `commands = ${data}`;
try {
// Use Function constructor for safer eval
new Function('commands', script).call(sandbox, sandbox);
commands = sandbox.commands;
} catch (e) {
console.error('Failed to parse Ankhbot commands:', e);
return;
}
}
// Convert to a map for easier lookup
const commandMap = {};
if (Array.isArray(commands)) {
commands.forEach(cmd => {
if (cmd.Enabled === true && cmd.Command && cmd.Response) {
// Remove prefix from command name for storage
const cmdName = cmd.Command.startsWith('!') ?
cmd.Command.substring(1) : cmd.Command;
commandMap[cmdName] = cmd.Response;
}
});
}
this.ankhbotCommands = commandMap;
console.log(`Loaded ${Object.keys(commandMap).length} Ankhbot commands`);
} catch (error) {
console.error('Error loading Ankhbot commands:', error);
}
}
/**
* Watch command files for changes
*/
watchFiles() {
// Watch static commands file
if (fs.existsSync(this.staticCommandsPath)) {
fs.watchFile(this.staticCommandsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Static commands file changed, reloading...');
this.loadStaticCommands();
}
});
}
// Watch Ankhbot commands file
if (fs.existsSync(this.ankhbotCommandsPath)) {
fs.watchFile(this.ankhbotCommandsPath, (curr, prev) => {
if (curr.mtime !== prev.mtime) {
console.log('Ankhbot commands file changed, reloading...');
this.loadAnkhbotCommands();
}
});
}
}
/**
* Check if a static command exists
* @param {string} command
* @returns {boolean}
*/
hasStaticCommand(command) {
return this.staticCommands.hasOwnProperty(command);
}
/**
* Get a static command response
* @param {string} command
* @returns {string|undefined}
*/
getStaticCommand(command) {
return this.staticCommands[command];
}
/**
* Check if an Ankhbot command exists
* @param {string} command
* @returns {boolean}
*/
hasAnkhbotCommand(command) {
return this.ankhbotCommands.hasOwnProperty(command);
}
/**
* Get an Ankhbot command response
* @param {string} command
* @returns {string|undefined}
*/
getAnkhbotCommand(command) {
return this.ankhbotCommands[command];
}
}
module.exports = new CommandLoader();

View File

@@ -0,0 +1,157 @@
const schedule = require('node-schedule');
class SchedulerService {
constructor() {
this.jobs = new Map();
}
/**
* Initialize scheduled events for all guilds
* @param {Client} client
* @param {Object} config
*/
async initialize(client, config) {
console.log('Initializing scheduled events...');
for (const guildConfig of config.discord.guilds) {
try {
const guild = await client.guilds.fetch(guildConfig.id);
if (!guild) {
console.error(`Could not find guild ${guildConfig.id}`);
continue;
}
if (!guildConfig.scheduledEvents || guildConfig.scheduledEvents.length === 0) {
continue;
}
for (const event of guildConfig.scheduledEvents) {
await this.scheduleEvent(guild, event, guildConfig);
}
} catch (error) {
console.error(`Error setting up scheduled events for guild ${guildConfig.id}:`, error);
}
}
}
/**
* Schedule a single event
* @param {Guild} guild
* @param {Object} event
* @param {Object} guildConfig
*/
async scheduleEvent(guild, event, guildConfig) {
try {
// Validate channel
let channel = null;
if (event.channelId) {
channel = await guild.channels.fetch(event.channelId);
if (!channel) {
console.error(`Invalid channel ${event.channelId} for event ${event.id} in guild ${guild.name}`);
return;
}
}
// Validate role
let pingRole = null;
if (event.pingRoleId) {
pingRole = await guild.roles.fetch(event.pingRoleId);
if (!pingRole) {
console.warn(`Invalid role ${event.pingRoleId} for event ${event.id} in guild ${guild.name}`);
}
}
console.log(`Scheduling event ${event.id} for ${guild.name}...`);
// Create the scheduled job
const job = schedule.scheduleJob(event.schedule, () => {
this.executeEvent(channel, event, pingRole);
});
if (job) {
// Store job reference
const jobKey = `${guild.id}-${event.id}`;
this.jobs.set(jobKey, job);
console.log(`Event ${event.id} scheduled. Next invocation: ${job.nextInvocation()}`);
} else {
console.error(`Failed to schedule event ${event.id} - invalid cron expression: ${event.schedule}`);
}
} catch (error) {
console.error(`Error scheduling event ${event.id}:`, error);
}
}
/**
* Execute a scheduled event
* @param {TextChannel} channel
* @param {Object} event
* @param {Role} pingRole
*/
async executeEvent(channel, event, pingRole) {
try {
const content = [];
// Add role ping if configured
if (pingRole) {
content.push(pingRole.toString());
}
// Add message if configured
if (event.message) {
content.push(event.message);
}
// Send the message
if (content.length > 0 && channel) {
await channel.send(content.join(' '));
console.log(`Executed scheduled event ${event.id}`);
}
} catch (error) {
console.error(`Error executing scheduled event ${event.id}:`, error);
}
}
/**
* Cancel a scheduled job
* @param {string} guildId
* @param {string} eventId
*/
cancelJob(guildId, eventId) {
const jobKey = `${guildId}-${eventId}`;
const job = this.jobs.get(jobKey);
if (job) {
job.cancel();
this.jobs.delete(jobKey);
console.log(`Cancelled scheduled event ${eventId} for guild ${guildId}`);
}
}
/**
* Cancel all jobs for a guild
* @param {string} guildId
*/
cancelGuildJobs(guildId) {
for (const [key, job] of this.jobs) {
if (key.startsWith(`${guildId}-`)) {
job.cancel();
this.jobs.delete(key);
}
}
console.log(`Cancelled all scheduled events for guild ${guildId}`);
}
/**
* Cancel all jobs
*/
cancelAllJobs() {
for (const job of this.jobs.values()) {
job.cancel();
}
this.jobs.clear();
console.log('Cancelled all scheduled events');
}
}
module.exports = new SchedulerService();

113
src/services/sfxManager.js Normal file
View File

@@ -0,0 +1,113 @@
const fs = require('fs');
const path = require('path');
class SFXManager {
constructor() {
this.sfxPath = path.join(__dirname, '..', '..', 'sfx');
this.sfxList = [];
// Load SFX list initially
this.loadSFXList();
// Watch for changes
this.watchSFXDirectory();
}
/**
* Load the list of available SFX files
*/
loadSFXList() {
try {
if (!fs.existsSync(this.sfxPath)) {
console.log('SFX directory not found, creating...');
fs.mkdirSync(this.sfxPath, { recursive: true });
}
const files = fs.readdirSync(this.sfxPath);
this.sfxList = files
.filter(file => file.endsWith('.mp3') || file.endsWith('.wav'))
.map(file => {
const ext = path.extname(file);
return {
name: file.replace(ext, ''),
filename: file,
path: path.join(this.sfxPath, file)
};
});
console.log(`Loaded ${this.sfxList.length} sound effects`);
} catch (error) {
console.error('Error loading SFX list:', error);
}
}
/**
* Watch the SFX directory for changes
*/
watchSFXDirectory() {
fs.watch(this.sfxPath, (eventType, filename) => {
if (eventType === 'rename') {
console.log('SFX directory changed, reloading...');
this.loadSFXList();
}
});
}
/**
* Get all available SFX
* @returns {Array} List of SFX objects
*/
getAllSFX() {
return this.sfxList;
}
/**
* Get SFX names for autocomplete
* @returns {Array} List of SFX names
*/
getSFXNames() {
return this.sfxList.map(sfx => sfx.name);
}
/**
* Find an SFX by name
* @param {string} name
* @returns {Object|undefined} SFX object or undefined
*/
findSFX(name) {
return this.sfxList.find(sfx => sfx.name.toLowerCase() === name.toLowerCase());
}
/**
* Check if an SFX exists
* @param {string} name
* @returns {boolean}
*/
hasSFX(name) {
return this.findSFX(name) !== undefined;
}
/**
* Get the file path for an SFX
* @param {string} name
* @returns {string|null}
*/
getSFXPath(name) {
const sfx = this.findSFX(name);
return sfx ? sfx.path : null;
}
/**
* Search SFX names (for autocomplete)
* @param {string} query
* @returns {Array} Matching SFX names
*/
searchSFX(query) {
const lowerQuery = query.toLowerCase();
return this.sfxList
.filter(sfx => sfx.name.toLowerCase().includes(lowerQuery))
.map(sfx => sfx.name);
}
}
module.exports = new SFXManager();

View File

@@ -0,0 +1,171 @@
const {
createAudioPlayer,
createAudioResource,
joinVoiceChannel,
VoiceConnectionStatus,
AudioPlayerStatus,
entersState,
getVoiceConnection,
generateDependencyReport
} = require('@discordjs/voice');
const { ChannelType } = require('discord.js');
// Try to use ffmpeg-static as fallback if system ffmpeg is not available
try {
const ffmpegPath = require('ffmpeg-static');
if (ffmpegPath && !process.env.FFMPEG_PATH) {
process.env.FFMPEG_PATH = ffmpegPath;
}
} catch (error) {
// ffmpeg-static not available, rely on system ffmpeg
}
class VoiceService {
constructor() {
this.connections = new Map();
this.players = new Map();
}
/**
* Join a voice channel
* @param {VoiceChannel} channel
* @returns {VoiceConnection}
*/
async join(channel) {
if (!channel || channel.type !== ChannelType.GuildVoice) {
throw new Error('Invalid voice channel');
}
// Check if already connected
let connection = getVoiceConnection(channel.guild.id);
if (!connection) {
connection = joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
});
// Wait for connection to be ready
try {
await entersState(connection, VoiceConnectionStatus.Ready, 10_000);
} catch (error) {
connection.destroy();
throw error;
}
// Store connection
this.connections.set(channel.guild.id, connection);
// Handle disconnection
connection.on(VoiceConnectionStatus.Disconnected, async () => {
try {
// Try to reconnect
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch (error) {
// Seems to be a real disconnect, destroy the connection
connection.destroy();
this.connections.delete(channel.guild.id);
this.players.delete(channel.guild.id);
}
});
}
return connection;
}
/**
* Leave a voice channel
* @param {string} guildId
*/
leave(guildId) {
const connection = this.connections.get(guildId);
if (connection) {
connection.destroy();
this.connections.delete(guildId);
this.players.delete(guildId);
}
}
/**
* Play an audio file
* @param {string} guildId
* @param {string} filePath
* @param {Object} options
* @returns {AudioPlayer}
*/
async play(guildId, filePath, options = {}) {
const connection = this.connections.get(guildId);
if (!connection) {
throw new Error('Not connected to voice channel');
}
// Create or get player for this guild
let player = this.players.get(guildId);
if (!player) {
player = createAudioPlayer();
this.players.set(guildId, player);
}
// Create audio resource with options
const resource = createAudioResource(filePath, {
inlineVolume: options.volume !== undefined,
});
if (options.volume !== undefined && resource.volume) {
resource.volume.setVolume(options.volume);
}
// Subscribe the connection to the player
connection.subscribe(player);
// Play the resource
player.play(resource);
// Return a promise that resolves when playback finishes
return new Promise((resolve, reject) => {
player.once(AudioPlayerStatus.Idle, () => {
resolve();
});
player.once('error', (error) => {
console.error('Player error:', error);
reject(error);
});
});
}
/**
* Stop playing audio
* @param {string} guildId
*/
stop(guildId) {
const player = this.players.get(guildId);
if (player) {
player.stop();
}
}
/**
* Check if connected to a voice channel
* @param {string} guildId
* @returns {boolean}
*/
isConnected(guildId) {
return this.connections.has(guildId);
}
/**
* Get the current voice connection
* @param {string} guildId
* @returns {VoiceConnection|undefined}
*/
getConnection(guildId) {
return this.connections.get(guildId);
}
}
module.exports = new VoiceService();

70
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* Pick a random element from an array
* @param {Array} arr
* @returns {*} Random element from the array
*/
function randElement(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* Random sort function
* @returns {number}
*/
function randSort() {
return 0.5 - Math.random();
}
/**
* Split a string into chunks of specified size
* @param {string} str
* @param {number} size
* @returns {string[]}
*/
function chunkSubstr(str, size) {
const numChunks = Math.ceil(str.length / size);
const chunks = new Array(numChunks);
for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
chunks[i] = str.substr(o, size);
}
return chunks;
}
/**
* Async forEach implementation
* @param {Array} array
* @param {Function} callback
*/
async function asyncForEach(array, callback) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
/**
* Converts seconds to human-readable time
* @param {number} seconds
* @returns {string} HH:MM:SS format
*/
function toHHMMSS(seconds) {
const sec_num = parseInt(seconds, 10);
let hours = Math.floor(sec_num / 3600);
let minutes = Math.floor((sec_num - hours * 3600) / 60);
let secs = sec_num - hours * 3600 - minutes * 60;
if (hours < 10) hours = "0" + hours;
if (minutes < 10) minutes = "0" + minutes;
if (secs < 10) secs = "0" + secs;
return hours + ":" + minutes + ":" + secs;
}
module.exports = {
randElement,
randSort,
chunkSubstr,
asyncForEach,
toHHMMSS
};