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; + } +}