diff --git a/.env b/.env new file mode 100644 index 0000000000..cc61343af1 --- /dev/null +++ b/.env @@ -0,0 +1,50 @@ +# Copy this file to .env and replace placeholder values. + +# Discord Bot Configuration +DISCORD_TOKEN=your_discord_bot_token_here +CLIENT_ID=your_discord_client_id_here +GUILD_ID=your_discord_guild_id_here +OWNER_IDS=1398076402460524545,1291778460641136641,1055209717544276040 + +# Bot Runtime Configuration +NODE_ENV=production +LOG_LEVEL=warn +LOG_TO_FILE=false +SENTRY_DSN= + +# Web/API Configuration +PORT=3000 +WEB_HOST=0.0.0.0 +PORT_RETRY_ATTEMPTS=5 +CORS_ORIGIN=* + +# PostgreSQL Configuration (Primary Database) +POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=titanbot +POSTGRES_USER=postgres +POSTGRES_PASSWORD=yourpassword + +# PostgreSQL Pool/Timeout Settings +POSTGRES_MAX_CONNECTIONS=20 +POSTGRES_MIN_CONNECTIONS=2 +POSTGRES_CONNECTION_TIMEOUT=10000 +POSTGRES_IDLE_TIMEOUT=30000 +POSTGRES_RETRIES=3 +POSTGRES_BACKOFF_BASE=100 +POSTGRES_BACKOFF_MULTIPLIER=2 + +# Migration & Schema Settings +AUTO_MIGRATE=true +POSTGRES_MIGRATION_TABLE=schema_migrations +SCHEMA_VERSION=1 +SCHEMA_VERSION_LABEL=baseline-v1 + +# Backup/Restore Script Settings +BACKUP_DIR=./backups +BACKUP_RETENTION_DAYS=14 +POSTGRES_RESTORE_URL= + +# Optional Feature/API Keys +TMDB_API_KEY= diff --git a/BUG_ANALYSIS.md b/BUG_ANALYSIS.md new file mode 100644 index 0000000000..58fa86906b --- /dev/null +++ b/BUG_ANALYSIS.md @@ -0,0 +1,706 @@ +# πŸ› Bug Analysis Report - M00NK1DD-B0T_ + +**Generated:** 2026-07-01 +**Repository:** arand606/M00NK1DD-B0T_ +**Language:** JavaScript (100%) + +--- + +## Executive Summary + +This document outlines **critical issues**, **bugs**, and **code quality concerns** found in the M00NK1DD-B0T_ codebase. The analysis covers the main application flow, database abstraction, error handling, and utility functions. Issues are categorized by severity. + +--- + +## πŸ”΄ CRITICAL ISSUES + +### 1. **Unsafe CORS Configuration** +**File:** `src/app.js` (Lines 113-127) +**Severity:** HIGH +**Impact:** Security vulnerability; potential for CORS bypass + +```javascript +// ❌ PROBLEMATIC CODE +const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; +const origin = req.headers.origin; + +if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { + res.header('Access-Control-Allow-Origin', origin || '*'); +} +``` + +**Problems:** +- When `corsOrigin` is `'*'`, the condition checks if an array contains the string `'*'` +- Setting `Access-Control-Allow-Origin: *` with credentials enabled violates CORS spec +- The wildcard `'*'` in `allowedOrigins` array is treated as a literal string, not a pattern match + +**Fix:** +```javascript +const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; +const origin = req.headers.origin; +const hasWildcard = allowedOrigins.includes('*'); + +if (hasWildcard) { + res.header('Access-Control-Allow-Origin', '*'); +} else if (allowedOrigins.includes(origin)) { + res.header('Access-Control-Allow-Origin', origin); +} +``` + +--- + +### 2. **Memory Leak in Rate Limiter** +**File:** `src/app.js` (Lines 129-151) +**Severity:** HIGH +**Impact:** Memory exhaustion under sustained load + +```javascript +// ❌ PROBLEMATIC CODE +const requestCounts = new Map(); +app.use((req, res, next) => { + const ip = req.ip; + const now = Date.now(); + const windowStart = now - windowMs; + + if (!requestCounts.has(ip)) { + requestCounts.set(ip, []); + } + + const times = requestCounts.get(ip).filter(t => t > windowStart); + times.push(now); + requestCounts.set(ip, times); // ❌ OLD ENTRIES ACCUMULATE + next(); +}); +``` + +**Problems:** +- IPs with `times.length === 0` remain as keys in the Map with empty arrays +- Over time with many unique IPs, the Map grows unboundedly +- No garbage collection mechanism for old or stale entries + +**Fix:** +```javascript +const requestCounts = new Map(); +app.use((req, res, next) => { + const ip = req.ip; + const now = Date.now(); + const windowStart = now - windowMs; + + let times = requestCounts.get(ip) || []; + times = times.filter(t => t > windowStart); + + if (times.length >= maxRequests) { + return res.status(429).json({ error: 'Too many requests' }); + } + + times.push(now); + + // Only store if times exist, otherwise cleanup + if (times.length > 0) { + requestCounts.set(ip, times); + } else { + requestCounts.delete(ip); + } + + next(); +}); +``` + +--- + +### 3. **Type Error in Handler Loader** +**File:** `src/app.js` (Lines 281-283) +**Severity:** MEDIUM-HIGH +**Impact:** Runtime error when handler type contains colon + +```javascript +// ❌ PROBLEMATIC CODE +const loaderFn = handler.type.startsWith('named:') + ? module[handler.type.split(':')[1]] // ❌ UNDEFINED if no colon or no [1] + : module.default; +``` + +**Problems:** +- `handler.type.split(':')` may return array with only one element +- Accessing `[1]` on a single-element array returns `undefined` +- No validation that the split actually produced a named export +- If `handler.type` is `'named'` (missing colon), `split(':')` returns `['named']` and `[1]` is undefined + +**Example fail case:** +```javascript +const handler = { path: 'events', type: 'named', required: true }; +// handler.type.startsWith('named:') === false +// Falls through to module.default βœ“ OK +// BUT if type is 'named:myExport': +// handler.type.split(':')[1] === 'myExport' βœ“ +// BUT if split returns only 1 element: +// module[undefined] throws or returns undefined ❌ +``` + +**Fix:** +```javascript +let loaderFn; +if (handler.type.startsWith('named:')) { + const parts = handler.type.split(':'); + if (parts.length < 2 || !parts[1]) { + throw new Error(`Invalid handler type format: "${handler.type}". Expected "named:exportName"`); + } + loaderFn = module[parts[1]]; + if (!loaderFn) { + throw new Error(`Export "${parts[1]}" not found in ${handler.path}`); + } +} else { + loaderFn = module.default; +} +``` + +--- + +## 🟠 HIGH-PRIORITY ISSUES + +### 4. **Unvalidated Port Binding Errors** +**File:** `src/app.js` (Lines 203-224) +**Severity:** MEDIUM +**Impact:** Fails silently on permission errors + +```javascript +// ❌ PROBLEMATIC CODE +server.on('error', (error) => { + const errorCode = error?.code || 'UNKNOWN_ERROR'; + + if (!hasStartedListening && errorCode === 'EADDRINUSE' && attempt < maxPortRetryAttempts) { + // Retry logic βœ“ + } + + if (hasStartedListening && errorCode === 'EADDRINUSE') { + logger.warn('...duplicate bind warning...'); + return; // ❌ Silently returns on port conflict AFTER binding + } + + logger.error(`Web server error: ${errorMessage}`); + // ❌ No handling for EACCES, EPERM, or other OS errors +}); +``` + +**Problems:** +- Only handles `EADDRINUSE` and `UNKNOWN_ERROR` +- Doesn't handle `EACCES` (permission denied), `EPERM` (operation not permitted) +- Silently returns if server started but port becomes unavailable +- No retry for permission-related errors + +**Likely scenarios:** +- Port < 1024 requires root/admin +- Firewall blocking port +- Previous process still holding port + +**Fix:** +```javascript +const OS_ERRORS = { + 'EADDRINUSE': 'Port is already in use', + 'EACCES': 'Permission denied (port < 1024 requires elevated privileges)', + 'EPERM': 'Operation not permitted', + 'ENOTFOUND': 'Host not found', + 'EHOSTUNREACH': 'Host is unreachable', +}; + +server.on('error', (error) => { + const errorCode = error?.code || 'UNKNOWN_ERROR'; + const errorMsg = OS_ERRORS[errorCode] || error.message; + + if (!hasStartedListening && errorCode === 'EADDRINUSE' && attempt < maxPortRetryAttempts) { + startupLog(`Port ${port} in use. Retrying on ${port + 1}...`); + setTimeout(() => startServer(port + 1, attempt + 1), 250); + return; + } + + if (!hasStartedListening && errorCode === 'EACCES') { + logger.error(`❌ Cannot bind to port ${port}: ${errorMsg}`); + process.exit(1); + } + + logger.error(`❌ Web server error (${errorCode}): ${errorMsg}`); + if (!hasStartedListening) process.exit(1); +}); +``` + +--- + +### 5. **Race Condition in Counter Updates** +**File:** `src/app.js` (Lines 236-270) +**Severity:** MEDIUM +**Impact:** Data inconsistency; orphaned counters + +```javascript +// ⚠️ PROBLEMATIC CODE - Race condition possible +for (const [guildId, guild] of this.guilds.cache) { + const counters = await getServerCounters(this, guildId); + + for (const counter of counters) { + const channel = guild.channels.cache.get(counter.channelId); + if (channel) { + // Counter is valid + } else { + // Channel deleted, but what if it's recreated between checks? + orphanedCounters.push(counter); + } + } + + if (orphanedCounters.length > 0) { + await saveServerCounters(this, guildId, validCounters); + } +} +``` + +**Problems:** +- Between `getServerCounters()` and `saveServerCounters()`, the guild state can change +- A channel may be deleted and recreated +- Multiple cron jobs could run simultaneously, causing conflicts + +**Fix:** Use mutex or transaction-like behavior (the codebase has `Mutex` at `src/utils/mutex.js`): +```javascript +import { Mutex } from './utils/mutex.js'; + +async updateAllCounters() { + for (const [guildId, guild] of this.guilds.cache) { + // Lock per guild to prevent concurrent updates + await Mutex.runExclusive(`counter:${guildId}`, async () => { + const counters = await getServerCounters(this, guildId); + const validCounters = []; + + for (const counter of counters) { + if (counter?.type && counter?.channelId && counter.enabled !== false) { + const channel = guild.channels.cache.get(counter.channelId); + if (channel) { + validCounters.push(counter); + await updateCounter(this, guild, counter); + } + } + } + + await saveServerCounters(this, guildId, validCounters); + }); + } +} +``` + +--- + +### 6. **Database Status Check Ambiguity** +**File:** `src/app.js` (Lines 154-183) +**Severity:** MEDIUM +**Impact:** Misleading health checks + +```javascript +// ❌ PROBLEMATIC CODE +app.get('/ready', (req, res) => { + const dbStatus = this.db?.getStatus?.() || { isDegraded: true }; // ⚠️ Defaults to degraded + const isReady = this.isReady() && !dbStatus.isDegraded; + + if (isReady) { + return res.status(200).json({ ready: true, message: 'Bot is ready' }); + } + + res.status(503).json({ + ready: false, + reason: !this.isReady() ? 'Bot not Ready' : 'Database degraded' + }); +}); +``` + +**Problems:** +- Defaults to `{ isDegraded: true }` if `this.db` is undefined +- This causes `/ready` to return 503 even if bot is actually initializing normally +- No distinction between "database missing" and "database degraded" +- Unclear whether this is during startup (expected) or runtime (problem) + +**Fix:** +```javascript +app.get('/ready', (req, res) => { + if (!this.db) { + return res.status(503).json({ + ready: false, + reason: 'Database not initialized' + }); + } + + const dbStatus = this.db.getStatus(); + const isReady = this.isReady() && !dbStatus.isDegraded; + + if (isReady) { + return res.status(200).json({ + ready: true, + message: 'Bot is ready', + database: dbStatus.connectionType + }); + } + + res.status(503).json({ + ready: false, + reason: !this.isReady() ? 'Bot not ready' : 'Database degraded', + database: dbStatus + }); +}); +``` + +--- + +## 🟑 MEDIUM-PRIORITY ISSUES + +### 7. **Missing Null Checks in Database Wrapper** +**File:** `src/utils/database.js` (Lines 105-112) +**Severity:** MEDIUM +**Impact:** Potential runtime errors + +```javascript +// ⚠️ PROBLEMATIC CODE +async increment(key, amount = 1) { + if (this.db.increment) { // ❌ No null check on this.db + return this.db.increment(key, amount); + } + const current = await this.db.get(key, 0); // ❌ What if this.db is null? + const newValue = current + amount; + await this.db.set(key, newValue); + return newValue; +} +``` + +**Problems:** +- If `this.db` is null, `this.db.increment` throws +- Multiple methods lack null checks on `this.db` +- No guard clause at method entry + +**Fix:** +```javascript +async increment(key, amount = 1) { + if (!this.db) { + throw new Error('Database not initialized'); + } + + if (typeof this.db.increment === 'function') { + return this.db.increment(key, amount); + } + + const current = await this.db.get(key, 0); + const newValue = current + amount; + await this.db.set(key, newValue); + return newValue; +} +``` + +--- + +### 8. **Improper Error Propagation in Event Handler** +**File:** `src/handlers/events.js` (Lines 16-23) +**Severity:** MEDIUM +**Impact:** Silent event failures + +```javascript +// ⚠️ PROBLEMATIC CODE +const safeExecute = async (...args) => { + try { + await event.execute(...args, client); + } catch (error) { + logger.error(`Error executing event ${event.name}:`, error); + // ❌ Error swallowed, not re-thrown or handled further + } +}; +``` + +**Problems:** +- Events that fail are logged but not tracked +- No way to know if critical events failed +- No circuit breaker or fallback + +**Better approach:** +```javascript +const safeExecute = async (...args) => { + try { + await event.execute(...args, client); + } catch (error) { + logger.error(`Error executing event ${event.name}:`, error); + + // For critical events, consider re-throwing + const criticalEvents = new Set(['ready', 'guildCreate', 'guildDelete']); + if (criticalEvents.has(event.name) && client.skipCriticalEventErrors !== true) { + throw error; // Re-throw to halt startup + } + } +}; +``` + +--- + +### 9. **Unvalidated Application Status Values** +**File:** `src/utils/database.js` (Lines 1268-1276) +**Severity:** MEDIUM +**Impact:** Data corruption; invalid states + +```javascript +// ⚠️ PROBLEMATIC CODE +const status = typeof application.status === 'string' ? application.status.toLowerCase() : 'pending'; + +if (status === 'pending') { + return ageMsFromCreated > pendingRetentionMs; +} + +if (status === 'approved' || status === 'denied') { // ⚠️ What about other values? + return ageMsFromReviewed > reviewedRetentionMs; +} + +return ageMsFromCreated > pendingRetentionMs; // ⚠️ Default fallback +``` + +**Problems:** +- Accepts any string as status (no validation) +- No enum or constant for valid statuses +- Database could contain `status: 'invalid'` or `status: 'PENDING'` (uppercase) +- Default behavior may not be what's intended + +**Fix:** +```javascript +const VALID_STATUSES = ['pending', 'approved', 'denied', 'rejected']; + +function isApplicationExpired(application, retentionDays, now = Date.now()) { + if (!application || typeof application !== 'object') { + return false; + } + + const rawStatus = application.status; + const status = typeof rawStatus === 'string' + ? rawStatus.toLowerCase().trim() + : 'pending'; + + if (!VALID_STATUSES.includes(status)) { + logger.warn(`Invalid application status: "${rawStatus}". Treating as pending.`); + } + + // ... rest of logic +} +``` + +--- + +### 10. **Missing Request Timeout Configuration** +**File:** `src/app.js` (Lines 106-228) +**Severity:** MEDIUM +**Impact:** Hanging requests; resource exhaustion + +```javascript +// ⚠️ INCOMPLETE CODE +const app = express(); +// ❌ No timeout configuration +app.use((req, res, next) => { + // Rate limiting, CORS, etc. + next(); +}); + +const server = app.listen(port, host, () => { + // ❌ No keepAliveTimeout or requestTimeout +}); +``` + +**Problems:** +- Requests can hang indefinitely +- Slow clients can hold connections open +- No protection against slowloris attacks + +**Fix:** +```javascript +const app = express(); + +// Add timeout middleware +app.use((req, res, next) => { + req.setTimeout(30000); // 30 seconds per request + res.setTimeout(30000); + next(); +}); + +// ... other middleware + +const server = app.listen(port, host, () => { + server.keepAliveTimeout = 65000; + server.requestTimeout = 60000; + startupLog(`βœ… Web Server running on ${host}:${port}`); +}); +``` + +--- + +## 🟒 LOW-PRIORITY ISSUES + +### 11. **Redundant Console Transport in Logger** +**File:** `src/utils/logger.js` (Lines 154-174) +**Severity:** LOW +**Impact:** Code duplication + +```javascript +// ⚠️ REDUNDANT CODE +if (process.env.NODE_ENV !== 'production') { + logger.add(new transports.Console({ + format: combine(colorize(), timestamp({ format: 'HH:mm:ss' }), errors({ stack: true }), logFormat), + level: resolvedLogLevel, + })); +} else { + logger.add(new transports.Console({ // ❌ EXACT SAME CONFIG + format: combine(colorize(), timestamp({ format: 'HH:mm:ss' }), errors({ stack: true }), logFormat), + level: resolvedLogLevel, + })); +} +``` + +**Fix:** +```javascript +const consoleTransport = new transports.Console({ + format: combine(colorize(), timestamp({ format: 'HH:mm:ss' }), errors({ stack: true }), logFormat), + level: resolvedLogLevel, +}); +logger.add(consoleTransport); +``` + +--- + +### 12. **Excessive Modlog Settings Duplication** +**File:** `src/utils/database.js` (Lines 1656-1789) +**Severity:** LOW +**Impact:** Maintenance burden; inconsistency risk + +```javascript +// ⚠️ PROBLEMATIC PATTERN +const defaultSettings = { + logBans: true, + logKicks: true, + logMutes: true, + // ... 50+ similar boolean flags ... + logGuildScheduledEventUsersUpdate: true, +}; + +// Repeated again in error case: +return { + logBans: true, + logKicks: true, + // ... ALL REPEATED ... +}; +``` + +**Problems:** +- Defaults defined in two places +- Hard to maintain consistency +- Prone to drift between versions + +**Fix:** +```javascript +const DEFAULT_MODLOG_SETTINGS = { + enabled: false, + channelId: null, + ignoredChannels: [], + ignoredUsers: [], + ignoredActions: [], + logActions: { + bans: true, + kicks: true, + mutes: true, + warns: true, + // ... + } +}; + +export async function getModlogSettings(client, guildId) { + const key = getModlogSettingsKey(guildId); + try { + const settings = await client.db.get(key, {}); + return { ...DEFAULT_MODLOG_SETTINGS, ...unwrapReplitData(settings) }; + } catch (error) { + logger.error(`Error getting modlog settings for guild ${guildId}:`, error); + return DEFAULT_MODLOG_SETTINGS; + } +} +``` + +--- + +### 13. **Hardcoded String Values for Logging** +**File:** `src/app.js` (Various lines) +**Severity:** LOW +**Impact:** Inconsistent log messages; hard to search/standardize + +```javascript +// ⚠️ SCATTERED STRINGS +logger.info('βœ… Database Status: ...'); +startupLog('Starting TitanBot...'); +logger.error('❌ Database Initialization Error:', error); +shutdownLog('Bot stopped successfully.'); +``` + +**Recommendation:** Extract to constants: +```javascript +const LOG_MESSAGES = { + DATABASE_INIT_START: 'Initializing database...', + DATABASE_INIT_SUCCESS: 'βœ… Database initialized', + DATABASE_INIT_ERROR: '❌ Database Initialization Error:', + BOT_STARTUP: 'Starting TitanBot...', + BOT_ONLINE: 'ONLINE βœ…', + BOT_SHUTDOWN: 'Bot is shutting down', +}; +``` + +--- + +## πŸ“‹ Summary Table + +| Issue | File | Severity | Type | Recommendation | +|-------|------|----------|------|-----------------| +| Unsafe CORS | app.js | πŸ”΄ CRITICAL | Security | Fix wildcard handling | +| Rate Limiter Memory Leak | app.js | πŸ”΄ CRITICAL | Performance | Add cleanup logic | +| Handler Type Error | app.js | 🟠 HIGH | Runtime | Add validation | +| Port Binding Errors | app.js | 🟠 HIGH | Reliability | Handle OS errors | +| Counter Race Condition | app.js | 🟠 HIGH | Data Integrity | Use Mutex | +| DB Status Ambiguity | app.js | 🟠 HIGH | Observability | Clarify states | +| Missing Null Checks | database.js | 🟑 MEDIUM | Runtime | Add guards | +| Event Error Swallowing | events.js | 🟑 MEDIUM | Debugging | Improve tracking | +| Invalid Status Values | database.js | 🟑 MEDIUM | Data Integrity | Add enum validation | +| Missing Timeouts | app.js | 🟑 MEDIUM | Security | Configure limits | +| Redundant Logger Config | logger.js | 🟒 LOW | Quality | DRY principle | +| Modlog Settings Duplication | database.js | 🟒 LOW | Maintainability | Extract constant | +| Hardcoded Strings | app.js | 🟒 LOW | Standards | Use constants | + +--- + +## βœ… Recommendations + +1. **Immediate (This week):** + - [ ] Fix CORS configuration (Issue #1) + - [ ] Implement rate limiter cleanup (Issue #2) + - [ ] Add handler type validation (Issue #3) + +2. **Short-term (This sprint):** + - [ ] Enhance port binding error handling (Issue #4) + - [ ] Add mutex-based counter updates (Issue #5) + - [ ] Clarify database status checks (Issue #6) + +3. **Medium-term (Next sprint):** + - [ ] Add comprehensive null checks (Issue #7) + - [ ] Improve event error handling (Issue #8) + - [ ] Add status enum validation (Issue #9) + +4. **Long-term (Ongoing):** + - [ ] Add request timeouts (Issue #10) + - [ ] Refactor for DRY principles (Issues #11-13) + - [ ] Add integration tests + - [ ] Implement request/response middleware logging + +--- + +## πŸ“š Related Files for Reference + +- **Main entry:** `src/app.js` +- **Database:** `src/utils/database.js` +- **Logger:** `src/utils/logger.js` +- **Event handlers:** `src/handlers/events.js` +- **Error handling:** `src/utils/errorHandler.js` +- **Mutex utility:** `src/utils/mutex.js` (already implements locking!) + +--- + +**Report prepared:** 2026-07-01 +**Status:** Initial Assessment +**Next Review:** After critical issues are resolved diff --git a/README.md b/README.md index 98755bccbc..ad448abb5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# TitanBot - Ultimate Discord Bot +# M00NB0T - Ultimate Discord Bot -**TitanBot** is a powerful, feature-rich Discord bot designed to enhance your server experience with comprehensive moderation tools, engaging economy systems, utility features, and much more. Built with modern Discord.js v14 and PostgreSQL for optimal performance and data persistence. +**M00NB0T** is a powerful, feature-rich Discord bot designed to enhance your server experience with comprehensive moderation tools, engaging economy systems, utility features, and much more. Built with modern Discord.js v14 and PostgreSQL for optimal performance and data persistence. [![Support Server](https://img.shields.io/badge/-Support%20Server-%235865F2?logo=discord&logoColor=white&style=flat-square&logoWidth=20)](https://discord.gg/8kJBYhTGW9) [![Discord.js](https://img.shields.io/npm/v/discord.js?style=flat-square&labelColor=%23202225&color=%23202225&logo=npm&logoColor=white&logoWidth=20)](https://www.npmjs.com/package/discord.js) diff --git a/commands/toggleAvatar.js b/commands/toggleAvatar.js new file mode 100644 index 0000000000..307861078f --- /dev/null +++ b/commands/toggleAvatar.js @@ -0,0 +1,70 @@ +const { SlashCommandBuilder } = require('discord.js'); +const fs = require('fs'); +const path = require('path'); + +// File paths +const STATE_FILE = path.join(__dirname, '..', 'data', 'avatarState.json'); // ensure data/ exists +const ASSETS = { + animated: path.join(__dirname, '..', 'assets', 'avatar_anim.gif'), + static: path.join(__dirname, '..', 'assets', 'avatar_static.png'), +}; + +// helper to read/write state +function readState() { + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); + } catch { + return { enabled: false }; + } +} +function writeState(state) { + fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +module.exports = { + data: new SlashCommandBuilder() + .setName('toggleavatar') + .setDescription('Toggle the bot animated avatar on/off (bot owner only)') + .addStringOption(opt => + opt.setName('state') + .setDescription('on / off (omit to toggle)') + .setRequired(false) + .addChoices( + { name: 'on', value: 'on' }, + { name: 'off', value: 'off' } + ) + ), + async execute(interaction) { + // Restrict to bot owner(s) + const ownerId = process.env.OWNER_ID; // set this in env or replace with an ID + if (!ownerId || interaction.user.id !== ownerId) { + return interaction.reply({ content: 'Only the bot owner can run this command.', ephemeral: true }); + } + + await interaction.deferReply({ ephemeral: true }); + + const arg = interaction.options.getString('state'); + const state = readState(); + let newEnabled = state.enabled; + + if (arg === 'on') newEnabled = true; + else if (arg === 'off') newEnabled = false; + else newEnabled = !newEnabled; + + const assetPath = newEnabled ? ASSETS.animated : ASSETS.static; + if (!fs.existsSync(assetPath)) { + return interaction.editReply({ content: `Missing asset: ${assetPath}. Add the file and try again.` }); + } + + try { + const buffer = fs.readFileSync(assetPath); + await interaction.client.user.setAvatar(buffer); + writeState({ enabled: newEnabled }); + return interaction.editReply({ content: `Animated avatar ${newEnabled ? 'enabled' : 'disabled'}.` }); + } catch (err) { + console.error('Failed to set avatar:', err); + return interaction.editReply({ content: `Failed to set avatar: ${err.message}` }); + } + }, +}; diff --git a/src/app.js b/src/app.js index c0625b6b5a..29993b1cba 100644 --- a/src/app.js +++ b/src/app.js @@ -1,4 +1,4 @@ -ο»Ώimport 'dotenv/config'; +import 'dotenv/config'; import { Client, Collection, GatewayIntentBits } from 'discord.js'; import { REST } from '@discordjs/rest'; import express from 'express'; @@ -110,13 +110,18 @@ class TitanBot extends Client { const host = process.env.WEB_HOST || '0.0.0.0'; const corsOrigin = this.config.api?.cors?.origin || '*'; + // FIX #1: Unsafe CORS Configuration - Properly handle wildcard app.use((req, res, next) => { const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin]; const origin = req.headers.origin; + const hasWildcard = allowedOrigins.includes('*'); - if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) { - res.header('Access-Control-Allow-Origin', origin || '*'); + if (hasWildcard) { + res.header('Access-Control-Allow-Origin', '*'); + } else if (allowedOrigins.includes(origin)) { + res.header('Access-Control-Allow-Origin', origin); } + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); @@ -126,9 +131,32 @@ class TitanBot extends Client { next(); }); + // FIX #2: Memory Leak in Rate Limiter - Add cleanup logic const requestCounts = new Map(); const windowMs = 60000; const maxRequests = this.config.api?.rateLimit?.max || 100; + const cleanupIntervalMs = 60000; // Cleanup every minute + + // Cleanup stale entries to prevent memory leak + setInterval(() => { + const now = Date.now(); + const windowStart = now - windowMs; + let cleaned = 0; + + for (const [ip, times] of requestCounts) { + const validTimes = times.filter(t => t > windowStart); + if (validTimes.length === 0) { + requestCounts.delete(ip); + cleaned++; + } else if (validTimes.length < times.length) { + requestCounts.set(ip, validTimes); + } + } + + if (cleaned > 0) { + logger.debug(`Rate limiter cleanup: removed ${cleaned} stale IP entries`); + } + }, cleanupIntervalMs); app.use((req, res, next) => { const ip = req.ip; @@ -150,8 +178,17 @@ class TitanBot extends Client { next(); }); + // FIX #6: Database Status Check Ambiguity - Clarify states app.get('/health', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: 'unknown' }; + if (!this.db) { + return res.status(503).json({ + status: 'unhealthy', + reason: 'Database not initialized', + timestamp: new Date().toISOString() + }); + } + + const dbStatus = this.db.getStatus(); const status = { status: 'healthy', timestamp: new Date().toISOString(), @@ -166,19 +203,28 @@ class TitanBot extends Client { }); app.get('/ready', (req, res) => { - const dbStatus = this.db?.getStatus?.() || { isDegraded: true }; + if (!this.db) { + return res.status(503).json({ + ready: false, + reason: 'Database not initialized' + }); + } + + const dbStatus = this.db.getStatus(); const isReady = this.isReady() && !dbStatus.isDegraded; if (isReady) { return res.status(200).json({ ready: true, - message: 'Bot is ready' + message: 'Bot is ready', + database: dbStatus.connectionType }); } res.status(503).json({ ready: false, - reason: !this.isReady() ? 'Bot not Ready' : 'Database degraded' + reason: !this.isReady() ? 'Bot not ready' : 'Database degraded', + database: dbStatus }); }); @@ -190,19 +236,38 @@ class TitanBot extends Client { }); }); + // FIX #10: Missing Request Timeout Configuration + app.use((req, res, next) => { + req.setTimeout(30000); // 30 seconds per request + res.setTimeout(30000); + next(); + }); + const startServer = (port, attempt = 0) => { let hasStartedListening = false; const server = app.listen(port, host, () => { hasStartedListening = true; this.webServer = server; + // Apply additional server timeouts + server.keepAliveTimeout = 65000; + server.requestTimeout = 60000; startupLog(`βœ… Web Server running on ${host}:${port}`); startupLog(`Health endpoint: http://localhost:${port}/health`); startupLog(`Ready endpoint: http://localhost:${port}/ready`); }); + // FIX #4: Unvalidated Port Binding Errors - Handle OS errors properly + const OS_ERRORS = { + 'EADDRINUSE': 'Port is already in use', + 'EACCES': 'Permission denied (port < 1024 requires elevated privileges)', + 'EPERM': 'Operation not permitted', + 'ENOTFOUND': 'Host not found', + 'EHOSTUNREACH': 'Host is unreachable', + }; + server.on('error', (error) => { const errorCode = error?.code || 'UNKNOWN_ERROR'; - const errorMessage = error?.message || 'Unknown server error'; + const errorMessage = OS_ERRORS[errorCode] || error?.message || 'Unknown server error'; if (!hasStartedListening && errorCode === 'EADDRINUSE' && attempt < maxPortRetryAttempts) { const nextPort = port + 1; @@ -211,6 +276,11 @@ class TitanBot extends Client { return; } + if (!hasStartedListening && errorCode === 'EACCES') { + logger.error(`❌ Cannot bind to port ${port}: ${errorMessage}`); + process.exit(1); + } + if (hasStartedListening && errorCode === 'EADDRINUSE') { logger.warn(`Web server reported a duplicate bind warning on ${host}:${port}, but the bot remains online.`); return; @@ -278,9 +348,21 @@ class TitanBot extends Client { for (const handler of handlers) { try { const module = await import(`./handlers/${handler.path}.js`); - const loaderFn = handler.type.startsWith('named:') - ? module[handler.type.split(':')[1]] - : module.default; + + // FIX #3: Type Error in Handler Loader - Add validation + let loaderFn; + if (handler.type.startsWith('named:')) { + const parts = handler.type.split(':'); + if (parts.length < 2 || !parts[1]) { + throw new Error(`Invalid handler type format: "${handler.type}". Expected "named:exportName"`); + } + loaderFn = module[parts[1]]; + if (!loaderFn) { + throw new Error(`Export "${parts[1]}" not found in ${handler.path}`); + } + } else { + loaderFn = module.default; + } if (typeof loaderFn === 'function') { await loaderFn(this); @@ -346,7 +428,7 @@ class TitanBot extends Client { } logger.info('βœ… Graceful shutdown complete'); - shutdownLog('Bot stopped successfully.'); + shutdownLog('Bot stopped successfully.'); process.exit(0); } catch (error) { logger.error('Error during graceful shutdown:', error); @@ -381,6 +463,3 @@ try { } export default TitanBot; - - - diff --git a/src/commands/Moderation/quarantine-channel.js b/src/commands/Moderation/quarantine-channel.js new file mode 100644 index 0000000000..8f45b70479 --- /dev/null +++ b/src/commands/Moderation/quarantine-channel.js @@ -0,0 +1,116 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logModerationAction } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName("quarantine-channel") + .setDescription("Quarantine a channel - restricts access to the channel") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel to quarantine") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for quarantine"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + category: "moderation", + + async execute(interaction, config, client) { + try { + // Permission check + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + throw new TitanBotError( + "User lacks permission", + ErrorTypes.PERMISSION, + "You do not have permission to manage channels." + ); + } + + const targetChannel = interaction.options.getChannel("channel"); + const reason = interaction.options.getString("reason") || "Channel quarantined"; + + // Validation checks + if (!targetChannel) { + throw new TitanBotError( + "Channel not found", + ErrorTypes.USER_INPUT, + "The specified channel could not be found.", + { subtype: 'channel_not_found' } + ); + } + + // Don't allow quarantining DM or Thread channels + if (targetChannel.isDMBased() || targetChannel.isThread()) { + throw new TitanBotError( + "Invalid channel", + ErrorTypes.USER_INPUT, + "You cannot quarantine DM or thread channels." + ); + } + + await interaction.deferReply(); + + const guild = interaction.guild; + const everyone = guild.roles.everyone; + + // Get current permissions for @everyone role + let currentOverwrite = targetChannel.permissionOverwrites.get(everyone.id); + + // Deny send messages permission for @everyone + await targetChannel.permissionOverwrites.edit( + everyone.id, + { + SendMessages: false, + SendMessagesInThreads: false, + CreatePublicThreads: false, + CreatePrivateThreads: false + }, + `Quarantine Channel: ${reason}` + ); + + // Log the moderation action + const caseId = await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "Channel Quarantined", + target: `#${targetChannel.name} (${targetChannel.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + metadata: { + channelId: targetChannel.id, + channelName: targetChannel.name, + moderatorId: interaction.user.id + } + } + }); + + // Prepare response message + const description = `**Channel:** ${targetChannel}\n**Reason:** ${reason}\n**Case ID:** #${caseId}\n\n⚠️ Channel access has been restricted. Members cannot send messages.`; + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `πŸ”’ **Quarantined** #${targetChannel.name}`, + description, + ), + ], + }); + + logger.info(`Channel #${targetChannel.name} quarantined in ${guild.name} by ${interaction.user.tag}`); + } catch (error) { + logger.error('Quarantine channel command error:', error); + const errorEmbed_default = errorEmbed( + "An unexpected error occurred while trying to quarantine the channel.", + error.message || "Could not quarantine the channel" + ); + await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); + } + } +}; \ No newline at end of file diff --git a/src/commands/Moderation/quarantine-user.js b/src/commands/Moderation/quarantine-user.js new file mode 100644 index 0000000000..485018e2a3 --- /dev/null +++ b/src/commands/Moderation/quarantine-user.js @@ -0,0 +1,173 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { logModerationAction } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName("quarantine-user") + .setDescription("Quarantine a user - restricts their messaging privileges across the server") + .addUserOption((option) => + option + .setName("target") + .setDescription("The user to quarantine") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for quarantine"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + category: "moderation", + + async execute(interaction, config, client) { + try { + // Permission check + if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + throw new TitanBotError( + "User lacks permission", + ErrorTypes.PERMISSION, + "You do not have permission to moderate members." + ); + } + + const targetUser = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); + const reason = interaction.options.getString("reason") || "User quarantined"; + + // Validation checks + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server.", + { subtype: 'user_not_found' } + ); + } + + // Check if user is trying to quarantine themselves + if (targetUser.id === interaction.user.id) { + throw new TitanBotError( + "Invalid target", + ErrorTypes.USER_INPUT, + "You cannot quarantine yourself." + ); + } + + // Check if user is trying to quarantine a bot + if (targetUser.bot) { + throw new TitanBotError( + "Invalid target", + ErrorTypes.USER_INPUT, + "You cannot quarantine bots." + ); + } + + await interaction.deferReply(); + + // Quarantine by denying send messages permission across all channels + const guild = interaction.guild; + let channelsModified = 0; + const failedChannels = []; + let roleAssigned = false; + let roleAssignmentError = null; + + for (const [, channel] of guild.channels.cache) { + try { + // Skip DM channels and threads + if (channel.isDMBased() || channel.isThread()) { + continue; + } + + // Skip voice channels + if (channel.type === ChannelType.GuildVoice) { + continue; + } + + // Set deny permissions for sending messages + await channel.permissionOverwrites.edit( + targetUser.id, + { + SendMessages: false, + SendMessagesInThreads: false, + CreatePublicThreads: false, + CreatePrivateThreads: false + }, + `Quarantine: ${reason}` + ); + channelsModified++; + } catch (error) { + logger.warn(`Failed to quarantine user in channel ${channel.name}:`, error); + failedChannels.push(channel.name); + } + } + + // Attempt to assign the quarantine role + const QUARANTINE_ROLE_ID = "1516423888605675650"; + try { + const quarantineRole = guild.roles.cache.get(QUARANTINE_ROLE_ID); + if (quarantineRole) { + await member.roles.add(quarantineRole, `Quarantine: ${reason}`); + roleAssigned = true; + logger.info(`Assigned quarantine role to ${targetUser.tag}`); + } else { + roleAssignmentError = "Quarantine role not found in server"; + logger.warn(`Quarantine role ${QUARANTINE_ROLE_ID} not found in guild ${guild.id}`); + } + } catch (error) { + roleAssignmentError = error.message; + logger.warn(`Failed to assign quarantine role to ${targetUser.tag}:`, error); + } + + // Log the moderation action + const caseId = await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "User Quarantined", + target: `${targetUser.tag} (${targetUser.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + metadata: { + userId: targetUser.id, + moderatorId: interaction.user.id, + channelsModified, + failedChannels: failedChannels.length > 0 ? failedChannels.join(', ') : 'none', + roleAssigned, + roleAssignmentError: roleAssignmentError || 'none' + } + } + }); + + // Prepare response message + let description = `**Target:** ${targetUser.tag}\n**Reason:** ${reason}\n**Case ID:** #${caseId}\n**Channels Modified:** ${channelsModified}`; + if (roleAssigned) { + description += `\nβœ… **Quarantine Role:** Assigned`; + } else if (roleAssignmentError) { + description += `\n⚠️ **Quarantine Role:** Failed to assign (${roleAssignmentError})`; + } + if (failedChannels.length > 0) { + description += `\n⚠️ **Failed to modify:** ${failedChannels.join(', ')}`; + } + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `πŸ”’ **Quarantined** ${targetUser.tag}`, + description, + ), + ], + }); + + logger.info(`User ${targetUser.tag} quarantined in ${guild.name} by ${interaction.user.tag}`); + } catch (error) { + logger.error('Quarantine user command error:', error); + const errorEmbed_default = errorEmbed( + "An unexpected error occurred while trying to quarantine the user.", + error.message || "Could not quarantine the user" + ); + await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); + } + } +}; diff --git a/src/commands/Moderation/unquarantine-channel.js b/src/commands/Moderation/unquarantine-channel.js new file mode 100644 index 0000000000..3973759e8c --- /dev/null +++ b/src/commands/Moderation/unquarantine-channel.js @@ -0,0 +1,115 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { logModerationAction } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName("unquarantine-channel") + .setDescription("Remove quarantine from a channel - restores access to the channel") + .addChannelOption((option) => + option + .setName("channel") + .setDescription("The channel to unquarantine") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for removing quarantine"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), + category: "moderation", + + async execute(interaction, config, client) { + try { + // Permission check + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + throw new TitanBotError( + "User lacks permission", + ErrorTypes.PERMISSION, + "You do not have permission to manage channels." + ); + } + + const targetChannel = interaction.options.getChannel("channel"); + const reason = interaction.options.getString("reason") || "Quarantine lifted"; + + // Validation checks + if (!targetChannel) { + throw new TitanBotError( + "Channel not found", + ErrorTypes.USER_INPUT, + "The specified channel could not be found.", + { subtype: 'channel_not_found' } + ); + } + + // Don't allow unquarantining DM or Thread channels + if (targetChannel.isDMBased() || targetChannel.isThread()) { + throw new TitanBotError( + "Invalid channel", + ErrorTypes.USER_INPUT, + "You cannot unquarantine DM or thread channels." + ); + } + + await interaction.deferReply(); + + const guild = interaction.guild; + const everyone = guild.roles.everyone; + + // Check if there's a deny permission for @everyone + const memberPermissions = targetChannel.permissionOverwrites.get(everyone.id); + + if (!memberPermissions) { + throw new TitanBotError( + "Not quarantined", + ErrorTypes.USER_INPUT, + "This channel does not appear to be quarantined." + ); + } + + // Remove the permission override for @everyone + await targetChannel.permissionOverwrites.delete(everyone.id, `Unquarantine: ${reason}`); + + // Log the moderation action + const caseId = await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "Channel Unquarantined", + target: `#${targetChannel.name} (${targetChannel.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + metadata: { + channelId: targetChannel.id, + channelName: targetChannel.name, + moderatorId: interaction.user.id + } + } + }); + + // Prepare response message + const description = `**Channel:** ${targetChannel}\n**Reason:** ${reason}\n**Case ID:** #${caseId}\n\nβœ… Channel access has been restored.`; + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `πŸ”“ **Unquarantined** #${targetChannel.name}`, + description, + ), + ], + }); + + logger.info(`Channel #${targetChannel.name} unquarantined in ${guild.name} by ${interaction.user.tag}`); + } catch (error) { + logger.error('Unquarantine channel command error:', error); + const errorEmbed_default = errorEmbed( + "An unexpected error occurred while trying to unquarantine the channel.", + error.message || "Could not unquarantine the channel" + ); + await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); + } + } +}; diff --git a/src/commands/Moderation/unquarantine-user.js b/src/commands/Moderation/unquarantine-user.js new file mode 100644 index 0000000000..03cf062b22 --- /dev/null +++ b/src/commands/Moderation/unquarantine-user.js @@ -0,0 +1,155 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { logModerationAction } from '../../utils/moderation.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; + +export default { + data: new SlashCommandBuilder() + .setName("unquarantine-user") + .setDescription("Remove quarantine from a user - restores their messaging privileges") + .addUserOption((option) => + option + .setName("target") + .setDescription("The user to unquarantine") + .setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("Reason for removing quarantine"), + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), + category: "moderation", + + async execute(interaction, config, client) { + try { + // Permission check + if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + throw new TitanBotError( + "User lacks permission", + ErrorTypes.PERMISSION, + "You do not have permission to moderate members." + ); + } + + const targetUser = interaction.options.getUser("target"); + const member = interaction.options.getMember("target"); + const reason = interaction.options.getString("reason") || "Quarantine lifted"; + + // Validation checks + if (!member) { + throw new TitanBotError( + "Target not found", + ErrorTypes.USER_INPUT, + "The target user is not currently in this server.", + { subtype: 'user_not_found' } + ); + } + + await interaction.deferReply(); + + // Remove quarantine by clearing all permission overrides for this user + const guild = interaction.guild; + let channelsModified = 0; + const failedChannels = []; + let roleRemoved = false; + let roleRemovalError = null; + + for (const [, channel] of guild.channels.cache) { + try { + // Skip voice channels and thread channels + if (channel.isDMBased() || channel.isThread()) { + continue; + } + + // Check if there's an existing permission override for this user + const memberPermissions = channel.permissionOverwrites.get(targetUser.id); + + if (memberPermissions) { + // Delete the permission override for this user + await channel.permissionOverwrites.delete(targetUser.id, `Unquarantine: ${reason}`); + channelsModified++; + } + } catch (error) { + logger.warn(`Failed to remove quarantine from channel ${channel.name}:`, error); + failedChannels.push(channel.name); + } + } + + // Attempt to remove the quarantine role + const QUARANTINE_ROLE_ID = "1516423888605675650"; + try { + const quarantineRole = guild.roles.cache.get(QUARANTINE_ROLE_ID); + if (quarantineRole) { + if (member.roles.cache.has(QUARANTINE_ROLE_ID)) { + await member.roles.remove(quarantineRole, `Unquarantine: ${reason}`); + roleRemoved = true; + logger.info(`Removed quarantine role from ${targetUser.tag}`); + } else { + roleRemoved = false; // User didn't have the role to begin with + } + } else { + roleRemovalError = "Quarantine role not found in server"; + logger.warn(`Quarantine role ${QUARANTINE_ROLE_ID} not found in guild ${guild.id}`); + } + } catch (error) { + roleRemovalError = error.message; + logger.warn(`Failed to remove quarantine role from ${targetUser.tag}:`, error); + } + + // Log the moderation action + const caseId = await logModerationAction({ + client, + guild: interaction.guild, + event: { + action: "User Unquarantined", + target: `${targetUser.tag} (${targetUser.id})`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason, + metadata: { + userId: targetUser.id, + moderatorId: interaction.user.id, + channelsModified, + failedChannels: failedChannels.length > 0 ? failedChannels.join(', ') : 'none', + roleRemoved, + roleRemovalError: roleRemovalError || 'none' + } + } + }); + + // Prepare response message + let description = `**Reason:** ${reason}\n**Case ID:** #${caseId}\n**Channels Modified:** ${channelsModified}`; + + if (roleRemoved) { + description += `\nβœ… **Quarantine Role:** Removed`; + } else if (roleRemovalError) { + description += `\n⚠️ **Quarantine Role:** Failed to remove (${roleRemovalError})`; + } else { + description += `\nℹ️ **Quarantine Role:** User did not have the role`; + } + + if (failedChannels.length > 0) { + description += `\n⚠️ **Failed to modify:** ${failedChannels.join(', ')}`; + } + + await InteractionHelper.universalReply(interaction, { + embeds: [ + successEmbed( + `πŸ”“ **Unquarantined** ${targetUser.tag}`, + description, + ), + ], + }); + + logger.info(`User ${targetUser.tag} unquarantined in ${guild.name} by ${interaction.user.tag}`); + } catch (error) { + logger.error('Unquarantine user command error:', error); + const errorEmbed_default = errorEmbed( + "An unexpected error occurred while trying to unquarantine the user.", + error.message || "Could not unquarantine the user" + ); + await InteractionHelper.universalReply(interaction, { embeds: [errorEmbed_default] }); + } + } +}; + diff --git a/src/config/bot.js b/src/config/bot.js index 36e588cd42..db96b2118f 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -2,7 +2,7 @@ import { logger } from '../utils/logger.js'; export const botConfig = { - // ========================= + // ========================= // BOT PRESENCE (what users see under the bot name) // ========================= // `status` options: @@ -14,23 +14,32 @@ export const botConfig = { // Current online state shown on Discord. status: "online", + // How often to switch statuses in milliseconds (30000 = 30 seconds) + cycleInterval: 10000, + // Activity lines shown under the bot name. // `type` number mapping from Discord: - // 0 = Playing - // 1 = Streaming - // 2 = Listening - // 3 = Watching - // 4 = Custom - // 5 = Competing + // 0 = Playing, 1 = Streaming, 2 = Listening, 3 = Watching, 5 = Competing activities: [ { - // Text users will see (example: "Playing /help | Titan Bot"). - name: "Made with ❀️", - // Activity type number (0 = Playing). - type: 0, + name: "Watching Channels | Team M00NK1DD Join Today!", + type: 3, // 3 = Watching + }, + { + name: "SCP: Containment Breach", + type: 0, // 0 = Playing }, + { + name: "Made By Vecxson", + type: 2, // 2 = Listening + }, + { + name: "/help | M00NB0T", + type: 0, // 0 = Playing + } ], }, + // ========================= // COMMAND BEHAVIOR @@ -38,7 +47,7 @@ export const botConfig = { commands: { // Bot owner user IDs (comma-separated in OWNER_IDS env var). // Owners can access owner/admin-level bot commands. - owners: process.env.OWNER_IDS?.split(",") || [], + owners: process.env.OWNER_IDS?.split("1398076402460524545,1291778460641136641,1055209717544276040") || [], // Default wait time between command uses (in seconds). defaultCooldown: 3, @@ -56,9 +65,12 @@ export const botConfig = { applications: { // Default questions shown when someone fills out an application. defaultQuestions: [ - { question: "What is your name?", required: true }, + { question: "What is your Username? (NOT DISPLAY NAME)", required: true }, { question: "How old are you?", required: true }, { question: "Why do you want to join?", required: true }, + { question: "Do you have Skill In Coding?", required: true }, + { question: "if a person is spamming what would you do?", required: true }, + { question: "Anything else you want to say?", required: true }, ], // Embed colors by application status. @@ -78,7 +90,7 @@ export const botConfig = { deleteApprovedAfter: 30, // Role IDs allowed to manage applications. - managerRoles: [], // Will be populated from environment or database + managerRoles: [1477697404592455927,1497297060318150696,1470022111916462247], // Will be populated from environment or database }, // ========================= @@ -88,8 +100,8 @@ export const botConfig = { embeds: { colors: { // Main brand colors. - primary: "#336699", - secondary: "#2F3136", + primary: "#FFFFFF", + secondary: "#000000", // Standard status colors for success/error/warning/info messages. success: "#57F287", @@ -136,7 +148,7 @@ export const botConfig = { }, footer: { // Default footer text used in bot embeds. - text: "Titan Bot", + text: "M00NB0T", // Footer icon URL (null = no icon). icon: null, }, @@ -156,11 +168,11 @@ export const botConfig = { economy: { currency: { // Currency display name. - name: "coins", + name: "euro", // Plural display name. - namePlural: "coins", + namePlural: "euro", // Currency symbol shown in balances. - symbol: "$", + symbol: "€", }, // Starting balance for new users. @@ -264,10 +276,10 @@ export const botConfig = { maximumDuration: 2592000000, // Role IDs allowed to host giveaways. - allowedRoles: [], + allowedRoles: [1470022111916462247,1497297060318150696,1477697404592455927], // Role IDs that bypass giveaway restrictions. - bypassRoles: [], + bypassRoles: [1477697404592455927,1497297060318150696], }, // ========================= @@ -346,10 +358,10 @@ export const botConfig = { maxAuditMetadataBytes: 4096, // Maximum number of audit entries kept in memory. maxInMemoryAuditEntries: 1000, - // If true, log every verification action. - logAllVerifications: true, - // If true, preserve verification audit history. - keepAuditTrail: true, + // If true, log every verification action. + logAllVerifications: true, + // If true, preserve verification audit history. + keepAuditTrail: true, }, // ========================= @@ -365,9 +377,9 @@ export const botConfig = { defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", // Channel ID for welcome messages. - defaultWelcomeChannel: null, + defaultWelcomeChannel: 1513188432208199820, // Channel ID for goodbye messages. - defaultGoodbyeChannel: null, + defaultGoodbyeChannel: 1513188432208199820, }, // ========================= @@ -543,7 +555,3 @@ export function getRandomColor() { } export default botConfig; - - - - diff --git a/src/events/ready.js b/src/events/ready.js index 8f3db50966..e92548bc4e 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -1,3 +1,4 @@ +import botConfig from '../config/bot.js'; import { Events } from "discord.js"; import { logger, startupLog } from "../utils/logger.js"; import config from "../config/application.js"; @@ -9,7 +10,31 @@ export default { async execute(client) { try { - client.user.setPresence(config.bot.presence); + const { activities, status, cycleInterval } = botConfig.presence; + + // Set initial status + if (activities && activities.length > 0) { + let currentIndex = 0; + + // Set the initial status right away + client.user.setPresence({ + activities: [activities[currentIndex]], + status: status + }); + + // Cycle through statuses + setInterval(() => { + currentIndex = (currentIndex + 1) % activities.length; + + client.user.setPresence({ + activities: [activities[currentIndex]], + status: status + }); + }, cycleInterval || 10000); + } else { + // Fallback if no activities configured + client.user.setPresence(config.bot.presence); + } startupLog(`Ready! Logged in as ${client.user.tag}`); startupLog(`Serving ${client.guilds.cache.size} guild(s)`); @@ -24,5 +49,3 @@ export default { } }, }; - - diff --git a/src/events/voiceStateUpdate.js b/src/events/voiceStateUpdate.js index 6753e76fa7..0e65167b16 100644 --- a/src/events/voiceStateUpdate.js +++ b/src/events/voiceStateUpdate.js @@ -61,7 +61,7 @@ export default { const now = Date.now(); if (channelCreationCooldown.has(cooldownKey)) { const lastCreation = channelCreationCooldown.get(cooldownKey); -if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { + if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { logger.warn(`User ${member.id} is on cooldown for channel creation`); return; } @@ -189,7 +189,6 @@ if (now - lastCreation < VOICE_CREATE_COOLDOWN_MS) { const channelName = sanitizeVoiceChannelName(finalName); -const channelName = sanitizeVoiceChannelName(finalName); if (!member.voice?.channel || member.voice.channel.id !== triggerChannel.id) { logger.debug(`Member ${member.id} no longer in trigger channel ${triggerChannel.id}, aborting temporary channel creation`); channelCreationCooldown.delete(cooldownKey); @@ -198,18 +197,18 @@ const channelName = sanitizeVoiceChannelName(finalName); const tempChannel = await guild.channels.create({ name: channelName, -type: ChannelType.GuildVoice, + type: ChannelType.GuildVoice, parent: triggerChannel.parentId, -userLimit: userLimit === 0 ? undefined : userLimit, + userLimit: userLimit === 0 ? undefined : userLimit, bitrate: bitrate, permissionOverwrites: [ { id: member.id, - allow: ['Connect', 'Speak', 'PrioritySpeaker', 'MoveMembers'] + allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.Speak, PermissionFlagsBits.PrioritySpeaker, PermissionFlagsBits.MoveMembers] }, { id: guild.id, - allow: ['Connect', 'Speak'] + allow: [PermissionFlagsBits.Connect, PermissionFlagsBits.Speak] } ] }); @@ -325,6 +324,3 @@ function trimCooldownMapIfNeeded() { channelCreationCooldown.delete(entries[index][0]); } } - - -