diff --git a/.replit b/.replit
new file mode 100644
index 0000000000..ac8cf0171f
--- /dev/null
+++ b/.replit
@@ -0,0 +1,49 @@
+language = "nodejs"
+run = "npm start"
+modules = ["nodejs-18", "postgresql-16"]
+
+[nix]
+channel = "stable-25_05"
+
+[userenv]
+
+[userenv.shared]
+AUTO_MIGRATE = "true"
+CORS_ORIGIN = "*"
+LOG_LEVEL = "info"
+LOG_TO_FILE = "false"
+MULTI_GUILD = "false"
+NODE_ENV = "development"
+PORT = "3000"
+WEB_HOST = "0.0.0.0"
+LAVALINK_HOST = "lavalinkv4.serenetia.com"
+LAVALINK_PORT = "443"
+LAVALINK_SEARCH_PLATFORM = "ytmsearch"
+LAVALINK_SECURE = "true"
+
+[workflows]
+runButton = "Project"
+
+[[workflows.workflow]]
+name = "Project"
+mode = "parallel"
+author = "agent"
+
+[[workflows.workflow.tasks]]
+task = "workflow.run"
+args = "Start application"
+
+[[workflows.workflow]]
+name = "Start application"
+author = "agent"
+
+[[workflows.workflow.tasks]]
+task = "shell.exec"
+args = "npm start"
+
+[workflows.workflow.metadata]
+outputType = "console"
+
+[[ports]]
+localPort = 3000
+externalPort = 80
diff --git a/README.md b/README.md
index ba20e54d99..124a451362 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,24 @@
- [Required Bot Intents](#bot-intents)
- [Contributing](CONTRIBUTING.md)
+### Replit Deployment
+
+If you use Replit to host the bot, do NOT commit your bot token to the repository. Instead set secrets in Replit:
+
+- Add `DISCORD_TOKEN` = your bot token
+- Add `CLIENT_ID` = your application (client) id
+- Add `GUILD_ID` = your test server id (optional)
+
+Steps:
+
+1. Open the Replit project for this repository.
+2. Go to the Secrets / Environment variables panel (often in the Tools sidebar).
+3. Add the variables above (paste the bot token exactly, no quotes or trailing spaces).
+4. Restart the Replit run.
+
+This repository includes a `.replit` file that runs `npm start`, which executes `node src/app.js`.
+
+
## Features Overview
diff --git a/package-lock.json b/package-lock.json
index 2903a8a07d..1dab1e7561 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "2.1.0",
"dependencies": {
"@discordjs/rest": "^2.6.1",
+ "@napi-rs/canvas": "^1.0.2",
"axios": "^1.15.2",
"discord.js": "^14.26.4",
"dotenv": "^17.2.3",
@@ -262,6 +263,255 @@
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas/-/canvas-1.0.2.tgz",
+ "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==",
+ "license": "MIT",
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "1.0.2",
+ "@napi-rs/canvas-darwin-arm64": "1.0.2",
+ "@napi-rs/canvas-darwin-x64": "1.0.2",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.2",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-x64-musl": "1.0.2",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.2",
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.2"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz",
+ "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz",
+ "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz",
+ "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz",
+ "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz",
+ "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz",
+ "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz",
+ "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz",
+ "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz",
+ "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz",
+ "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz",
+ "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
@@ -2195,6 +2445,90 @@
"ws": "^8.17.0"
}
},
+ "@napi-rs/canvas": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas/-/canvas-1.0.2.tgz",
+ "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==",
+ "requires": {
+ "@napi-rs/canvas-android-arm64": "1.0.2",
+ "@napi-rs/canvas-darwin-arm64": "1.0.2",
+ "@napi-rs/canvas-darwin-x64": "1.0.2",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.2",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.2",
+ "@napi-rs/canvas-linux-x64-musl": "1.0.2",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.2",
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.2"
+ }
+ },
+ "@napi-rs/canvas-android-arm64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz",
+ "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==",
+ "optional": true
+ },
+ "@napi-rs/canvas-darwin-arm64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz",
+ "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==",
+ "optional": true
+ },
+ "@napi-rs/canvas-darwin-x64": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz",
+ "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz",
+ "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz",
+ "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz",
+ "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz",
+ "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz",
+ "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==",
+ "optional": true
+ },
+ "@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz",
+ "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==",
+ "optional": true
+ },
+ "@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz",
+ "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==",
+ "optional": true
+ },
+ "@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.0.2",
+ "resolved": "http://package-firewall.replit.local/npm/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz",
+ "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==",
+ "optional": true
+ },
"@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
diff --git a/package.json b/package.json
index fe61ec9332..e413a24577 100644
--- a/package.json
+++ b/package.json
@@ -16,16 +16,17 @@
},
"dependencies": {
"@discordjs/rest": "^2.6.1",
+ "@napi-rs/canvas": "^1.0.2",
"axios": "^1.15.2",
"discord.js": "^14.26.4",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"node-cron": "^4.2.1",
"pg": "^8.11.3",
+ "riffy": "1.0.12",
"winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0",
- "zod": "^3.25.76",
- "riffy": "1.0.12"
+ "zod": "^3.25.76"
},
"engines": {
"node": ">=18.0.0"
diff --git a/replit.md b/replit.md
new file mode 100644
index 0000000000..46f519baba
--- /dev/null
+++ b/replit.md
@@ -0,0 +1,45 @@
+# TitanBot
+
+A feature-rich Discord bot built with Discord.js v14 and PostgreSQL. Includes moderation, economy, leveling, giveaways, tickets, music (Lavalink), and more.
+
+## How to run
+
+The `Start application` workflow runs `npm start` → `node src/app.js`.
+
+## Required secrets (set in Replit Secrets)
+
+| Secret | Description |
+|---|---|
+| `DISCORD_TOKEN` | Bot token from Discord Developer Portal → Bot tab |
+| `CLIENT_ID` | Application/client ID from OAuth2 tab |
+| `GUILD_ID` | Test server ID for single-guild slash command registration |
+
+## Environment variables (set in Replit)
+
+| Variable | Value |
+|---|---|
+| `NODE_ENV` | `development` — avoids production-only PostgreSQL validation checks; bot still uses Replit's managed `DATABASE_URL` |
+| `PORT` | `3000` — Express health/ready endpoints |
+| `AUTO_MIGRATE` | `true` — runs DB migrations on startup |
+| `MULTI_GUILD` | `false` — single-server slash command registration |
+
+## Database
+
+Uses Replit's built-in PostgreSQL (connection string auto-provided via `DATABASE_URL`). Migrations run automatically on startup via `AUTO_MIGRATE=true`. Tables are created on first run.
+
+## Music (Lavalink)
+
+The music system requires a separate Lavalink v4 server. Without one the bot logs a disconnection warning but all other features work normally. See `docker-compose.yml` for local Lavalink setup or configure `LAVALINK_HOST/PORT/PASSWORD` to point to an external node.
+
+## Useful commands
+
+```bash
+npm start # start the bot
+npm run migrate # apply DB migrations manually
+npm run migrate:status # check migration status
+npm test # run tests
+```
+
+## User preferences
+
+- Keep `NODE_ENV=development` on Replit (avoids the production PostgreSQL env var check that conflicts with Replit's PG* naming convention).
diff --git a/src/app.js b/src/app.js
index 758230f540..67b6b51c82 100644
--- a/src/app.js
+++ b/src/app.js
@@ -85,6 +85,18 @@ class TitanBot extends Client {
initializeMusic(this);
+ const token = this.config.bot.token;
+ if (token) {
+ const looksNumeric = /^\d+$/.test(token);
+ if (looksNumeric) {
+ logger.warn('DISCORD_TOKEN appears to be numeric — this looks like an Application/Client ID, not a bot token.');
+ }
+ const masked = token.length > 12 ? `${token.slice(0,6)}...${token.slice(-6)}` : '********';
+ logger.info(`Discord token present: ${masked}`);
+ } else {
+ logger.warn('Discord token not found in environment (DISCORD_TOKEN or TOKEN).');
+ }
+
startupLog('Logging into Discord...');
await this.login(this.config.bot.token);
startupLog('Discord login successful');
diff --git a/src/assets/welcome_bg.png b/src/assets/welcome_bg.png
new file mode 100644
index 0000000000..dcdd258f71
Binary files /dev/null and b/src/assets/welcome_bg.png differ
diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js
index 8bb4be1163..8b982ded67 100644
--- a/src/events/guildMemberAdd.js
+++ b/src/events/guildMemberAdd.js
@@ -1,8 +1,9 @@
-import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js';
+import { Events, EmbedBuilder, AttachmentBuilder, PermissionFlagsBits } from 'discord.js';
import { getColor } from '../config/bot.js';
import { getGuildConfig } from '../services/guildConfig.js';
import { getWelcomeConfig } from '../utils/database.js';
import { formatWelcomeMessage } from '../utils/welcome.js';
+import { generateWelcomeCard } from '../utils/welcomeCard.js';
import { logEvent, EVENT_TYPES } from '../services/loggingService.js';
import { getServerCounters, updateCounter } from '../services/serverstatsService.js';
import { setBirthday as dbSetBirthday } from '../utils/database.js';
@@ -37,45 +38,33 @@ export default {
formatData
);
- const messageContent = welcomeConfig.welcomePing ? user.toString() : null;
+ // Welcome text content — use configured message or default
+ const textContent = `Hey ${user.toString()}, welcome to **${guild.name}**!`;
- const embedTitle = formatWelcomeMessage(
- welcomeConfig.welcomeEmbed?.title || '🎉 Welcome!',
- formatData
- );
- const embedFooter = welcomeConfig.welcomeEmbed?.footer
- ? formatWelcomeMessage(welcomeConfig.welcomeEmbed.footer, formatData)
- : `Welcome to ${guild.name}!`;
+ // Try to generate the custom welcome card image
+ const avatarUrl = user.displayAvatarURL({ extension: 'png', size: 256 });
+ const cardBuffer = await generateWelcomeCard(avatarUrl, user.displayName || user.username, guild.memberCount);
- const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks);
-
- if (!canEmbed) {
+ if (cardBuffer) {
+ const attachment = new AttachmentBuilder(cardBuffer, { name: 'welcome.png' });
await channel.send({
- content: messageContent || welcomeMessage
+ content: textContent,
+ files: [attachment],
});
} else {
- const embed = new EmbedBuilder()
- .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success'))
- .setTitle(embedTitle)
- .setDescription(welcomeMessage)
- .setThumbnail(user.displayAvatarURL())
- .addFields(
- { name: 'User', value: `${user.tag} (${user.id})`, inline: true },
- { name: 'Member Count', value: guild.memberCount.toString(), inline: true }
- )
- .setTimestamp()
- .setFooter({ text: embedFooter });
-
- if (welcomeConfig.welcomeImage) {
- embed.setImage(welcomeConfig.welcomeImage);
- } else if (welcomeConfig.welcomeEmbed?.image?.url) {
- embed.setImage(welcomeConfig.welcomeEmbed.image.url);
+ // Fallback to plain embed if card generation failed
+ const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks);
+ if (canEmbed) {
+ const embed = new EmbedBuilder()
+ .setColor(welcomeConfig.welcomeEmbed?.color || getColor('success'))
+ .setTitle('🎉 Welcome!')
+ .setDescription(welcomeMessage)
+ .setThumbnail(user.displayAvatarURL())
+ .setTimestamp();
+ await channel.send({ content: textContent, embeds: [embed] });
+ } else {
+ await channel.send({ content: textContent });
}
-
- await channel.send({
- content: messageContent,
- embeds: [embed]
- });
}
}
}
diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js
index c76c1e123f..85dcfd4a68 100644
--- a/src/events/voiceStateUpdate.js
+++ b/src/events/voiceStateUpdate.js
@@ -9,6 +9,7 @@ import {
import { sanitizeInput } from '../utils/validation.js';
import { logger } from '../utils/logger.js';
import { handleMusicVoiceState } from '../services/music/musicVoiceState.js';
+import { logEvent } from '../services/loggingService.js';
const channelCreationCooldown = new Map();
const VOICE_CREATE_COOLDOWN_MS = 2000;
@@ -25,10 +26,71 @@ export default {
if (newState.member.user.bot) return;
const guildId = newState.guild.id;
- const userId = newState.member.id;
+ const member = newState.member || oldState.member;
+ const userId = member.id;
const cooldownKey = `${guildId}-${userId}`;
cleanupCooldownEntries();
+ const thumbnail = member.user.displayAvatarURL({ dynamic: true, size: 256 });
+ const userLine = `**User:** <@${member.id}> (${member.user.username})`;
+
+ // General voice logging — fires for all channels regardless of join-to-create
+ try {
+ if (!oldState.channel && newState.channel) {
+ // Joined a voice channel
+ await logEvent({
+ client,
+ guildId,
+ eventType: 'member.voice.join',
+ data: {
+ title: 'Joined Voice',
+ lines: [
+ userLine,
+ `**Channel:** <#${newState.channel.id}> — ${newState.channel.name}`,
+ ],
+ thumbnail,
+ footer: { text: 'Powered by HACKYI' },
+ },
+ });
+ } else if (oldState.channel && !newState.channel) {
+ // Left a voice channel
+ await logEvent({
+ client,
+ guildId,
+ eventType: 'member.voice.leave',
+ data: {
+ title: 'Left Voice',
+ lines: [
+ userLine,
+ `**Channel:** <#${oldState.channel.id}> — ${oldState.channel.name}`,
+ ],
+ thumbnail,
+ footer: { text: 'Powered by HACKYI' },
+ },
+ });
+ } else if (oldState.channel && newState.channel && oldState.channel.id !== newState.channel.id) {
+ // Moved between voice channels
+ await logEvent({
+ client,
+ guildId,
+ eventType: 'member.voice.move',
+ data: {
+ title: 'Moved Voice Channel',
+ lines: [
+ userLine,
+ `**From:** <#${oldState.channel.id}> — ${oldState.channel.name}`,
+ `**To:** <#${newState.channel.id}> — ${newState.channel.name}`,
+ ],
+ thumbnail,
+ footer: { text: 'Powered by HACKYI' },
+ },
+ });
+ }
+ } catch (err) {
+ logger.debug('Failed to send general voice log event:', err);
+ }
+
+ // Join-to-create logic — only runs when the feature is configured
try {
const config = await getJoinToCreateConfig(client, guildId);
@@ -223,6 +285,24 @@ userLimit: userLimit === 0 ? undefined : userLimit,
}
logger.info(`Created temporary voice channel ${tempChannel.name} (${tempChannel.id}) for user ${member.user.tag} in guild ${guild.name} with user limit ${userLimit}`);
+ try {
+ await logEvent({
+ client,
+ guildId: guild.id,
+ eventType: 'member.voice.join',
+ data: {
+ title: 'Voice Channel Created / Joined',
+ lines: [
+ `**User:** <@${member.id}>`,
+ `**Channel:** <#${tempChannel.id}>`,
+ ],
+ footer: { text: 'Powered by HACKYI' },
+ timestamp: true,
+ },
+ });
+ } catch (err) {
+ logger.debug('Failed to send voice join log event:', err);
+ }
} catch (error) {
logger.error(`Failed to create temporary channel for user ${member.user.tag} in guild ${guild.name}:`, error);
@@ -246,6 +326,23 @@ userLimit: userLimit === 0 ? undefined : userLimit,
await channel.delete('Temporary voice channel - empty');
logger.info(`Deleted temporary voice channel ${channel.name} (${channel.id}) in guild ${channel.guild.name}`);
+ try {
+ await logEvent({
+ client,
+ guildId: guild.id,
+ eventType: 'member.voice.leave',
+ data: {
+ title: 'Voice Channel Deleted / Left',
+ lines: [
+ `**Channel:** ${channel.name} (${channel.id})`,
+ ],
+ footer: { text: 'Powered by HACKYI' },
+ timestamp: true,
+ },
+ });
+ } catch (err) {
+ logger.debug('Failed to send voice leave log event:', err);
+ }
} catch (error) {
logger.error(`Failed to delete temporary channel ${channel.id}:`, error);
diff --git a/src/services/loggingService.js b/src/services/loggingService.js
index 29c2a69a25..bc4e64f434 100644
--- a/src/services/loggingService.js
+++ b/src/services/loggingService.js
@@ -41,6 +41,9 @@ const EVENT_TYPES = {
MEMBER_JOIN: 'member.join',
MEMBER_LEAVE: 'member.leave',
MEMBER_NAME_CHANGE: 'member.namechange',
+ MEMBER_VOICE_JOIN: 'member.voice.join',
+ MEMBER_VOICE_LEAVE: 'member.voice.leave',
+ MEMBER_VOICE_MOVE: 'member.voice.move',
REACTION_ROLE_ADD: 'reactionrole.add',
REACTION_ROLE_REMOVE: 'reactionrole.remove',
@@ -86,6 +89,9 @@ const EVENT_COLORS = {
'member.join': 0x2ecc71,
'member.leave': 0xe74c3c,
'member.namechange': 0x3498db,
+ 'member.voice.join': 0x00B0F4,
+ 'member.voice.leave': 0x747F8D,
+ 'member.voice.move': 0x5865F2,
'reactionrole.add': 0x2ecc71,
'reactionrole.remove': 0xe74c3c,
'reactionrole.create': 0x3498db,
@@ -126,6 +132,9 @@ const EVENT_ICONS = {
'member.join': '👋',
'member.leave': '👋',
'member.namechange': '🏷️',
+ 'member.voice.join': '🎤',
+ 'member.voice.leave': '🔇',
+ 'member.voice.move': '↪️',
'reactionrole.add': '✅',
'reactionrole.remove': '❌',
'reactionrole.create': '🎭',
@@ -325,6 +334,8 @@ function createLogEmbed(guild, eventType, data) {
inlineFields.push(...data.inlineFields);
}
+ const timestamp = data.timestamp !== undefined ? data.timestamp : true;
+
return buildStandardLogEmbed({
color,
title,
@@ -333,7 +344,7 @@ function createLogEmbed(guild, eventType, data) {
inlineFields,
fields: data.blockFields || [],
author: data.author || null,
- timestamp: true,
+ timestamp,
footer: data.footer || { text: guild.name, iconURL: guild.iconURL({ dynamic: true }) || undefined },
});
}
diff --git a/src/services/music/musicActions.js b/src/services/music/musicActions.js
index 0a522aabb1..8dc8e44b27 100644
--- a/src/services/music/musicActions.js
+++ b/src/services/music/musicActions.js
@@ -118,14 +118,6 @@ export async function joinVoiceChannel(client, interaction) {
}
export async function playQuery(client, interaction, query) {
- if (YOUTUBE_URL_PATTERN.test(query)) {
- throw new TitanBotError(
- 'YouTube URL blocked',
- ErrorTypes.USER_INPUT,
- 'YouTube links are not supported. Try a song name instead.',
- );
- }
-
const { player, guildData } = await ensurePlayer(client, interaction);
const result = await client.riffy.resolve({
diff --git a/src/utils/embeds.js b/src/utils/embeds.js
index fa21f863f5..6e3c2a91a7 100644
--- a/src/utils/embeds.js
+++ b/src/utils/embeds.js
@@ -84,12 +84,11 @@ function isImportantFooter(footerText) {
}
const normalized = footerText.toLowerCase();
- return /\b(close|closes|closed|expire|expires|available in|page\s+\d+|dashboard closes|ticket id)\b/.test(normalized);
+ return /\b(close|closes|closed|expire|expires|available in|page\s+\d+|dashboard closes|ticket id|powered)\b/.test(normalized);
}
const originalSetDescription = EmbedBuilder.prototype.setDescription;
const originalSetFooter = EmbedBuilder.prototype.setFooter;
-const originalSetTimestamp = EmbedBuilder.prototype.setTimestamp;
EmbedBuilder.prototype.setDescription = function(description = '') {
const descString = sanitizeEmbedText(description || '');
@@ -107,10 +106,6 @@ EmbedBuilder.prototype.setFooter = function(footer) {
return originalSetFooter.call(this, { text: footerText });
};
-EmbedBuilder.prototype.setTimestamp = function() {
- return this;
-};
-
export function createEmbed({
title = '',
description = '',
diff --git a/src/utils/logEmbeds.js b/src/utils/logEmbeds.js
index f334e8dec7..45a442402c 100644
--- a/src/utils/logEmbeds.js
+++ b/src/utils/logEmbeds.js
@@ -161,7 +161,9 @@ export function buildStandardLogEmbed({
);
}
- if (timestamp) embed.setTimestamp();
+ if (timestamp) {
+ embed.setTimestamp();
+ }
if (footer?.text) {
embed.setFooter({
diff --git a/src/utils/ticketLogging.js b/src/utils/ticketLogging.js
index db297ddaad..1ae7c63b9b 100644
--- a/src/utils/ticketLogging.js
+++ b/src/utils/ticketLogging.js
@@ -119,7 +119,7 @@ async function createTicketLogEmbed(guild, event) {
let inlineFields = [];
let fields = [];
let author = null;
- let footer = { text: 'TitanBot Ticketing' };
+ let footer = { text: 'Powered by HACKYI' };
switch (event.type) {
case 'open':
@@ -243,6 +243,7 @@ async function createTicketLogEmbed(guild, event) {
fields,
author,
footer,
+ timestamp: true,
});
}
diff --git a/src/utils/welcomeCard.js b/src/utils/welcomeCard.js
new file mode 100644
index 0000000000..e2ab297440
--- /dev/null
+++ b/src/utils/welcomeCard.js
@@ -0,0 +1,85 @@
+import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas';
+import { createRequire } from 'module';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { logger } from './logger.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const BG_PATH = path.join(__dirname, '../assets/welcome_bg.png');
+
+// Circle position measured from the background image
+const AVATAR_CENTER_X = 516;
+const AVATAR_CENTER_Y = 235;
+const AVATAR_RADIUS = 65; // fits inside the white ring (outer ring radius ~76)
+
+/**
+ * Generates a welcome card buffer.
+ * @param {string} avatarUrl - User's avatar URL
+ * @param {string} username - Display name shown on the card
+ * @param {number} memberCount - Guild member count
+ * @returns {Promise} PNG buffer
+ */
+export async function generateWelcomeCard(avatarUrl, username, memberCount) {
+ try {
+ const bg = await loadImage(BG_PATH);
+ const avatar = await loadImage(avatarUrl);
+
+ const canvas = createCanvas(bg.width, bg.height);
+ const ctx = canvas.getContext('2d');
+
+ // ── Background ──────────────────────────────────────────────
+ ctx.drawImage(bg, 0, 0);
+
+ // ── Circular avatar clip ────────────────────────────────────
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(AVATAR_CENTER_X, AVATAR_CENTER_Y, AVATAR_RADIUS, 0, Math.PI * 2);
+ ctx.closePath();
+ ctx.clip();
+ ctx.drawImage(
+ avatar,
+ AVATAR_CENTER_X - AVATAR_RADIUS,
+ AVATAR_CENTER_Y - AVATAR_RADIUS,
+ AVATAR_RADIUS * 2,
+ AVATAR_RADIUS * 2,
+ );
+ ctx.restore();
+
+ // ── Semi-transparent bottom bar for text legibility ─────────
+ const textAreaY = AVATAR_CENTER_Y + AVATAR_RADIUS + 18;
+ const barHeight = bg.height - textAreaY - 8;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
+ ctx.beginPath();
+ ctx.roundRect(40, textAreaY, bg.width - 80, barHeight, 8);
+ ctx.fill();
+
+ // ── "username just joined the server" ───────────────────────
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = 'bold 30px sans-serif';
+ ctx.fillStyle = '#ffffff';
+ ctx.shadowColor = 'rgba(0,0,0,0.8)';
+ ctx.shadowBlur = 8;
+ ctx.fillText(
+ `${username} just joined the server`,
+ bg.width / 2,
+ textAreaY + barHeight * 0.38,
+ );
+
+ // ── "Member #N" ─────────────────────────────────────────────
+ ctx.font = '22px sans-serif';
+ ctx.fillStyle = '#cccccc';
+ ctx.fillText(
+ `Member #${memberCount}`,
+ bg.width / 2,
+ textAreaY + barHeight * 0.72,
+ );
+
+ ctx.shadowBlur = 0;
+
+ return canvas.toBuffer('image/png');
+ } catch (err) {
+ logger.error('welcomeCard: failed to generate card:', err);
+ return null;
+ }
+}