diff --git a/PRODUCTION_READINESS_AUDIT.md b/PRODUCTION_READINESS_AUDIT.md new file mode 100644 index 0000000000..5740febdfa --- /dev/null +++ b/PRODUCTION_READINESS_AUDIT.md @@ -0,0 +1,248 @@ +# TitanBot Production Readiness Audit + +**Date:** 2026-02-25 +**Scope:** Full repository scan (`src`, `config`, `events`, `handlers`, `services`, `utils`, startup/runtime scripts) +**Focus Areas:** Production standards, PostgreSQL ("PQL") security posture, logging consistency, error handling, operational security, and database architecture maturity. + +--- + +## Executive Summary + +TitanBot is **close to production-ready** and demonstrates strong architectural direction (centralized logging utility, robust PostgreSQL wrapper, graceful startup/shutdown, and structured error handling patterns). However, there are several **high-priority security and standardization gaps** that should be closed before enterprise-grade production operation. + +### Overall Rating +- **Current Readiness:** **7.1 / 10** (Good, with important hardening work remaining) +- **Recommendation:** **Conditional Go** for controlled production; complete P0 items before broad rollout. + +### Top Strengths +- PostgreSQL-first architecture with pooling, health checks, retries, indexes, and audit tables. +- Parameterized SQL usage in the majority of data operations. +- Central logger (`winston` + rotate files + exception/rejection handlers). +- Centralized interaction error system with categorized user/system error paths. +- Dependency audit currently clean (`npm audit`: 0 known vulnerabilities). + +### Top Risks +- Unsafe dynamic code execution path present (`new Function`) in calculator flows. +- Inconsistent logging standards: mixed `logger.*` and `console.*` usage. +- Weak database TLS setting (`rejectUnauthorized: false` when SSL enabled). +- Hardcoded external API key fallback exists in movie command. +- No CI workflow found enforcing tests/security/quality gates. + +--- + +## Methodology + +This report is based on: +- Static scan of source tree for security/logging/error/database patterns. +- Review of core runtime/config modules and key services. +- Query pattern inspection (parameterized vs interpolated SQL). +- Dependency risk check (`npm audit --json`). +- Operational-readiness signals (health/readiness endpoints, migration scripts, policy docs, workflow presence). + +--- + +## Scorecard by Domain + +| Domain | Score | Status | Summary | +|---|---:|---|---| +| Runtime Architecture | 8.0 | Good | Clean modular layout, service separation, and startup sequencing | +| PostgreSQL / Data Layer | 7.5 | Good | Strong schema/index setup; TLS and migration governance need hardening | +| PQL/SQL Security | 7.0 | Moderate | Parameterized queries mostly strong; dynamic SQL surfaces need guardrails | +| Logging Consistency | 6.0 | Moderate Risk | Central logger exists, but substantial `console.*` drift remains | +| Error Handling | 8.0 | Good | Centralized error categorization and interaction-safe response handling | +| Secrets & Config Security | 5.8 | Moderate Risk | Env-based model is good; hardcoded API key fallback is a security anti-pattern | +| Operations / Delivery | 5.5 | Moderate Risk | Health/readiness present, but no CI workflows detected | +| Dependency Security | 9.0 | Strong | Current advisory scan clean | + +--- + +## Findings (Pros, Cons, Improvements) + +## 1) PostgreSQL / PQL Security & Database Standards + +### Pros +- PostgreSQL wrapper includes: + - Connection pooling and retry/backoff. + - `statement_timeout` in production. + - Automatic table/index creation. + - Audit-oriented schema (`verification_audit`) and timestamp triggers. +- Most SQL operations use bind parameters (`$1`, `$2`, ...), reducing injection risk. +- Production protection exists in `src/services/database.js`: fallback storage is refused in production. + +### Cons / Risks +- SSL config uses `rejectUnauthorized: false` when `POSTGRES_SSL=true`, which weakens TLS trust verification. +- Some SQL statements interpolate identifiers (table/trigger names) dynamically; currently fed by internal constants, but still a sensitive surface. +- Two database access layers coexist (`src/utils/database.js` and `src/services/database.js`), increasing behavior divergence risk. +- No explicit migration history enforcement in standard runtime path (migration tool exists but workflow governance is unclear). + +### Improvements +- **P0:** Enforce strict TLS in production (`rejectUnauthorized: true` + CA bundle support). +- **P1:** Add identifier allowlist/assertion utility for dynamic SQL identifiers. +- **P1:** Consolidate to a single database abstraction or formally define boundaries between the two layers. +- **P1:** Add migration ledger/check gate at startup (or CI) to prevent schema drift. + +--- + +## 2) Logging Consistency & Observability + +### Pros +- Strong central logger (`winston`) with: + - Rotating files for combined/error logs. + - Exception/rejection handlers. + - Structured metadata support. +- Logging service provides event taxonomy and guild-level filtering. +- Good startup/shutdown and degraded-mode logging visibility. + +### Cons / Risks +- Logging standards are inconsistent across repo: + - Approx. **644** `logger.*` calls (good adoption) + - Approx. **60** `console.*` calls (standard drift) +- `console.*` usage bypasses central formatting, correlation, and retention behavior. +- No request/interaction correlation ID propagated consistently across services. + +### Improvements +- **P0:** Replace direct `console.*` with centralized logger wrapper. +- **P1:** Introduce correlation IDs for command interactions and propagate through service calls. +- **P1:** Define logging schema contract (`event`, `guildId`, `userId`, `command`, `errorCode`, `traceId`). +- **P2:** Add log quality checks (lint rule or CI grep guard) to block new `console.*` usage. + +--- + +## 3) Error Handling & Resilience + +### Pros +- Centralized `errorHandler` supports: + - Typed error categories. + - User-safe messaging. + - System-vs-user error separation. + - Safe interaction reply/edit fallback logic. +- `interactionCreate` catches handler-level failures and provides fallback user response. +- Startup catches fatal failures and exits in a controlled way. + +### Cons / Risks +- Error shape still varies by module (some throw typed errors, others raw errors). +- Some paths still report through console rather than central error flow. +- Limited evidence of automated negative-path testing for error contracts. + +### Improvements +- **P1:** Require typed application errors in service boundary layers. +- **P1:** Add error code registry and map to remediation hints. +- **P2:** Add tests for critical error scenarios (DB unavailable, Discord API failures, expired interactions). + +--- + +## 4) Security Posture (Application + Secrets) + +### Pros +- Security policy file exists with disclosure workflow and hardening guidance. +- Permission checks/audits are present in command helpers. +- Input sanitization helpers and schema normalization (`zod`) are in use. +- Dependency scan currently reports zero known vulnerabilities. + +### Cons / Risks +- **High-risk:** Dynamic execution present via `new Function` in calculator-related code paths. +- **High-risk:** Hardcoded TMDB API key fallback in movie search command. +- Sanitization utility is partial and may not be uniformly applied in all user-input flows. +- No CI workflow found for automated SAST/dependency/license/security policy enforcement. + +### Improvements +- **P0:** Remove `new Function`; replace with strict expression parser/evaluator (whitelist-based AST parser). +- **P0:** Remove hardcoded API key fallback and fail closed when env var is missing. +- **P1:** Expand schema-based validation (`zod`) for all command payloads and config mutations. +- **P1:** Add secret scanning and dependency policy checks in CI. +- **P2:** Add abuse protections for high-risk endpoints/commands (cooldown + anomaly logging). + +--- + +## 5) Operations, Reliability, and Production Standards + +### Pros +- Health (`/health`) and readiness (`/ready`) endpoints exist. +- Startup sequence and graceful shutdown behavior are implemented. +- Cron job registration and periodic maintenance tasks are structured. + +### Cons / Risks +- No GitHub Actions workflows detected in repository (build/test/security gates absent). +- No explicit SLO/SLI definitions or alert threshold policy found. +- Backup/restore drills and DR evidence are not represented in repo artifacts. + +### Improvements +- **P0:** Add CI workflows: lint, syntax checks, unit tests, dependency audit, secret scan. +- **P1:** Define minimum release gates (branch protection + required checks). +- **P1:** Add operational runbook docs for incident, rollback, and DB restore validation. +- **P2:** Add service-level metrics and alerting playbook (error rate, latency, DB connectivity, command failure rate). + +--- + +## Priority Remediation Plan + +### P0 (Do Before Broad Production Rollout) +1. Replace `new Function` evaluator with safe math parser. +2. Remove hardcoded TMDB API key fallback. +3. Enforce strict PostgreSQL TLS verification in production. +4. Eliminate `console.*` in runtime/service layers (route through logger). +5. Add basic CI workflow with mandatory security and quality checks. + +### P1 (Next 2โ€“4 Weeks) +1. Consolidate database access abstractions and codify single source of truth. +2. Add correlation IDs and standardized structured log fields. +3. Expand schema validation coverage for commands/config writes. +4. Add migration governance (version checks and controlled rollout). + +### P2 (Maturity / Scale) +1. Add SLO/SLI monitoring with alerts. +2. Add backup/restore automation and periodic drill evidence. +3. Add policy-as-code checks for security controls and code quality drift. + +--- + +## Evidence Snapshot + +- Dependency audit: **0 vulnerabilities** (prod deps: 149). +- Logging pattern scan: + - `logger.*`: ~644 occurrences + - `console.*`: ~60 occurrences +- Dynamic execution scan: + - `new Function`: detected in calculator-related modules. +- Security-sensitive config scan: + - PostgreSQL SSL currently allows `rejectUnauthorized: false` when enabled. +- Repo operations scan: + - No `.github/workflows/*` files detected. + +--- + +## Final Verdict + +TitanBot is **architecturally solid and operationally promising**, but it is **not yet at full industry-standard production hardening** due to specific P0 security and governance gaps. + +If P0 actions are completed, the project moves from **Conditional Production** to **Production Ready (Standard)**. If P1 actions are then completed, it reaches **Production Ready (Mature)** for larger-scale deployments. + +--- + +## Simple Improvement List (Copy/Paste) + +Use this checklist to track everything that should be improved: + +1. Turn on strict PostgreSQL TLS checks in production (require trusted cert validation). +2. Add a safe allowlist check for any dynamic SQL identifiers (like table/trigger names). +3. Merge the two database access layers into one standard approach (or clearly define strict boundaries). +4. Add migration version checks in startup/CI so schema drift is blocked. +5. Replace all `console.*` logs with the central logger. +6. Add a correlation/trace ID for each interaction and pass it through service calls. +7. Standardize log fields (`event`, `guildId`, `userId`, `command`, `errorCode`, `traceId`). +8. Add a lint/CI rule to block new `console.*` usage. +9. Enforce typed application errors at service boundaries. +10. Create an error code registry with remediation hints. +11. Add tests for major failure paths (DB down, Discord API failure, expired interaction). +12. Remove `new Function` and use a safe whitelist-based math parser. +13. Remove hardcoded TMDB key fallback; fail safely if env key is missing. +14. Expand `zod` validation to all command inputs and config writes. +15. Add CI secret scanning and dependency policy checks. +16. Add abuse protections on risky commands (cooldowns + anomaly logging). +17. Create CI workflows for lint, syntax checks, tests, dependency audit, and secret scan. +18. Add branch protection and required checks as release gates. +19. Add runbooks for incident response, rollback, and DB restore steps. +20. Add service metrics + alert playbooks (error rate, latency, DB connectivity, command failure rate). +21. Define SLO/SLI targets and alert thresholds. +22. Add backup/restore automation and run regular restore drills. +23. Add policy-as-code checks to prevent security/quality drift. diff --git a/README.md b/README.md index 5bcb712024..f897a9e0ac 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ TitanBot offers a complete suite of tools for Discord server management and comm - **Ticket Limits** - Prevent spam - **Transcript System** - Save ticket history -### ๐Ÿ”ข Server Counters +### ๏ฟฝ Server Stats - **Member Counter** - Live member count channels - **Voice Counters** - Track voice stats - **Dynamic Updates** - Real-time channel updates @@ -88,12 +88,11 @@ TitanBot offers a complete suite of tools for Discord server management and comm - ## ๐Ÿš€ Quick Setup (recommend) ### ๐Ÿ“น Video Tutorial For a detailed step-by-step setup guide, watch our comprehensive video tutorial: -[**TitanBot Setup Tutorial**](https://youtu.be/1jCZX8s3bJE) +[**TitanBot Setup Tutorial**](https://www.youtube.com/@TouchDisc) ## โš™๏ธ Manual Installation Steps @@ -178,6 +177,10 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: - `npm run migrate` applies schema setup and records the expected schema version. - `npm run migrate:check` fails if the database schema version does not match the code's expected version. - `npm run migrate:status` prints current vs expected schema version metadata. +<<<<<<< HEAD + +======= +>>>>>>> main ## ๐Ÿ—„๏ธ Database System @@ -198,6 +201,10 @@ TitanBot uses **PostgreSQL** as its primary database with intelligent fallback t - **Graceful Degradation**: Bot continues functioning without database - **Backward Compatibility**: Maintains existing API structure - **Zero Downtime**: Seamless switching between database and memory +<<<<<<< HEAD + +======= +>>>>>>> main ## ๐Ÿ—๏ธ Bot Architecture @@ -211,8 +218,18 @@ TitanBot uses **PostgreSQL** as its primary database with intelligent fallback t ### Bot Intents TitanBot requires the following Discord intents: +<<<<<<< HEAD +- Guilds +- Guild Messages +- Message Content +- Guild Members +- Guild Message Reactions +- Guild Voice States +- Direct Messages +======= - Bot - Applications.commands +>>>>>>> main ### Required Permissions - **View Channels** @@ -224,9 +241,14 @@ TitanBot requires the following Discord intents: - **Manage Channels** - **Manage Roles** - **Kick Members** +<<<<<<< HEAD +- **Manage Messages** + +======= - **Ban Members** - **Moderate Members** - **Connect** +>>>>>>> main ## ๐Ÿค Contributing @@ -252,8 +274,12 @@ TitanBot is released under the MIT License. See [LICENSE](LICENSE) for details. Thank you for choosing TitanBot for your Discord server! We're constantly working to improve and add new features based on community feedback. +<<<<<<< HEAD +**Made with โค๏ธ** +======= **Made with โค๏ธ** --- *Last updated: March 2026* +>>>>>>> main diff --git a/package-lock.json b/package-lock.json index 2a62909de1..9f14dea824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "web-streams-polyfill": "^3.3.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" @@ -43,14 +42,14 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -85,19 +84,19 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -117,6 +116,15 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", @@ -204,11 +212,11 @@ } }, "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/triple-beam": { @@ -256,13 +264,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/body-parser": { @@ -377,9 +385,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "engines": { "node": ">=18" }, @@ -445,28 +453,28 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.39", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", - "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==" + "version": "0.38.46", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.46.tgz", + "integrity": "sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==" }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -476,9 +484,9 @@ } }, "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "engines": { "node": ">=12" }, @@ -657,9 +665,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -884,9 +892,9 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash.snakecase": { "version": "4.1.1", @@ -1048,22 +1056,22 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1089,9 +1097,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -1102,17 +1110,17 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -1185,14 +1193,17 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dependencies": { "side-channel": "^1.1.0" }, @@ -1352,12 +1363,12 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1478,17 +1489,17 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" }, "node_modules/unpipe": { "version": "1.0.0", @@ -1511,14 +1522,6 @@ "node": ">= 0.8" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "engines": { - "node": ">= 8" - } - }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -1576,9 +1579,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "engines": { "node": ">=10.0.0" }, @@ -1629,14 +1632,14 @@ } }, "@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "requires": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -1656,25 +1659,30 @@ } }, "@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "requires": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "^6.23.0" + "undici": "6.24.1" }, "dependencies": { "@discordjs/collection": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==" + }, + "@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==" } } }, @@ -1738,11 +1746,11 @@ } }, "@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "requires": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "@types/triple-beam": { @@ -1783,13 +1791,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "requires": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "body-parser": { @@ -1870,9 +1878,9 @@ } }, "content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" }, "content-type": { "version": "1.0.5", @@ -1908,34 +1916,34 @@ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "discord-api-types": { - "version": "0.38.39", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", - "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==" + "version": "0.38.46", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.46.tgz", + "integrity": "sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==" }, "discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "requires": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "^6.23.0" + "undici": "6.24.1" } }, "dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==" + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==" }, "dunder-proto": { "version": "1.0.1", @@ -2073,9 +2081,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" }, "form-data": { "version": "4.0.5", @@ -2217,9 +2225,9 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "lodash.snakecase": { "version": "4.1.1", @@ -2332,19 +2340,19 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, "pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "requires": { "pg-cloudflare": "^1.3.0", - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" } @@ -2356,9 +2364,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" }, "pg-int8": { "version": "1.0.1", @@ -2366,15 +2374,15 @@ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, "pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "requires": {} }, "pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" }, "pg-types": { "version": "2.2.0", @@ -2429,14 +2437,14 @@ } }, "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, "qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "requires": { "side-channel": "^1.1.0" } @@ -2541,12 +2549,12 @@ } }, "side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "requires": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" } }, "side-channel-map": { @@ -2631,14 +2639,14 @@ } }, "undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==" + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==" }, "undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" }, "unpipe": { "version": "1.0.0", @@ -2655,11 +2663,6 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" - }, "winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -2705,9 +2708,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "requires": {} }, "xtend": { diff --git a/package.json b/package.json index 720a37903b..689c5394f5 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,11 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "web-streams-polyfill": "^3.3.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, "devDependencies": {}, - "overrides": { - "undici": "^6.23.0" - }, "engines": { "node": ">=18.0.0" } diff --git a/roadmap.txt b/roadmap.txt index 207e7b101d..d1ebec84b1 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -3,69 +3,146 @@ TitanBot Roadmap / Structure Overview Goal - Keep this as a practical map of the codebase. - Show main folders and important files only (not every file). +- Document all implemented systems and features. Root - index.js -> process entry point -- package.json -> scripts, dependencies, project metadata +- package.json -> scripts, dependencies, project metadata (v2.0.0) - README.md -> setup and usage docs - SECURITY.md -> security policy -- scripts/ -> utility/maintenance scripts -- database/ -> database model definitions +- scripts/ -> utility/maintenance scripts (backup, restore, migrate) - logs/ -> runtime log output - src/ -> main bot application code +- tests/ -> test suite (17 tests for core systems) src/ -- app.js -> creates client, initializes systems +- app.js -> creates client, initializes systems, starts web server -- commands/ -> slash command categories - - Core/ -> essential bot commands - - help.js, ping.js, stats.js, uptime.js, invite.js - - Community/ -> application/community commands - - Config/ -> server/bot configuration commands - - Counter/ -> counter channel commands - - Economy/ -> economy system commands - - Moderation/ -> moderation/admin commands - - Ticket/ -> ticket system commands - - Verification/ -> verification commands - - JoinToCreate/ -> dynamic voice channel commands - - Reaction_roles/ -> role assignment commands - - (Also includes: Birthday, Fun, Giveaway, Leveling, Search, Tools, Utility, Voice, Welcome) +- commands/ -> slash command categories (19 feature modules) + - Core/ -> essential bot commands (help, ping, stats, uptime, invite) + - Community/ -> application management system + dashboards + - modules/app_dashboard.js -> interactive applications management UI + - Logging/ -> audit logging + event management + - modules/logging_dashboard.js -> logging configuration dashboard + - modules/logging_filter.js -> ignore list management + - modules/logging_setchannel.js -> log channel configuration + - Ticket/ -> support ticket system + management + - modules/ticket_dashboard.js -> ticket system configuration dashboard + - ServerStats/ -> server stats channel system + - Economy/ -> economy system (currency, shop, transactions) + - Moderation/ -> moderation/admin commands (ban, warn, kick, mute) + - Verification/ -> member verification flow + - JoinToCreate/ -> dynamic voice channel creation + - Reaction_roles/ -> automatic role assignment + - Birthday/ -> birthday tracking and announcements + - Fun/ -> entertainment/game commands + - Giveaway/ -> giveaway management system + - Leveling/ -> xp and level system + - Search/ -> search functionality + - Tools/ -> utility tools + - Utility/ -> general utility commands + - Voice/ -> voice channel features + - Welcome/ -> welcome message system -- config/ -> centralized configuration - - index.js -> config loader/exports - - bot.js -> bot settings, embed theme/colors, feature flags - - postgres.js -> PostgreSQL config - - shop/ -> shop config entries +- config/ -> centralized configuration modules + - application.js -> main application config loader + - bot.js -> bot settings, embed colors/theme, presence, feature flags + - postgres.js -> PostgreSQL connection config and pool settings + - schemaVersion.js -> database schema version tracking + - shop/ -> shop item definitions and economy config -- events/ -> Discord event listeners - - ready.js, interactionCreate.js, messageCreate.js - - guildMemberAdd.js, guildMemberRemove.js, voiceStateUpdate.js +- events/ -> Discord event listeners (13 event handlers) + - ready.js -> bot startup initialization + - interactionCreate.js -> slash command/button/modal interaction handler + - messageCreate.js -> message processing and prefix commands + - messageDelete.js -> message deletion logging + - messageUpdate.js -> message edit logging + - guildMemberAdd.js -> new member join handling + - guildMemberRemove.js -> member leave handling + - guildMemberUpdate.js -> member profile change handling + - voiceStateUpdate.js -> voice channel join/leave handling + - roleCreate.js -> role creation logging + - roleDelete.js -> role deletion logging + - userUpdate.js -> user profile change logging + - channelDelete.js -> channel deletion logging -- handlers/ -> loader/router layer for commands/interactions - - commandLoader.js, commands.js, events.js, interactions.js - - helpSelectMenuLoader.js, helpButtons.js - - ticketButtons.js, verificationButtonLoader.js +- handlers/ -> interaction routing and component handlers + - commandLoader.js -> loads all slash commands at startup + - commands.js -> slash command execution router + - events.js -> Discord event listener registration + - interactions.js -> button/select menu/modal interaction router + - interactionHandlers/ -> specific interaction handler modules + - calculateButtons.js -> calculator button interactions + - calculateModals.js -> calculator modal interactions + - countdownButtons.js -> countdown timer button handlers + - counterButtons.js -> counter system button handlers + - giveawayButtons.js -> giveaway participation handlers + - helpButtons.js -> help command buttons + - helpSelectMenus.js -> help command select menus + - loggingButtons.js -> logging dashboard buttons + - reactionRoles.js -> reaction role assignment handlers + - ticketButtons.js -> ticket system button handlers + - todoButtons.js -> todo system button handlers + - verificationButtons.js -> verification button handlers + - wipedataButtons.js -> wipe data confirmation handlers -- interactions/ -> interaction-specific handlers - - verificationButtonHandler.js - - selectMenus/ -> select menu handlers +- interactions/ -> Discord interaction-specific handlers + - buttons/ -> button component response handlers + - modals/ -> modal form submission handlers + - selectMenus/ -> select menu choice handlers -- services/ -> business logic + data operations - - database.js -> storage abstraction - - guildConfig.js -> guild-level settings service - - ticket.js, giveawayService.js, reactionRoleService.js - - leveling.js, xpSystem.js, economy.js, counterService.js +- services/ -> business logic + data operations (22 service modules) + - database.js -> storage abstraction layer + - guildConfig.js -> guild-level settings persistence + - applicationService.js -> application role management + - birthdayService.js -> birthday tracking and notifications + - configService.js -> configuration management + - economy.js -> economy system operations + - economyService.js -> economy service layer + - giveawayService.js -> giveaway management and scheduling + - joinToCreateService.js -> dynamic voice channel creation + - leveling.js -> user leveling system + - loggingService.js -> audit logging service + - moderationService.js -> moderation operations (warnings, bans, etc) + - reactionRoleService.js -> reaction role assignment + - serverstatsService.js -> server statistics tracking + - shopService.js -> economy shop management + - ticket.js -> ticket system operations + - utilityService.js -> utility helper functions + - verificationService.js -> member verification + - voiceService.js -> voice channel operations + - warningService.js -> user warning system + - welcomeService.js -> welcome message system + - xpSystem.js -> experience points calculation -- utils/ -> shared helpers/utilities - - embeds.js -> standardized embed creation - - components.js -> Discord UI component builders - - logger.js -> logging utility - - postgresDatabase.js, memoryStorage.js, schemas.js - - errorHandler.js, permissionGuard.js, interactionHelper.js +- utils/ -> shared helpers and utilities (20+ utility modules) + - logger.js -> logging system with file rotation + - embeds.js -> standardized embed creation helpers + - components.js -> Discord button/select menu/modal builders + - helpers.js -> general purpose helper functions + - constants.js -> application constants and enums + - errorHandler.js -> custom error handling and error registry + - errorRegistry.js -> error message registry + - permissionGuard.js -> permission checking utilities + - interactionHelper.js -> safe interaction response utilities + - interactionValidator.js -> interaction input validation + - database.js -> database utility functions + - postgresDatabase.js -> PostgreSQL-specific operations + - memoryStorage.js -> in-memory fallback storage + - dateUtils.js -> date/time helper functions + - messageTemplates.js -> message template definitions + - abuseProtection.js -> rate limiting and abuse detection + - commandInputValidation.js -> Zod schema validation + - moderation.js -> moderation utility functions + - economy.js -> economy calculation helpers + - giveaways.js -> giveaway scheduling helpers + - loggingUi.js -> logging dashboard UI builders + - rateLimiter.js -> cooldown and rate limit management -Database models -- database/models/welcomeSystem.js -> welcome system schema/model +Database Abstraction Layer +- Supports PostgreSQL (primary) with in-memory fallback for development +- Graceful degradation: if PostgreSQL unavailable, bot switches to in-memory mode Notes -- This project is modular by feature area: commands -> handlers -> services -> utils. -- bot.js is the single source of truth for embed theming/colors and core bot config. +- This project is modular by feature area: commands -> handlers -> services -> utils +- bot.js is the single source of truth for embed theming/colors and core bot config diff --git a/src/app.js b/src/app.js index 8f0e2b4dc7..9de27d1f94 100644 --- a/src/app.js +++ b/src/app.js @@ -4,15 +4,10 @@ import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; -import { ReadableStream } from 'web-streams-polyfill'; -if (typeof global.ReadableStream === 'undefined') { - global.ReadableStream = ReadableStream; -} - import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/guildConfig.js'; -import { getServerCounters, saveServerCounters, updateCounter } from './services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js index 92bab80fe5..fcd9fafd5f 100644 --- a/src/commands/Birthday/birthday.js +++ b/src/commands/Birthday/birthday.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -8,6 +8,7 @@ import birthdayInfo from './modules/birthday_info.js'; import birthdayList from './modules/birthday_list.js'; import birthdayRemove from './modules/birthday_remove.js'; import nextBirthdays from './modules/next_birthdays.js'; +import birthdaySetchannel from './modules/birthday_setchannel.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -60,6 +61,18 @@ export default { subcommand .setName('next') .setDescription('Show upcoming birthdays') + ) + .addSubcommand(subcommand => + subcommand + .setName('setchannel') + .setDescription('Set or disable the channel for birthday announcements. (Manage Server required)') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The text channel for announcements. Leave empty to disable.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false) + ) ), async execute(interaction, config, client) { @@ -77,6 +90,8 @@ export default { return await birthdayRemove.execute(interaction, config, client); case 'next': return await nextBirthdays.execute(interaction, config, client); + case 'setchannel': + return await birthdaySetchannel.execute(interaction, config, client); default: return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Error', 'Unknown subcommand')], diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js new file mode 100644 index 0000000000..2da4b9409a --- /dev/null +++ b/src/commands/Birthday/modules/birthday_setchannel.js @@ -0,0 +1,44 @@ +import { PermissionsBitField, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to configure the birthday channel.')], + flags: MessageFlags.Ephemeral, + }); + } + + try { + const channel = interaction.options.getChannel('channel'); + const guildId = interaction.guildId; + const guildConfig = await getGuildConfig(client, guildId); + + if (channel) { + guildConfig.birthdayChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('๐ŸŽ‚ Birthday Announcements Enabled', `Birthday announcements will now be posted in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + } else { + guildConfig.birthdayChannelId = null; + await setGuildConfig(client, guildId, guildConfig); + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('๐ŸŽ‚ Birthday Announcements Disabled', 'No channel provided โ€” birthday announcements have been disabled.')], + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + logger.error('birthday_setchannel error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Configuration Error', 'Could not save the birthday channel configuration.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index 898c1ec4f8..06ce3d2da0 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getColor } from '../../config/bot.js'; import { logger } from '../../utils/logger.js'; @@ -11,9 +11,13 @@ import { getApplications, updateApplication, getApplicationRoles, - saveApplicationRoles + saveApplicationRoles, + getApplicationRoleSettings, + saveApplicationRoleSettings, + deleteApplication } from '../../utils/database.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import appDashboard from './modules/app_dashboard.js'; function getApplicationStatusPresentation(statusValue) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; @@ -39,40 +43,7 @@ export default { .addSubcommand((subcommand) => subcommand .setName("setup") - .setDescription("Configure application settings") - .addChannelOption((option) => - option - .setName("log-channel") - .setDescription( - "Channel where new applications will be logged", - ) - .setRequired(false), - ) - .addRoleOption((option) => - option - .setName("manager-role") - .setDescription( - "Role that can manage applications (can be used multiple times)", - ) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("enabled") - .setDescription("Enable or disable applications") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("view") - .setDescription("View a specific application") - .addStringOption((option) => - option - .setName("id") - .setDescription("The application ID") - .setRequired(true), - ), + .setDescription("Set up a new application") ) .addSubcommand((subcommand) => subcommand @@ -83,22 +54,6 @@ export default { .setName("id") .setDescription("The application ID") .setRequired(true), - ) - .addStringOption((option) => - option - .setName("action") - .setDescription("Approve or deny the application") - .setRequired(true) - .addChoices( - { name: "Approve", value: "approve" }, - { name: "Deny", value: "deny" }, - ), - ) - .addStringOption((option) => - option - .setName("reason") - .setDescription("Reason for approval/denial") - .setRequired(false), ), ) .addSubcommand((subcommand) => @@ -133,37 +88,15 @@ export default { ) .addSubcommand((subcommand) => subcommand - .setName("roles") - .setDescription("Manage application roles") - .addStringOption((option) => - option - .setName("action") - .setDescription("Action to perform") - .setRequired(true) - .addChoices( - { name: "Add Role", value: "add" }, - { name: "Remove Role", value: "remove" }, - { name: "List Roles", value: "list" } - ) - ) - .addRoleOption((option) => - option - .setName("role") - .setDescription("The role to add/remove") - .setRequired(false) - ) + .setName("dashboard") + .setDescription("Open the applications configuration dashboard") .addStringOption((option) => option - .setName("name") - .setDescription("Custom name for the application") + .setName("application") + .setDescription("Select an application to configure") .setRequired(false) - .setMaxLength(50) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("questions") - .setDescription("Configure application questions") + .setAutocomplete(true), + ), ), category: "Community", @@ -179,8 +112,8 @@ export default { const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); - if (subcommand !== "questions") { - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + if (subcommand !== 'dashboard' && subcommand !== 'setup') { + await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); } logger.info(`App-admin command executed: ${subcommand}`, { @@ -189,206 +122,172 @@ export default { subcommand }); - + // โœ“ Permission check: User must have ManageGuild permission or a configured manager role + // This prevents unauthorized users from accessing admin functions await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); if (subcommand === "setup") { await handleSetup(interaction); - } else if (subcommand === "view") { - await handleView(interaction); } else if (subcommand === "review") { await handleReview(interaction); } else if (subcommand === "list") { await handleList(interaction); - } else if (subcommand === "roles") { - await handleRoles(interaction); - } else if (subcommand === "questions") { - await handleQuestions(interaction); + } else if (subcommand === "dashboard") { + const selectedAppName = interaction.options.getString("application"); + await appDashboard.execute(interaction, null, interaction.client, selectedAppName); } }, { type: 'command', commandName: 'app-admin' }) }; async function handleSetup(interaction) { - const logChannel = interaction.options.getChannel("log-channel"); - const managerRole = interaction.options.getRole("manager-role"); - const enabled = interaction.options.getBoolean("enabled"); - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const updates = {}; - - if (logChannel) { - if (!logChannel.isTextBased()) { - throw createError( - 'Invalid channel type', - ErrorTypes.VALIDATION, - 'The log channel must be a text channel.', - { channelId: logChannel.id } - ); - } - updates.logChannelId = logChannel.id; - } - - if (managerRole) { - const managerRoles = new Set(settings.managerRoles || []); - if (managerRoles.has(managerRole.id)) { - managerRoles.delete(managerRole.id); - updates.managerRoles = Array.from(managerRoles); - } else { - managerRoles.add(managerRole.id); - updates.managerRoles = Array.from(managerRoles); - } - } - - if (enabled !== null) { - updates.enabled = enabled; - } - - if (Object.keys(updates).length === 0) { - return showCurrentSettings(interaction, settings); + // Ensure interaction hasn't been deferred/replied yet (safety check) + if (interaction.deferred || interaction.replied) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed("This interaction has already been processed. Please try the command again.")], + flags: ["Ephemeral"], + }); } - const updatedSettings = await ApplicationService.updateSettings( - interaction.client, - interaction.guild.id, - updates - ); - - await showCurrentSettings(interaction, updatedSettings); -} + // Show modal for setting up a new application + const modal = new ModalBuilder() + .setCustomId('app_setup_modal') + .setTitle('Set Up New Application'); + + const rows = [ + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_name') + .setLabel('Application Name') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g., Moderator, Helper, Developer') + .setMaxLength(50) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('role_id') + .setLabel('Role ID') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Right-click a role and copy its ID') + .setMaxLength(20) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_1') + .setLabel('Question 1 (required)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Why do you want this role?') + .setMaxLength(100) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_2') + .setLabel('Question 2 (optional)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('What experience do you have?') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_3') + .setLabel('Question 3 (optional)') + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(false), + ), + ]; + + modal.addComponents(...rows); + + await interaction.showModal(modal); -async function showCurrentSettings(interaction, settings) { - const embed = createEmbed({ title: "Application Settings", description: "Current configuration for the application system.", }); + try { + const submitted = await interaction.awaitModalSubmit({ + time: 15 * 60 * 1000, // 15 minutes + filter: (i) => + i.customId === 'app_setup_modal' && + i.user.id === interaction.user.id, + }); - embed.addFields( - { - name: "Status", - value: settings.enabled ? "โœ… Enabled" : "โŒ Disabled", - inline: true, - }, - { - name: "Log Channel", - value: settings.logChannelId - ? `<#${settings.logChannelId}>` - : "Not set", - inline: true, - }, - { - name: "Manager Roles", - value: - settings.managerRoles?.length > 0 - ? settings.managerRoles.map((id) => `<@&${id}>`).join(", ") - : "Server Admins only", - inline: false, - }, - { - name: "Default Questions", - value: - `There are ${settings.questions?.length || 0} default questions configured.\n` + - `Use \`/app-admin questions\` to edit them.`, - inline: false, - }, - ); + const appName = submitted.fields.getTextInputValue('app_name').trim(); + const roleId = submitted.fields.getTextInputValue('role_id').trim(); + const questions = [ + submitted.fields.getTextInputValue('app_question_1').trim(), + submitted.fields.getTextInputValue('app_question_2').trim(), + submitted.fields.getTextInputValue('app_question_3').trim(), + ].filter(q => q.length > 0); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); -} + // Get the role to verify it exists + let role; + try { + role = await interaction.guild.roles.fetch(roleId); + } catch (error) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Role', 'The role ID you provided does not exist.')], + flags: ['Ephemeral'], + }); + return; + } -async function handleView(interaction) { - const appId = interaction.options.getString("id"); - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); + // Check if this role is already an application + const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + if (existingRoles.some(r => r.roleId === roleId)) { + await submitted.reply({ + embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], + flags: ['Ephemeral'], + }); + return; + } - if (!application) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Application not found.")], - flags: ["Ephemeral"], + // Add the role to applications with enabled status + existingRoles.push({ + roleId: roleId, + name: appName, + enabled: true, // New applications start enabled }); - } - const { normalized: rawStatus, statusLabel, statusEmoji } = getApplicationStatusPresentation(application?.status); - const statusColor = rawStatus === "approved" ? getColor('success') : (rawStatus === "denied" ? getColor('error') : getColor('warning')); - const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; - const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) - ? submittedAt.toLocaleString() - : 'Unknown date'; - const roleName = application?.roleName || 'Unknown Role'; - const embed = createEmbed({ title: `${statusEmoji} Application #${application.id} - ${roleName}`, description: `**User:** <@${application.userId}> (${application.userId})\n **Status:** ${statusEmoji} ${statusLabel}\n` + - (application.reviewer - ? `**Reviewed by:** <@${application.reviewer}>\n` - : "") + - (application.reviewMessage - ? `**Note:** ${application.reviewMessage}\n` - : "") + - `**Submitted on:** ${submittedAtDisplay}`, - }).setColor(statusColor); - - if (application.avatar) { - embed.setThumbnail(application.avatar); - } + await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - const answers = Array.isArray(application.answers) ? application.answers : []; - answers.forEach((answer) => { - const question = typeof answer?.question === 'string' && answer.question.trim().length > 0 - ? answer.question - : 'Question'; - const response = typeof answer?.answer === 'string' && answer.answer.length > 0 - ? answer.answer - : 'No response provided.'; - embed.addFields({ - name: question, - value: - response.length > 1000 - ? response.substring(0, 997) + "..." - : response, - inline: false, - }); - }); + // Enable the system + const settings = await getApplicationSettings(interaction.client, interaction.guild.id); + if (!settings.enabled) { + await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); + } - if (answers.length === 0) { - embed.addFields({ - name: 'Application Answers', - value: 'No answers were stored for this application.', - inline: false, + // Save the questions for this specific role + await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); + + await submitted.reply({ + embeds: [successEmbed( + 'โœ… Application Created', + `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, + )], + flags: ['Ephemeral'], }); - } - if (application.status === "pending") { - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_approve_${application.id}`) - .setLabel("Approve") - .setStyle(ButtonStyle.Success) - .setEmoji("โœ…"), - new ButtonBuilder() - .setCustomId(`app_deny_${application.id}`) - .setLabel("Deny") - .setStyle(ButtonStyle.Danger) - .setEmoji("โŒ"), - ); + // Auto-open dashboard with this app selected + setTimeout(() => { + appDashboard.execute(submitted, null, interaction.client, appName); + }, 500); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - flags: ["Ephemeral"], - }); - } else { - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); + } catch (error) { + if (error.message.includes('timeout')) { + logger.info('App setup modal timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); + return; + } + throw error; } } + async function handleReview(interaction) { const appId = interaction.options.getString("id"); - const action = interaction.options.getString("action"); - const reason = - interaction.options.getString("reason") || "No reason provided."; const application = await getApplication( interaction.client, @@ -411,102 +310,208 @@ async function handleReview(interaction) { }); } - const status = action === "approve" ? "approved" : "denied"; + // Show application details with approve/deny buttons + const appEmbed = createEmbed({ + title: `๐Ÿ“‹ Review Application`, + description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, + color: 'info', + }); - const updatedApplication = await ApplicationService.reviewApplication( - interaction.client, - interaction.guild.id, - appId, - { - action, - reason, - reviewerId: interaction.user.id - } + // Add application answers to the embed + if (application.answers && application.answers.length > 0) { + application.answers.forEach((item, index) => { + appEmbed.addFields({ + name: `Q${index + 1}: ${item.question}`, + value: item.answer || '*No answer provided*', + inline: false + }); + }); + } + + const buttonRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_review_approve_${appId}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`app_review_deny_${appId}`) + .setLabel('Deny') + .setStyle(ButtonStyle.Danger), ); - - try { - const user = await interaction.client.users.fetch(application.userId); - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed( - `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - `Your application for **${application.roleName}** has been **${status}**.\n` + - `**Note:** ${reason}\n\n` + - `Use \`/apply status id:${appId}\` to view details.` - ).setColor(statusColor); + await InteractionHelper.safeEditReply(interaction, { + embeds: [appEmbed], + components: [buttonRow], + flags: ["Ephemeral"], + }); - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.warn('Failed to send DM to user for application review', { - error: error.message, - userId: application.userId, - applicationId: appId - }); - } + // Setup button collector + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId.startsWith(`app_review_approve_${appId}`) || + i.customId.startsWith(`app_review_deny_${appId}`)), + time: 300_000, // 5 minutes + max: 1, + }); + + collector.on('collect', async buttonInteraction => { + const isApprove = buttonInteraction.customId.includes('approve'); + + // Show modal for reason + const reasonModal = new ModalBuilder() + .setCustomId(`app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}`) + .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); + + reasonModal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('review_reason') + .setLabel('Reason (optional)') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Provide a reason for this decision...') + .setMaxLength(500) + .setRequired(false), + ), + ); + + await buttonInteraction.showModal(reasonModal); - - if (application.logMessageId && application.logChannelId) { try { - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const logChannel = interaction.guild.channels.cache.get( - application.logChannelId, + const reasonSubmit = await buttonInteraction.awaitModalSubmit({ + time: 5 * 60 * 1000, // 5 minutes + filter: i => + i.customId === `app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}` && + i.user.id === buttonInteraction.user.id, + }).catch(() => null); + + if (!reasonSubmit) return; + + const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "No reason provided."; + const action = isApprove ? 'approve' : 'deny'; + const status = isApprove ? 'approved' : 'denied'; + + const updatedApplication = await ApplicationService.reviewApplication( + reasonSubmit.client, + interaction.guild.id, + appId, + { + action, + reason, + reviewerId: reasonSubmit.user.id + } ); - if (logChannel) { - const logMessage = await logChannel.messages.fetch( - application.logMessageId, - ); - if (logMessage) { - const embed = logMessage.embeds[0]; - if (embed) { - const reviewStatus = getApplicationStatusPresentation(status); - const newEmbed = EmbedBuilder.from(embed) - .setColor(statusColor) - .spliceFields(0, 1, { - name: "Status", - value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, - }); - await logMessage.edit({ - embeds: [newEmbed], -components: [], - }); + // Send DM to user + try { + const user = await reasonSubmit.client.users.fetch(application.userId); + const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const reviewStatus = getApplicationStatusPresentation(status); + const dmEmbed = createEmbed( + `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, + `Your application for **${application.roleName}** has been **${status}**\n` + + `**Note:** ${reason}\n\n` + + `Use \`/apply status id:${appId}\` to view details.` + ).setColor(statusColor); + + await user.send({ embeds: [dmEmbed] }); + } catch (error) { + logger.warn('Failed to send DM to user for application review', { + error: error.message, + userId: application.userId, + applicationId: appId + }); + } + + // Update log message + if (application.logMessageId && application.logChannelId) { + try { + const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const logChannel = interaction.guild.channels.cache.get( + application.logChannelId, + ); + if (logChannel) { + const logMessage = await logChannel.messages.fetch( + application.logMessageId, + ); + if (logMessage) { + const embed = logMessage.embeds[0]; + if (embed) { + const reviewStatus = getApplicationStatusPresentation(status); + const newEmbed = EmbedBuilder.from(embed) + .setColor(statusColor) + .spliceFields(0, 1, { + name: "Status", + value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, + }); + + await logMessage.edit({ + embeds: [newEmbed], + components: [], + }); + } + } } + } catch (error) { + logger.warn('Failed to update log message for application', { + error: error.message, + applicationId: appId, + logMessageId: application.logMessageId + }); } } - } catch (error) { - logger.warn('Failed to update log message for application', { - error: error.message, - applicationId: appId, - logMessageId: application.logMessageId + + // Assign role if approved + if (isApprove) { + try { + const member = await interaction.guild.members.fetch( + application.userId, + ); + await member.roles.add(application.roleId); + } catch (error) { + logger.error('Failed to assign role to approved applicant', { + error: error.message, + userId: application.userId, + roleId: application.roleId, + applicationId: appId + }); + } + } + + // Respond to modal submission + await reasonSubmit.reply({ + embeds: [ + successEmbed( + `Application ${status}`, + `The application has been **${status}**.`, + ), + ], + flags: ["Ephemeral"], }); - } - } - if (action === "approve") { - try { - const member = await interaction.guild.members.fetch( - application.userId, - ); - await member.roles.add(application.role); } catch (error) { - logger.error('Failed to assign role to approved applicant', { - error: error.message, - userId: application.userId, - roleId: application.roleId, - applicationId: appId + logger.error('Error reviewing application:', error); + await buttonInteraction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while reviewing the application.')], + flags: ["Ephemeral"], }); } - } + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `Application ${status}`, - `The application has been ${status}.`, - ), - ], - flags: ["Ephemeral"], + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = createEmbed({ + title: 'โฑ๏ธ Review Timeout', + description: 'The review buttons have timed out.', + color: 'warning', + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } }); } @@ -516,13 +521,34 @@ async function handleList(interaction) { const limit = interaction.options.getNumber("limit") || 10; const filters = {}; - if (status) filters.status = status; + // Default to showing only pending applications if no status specified + if (status) { + filters.status = status; + } else { + filters.status = 'pending'; + } let applications = await getApplications( interaction.client, interaction.guild.id, filters, ); + + // Filter out applications from users who are no longer in the guild (except if filtering by specific user) + if (!user) { + applications = await Promise.all( + applications.map(async (app) => { + try { + await interaction.guild.members.fetch(app.userId); + return app; // User still in guild + } catch { + // User no longer in guild, delete the application + await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); + return null; // Mark for removal + } + }) + ).then(results => results.filter(Boolean)); // Remove nulls + } if (user) { applications = applications.filter((app) => app.userId === user.id); @@ -595,202 +621,6 @@ async function handleList(interaction) { }); } -async function handleQuestions(interaction) { - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const modal = new ModalBuilder() - .setCustomId("edit_questions") - .setTitle("Edit Application Questions"); - - const questions = - settings.questions || ["Question 1", "Question 2"]; - - for (let i = 0; i < 5; i++) { - const input = new TextInputBuilder() - .setCustomId(`q${i}`) - .setLabel(`Question ${i + 1}`) - .setStyle(TextInputStyle.Short) -.setRequired(i === 0) - .setValue(questions[i] || ""); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - } - - await interaction.showModal(modal); - - try { - const modalResponse = await interaction.awaitModalSubmit({ -time: 30 * 60 * 1000, - filter: (i) => - i.customId === "edit_questions" && - i.user.id === interaction.user.id, - }); - - const newQuestions = []; - for (let i = 0; i < 5; i++) { - const question = modalResponse.fields - .getTextInputValue(`q${i}`) - .trim(); - if (question) { - newQuestions.push(question); - } - } - - if (newQuestions.length === 0) { - return modalResponse.reply({ - embeds: [errorEmbed("You must provide at least one question.")], - flags: ["Ephemeral"], - }); - } - - await ApplicationService.updateSettings( - interaction.client, - interaction.guild.id, - { questions: newQuestions } - ); - - await modalResponse.reply({ - embeds: [ - successEmbed( - "Questions Updated", - `The application questions have been updated.\n\n` + - newQuestions.map((q, i) => `${i + 1}. ${q}`).join("\n"), - ), - ], - flags: ["Ephemeral"], - }); - } catch (error) { - if (error.message.includes("timeout")) { - return; - } - logger.error('Error processing questions modal', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack - }); - } -} - -async function handleRoles(interaction) { - const action = interaction.options.getString("action"); - const role = interaction.options.getRole("role"); - const name = interaction.options.getString("name"); - - const currentRoles = await ApplicationService.manageApplicationRoles( - interaction.client, - interaction.guild.id, - { action, roleId: role?.id, name } - ); - - if (action === "list") { - if (currentRoles.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("No application roles have been configured.")], - flags: ["Ephemeral"], - }); - } - - const embed = createEmbed({ - title: "Application Roles", - description: "Here are the configured application roles:" - }); - - currentRoles.forEach((appRole, index) => { - const roleObj = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${roleObj ? `<@&${appRole.roleId}>` : 'Role not found'}\n**ID:** \`${appRole.roleId}\``, - inline: false - }); - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } - - if (action === "add") { - const customName = name || role.name; - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Role Added", - `**${customName}** has been added to the application system.\nUsers can now apply for this role using \`/apply submit\`.` - )], - flags: ["Ephemeral"], - }); - } - - if (action === "remove") { - const removedRole = currentRoles.find(r => r.roleId === role.id); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Role Removed", - `**${removedRole?.name || 'Role'}** has been removed from the application system.` - )], - flags: ["Ephemeral"], - }); - } -} - -export async function handleApplicationButton(interaction) { - if (!interaction.isButton()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_approve_') && !customId.startsWith('app_deny_')) return; - - const [, action, appId] = customId.split('_'); - const isApprove = action === 'approve'; - - try { - const application = await getApplication(interaction.client, interaction.guild.id, appId); - if (!application) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Application not found.')], - flags: ["Ephemeral"] - }); - } - - if (application.status !== 'pending') { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('This application has already been processed.')], - flags: ["Ephemeral"] - }); - } - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const isManager = interaction.member.permissions.has(PermissionFlagsBits.ManageGuild) || - (settings.managerRoles && settings.managerRoles.some(roleId => interaction.member.roles.cache.has(roleId))); - - if (!isManager) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('You do not have permission to manage applications.')], - flags: ["Ephemeral"] - }); - } - - const modal = new ModalBuilder() - .setCustomId(`app_review_${appId}_${isApprove ? 'approve' : 'deny'}`) - .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application`) - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('reason') - .setLabel('Reason (optional)') - .setStyle(TextInputStyle.Paragraph) - .setRequired(false) - .setMaxLength(500) - ) - ); - - await interaction.showModal(modal); - - } catch (error) { - logger.error('Error handling application button:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('An error occurred while processing the application.')], - flags: ["Ephemeral"] - }); - } -} - export async function handleApplicationReviewModal(interaction) { if (!interaction.isModalSubmit()) return; diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index ef617c1b59..f92bd14197 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -11,7 +11,8 @@ import { createApplication, getApplication, getApplicationRoles, - updateApplication + updateApplication, + getApplicationRoleSettings } from '../../utils/database.js'; function getApplicationStatusPresentation(statusValue) { @@ -43,6 +44,7 @@ export default { .setName("application") .setDescription("The application you want to submit") .setRequired(true) + .setAutocomplete(true), ), ) .addSubcommand((subcommand) => @@ -76,7 +78,8 @@ export default { const subcommand = options.getSubcommand(); if (subcommand !== "submit") { - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + const isListCommand = subcommand === "list"; + await InteractionHelper.safeDefer(interaction, { flags: isListCommand ? [] : ["Ephemeral"] }); } logger.info(`Apply command executed: ${subcommand}`, { @@ -138,7 +141,13 @@ export async function handleApplicationModal(interaction) { const answers = []; const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + + // Get questions - use per-application questions if they exist, otherwise use global + let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); + if (roleSettings.questions && roleSettings.questions.length > 0) { + questions = roleSettings.questions; + } for (let i = 0; i < questions.length; i++) { const answer = interaction.fields.getTextInputValue(`q${i}`); @@ -169,8 +178,13 @@ export async function handleApplicationModal(interaction) { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (settings.logChannelId) { - const logChannel = interaction.guild.channels.cache.get(settings.logChannelId); + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); + + // Use per-application log channel if exists, otherwise use global + const logChannelId = roleSettings.logChannelId || settings.logChannelId; + + if (logChannelId) { + const logChannel = interaction.guild.channels.cache.get(logChannelId); if (logChannel) { const logEmbed = createEmbed({ title: '๐Ÿ“ New Application', @@ -185,7 +199,7 @@ export async function handleApplicationModal(interaction) { await updateApplication(interaction.client, interaction.guild.id, application.id, { logMessageId: logMessage.id, - logChannelId: settings.logChannelId + logChannelId: logChannelId }); } } @@ -213,7 +227,6 @@ async function handleList(interaction) { if (applicationRoles.length === 0) { return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("No applications are currently available.")], - flags: ["Ephemeral"], }); } @@ -236,7 +249,7 @@ async function handleList(interaction) { text: "Use /apply submit application: to apply for any of these roles." }); - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error('Error listing applications:', { error: error.message, @@ -305,8 +318,12 @@ async function handleSubmit(interaction, settings) { .setCustomId(`app_modal_${applicationRole.roleId}`) .setTitle(`Application for ${applicationRole.name}`); - const questions = - settings.questions || ["Why do you want this role?", "What is your experience?"]; + // Get questions - use per-application questions if they exist, otherwise use global + let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); + if (roleSettings.questions && roleSettings.questions.length > 0) { + questions = roleSettings.questions; + } questions.forEach((question, index) => { const input = new TextInputBuilder() diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js new file mode 100644 index 0000000000..692ce1d273 --- /dev/null +++ b/src/commands/Community/modules/app_dashboard.js @@ -0,0 +1,1278 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { safeDeferInteraction } from '../../../utils/interactionValidator.js'; +import { + getApplicationSettings, + saveApplicationSettings, + getApplicationRoles, + saveApplicationRoles, + getApplicationRoleSettings, + saveApplicationRoleSettings, + deleteApplicationRoleSettings, + getApplications, + deleteApplication, +} from '../../../utils/database.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(settings, roles, guild) { + const logChannel = settings.logChannelId ? `<#${settings.logChannelId}>` : '`Not set`'; + const managerRoleList = + settings.managerRoles?.length > 0 + ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') + : '`None configured`'; + const roleList = + roles.length > 0 + ? roles.map(r => `<@&${r.roleId}> โ€” ${r.name}`).join('\n') + : '`No application roles configured`'; + const questionCount = settings.questions?.length ?? 0; + const firstQ = + settings.questions?.[0] + ? `\`${settings.questions[0].length > 55 ? settings.questions[0].substring(0, 55) + 'โ€ฆ' : settings.questions[0]}\`` + : '`Not set`'; + + return new EmbedBuilder() + .setTitle('๐Ÿ“‹ Applications Dashboard') + .setDescription(`Manage application settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: 'โš™๏ธ Application Status', value: settings.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ“ข Log Channel', value: logChannel, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ›ก๏ธ Manager Roles', value: managerRoleList, inline: false }, + { name: '๐Ÿ“ Questions', value: `${questionCount} configured โ€” first: ${firstQ}`, inline: false }, + { name: '๐ŸŽญ Application Roles', value: roleList, inline: false }, + { + name: '๐Ÿ—‘๏ธ Retention', + value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + inline: false, + }, + ) + .setFooter({ text: 'Dashboard closes after 15 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`app_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Log Channel') + .setDescription('Set the channel where new applications are logged') + .setValue('log_channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Manager Roles') + .setDescription('Add or remove a role that can manage applications') + .setValue('manager_role') + .setEmoji('๐Ÿ›ก๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Questions') + .setDescription('Customise the questions shown on the application form') + .setValue('questions') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Add Application Role') + .setDescription('Add a role that members can apply for') + .setValue('role_add') + .setEmoji('โž•'), + new StringSelectMenuOptionBuilder() + .setLabel('Remove Application Role') + .setDescription('Remove a role from the applications list') + .setValue('role_remove') + .setEmoji('โž–'), + new StringSelectMenuOptionBuilder() + .setLabel('Retention Period') + .setDescription('Set how long pending and reviewed applications are kept') + .setValue('retention') + .setEmoji('๐Ÿ—‘๏ธ'), + ); +} + +function buildButtonRow(settings, guildId, disabled = false) { + const systemOn = settings.enabled === true; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_cfg_toggle_${guildId}`) + .setLabel('Applications') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, settings, roles, guildId) { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(settings, roles, rootInteraction.guild)], + components: [ + buildButtonRow(settings, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }).catch(() => {}); +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client, selectedAppName = null) { + try { + const guildId = interaction.guild.id; + + // Defer immediately to prevent Discord interaction timeout + await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); + + const [settings, roles] = await Promise.all([ + getApplicationSettings(client, guildId), + getApplicationRoles(client, guildId), + ]); + + // Check if application system is completely unconfigured + const isCompletelyUnconfigured = + !settings.logChannelId && + !settings.enabled && + (settings.managerRoles?.length ?? 0) === 0 && + roles.length === 0; + + if (isCompletelyUnconfigured) { + throw new TitanBotError( + 'Applications system not set up', + ErrorTypes.CONFIGURATION, + 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', + ); + } + + // If no application roles exist, show global settings to add one + if (roles.length === 0) { + await showGlobalDashboard(interaction, settings, roles, guildId, client); + return; + } + + // If a specific app was selected via autocomplete, show its dashboard directly + if (selectedAppName) { + const selectedRole = roles.find(r => r.name.toLowerCase() === selectedAppName.toLowerCase()); + if (selectedRole) { + await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); + return; + } + // If name doesn't match, fall through + } + + // Default: Show first application if no selection made + const defaultRole = roles[0]; + await showApplicationDashboard(interaction, defaultRole, settings, roles, guildId, client); + + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in app_dashboard:', error); + throw new TitanBotError( + `Applications dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the applications dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Application Selector (for multiple applications) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showApplicationSelector(interaction, roles, settings, guildId, client) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`app_select_${guildId}`) + .setPlaceholder('Select an application to configure...') + .addOptions( + roles.map(role => + new StringSelectMenuOptionBuilder() + .setLabel(role.name) + .setDescription(`Configure the ${role.name} application`) + .setValue(role.roleId) + .setEmoji('๐Ÿ“‹'), + ), + ); + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽฏ Select Application') + .setDescription('Choose which application role you want to configure.') + .setColor(getColor('info')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [new ActionRowBuilder().addComponents(selectMenu)], + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `app_select_${guildId}`, + time: 600_000, + max: 1, + }); + + collector.on('collect', async selectInteraction => { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const selectedRoleId = selectInteraction.values[0]; + const selectedRole = roles.find(r => r.roleId === selectedRoleId); + + if (selectedRole) { + await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); + } + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Timed Out', 'No selection was made. The dashboard has closed.')], + components: [], + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Global Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showGlobalDashboard(interaction, settings, roles, guildId, client) { + const selectMenu = buildSelectMenu(guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(settings, roles, interaction.guild)], + components: [ + buildButtonRow(settings, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }); + + setupCollectors(interaction, settings, roles, guildId, client, null); +} + +// โ”€โ”€โ”€ Application-Specific Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { + const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); + + // Get application-specific settings + const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); + const questions = appSettings.questions || settings.questions || []; + const appLogChannelId = appSettings.logChannelId || settings.logChannelId; + const isEnabled = selectedRole.enabled !== false; // Default to true if not specified + + // Build comprehensive embed + const logChannelDisplay = appLogChannelId + ? `<#${appLogChannelId}>` + : '`Inherits global log channel`'; + + const questionsDisplay = questions.length > 0 + ? questions.map((q, i) => `${i + 1}. \`${q.length > 60 ? q.substring(0, 60) + 'โ€ฆ' : q}\``).join('\n') + : '`Inherits global questions`'; + + const managerRolesDisplay = settings.managerRoles && settings.managerRoles.length > 0 + ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') + : '`None configured`'; + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽญ Application Dashboard') + .setDescription(`Configuration for **${selectedRole.name}**`) + .setColor(isEnabled ? getColor('success') : getColor('error')) + .addFields( + { + name: '๐ŸŽญ Role', + value: roleObj ? roleObj.toString() : `<@&${selectedRole.roleId}>`, + inline: true + }, + { + name: 'โš™๏ธ Application Status', + value: isEnabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**', + inline: true + }, + { name: '\u200B', value: '\u200B', inline: true }, + { + name: '๐Ÿ“ Questions', + value: questionsDisplay, + inline: false + }, + { + name: '๐Ÿ“ข Log Channel', + value: logChannelDisplay, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Manager Roles', + value: managerRolesDisplay, + inline: true + }, + { + name: '๐Ÿ—‘๏ธ Retention Period', + value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + inline: false + }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); + + // Create dropdown button with customization options + const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); + + // Create control buttons + const controlButtons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_toggle_${selectedRole.roleId}`) + .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') + .setStyle(isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`app_delete_${selectedRole.roleId}`) + .setLabel('Delete Application') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + const menuRow = new ActionRowBuilder().addComponents(configMenu); + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [embed], + components: [menuRow, controlButtons], + }); + + setupCollectors(rootInteraction, settings, roles, guildId, client, selectedRole.roleId); +} + +// โ”€โ”€โ”€ Collector Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function setupCollectors(interaction, settings, roles, guildId, client, selectedRoleId) { + const customIdPrefix = selectedRoleId ? `app_cfg_${selectedRoleId}` : `app_cfg_${guildId}`; + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && + (selectedRoleId + ? i.customId === customIdPrefix + : (i.customId === `app_cfg_${guildId}` || i.customId === `app_select_${guildId}`)), + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + // Catch expired interactions + if (!selectInteraction.isStringSelectMenu()) { + return; + } + switch (selectedOption) { + case 'log_channel': + await handleLogChannel(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'manager_role': + await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'questions': + await handleQuestions(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'role_add': + await handleRoleAdd(selectInteraction, interaction, settings, roles, guildId, client); + break; + case 'role_remove': + await handleRoleRemove(selectInteraction, interaction, settings, roles, guildId, client); + break; + case 'retention': + await handleRetention(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Applications config validation error: ${error.message}`); + } else { + logger.error('Unexpected applications dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await safeDeferInteraction(selectInteraction); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('\u23f0 Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + // โ”€โ”€ Global Toggle Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (!selectedRoleId) { + const globalToggleCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_cfg_toggle_${guildId}`, + time: 600_000, + }); + + globalToggleCollector.on('collect', async toggleInteraction => { + const deferred = await safeDeferInteraction(toggleInteraction); + if (!deferred) return; + + try { + const wasEnabled = settings.enabled === true; + settings.enabled = !wasEnabled; + + // Save the updated settings + await saveApplicationSettings(interaction.client, guildId, settings); + + // Refresh dashboard to show new status + const updatedSettings = await getApplicationSettings(interaction.client, guildId); + const updatedRoles = await getApplicationRoles(interaction.client, guildId); + await showGlobalDashboard(interaction, updatedSettings, updatedRoles, guildId, interaction.client); + + await toggleInteraction.followUp({ + embeds: [successEmbed( + wasEnabled ? '๐Ÿ”ด Applications Disabled' : '๐ŸŸข Applications Enabled', + `The applications system is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled + ? 'Members will no longer be able to apply for roles.' + : 'Members can now start applying for roles.' + }`, + )], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error toggling global application status:', error); + await toggleInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + globalToggleCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } + + // โ”€โ”€ Delete Button Collector (for application-specific dashboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (selectedRoleId) { + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_delete_${selectedRoleId}`, + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + // Defer the interaction to prevent timeout errors + const deferred = await safeDeferInteraction(btnInteraction); + if (!deferred) return; + + // Show confirmation modal + const confirmModal = new ModalBuilder() + .setCustomId('app_delete_confirm') + .setTitle('Confirm Application Deletion'); + + confirmModal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('confirm_text') + .setLabel(`Type "DELETE" to confirm deletion`) + .setStyle(TextInputStyle.Short) + .setPlaceholder('Type DELETE here') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true), + ), + ); + + try { + await btnInteraction.showModal(confirmModal); + } catch (error) { + logger.error('Error showing delete confirmation modal:', error); + await btnInteraction.followUp({ + embeds: [errorEmbed('Error', 'Failed to show confirmation modal. Please try again.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + return; + } + + try { + const confirmSubmit = await btnInteraction.awaitModalSubmit({ + time: 60_000, + filter: i => + i.customId === 'app_delete_confirm' && i.user.id === btnInteraction.user.id, + }).catch(() => null); + + if (!confirmSubmit) { + await btnInteraction.followUp({ + embeds: [errorEmbed('Cancelled', 'Application deletion was cancelled.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const confirmText = confirmSubmit.fields.getTextInputValue('confirm_text').trim(); + if (confirmText !== 'DELETE') { + await confirmSubmit.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type exactly "DELETE" to confirm.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Delete the application + await handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client); + collector.stop(); + btnCollector.stop(); + + } catch (error) { + logger.error('Error confirming application deletion:', error); + await btnInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the application.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + btnCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + // โ”€โ”€ Toggle Enable/Disable Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const toggleCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_toggle_${selectedRoleId}`, + time: 900_000, + }); + + toggleCollector.on('collect', async toggleInteraction => { + const deferred = await safeDeferInteraction(toggleInteraction); + if (!deferred) return; + + try { + // Find and toggle the role + const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); + if (roleIndex === -1) { + await toggleInteraction.followUp({ + embeds: [errorEmbed('Not Found', 'Application role not found.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const wasEnabled = roles[roleIndex].enabled !== false; + roles[roleIndex].enabled = !wasEnabled; + + // Save the updated roles + await saveApplicationRoles(interaction.client, guildId, roles); + + // Refresh dashboard to show new status + const updatedRole = roles[roleIndex]; + const updatedSettings = await getApplicationSettings(interaction.client, guildId); + await showApplicationDashboard(interaction, updatedRole, updatedSettings, roles, guildId, interaction.client); + + await toggleInteraction.followUp({ + embeds: [successEmbed( + wasEnabled ? '๐Ÿ”ด Application Disabled' : '๐ŸŸข Application Enabled', + `The **${updatedRole.name}** application is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled + ? 'This application will no longer appear in `/apply submit` options.' + : 'This application will now appear in `/apply submit` options.' + }`, + )], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error toggling application status:', error); + await toggleInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + toggleCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } +} + +// โ”€โ”€โ”€ Build Select Menus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildApplicationSelectMenu(guildId, roleId) { + return new StringSelectMenuBuilder() + .setCustomId(`app_cfg_${roleId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Log Channel') + .setDescription('Set the channel where applications are logged') + .setValue('log_channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Manager Roles') + .setDescription('Add or remove a role that can manage applications') + .setValue('manager_role') + .setEmoji('๐Ÿ›ก๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Questions') + .setDescription('Customise the questions shown on the application form') + .setValue('questions') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Retention Period') + .setDescription('Set how long pending and reviewed applications are kept') + .setValue('retention') + .setEmoji('๐Ÿ—‘๏ธ'), + ); +} + +// โ”€โ”€โ”€ Log Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('app_cfg_log_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + let currentChannel = settings.logChannelId; + if (selectedRoleId) { + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + currentChannel = roleSettings.logChannelId || settings.logChannelId; + } + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Log Channel') + .setDescription( + `**Current:** ${currentChannel ? `<#${currentChannel}>` : '`Not set`'}\n\nSelect the channel where new application submissions will be logged.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_log_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + const deferred = await safeDeferInteraction(chanInteraction); + if (!deferred) return; + + const channel = chanInteraction.channels.first(); + + if (!channel.isTextBased()) { + await chanInteraction.followUp({ + embeds: [errorEmbed('Invalid Channel', 'Please select a text channel.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (selectedRoleId) { + // Save per-application log channel + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + roleSettings.logChannelId = channel.id; + await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); + } else { + // Save global log channel + settings.logChannelId = channel.id; + await saveApplicationSettings(client, guildId, settings); + } + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Log Channel Updated', `Application submissions will now be logged in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Manager Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const currentRoles = settings.managerRoles ?? []; + const currentList = + currentRoles.length > 0 ? currentRoles.map(id => `<@&${id}>`).join(', ') : '`None`'; + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_manager_role') + .setPlaceholder('Select a role to add or remove...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ›ก๏ธ Manager Roles') + .setDescription( + `**Current:** ${currentList}\n\nSelect a role to **toggle** it โ€” selecting an existing manager role will remove it, selecting a new one will add it.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_manager_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + const role = roleInteraction.roles.first(); + const roleSet = new Set(settings.managerRoles ?? []); + const wasPresent = roleSet.has(role.id); + + if (wasPresent) { + roleSet.delete(role.id); + } else { + roleSet.add(role.id); + } + + settings.managerRoles = Array.from(roleSet); + await saveApplicationSettings(client, guildId, settings); + + await roleInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Manager Role Updated', + `${role} has been **${wasPresent ? 'removed from' : 'added to'}** the manager roles list.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Questions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleQuestions(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { + let currentQuestions = settings.questions ?? []; + + if (selectedRoleId) { + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + currentQuestions = roleSettings.questions ?? currentQuestions; + } + + const modal = new ModalBuilder() + .setCustomId('app_cfg_questions') + .setTitle('Edit Application Questions') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q1') + .setLabel('Question 1 (required)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[0] ?? '') + .setMaxLength(100) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q2') + .setLabel('Question 2 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[1] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q3') + .setLabel('Question 3 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[2] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q4') + .setLabel('Question 4 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[3] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q5') + .setLabel('Question 5 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[4] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_questions' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'] + .map(key => submitted.fields.getTextInputValue(key).trim()) + .filter(Boolean); + + if (newQuestions.length === 0) { + await submitted.reply({ + embeds: [errorEmbed('No Questions', 'At least one question is required.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (selectedRoleId) { + // Save per-application questions + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + roleSettings.questions = newQuestions; + await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); + } else { + // Save global questions + settings.questions = newQuestions; + await saveApplicationSettings(client, guildId, settings); + } + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Questions Updated', + `${newQuestions.length} question${newQuestions.length !== 1 ? 's' : ''} saved.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); +} + +// โ”€โ”€โ”€ Add Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_role_add_pick') + .setPlaceholder('Select the Discord role to add...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž• Add Application Role') + .setDescription( + 'Select a role that members can apply for. You can optionally set a custom display name after selecting.', + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_add_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const role = roleInteraction.roles.first(); + + // Check for duplicate + if (roles.some(r => r.roleId === role.id)) { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + await roleInteraction.followUp({ + embeds: [errorEmbed('Already Added', `${role} is already an application role.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Show modal for optional custom name + const nameModal = new ModalBuilder() + .setCustomId('app_cfg_role_add_name') + .setTitle('Application Role Name') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('role_name') + .setLabel('Display name (leave blank to use role name)') + .setStyle(TextInputStyle.Short) + .setValue(role.name) + .setMaxLength(50) + .setRequired(false), + ), + ); + + await roleInteraction.showModal(nameModal); + + const nameSubmit = await roleInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_role_add_name' && i.user.id === roleInteraction.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!nameSubmit) return; + + const customName = nameSubmit.fields.getTextInputValue('role_name').trim() || role.name; + + roles.push({ roleId: role.id, name: customName }); + await saveApplicationRoles(client, guildId, roles); + + await nameSubmit.reply({ + embeds: [ + successEmbed( + 'โœ… Role Added', + `${role} has been added as an application role with name **${customName}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was added.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Remove Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + if (roles.length === 0) { + await selectInteraction.followUp({ + embeds: [errorEmbed('No Roles', 'There are no application roles configured to remove.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_role_remove_pick') + .setPlaceholder('Select the role to remove...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Application Role') + .setDescription( + `**Current roles:** ${roles.map(r => `<@&${r.roleId}> (${r.name})`).join(', ')}\n\nSelect the role to remove from the applications list.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_remove_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + const role = roleInteraction.roles.first(); + const index = roles.findIndex(r => r.roleId === role.id); + + if (index === -1) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Not Found', `${role} is not in the application roles list.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + roles.splice(index, 1); + await saveApplicationRoles(client, guildId, roles); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Removed', `${role} has been removed from the application roles.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was removed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Retention Period โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('app_cfg_retention') + .setTitle('Application Retention Periods') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('pending_days') + .setLabel('Pending retention (days, 1โ€“3650)') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('reviewed_days') + .setLabel('Reviewed retention (days, 1โ€“3650)') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_retention' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const pendingDays = parseInt(submitted.fields.getTextInputValue('pending_days').trim(), 10); + const reviewedDays = parseInt(submitted.fields.getTextInputValue('reviewed_days').trim(), 10); + + if (isNaN(pendingDays) || pendingDays < 1 || pendingDays > 3650) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Pending retention must be a whole number between **1** and **3650** days.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Reviewed retention must be a whole number between **1** and **3650** days.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + settings.pendingApplicationRetentionDays = pendingDays; + settings.reviewedApplicationRetentionDays = reviewedDays; + await saveApplicationSettings(client, guildId, settings); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Retention Updated', + `Pending applications will be kept for **${pendingDays} days**.\nReviewed applications will be kept for **${reviewedDays} days**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); +} + +// โ”€โ”€โ”€ Delete Application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { + try { + // Find the application in the roles array + const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); + if (roleIndex === -1) { + await confirmSubmit.reply({ + embeds: [errorEmbed('Not Found', 'Application role not found.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const deletedRole = roles[roleIndex]; + + // Remove from roles array + roles.splice(roleIndex, 1); + + // Save updated roles list + await saveApplicationRoles(client, guildId, roles); + + // Delete per-application settings + await deleteApplicationRoleSettings(client, guildId, selectedRoleId); + + // Get all applications for this guild and find ones with this roleId + const allApplications = await getApplications(client, guildId); + const applicationsToDelete = allApplications.filter(app => app.roleId === selectedRoleId); + + // Delete each application + for (const app of applicationsToDelete) { + await deleteApplication(client, guildId, app.id, app.userId); + } + + // Send success message + await confirmSubmit.reply({ + embeds: [ + successEmbed( + '๐Ÿ—‘๏ธ Application Deleted', + `The application for <@&${selectedRoleId}> (**${deletedRole.name}**) has been permanently deleted.\n\n` + + `Deleted: **${applicationsToDelete.length}** application${applicationsToDelete.length !== 1 ? 's' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error in handleDeleteApplication:', error); + await confirmSubmit.reply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the application. Please try again.')], + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/commands/Config/config.js b/src/commands/Config/config.js deleted file mode 100644 index 81f45f7c85..0000000000 --- a/src/commands/Config/config.js +++ /dev/null @@ -1,225 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; - -import birthdayToggle from './modules/config_birthday_toggle.js'; -import loggingStatus from './modules/config_logging_status.js'; -import loggingSetchannel from './modules/config_logging_setchannel.js'; -import loggingFilter from './modules/config_logging_filter.js'; -import reportsSetchannel from './modules/config_reports_setchannel.js'; -import premiumSetrole from './modules/config_premium_setrole.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("config") - .setDescription("Configuration commands for the bot.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommandGroup((group) => - group - .setName("birthday") - .setDescription("Manage birthday announcement settings.") - .addSubcommand((subcommand) => - subcommand - .setName("toggle") - .setDescription( - "Enable or disable birthday announcements by selecting a channel.", - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The text channel for birthday announcements (leave empty to disable).", - ) - .setRequired(false) - .addChannelTypes(ChannelType.GuildText), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("logging") - .setDescription("Manage logging configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("status") - .setDescription("Display current logging configuration."), - ) - .addSubcommand((subcommand) => - subcommand - .setName("setchannel") - .setDescription("Sets the channel where bot moderation and audit logs are sent.") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The text channel to use for logging.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("ticket_lifecycle") - .setDescription("The channel for ticket lifecycle events (open, close, delete, claim, etc.).") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("ticket_transcript") - .setDescription("The channel for ticket transcript logs.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("disable") - .setDescription("Set to True to disable logging completely.") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("add") - .setDescription("Adds a user or channel to the ignore list.") - .addStringOption((option) => - option - .setName("type") - .setDescription("The type of entity to ignore.") - .setRequired(true) - .addChoices( - { name: "User", value: "user" }, - { name: "Channel", value: "channel" }, - ), - ) - .addStringOption((option) => - option - .setName("id") - .setDescription("The ID of the User or Channel to ignore.") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("remove") - .setDescription("Removes a user or channel from the ignore list.") - .addStringOption((option) => - option - .setName("type") - .setDescription("The type of entity to stop ignoring.") - .setRequired(true) - .addChoices( - { name: "User", value: "user" }, - { name: "Channel", value: "channel" }, - ), - ) - .addStringOption((option) => - option - .setName("id") - .setDescription("The ID of the User or Channel to remove from the ignore list.") - .setRequired(true), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("reports") - .setDescription("Manage report configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("setchannel") - .setDescription("Sets the channel where user reports will be sent.") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The text channel to send reports to.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("premium") - .setDescription("Manage premium role configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("setrole") - .setDescription("Sets the Discord role granted when the Premium Role shop item is purchased.") - .addRoleOption((option) => - option - .setName("role") - .setDescription("The role to be designated as the Premium Shop Role.") - .setRequired(true), - ), - ), - ), - - async execute(interaction, config, client) { - if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Server` permission to use this command.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const subcommandGroup = interaction.options.getSubcommandGroup(); - const subcommand = interaction.options.getSubcommand(); - - try { - if (subcommandGroup === "birthday") { - if (subcommand === "toggle") { - return birthdayToggle.execute(interaction, config, client); - } - } else if (subcommandGroup === "logging") { - if (subcommand === "status") { - return loggingStatus.execute(interaction, config, client); - } else if (subcommand === "setchannel") { - return loggingSetchannel.execute(interaction, config, client); - } else if (subcommand === "add" || subcommand === "remove") { - return loggingFilter.execute(interaction, config, client); - } - } else if (subcommandGroup === "reports") { - if (subcommand === "setchannel") { - return reportsSetchannel.execute(interaction, config, client); - } - } else if (subcommandGroup === "premium") { - if (subcommand === "setrole") { - return premiumSetrole.execute(interaction, config, client); - } - } - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Error", "Unknown subcommand or subcommand group."), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error("Config command error:", error); - - const errorMessage = { - embeds: [ - errorEmbed( - "Configuration Failed", - "Could not save the configuration to the database.", - ), - ], - flags: MessageFlags.Ephemeral, - }; - - if (interaction.deferred || interaction.replied) { - return InteractionHelper.safeEditReply(interaction, errorMessage); - } else { - return InteractionHelper.safeEditReply(interaction, errorMessage); - } - } - }, -}; - - diff --git a/src/commands/Config/modules/config_birthday_toggle.js b/src/commands/Config/modules/config_birthday_toggle.js deleted file mode 100644 index b0b6e185c5..0000000000 --- a/src/commands/Config/modules/config_birthday_toggle.js +++ /dev/null @@ -1,58 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { -try { - const channel = interaction.options.getChannel("channel"); - const guildId = interaction.guildId; - - let guildConfig = await getGuildConfig(client, guildId); - - if (channel) { - guildConfig.birthdayChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽ‚ Birthday Announcements Enabled", - `Birthday announcements will now be posted in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else { - guildConfig.birthdayChannelId = null; - await setGuildConfig(client, guildId, guildConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽ‚ Birthday Announcements Disabled", - "Birthday announcements have been disabled. No channel selected.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - logger.error("config_birthday_toggle error:", error); - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Could not save the birthday configuration.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_logging_filter.js b/src/commands/Config/modules/config_logging_filter.js deleted file mode 100644 index 8970a20d2d..0000000000 --- a/src/commands/Config/modules/config_logging_filter.js +++ /dev/null @@ -1,145 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { logEvent } from '../../../utils/moderation.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { -if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Administrator permissions to manage log filters.", - ), - ], - }); - } - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Database Error", "Database not initialized."), - ], - }); - } - - const subcommand = interaction.options.getSubcommand(); - const type = interaction.options.getString("type"); - const entityId = interaction.options.getString("id"); - const guildId = interaction.guildId; - - const currentConfig = config; - if (!currentConfig.logIgnore) { - currentConfig.logIgnore = { users: [], channels: [] }; - } - - let targetArray; - let entityName = ""; - let entityType = ""; - - if (type === "user") { - targetArray = currentConfig.logIgnore.users; - entityType = "User"; - const member = await interaction.guild.members - .fetch(entityId) - .catch(() => null); - entityName = member ? member.user.tag : `ID: ${entityId}`; - } else if (type === "channel") { - targetArray = currentConfig.logIgnore.channels; - entityType = "Channel"; - const channel = interaction.guild.channels.cache.get(entityId); - entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; - } else { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Type", - "Please choose 'user' or 'channel'.", - ), - ], - }); - } - - let successMessage = ""; - - if (subcommand === "add") { - if (targetArray.includes(entityId)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Already Filtered", - `${entityType} **${entityName}** is already on the ignore list.`, - ), - ], - }); - } - targetArray.push(entityId); - successMessage = `${entityType} **${entityName}** has been added to the log ignore list. Events originating from them will not be logged.`; - } else if (subcommand === "remove") { - const index = targetArray.indexOf(entityId); - if (index === -1) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not Filtered", - `${entityType} **${entityName}** was not found on the ignore list.`, - ), - ], - }); - } - targetArray.splice(index, 1); - successMessage = `${entityType} **${entityName}** has been removed from the log ignore list. Events will now be logged.`; - } - - try { - const { getGuildConfigKey } = await import('../../../utils/database.js'); - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, currentConfig); - - const logEmbed = successEmbed( - "Log Filter Updated", - successMessage, - ).addFields( - { - name: "Action", - value: - subcommand.charAt(0).toUpperCase() + - subcommand.slice(1), - inline: true, - }, - { name: "Entity Type", value: entityType, inline: true }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Log Filter Updated", - target: `Filter ${subcommand}`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - entityType, - loggingEnabled: currentConfig.enableLogging - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("Success!", successMessage)], - }); - } catch (error) { - logger.error("Error saving log filter:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Database Error", - "Failed to save configuration change.", - ), - ], - }); - } - } -}; - - diff --git a/src/commands/Config/modules/config_logging_setchannel.js b/src/commands/Config/modules/config_logging_setchannel.js deleted file mode 100644 index 428428a55f..0000000000 --- a/src/commands/Config/modules/config_logging_setchannel.js +++ /dev/null @@ -1,189 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logEvent } from '../../../utils/moderation.js'; -import { validateLogChannel } from '../../../utils/ticketLogging.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { -if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Administrator permissions.", - ), - ], - }); - } - - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Database Error", "Database not initialized."), - ], - }); - } - - const guildId = interaction.guildId; - - const currentConfig = await getGuildConfig(client, guildId); - - const logChannel = interaction.options.getChannel("channel"); - const disableLogging = interaction.options.getBoolean("disable"); - const ticketLifecycle = interaction.options.getChannel("ticket_lifecycle"); - const ticketTranscript = interaction.options.getChannel("ticket_transcript"); - - try { - if (ticketLifecycle) { - const validation = validateLogChannel(ticketLifecycle, interaction.guild.members.me); - if (!validation.valid) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Invalid Channel", validation.error)], - }); - } - - if (!currentConfig.ticketLogging) { - currentConfig.ticketLogging = {}; - } - currentConfig.ticketLogging.lifecycleChannelId = ticketLifecycle.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽซ Ticket Lifecycle Channel Set", - `**Channel:** ${ticketLifecycle}\n**Logs:** Ticket open, close, delete, claim, unclaim, and priority events\n\n**Updated by:** ${interaction.user.tag}` - ), - ], - }); - } - - if (ticketTranscript) { - const validation = validateLogChannel(ticketTranscript, interaction.guild.members.me); - if (!validation.valid) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Invalid Channel", validation.error)], - }); - } - - if (!currentConfig.ticketLogging) { - currentConfig.ticketLogging = {}; - } - currentConfig.ticketLogging.transcriptChannelId = ticketTranscript.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐Ÿ“œ Ticket Transcript Channel Set", - `**Channel:** ${ticketTranscript}\n**Logs:** Ticket transcript generation\n\n**Updated by:** ${interaction.user.tag}` - ), - ], - }); - } - - if (disableLogging) { - currentConfig.logChannelId = null; - currentConfig.enableLogging = false; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: false, - channelId: null - }; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Logging Disabled ๐Ÿšซ", - "Server logging has been disabled.", - ), - ], - }); - } - - if (logChannel) { - const permissionsInChannel = logChannel.permissionsFor( - interaction.guild.members.me, - ); - if ( - !permissionsInChannel.has( - PermissionsBitField.Flags.SendMessages, - ) || - !permissionsInChannel.has(PermissionsBitField.Flags.EmbedLinks) - ) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Bot Permission Error", - `I need Send Messages and Embed Links permissions in ${logChannel}.`, - ), - ], - }); - } - - currentConfig.logChannelId = logChannel.id; - currentConfig.enableLogging = true; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: true, - channelId: logChannel.id - }; - - await setGuildConfig(client, guildId, currentConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Log Channel Set ๐Ÿ“", - `Logs will be sent to ${logChannel}.`, - ), - ], - }); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Log Channel Activated", - target: logChannel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Logging set by ${interaction.user}`, - metadata: { - channelId: logChannel.id, - moderatorId: interaction.user.id, - loggingEnabled: true - } - } - }); - return; - } - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "No Option Provided", - "Please provide one of the following: channel, ticket_lifecycle, ticket_transcript, or disable." - ), - ], - }); - - } catch (error) { - logger.error("Error setting log channel:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Could not save configuration.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_logging_status.js b/src/commands/Config/modules/config_logging_status.js deleted file mode 100644 index 095e665381..0000000000 --- a/src/commands/Config/modules/config_logging_status.js +++ /dev/null @@ -1,184 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { PermissionsBitField, EmbedBuilder } from 'discord.js'; -import { errorEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getLoggingStatus, EVENT_TYPES } from '../../../services/loggingService.js'; -import { getWelcomeConfig, getApplicationSettings } from '../../../utils/database.js'; -import { createLoggingStatusComponents } from '../../../utils/loggingUi.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { getConfiguration as getJoinToCreateConfiguration } from '../../../services/joinToCreateService.js'; -import { getLevelingConfig } from '../../../services/leveling.js'; -import { logger } from '../../../utils/logger.js'; - -const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { - const [category] = eventType.split('.'); - if (!accumulator[category]) { - accumulator[category] = []; - } - accumulator[category].push(eventType); - return accumulator; -}, {}); - -function asEnabledLabel(enabled) { - return enabled ? 'โœ… Enabled' : 'โŒ Disabled'; -} - -async function formatChannelMention(guild, id) { - if (!id) return 'โŒ Not Set'; - const channel = guild.channels.cache.get(id) || await guild.channels.fetch(id).catch(() => null); - return channel ? channel.toString() : `โš ๏ธ Missing (${id})`; -} - -function formatRoleMention(guild, id) { - if (!id) return 'โŒ Not Set'; - const role = guild.roles.cache.get(id); - return role ? role.toString() : `โš ๏ธ Missing (${id})`; -} - -function getCategoryStatus(enabledEvents, category, auditEnabled = true) { - if (!auditEnabled) { - return false; - } - - const events = enabledEvents || {}; - if (events[`${category}.*`] === false) { - return false; - } - - const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; - if (categoryEvents.length === 0) { - return true; - } - - return categoryEvents.every((eventType) => events[eventType] !== false); -} - -export async function buildLoggingStatusView(interaction, client) { - const guildConfig = await getGuildConfig(client, interaction.guildId); - const loggingStatus = await getLoggingStatus(client, interaction.guildId); - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - const welcomeConfig = await getWelcomeConfig(client, interaction.guildId); - const applicationConfig = await getApplicationSettings(client, interaction.guildId); - const joinToCreateConfig = await getJoinToCreateConfiguration(client, interaction.guildId); - - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig?.roleIds) && welcomeConfig.roleIds.length > 0); - - const auditEnabled = Boolean(loggingStatus.enabled); - const auditChannelStatus = await formatChannelMention( - interaction.guild, - loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId - ); - const reportChannelStatus = await formatChannelMention(interaction.guild, guildConfig.reportChannelId); - const lifecycleChannelStatus = await formatChannelMention(interaction.guild, guildConfig.ticketLogging?.lifecycleChannelId); - const transcriptChannelStatus = await formatChannelMention(interaction.guild, guildConfig.ticketLogging?.transcriptChannelId); - - const systems = [ - { name: '๐Ÿงพ Audit Logging', value: asEnabledLabel(auditEnabled), inline: true }, - { name: '๐Ÿ“ˆ Leveling', value: asEnabledLabel(Boolean(levelingConfig?.enabled)), inline: true }, - { name: '๐Ÿ‘‹ Welcome', value: asEnabledLabel(Boolean(welcomeConfig?.enabled)), inline: true }, - { name: '๐Ÿ‘‹ Goodbye', value: asEnabledLabel(Boolean(welcomeConfig?.goodbyeEnabled)), inline: true }, - { name: '๐ŸŽ‚ Birthday', value: asEnabledLabel(Boolean(guildConfig.birthdayChannelId)), inline: true }, - { name: '๐Ÿ“‹ Applications', value: asEnabledLabel(Boolean(applicationConfig?.enabled)), inline: true }, - { name: 'โœ… Verification', value: asEnabledLabel(verificationEnabled), inline: true }, - { name: '๐Ÿค– AutoVerify', value: asEnabledLabel(autoVerifyEnabled), inline: true }, - { name: '๐ŸŽง Join to Create', value: asEnabledLabel(Boolean(joinToCreateConfig?.enabled)), inline: true }, - { name: '๐Ÿ›ก๏ธ Auto Role', value: autoRoleConfigured ? `โœ… Configured (${formatRoleMention(interaction.guild, guildConfig.autoRole)})` : 'โŒ Disabled', inline: true } - ]; - - const categoryMap = [ - ['moderation', '๐Ÿ”จ Moderation'], - ['ticket', '๐ŸŽซ Ticket Events'], - ['message', 'โŒ Message Events'], - ['role', '๐Ÿท๏ธ Role Events'], - ['member', '๐Ÿ‘ฅ Member Events'], - ['leveling', '๐Ÿ“ˆ Leveling Events'], - ['reactionrole', '๐ŸŽญ Reaction Role Events'], - ['giveaway', '๐ŸŽ Giveaway Events'], - ['counter', '๐Ÿ“Š Counter Events'] - ]; - - const eventStatusLines = categoryMap - .map(([key, label]) => `${getCategoryStatus(loggingStatus.enabledEvents, key, auditEnabled) ? 'โœ…' : 'โŒ'} ${label}`) - .join('\n'); - - const ignoredUsers = guildConfig.logIgnore?.users || []; - const ignoredChannels = guildConfig.logIgnore?.channels || []; - - const statusEmbed = new EmbedBuilder() - .setTitle('โš™๏ธ Configuration Status') - .setDescription(`Live status for **${interaction.guild.name}**. Toggle buttons update this embed instantly.`) - .setColor(getColor('info')) - .addFields( - ...systems, - { - name: '๐Ÿ“ก Log Destinations', - value: - `Audit: ${auditChannelStatus}\n` + - `Reports: ${reportChannelStatus}\n` + - `Ticket Lifecycle: ${lifecycleChannelStatus}\n` + - `Ticket Transcripts: ${transcriptChannelStatus}`, - inline: false, - }, - { - name: '๐Ÿ“‹ Event Categories', - value: eventStatusLines, - inline: false, - }, - { - name: '๐Ÿงน Ignore Filters', - value: `Users: ${ignoredUsers.length}\nChannels: ${ignoredChannels.length}`, - inline: true, - }, - { - name: '๐Ÿ•’ Last Refresh', - value: ``, - inline: true, - } - ) - .setTimestamp(); - - const components = createLoggingStatusComponents(loggingStatus.enabledEvents, auditEnabled); - return { embed: statusEmbed, components }; -} - -export default { - async execute(interaction, config, client) { - try { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Manage Server permissions.", - ), - ], - }); - } - - await InteractionHelper.safeDefer(interaction); - const { embed, components } = await buildLoggingStatusView(interaction, client); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components - }); - } catch (error) { - logger.error("config_logging_status error:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Failed to fetch or display the configuration status.", - ), - ], - }); - } - } -}; - - - - - diff --git a/src/commands/Config/modules/config_premium_setrole.js b/src/commands/Config/modules/config_premium_setrole.js deleted file mode 100644 index 562859faee..0000000000 --- a/src/commands/Config/modules/config_premium_setrole.js +++ /dev/null @@ -1,53 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Manage Server permissions to set the premium role.", - ), - ], - }); - } - - const role = interaction.options.getRole("role"); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - - currentConfig.premiumRoleId = role.id; - - await setGuildConfig(client, guildId, currentConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "โœ… Configuration Saved", - `The **Premium Shop Role** has been successfully set to ${role.toString()}.`, - ), - ], - }); - } catch (error) { - logger.error("SetPremiumRole command error:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "System Error", - "Could not save the guild configuration.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_reports_setchannel.js b/src/commands/Config/modules/config_reports_setchannel.js deleted file mode 100644 index 625850322a..0000000000 --- a/src/commands/Config/modules/config_reports_setchannel.js +++ /dev/null @@ -1,41 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; -import { logger } from '../../../utils/logger.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - const channel = interaction.options.getChannel("channel"); - const guildId = interaction.guildId; - - try { - let guildConfig = await getGuildConfig(client, guildId); - - guildConfig.reportChannelId = channel.id; - - await setGuildConfig(client, guildId, guildConfig); - - await InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "โœ… Report Channel Set!", - `All new reports will now be sent to ${channel}.`, - ), - ], - }); - } catch (error) { - logger.error("Error setting report channel:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Database Error", - "Could not save the channel configuration. Check bot permissions.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Core/overview.js b/src/commands/Core/overview.js new file mode 100644 index 0000000000..4c06839a80 --- /dev/null +++ b/src/commands/Core/overview.js @@ -0,0 +1,115 @@ +import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { getColor } from '../../config/bot.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; +import { getLoggingStatus } from '../../services/loggingService.js'; +import { getLevelingConfig } from '../../services/leveling.js'; +import { getConfiguration as getJoinToCreateConfiguration } from '../../services/joinToCreateService.js'; +import { getWelcomeConfig, getApplicationSettings } from '../../utils/database.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; + +function pill(enabled) { + return enabled ? 'โœ… On' : 'โŒ Off'; +} + +async function formatChannelMention(guild, id) { + if (!id) return '`Not configured`'; + const channel = guild.channels.cache.get(id) ?? await guild.channels.fetch(id).catch(() => null); + return channel ? channel.toString() : `โš ๏ธ Missing (${id})`; +} + +function formatRoleMention(guild, id) { + if (!id) return '`Not configured`'; + const role = guild.roles.cache.get(id); + return role ? role.toString() : `โš ๏ธ Missing (${id})`; +} + +export default { + data: new SlashCommandBuilder() + .setName('overview') + .setDescription('Read-only snapshot of all server system statuses.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false), + + async execute(interaction, config, client) { + try { + await InteractionHelper.safeDefer(interaction); + + const [guildConfig, loggingStatus, levelingConfig, welcomeConfig, applicationConfig, joinToCreateConfig] = + await Promise.all([ + getGuildConfig(client, interaction.guildId), + getLoggingStatus(client, interaction.guildId), + getLevelingConfig(client, interaction.guildId), + getWelcomeConfig(client, interaction.guildId), + getApplicationSettings(client, interaction.guildId), + getJoinToCreateConfiguration(client, interaction.guildId), + ]); + + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleId = guildConfig.autoRole || welcomeConfig?.roleIds?.[0]; + + // โ”€โ”€ Channels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [auditChannel, lifecycleChannel, transcriptChannel, reportChannel, birthdayChannel] = + await Promise.all([ + formatChannelMention(interaction.guild, loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId), + formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId), + formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId), + formatChannelMention(interaction.guild, guildConfig.reportChannelId), + formatChannelMention(interaction.guild, guildConfig.birthdayChannelId), + ]); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ–ฅ๏ธ System Overview') + .setDescription(`Read-only snapshot for **${interaction.guild.name}**. Use the relevant command's dashboard to make changes.`) + .setColor(getColor('primary')) + .addFields( + // โ”€โ”€ Core systems โ”€โ”€ + { + name: 'โš™๏ธ Core Systems', + value: [ + `๐Ÿงพ **Audit Logging** โ€” ${pill(Boolean(loggingStatus.enabled))}`, + `๐Ÿ“ˆ **Leveling** โ€” ${pill(Boolean(levelingConfig?.enabled))}`, + `๐Ÿ‘‹ **Welcome** โ€” ${pill(Boolean(welcomeConfig?.enabled))}`, + `๐Ÿ‘‹ **Goodbye** โ€” ${pill(Boolean(welcomeConfig?.goodbyeEnabled))}`, + `๐ŸŽ‚ **Birthdays** โ€” ${pill(Boolean(guildConfig.birthdayChannelId))}`, + `๐Ÿ“‹ **Applications** โ€” ${pill(Boolean(applicationConfig?.enabled))}`, + `โœ… **Verification** โ€” ${pill(verificationEnabled)}`, + `๐Ÿค– **Auto-Verify** โ€” ${pill(autoVerifyEnabled)}`, + `๐ŸŽง **Join to Create** โ€” ${pill(Boolean(joinToCreateConfig?.enabled))}`, + `๐Ÿ›ก๏ธ **Auto Role** โ€” ${autoRoleId ? `โœ… ${formatRoleMention(interaction.guild, autoRoleId)}` : 'โŒ Off'}`, + ].join('\n'), + inline: false, + }, + // โ”€โ”€ Channels โ”€โ”€ + { + name: '๐Ÿ“ก Configured Channels', + value: [ + `**Audit Log:** ${auditChannel}`, + `**Ticket Lifecycle:** ${lifecycleChannel}`, + `**Ticket Transcripts:** ${transcriptChannel}`, + `**Reports:** ${reportChannel}`, + `**Birthdays:** ${birthdayChannel}`, + ].join('\n'), + inline: false, + }, + // โ”€โ”€ Refresh stamp โ”€โ”€ + { + name: '๐Ÿ•’ Snapshot Taken', + value: ``, + inline: true, + }, + ) + .setFooter({ text: 'Read-only โ€” run /logging dashboard to manage audit settings' }) + .setTimestamp(); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('overview command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Overview Error', 'Failed to load the system overview.')], + }); + } + }, +}; diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js new file mode 100644 index 0000000000..53ae62d4e5 --- /dev/null +++ b/src/commands/Economy/modules/shop_browse.js @@ -0,0 +1,90 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; +import { shopItems } from '../../../config/shop/items.js'; +import { getColor } from '../../../config/bot.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + try { + const TARGET_MAX_PAGES = 3; + const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); + const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); + let currentPage = 1; + + const createShopEmbed = (page) => { + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); + const embed = new EmbedBuilder() + .setTitle('๐Ÿ›’ Store') + .setColor(getColor('primary')) + .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); + pageItems.forEach(item => { + embed.addFields({ + name: `${item.name} (${item.id})`, + value: `๐Ÿท๏ธ **Type:** ${item.type}\n๐Ÿ’š **Price:** $${item.price.toLocaleString()}\n${item.description}`, + inline: false, + }); + }); + embed.setFooter({ text: `Page ${page}/${totalPages}` }); + return embed; + }; + + const createShopComponents = (page) => { + if (totalPages <= 1) return []; + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('shop_prev') + .setLabel('โฌ…๏ธ Previous') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === 1), + new ButtonBuilder() + .setCustomId('shop_next') + .setLabel('Next โžก๏ธ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === totalPages), + ), + ]; + }; + + const message = await interaction.reply({ + embeds: [createShopEmbed(currentPage)], + components: createShopComponents(currentPage), + flags: 0, + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300000, + }); + + collector.on('collect', async (buttonInteraction) => { + if (buttonInteraction.user.id !== interaction.user.id) { + await buttonInteraction.reply({ content: 'โŒ You cannot use these buttons. Run `/shop browse` to get your own shop view.', flags: 64 }); + return; + } + const { customId } = buttonInteraction; + if (customId === 'shop_prev' || customId === 'shop_next') { + await buttonInteraction.deferUpdate(); + if (customId === 'shop_prev' && currentPage > 1) currentPage--; + else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; + await buttonInteraction.editReply({ + embeds: [createShopEmbed(currentPage)], + components: createShopComponents(currentPage), + }); + } + }); + + collector.on('end', async () => { + try { + const disabledComponents = createShopComponents(currentPage); + disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); + await message.edit({ components: disabledComponents }); + } catch (_) {} + }); + } catch (error) { + logger.error('shop_browse error:', error); + await interaction.reply({ content: 'โŒ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); + } + }, +}; diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js new file mode 100644 index 0000000000..aaf52f33fa --- /dev/null +++ b/src/commands/Economy/modules/shop_config_setrole.js @@ -0,0 +1,36 @@ +import { PermissionsBitField } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the premium role.')], + ephemeral: true, + }); + } + + const role = interaction.options.getRole('role'); + const guildId = interaction.guildId; + + try { + const currentConfig = await getGuildConfig(client, guildId); + currentConfig.premiumRoleId = role.id; + await setGuildConfig(client, guildId, currentConfig); + + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('โœ… Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], + ephemeral: true, + }); + } catch (error) { + logger.error('shop_config_setrole error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('System Error', 'Could not save the guild configuration.')], + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index 9fd4272d16..6ebdfe4585 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -1,123 +1,60 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import shopBrowse from './modules/shop_browse.js'; +import shopConfigSetrole from './modules/shop_config_setrole.js'; export default { data: new SlashCommandBuilder() .setName('shop') - .setDescription('View the economy shop with pagination'), + .setDescription('Economy shop commands.') + .addSubcommand(subcommand => + subcommand + .setName('browse') + .setDescription('Browse the economy shop.'), + ) + .addSubcommandGroup(group => + group + .setName('config') + .setDescription('Configure shop settings. (Manage Server required)') + .addSubcommand(subcommand => + subcommand + .setName('setrole') + .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role to grant for Premium Role purchases.') + .setRequired(true), + ), + ), + ), async execute(interaction, config, client) { try { - const TARGET_MAX_PAGES = 3; - const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); - const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); - let currentPage = 1; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, endIndex); - - const embed = new EmbedBuilder() - .setTitle("๐Ÿ›’ Store") - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `๐Ÿท๏ธ **Type:** ${item.type}\n๐Ÿ’š **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(); - const createShopComponents = (page) => { - if (totalPages <= 1) { - return []; - } + if (subcommand === 'browse') { + return await shopBrowse.execute(interaction, config, client); + } - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('โฌ…๏ธ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next โžก๏ธ') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages) - ) - ]; - }; + if (subcommandGroup === 'config' && subcommand === 'setrole') { + return await shopConfigSetrole.execute(interaction, config, client); + } - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0 - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000 - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ - content: 'โŒ You cannot use these buttons. Run `/shop` to get your own shop view.', - flags: 64 - }); - return; - } - - const { customId } = buttonInteraction; - - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) { - currentPage--; - } else if (customId === 'shop_next' && currentPage < totalPages) { - currentPage++; - } - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage) - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => { - row.components.forEach(button => button.setDisabled(true)); - }); - - await message.edit({ - components: disabledComponents - }); - } catch (error) { - - } + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'Unknown subcommand.')], + flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.error('Shop command error:', error); - await interaction.reply({ - content: 'โŒ An error occurred while loading the shop.', - flags: MessageFlags.Ephemeral - }); + logger.error('shop command error:', error); + await InteractionHelper.safeReply(interaction, { + content: 'โŒ An error occurred while running the shop command.', + flags: MessageFlags.Ephemeral, + }).catch(() => {}); } }, -}; - - - - +}; \ No newline at end of file diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index d51401940f..bec14aeaa7 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -61,7 +61,7 @@ export default { ) .addSubcommand((subcommand) => subcommand - .setName("config") + .setName("dashboard") .setDescription("Configure an existing Join to Create system.") .addChannelOption((option) => option @@ -92,7 +92,7 @@ export default { if (subcommand === "setup") { await handleSetupSubcommand(interaction, client); return; - } else if (subcommand === "config") { + } else if (subcommand === "dashboard") { await handleConfigSubcommand(interaction, client); return; } @@ -158,7 +158,7 @@ async function handleSetupSubcommand(interaction, client) { if (activeTriggerChannels.length > 0) { const primaryTrigger = activeTriggerChannels[0]; - const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate config\` to modify it, or remove it first before creating a new one.`; + const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; throw new TitanBotError( 'Guild already has a Join to Create channel', @@ -302,7 +302,7 @@ async function handleConfigSubcommand(interaction, client) { throw new TitanBotError( 'Failed to fetch interaction reply for collector setup', ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate config` again.' + 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' ); } @@ -608,7 +608,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, filter: (i) => i.user.id === interaction.user.id && (i.customId === `jtc_delete_confirm_${triggerChannel.id}` || i.customId === `jtc_delete_cancel_${triggerChannel.id}`), - time: 30000, + time: 600_000, max: 1 }); diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js index 93fa30ed50..53c5b534bd 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.js @@ -191,7 +191,7 @@ async function handleNameTemplateChange(interaction, triggerChannel, currentConf const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id, -time: 30000, +time: 600_000, max: 1 }); @@ -269,7 +269,7 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 30000, + time: 600_000, max: 1 }); @@ -352,7 +352,7 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 30000, + time: 600_000, max: 1 }); @@ -440,7 +440,7 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c componentType: ComponentType.Button, filter: (i) => i.user.id === interaction.user.id && (i.customId === `confirm_remove_${triggerChannel.id}` || i.customId === `cancel_remove_${triggerChannel.id}`), - time: 30000, + time: 600_000, max: 1 }); diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index 659b606af8..a0b0e6afa0 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -60,7 +60,7 @@ export default { leaderboard.map(async (user, index) => { try { const member = await interaction.guild.members.fetch(user.userId).catch(() => null); - const username = member?.user?.tag || `Unknown User (${user.userId})`; + const userMention = member?.user.toString() || `<@${user.userId}>`; const xpForNextLevel = getXpForLevel(user.level + 1); let rankPrefix = `${index + 1}.`; @@ -69,7 +69,7 @@ export default { else if (index === 2) rankPrefix = '๐Ÿฅ‰'; else rankPrefix = `**${index + 1}.**`; - return `${rankPrefix} ${username} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; + return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; } catch { return `**${index + 1}.** Error loading user ${user.userId}`; } diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.js new file mode 100644 index 0000000000..df7235b1bd --- /dev/null +++ b/src/commands/Leveling/level.js @@ -0,0 +1,179 @@ +import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; +import { botHasPermission } from '../../utils/permissionGuard.js'; +import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import levelDashboard from './modules/level_dashboard.js'; + +export default { + data: new SlashCommandBuilder() + .setName('level') + .setDescription('Manage the leveling system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('setup') + .setDescription('Set up the leveling system โ€” this also enables it') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send level-up notifications in') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('xp_min') + .setDescription('Minimum XP awarded per message (default: 15)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_max') + .setDescription('Maximum XP awarded per message (default: 25)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('message') + .setDescription( + 'Level-up message. Use {user} and {level} as placeholders (default provided)', + ) + .setMaxLength(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_cooldown') + .setDescription('Seconds between XP grants per user (default: 60)') + .setMinValue(0) + .setMaxValue(3600) + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('dashboard') + .setDescription('Open the interactive leveling configuration dashboard'), + ), + category: 'Leveling', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferred) return; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Missing Permissions', + 'You need the **Manage Server** permission to use this command.', + ), + ], + }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + return levelDashboard.execute(interaction, config, client); + } + + if (subcommand === 'setup') { + const channel = interaction.options.getChannel('channel'); + const xpMin = interaction.options.getInteger('xp_min') ?? 15; + const xpMax = interaction.options.getInteger('xp_max') ?? 25; + const message = + interaction.options.getString('message') ?? + '{user} has leveled up to level {level}!'; + const xpCooldown = interaction.options.getInteger('xp_cooldown') ?? 60; + + if (xpMin > xpMax) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Invalid XP Range', + `Minimum XP (**${xpMin}**) cannot be greater than maximum XP (**${xpMax}**).`, + ), + ], + }); + } + + if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + throw new TitanBotError( + 'Bot missing permissions in the specified channel', + ErrorTypes.PERMISSION, + `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + ); + } + + const existingConfig = await getLevelingConfig(client, interaction.guildId); + + if (existingConfig.configured) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Leveling System Already Active', + `The leveling system is already set up on this server (level-up notifications go to <#${existingConfig.levelUpChannel}>).\n\nUse \`/level dashboard\` to adjust any settings.`, + ), + ], + }); + } + + const newConfig = { + ...existingConfig, + configured: true, + enabled: true, + levelUpChannel: channel.id, + xpRange: { min: xpMin, max: xpMax }, + xpCooldown: xpCooldown, + levelUpMessage: message, + announceLevelUp: true, + }; + + await saveLevelingConfig(client, interaction.guildId, newConfig); + + logger.info(`Leveling system set up in guild ${interaction.guildId}`, { + channelId: channel.id, + xpMin, + xpMax, + xpCooldown, + userId: interaction.user.id, + }); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'โœ… Leveling System Set Up', + description: + `The leveling system is now **enabled** and ready to go.\n\n` + + `**Level-up Channel:** ${channel}\n` + + `**XP per Message:** ${xpMin} โ€“ ${xpMax}\n` + + `**XP Cooldown:** ${xpCooldown}s\n` + + `**Level-up Message:** \`${message}\`\n\n` + + `Use \`/level dashboard\` to adjust any of these settings at any time.`, + color: 'success', + }), + ], + }); + } + } catch (error) { + logger.error('Level command error:', error); + await handleInteractionError(interaction, error, { + type: 'command', + commandName: 'level', + }); + } + }, +}; diff --git a/src/commands/Leveling/levelconfig.js b/src/commands/Leveling/levelconfig.js deleted file mode 100644 index 8e345f5ceb..0000000000 --- a/src/commands/Leveling/levelconfig.js +++ /dev/null @@ -1,216 +0,0 @@ - - - - - -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions, botHasPermission } from '../../utils/permissionGuard.js'; -import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; -import { createEmbed } from '../../utils/embeds.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('levelconfig') - .setDescription('Configure the leveling system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand((subcommand) => - subcommand - .setName('toggle') - .setDescription('Enable or disable the leveling system') - .addBooleanOption((option) => - option - .setName('enabled') - .setDescription('Whether to enable or disable the leveling system') - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('channel') - .setDescription('Set the level up notification channel') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('The channel to send level up notifications to') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('xp') - .setDescription('Set the XP range per message') - .addIntegerOption((option) => - option - .setName('min') - .setDescription('Minimum XP per message') - .setRequired(true) - .setMinValue(1) - ) - .addIntegerOption((option) => - option - .setName('max') - .setDescription('Maximum XP per message') - .setRequired(true) - .setMinValue(1) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('message') - .setDescription('Set the level up message') - .addStringOption((option) => - option - .setName('text') - .setDescription('Use {user} for the username and {level} for the level') - .setRequired(true) - ) - ) - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`LevelConfig defer failed for interaction ${interaction.id}`); - return; - } - - - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const subcommand = interaction.options.getSubcommand(); - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - - switch (subcommand) { - case 'toggle': { - const enabled = interaction.options.getBoolean('enabled'); - levelingConfig.enabled = enabled; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Leveling System Updated', - description: `The leveling system has been **${enabled ? 'enabled' : 'disabled'}**.`, - color: 'success' - }) - ] - }); - - logger.info(`Leveling system ${enabled ? 'enabled' : 'disabled'} in guild ${interaction.guildId}`); - break; - } - - case 'channel': { - const channel = interaction.options.getChannel('channel'); - - - if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new TitanBotError( - 'Bot missing permissions in the specified channel', - ErrorTypes.PERMISSION, - 'I need SendMessages and EmbedLinks permissions in that channel.' - ); - } - - levelingConfig.levelUpChannel = channel.id; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Level Up Channel Set', - description: `Level up notifications will now be sent in ${channel}.`, - color: 'success' - }) - ] - }); - - logger.info(`Level up channel set to ${channel.id} in guild ${interaction.guildId}`); - break; - } - - case 'xp': { - const min = interaction.options.getInteger('min'); - const max = interaction.options.getInteger('max'); - - if (min > max) { - throw new TitanBotError( - 'Invalid XP range configuration', - ErrorTypes.VALIDATION, - 'The minimum XP cannot be greater than the maximum XP.' - ); - } - - levelingConfig.xpRange = { min, max }; - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… XP Range Updated', - description: `XP per message is now between **${min}** and **${max}**.`, - color: 'success' - }) - ] - }); - - logger.info(`XP range set to ${min}-${max} in guild ${interaction.guildId}`); - break; - } - - case 'message': { - const text = interaction.options.getString('text'); - - - if (!text.includes('{user}') && !text.includes('{level}')) { - logger.warn(`Message template missing placeholders in guild ${interaction.guildId}`); - } - - levelingConfig.levelUpMessage = text; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Level Up Message Updated', - description: `The level up message has been updated.\n\n**Preview:** ${text.replace('{user}', '@user').replace('{level}', '5')}`, - color: 'success' - }) - ] - }); - - logger.info(`Level up message updated in guild ${interaction.guildId}`); - break; - } - } - } catch (error) { - logger.error('LevelConfig command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelconfig' - }); - } - } -}; - - diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js new file mode 100644 index 0000000000..30b58c2c2e --- /dev/null +++ b/src/commands/Leveling/modules/level_dashboard.js @@ -0,0 +1,562 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild) { + const channel = cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'; + const xpMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; + const xpMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; + const cooldown = cfg.xpCooldown ?? 60; + const rawMsg = cfg.levelUpMessage || '{user} has leveled up to level {level}!'; + const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + + return new EmbedBuilder() + .setTitle('๐Ÿ“Š Leveling System Dashboard') + .setDescription(`Manage leveling settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Level-up Channel', value: channel, inline: true }, + { name: 'โš™๏ธ System Status', value: cfg.enabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**', inline: true }, + { name: '๐Ÿ“ฃ Announcements', value: cfg.announceLevelUp !== false ? 'โœ… **Enabled**' : 'โŒ **Disabled**', inline: true }, + { name: '๐ŸŽฒ XP per Message', value: `\`${xpMin} โ€“ ${xpMax}\``, inline: true }, + { name: 'โฑ๏ธ XP Cooldown', value: `\`${cooldown}s\``, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ’ฌ Level-up Message', value: msgPreview, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`level_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Level-up Channel') + .setDescription('Set the channel where level-up notifications are sent') + .setValue('channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Level-up Message') + .setDescription('Customise the message shown when a user levels up') + .setValue('message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set XP Range') + .setDescription('Set the minimum and maximum XP rewarded per message') + .setValue('xp_range') + .setEmoji('๐ŸŽฒ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set XP Cooldown') + .setDescription('Seconds between XP grants for the same user') + .setValue('xp_cooldown') + .setEmoji('โฑ๏ธ'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const announceOn = cfg.announceLevelUp !== false; + const systemOn = cfg.enabled !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`level_cfg_toggle_announce_${guildId}`) + .setLabel('Announcements') + .setStyle(announceOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ“ฃ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`level_cfg_toggle_system_${guildId}`) + .setLabel('Leveling') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('โšก') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId) { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }).catch(() => {}); +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const cfg = await getLevelingConfig(client, guildId); + + if (!cfg.configured) { + throw new TitanBotError( + 'Leveling system not configured', + ErrorTypes.CONFIGURATION, + 'The leveling system has not been set up yet. Run `/level setup` first to configure it.', + ); + } + + const selectMenu = buildSelectMenu(guildId); + const selectRow = new ActionRowBuilder().addComponents(selectMenu); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild)], + components: [buildButtonRow(cfg, guildId), selectRow], + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `level_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'channel': + await handleChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'message': + await handleMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'xp_range': + await handleXpRange(selectInteraction, interaction, cfg, guildId, client); + break; + case 'xp_cooldown': + await handleXpCooldown(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Leveling config validation error: ${error.message}`); + } else { + logger.error('Unexpected leveling dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for the two toggle buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `level_cfg_toggle_announce_${guildId}` || + i.customId === `level_cfg_toggle_system_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + const isAnnounce = btnInteraction.customId === `level_cfg_toggle_announce_${guildId}`; + + if (isAnnounce) { + cfg.announceLevelUp = cfg.announceLevelUp === false; + await saveLevelingConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Announcements Updated', + `Level-up announcements are now **${cfg.announceLevelUp ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else { + const wasEnabled = cfg.enabled !== false; + cfg.enabled = !wasEnabled; + await saveLevelingConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… System Updated', + `The leveling system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.${!cfg.enabled ? '\nUsers will not earn XP until the system is re-enabled.' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await refreshDashboard(interaction, cfg, guildId); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + btnCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in level_dashboard:', error); + throw new TitanBotError( + `Level dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the leveling dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Change Level-up Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('level_cfg_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Change Level-up Channel') + .setDescription( + `**Current:** ${cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'}\n\nSelect the channel where level-up notifications will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'level_cfg_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.levelUpChannel = channel.id; + await saveLevelingConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Channel Updated', + `Level-up notifications will now be sent in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Level-up Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('level_cfg_message') + .setTitle('Edit Level-up Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message ({user} and {level} are available)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.levelUpMessage || '{user} has leveled up to level {level}!') + .setMaxLength(500) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('{user} has leveled up to level {level}!'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newMessage = submitted.fields.getTextInputValue('message_input').trim(); + + if (!newMessage.includes('{user}') && !newMessage.includes('{level}')) { + logger.warn( + `Level-up message set without {user} or {level} placeholders in guild ${guildId}`, + ); + } + + cfg.levelUpMessage = newMessage; + await saveLevelingConfig(client, guildId, cfg); + + const preview = newMessage.replace('{user}', '@User').replace('{level}', '5'); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Message Updated', + `Level-up message saved.\n**Preview:** ${preview}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Set XP Range โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, client) { + const currentMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; + const currentMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; + + const modal = new ModalBuilder() + .setCustomId('level_cfg_xp_range') + .setTitle('Set XP Range per Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('xp_min_input') + .setLabel('Minimum XP (1โ€“500)') + .setStyle(TextInputStyle.Short) + .setValue(String(currentMin)) + .setMaxLength(3) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('15'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('xp_max_input') + .setLabel('Maximum XP (1โ€“500)') + .setStyle(TextInputStyle.Short) + .setValue(String(currentMax)) + .setMaxLength(3) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('25'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_xp_range' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const rawMin = submitted.fields.getTextInputValue('xp_min_input').trim(); + const rawMax = submitted.fields.getTextInputValue('xp_max_input').trim(); + const newMin = parseInt(rawMin, 10); + const newMax = parseInt(rawMax, 10); + + if (isNaN(newMin) || isNaN(newMax) || newMin < 1 || newMax < 1 || newMin > 500 || newMax > 500) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid Values', 'Both XP values must be whole numbers between **1** and **500**.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (newMin > newMax) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid Range', 'Minimum XP cannot be greater than maximum XP.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.xpRange = { min: newMin, max: newMax }; + await saveLevelingConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… XP Range Updated', + `Users will now earn between **${newMin}** and **${newMax}** XP per message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Set XP Cooldown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleXpCooldown(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('level_cfg_cooldown') + .setTitle('Set XP Cooldown') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('cooldown_input') + .setLabel('Cooldown in seconds (0โ€“3600)') + .setStyle(TextInputStyle.Short) + .setValue(String(cfg.xpCooldown ?? 60)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('60'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_cooldown' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const raw = submitted.fields.getTextInputValue('cooldown_input').trim(); + const newCooldown = parseInt(raw, 10); + + if (isNaN(newCooldown) || newCooldown < 0 || newCooldown > 3600) { + await submitted.reply({ + embeds: [ + errorEmbed( + 'Invalid Value', + 'Cooldown must be a whole number between **0** and **3600** seconds.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.xpCooldown = newCooldown; + await saveLevelingConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Cooldown Updated', + `XP cooldown set to **${newCooldown} second${newCooldown !== 1 ? 's' : ''}**.${newCooldown === 0 ? '\n> โš ๏ธ A cooldown of 0 means XP is granted on every message.' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.js new file mode 100644 index 0000000000..6009f6b8e7 --- /dev/null +++ b/src/commands/Logging/logging.js @@ -0,0 +1,118 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import dashboard from './modules/logging_dashboard.js'; +import setchannel from './modules/logging_setchannel.js'; +import filter from './modules/logging_filter.js'; + +export default { + data: new SlashCommandBuilder() + .setName('logging') + .setDescription('Manage audit logging for this server.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('dashboard') + .setDescription('Open the interactive logging dashboard โ€” view status and toggle event categories.'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('setchannel') + .setDescription('Set the audit log channel for this server.') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('The text channel for audit logs.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false), + ) + .addBooleanOption((option) => + option + .setName('disable') + .setDescription('Set to True to disable audit logging entirely.') + .setRequired(false), + ), + ) + .addSubcommandGroup((group) => + group + .setName('filter') + .setDescription('Manage the log ignore list (users and channels to skip).') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add a user or channel to the log ignore list.') + .addStringOption((option) => + option + .setName('type') + .setDescription('Whether to ignore a user or channel.') + .setRequired(true) + .addChoices( + { name: 'User', value: 'user' }, + { name: 'Channel', value: 'channel' }, + ), + ) + .addStringOption((option) => + option + .setName('id') + .setDescription('The ID of the user or channel to ignore.') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove a user or channel from the log ignore list.') + .addStringOption((option) => + option + .setName('type') + .setDescription('Whether this is a user or channel.') + .setRequired(true) + .addChoices( + { name: 'User', value: 'user' }, + { name: 'Channel', value: 'channel' }, + ), + ) + .addStringOption((option) => + option + .setName('id') + .setDescription('The ID of the user or channel to remove from the ignore list.') + .setRequired(true), + ), + ), + ), + + async execute(interaction, config, client) { + try { + // setchannel and filter both need a reply deferred before their logic runs + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + return await dashboard.execute(interaction, config, client); + } + + await InteractionHelper.safeDefer(interaction); + + if (subcommand === 'setchannel') { + return await setchannel.execute(interaction, config, client); + } + + if (subcommandGroup === 'filter') { + return await filter.execute(interaction, config, client); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Unknown Subcommand', 'This subcommand is not recognised.')], + }); + } catch (error) { + logger.error('logging command error:', error); + await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'An unexpected error occurred.')], + ephemeral: true, + }).catch(() => {}); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js new file mode 100644 index 0000000000..a2d8e18dc1 --- /dev/null +++ b/src/commands/Logging/modules/logging_dashboard.js @@ -0,0 +1,135 @@ +import { EmbedBuilder, PermissionsBitField } from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getLoggingStatus, EVENT_TYPES } from '../../../services/loggingService.js'; +import { createLoggingDashboardComponents } from '../../../utils/loggingUi.js'; +import { errorEmbed } from '../../../utils/embeds.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((acc, eventType) => { + const [category] = eventType.split('.'); + if (!acc[category]) acc[category] = []; + acc[category].push(eventType); + return acc; +}, {}); + +const CATEGORY_MAP = [ + ['moderation', '๐Ÿ”จ Moderation'], + ['ticket', '๐ŸŽซ Ticket Events'], + ['message', 'โœ‰๏ธ Message Events'], + ['role', '๐Ÿท๏ธ Role Events'], + ['member', '๐Ÿ‘ฅ Member Events'], + ['leveling', '๐Ÿ“ˆ Leveling Events'], + ['reactionrole', '๐ŸŽญ Reaction Role Events'], + ['giveaway', '๐ŸŽ Giveaway Events'], + ['counter', '๐Ÿ“Š Counter Events'], +]; + +function getCategoryStatus(enabledEvents, category, auditEnabled) { + if (!auditEnabled) return false; + const events = enabledEvents || {}; + if (events[`${category}.*`] === false) return false; + const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; + if (categoryEvents.length === 0) return true; + return categoryEvents.every((eventType) => events[eventType] !== false); +} + +async function formatChannelMention(guild, id) { + if (!id) return '`Not configured`'; + const channel = guild.channels.cache.get(id) ?? await guild.channels.fetch(id).catch(() => null); + return channel ? channel.toString() : `โš ๏ธ Missing (${id})`; +} + +export async function buildLoggingDashboardView(interaction, client) { + const guildConfig = await getGuildConfig(client, interaction.guildId); + const loggingStatus = await getLoggingStatus(client, interaction.guildId); + + const auditEnabled = Boolean(loggingStatus.enabled); + const auditChannel = await formatChannelMention( + interaction.guild, + loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId, + ); + const lifecycleChannel = await formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId); + const transcriptChannel = await formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId); + + const ignoredUsers = guildConfig.logIgnore?.users || []; + const ignoredChannels = guildConfig.logIgnore?.channels || []; + + const categoryLines = CATEGORY_MAP.map(([key, label]) => { + const on = getCategoryStatus(loggingStatus.enabledEvents, key, auditEnabled); + return `${on ? 'โœ…' : 'โŒ'} ${label}`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ“‹ Logging Dashboard') + .setDescription(`Manage audit logging for **${interaction.guild.name}**. Category buttons toggle logging instantly.`) + .setColor(auditEnabled ? getColor('success') : getColor('warning')) + .addFields( + { + name: '๐Ÿงพ Audit Logging', + value: auditEnabled ? 'โœ… Enabled' : 'โŒ Disabled', + inline: true, + }, + { + name: '\u200B', + value: '\u200B', + inline: true, + }, + { + name: '\u200B', + value: '\u200B', + inline: true, + }, + { + name: '๐Ÿ“ก Log Channels', + value: [ + `**Audit:** ${auditChannel}`, + `**Ticket Logs:** ${lifecycleChannel}`, + `**Ticket Transcripts:** ${transcriptChannel}`, + ].join('\n'), + inline: false, + }, + { + name: '๐Ÿ“‹ Event Categories', + value: categoryLines, + inline: false, + }, + { + name: '๐Ÿงน Ignore Filters', + value: `Users: **${ignoredUsers.length}**\nChannels: **${ignoredChannels.length}**`, + inline: true, + }, + { + name: '๐Ÿ•’ Last Refresh', + value: ``, + inline: true, + }, + ) + .setFooter({ text: 'Use /logging setchannel to configure the audit channel โ€ข /ticket setup or /ticket dashboard to configure ticket channels' }) + .setTimestamp(); + + const components = createLoggingDashboardComponents(loggingStatus.enabledEvents, auditEnabled); + return { embed, components }; +} + +export default { + async execute(interaction, config, client) { + try { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to view the logging dashboard.')], + }); + } + + await InteractionHelper.safeDefer(interaction); + const { embed, components } = await buildLoggingDashboardView(interaction, client); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); + } catch (error) { + logger.error('logging_dashboard error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Dashboard Error', 'Failed to load the logging dashboard.')], + }); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_filter.js b/src/commands/Logging/modules/logging_filter.js new file mode 100644 index 0000000000..a75aa9c9bf --- /dev/null +++ b/src/commands/Logging/modules/logging_filter.js @@ -0,0 +1,99 @@ +import { PermissionsBitField } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to manage log filters.')], + }); + } + + if (!client.db) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Database not initialized.')], + }); + } + + const subcommand = interaction.options.getSubcommand(); + const type = interaction.options.getString('type'); + const entityId = interaction.options.getString('id'); + const guildId = interaction.guildId; + + const currentConfig = await getGuildConfig(client, guildId); + if (!currentConfig.logIgnore) { + currentConfig.logIgnore = { users: [], channels: [] }; + } + + let targetArray; + let entityType; + let entityName; + + if (type === 'user') { + targetArray = currentConfig.logIgnore.users; + entityType = 'User'; + const member = await interaction.guild.members.fetch(entityId).catch(() => null); + entityName = member ? member.user.tag : `ID: ${entityId}`; + } else if (type === 'channel') { + targetArray = currentConfig.logIgnore.channels; + entityType = 'Channel'; + const channel = interaction.guild.channels.cache.get(entityId); + entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; + } else { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Invalid Type', "Choose `user` or `channel`.")], + }); + } + + let successMessage; + + if (subcommand === 'add') { + if (targetArray.includes(entityId)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Filtered', `${entityType} **${entityName}** is already on the ignore list.`)], + }); + } + targetArray.push(entityId); + successMessage = `${entityType} **${entityName}** added to the log ignore list. Events from them will not be logged.`; + } else if (subcommand === 'remove') { + const index = targetArray.indexOf(entityId); + if (index === -1) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Filtered', `${entityType} **${entityName}** was not on the ignore list.`)], + }); + } + targetArray.splice(index, 1); + successMessage = `${entityType} **${entityName}** removed from the log ignore list. Events will now be logged.`; + } else { + return; + } + + try { + await setGuildConfig(client, guildId, currentConfig); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Log Filter Updated', + target: `Filter ${subcommand}`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { entityType, loggingEnabled: currentConfig.enableLogging }, + }, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Filter Updated', successMessage)], + }); + } catch (error) { + logger.error('logging filter error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Failed to save the filter change.')], + }); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_setchannel.js b/src/commands/Logging/modules/logging_setchannel.js new file mode 100644 index 0000000000..5ce85c97d7 --- /dev/null +++ b/src/commands/Logging/modules/logging_setchannel.js @@ -0,0 +1,88 @@ +import { PermissionsBitField, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to change log channels.')], + }); + } + + if (!client.db) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Database not initialized.')], + }); + } + + const guildId = interaction.guildId; + const currentConfig = await getGuildConfig(client, guildId); + + const logChannel = interaction.options.getChannel('channel'); + const disableLogging = interaction.options.getBoolean('disable'); + + try { + if (disableLogging) { + currentConfig.logChannelId = null; + currentConfig.enableLogging = false; + currentConfig.logging = { + ...(currentConfig.logging || {}), + enabled: false, + channelId: null, + }; + await setGuildConfig(client, guildId, currentConfig); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Logging Disabled ๐Ÿšซ', 'Audit logging has been disabled for this server.')], + }); + } + + if (logChannel) { + const perms = logChannel.permissionsFor(interaction.guild.members.me); + if (!perms.has(PermissionsBitField.Flags.SendMessages) || !perms.has(PermissionsBitField.Flags.EmbedLinks)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Bot Permission Error', `I need **Send Messages** and **Embed Links** permissions in ${logChannel}.`)], + }); + } + + currentConfig.logChannelId = logChannel.id; + currentConfig.enableLogging = true; + currentConfig.logging = { + ...(currentConfig.logging || {}), + enabled: true, + channelId: logChannel.id, + }; + await setGuildConfig(client, guildId, currentConfig); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Log Channel Set ๐Ÿ“', `Audit logs will be sent to ${logChannel}.`)], + }); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Log Channel Activated', + target: logChannel.toString(), + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason: `Logging channel set by ${interaction.user}`, + metadata: { channelId: logChannel.id, moderatorId: interaction.user.id, loggingEnabled: true }, + }, + }); + return; + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('No Option Provided', 'Provide one of: `channel` or `disable: True`.\n\n> Ticket transcript and logs channels are managed via `/ticket setup` or `/ticket dashboard`.')], + }); + } catch (error) { + logger.error('logging setchannel error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Configuration Error', 'Could not save the configuration.')], + }); + } + }, +}; diff --git a/src/commands/Reaction_roles/rdelete.js b/src/commands/Reaction_roles/rdelete.js deleted file mode 100644 index 29659d5ab0..0000000000 --- a/src/commands/Reaction_roles/rdelete.js +++ /dev/null @@ -1,134 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { deleteReactionRoleMessage, getReactionRoleMessage } from '../../services/reactionRoleService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rdelete') - .setDescription('Delete a reaction role message') - .addStringOption(option => - option.setName('message_id') - .setDescription('The ID of the reaction role message to delete') - .setRequired(true) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - const messageId = interaction.options.getString('message_id'); - - logger.info(`Reaction role deletion requested by ${interaction.user.tag} for message ${messageId} in guild ${interaction.guild.name}`); - - - if (!/^\d{17,19}$/.test(messageId)) { - throw createError( - `Invalid message ID format: ${messageId}`, - ErrorTypes.VALIDATION, - 'Please provide a valid message ID (17-19 digits).', - { messageId } - ); - } - - - const reactionRoleData = await getReactionRoleMessage(interaction.client, interaction.guildId, messageId); - - if (!reactionRoleData) { - throw createError( - `Reaction role message not found: ${messageId}`, - ErrorTypes.CONFIGURATION, - 'No reaction role message found with that ID in this server.', - { messageId, guildId: interaction.guildId } - ); - } - - - let messageDeleted = false; - try { - const channel = await interaction.guild.channels.fetch(reactionRoleData.channelId).catch(() => null); - - if (channel) { - const message = await channel.messages.fetch(messageId).catch(() => null); - if (message) { - await message.delete(); - messageDeleted = true; - logger.info(`Deleted reaction role Discord message ${messageId} from channel ${channel.name}`); - } else { - logger.warn(`Discord message ${messageId} not found in channel ${channel.name}, will only remove from database`); - } - } else { - logger.warn(`Channel ${reactionRoleData.channelId} not found, will only remove from database`); - } - } catch (deleteError) { - logger.warn(`Failed to delete Discord message ${messageId}:`, deleteError); - - } - - - await deleteReactionRoleMessage(interaction.client, interaction.guildId, messageId); - - logger.info(`Reaction role message ${messageId} deleted from database by ${interaction.user.tag}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_DELETE, - data: { - description: `Reaction role message deleted by ${interaction.user.tag}`, - userId: interaction.user.id, - fields: [ - { - name: '๐Ÿ—‘๏ธ Message ID', - value: messageId, - inline: true - }, - { - name: '๐Ÿ“ Channel', - value: `<#${reactionRoleData.channelId}>`, - inline: true - }, - { - name: '๐Ÿ“Š Status', - value: messageDeleted ? 'โœ… Message deleted' : 'โš ๏ธ Database only', - inline: true - }, - { - name: '๐Ÿท๏ธ Roles', - value: `${Array.isArray(reactionRoleData.roles) ? reactionRoleData.roles.length : Object.keys(reactionRoleData.roles || {}).length} roles removed`, - inline: false - } - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role deletion:', logError); - } - - - const responseMessage = messageDeleted - ? 'โœ… Reaction role message has been deleted from both Discord and the database.' - : 'โœ… Reaction role message has been deleted from the database.\nโš ๏ธ The Discord message could not be found or deleted.'; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', responseMessage)] - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rdelete' - }); - } - } -}; - - diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js new file mode 100644 index 0000000000..cf84511a24 --- /dev/null +++ b/src/commands/Reaction_roles/reactroles.js @@ -0,0 +1,1096 @@ +import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError, createError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createReactionRoleMessage, hasDangerousPermissions, getAllReactionRoleMessages, deleteReactionRoleMessage } from '../../services/reactionRoleService.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('reactroles') + .setDescription('Manage reaction role assignments') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('setup') + .setDescription('Set up a new reaction role panel') + .addChannelOption(option => + option.setName('channel') + .setDescription('The channel to send the reaction role message to') + .setRequired(true) + ) + .addStringOption(option => + option.setName('title') + .setDescription('Title for the reaction role panel') + .setRequired(true) + ) + .addStringOption(option => + option.setName('description') + .setDescription('Description for the reaction role panel') + .setRequired(true) + ) + .addRoleOption(option => + option.setName('role1') + .setDescription('First role to add') + .setRequired(true) + ) + .addRoleOption(option => + option.setName('role2') + .setDescription('Second role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role3') + .setDescription('Third role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role4') + .setDescription('Fourth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role5') + .setDescription('Fifth role to add') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('dashboard') + .setDescription('Manage and configure your reaction role panels') + .addStringOption(option => + option + .setName('panel') + .setDescription('Select a reaction role panel to manage') + .setRequired(false) + .setAutocomplete(true) + ) + ), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + try { + if (subcommand === 'setup') { + await handleSetup(interaction); + } else if (subcommand === 'dashboard') { + const selectedPanelId = interaction.options.getString('panel'); + await handleDashboard(interaction, selectedPanelId); + } + } catch (error) { + await handleInteractionError(interaction, error, { + type: 'command', + commandName: 'reactroles', + subcommand: subcommand + }); + } + }, + + async autocomplete(interaction) { + if (interaction.commandName !== 'reactroles') return; + if (interaction.options.getSubcommand() !== 'dashboard') return; + + try { + const guildId = interaction.guild.id; + const client = interaction.client; + + let panels; + try { + panels = await getAllReactionRoleMessages(client, guildId); + } catch (dbError) { + // If database query fails, just respond with empty + await interaction.respond([]).catch(() => {}); + return; + } + + if (!panels || panels.length === 0) { + await interaction.respond([]).catch(() => {}); + return; + } + + const guild = interaction.guild; + + // Filter out panels whose messages no longer exist and clean up stale data + const validPanels = []; + for (const panel of panels) { + // Validate panel structure + if (!panel.messageId || !panel.channelId) { + continue; + } + + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + await interaction.respond([]).catch(() => {}); + return; + } + + const choices = await Promise.all( + validPanels.slice(0, 25).map(async panel => { + try { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) return null; + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) return null; + + const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const channelName = channel?.name ?? 'unknown'; + + return { + name: `${title} (${channelName})`.substring(0, 100), + value: panel.messageId + }; + } catch (e) { + return null; + } + }) + ); + + const validChoices = choices.filter(c => c !== null); + await interaction.respond(validChoices).catch(() => {}); + } catch (error) { + await interaction.respond([]).catch(() => {}); + } + } +}; + +// โ”€โ”€โ”€ Setup Subcommand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleSetup(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); + + const channel = interaction.options.getChannel('channel'); + const title = interaction.options.getString('title'); + const description = interaction.options.getString('description'); + + // Validate channel type + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + throw createError( + `Invalid channel type: ${channel.type}`, + ErrorTypes.VALIDATION, + 'Please select a text or announcement channel.', + { channelType: channel.type } + ); + } + + // Check bot permissions + if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw createError( + 'Bot missing ManageRoles permission', + ErrorTypes.PERMISSION, + 'I need the "Manage Roles" permission to set up reaction roles.', + { permission: 'ManageRoles' } + ); + } + + if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { + throw createError( + `Bot cannot send messages in ${channel.name}`, + ErrorTypes.PERMISSION, + `I don't have permission to send messages in ${channel}.`, + { channelId: channel.id } + ); + } + + // Check if guild has reached max of 5 panels + const existingPanels = await getAllReactionRoleMessages(interaction.client, interaction.guildId); + if (existingPanels && existingPanels.length >= 5) { + throw createError( + 'Panel limit reached', + ErrorTypes.VALIDATION, + 'Your guild has reached the maximum of 5 reaction role panels. Delete an existing panel to create a new one.', + { maxPanels: 5, currentPanels: existingPanels.length } + ); + } + + // Collect and validate roles + const roles = []; + const roleValidationErrors = []; + + for (let i = 1; i <= 5; i++) { + const role = interaction.options.getRole(`role${i}`); + if (role) { + if (role.position >= interaction.guild.members.me.roles.highest.position) { + roleValidationErrors.push(`**${role.name}** - My bot's role is positioned lower than this role in your server's role hierarchy and cannot assign it`); + continue; + } + + if (hasDangerousPermissions(role)) { + roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); + continue; + } + + if (role.managed) { + roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); + continue; + } + + if (role.id === interaction.guild.id) { + roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); + continue; + } + + roles.push(role); + } + } + + if (roleValidationErrors.length > 0) { + const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; + + if (roles.length === 0) { + throw createError( + 'No valid roles provided', + ErrorTypes.VALIDATION, + errorMsg, + { errors: roleValidationErrors } + ); + } + + await interaction.followUp({ + embeds: [warningEmbed('Role Validation Warning', errorMsg)], + ephemeral: true + }); + } + + if (roles.length < 1) { + throw createError( + 'No roles provided', + ErrorTypes.VALIDATION, + 'You must provide at least one valid role.', + {} + ); + } + + // Create the reaction role message + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('reaction_roles') + .setPlaceholder('Select your roles') + .setMinValues(0) + .setMaxValues(roles.length) + .addOptions( + roles.map(role => ({ + label: role.name, + description: `Add/remove the ${role.name} role`, + value: role.id, + emoji: '๐ŸŽญ' + })) + ) + ); + + const panelEmbed = new EmbedBuilder() + .setTitle(title) + .setDescription(description) + .setColor(getColor('info')) + .addFields({ + name: 'Available Roles', + value: roles.map(role => `โ€ข ${role}`).join('\n') + }) + .setFooter({ text: 'Select roles from the dropdown menu below' }); + + const message = await channel.send({ + embeds: [panelEmbed], + components: [row] + }); + + const roleIds = roles.map(role => role.id); + await createReactionRoleMessage( + interaction.client, + interaction.guildId, + channel.id, + message.id, + roleIds + ); + + logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); + + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.REACTION_ROLE_CREATE, + data: { + description: `Reaction role panel created by ${interaction.user.tag}`, + userId: interaction.user.id, + channelId: channel.id, + fields: [ + { + name: '๐Ÿ“ Title', + value: title, + inline: false + }, + { + name: '๐Ÿ“ Channel', + value: channel.toString(), + inline: true + }, + { + name: '๐Ÿ“Š Roles', + value: `${roles.length} roles`, + inline: true + }, + { + name: '๐Ÿท๏ธ Role List', + value: roles.map(r => r.toString()).join(', '), + inline: false + }, + { + name: '๐Ÿ”— Message Link', + value: message.url, + inline: false + } + ] + } + }); + } catch (logError) { + logger.warn('Failed to log reaction role creation:', logError); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Success', `โœ… Reaction role panel created in ${channel}!\n\n${message.url}`)] + }); +} + +// โ”€โ”€โ”€ Dashboard Subcommand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDashboard(interaction, selectedPanelId) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); + if (!deferSuccess) return; + + const guildId = interaction.guild.id; + const guild = interaction.guild; + const client = interaction.client; + + let panels = await getAllReactionRoleMessages(client, guildId); + + if (!panels || panels.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'No Panels Found', + 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', + ), + ], + }); + } + + // Filter out panels whose messages no longer exist + const validPanels = []; + for (const panel of panels) { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'No Valid Panels Found', + 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', + ), + ], + }); + } + + // If a panel was selected, use it. Otherwise, pick a random one. + let activePanelData = null; + if (selectedPanelId) { + activePanelData = validPanels.find(p => p.messageId === selectedPanelId); + if (!activePanelData) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Panel Not Found', + 'That panel no longer exists or has been deleted.', + ), + ], + }); + } + } else { + // Pick a random panel from valid panels + activePanelData = validPanels[Math.floor(Math.random() * validPanels.length)]; + } + + const discordMsg = await fetchPanelDiscordMessage(guild, activePanelData); + await showPanelDashboard(interaction, activePanelData, discordMsg, guildId, guild); + + let rootInteraction = interaction; + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => + i.user.id === interaction.user.id && + (i.customId === `rr_opts_${guildId}`), + time: 600_000, + }); + + const buttonCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `rr_edit_text_${guildId}` || + i.customId === `rr_delete_${guildId}`), + time: 600_000, + }); + + collector.on('collect', async ci => { + try { + if (ci.customId === `rr_opts_${guildId}`) { + const option = ci.values[0]; + switch (option) { + case 'add_role': + await handleAddRole(ci, rootInteraction, activePanelData, guildId, guild, client); + break; + case 'remove_role': + await handleRemoveRole(ci, rootInteraction, activePanelData, validPanels, guildId, guild, client); + break; + } + } + } catch (error) { + logger.error('Error in reactroles dashboard collector:', error); + const msg = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred.' + : 'An unexpected error occurred.'; + if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); + await ci + .followUp({ embeds: [errorEmbed('Error', msg)], flags: MessageFlags.Ephemeral }) + .catch(() => {}); + } + }); + + buttonCollector.on('collect', async btnInteraction => { + try { + if (btnInteraction.customId === `rr_edit_text_${guildId}`) { + await handleEditText(btnInteraction, rootInteraction, activePanelData, guildId, guild, client); + } else if (btnInteraction.customId === `rr_delete_${guildId}`) { + await handleDeletePanel(btnInteraction, rootInteraction, activePanelData, validPanels, guildId, guild, client, collector, buttonCollector); + } + } catch (error) { + logger.error('Error in reactroles button collector:', error); + const msg = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred.' + : 'An unexpected error occurred.'; + if (!btnInteraction.replied && !btnInteraction.deferred) await btnInteraction.deferUpdate().catch(() => {}); + await btnInteraction + .followUp({ embeds: [errorEmbed('Error', msg)], flags: MessageFlags.Ephemeral }) + .catch(() => {}); + } + }); + + collector.on('end', async (_, reason) => { + buttonCollector.stop(); + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Dashboard Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue managing your reaction roles, please run `/reactroles dashboard` again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [] + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Discord Message Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function fetchPanelDiscordMessage(guild, panelData) { + try { + const channel = guild.channels.cache.get(panelData.channelId); + if (!channel) return null; + return await channel.messages.fetch(panelData.messageId).catch(() => null); + } catch { + return null; + } +} + +async function rebuildLivePanelMessage(guild, panelData) { + try { + const channel = guild.channels.cache.get(panelData.channelId); + if (!channel) return; + const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); + if (!msg || !msg.embeds[0]) return; + + const roleObjects = panelData.roles + .map(id => guild.roles.cache.get(id)) + .filter(Boolean); + + if (roleObjects.length === 0) return; + + const currentEmbed = msg.embeds[0]; + const updatedEmbed = EmbedBuilder.from(currentEmbed); + const fields = currentEmbed.fields.map(f => ({ name: f.name, value: f.value, inline: f.inline })); + const roleFieldIdx = fields.findIndex(f => f.name === 'Available Roles'); + const newRoleValue = roleObjects.map(r => `โ€ข ${r}`).join('\n'); + if (roleFieldIdx !== -1) { + fields[roleFieldIdx] = { name: 'Available Roles', value: newRoleValue, inline: false }; + } else { + fields.push({ name: 'Available Roles', value: newRoleValue, inline: false }); + } + updatedEmbed.setFields(fields); + + const selectRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('reaction_roles') + .setPlaceholder('Select your roles') + .setMinValues(0) + .setMaxValues(roleObjects.length) + .addOptions( + roleObjects.map(r => ({ + label: r.name.substring(0, 100), + description: `Add/remove the ${r.name} role`.substring(0, 100), + value: r.id, + emoji: '๐ŸŽญ', + })), + ), + ); + + await msg.edit({ embeds: [updatedEmbed], components: [selectRow] }); + } catch (error) { + logger.warn('Could not rebuild live reaction role panel:', error.message); + } +} + +// โ”€โ”€โ”€ View Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showPanelDashboard(interaction, panelData, discordMsg, guildId, guild) { + const channel = guild.channels.cache.get(panelData.channelId); + const title = discordMsg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const roleList = + panelData.roles.length > 0 + ? panelData.roles.map(id => `<@&${id}>`).join(', ') + : '`None`'; + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽญ Reaction Roles Dashboard') + .setDescription( + `**Title:** ${title}\n\nSelect an option below to modify a setting.${discordMsg ? `\n[Click Here to View Panel](${discordMsg.url})` : ''}`, + ) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ Channel', value: channel ? `<#${channel.id}>` : '`Not found`', inline: true }, + { name: '๐ŸŽญ Roles', value: `\`${panelData.roles.length} / 25\``, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿท๏ธ Role List', value: roleList, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); + + const editTextButton = new ButtonBuilder() + .setCustomId(`rr_edit_text_${guildId}`) + .setLabel('Edit Panel Text') + .setStyle(ButtonStyle.Primary) + .setEmoji('โœ๏ธ'); + + const deleteButton = new ButtonBuilder() + .setCustomId(`rr_delete_${guildId}`) + .setLabel('Delete Panel') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ'); + + const optionsSelect = new StringSelectMenuBuilder() + .setCustomId(`rr_opts_${guildId}`) + .setPlaceholder('Select an action...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Add Role') + .setDescription('Add a role to this panel (up to 25 total)') + .setValue('add_role') + .setEmoji('โž•'), + ...(panelData.roles.length > 0 ? [ + new StringSelectMenuOptionBuilder() + .setLabel('Remove Role') + .setDescription('Remove a role from this panel') + .setValue('remove_role') + .setEmoji('โž–') + ] : []) + ); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents(editTextButton, deleteButton), + new ActionRowBuilder().addComponents(optionsSelect), + ], + }); +} + +// โ”€โ”€โ”€ Edit Panel Text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleEditText(buttonInteraction, rootInteraction, panelData, guildId, guild, client) { + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + + const currentTitle = discordMsg?.embeds?.[0]?.title ?? ''; + const currentDesc = discordMsg?.embeds?.[0]?.description ?? ''; + + const modal = new ModalBuilder() + .setCustomId('rr_edit_text') + .setTitle('Edit Panel Text') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_title') + .setLabel('Title') + .setStyle(TextInputStyle.Short) + .setValue(currentTitle) + .setMaxLength(256) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_description') + .setLabel('Description') + .setStyle(TextInputStyle.Paragraph) + .setValue(currentDesc) + .setMaxLength(2048) + .setMinLength(1) + .setRequired(true), + ), + ); + + try { + await buttonInteraction.showModal(modal); + } catch (error) { + logger.error('Error showing edit text modal:', error); + await buttonInteraction.followUp({ + embeds: [errorEmbed('Error', 'Failed to show the edit panel text modal. Please try again.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + return; + } + + const submitted = await buttonInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'rr_edit_text' && i.user.id === buttonInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newTitle = submitted.fields.getTextInputValue('panel_title').trim(); + const newDesc = submitted.fields.getTextInputValue('panel_description').trim(); + + if (discordMsg) { + const updatedEmbed = EmbedBuilder.from(discordMsg.embeds[0]).setTitle(newTitle).setDescription(newDesc); + await discordMsg.edit({ embeds: [updatedEmbed] }).catch(err => { + logger.warn('Could not edit live panel message:', err.message); + }); + } + + await submitted.reply({ + embeds: [successEmbed('โœ… Panel Updated', 'The title and description have been updated.')], + flags: MessageFlags.Ephemeral, + }); + + const refreshedMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, refreshedMsg, guildId, guild); +} + +// โ”€โ”€โ”€ Add Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleAddRole(selectInteraction, rootInteraction, panelData, guildId, guild, client) { + await selectInteraction.deferUpdate(); + + if (panelData.roles.length >= 25) { + await selectInteraction.followUp({ + embeds: [errorEmbed('Panel Full', 'This panel already has the maximum of 25 roles.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('rr_add_role_pick') + .setPlaceholder('Select a role to add...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž• Add Role') + .setDescription( + `**Current roles:** ${panelData.roles.length}/25\n\nSelect a role to add to this panel.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'rr_add_role_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + if (panelData.roles.includes(role.id)) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Already Added', `${role} is already in this panel.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.id === guild.id) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Invalid Role', 'You cannot use @everyone.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.managed) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Invalid Role', 'Managed/bot roles cannot be used.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (hasDangerousPermissions(role)) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Dangerous Permissions', + 'That role has sensitive permissions (Administrator, Manage Server, etc.) and cannot be used.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.position >= guild.members.me.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + "That role is above my highest role in the hierarchy. Move my role above it first.", + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + panelData.roles.push(role.id); + const key = `reaction_roles:${guildId}:${panelData.messageId}`; + await client.db.set(key, panelData); + + await rebuildLivePanelMessage(guild, panelData); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Added', `${role} has been added to the panel.`)], + flags: MessageFlags.Ephemeral, + }); + + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Remove Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRemoveRole(selectInteraction, rootInteraction, panelData, panels, guildId, guild, client) { + await selectInteraction.deferUpdate(); + + const roleOptions = panelData.roles + .map(id => { + const role = guild.roles.cache.get(id); + return role ? { label: role.name.substring(0, 100), value: id } : null; + }) + .filter(Boolean); + + if (roleOptions.length === 0) { + await selectInteraction.followUp({ + embeds: [errorEmbed('No Valid Roles', 'The roles on this panel no longer exist in the server.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const removeSelect = new StringSelectMenuBuilder() + .setCustomId('rr_remove_role_pick') + .setPlaceholder('Select a role to remove...') + .setMaxValues(1) + .addOptions( + roleOptions.map(r => + new StringSelectMenuOptionBuilder().setLabel(r.label).setValue(r.value).setEmoji('๐ŸŽญ'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Role') + .setDescription('Select the role you want to remove from this panel.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(removeSelect)], + flags: MessageFlags.Ephemeral, + }); + + const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'rr_remove_role_pick', + time: 60_000, + max: 1, + }); + + removeCollector.on('collect', async removeInteraction => { + await removeInteraction.deferUpdate(); + const roleId = removeInteraction.values[0]; + const role = guild.roles.cache.get(roleId); + + panelData.roles = panelData.roles.filter(id => id !== roleId); + + if (panelData.roles.length === 0) { + const channel = guild.channels.cache.get(panelData.channelId); + if (channel) { + const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + await deleteReactionRoleMessage(client, guildId, panelData.messageId); + + await removeInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Role Removed', + 'That was the last role on the panel. The panel has been deleted.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + + // Remove the deleted panel from the array + const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); + if (panelIndex > -1) { + panels.splice(panelIndex, 1); + } + + if (panels.length === 0) { + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setColor(getColor('info')), + ], + components: [], + }); + } else { + // Dashboard closed after last role removed + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setColor(getColor('success')), + ], + components: [], + }); + } + } else { + const key = `reaction_roles:${guildId}:${panelData.messageId}`; + await client.db.set(key, panelData); + await rebuildLivePanelMessage(guild, panelData); + + await removeInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Role Removed', + `${role ? role.toString() : `<@&${roleId}>`} has been removed from the panel.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + } + }); + + removeCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Delete Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeletePanel(btnInteraction, rootInteraction, panelData, panels, guildId, guild, client, collector, buttonCollector) { + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + const title = discordMsg?.embeds?.[0]?.title ?? 'this panel'; + + const deleteModal = new ModalBuilder() + .setCustomId('rr_delete_confirm_modal') + .setTitle('Delete Reaction Role Panel') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true) + ) + ); + + await btnInteraction.showModal(deleteModal); + + const submitted = await btnInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'rr_delete_confirm_modal' && i.user.id === btnInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) { + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + return; + } + + const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); + + if (confirmation !== 'DELETE') { + await submitted.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], + flags: MessageFlags.Ephemeral, + }); + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + return; + } + + await submitted.deferUpdate(); + + if (discordMsg) { + await discordMsg.delete().catch(() => {}); + } + await deleteReactionRoleMessage(client, guildId, panelData.messageId); + + try { + await logEvent({ + client, + guildId, + eventType: EVENT_TYPES.REACTION_ROLE_DELETE, + data: { + description: `Reaction role panel deleted by ${submitted.user.tag}`, + userId: submitted.user.id, + channelId: panelData.channelId, + fields: [ + { name: '๐Ÿ“‹ Panel', value: title, inline: true }, + { name: '๐Ÿ“ Channel', value: channel ? channel.toString() : 'Unknown', inline: true }, + ], + }, + }); + } catch (logErr) { + logger.warn('Failed to log reaction role deletion:', logErr); + } + + await submitted.followUp({ + embeds: [successEmbed('โœ… Panel Deleted', `**${title}** has been deleted.`)], + flags: MessageFlags.Ephemeral, + }); + + // Remove the deleted panel from the array + const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); + if (panelIndex > -1) { + panels.splice(panelIndex, 1); + } + + if (panels.length === 0) { + collector.stop(); + buttonCollector.stop(); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setColor(getColor('info')), + ], + components: [], + }); + } else { + // Close the dashboard after deletion + collector.stop(); + buttonCollector.stop(); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setColor(getColor('success')), + ], + components: [], + }); + } +} \ No newline at end of file diff --git a/src/commands/Reaction_roles/rlist.js b/src/commands/Reaction_roles/rlist.js deleted file mode 100644 index d556877189..0000000000 --- a/src/commands/Reaction_roles/rlist.js +++ /dev/null @@ -1,91 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, EmbedBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getAllReactionRoleMessages } from '../../services/reactionRoleService.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rlist') - .setDescription('List all reaction role messages in this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - logger.info(`Reaction role list requested by ${interaction.user.tag} in guild ${interaction.guild.name}`); - - - const guildReactionRoles = await getAllReactionRoleMessages(interaction.client, interaction.guildId); - - if (guildReactionRoles.length === 0) { - logger.debug(`No reaction role messages found in guild ${interaction.guild.name}`); - return InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed('No Reaction Roles', 'There are no reaction role messages in this server.')] - }); - } - - const embed = new EmbedBuilder() - .setTitle('๐ŸŽญ Reaction Role Messages') - .setColor(getColor('info')) - .setDescription(`Found **${guildReactionRoles.length}** active reaction role message${guildReactionRoles.length !== 1 ? 's' : ''}:`) - .setFooter({ text: `Total: ${guildReactionRoles.length} message${guildReactionRoles.length !== 1 ? 's' : ''}` }) - .setTimestamp(); - - for (const rr of guildReactionRoles) { - try { - const channel = await interaction.guild.channels.fetch(rr.channelId).catch(() => null); - const message = channel ? await channel.messages.fetch(rr.messageId).catch(() => null) : null; - - - let roleCount = 0; - if (Array.isArray(rr.roles)) { - roleCount = rr.roles.length; - } else if (typeof rr.roles === 'object') { - roleCount = Object.keys(rr.roles).length; - } - - - let fieldValue = ''; - fieldValue += `๐Ÿ“ **Channel:** ${channel ? channel.toString() : 'โŒ Not found'}\n`; - fieldValue += `๐Ÿ”— **Message:** ${message ? `[Jump to Message](${message.url})` : 'โŒ Message not found'}\n`; - fieldValue += `๐Ÿท๏ธ **Roles:** ${roleCount} role${roleCount !== 1 ? 's' : ''} configured`; - - - if (rr.createdAt) { - fieldValue += `\n๐Ÿ“… **Created:** `; - } - - embed.addFields({ - name: `Message ID: \`${rr.messageId}\``, - value: fieldValue, - inline: false - }); - } catch (fieldError) { - logger.warn(`Error processing reaction role message ${rr.messageId}:`, fieldError); - embed.addFields({ - name: `Message ID: \`${rr.messageId}\``, - value: 'โš ๏ธ Error loading message details', - inline: false - }); - } - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Reaction role list displayed to ${interaction.user.tag}, showing ${guildReactionRoles.length} messages`); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rlist' - }); - } - } -}; - - diff --git a/src/commands/Reaction_roles/rsetup.js b/src/commands/Reaction_roles/rsetup.js deleted file mode 100644 index 056bfa4307..0000000000 --- a/src/commands/Reaction_roles/rsetup.js +++ /dev/null @@ -1,267 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, EmbedBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createReactionRoleMessage, hasDangerousPermissions } from '../../services/reactionRoleService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rsetup') - .setDescription('Set up a reaction role message') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send the reaction role message to') - .setRequired(true) - ) - .addStringOption(option => - option.setName('title') - .setDescription('Title for the reaction role message') - .setRequired(true) - ) - .addStringOption(option => - option.setName('description') - .setDescription('Description for the reaction role message') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role1') - .setDescription('First role to add') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role2') - .setDescription('Second role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role3') - .setDescription('Third role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role4') - .setDescription('Fourth role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role5') - .setDescription('Fifth role to add') - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); - - const channel = interaction.options.getChannel('channel'); - const title = interaction.options.getString('title'); - const description = interaction.options.getString('description'); - - - if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { - throw createError( - `Invalid channel type: ${channel.type}`, - ErrorTypes.VALIDATION, - 'Please select a text or announcement channel.', - { channelType: channel.type } - ); - } - - - if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - 'Bot missing ManageRoles permission', - ErrorTypes.PERMISSION, - 'I need the "Manage Roles" permission to set up reaction roles.', - { permission: 'ManageRoles' } - ); - } - - - if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { - throw createError( - `Bot cannot send messages in ${channel.name}`, - ErrorTypes.PERMISSION, - `I don't have permission to send messages in ${channel}.`, - { channelId: channel.id } - ); - } - - - const roles = []; - const roleValidationErrors = []; - - for (let i = 1; i <= 5; i++) { - const role = interaction.options.getRole(`role${i}`); - if (role) { - - if (role.position >= interaction.guild.members.me.roles.highest.position) { - roleValidationErrors.push(`**${role.name}** - My role is not high enough in the hierarchy`); - continue; - } - - - if (hasDangerousPermissions(role)) { - roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); - continue; - } - - - if (role.managed) { - roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); - continue; - } - - - if (role.id === interaction.guild.id) { - roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); - continue; - } - - roles.push(role); - } - } - - - if (roleValidationErrors.length > 0) { - const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; - - if (roles.length === 0) { - throw createError( - 'No valid roles provided', - ErrorTypes.VALIDATION, - errorMsg, - { errors: roleValidationErrors } - ); - } - - - await interaction.followUp({ - embeds: [warningEmbed('Role Validation Warning', errorMsg)], - ephemeral: true - }); - } - - if (roles.length < 1) { - throw createError( - 'No roles provided', - ErrorTypes.VALIDATION, - 'You must provide at least one valid role.', - {} - ); - } - - - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') - .setMinValues(0) - .setMaxValues(roles.length) - .addOptions( - roles.map(role => ({ - label: role.name, - description: `Add/remove the ${role.name} role`, - value: role.id, - emoji: '๐ŸŽญ' - })) - ) - ); - - - const message = await channel.send({ - embeds: [{ - title, - description, - color: getColor('info'), - fields: [ - { - name: 'Available Roles', - value: roles.map(role => `โ€ข ${role}`).join('\n') - } - ], - footer: { - text: 'Select roles from the dropdown menu below' - } - }], - components: [row] - }); - - - const roleIds = roles.map(role => role.id); - await createReactionRoleMessage( - interaction.client, - interaction.guildId, - channel.id, - message.id, - roleIds - ); - - logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_CREATE, - data: { - description: `Reaction role message created by ${interaction.user.tag}`, - userId: interaction.user.id, - channelId: channel.id, - fields: [ - { - name: '๐Ÿ“ Title', - value: title, - inline: false - }, - { - name: '๐Ÿ“ Channel', - value: channel.toString(), - inline: true - }, - { - name: '๐Ÿ“Š Roles', - value: `${roles.length} roles`, - inline: true - }, - { - name: '๐Ÿท๏ธ Role List', - value: roles.map(r => r.toString()).join(', '), - inline: false - }, - { - name: '๐Ÿ”— Message Link', - value: message.url, - inline: false - } - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role creation:', logError); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', `โœ… Reaction role message created in ${channel}!\n\n${message.url}`)] - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rsetup' - }); - } - } -}; - - - diff --git a/src/commands/Counter/modules/counter_create.js b/src/commands/ServerStats/modules/serverstats_create.js similarity index 99% rename from src/commands/Counter/modules/counter_create.js rename to src/commands/ServerStats/modules/serverstats_create.js index 9a98999ca5..9bd4912507 100644 --- a/src/commands/Counter/modules/counter_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js similarity index 98% rename from src/commands/Counter/modules/counter_delete.js rename to src/commands/ServerStats/modules/serverstats_delete.js index 9a466e308a..e0f52f204e 100644 --- a/src/commands/Counter/modules/counter_delete.js +++ b/src/commands/ServerStats/modules/serverstats_delete.js @@ -1,7 +1,7 @@ import { getColor } from '../../../config/bot.js'; import { PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_list.js b/src/commands/ServerStats/modules/serverstats_list.js similarity index 99% rename from src/commands/Counter/modules/counter_list.js rename to src/commands/ServerStats/modules/serverstats_list.js index b443ce473b..24c3aefec3 100644 --- a/src/commands/Counter/modules/counter_list.js +++ b/src/commands/ServerStats/modules/serverstats_list.js @@ -1,7 +1,7 @@ import { getColor } from '../../../config/bot.js'; import { PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_update.js b/src/commands/ServerStats/modules/serverstats_update.js similarity index 98% rename from src/commands/Counter/modules/counter_update.js rename to src/commands/ServerStats/modules/serverstats_update.js index 18d0ed4e46..cd3d30ece3 100644 --- a/src/commands/Counter/modules/counter_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/counter.js b/src/commands/ServerStats/serverstats.js similarity index 80% rename from src/commands/Counter/counter.js rename to src/commands/ServerStats/serverstats.js index fc576460ab..8da7cc79f8 100644 --- a/src/commands/Counter/counter.js +++ b/src/commands/ServerStats/serverstats.js @@ -3,25 +3,25 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } f import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleCreate } from './modules/counter_create.js'; -import { handleList } from './modules/counter_list.js'; -import { handleUpdate } from './modules/counter_update.js'; -import { handleDelete } from './modules/counter_delete.js'; +import { handleCreate } from './modules/serverstats_create.js'; +import { handleList } from './modules/serverstats_list.js'; +import { handleUpdate } from './modules/serverstats_update.js'; +import { handleDelete } from './modules/serverstats_delete.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("counter") - .setDescription("Manage server counters that track statistics in channel names") + .setName("serverstats") + .setDescription("Manage server statistics that track member counts and channel data") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand(subcommand => subcommand .setName("create") - .setDescription("Create a new counter channel in a category") + .setDescription("Create a new statistics tracker channel in a category") .addStringOption(option => option .setName("type") - .setDescription("The type of counter to create") + .setDescription("The type of statistics to track") .setRequired(true) .addChoices( { name: "members + bots", value: "members" }, @@ -32,7 +32,7 @@ export default { .addStringOption(option => option .setName("channel_type") - .setDescription("The channel type to create for this counter") + .setDescription("The channel type to create for this tracker") .setRequired(true) .addChoices( { name: "voice channel (recommended)", value: "voice" }, @@ -42,7 +42,7 @@ export default { .addChannelOption(option => option .setName("category") - .setDescription("The category where the counter channel will be created") + .setDescription("The category where the statistics tracker channel will be created") .setRequired(true) .addChannelTypes(ChannelType.GuildCategory) ) @@ -50,22 +50,22 @@ export default { .addSubcommand(subcommand => subcommand .setName("list") - .setDescription("List all counters for this server") + .setDescription("List all statistics trackers for this server") ) .addSubcommand(subcommand => subcommand .setName("update") - .setDescription("Update an existing counter") + .setDescription("Update an existing statistics tracker") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the counter to update") + .setDescription("The ID of the tracker to update") .setRequired(true) ) .addStringOption(option => option .setName("type") - .setDescription("The new counter type") + .setDescription("The new tracker type") .setRequired(false) .addChoices( { name: "members + bots", value: "members" }, @@ -77,11 +77,11 @@ export default { .addSubcommand(subcommand => subcommand .setName("delete") - .setDescription("Delete an existing counter") + .setDescription("Delete an existing statistics tracker") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the counter to delete") + .setDescription("The ID of the tracker to delete") .setRequired(true) ) ), @@ -110,7 +110,7 @@ export default { }); } } catch (error) { - logger.error(`Error in counter ${subcommand}:`, error); + logger.error(`Error in serverstats ${subcommand}:`, error); const errorEmbedMsg = createEmbed({ title: "โŒ Error", diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js new file mode 100644 index 0000000000..43ece8d982 --- /dev/null +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -0,0 +1,1036 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + RoleSelectMenuBuilder, + ChannelSelectMenuBuilder, + UserSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfigKey } from '../../../utils/database.js'; +import { getUserTicketCount } from '../../../services/ticket.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(config, guild) { + const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Not set`'; + const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Not set`'; + const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Not set`'; + const transcriptChannel = config.ticketTranscriptChannelId ? `<#${config.ticketTranscriptChannelId}>` : '`Not set`'; + + // Get category names from guild + const openCategoryChannel = config.ticketCategoryId ? guild.channels.cache.get(config.ticketCategoryId) : null; + const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Not set`'; + + const closedCategoryChannel = config.ticketClosedCategoryId ? guild.channels.cache.get(config.ticketClosedCategoryId) : null; + const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Not set`'; + + const rawMsg = config.ticketPanelMessage || 'Click the button below to create a support ticket.'; + const panelMsg = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + const btnLabel = `\`${config.ticketButtonLabel || 'Create Ticket'}\``; + + return new EmbedBuilder() + .setTitle('๐ŸŽซ Ticket System Dashboard') + .setDescription(`Manage ticket system settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Panel Channel', value: panelChannel, inline: true }, + { name: '๐Ÿ›ก๏ธ Staff Role', value: staffRole, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ“ Open Tickets Category', value: openCategory, inline: true }, + { name: '๐Ÿ“‚ Closed Tickets Category', value: closedCategory, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ“ Panel Message', value: panelMsg, inline: false }, + { name: '๐Ÿท๏ธ Button Label', value: btnLabel, inline: true }, + { name: '๐Ÿ”ข Max Tickets/User', value: String(config.maxTicketsPerUser || 3), inline: true }, + { name: '๐Ÿ“ฌ DM on Close', value: config.dmOnClose !== false ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐ŸŽซ Ticket Logs Channel', value: ticketLogsChannel, inline: true }, + { name: '๐Ÿ“œ Transcript Channel', value: transcriptChannel, inline: true }, + ) + .setFooter({ text: 'Select an option below โ€ข Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`ticket_config_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Panel Message') + .setDescription('Change the message displayed on the ticket creation panel') + .setValue('panel_message') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Button Label') + .setDescription('Change the label on the Create Ticket button') + .setValue('button_label') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Open Tickets Category') + .setDescription('Category where new tickets are created') + .setValue('open_category') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Closed Tickets Category') + .setDescription('Category where closed tickets are moved') + .setValue('closed_category') + .setEmoji('๐Ÿ“‚'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Max Tickets per User') + .setDescription('Limit how many open tickets one user can have at once') + .setValue('max_tickets') + .setEmoji('๐Ÿ”ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Ticket Logs Channel') + .setDescription('Channel to receive ticket feedback, lifecycle events, and logs') + .setValue('logs_channel') + .setEmoji('๐ŸŽซ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Transcript Channel') + .setDescription('Channel to receive auto-generated transcripts on deletion') + .setValue('transcript_channel') + .setEmoji('๐Ÿ“œ'), + ); +} + +function buildButtonRow(guildConfig, guildId, disabled = false) { + const dmEnabled = guildConfig.dmOnClose !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ticket_cfg_dm_toggle_${guildId}`) + .setLabel('DM on Close') + .setStyle(dmEnabled ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji(dmEnabled ? '๐Ÿ“ฌ' : '๐Ÿ“ญ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`ticket_cfg_staff_role_btn_${guildId}`) + .setLabel('Staff Role') + .setStyle(ButtonStyle.Secondary) + .setEmoji('๐Ÿ›ก๏ธ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`ticket_cfg_delete_${guildId}`) + .setLabel('Delete System') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, guildConfig, guildId) { + const buttonRow = buildButtonRow(guildConfig, guildId); + const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild)], + components: [buttonRow, selectRow], + }).catch(() => {}); +} + +/** + * Attempts to find and edit the live ticket panel message in the panel channel. + * Returns true if the panel was found and updated, false otherwise. + */ +async function updateLivePanel(client, guild, config) { + if (!config.ticketPanelChannelId) return false; + try { + const channel = await guild.channels.fetch(config.ticketPanelChannelId).catch(() => null); + if (!channel) return false; + + const messages = await channel.messages.fetch({ limit: 50 }); + const panelMsg = messages.find( + m => + m.author.id === client.user.id && + m.components?.length > 0 && + m.components[0]?.components?.[0]?.customId === 'create_ticket', + ); + if (!panelMsg) return false; + + const updatedEmbed = new EmbedBuilder() + .setTitle('๐ŸŽซ Support Tickets') + .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') + .setColor(getColor('info')); + + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('create_ticket') + .setLabel(config.ticketButtonLabel || 'Create Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐Ÿ“ฉ'), + ); + + await panelMsg.edit({ embeds: [updatedEmbed], components: [button] }); + return true; + } catch (error) { + logger.warn('Failed to update live ticket panel:', error.message); + return false; + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + + if (!guildConfig.ticketPanelChannelId) { + throw new TitanBotError( + 'Ticket system not configured', + ErrorTypes.CONFIGURATION, + 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', + ); + } + + const selectMenu = buildSelectMenu(guildId); + const selectRow = new ActionRowBuilder().addComponents(selectMenu); + const buttonRow = buildButtonRow(guildConfig, guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(guildConfig, interaction.guild)], + components: [buttonRow, selectRow], + }); + + const replyMessage = await interaction.fetchReply().catch(() => null); + const replyMessageId = replyMessage?.id; + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && + i.customId === `ticket_config_${guildId}` && + (!replyMessageId || i.message.id === replyMessageId), + time: 600_000, + }); + + const buttonCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (!replyMessageId || i.message.id === replyMessageId) && + (i.customId === `ticket_cfg_dm_toggle_${guildId}` || + i.customId === `ticket_cfg_staff_role_btn_${guildId}` || + i.customId === `ticket_cfg_delete_${guildId}`), + + time: 600_000, + }); + + collector.on('collect', async (selectInteraction) => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'panel_message': + await handlePanelMessage(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'button_label': + await handleButtonLabel(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'staff_role': + await handleStaffRole(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'open_category': + await handleOpenCategory(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'closed_category': + await handleClosedCategory(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'max_tickets': + await handleMaxTickets(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'logs_channel': + await handleLogsChannel(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'transcript_channel': + await handleTranscriptChannel(selectInteraction, interaction, guildConfig, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Ticket config validation error: ${error.message}`); + } else { + logger.error('Unexpected ticket config menu error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + // Already deferred at the top of the collector + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + buttonCollector.on('collect', async (btnInteraction) => { + try { + if (btnInteraction.customId === `ticket_cfg_dm_toggle_${guildId}`) { + await handleDmOnClose(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `ticket_cfg_staff_role_btn_${guildId}`) { + await handleStaffRole(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `ticket_cfg_delete_${guildId}`) { + await handleDeleteSystem(btnInteraction, interaction, guildConfig, guildId, client); + } + } catch (error) { + if (error.code === 40060) return; + if (error instanceof TitanBotError) { + logger.debug(`Ticket config button error: ${error.message}`); + } else { + logger.error('Unexpected ticket config button error:', error); + } + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + // Already deferred at the top of the collector + await btnInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (collected, reason) => { + buttonCollector.stop(); + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in ticket_config:', error); + throw new TitanBotError( + `Ticket config failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the ticket configuration dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Panel Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_panel_msg') + .setTitle('Edit Panel Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_msg_input') + .setLabel('Panel Message') + .setStyle(TextInputStyle.Paragraph) + .setValue( + guildConfig.ticketPanelMessage || + 'Click the button below to create a support ticket.', + ) + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('Click the button below to create a support ticket.'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_panel_msg' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newMessage = submitted.fields.getTextInputValue('panel_msg_input').trim(); + guildConfig.ticketPanelMessage = newMessage; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Panel Message Updated', + `The panel message has been updated.${ + panelUpdated + ? '\nThe live ticket panel has also been refreshed.' + : '\n> **Note:** The live panel could not be located. The new message will apply the next time you run `/ticket setup`.' + }`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Button Label โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_btn_label') + .setTitle('Edit Button Label') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('btn_label_input') + .setLabel('Button Label (max 80 characters)') + .setStyle(TextInputStyle.Short) + .setValue(guildConfig.ticketButtonLabel || 'Create Ticket') + .setMaxLength(80) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('Create Ticket'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_btn_label' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newLabel = submitted.fields.getTextInputValue('btn_label_input').trim(); + guildConfig.ticketButtonLabel = newLabel; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Button Label Updated', + `Button label changed to \`${newLabel}\`.${ + panelUpdated + ? '\nThe live ticket panel button has also been updated.' + : '\n> **Note:** The live panel could not be located. The new label will apply the next time you run `/ticket setup`.' + }`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Staff Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('ticket_cfg_staff_role') + .setPlaceholder('Select the staff role...') + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(roleSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ›ก๏ธ Change Staff Role') + .setDescription( + `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Not set`'}\n\nSelect the role that should have staff access to manage tickets.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_staff_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + guildConfig.ticketStaffRoleId = role.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Staff Role Updated', `Staff role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The staff role was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Open Tickets Category โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_open_cat') + .setPlaceholder('Select a category...') + .addChannelTypes(ChannelType.GuildCategory) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ Change Open Tickets Category') + .setDescription( + `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Not set`'}\n\nSelect the category where new tickets will be created.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const catCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_open_cat', + time: 60_000, + max: 1, + }); + + catCollector.on('collect', async catInteraction => { + await catInteraction.deferUpdate(); + const category = catInteraction.channels.first(); + + guildConfig.ticketCategoryId = category.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await catInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Open Category Updated', + `New tickets will now be created in **${category.name}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + catCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Closed Tickets Category โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleClosedCategory( + selectInteraction, + rootInteraction, + guildConfig, + guildId, + client, +) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_closed_cat') + .setPlaceholder('Select a category...') + .addChannelTypes(ChannelType.GuildCategory) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‚ Change Closed Tickets Category') + .setDescription( + `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Not set`'}\n\nSelect the category where closed tickets will be moved.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const catCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_closed_cat', + time: 60_000, + max: 1, + }); + + catCollector.on('collect', async catInteraction => { + await catInteraction.deferUpdate(); + const category = catInteraction.channels.first(); + + guildConfig.ticketClosedCategoryId = category.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await catInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Closed Category Updated', + `Closed tickets will now be moved to **${category.name}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + catCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Max Tickets per User โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_max_tickets') + .setTitle('Set Max Tickets per User') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('max_tickets_input') + .setLabel('Max Open Tickets (1โ€“10)') + .setStyle(TextInputStyle.Short) + .setValue(String(guildConfig.maxTicketsPerUser || 3)) + .setMaxLength(2) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('3'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_max_tickets' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const raw = submitted.fields.getTextInputValue('max_tickets_input').trim(); + const newMax = parseInt(raw, 10); + + if (isNaN(newMax) || newMax < 1 || newMax > 10) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Max tickets must be a whole number between **1** and **10**.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.maxTicketsPerUser = newMax; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Max Tickets Updated', + `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ DM on Close Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { + await btnInteraction.deferUpdate(); + + const newState = guildConfig.dmOnClose === false; + guildConfig.dmOnClose = newState; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… DM on Close Updated', + `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Feedback Logs Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_logs_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŽซ Select Ticket Logs Channel') + .setDescription('Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.') + .setColor(getColor('info')) + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral + }); + + const collector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_logs_channel', + time: 60_000, + max: 1 + }); + + collector.on('collect', async channelInteraction => { + await channelInteraction.deferUpdate(); + const channel = channelInteraction.channels.first(); + + guildConfig.ticketLogsChannelId = channel.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await channelInteraction.followUp({ + embeds: [successEmbed('โœ… Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], + flags: MessageFlags.Ephemeral + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], + flags: MessageFlags.Ephemeral + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Transcript Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_transcript_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“œ Select Transcript Channel') + .setDescription('Choose where auto-generated transcripts will be sent when tickets are deleted.') + .setColor(getColor('info')) + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral + }); + + const collector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_transcript_channel', + time: 60_000, + max: 1 + }); + + collector.on('collect', async channelInteraction => { + await channelInteraction.deferUpdate(); + const channel = channelInteraction.channels.first(); + + guildConfig.ticketTranscriptChannelId = channel.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await channelInteraction.followUp({ + embeds: [successEmbed('โœ… Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], + flags: MessageFlags.Ephemeral + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], + flags: MessageFlags.Ephemeral + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Check User Tickets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const userSelect = new UserSelectMenuBuilder() + .setCustomId('ticket_cfg_check_user') + .setPlaceholder('Select a user to check...') + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(userSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ” Check User Tickets') + .setDescription('Select a user to view their current open ticket count.') + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const userCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.UserSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_check_user', + time: 60_000, + max: 1, + }); + + userCollector.on('collect', async userInteraction => { + await userInteraction.deferUpdate(); + const targetUser = userInteraction.users.first(); + const maxTickets = guildConfig.maxTicketsPerUser || 3; + const openCount = await getUserTicketCount(guildId, targetUser.id); + const atLimit = openCount >= maxTickets; + + await userInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle(`๐ŸŽซ Ticket Check โ€” ${targetUser.username}`) + .setDescription( + `**Open Tickets:** ${openCount} / ${maxTickets}\n` + + `**Remaining:** ${Math.max(0, maxTickets - openCount)}\n\n` + + (atLimit + ? 'โš ๏ธ This user has reached their ticket limit.' + : 'โœ… This user can still open more tickets.'), + ) + .setColor(atLimit ? getColor('error') : getColor('success')) + .setThumbnail(targetUser.displayAvatarURL({ size: 64 })) + .setTimestamp(), + ], + flags: MessageFlags.Ephemeral, + }); + }); + + userCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No user was selected.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Delete Ticket System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, guildId, client) { + const deleteModal = new ModalBuilder() + .setCustomId('ticket_delete_confirm_modal') + .setTitle('Delete Ticket System') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true) + ) + ); + + await btnInteraction.showModal(deleteModal); + + const submitted = await btnInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'ticket_delete_confirm_modal' && i.user.id === btnInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) { + await refreshDashboard(rootInteraction, guildConfig, guildId); + return; + } + + const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); + + if (confirmation !== 'DELETE') { + await submitted.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], + flags: MessageFlags.Ephemeral, + }); + await refreshDashboard(rootInteraction, guildConfig, guildId); + return; + } + + await submitted.deferUpdate(); + + const keysToDelete = [ + 'ticketPanelChannelId', + 'ticketPanelMessageId', + 'ticketStaffRoleId', + 'ticketCategoryId', + 'ticketClosedCategoryId', + 'ticketPanelMessage', + 'ticketButtonLabel', + 'maxTicketsPerUser', + 'dmOnClose', + ]; + + // Delete the panel embed from Discord + if (guildConfig.ticketPanelChannelId) { + try { + const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); + if (panelChannel) { + if (guildConfig.ticketPanelMessageId) { + const panelMessage = await panelChannel.messages.fetch(guildConfig.ticketPanelMessageId).catch(() => null); + if (panelMessage) await panelMessage.delete().catch(() => {}); + } else { + // Fallback: scan for the panel by button customId + const messages = await panelChannel.messages.fetch({ limit: 50 }).catch(() => null); + if (messages) { + const found = messages.find( + m => m.author.id === client.user.id && + m.components?.[0]?.components?.[0]?.customId === 'create_ticket' + ); + if (found) await found.delete().catch(() => {}); + } + } + } + } catch (panelDeleteError) { + logger.warn('Could not delete ticket panel message:', panelDeleteError.message); + } + } + + // Clear all open ticket records for the guild from the database + try { + const { pgConfig } = await import('../../../config/postgres.js'); + if (client.db?.db?.pool && typeof client.db.db.isAvailable === 'function' && client.db.db.isAvailable()) { + await client.db.db.pool.query( + `DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, + [guildId] + ); + } + } catch (ticketDeleteError) { + logger.warn('Could not clear ticket records from database:', ticketDeleteError.message); + } + + for (const key of keysToDelete) { + delete guildConfig[key]; + } + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await submitted.followUp({ + embeds: [ + successEmbed( + 'โœ… Ticket System Deleted', + 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ—‘๏ธ Ticket System Deleted') + .setDescription('The ticket system configuration has been cleared.') + .setColor(getColor('error')) + .setTimestamp(), + ], + components: [], + }).catch(() => {}); +} \ No newline at end of file diff --git a/src/commands/Ticket/modules/ticket_limits_check.js b/src/commands/Ticket/modules/ticket_limits_check.js deleted file mode 100644 index 29e6bd8448..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_check.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getUserTicketCount } from '../../../services/ticket.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const user = interaction.options.getUser('user'); - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - const maxTickets = guildConfig.maxTicketsPerUser || 3; - - const openTicketCount = await getUserTicketCount(guildId, user.id); - - const embed = infoEmbed( - `๐ŸŽซ Ticket Limit Check: ${user.tag}`, - `**Open Tickets:** ${openTicketCount}/${maxTickets}\n` + - `**Remaining:** ${Math.max(0, maxTickets - openTicketCount)}\n\n` + - (openTicketCount >= maxTickets - ? 'โš ๏ธ This user has reached their ticket limit.' - : 'โœ… This user can create more tickets.') - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket limit check completed', { - userId: interaction.user.id, - targetUserId: user.id, - targetUserTag: user.tag, - guildId: guildId, - openTickets: openTicketCount, - maxTickets: maxTickets, - commandName: 'ticket_limits_check' - }); - } catch (error) { - logger.error('Error checking ticket limits', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_check' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_check', - source: 'ticket_limits_module' - }); - } - } -}; - - - diff --git a/src/commands/Ticket/modules/ticket_limits_set.js b/src/commands/Ticket/modules/ticket_limits_set.js deleted file mode 100644 index 337d31d5e7..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_set.js +++ /dev/null @@ -1,61 +0,0 @@ -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const maxTickets = interaction.options.getInteger('max_tickets'); - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - - guildConfig.maxTicketsPerUser = maxTickets; - - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, guildConfig); - - const embed = successEmbed( - 'โœ… Ticket Limit Updated', - `Maximum tickets per user has been set to **${maxTickets}**.\n\n` + - `Users will now be limited to ${maxTickets} open ticket${maxTickets !== 1 ? 's' : ''} at a time.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket limit updated', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: guildId, - maxTickets: maxTickets, - commandName: 'ticket_limits_set' - }); - } catch (error) { - logger.error('Error setting ticket limits', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_set' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_set', - source: 'ticket_limits_module' - }); - } - } -}; - - - diff --git a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js b/src/commands/Ticket/modules/ticket_limits_toggle_dm.js deleted file mode 100644 index b6d87e11c9..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - - const currentSetting = guildConfig.dmOnClose !== false; - guildConfig.dmOnClose = !currentSetting; - - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, guildConfig); - - const embed = successEmbed( - 'โœ… DM Notification Setting Updated', - `DM notifications when tickets are closed: **${guildConfig.dmOnClose ? 'Enabled' : 'Disabled'}**\n\n` + - (guildConfig.dmOnClose - ? '๐Ÿ“ฌ Users will receive a DM when their ticket is closed.' - : '๐Ÿ“ญ Users will NOT receive a DM when their ticket is closed.') - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket DM notification setting toggled', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: guildId, - dmOnClose: guildConfig.dmOnClose, - commandName: 'ticket_limits_toggle_dm' - }); - } catch (error) { - logger.error('Error toggling DM setting', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_toggle_dm' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_toggle_dm', - source: 'ticket_limits_module' - }); - } - } -}; - - - - diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 750e3b3edb..22ec056e1a 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -7,9 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import ticketLimitsSet from './modules/ticket_limits_set.js'; -import ticketLimitsCheck from './modules/ticket_limits_check.js'; -import ticketLimitsToggleDM from './modules/ticket_limits_toggle_dm.js'; +import ticketConfig from './modules/ticket_dashboard.js'; export default { data: new SlashCommandBuilder() @@ -89,39 +87,10 @@ export default { .setRequired(false), ), ) - .addSubcommandGroup((group) => - group - .setName("limits") - .setDescription("Manage ticket limits and settings") - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Set the maximum number of tickets per user") - .addIntegerOption((option) => - option - .setName("max_tickets") - .setDescription("Maximum number of tickets a user can create (1-10)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("check") - .setDescription("Check a user's current ticket count") - .addUserOption((option) => - option - .setName("user") - .setDescription("The user to check") - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("toggle_dm") - .setDescription("Toggle DM notifications when tickets are closed") - ) + .addSubcommand((subcommand) => + subcommand + .setName("dashboard") + .setDescription("Open the interactive ticket system dashboard"), ), category: "ticket", @@ -154,29 +123,24 @@ export default { } const subcommand = interaction.options.getSubcommand(); - const subcommandGroup = interaction.options.getSubcommandGroup(); - if (subcommandGroup === "limits") { - switch (subcommand) { - case "set": - return ticketLimitsSet.execute(interaction, config, client); - case "check": - return ticketLimitsCheck.execute(interaction, config, client); - case "toggle_dm": - return ticketLimitsToggleDM.execute(interaction, config, client); - default: - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Subcommand", - "Please select a valid limits subcommand." - ), - ], - }); - } + if (subcommand === "dashboard") { + return ticketConfig.execute(interaction, config, client); } if (subcommand === "setup") { + const existingConfig = await getGuildConfig(client, interaction.guildId); + if (existingConfig?.ticketPanelChannelId) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Ticket System Already Active', + `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.`, + ), + ], + }); + } + const panelChannel = interaction.options.getChannel("panel_channel"); const categoryChannel = interaction.options.getChannel("category"); @@ -210,14 +174,13 @@ description: panelMessage, }); if (client.db && interaction.guildId) { - const currentConfig = await getGuildConfig( - client, - interaction.guildId, - ); + const currentConfig = existingConfig; currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; currentConfig.ticketPanelChannelId = panelChannel.id; + currentConfig.ticketPanelMessage = panelMessage; + currentConfig.ticketButtonLabel = buttonLabel; currentConfig.maxTicketsPerUser = maxTicketsPerUser; currentConfig.dmOnClose = dmOnClose; diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.js new file mode 100644 index 0000000000..4c3f08ed77 --- /dev/null +++ b/src/commands/Utility/modules/report.js @@ -0,0 +1,69 @@ +import { getColor } from '../../../config/bot.js'; +import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + if (!deferSuccess) { + logger.warn('Report interaction defer failed', { userId: interaction.user.id, guildId: interaction.guildId }); + return; + } + + const targetUser = interaction.options.getUser('user'); + const reason = interaction.options.getString('reason'); + const guildId = interaction.guildId; + + const guildConfig = await getGuildConfig(client, guildId); + const reportChannelId = guildConfig.reportChannelId; + + if (!reportChannelId) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Setup Required', 'The report channel has not been set up. Please ask a moderator to use `/report setchannel` first.')], + }); + } + + const reportChannel = interaction.guild.channels.cache.get(reportChannelId); + if (!reportChannel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Channel Missing', 'The configured report channel is missing or inaccessible. Please ask a moderator to reset it.')], + }); + } + + try { + const reportEmbed = createEmbed({ + title: `๐Ÿšจ NEW USER REPORT: ${targetUser.tag}`, + description: `**Reported By:** ${interaction.user.tag} (\`${interaction.user.id}\`)\n**Reported User:** ${targetUser.tag} (\`${targetUser.id}\`)`, + }) + .setColor(getColor('error')) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Reason', value: reason }, + { name: 'Reported In Channel', value: interaction.channel.toString(), inline: true }, + { name: 'Time', value: new Date().toUTCString(), inline: true }, + ); + + await reportChannel.send({ + content: `<@&${interaction.guild.ownerId}> New Report!`, + embeds: [reportEmbed], + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ title: 'โœ… Report Submitted', description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!` })], + }); + + logger.info('Report submitted', { + userId: interaction.user.id, + reportedUserId: targetUser.id, + guildId, + reasonLength: reason.length, + }); + } catch (error) { + logger.error('report error:', error); + await handleInteractionError(interaction, error, { commandName: 'report', source: 'report' }); + } + }, +}; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.js new file mode 100644 index 0000000000..10c1e803e5 --- /dev/null +++ b/src/commands/Utility/modules/report_setchannel.js @@ -0,0 +1,36 @@ +import { PermissionsBitField, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the report channel.')], + ephemeral: true, + }); + } + + const channel = interaction.options.getChannel('channel'); + const guildId = interaction.guildId; + + try { + const guildConfig = await getGuildConfig(client, guildId); + guildConfig.reportChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('โœ… Report Channel Set', `All new reports will now be sent to ${channel}.`)], + ephemeral: true, + }); + } catch (error) { + logger.error('report_setchannel error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Database Error', 'Could not save the channel configuration.')], + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js index 4f062fe279..57ef35b97e 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.js @@ -1,142 +1,68 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { SlashCommandBuilder, ChannelType } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import report from './modules/report.js'; +import reportSetchannel from './modules/report_setchannel.js'; + export default { data: new SlashCommandBuilder() - .setName("report") - .setDescription("Report a user or an issue to the server staff.") - .addUserOption((option) => - option - .setName("user") - .setDescription("The user you want to report.") - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("reason") - .setDescription("The reason for the report (be detailed).") - .setRequired(true) - .setMaxLength(500), + .setName('report') + .setDescription('Report a user to server staff, or configure where reports are sent.') + .setDMPermission(false) + .addSubcommand(subcommand => + subcommand + .setName('file') + .setDescription('Report a user to the server moderation team.') + .addUserOption(option => + option + .setName('user') + .setDescription('The user you want to report.') + .setRequired(true), + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the report (be detailed).') + .setRequired(true) + .setMaxLength(500), + ), ) - .setDMPermission(false), - category: "Utility", - - - - - + .addSubcommand(subcommand => + subcommand + .setName('setchannel') + .setDescription('Set the channel where user reports are sent. (Manage Server required)') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The text channel to receive reports.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), + ), + category: 'Utility', async execute(interaction, config, client) { try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`Report interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'report' - }); - return; - } - - const targetUser = interaction.options.getUser("user"); - const reason = interaction.options.getString("reason"); - const guildId = interaction.guildId; + const subcommand = interaction.options.getSubcommand(); - const guildConfig = await getGuildConfig(client, guildId); - const reportChannelId = guildConfig.reportChannelId; - - if (!reportChannelId) { - logger.warn(`Report command - report channel not configured`, { - userId: interaction.user.id, - guildId: guildId, - commandName: 'report' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Setup Required", - "The report channel has not been set up. Please ask a moderator to use `/setreportchannel` first.", - ), - ], - }); + if (subcommand === 'file') { + return await report.execute(interaction, config, client); } - const reportChannel = interaction.guild.channels.cache.get(reportChannelId); - - if (!reportChannel) { - logger.warn(`Report command - report channel missing`, { - userId: interaction.user.id, - guildId: guildId, - reportChannelId: reportChannelId, - commandName: 'report' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Missing", - "The configured report channel is missing or inaccessible. Please ask a moderator to reset it.", - ), - ], - }); + if (subcommand === 'setchannel') { + return await reportSetchannel.execute(interaction, config, client); } - const reportEmbed = createEmbed({ - title: `๐Ÿšจ NEW USER REPORT: ${targetUser.tag}`, - description: `**Reported By:** ${interaction.user.tag} (\`${interaction.user.id}\`)\n**Reported User:** ${targetUser.tag} (\`${targetUser.id}\`)` - }) - .setColor(getColor('error')) - .setThumbnail(targetUser.displayAvatarURL()) - .addFields( - { name: "Reason", value: reason }, - { - name: "Reported In Channel", - value: interaction.channel.toString(), - inline: true, - }, - { - name: "Time", - value: new Date().toUTCString(), - inline: true, - }, - ); - - await reportChannel.send({ - content: `<@&${interaction.guild.ownerId}> New Report!`, - embeds: [reportEmbed], - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ title: "โœ… Report Submitted", description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!`, }), - ], - }); - - logger.info(`Report command executed - user report submitted`, { - userId: interaction.user.id, - reportedUserId: targetUser.id, - guildId: guildId, - reasonLength: reason.length + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'Unknown subcommand.')], + ephemeral: true, }); } catch (error) { - logger.error(`Report command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'report' - }); - await handleInteractionError(interaction, error, { - commandName: 'report', - source: 'report_command' - }); + logger.error('report command error:', error); + await handleInteractionError(interaction, error, { commandName: 'report', source: 'report_command' }); } }, -}; - - - - +}; \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index b944131c1e..3b0fd28dcc 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -7,12 +7,12 @@ import { validateAutoVerifyCriteria } from '../../../services/verificationServic import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../../utils/database.js'; +import autoVerifyDashboard from './autoVerifyDashboard.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; -const serverSizeThreshold = autoVerifyDefaults.serverSizeThreshold ?? 1000; export default { data: new SlashCommandBuilder() @@ -34,16 +34,15 @@ export default { .setName("criteria") .setDescription("Criteria for automatic verification") .addChoices( - { name: `Account Age (older than ${defaultAccountAgeDays} days)`, value: "account_age" }, - { name: `Server Members (less than ${serverSizeThreshold} members)`, value: "server_size" }, - { name: "No Criteria (verify everyone)", value: "none" } + { name: "Account Age", value: "account_age" }, + { name: "No Criteria", value: "none" } ) .setRequired(true) ) .addIntegerOption(option => option .setName("account_age_days") - .setDescription("Minimum account age in days (for account age criteria)") + .setDescription("Minimum account age in days (required for account age criteria)") .setMinValue(minAccountAgeDays) .setMaxValue(maxAccountAgeDays) .setRequired(false) @@ -51,13 +50,8 @@ export default { ) .addSubcommand(subcommand => subcommand - .setName("disable") - .setDescription("Disable automatic verification") - ) - .addSubcommand(subcommand => - subcommand - .setName("status") - .setDescription("Check automatic verification status") + .setName("dashboard") + .setDescription("Open the auto-verification dashboard for customization") ), async execute(interaction, config, client) { @@ -68,10 +62,8 @@ export default { switch (subcommand) { case "setup": return await handleSetup(interaction, guild, client); - case "disable": - return await handleDisable(interaction, guild, client); - case "status": - return await handleStatus(interaction, guild, client); + case "dashboard": + return await autoVerifyDashboard.execute(interaction, config, client); default: throw createError( `Unknown subcommand: ${subcommand}`, @@ -171,10 +163,7 @@ async function handleSetup(interaction, guild, client) { let criteriaDescription = ""; switch (criteria) { case "account_age": - criteriaDescription = `Accounts older than ${accountAgeDays} days`; - break; - case "server_size": - criteriaDescription = `All users (server has less than ${serverSizeThreshold} members)`; + criteriaDescription = `\`${accountAgeDays} days\` old`; break; case "none": criteriaDescription = "All users immediately"; @@ -201,92 +190,3 @@ async function handleSetup(interaction, guild, client) { } } -async function handleDisable(interaction, guild, client) { - await InteractionHelper.safeDefer(interaction); - - const guildConfig = await getGuildConfig(client, guild.id); - - if (!guildConfig.verification?.autoVerify?.enabled) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed("Already Disabled", "Auto-verification is already disabled.")], - }); - } - - guildConfig.verification.autoVerify.enabled = false; - await setGuildConfig(client, guild.id, guildConfig); - - logger.info('Auto-verify disabled', { guildId: guild.id }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Auto-Verification Disabled", - "Automatic verification has been disabled. Users will now need to verify manually." - )] - }); -} - -async function handleStatus(interaction, guild, client) { - const guildConfig = await getGuildConfig(client, guild.id); - const welcomeConfig = await getWelcomeConfig(client, guild.id); - const verificationEnabled = Boolean(guildConfig.verification?.enabled); - const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - const conflictSummary = [ - verificationEnabled ? 'Verification system is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null - ].filter(Boolean).join('\n'); - - if (!guildConfig.verification?.autoVerify?.enabled) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed( - "Auto-Verification Status", - `๐Ÿ”ด **Status:** Disabled\n\nAuto-verification is currently disabled.\n\nUse \`/autoverify setup\` to configure it.${conflictSummary ? `\n\nโš ๏ธ **Setup Blockers:**\n${conflictSummary}` : ''}` - )], - flags: MessageFlags.Ephemeral - }); - } - - const autoVerify = guildConfig.verification.autoVerify; - const autoVerifyRole = autoVerify.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; - let criteriaDescription = ""; - - switch (autoVerify.criteria) { - case "account_age": - criteriaDescription = `Accounts older than ${autoVerify.accountAgeDays} days`; - break; - case "server_size": - criteriaDescription = `All users (server has less than ${serverSizeThreshold} members)`; - break; - case "none": - criteriaDescription = "All users immediately"; - break; - } - - const statusEmbed = createEmbed({ - title: "๐Ÿค– Auto-Verification Status", - description: "Current auto-verification configuration:", - color: getColor('success') - }) - .addFields( - { name: "๐Ÿ“Š Status", value: "โœ… Enabled", inline: true }, - { name: "๐Ÿท๏ธ Target Role", value: autoVerifyRole ? autoVerifyRole.toString() : "Not found", inline: true }, - { name: "๐ŸŽฏ Criteria", value: criteriaDescription, inline: true }, - { - name: "๐Ÿ“… Account Age Requirement", - value: autoVerify.accountAgeDays ? `${autoVerify.accountAgeDays} days` : "N/A", - inline: true - }, - { - name: "โš ๏ธ Setup Conflicts", - value: conflictSummary || "None", - inline: false - } - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [statusEmbed], - flags: MessageFlags.Ephemeral - }); -} - - - diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js new file mode 100644 index 0000000000..1b3834e192 --- /dev/null +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -0,0 +1,544 @@ +import { botConfig, getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getWelcomeConfig } from '../../../utils/database.js'; +import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; +const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; +const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; +const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild, conflictSummary = '') { + const autoVerify = cfg.verification?.autoVerify; + const autoVerifyRole = autoVerify?.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; + + let criteriaDescription = "`Not configured`"; + if (autoVerify?.criteria) { + switch (autoVerify.criteria) { + case "account_age": + criteriaDescription = `\`Account Age\` - \`${autoVerify.accountAgeDays} days\``; + break; + case "none": + criteriaDescription = `\`No Criteria\``; + break; + } + } + + const embed = new EmbedBuilder() + .setTitle('๐Ÿค– Auto-Verification Dashboard') + .setDescription(`Manage auto-verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: 'โš™๏ธ System Status', value: autoVerify?.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿท๏ธ Target Role', value: autoVerifyRole ? autoVerifyRole.toString() : '`Not set`', inline: true }, + { name: '๐ŸŽฏ Criteria', value: criteriaDescription, inline: true }, + { name: '๐Ÿ“… Account Age', value: autoVerify?.accountAgeDays ? `\`${autoVerify.accountAgeDays}\` days` : '`N/A`', inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + ); + + if (conflictSummary) { + embed.addFields({ name: 'โš ๏ธ Setup Conflicts', value: conflictSummary, inline: false }); + } + + return embed + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`autoverify_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Role') + .setDescription('Select the role to assign automatically') + .setValue('role') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Account Age Days') + .setDescription('Set minimum account age in days') + .setValue('account_age') + .setEmoji('๐Ÿ“…'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const autoVerifyOn = cfg.verification?.autoVerify?.enabled === true; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`autoverify_cfg_criteria_${guildId}`) + .setLabel('Change Criteria') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽฏ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`autoverify_cfg_toggle_${guildId}`) + .setLabel('Auto-Verification') + .setStyle(autoVerifyOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿค–') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId, client) { + try { + const selectMenu = buildSelectMenu(guildId); + + // Get conflict summary + let conflictSummary = ''; + try { + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(cfg.verification?.enabled); + const autoRoleConfigured = Boolean(cfg.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + } + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh autoverify dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + + // Check if auto-verification is configured + if (!guildConfig.verification?.autoVerify?.enabled) { + // Check for blocking systems + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const blockingMessage = []; + if (verificationEnabled) blockingMessage.push('Verification system is enabled'); + if (autoRoleConfigured) blockingMessage.push('AutoRole is configured'); + + const blockingText = blockingMessage.length > 0 + ? `\n\nโš ๏ธ **To enable AutoVerify, you must first disable:**\n${blockingMessage.map(msg => `โ€ข ${msg}`).join('\n')}` + : ''; + + return await InteractionHelper.safeReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿค– Auto-Verification Dashboard') + .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/autoverify setup\` to configure it.`) + .setColor(getColor('warning')) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp() + ], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + + const selectMenu = buildSelectMenu(guildId); + + // Get conflict summary + let conflictSummary = ''; + try { + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(guildConfig, interaction.guild, conflictSummary)], + components: [ + buildButtonRow(guildConfig, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + // โ”€โ”€ Select collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `autoverify_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'role': + await handleRole(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'account_age': + await handleAccountAge(selectInteraction, interaction, guildConfig, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Autoverify config validation error: ${error.message}`); + } else { + logger.error('Unexpected autoverify dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `autoverify_cfg_toggle_${guildId}` || i.customId === `autoverify_cfg_criteria_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + if (btnInteraction.customId === `autoverify_cfg_criteria_${guildId}`) { + await handleCriteria(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `autoverify_cfg_toggle_${guildId}`) { + await btnInteraction.deferUpdate().catch(() => null); + guildConfig.verification.autoVerify.enabled = !guildConfig.verification.autoVerify.enabled; + await setGuildConfig(client, guildId, guildConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Status Updated', + `Auto-verification is now **${guildConfig.verification.autoVerify.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(interaction, guildConfig, guildId, client); + } + } catch (err) { + logger.debug('Button interaction error:', err.message); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in autoverify_dashboard:', error); + throw new TitanBotError( + `Auto-verification dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the auto-verification dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Handle Criteria โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleCriteria(selectInteraction, rootInteraction, guildConfig, guildId, client) { + // Defer the interaction if it's a button, otherwise it was already deferred by select menu + if (!selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => null); + } + + const criteriaEmbed = new EmbedBuilder() + .setTitle('๐ŸŽฏ Select Verification Criteria') + .setDescription('Choose the criteria for automatic verification') + .setColor(getColor('info')); + + const criteriaMenu = new StringSelectMenuBuilder() + .setCustomId('autoverify_criteria_select') + .setPlaceholder('Select criteria...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(`Account Age (older than ${defaultAccountAgeDays} days)`) + .setDescription('Users with older accounts will be auto-verified') + .setValue('account_age'), + new StringSelectMenuOptionBuilder() + .setLabel('No Criteria (verify everyone)') + .setDescription('All users gain the role immediately') + .setValue('none'), + ); + + await selectInteraction.followUp({ + embeds: [criteriaEmbed], + components: [new ActionRowBuilder().addComponents(criteriaMenu)], + flags: MessageFlags.Ephemeral, + }); + + const criteriaCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'autoverify_criteria_select', + time: 60_000, + max: 1, + }); + + criteriaCollector.on('collect', async criteriaInteraction => { + await criteriaInteraction.deferUpdate(); + const newCriteria = criteriaInteraction.values[0]; + + guildConfig.verification.autoVerify.criteria = newCriteria; + + // Reset age-related fields if not using them + if (newCriteria !== 'account_age') { + guildConfig.verification.autoVerify.accountAgeDays = null; + } else if (!guildConfig.verification.autoVerify.accountAgeDays) { + guildConfig.verification.autoVerify.accountAgeDays = defaultAccountAgeDays; + } + + await setGuildConfig(client, guildId, guildConfig); + + let criteriaDisplay = ''; + switch (newCriteria) { + case 'account_age': + criteriaDisplay = `Account Age (${guildConfig.verification.autoVerify.accountAgeDays} days)`; + break; + case 'none': + criteriaDisplay = 'No Criteria'; + break; + } + + await criteriaInteraction.followUp({ + embeds: [successEmbed('โœ… Criteria Updated', `Auto-verification criteria changed to **${criteriaDisplay}**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); + }); + + criteriaCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No criteria selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Handle Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('autoverify_role_select') + .setPlaceholder('Select a role...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿท๏ธ Auto-Verification Role') + .setDescription('Select the role to assign to auto-verified users.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'autoverify_role_select', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + if (role.id === rootInteraction.guild.id || role.managed) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Invalid Role', + 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const botMember = rootInteraction.guild.members.me; + if (role.position >= botMember.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + 'The selected role must be below my highest role in the server role hierarchy.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.verification.autoVerify.roleId = role.id; + await setGuildConfig(client, guildId, guildConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Updated', `Auto-verification role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Handle Account Age โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('autoverify_account_age_modal') + .setTitle('Set Account Age Requirement') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('age_input') + .setLabel('Minimum Account Age (days)') + .setStyle(TextInputStyle.Short) + .setPlaceholder(`Between ${minAccountAgeDays} and ${maxAccountAgeDays}`) + .setValue((guildConfig.verification.autoVerify.accountAgeDays || defaultAccountAgeDays).toString()) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'autoverify_account_age_modal' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const inputValue = submitted.fields.getTextInputValue('age_input').trim(); + const days = parseInt(inputValue, 10); + + if (isNaN(days) || days < minAccountAgeDays || days > maxAccountAgeDays) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Input', `Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.verification.autoVerify.accountAgeDays = days; + await setGuildConfig(client, guildId, guildConfig); + + await submitted.reply({ + embeds: [successEmbed('โœ… Account Age Updated', `Minimum account age requirement set to **${days} days**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); +} + +// โ”€โ”€โ”€ Handle Member Duration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js new file mode 100644 index 0000000000..371701581b --- /dev/null +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -0,0 +1,699 @@ +import { botConfig, getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getWelcomeConfig } from '../../../utils/database.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Live Panel Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function updateLivePanel(guild, cfg) { + if (!cfg.channelId || !cfg.messageId) return; + try { + const channel = guild.channels.cache.get(cfg.channelId); + if (!channel) return; + const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); + if (!msg) return; + + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + await msg.edit({ embeds: [verifyEmbed], components: [verifyButton] }); + } catch (error) { + logger.warn('Could not update live verification panel:', error.message); + } +} + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary = '') { + const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; + const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'; + const rawMsg = cfg.message || botConfig.verification.defaultMessage; + const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + const buttonText = cfg.buttonText || botConfig.verification.defaultButtonText; + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ”’ Verification System Dashboard') + .setDescription(`Manage verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Verification Channel', value: channel, inline: true }, + { name: '๐Ÿท๏ธ Verified Role', value: role, inline: true }, + { name: 'โš™๏ธ System Status', value: cfg.enabled !== false ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”˜ Button Text', value: `\`${buttonText}\``, inline: true }, + { name: '๐Ÿ‘ฅ Verified Users', value: `${verifiedUserCount} users`, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ’ฌ Verification Message', value: msgPreview, inline: false }, + ); + + if (conflictSummary) { + embed.addFields({ name: 'โš ๏ธ Setup Conflicts', value: conflictSummary, inline: false }); + } + + return embed + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`verif_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Verification Channel') + .setDescription('Set the channel where the verification panel is posted') + .setValue('channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Verified Role') + .setDescription('Set the role assigned when a user verifies') + .setValue('role') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Verification Message') + .setDescription('Customise the message shown on the verification panel embed') + .setValue('message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Button Text') + .setDescription('Change the label on the verify button') + .setValue('button_text') + .setEmoji('๐Ÿ”˜'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const systemOn = cfg.enabled !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`verif_cfg_toggle_${guildId}`) + .setLabel('Verification') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ”’') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId, client) { + try { + const selectMenu = buildSelectMenu(guildId); + + // Get verified user count and conflict summary + let verifiedUserCount = 0; + let conflictSummary = ''; + + try { + const verifiedRole = rootInteraction.guild.roles.cache.get(cfg.roleId); + if (verifiedRole) { + verifiedUserCount = verifiedRole.members.size; + } + + const guildConfig = await getGuildConfig(client, guildId); + const welcomeConfig = await getWelcomeConfig(client, guildId); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + autoVerifyEnabled ? 'AutoVerify is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch verification dashboard details:', error.message); + } + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, verifiedUserCount, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh verification dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + const cfg = guildConfig.verification; + + if (!cfg?.channelId) { + throw new TitanBotError( + 'Verification not configured', + ErrorTypes.CONFIGURATION, + 'The verification system has not been set up yet. Run `/verification setup` first.', + ); + } + + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + const selectMenu = buildSelectMenu(guildId); + + // Get verified user count and conflict summary + let verifiedUserCount = 0; + let conflictSummary = ''; + + try { + const verifiedRole = interaction.guild.roles.cache.get(cfg.roleId); + if (verifiedRole) { + verifiedUserCount = verifiedRole.members.size; + } + + const welcomeConfig = await getWelcomeConfig(client, guildId); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + autoVerifyEnabled ? 'AutoVerify is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch verification dashboard details:', error.message); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild, verifiedUserCount, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `verif_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'channel': + await handleChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'role': + await handleRole(selectInteraction, interaction, cfg, guildId, client); + break; + case 'message': + await handleMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'button_text': + await handleButtonText(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Verification config validation error: ${error.message}`); + } else { + logger.error('Unexpected verification dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `verif_cfg_toggle_${guildId}`, + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + + const wasEnabled = cfg.enabled !== false; + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + + // Prevent enabling Verification if AutoVerify is enabled + if (!wasEnabled && autoVerifyEnabled) { + await btnInteraction.followUp({ + embeds: [errorEmbed( + 'โŒ Cannot Enable Verification', + 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.' + )], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.enabled = !wasEnabled; + + // Disabling โ€” remove the live panel message from the channel + if (!cfg.enabled && cfg.channelId && cfg.messageId) { + const channel = interaction.guild.channels.cache.get(cfg.channelId); + if (channel) { + try { + const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); + if (msg) await msg.delete(); + } catch { + // already gone + } + } + } + + // Re-enabling โ€” re-post the verification panel in the configured channel + if (cfg.enabled && cfg.channelId) { + const channel = interaction.guild.channels.cache.get(cfg.channelId); + if (channel) { + try { + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + const newMsg = await channel.send({ embeds: [verifyEmbed], components: [verifyButton] }); + cfg.messageId = newMsg.id; + } catch (error) { + logger.warn('Could not re-post verification panel on re-enable:', error.message); + } + } + } + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… System Updated', + `The verification system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(interaction, cfg, guildId, client); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')) + ], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in verification_dashboard:', error); + throw new TitanBotError( + `Verification dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the verification dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Change Verification Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('verif_cfg_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Change Verification Channel') + .setDescription( + `**Current:** ${cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'}\n\nSelect the channel where the verification panel will be posted.\n\n> โš ๏ธ The existing panel will be deleted and re-posted in the new channel.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const newChannel = chanInteraction.channels.first(); + + if (!botHasPermission(newChannel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** permissions in ${newChannel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Delete old panel if it exists + if (cfg.channelId && cfg.messageId) { + const oldChannel = rootInteraction.guild.channels.cache.get(cfg.channelId); + if (oldChannel) { + try { + const oldMsg = await oldChannel.messages.fetch(cfg.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete(); + } catch { + // already gone + } + } + } + + // Post new panel in the new channel (only if system is enabled) + if (cfg.enabled !== false) { + try { + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + const newMsg = await newChannel.send({ embeds: [verifyEmbed], components: [verifyButton] }); + cfg.messageId = newMsg.id; + } catch (error) { + logger.warn('Could not post verification panel in new channel:', error.message); + } + } + + cfg.channelId = newChannel.id; + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Verification panel moved to ${newChannel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Change Verified Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRole(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('verif_cfg_role') + .setPlaceholder('Select a role...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿท๏ธ Change Verified Role') + .setDescription( + `**Current:** ${cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'}\n\nSelect the role to assign when a user verifies.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + const guild = rootInteraction.guild; + const botMember = guild.members.me; + + if (role.id === guild.id || role.managed) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Invalid Role', + 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (role.position >= botMember.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + 'The verified role must be below my highest role in the server role hierarchy.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.roleId = role.id; + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Updated', `Verified role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Verification Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + try { + const modal = new ModalBuilder() + .setCustomId('verif_cfg_message') + .setTitle('Edit Verification Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message shown on the verification panel embed') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.message || botConfig.verification.defaultMessage) + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'verif_cfg_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.message = submitted.fields.getTextInputValue('message_input').trim(); + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await updateLivePanel(rootInteraction.guild, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Message Updated', 'The verification panel has been updated with the new message.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + } catch (error) { + logger.error('Error in handleMessage:', error); + // Silently fail - modal display failed, user can try again + } +} + +// โ”€โ”€โ”€ Edit Button Text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleButtonText(selectInteraction, rootInteraction, cfg, guildId, client) { + try { + const modal = new ModalBuilder() + .setCustomId('verif_cfg_button_text') + .setTitle('Edit Button Text') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('button_text_input') + .setLabel('Button label (max 80 characters)') + .setStyle(TextInputStyle.Short) + .setValue(cfg.buttonText || botConfig.verification.defaultButtonText) + .setMaxLength(80) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'verif_cfg_button_text' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.buttonText = submitted.fields.getTextInputValue('button_text_input').trim(); + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await updateLivePanel(rootInteraction.guild, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Button Text Updated', `The verify button now reads **${cfg.buttonText}**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + } catch (error) { + logger.error('Error in handleButtonText:', error); + // Silently fail - modal display failed, user can try again + } +} diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index 27e77604a5..5852451833 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -8,6 +8,7 @@ import { ContextualMessages } from '../../utils/messageTemplates.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../utils/database.js'; +import verificationDashboard from './modules/verification_dashboard.js'; export default { data: new SlashCommandBuilder() @@ -45,11 +46,6 @@ export default { .setRequired(false) ) ) - .addSubcommand(subcommand => - subcommand - .setName("verify") - .setDescription("Verify yourself (for users to use)") - ) .addSubcommand(subcommand => subcommand .setName("remove") @@ -63,13 +59,8 @@ export default { ) .addSubcommand(subcommand => subcommand - .setName("disable") - .setDescription("Disable the verification system") - ) - .addSubcommand(subcommand => - subcommand - .setName("status") - .setDescription("Check verification system status") + .setName("dashboard") + .setDescription("Open the verification system configuration dashboard") ), async execute(interaction, config, client) { @@ -77,7 +68,7 @@ export default { const subcommand = interaction.options.getSubcommand(); const guild = interaction.guild; - if (subcommand !== 'verify' && !interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { throw createError( 'Missing ManageGuild permission for verification admin subcommand', ErrorTypes.PERMISSION, @@ -89,14 +80,10 @@ export default { switch (subcommand) { case "setup": return await handleSetup(interaction, guild, client); - case "verify": - return await handleVerify(interaction, guild, client); case "remove": return await handleRemove(interaction, guild, client); - case "disable": - return await handleDisable(interaction, guild, client); - case "status": - return await handleStatus(interaction, guild, client); + case "dashboard": + return await verificationDashboard.execute(interaction, config, client); default: throw createError( `Unknown subcommand: ${subcommand}`, @@ -237,38 +224,6 @@ async function handleSetup(interaction, guild, client) { }); } -async function handleVerify(interaction, guild, client) { - const result = await verifyUser(client, guild.id, interaction.user.id, { - source: 'command_self', - moderatorId: null - }); - - if (!result.success) { - if (result.alreadyVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Already Verified", "You are already verified.")], - flags: MessageFlags.Ephemeral - }); - } - - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed( - "Verification Failed", - "An error occurred during verification. Please try again or contact an administrator." - )], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed( - "Verification Complete", - `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` - )], - flags: MessageFlags.Ephemeral - }); -} - async function handleRemove(interaction, guild, client) { const targetUser = interaction.options.getUser("user"); @@ -306,107 +261,6 @@ async function handleRemove(interaction, guild, client) { } } -async function handleDisable(interaction, guild, client) { - const guildConfig = await getGuildConfig(client, guild.id); - - if (!guildConfig.verification?.enabled) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Already Disabled", "The verification system is already disabled.")], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeDefer(interaction); - - if (guildConfig.verification.channelId && guildConfig.verification.messageId) { - const channel = guild.channels.cache.get(guildConfig.verification.channelId); - if (channel) { - try { - const message = await channel.messages.fetch(guildConfig.verification.messageId); - if (message) { - await message.delete(); - } - } catch (error) { - logger.info("Could not delete verification message (may have been deleted already):", error.message); - } - } - } - - guildConfig.verification.enabled = false; - await setGuildConfig(client, guild.id, guildConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("Verification Disabled", "The verification system has been disabled and the verification message has been removed.")] - }); -} - -async function handleStatus(interaction, guild, client) { - const guildConfig = await getGuildConfig(client, guild.id); - const welcomeConfig = await getWelcomeConfig(client, guild.id); - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); - const conflictSummary = [ - autoVerifyEnabled ? 'AutoVerify is enabled' : null, - autoRoleConfigured ? 'AutoRole is configured' : null - ].filter(Boolean).join('\n'); - - if (!guildConfig.verification?.enabled) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed( - "Verification Status", - `๐Ÿ”ด **Status:** Disabled\n\nThe verification system is not currently enabled on this server.\n\nUse \`/verification setup\` to enable it.${conflictSummary ? `\n\nโš ๏ธ **Setup Blockers:**\n${conflictSummary}` : ''}` - )], - flags: MessageFlags.Ephemeral - }); - } - - const verificationChannel = guild.channels.cache.get(guildConfig.verification.channelId); - const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId); - - const statusEmbed = createEmbed({ - title: "โœ… Verification System Status", - description: "Current verification system configuration:", - color: getColor('success') - }) - .addFields( - { - name: "๐Ÿ“ข Verification Channel", - value: verificationChannel ? verificationChannel.toString() : "Not found", - inline: true - }, - { - name: "๐Ÿท๏ธ Verified Role", - value: verifiedRole ? verifiedRole.toString() : "Not found", - inline: true - }, - { - name: "๐Ÿ”˜ Button Text", - value: guildConfig.verification.buttonText || "Verify", - inline: true - }, - { - name: "๐Ÿ“ Custom Message", - value: guildConfig.verification.message ? "โœ… Configured" : "โŒ Not set", - inline: true - }, - { - name: "๐Ÿ‘ฅ Verified Users", - value: verifiedRole ? `${verifiedRole.members.size} users` : "Unknown", - inline: true - }, - { - name: "โš ๏ธ Setup Conflicts", - value: conflictSummary || "None", - inline: false - } - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [statusEmbed], - flags: MessageFlags.Ephemeral - }); -} - diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.js new file mode 100644 index 0000000000..a49ff844a7 --- /dev/null +++ b/src/commands/Verification/verify.js @@ -0,0 +1,50 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { errorEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; +import { verifyUser } from '../../services/verificationService.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('verify') + .setDescription('Verify yourself and gain access to the server'), + + async execute(interaction, config, client) { + const wrappedExecute = withErrorHandling(async () => { + const guild = interaction.guild; + + const result = await verifyUser(client, guild.id, interaction.user.id, { + source: 'command_self', + moderatorId: null + }); + + if (!result.success) { + if (result.alreadyVerified) { + return await InteractionHelper.safeReply(interaction, { + embeds: [infoEmbed("Already Verified", "You are already verified.")], + flags: MessageFlags.Ephemeral + }); + } + + return await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed( + "Verification Failed", + "An error occurred during verification. Please try again or contact an administrator." + )], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed( + "Verification Complete", + `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` + )], + flags: MessageFlags.Ephemeral + }); + }, { command: 'verify' }); + + return await wrappedExecute(interaction, config, client); + } +}; diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 579d1762f9..dcfd25e318 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -5,7 +5,6 @@ import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildGoodbyeConfigPayload, hasGoodbyeSetup } from './modules/goodbyeConfig.js'; export default { data: new SlashCommandBuilder() @@ -32,15 +31,7 @@ export default { .addBooleanOption(option => option.setName('ping') .setDescription('Whether to ping the user in the goodbye message') - .setRequired(false))) - .addSubcommand(subcommand => - subcommand - .setName('toggle') - .setDescription('Enable or disable goodbye messages')) - .addSubcommand(subcommand => - subcommand - .setName('config') - .setDescription('Customize your existing goodbye setup with buttons')), + .setRequired(false))), async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -135,7 +126,7 @@ export default { { name: 'Ping User', value: ping ? 'โœ… Yes' : 'โŒ No' }, { name: 'Status', value: 'โœ… Enabled' } ) - .setFooter({ text: 'Tip: Use /goodbye toggle to enable/disable goodbye messages' }); + .setFooter({ text: 'Tip: Use /goodbye config to customize goodbye settings' }); if (image) { embed.setImage(image); @@ -154,63 +145,6 @@ export default { }); } } - else if (subcommand === 'config') { - try { - const currentConfig = await getWelcomeConfig(client, guild.id); - - if (!hasGoodbyeSetup(currentConfig)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'No Goodbye Setup Found', - 'Set up goodbye first using **/goodbye setup**.' - )], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeEditReply( - interaction, - buildGoodbyeConfigPayload(guild, currentConfig, 'Use the buttons below to customize your goodbye setup.') - ); - } catch (error) { - logger.error(`[Goodbye] Failed to load goodbye config panel for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Config Panel Failed', - 'An error occurred while loading the goodbye config panel. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - - else if (subcommand === 'toggle') { - try { - const currentConfig = await getWelcomeConfig(client, guild.id); - const newStatus = !currentConfig.goodbyeEnabled; - - await updateWelcomeConfig(client, guild.id, { - goodbyeEnabled: newStatus - }); - - logger.info(`[Goodbye] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - await InteractionHelper.safeEditReply(interaction, { - content: `โœ… Goodbye messages have been ${newStatus ? 'enabled' : 'disabled'}.`, - flags: MessageFlags.Ephemeral - }); - } catch (error) { - logger.error(`[Goodbye] Failed to toggle goodbye system for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Toggle Failed', - 'An error occurred while toggling goodbye messages. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } }, }; diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.js new file mode 100644 index 0000000000..ce2b93e17e --- /dev/null +++ b/src/commands/Welcome/greet.js @@ -0,0 +1,51 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError, TitanBotError } from '../../utils/errorHandler.js'; +import greetDashboard from './modules/greet_dashboard.js'; + +export default { + data: new SlashCommandBuilder() + .setName('greet') + .setDescription('Manage welcome & goodbye settings') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('dashboard') + .setDescription('Open the welcome & goodbye configuration dashboard'), + ), + + async execute(interaction, config, client) { + try { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeReply(interaction, { + embeds: [ + errorEmbed( + 'Missing Permissions', + 'You need the **Manage Server** permission to use `/greet`.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'dashboard': + return await greetDashboard.execute(interaction, config, client); + default: + logger.warn(`Unknown /greet subcommand: ${subcommand}`); + } + } catch (error) { + if (error instanceof TitanBotError) { + return await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Configuration Error', error.userMessage || 'Something went wrong.')], + flags: MessageFlags.Ephemeral, + }); + } + await handleInteractionError(interaction, error, { command: 'greet' }); + } + }, +}; diff --git a/src/commands/Welcome/modules/goodbyeConfig.js b/src/commands/Welcome/modules/goodbyeConfig.js deleted file mode 100644 index 79875b29e2..0000000000 --- a/src/commands/Welcome/modules/goodbyeConfig.js +++ /dev/null @@ -1,182 +0,0 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle -} from 'discord.js'; -import { getColor } from '../../../config/bot.js'; -import { formatWelcomeMessage } from '../../../utils/welcome.js'; - -export const GOODBYE_CONFIG_BUTTON_ID = 'goodbye_config'; -export const GOODBYE_CONFIG_MODAL_ID = 'goodbye_config_modal'; -export const GOODBYE_CONFIG_ACTIONS = ['channel', 'message', 'ping', 'image']; - -export function hasGoodbyeSetup(config) { - return Boolean(config?.goodbyeChannelId); -} - -export function parseChannelInput(rawInput) { - const value = String(rawInput || '').trim(); - if (!value) return null; - - const mentionMatch = value.match(/^<#(\d+)>$/); - if (mentionMatch) return mentionMatch[1]; - - if (/^\d{17,20}$/.test(value)) { - return value; - } - - return null; -} - -export function isValidImageUrl(rawInput) { - const value = String(rawInput || '').trim(); - if (!value) return true; - - try { - const url = new URL(value); - return ['http:', 'https:'].includes(url.protocol); - } catch { - return false; - } -} - -function truncatePreview(value, maxLength = 512) { - if (!value) return 'Not set'; - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3)}...`; -} - -export function buildGoodbyeConfigPayload(guild, config, notice = null) { - const previewMessage = formatWelcomeMessage(config.leaveMessage || '{user.tag} has left the server.', { - user: guild?.members?.me?.user || guild?.client?.user, - guild - }); - - const imageValue = - typeof config?.leaveEmbed?.image === 'string' - ? config.leaveEmbed.image - : config?.leaveEmbed?.image?.url || null; - - const embed = new EmbedBuilder() - .setColor(getColor('primary')) - .setTitle('โš™๏ธ Goodbye Configuration') - .setDescription(notice || 'Customize your goodbye setup using the buttons below.') - .addFields( - { - name: 'Channel', - value: config.goodbyeChannelId ? `<#${config.goodbyeChannelId}>` : 'Not set', - inline: true - }, - { - name: 'Ping User', - value: config.goodbyePing ? 'โœ… Yes' : 'โŒ No', - inline: true - }, - { - name: 'Status', - value: config.goodbyeEnabled ? 'โœ… Enabled' : 'โŒ Disabled', - inline: true - }, - { - name: 'Message Preview', - value: truncatePreview(previewMessage, 1024) - }, - { - name: 'Image URL', - value: imageValue ? truncatePreview(imageValue, 1024) : 'Not set' - } - ) - .setFooter({ text: 'Buttons update your saved goodbye setup immediately.' }); - - if (imageValue) { - embed.setImage(imageValue); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:channel`) - .setLabel('Channel') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:message`) - .setLabel('Message') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:ping`) - .setLabel('Toggle Ping') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:image`) - .setLabel('Image') - .setStyle(ButtonStyle.Secondary) - ); - - return { - embeds: [embed], - components: [row] - }; -} - -export function buildGoodbyeConfigModal(action, config) { - if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { - return null; - } - - const modal = new ModalBuilder().setCustomId(`${GOODBYE_CONFIG_MODAL_ID}:${action}`); - - if (action === 'channel') { - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Channel mention or ID') - .setPlaceholder('Example: #goodbye or 123456789012345678') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setValue(config.goodbyeChannelId ? `<#${config.goodbyeChannelId}>` : ''); - - modal - .setTitle('Set Goodbye Channel') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - if (action === 'message') { - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Goodbye message') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true) - .setMaxLength(2000) - .setValue(config.leaveMessage || '{user.tag} has left the server.'); - - modal - .setTitle('Set Goodbye Message') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - if (action === 'image') { - const imageValue = - typeof config?.leaveEmbed?.image === 'string' - ? config.leaveEmbed.image - : config?.leaveEmbed?.image?.url || ''; - - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Image URL (leave blank to remove)') - .setPlaceholder('https://example.com/goodbye.png') - .setStyle(TextInputStyle.Short) - .setRequired(false) - .setValue(imageValue); - - modal - .setTitle('Set Goodbye Image') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - return null; -} \ No newline at end of file diff --git a/src/commands/Welcome/modules/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js new file mode 100644 index 0000000000..9302c9f036 --- /dev/null +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -0,0 +1,744 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; +import { getWelcomeConfig, saveWelcomeConfig } from '../../../utils/database.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild) { + const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; + const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'; + + const rawWelcome = cfg.welcomeMessage || 'Welcome {user} to {server}!'; + const rawGoodbye = cfg.leaveMessage || '{user.tag} has left the server.'; + const welcomePreview = `\`${rawWelcome.length > 55 ? rawWelcome.substring(0, 55) + 'โ€ฆ' : rawWelcome}\``; + const goodbyePreview = `\`${rawGoodbye.length > 55 ? rawGoodbye.substring(0, 55) + 'โ€ฆ' : rawGoodbye}\``; + + return new EmbedBuilder() + .setTitle('๐Ÿ‘‹ Greet System Dashboard') + .setDescription( + `Manage welcome & goodbye settings for **${guild.name}**.\nUse the toggles to enable/disable each side, then select an option to edit.`, + ) + .setColor(getColor('info')) + .addFields( + { name: '๐ŸŸข Welcome Channel', value: welcomeChannel, inline: true }, + { name: 'โš™๏ธ Welcome Status', value: cfg.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”” Welcome Ping', value: cfg.welcomePing ? 'โœ… On' : 'โŒ Off', inline: true }, + { name: '๐Ÿ”ด Goodbye Channel', value: goodbyeChannel, inline: true }, + { name: 'โš™๏ธ Goodbye Status', value: cfg.goodbyeEnabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”” Goodbye Ping', value: cfg.goodbyePing ? 'โœ… On' : 'โŒ Off', inline: true }, + { name: '๐Ÿ’ฌ Welcome Message', value: welcomePreview, inline: false }, + { name: '๐Ÿ’ฌ Goodbye Message', value: goodbyePreview, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`greet_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Channel') + .setDescription('Set the channel where welcome messages are sent') + .setValue('welcome_channel') + .setEmoji('๐ŸŸข'), + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Message') + .setDescription('Edit the text shown when a member joins') + .setValue('welcome_message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Image') + .setDescription('Set the image for welcome messages') + .setValue('welcome_image') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Channel') + .setDescription('Set the channel where goodbye messages are sent') + .setValue('goodbye_channel') + .setEmoji('๐Ÿ”ด'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Message') + .setDescription('Edit the text shown when a member leaves') + .setValue('goodbye_message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Image') + .setDescription('Set the image for goodbye messages') + .setValue('goodbye_image') + .setEmoji('๐Ÿ–ผ๏ธ'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const welcomeOn = cfg.enabled === true; + const goodbyeOn = cfg.goodbyeEnabled === true; + const welcomePingOn = cfg.welcomePing === true; + const goodbyePingOn = cfg.goodbyePing === true; + + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`greet_cfg_toggle_welcome_${guildId}`) + .setLabel('Welcome') + .setStyle(welcomeOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐ŸŸข') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`greet_cfg_toggle_goodbye_${guildId}`) + .setLabel('Goodbye') + .setStyle(goodbyeOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ”ด') + .setDisabled(disabled), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`greet_cfg_ping_welcome_${guildId}`) + .setLabel('Ping Welcome') + .setStyle(welcomePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('๐Ÿ””') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`greet_cfg_ping_goodbye_${guildId}`) + .setLabel('Ping Goodbye') + .setStyle(goodbyePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('๐Ÿ””') + .setDisabled(disabled), + ), + ]; +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId) { + try { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + components: [ + ...buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh greet dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const cfg = await getWelcomeConfig(client, guildId); + + if (!cfg.channelId && !cfg.goodbyeChannelId) { + throw new TitanBotError( + 'Greet system not configured', + ErrorTypes.CONFIGURATION, + 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome setup` or `/goodbye setup` first.', + ); + } + + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + const selectMenu = buildSelectMenu(guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild)], + components: [ + ...buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + // โ”€โ”€ Select collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `greet_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'welcome_channel': + await handleWelcomeChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'welcome_message': + await handleWelcomeMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'welcome_image': + await handleWelcomeImage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_channel': + await handleGoodbyeChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_message': + await handleGoodbyeMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_image': + await handleGoodbyeImage(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Greet config validation error: ${error.message}`); + } else { + logger.error('Unexpected greet dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for toggles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `greet_cfg_toggle_welcome_${guildId}` || + i.customId === `greet_cfg_toggle_goodbye_${guildId}` || + i.customId === `greet_cfg_ping_welcome_${guildId}` || + i.customId === `greet_cfg_ping_goodbye_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + const customId = btnInteraction.customId; + + if (customId === `greet_cfg_toggle_welcome_${guildId}`) { + cfg.enabled = !cfg.enabled; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Updated', + `Welcome messages are now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_toggle_goodbye_${guildId}`) { + cfg.goodbyeEnabled = !cfg.goodbyeEnabled; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Updated', + `Goodbye messages are now **${cfg.goodbyeEnabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_ping_welcome_${guildId}`) { + cfg.welcomePing = !cfg.welcomePing; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Ping Updated', + `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_ping_goodbye_${guildId}`) { + cfg.goodbyePing = !cfg.goodbyePing; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Ping Updated', + `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await refreshDashboard(interaction, cfg, guildId); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')) + ], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in greet_dashboard:', error); + throw new TitanBotError( + `Greet dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the greet dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Welcome Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('greet_cfg_welcome_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŸข Welcome Channel') + .setDescription( + `**Current:** ${cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'}\n\nSelect the channel where welcome messages will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_welcome_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.channelId = channel.id; + await saveWelcomeConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Welcome messages will now be sent in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Welcome Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_welcome_message') + .setTitle('Edit Welcome Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message (variables: {user}, {server}, etc)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.welcomeMessage || 'Welcome {user} to {server}!') + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_welcome_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.welcomeMessage = submitted.fields.getTextInputValue('message_input').trim(); + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Welcome Message Updated', 'The welcome message has been saved.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Welcome Image โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_welcome_image') + .setTitle('Set Welcome Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/welcome.png') + .setStyle(TextInputStyle.Short) + .setValue(cfg.welcomeImage || '') + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_welcome_image' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + + // Validate image URL + if (imageUrl) { + try { + new URL(imageUrl); + if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } catch { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } + + cfg.welcomeImage = imageUrl || null; + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Welcome Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Welcome Ping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + cfg.welcomePing = !cfg.welcomePing; + await saveWelcomeConfig(client, guildId, cfg); + + await selectInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Ping Updated', + `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('greet_cfg_goodbye_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ”ด Goodbye Channel') + .setDescription( + `**Current:** ${cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'}\n\nSelect the channel where goodbye messages will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_goodbye_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.goodbyeChannelId = channel.id; + await saveWelcomeConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Goodbye messages will now be sent in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Goodbye Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_goodbye_message') + .setTitle('Edit Goodbye Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message (variables: {user}, {server}, etc)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.leaveMessage || '{user.tag} has left the server.') + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_goodbye_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.leaveMessage = submitted.fields.getTextInputValue('message_input').trim(); + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Goodbye Message Updated', 'The goodbye message has been saved.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Image โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_goodbye_image') + .setTitle('Set Goodbye Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/goodbye.png') + .setStyle(TextInputStyle.Short) + .setValue( + typeof cfg.leaveEmbed?.image === 'string' + ? cfg.leaveEmbed.image + : cfg.leaveEmbed?.image?.url || '' + ) + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_goodbye_image' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + + // Validate image URL + if (imageUrl) { + try { + new URL(imageUrl); + if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } catch { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } + + const nextLeaveEmbed = { ...(cfg.leaveEmbed || {}) }; + if (imageUrl) { + nextLeaveEmbed.image = imageUrl; + } else { + delete nextLeaveEmbed.image; + } + + cfg.leaveEmbed = nextLeaveEmbed; + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Goodbye Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Ping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyePing(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + cfg.goodbyePing = !cfg.goodbyePing; + await saveWelcomeConfig(client, guildId, cfg); + + await selectInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Ping Updated', + `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} diff --git a/src/commands/Welcome/modules/welcomeConfig.js b/src/commands/Welcome/modules/welcomeConfig.js deleted file mode 100644 index ca6774c761..0000000000 --- a/src/commands/Welcome/modules/welcomeConfig.js +++ /dev/null @@ -1,172 +0,0 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - EmbedBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle -} from 'discord.js'; -import { getColor } from '../../../config/bot.js'; -import { formatWelcomeMessage } from '../../../utils/welcome.js'; - -export const WELCOME_CONFIG_BUTTON_ID = 'welcome_config'; -export const WELCOME_CONFIG_MODAL_ID = 'welcome_config_modal'; -export const WELCOME_CONFIG_ACTIONS = ['channel', 'message', 'ping', 'image']; - -export function hasWelcomeSetup(config) { - return Boolean(config?.channelId); -} - -export function parseChannelInput(rawInput) { - const value = String(rawInput || '').trim(); - if (!value) return null; - - const mentionMatch = value.match(/^<#(\d+)>$/); - if (mentionMatch) return mentionMatch[1]; - - if (/^\d{17,20}$/.test(value)) { - return value; - } - - return null; -} - -export function isValidImageUrl(rawInput) { - const value = String(rawInput || '').trim(); - if (!value) return true; - - try { - const url = new URL(value); - return ['http:', 'https:'].includes(url.protocol); - } catch { - return false; - } -} - -function truncatePreview(value, maxLength = 512) { - if (!value) return 'Not set'; - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3)}...`; -} - -export function buildWelcomeConfigPayload(guild, config, notice = null) { - const previewMessage = formatWelcomeMessage(config.welcomeMessage || 'Welcome {user} to {server}!', { - user: guild?.members?.me?.user || guild?.client?.user, - guild - }); - - const embed = new EmbedBuilder() - .setColor(getColor('primary')) - .setTitle('โš™๏ธ Welcome Configuration') - .setDescription(notice || 'Customize your welcome setup using the buttons below.') - .addFields( - { - name: 'Channel', - value: config.channelId ? `<#${config.channelId}>` : 'Not set', - inline: true - }, - { - name: 'Ping User', - value: config.welcomePing ? 'โœ… Yes' : 'โŒ No', - inline: true - }, - { - name: 'Status', - value: config.enabled ? 'โœ… Enabled' : 'โŒ Disabled', - inline: true - }, - { - name: 'Message Preview', - value: truncatePreview(previewMessage, 1024) - }, - { - name: 'Image URL', - value: config.welcomeImage ? truncatePreview(config.welcomeImage, 1024) : 'Not set' - } - ) - .setFooter({ text: 'Buttons update your saved welcome setup immediately.' }); - - if (config.welcomeImage) { - embed.setImage(config.welcomeImage); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:channel`) - .setLabel('Channel') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:message`) - .setLabel('Message') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:ping`) - .setLabel('Toggle Ping') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:image`) - .setLabel('Image') - .setStyle(ButtonStyle.Secondary) - ); - - return { - embeds: [embed], - components: [row] - }; -} - -export function buildWelcomeConfigModal(action, config) { - if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { - return null; - } - - const modal = new ModalBuilder().setCustomId(`${WELCOME_CONFIG_MODAL_ID}:${action}`); - - if (action === 'channel') { - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Channel mention or ID') - .setPlaceholder('Example: #welcome or 123456789012345678') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setValue(config.channelId ? `<#${config.channelId}>` : ''); - - modal - .setTitle('Set Welcome Channel') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - if (action === 'message') { - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Welcome message') - .setStyle(TextInputStyle.Paragraph) - .setRequired(true) - .setMaxLength(2000) - .setValue(config.welcomeMessage || 'Welcome {user} to {server}!'); - - modal - .setTitle('Set Welcome Message') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - if (action === 'image') { - const input = new TextInputBuilder() - .setCustomId('value') - .setLabel('Image URL (leave blank to remove)') - .setPlaceholder('https://example.com/welcome.png') - .setStyle(TextInputStyle.Short) - .setRequired(false) - .setValue(config.welcomeImage || ''); - - modal - .setTitle('Set Welcome Image') - .addComponents(new ActionRowBuilder().addComponents(input)); - return modal; - } - - return null; -} \ No newline at end of file diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 9c919e647d..02d864b62d 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -5,7 +5,6 @@ import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { buildWelcomeConfigPayload, hasWelcomeSetup } from './modules/welcomeConfig.js'; export default { data: new SlashCommandBuilder() @@ -32,15 +31,7 @@ export default { .addBooleanOption(option => option.setName('ping') .setDescription('Whether to ping the user in the welcome message') - .setRequired(false))) - .addSubcommand(subcommand => - subcommand - .setName('toggle') - .setDescription('Enable or disable welcome messages')) - .addSubcommand(subcommand => - subcommand - .setName('config') - .setDescription('Customize your existing welcome setup with buttons')), + .setRequired(false))), async execute(interaction) { try { @@ -133,7 +124,7 @@ export default { { name: 'Ping User', value: ping ? 'โœ… Yes' : 'โŒ No' }, { name: 'Status', value: 'โœ… Enabled' } ) - .setFooter({ text: 'Tip: Use /welcome toggle to enable/disable welcome messages' }); + .setFooter({ text: 'Tip: Use /welcome config to customize welcome settings' }); if (image) { embed.setImage(image); @@ -151,63 +142,6 @@ export default { flags: MessageFlags.Ephemeral }); } - } - else if (subcommand === 'config') { - try { - const currentConfig = await getWelcomeConfig(client, guild.id); - - if (!hasWelcomeSetup(currentConfig)) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'No Welcome Setup Found', - 'Set up welcome first using **/welcome setup**.' - )], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeEditReply( - interaction, - buildWelcomeConfigPayload(guild, currentConfig, 'Use the buttons below to customize your welcome setup.') - ); - } catch (error) { - logger.error(`[Welcome] Failed to load welcome config panel for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Config Panel Failed', - 'An error occurred while loading the welcome config panel. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } - - else if (subcommand === 'toggle') { - try { - const currentConfig = await getWelcomeConfig(client, guild.id); - const newStatus = !currentConfig.enabled; - - await updateWelcomeConfig(client, guild.id, { - enabled: newStatus - }); - - logger.info(`[Welcome] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - await InteractionHelper.safeEditReply(interaction, { - content: `โœ… Welcome messages have been ${newStatus ? 'enabled' : 'disabled'}.`, - flags: MessageFlags.Ephemeral - }); - } catch (error) { - logger.error(`[Welcome] Failed to toggle welcome system for guild ${guild.id}:`, error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Toggle Failed', - 'An error occurred while toggling welcome messages. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } } }, }; diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js index db21d9b6c5..3941e57745 100644 --- a/src/events/channelDelete.js +++ b/src/events/channelDelete.js @@ -1,14 +1,31 @@ import { getJoinToCreateConfig, removeJoinToCreateTrigger, - unregisterTemporaryChannel + unregisterTemporaryChannel, + getTicketData, + saveTicketData } from '../utils/database.js'; -import { getServerCounters, saveServerCounters } from '../services/counterService.js'; +import { getServerCounters, saveServerCounters } from '../services/serverstatsService.js'; import { logger } from '../utils/logger.js'; export default { name: 'channelDelete', async execute(channel, client) { + // Handle ticket text channel deletion + if (channel.type === 0 && channel.guild) { + try { + const ticketData = await getTicketData(channel.guild.id, channel.id); + if (ticketData && ticketData.status === 'open') { + ticketData.status = 'deleted'; + ticketData.closedAt = new Date().toISOString(); + await saveTicketData(channel.guild.id, channel.id, ticketData); + logger.info(`Ticket channel ${channel.id} was manually deleted in guild ${channel.guild.id}, marked as deleted`); + } + } catch (err) { + logger.warn(`Could not clean up ticket record for deleted channel ${channel.id}:`, err); + } + } + if (channel.type !== 2 && channel.type !== 4) { return; } diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 0310155716..42c2d84507 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -4,7 +4,7 @@ import { getGuildConfig } from '../services/guildConfig.js'; import { getWelcomeConfig } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/counterService.js'; +import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index a48aa6121f..97b4de438c 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -1,10 +1,11 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; import { getColor } from '../config/bot.js'; -import { getWelcomeConfig } from '../utils/database.js'; +import { getWelcomeConfig, getUserApplications, deleteApplication } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; -import { getServerCounters, updateCounter } from '../services/counterService.js'; +import { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; +import { deleteUserLevelData } from '../services/leveling.js'; import { logger } from '../utils/logger.js'; export default { @@ -136,6 +137,27 @@ export default { logger.debug('Error handling birthday on member leave:', error); } + // Remove all pending applications when a member leaves + try { + const userApplications = await getUserApplications(member.client, guild.id, user.id); + if (userApplications && userApplications.length > 0) { + for (const app of userApplications) { + await deleteApplication(member.client, guild.id, app.id, user.id); + } + logger.debug(`Removed ${userApplications.length} applications for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error handling applications on member leave:', error); + } + + // Remove leveling data when a member leaves + try { + await deleteUserLevelData(member.client, guild.id, user.id); + logger.debug(`Removed leveling data for user ${user.id} in guild ${guild.id}`); + } catch (error) { + logger.debug('Error handling leveling data on member leave:', error); + } + } catch (error) { logger.error('Error in guildMemberRemove event:', error); } diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index bfd3e9d2eb..450ace66bb 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -2,7 +2,7 @@ import { Events, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/guildConfig.js'; import { handleApplicationModal } from '../commands/Community/apply.js'; -import { handleApplicationButton, handleApplicationReviewModal } from '../commands/Community/app-admin.js'; +import { handleApplicationReviewModal } from '../commands/Community/app-admin.js'; import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; import { MessageTemplates } from '../utils/messageTemplates.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -95,20 +95,134 @@ export default { commandName: interaction.commandName }, interactionTraceContext)); } - } else if (interaction.isButton()) { - if (interaction.customId.startsWith('app_approve_') || interaction.customId.startsWith('app_deny_')) { + } else if (interaction.isAutocomplete()) { + // Handle autocomplete interactions + const focusedOption = interaction.options.getFocused(true); + + if (interaction.commandName === 'apply' && focusedOption.name === 'application') { try { - await handleApplicationButton(interaction); + const { getApplicationRoles } = await import('../utils/database.js'); + const roles = await getApplicationRoles(client, interaction.guildId); + const roleName = interaction.options.getString('application', false); + + // Filter: only show enabled applications + const filtered = roles.filter(role => + role.enabled !== false && + role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '') + ); + + await interaction.respond( + filtered.slice(0, 25).map(role => ({ + name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + value: role.name + })) + ); } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'application' - }, interactionTraceContext)); + logger.error('Error handling autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); + } + } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { + try { + const { getApplicationRoles } = await import('../utils/database.js'); + const roles = await getApplicationRoles(client, interaction.guildId); + const appName = interaction.options.getString('application', false); + + // Show all applications (enabled and disabled), but mark disabled ones + const filtered = roles.filter(role => + role.name.toLowerCase().startsWith(appName?.toLowerCase() || '') + ); + + await interaction.respond( + filtered.slice(0, 25).map(role => ({ + name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + value: role.name + })) + ); + } catch (error) { + logger.error('Error handling app-admin autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); + } + } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { + try { + const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); + const guildId = interaction.guildId; + const guild = interaction.guild; + + let panels = await getAllReactionRoleMessages(client, guildId); + + if (!panels || panels.length === 0) { + await interaction.respond([]); + return; + } + + // Filter out panels whose messages no longer exist + const validPanels = []; + for (const panel of panels) { + if (!panel.messageId || !panel.channelId) { + continue; + } + + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + await interaction.respond([]); + return; + } + + const choices = await Promise.all( + validPanels.slice(0, 25).map(async panel => { + try { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) return null; + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) return null; + + const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const channelName = channel?.name ?? 'unknown'; + + return { + name: `${title} (${channelName})`.substring(0, 100), + value: panel.messageId + }; + } catch (e) { + return null; + } + }) + ); + + const validChoices = choices.filter(c => c !== null); + await interaction.respond(validChoices); + } catch (error) { + logger.error('Error handling reactroles autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); } - return; } - + } else if (interaction.isButton()) { if (interaction.customId.startsWith('shared_todo_')) { const parts = interaction.customId.split('_'); const buttonType = parts.slice(0, 3).join('_'); @@ -166,6 +280,13 @@ export default { const selectMenu = client.selectMenus.get(customId); if (!selectMenu) { + if (!interaction.customId.includes(':')) { + // No registered handler and no ':' delimiter โ€” this is an inline-collected + // select menu (e.g. ticket_config_, jointocreate_config_). + // Return silently so the existing MessageComponentCollector handles it. + return; + } + throw createError( `No select menu handler found for ${customId}`, ErrorTypes.CONFIGURATION, @@ -221,6 +342,12 @@ export default { const modal = client.modals.get(customId); if (!modal) { + if (!interaction.customId.includes(':')) { + // No registered handler and no ':' delimiter โ€” this is an inline-awaited + // modal (e.g. via awaitModalSubmit). Return silently so the caller handles it. + return; + } + throw createError( `No modal handler found for ${customId}`, ErrorTypes.CONFIGURATION, diff --git a/src/handlers/counterButtons.js b/src/handlers/counterButtons.js index 88380289ce..23b1a0d3f9 100644 --- a/src/handlers/counterButtons.js +++ b/src/handlers/counterButtons.js @@ -1,6 +1,6 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../utils/embeds.js'; -import { performDeletionByCounterId } from '../commands/Counter/modules/counter_delete.js'; +import { performDeletionByCounterId } from '../commands/ServerStats/modules/serverstats_delete.js'; import { logger } from '../utils/logger.js'; export const counterDeleteActionHandler = { diff --git a/src/handlers/interactionHandlers/goodbyeConfigButton.js b/src/handlers/interactionHandlers/goodbyeConfigButton.js deleted file mode 100644 index 93b7ea9c3a..0000000000 --- a/src/handlers/interactionHandlers/goodbyeConfigButton.js +++ /dev/null @@ -1,59 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - GOODBYE_CONFIG_ACTIONS, - buildGoodbyeConfigModal, - buildGoodbyeConfigPayload, - hasGoodbyeSetup -} from '../../commands/Welcome/modules/goodbyeConfig.js'; - -export async function handleGoodbyeConfigButton(interaction, client, args) { - const action = args[0]; - - if (!GOODBYE_CONFIG_ACTIONS.includes(action)) { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That goodbye config action is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const config = await getWelcomeConfig(client, interaction.guildId); - if (!hasGoodbyeSetup(config)) { - await interaction.reply({ - embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - if (action === 'ping') { - const newPingValue = !Boolean(config.goodbyePing); - await updateWelcomeConfig(client, interaction.guildId, { goodbyePing: newPingValue }); - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.update( - buildGoodbyeConfigPayload( - interaction.guild, - updatedConfig, - `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` - ) - ); - - logger.info(`[Goodbye Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); - return; - } - - const modal = buildGoodbyeConfigModal(action, config); - if (!modal) { - await interaction.reply({ - embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.showModal(modal); -} diff --git a/src/handlers/interactionHandlers/goodbyeConfigModal.js b/src/handlers/interactionHandlers/goodbyeConfigModal.js deleted file mode 100644 index 19dc944c8d..0000000000 --- a/src/handlers/interactionHandlers/goodbyeConfigModal.js +++ /dev/null @@ -1,119 +0,0 @@ -import { ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - GOODBYE_CONFIG_ACTIONS, - buildGoodbyeConfigPayload, - hasGoodbyeSetup, - isValidImageUrl, - parseChannelInput -} from '../../commands/Welcome/modules/goodbyeConfig.js'; - -export async function handleGoodbyeConfigModal(interaction, client, args) { - const action = args[0]; - - if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That goodbye config form is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const currentConfig = await getWelcomeConfig(client, interaction.guildId); - if (!hasGoodbyeSetup(currentConfig)) { - await interaction.editReply({ - embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')] - }); - return; - } - - const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; - - try { - if (action === 'channel') { - const channelId = parseChannelInput(inputValue); - if (!channelId) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] - }); - return; - } - - const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { goodbyeChannelId: channel.id }); - } - - if (action === 'message') { - if (!inputValue) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Message', 'Goodbye message cannot be empty.')] - }); - return; - } - - if (inputValue.length > 2000) { - await interaction.editReply({ - embeds: [errorEmbed('Message Too Long', 'Goodbye message must be 2000 characters or less.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { - leaveMessage: inputValue, - leaveEmbed: { - ...(currentConfig.leaveEmbed || {}), - description: inputValue - } - }); - } - - if (action === 'image') { - if (inputValue && !isValidImageUrl(inputValue)) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] - }); - return; - } - - const existingLeaveEmbed = currentConfig.leaveEmbed || {}; - const nextLeaveEmbed = { ...existingLeaveEmbed }; - - if (inputValue) { - nextLeaveEmbed.image = inputValue; - } else { - delete nextLeaveEmbed.image; - } - - await updateWelcomeConfig(client, interaction.guildId, { - leaveEmbed: nextLeaveEmbed - }); - } - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.editReply( - buildGoodbyeConfigPayload( - interaction.guild, - updatedConfig, - `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` - ) - ); - - logger.info(`[Goodbye Config] ${action} updated in guild ${interaction.guildId}`); - } catch (error) { - logger.error(`[Goodbye Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); - await interaction.editReply({ - embeds: [errorEmbed('Update Failed', 'An error occurred while updating your goodbye config.')] - }); - } -} diff --git a/src/handlers/interactionHandlers/welcomeConfigButton.js b/src/handlers/interactionHandlers/welcomeConfigButton.js deleted file mode 100644 index 77847a6a43..0000000000 --- a/src/handlers/interactionHandlers/welcomeConfigButton.js +++ /dev/null @@ -1,59 +0,0 @@ -import { MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - WELCOME_CONFIG_ACTIONS, - buildWelcomeConfigModal, - buildWelcomeConfigPayload, - hasWelcomeSetup -} from '../../commands/Welcome/modules/welcomeConfig.js'; - -export async function handleWelcomeConfigButton(interaction, client, args) { - const action = args[0]; - - if (!WELCOME_CONFIG_ACTIONS.includes(action)) { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That welcome config action is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const config = await getWelcomeConfig(client, interaction.guildId); - if (!hasWelcomeSetup(config)) { - await interaction.reply({ - embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - if (action === 'ping') { - const newPingValue = !Boolean(config.welcomePing); - await updateWelcomeConfig(client, interaction.guildId, { welcomePing: newPingValue }); - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.update( - buildWelcomeConfigPayload( - interaction.guild, - updatedConfig, - `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` - ) - ); - - logger.info(`[Welcome Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); - return; - } - - const modal = buildWelcomeConfigModal(action, config); - if (!modal) { - await interaction.reply({ - embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.showModal(modal); -} diff --git a/src/handlers/interactionHandlers/welcomeConfigModal.js b/src/handlers/interactionHandlers/welcomeConfigModal.js deleted file mode 100644 index ce49097f84..0000000000 --- a/src/handlers/interactionHandlers/welcomeConfigModal.js +++ /dev/null @@ -1,104 +0,0 @@ -import { ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - WELCOME_CONFIG_ACTIONS, - buildWelcomeConfigPayload, - hasWelcomeSetup, - isValidImageUrl, - parseChannelInput -} from '../../commands/Welcome/modules/welcomeConfig.js'; - -export async function handleWelcomeConfigModal(interaction, client, args) { - const action = args[0]; - - if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That welcome config form is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const currentConfig = await getWelcomeConfig(client, interaction.guildId); - if (!hasWelcomeSetup(currentConfig)) { - await interaction.editReply({ - embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')] - }); - return; - } - - const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; - - try { - if (action === 'channel') { - const channelId = parseChannelInput(inputValue); - if (!channelId) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] - }); - return; - } - - const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { channelId: channel.id }); - } - - if (action === 'message') { - if (!inputValue) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Message', 'Welcome message cannot be empty.')] - }); - return; - } - - if (inputValue.length > 2000) { - await interaction.editReply({ - embeds: [errorEmbed('Message Too Long', 'Welcome message must be 2000 characters or less.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { welcomeMessage: inputValue }); - } - - if (action === 'image') { - if (inputValue && !isValidImageUrl(inputValue)) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { - welcomeImage: inputValue || null - }); - } - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.editReply( - buildWelcomeConfigPayload( - interaction.guild, - updatedConfig, - `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` - ) - ); - - logger.info(`[Welcome Config] ${action} updated in guild ${interaction.guildId}`); - } catch (error) { - logger.error(`[Welcome Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); - await interaction.editReply({ - embeds: [errorEmbed('Update Failed', 'An error occurred while updating your welcome config.')] - }); - } -} diff --git a/src/handlers/loggingButtons.js b/src/handlers/loggingButtons.js index 2eef33ab83..817a94d761 100644 --- a/src/handlers/loggingButtons.js +++ b/src/handlers/loggingButtons.js @@ -9,12 +9,12 @@ import { parseEventTypeFromButton } from '../utils/loggingUi.js'; import { logger } from '../utils/logger.js'; -import { buildLoggingStatusView } from '../commands/Config/modules/config_logging_status.js'; +import { buildLoggingDashboardView } from '../commands/Logging/modules/logging_dashboard.js'; const LOGGING_CATEGORIES = [...new Set(Object.values(EVENT_TYPES).map((eventType) => eventType.split('.')[0]))]; export default { - customIds: ['logging_toggle', 'logging_refresh_status'], + customIds: ['logging_toggle', 'logging_refresh_status', 'log_dash_toggle', 'log_dash_refresh'], async execute(interaction) { try { @@ -26,10 +26,18 @@ export default { }); } + // Dashboard-specific buttons + if (interaction.customId === 'log_dash_refresh') { + return await handleDashboardRefresh(interaction); + } + if (interaction.customId.startsWith('log_dash_toggle')) { + return await handleDashboardToggle(interaction); + } + + // Legacy /config logging status buttons if (interaction.customId === 'logging_refresh_status') { return await handleRefresh(interaction); } - if (interaction.customId.startsWith('logging_toggle')) { return await handleToggle(interaction); } @@ -60,7 +68,7 @@ async function handleToggle(interaction) { const newState = !Boolean(status.enabled); await setLoggingEnabled(interaction.client, interaction.guildId, newState); - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); return interaction.update({ embeds: [embed], components @@ -82,7 +90,7 @@ async function handleToggle(interaction) { await toggleEventLogging(interaction.client, interaction.guildId, eventType, newState); } - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); await interaction.update({ embeds: [embed], components @@ -99,7 +107,7 @@ async function handleToggle(interaction) { async function handleRefresh(interaction) { try { - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); await interaction.update({ embeds: [embed], @@ -114,3 +122,45 @@ async function handleRefresh(interaction) { }); } } + +// โ”€โ”€โ”€ Dashboard button handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDashboardToggle(interaction) { + try { + // customId: log_dash_toggle:audit_enabled | log_dash_toggle:all | log_dash_toggle:category.* + const eventType = interaction.customId.replace('log_dash_toggle:', ''); + if (!eventType) { + return interaction.reply({ content: 'โŒ Invalid event type.', ephemeral: true }); + } + + const status = await getLoggingStatus(interaction.client, interaction.guildId); + + if (eventType === 'audit_enabled') { + await setLoggingEnabled(interaction.client, interaction.guildId, !Boolean(status.enabled)); + } else if (eventType === 'all') { + const newState = !Object.values(status.enabledEvents).every((v) => v !== false); + const allTypes = Object.values(EVENT_TYPES); + const categoryTypes = LOGGING_CATEGORIES.map((c) => `${c}.*`); + await toggleEventLogging(interaction.client, interaction.guildId, [...allTypes, ...categoryTypes], newState); + } else { + const currentState = status.enabledEvents[eventType] !== false; + await toggleEventLogging(interaction.client, interaction.guildId, eventType, !currentState); + } + + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); + await interaction.update({ embeds: [embed], components }); + } catch (error) { + logger.error('Error in dashboard toggle:', error); + await interaction.reply({ content: 'โŒ An error occurred while toggling.', ephemeral: true }); + } +} + +async function handleDashboardRefresh(interaction) { + try { + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); + await interaction.update({ embeds: [embed], components }); + } catch (error) { + logger.error('Error refreshing logging dashboard:', error); + await interaction.reply({ content: 'โŒ An error occurred while refreshing the dashboard.', ephemeral: true }); + } +} diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index 8af40f7b84..c28c5b6f46 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -9,6 +9,17 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { getTicketPermissionContext } from '../utils/ticketPermissions.js'; +// Helper function to escape HTML special characters +function escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + async function ensureGuildContext(interaction) { if (interaction.inGuild()) { return true; @@ -24,6 +35,38 @@ async function ensureGuildContext(interaction) { return false; } +async function checkTicketPermissionWithTimeout(interaction, client, actionLabel, options = {}, timeoutMs = 2500) { + const { allowTicketCreator = false } = options; + + try { + const contextPromise = getTicketPermissionContext({ client, interaction }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeoutMs) + ); + + const context = await Promise.race([contextPromise, timeoutPromise]); + + if (!context.ticketData) { + return { success: false, error: 'Not a Ticket Channel', details: 'This action can only be used in a valid ticket channel.' }; + } + + const allowed = allowTicketCreator ? context.canCloseTicket : context.canManageTicket; + if (!allowed) { + const permissionMessage = allowTicketCreator + ? 'You must have **Manage Channels**, the configured **Ticket Staff Role**, or be the **ticket creator**.' + : 'You must have **Manage Channels** or the configured **Ticket Staff Role**.'; + return { success: false, error: 'Permission Denied', details: `${permissionMessage}\n\nYou cannot ${actionLabel}.` }; + } + + return { success: true, context }; + } catch (error) { + if (error.message === 'Timeout') { + return { success: false, error: 'Request Timeout', details: 'The permission check took too long. Please try again.' }; + } + return { success: false, error: 'Error', details: `Failed to check permissions: ${error.message}` }; + } +} + async function ensureTicketPermission(interaction, client, actionLabel, options = {}) { const { allowTicketCreator = false } = options; @@ -76,7 +119,7 @@ const createTicketHandler = { const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); if (currentTicketCount >= maxTicketsPerUser) { - return interaction.reply({ + return await interaction.reply({ embeds: [ errorEmbed( '๐ŸŽซ Ticket Limit Reached', @@ -102,6 +145,7 @@ const createTicketHandler = { const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); + // showModal must be called directly without defer await interaction.showModal(modal); } catch (error) { logger.error('Error creating ticket modal:', error); @@ -110,11 +154,6 @@ const createTicketHandler = { embeds: [errorEmbed('Error', 'Could not open ticket creation form.')], flags: MessageFlags.Ephemeral }); - } else { - await interaction.followUp({ - embeds: [errorEmbed('Error', 'Could not open ticket creation form.')], - flags: MessageFlags.Ephemeral - }); } } } @@ -169,7 +208,24 @@ const closeTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + // Use timeout-aware permission check to prevent interaction expiration + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'close this ticket', + { allowTicketCreator: true }, + 2000 // 2 second timeout for permission checks + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const modal = new ModalBuilder() .setCustomId('ticket_close_modal') @@ -186,6 +242,7 @@ const closeTicketHandler = { const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); + // showModal must be called directly without defer await interaction.showModal(modal); } catch (error) { logger.error('Error closing ticket:', error); @@ -195,11 +252,6 @@ const closeTicketHandler = { embeds: [errorEmbed('Error', 'Could not open ticket close form.')], flags: MessageFlags.Ephemeral }); - } else { - await interaction.followUp({ - embeds: [errorEmbed('Error', 'Could not open ticket close form.')], - flags: MessageFlags.Ephemeral - }); } } } @@ -211,7 +263,24 @@ const closeTicketModalHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + // Use timeout-aware permission check + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'close this ticket', + { allowTicketCreator: true }, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -226,17 +295,6 @@ const closeTicketModalHandler = { embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Closed', - target: interaction.channel.toString(), - executor: interaction.user.toString(), - reason - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to close ticket.')], @@ -245,10 +303,17 @@ const closeTicketModalHandler = { } } catch (error) { logger.error('Error submitting close ticket modal:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -259,7 +324,23 @@ const claimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'claim tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'claim tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -289,10 +370,17 @@ const claimTicketHandler = { } } catch (error) { logger.error('Error claiming ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -303,7 +391,23 @@ const priorityTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'change ticket priority'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'change ticket priority', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -332,186 +436,139 @@ const priorityTicketHandler = { } } catch (error) { logger.error('Error updating ticket priority:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], + flags: MessageFlags.Ephemeral + }); + } } } }; -const transcriptTicketHandler = { - name: 'ticket_transcript', +const pinTicketHandler = { + name: 'ticket_pin', async execute(interaction, client) { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'create transcripts'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'pin tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - - const messages = await interaction.channel.messages.fetch({ limit: 100 }); - if (process.env.NODE_ENV !== 'production') { - logger.debug('Total messages fetched:', messages?.size || 0); - } - - if (!messages || messages.size === 0) { + + const channel = interaction.channel; + const category = channel.parent; + + if (!category) { await interaction.editReply({ - embeds: [errorEmbed('No Messages', 'No messages found in this ticket channel.')], + embeds: [errorEmbed('Error', 'This ticket is not in a category.')], flags: MessageFlags.Ephemeral }); return; } + + // Check if channel name already has ping emoji + const hasPingEmoji = channel.name.startsWith('๐Ÿ“'); - const messagesArray = Array.from(messages.values()); - const userMessages = messagesArray.filter(m => { - const hasAuthor = m.author && typeof m.author === 'object'; - const hasTag = hasAuthor && m.author.tag; -const isUserMessage = m.type === 0; - - if (!hasAuthor) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Filtering message without author:', m.id, m.type); - } - } else if (!hasTag) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Message author exists but no tag:', m.id, m.author); - } - } - - return hasAuthor && hasTag && isUserMessage; - }); - - if (process.env.NODE_ENV !== 'production') { - logger.debug('Filtered user messages:', userMessages?.length || 0); - } - const sortedMessages = userMessages.sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - if (!sortedMessages || sortedMessages.length === 0) { + if (hasPingEmoji) { + // Unpin: remove emoji and update position + const newName = channel.name.replace(/^๐Ÿ“\s*/, ''); + await channel.edit({ + name: newName, + position: 999 // Move to end + }); + await interaction.editReply({ - embeds: [errorEmbed('No User Messages', 'No user messages found to include in the transcript.')], + embeds: [createEmbed({ + title: '๐Ÿ“ Ticket Unpinned', + description: 'This ticket has been unpinned and moved back to normal position.', + color: 0x95A5A6 + })], flags: MessageFlags.Ephemeral }); - return; - } - - let htmlTranscript = ` - - - Ticket Transcript - ${interaction.channel.name} - - - -
-

๐ŸŽซ Ticket Transcript

-

Channel: ${interaction.channel.name}

-

Created:

-

Generated: ๐Ÿ“…

-

Messages: ${sortedMessages.length}

-
-`; - - for (const message of sortedMessages) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Processing message:', message.id, 'Author exists:', !!message.author, 'Attachments exist:', !!message.attachments); - } - - const timestamp = ``; - const author = message.author?.tag || message.author?.username || 'Unknown User'; - const content = message.content || '*No content (embed/attachment only)*'; - - htmlTranscript += ` -
-
[${timestamp}]
-
${author}
-
${content.replace(/\n/g, '
')}
`; - - if (message.attachments && message.attachments.size > 0) { - htmlTranscript += ` -
- ๐Ÿ“Ž Attachments: ${message.attachments.map(a => `${a.name}`).join(', ')} -
`; - } - - htmlTranscript += ` -
`; - } - - htmlTranscript += ` - -`; - - const transcriptEmbed = createEmbed({ - title: `๐Ÿ“œ Ticket Transcript - ${interaction.channel.name}`, - description: `**Channel:** ${interaction.channel.name}\n**Created:** \n**Generated:** ๐Ÿ“… \n**Messages:** ${sortedMessages.length}\n\n๐Ÿ“Ž The complete HTML transcript has been attached as a file.`, - color: 0x3498db, - footer: { text: `Ticket ID: ${interaction.channel.id}` } - }); - - const { Buffer } = await import('buffer'); - const buffer = Buffer.from(htmlTranscript, 'utf-8'); - - try { - await interaction.user.send({ - content: `๐Ÿ“œ **Ticket Transcript** for \`${interaction.channel.name}\``, - embeds: [transcriptEmbed], - files: [{ - attachment: buffer, - name: `ticket-transcript-${interaction.channel.name}.html` - }] + + logger.info('Ticket unpinned', { + guildId: interaction.guildId, + channelId: channel.id, + channelName: newName, + userId: interaction.user.id }); - + } else { + // Pin: add emoji and update position + const newName = `๐Ÿ“ ${channel.name}`; + await channel.edit({ + name: newName, + position: 0 // Move to top + }); + await interaction.editReply({ - embeds: [{ - title: 'โœ… Transcript Sent', - description: 'The ticket transcript has been sent to your DMs as both an embed and an HTML file.', -color: 4689679 - }], + embeds: [createEmbed({ + title: '๐Ÿ“ Ticket Pinned', + description: 'This ticket has been pinned to the top of the category.', + color: 0x3498db + })], flags: MessageFlags.Ephemeral }); - - await logTicketEvent({ - client: interaction.client, + + logger.info('Ticket pinned', { guildId: interaction.guildId, - event: { - type: 'transcript', - ticketId: interaction.channel.id, - ticketNumber: interaction.channel.name.replace(/[^0-9]/g, ''), - userId: interaction.user.id, - executorId: interaction.user.id, - metadata: { - messageCount: sortedMessages.length, - sentToDM: true, - transcriptSize: htmlTranscript.length - }, - attachments: [ - new AttachmentBuilder(buffer, { name: `ticket-transcript-${interaction.channel.name}.html` }) - ] + channelId: channel.id, + channelName: newName, + userId: interaction.user.id + }); + } + + await logTicketEvent({ + client: interaction.client, + guildId: interaction.guildId, + event: { + type: hasPingEmoji ? 'unpin' : 'pin', + ticketId: channel.id, + ticketNumber: channel.name.replace(/[^0-9]/g, ''), + userId: interaction.user.id, + executorId: interaction.user.id, + metadata: { + isPinned: !hasPingEmoji, + newChannelName: hasPingEmoji ? channel.name.replace(/^๐Ÿ“\s*/, '') : `๐Ÿ“ ${channel.name}` } + } + }); + + } catch (error) { + logger.error('Error pinning/unpinning ticket:', error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'Failed to pin/unpin the ticket.')], + flags: MessageFlags.Ephemeral }); - } catch (dmError) { - logger.error('Could not DM user:', dmError); + } else if (interaction.deferred) { await interaction.editReply({ - embeds: [errorEmbed('DM Failed', 'I couldn\'t send you the transcript. Please enable DMs from server members.')], + embeds: [errorEmbed('Error', 'Failed to pin/unpin the ticket.')], flags: MessageFlags.Ephemeral }); } - - } catch (error) { - logger.error('Error creating transcript:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'Failed to create ticket transcript.')], - flags: MessageFlags.Ephemeral - }); } } }; @@ -522,7 +579,23 @@ const unclaimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'unclaim tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'unclaim tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -553,10 +626,17 @@ const unclaimTicketHandler = { } } catch (error) { logger.error('Error unclaiming ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -567,7 +647,23 @@ const reopenTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'reopen tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'reopen tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -603,10 +699,17 @@ const reopenTicketHandler = { } } catch (error) { logger.error('Error reopening ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -617,7 +720,24 @@ const deleteTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'delete tickets'))) return; + // Use timeout-aware permission check + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'delete tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -648,10 +768,17 @@ const deleteTicketHandler = { } } catch (error) { logger.error('Error deleting ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -663,7 +790,7 @@ export { closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler diff --git a/src/interactions/buttons/goodbyeConfig.js b/src/interactions/buttons/goodbyeConfig.js deleted file mode 100644 index 2016b03be6..0000000000 --- a/src/interactions/buttons/goodbyeConfig.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleGoodbyeConfigButton } from '../../handlers/interactionHandlers/goodbyeConfigButton.js'; - -export default { - name: 'goodbye_config', - async execute(interaction, client, args) { - return handleGoodbyeConfigButton(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/buttons/logging.js b/src/interactions/buttons/logging.js index 7ee27ae635..4c34e6e429 100644 --- a/src/interactions/buttons/logging.js +++ b/src/interactions/buttons/logging.js @@ -9,4 +9,12 @@ export default [ name: 'logging_refresh_status', execute: loggingButtonsHandler.execute, }, + { + name: 'log_dash_toggle', + execute: loggingButtonsHandler.execute, + }, + { + name: 'log_dash_refresh', + execute: loggingButtonsHandler.execute, + }, ]; \ No newline at end of file diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js index b65be15517..bcc2521f71 100644 --- a/src/interactions/buttons/ticket.js +++ b/src/interactions/buttons/ticket.js @@ -2,7 +2,7 @@ import createTicketHandler, { closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, @@ -13,7 +13,7 @@ export default [ closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, diff --git a/src/interactions/buttons/welcomeConfig.js b/src/interactions/buttons/welcomeConfig.js deleted file mode 100644 index 84359d6e2e..0000000000 --- a/src/interactions/buttons/welcomeConfig.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleWelcomeConfigButton } from '../../handlers/interactionHandlers/welcomeConfigButton.js'; - -export default { - name: 'welcome_config', - async execute(interaction, client, args) { - return handleWelcomeConfigButton(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/modals/goodbyeConfigModal.js b/src/interactions/modals/goodbyeConfigModal.js deleted file mode 100644 index 0f329fc73b..0000000000 --- a/src/interactions/modals/goodbyeConfigModal.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleGoodbyeConfigModal } from '../../handlers/interactionHandlers/goodbyeConfigModal.js'; - -export default { - name: 'goodbye_config_modal', - async execute(interaction, client, args) { - return handleGoodbyeConfigModal(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/modals/welcomeConfigModal.js b/src/interactions/modals/welcomeConfigModal.js deleted file mode 100644 index 7f741259b9..0000000000 --- a/src/interactions/modals/welcomeConfigModal.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleWelcomeConfigModal } from '../../handlers/interactionHandlers/welcomeConfigModal.js'; - -export default { - name: 'welcome_config_modal', - async execute(interaction, client, args) { - return handleWelcomeConfigModal(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/selectMenus/ticketFeedback.js b/src/interactions/selectMenus/ticketFeedback.js new file mode 100644 index 0000000000..54524b449c --- /dev/null +++ b/src/interactions/selectMenus/ticketFeedback.js @@ -0,0 +1,144 @@ +import { EmbedBuilder } from 'discord.js'; +import { getTicketData, saveTicketData } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; +import { getColor } from '../../config/bot.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; + +const STAR_LABELS = { + '1': 'โญ 1 โ€” Poor', + '2': 'โญโญ 2 โ€” Below Average', + '3': 'โญโญโญ 3 โ€” Average', + '4': 'โญโญโญโญ 4 โ€” Good', + '5': 'โญโญโญโญโญ 5 โ€” Excellent', +}; + +export default { + name: 'ticket_feedback', + + async execute(interaction, client, args) { + // args = [guildId, channelId] from the customId split on ':' + const [guildId, channelId] = args; + + if (!guildId || !channelId) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Invalid Feedback Link') + .setDescription('This feedback link appears to be malformed.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + // Only the ticket creator should be able to submit + let ticketData; + try { + ticketData = await getTicketData(guildId, channelId); + } catch (err) { + logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); + } + + if (!ticketData) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Ticket Not Found') + .setDescription('Could not find the ticket associated with this survey.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + if (interaction.user.id !== ticketData.userId) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle('โŒ Not Allowed') + .setDescription('Only the ticket creator can submit feedback for this ticket.') + .setColor(getColor('error')), + ], + ephemeral: true, + }); + return; + } + + // Guard against duplicate submission + if (ticketData.feedback?.rating) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โœ… Already Submitted') + .setDescription(`You already rated this ticket **${STAR_LABELS[String(ticketData.feedback.rating)]}**.\nThank you for your feedback!`) + .setColor(getColor('success')), + ], + components: [], + }); + return; + } + + const rating = parseInt(interaction.values[0], 10); + const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; + + // Persist the feedback + try { + ticketData.feedback = { + rating, + submittedAt: new Date().toISOString(), + }; + await saveTicketData(guildId, channelId, ticketData); + } catch (err) { + logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); + } + + // Send feedback to logs channel + try { + const guildConfig = await getGuildConfig(interaction.client, guildId); + if (guildConfig.ticketLogsChannelId) { + const logsChannel = await interaction.client.channels.fetch(guildConfig.ticketLogsChannelId).catch(() => null); + if (logsChannel && logsChannel.isSendable()) { + const feedbackEmbed = new EmbedBuilder() + .setTitle('๐Ÿ“‹ Ticket Feedback Received') + .setDescription(`User submitted feedback for a ticket`) + .setColor(getColor('info')) + .addFields( + { name: 'Ticket ID', value: `\`${channelId}\``, inline: true }, + { name: 'Rating', value: ratingLabel, inline: true }, + { name: 'User', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Submitted', value: ``, inline: true } + ) + .setThumbnail(interaction.user.displayAvatarURL()) + .setFooter({ text: `User ID: ${interaction.user.id}` }) + .setTimestamp(); + + await logsChannel.send({ embeds: [feedbackEmbed] }); + } + } + } catch (err) { + logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); + } + + // Edit the DM message to remove the select and show thanks + const thankYouEmbed = new EmbedBuilder() + .setTitle('โœ… Thanks for your feedback!') + .setDescription(`You rated your support experience **${ratingLabel}**.\n\nYour feedback has been recorded and helps us improve!`) + .setColor(getColor('success')) + .setFooter({ text: 'Thank you for using our support system.' }) + .setTimestamp(); + + await interaction.update({ + embeds: [thankYouEmbed], + components: [], + }); + + logger.info('Ticket feedback submitted', { + guildId, + channelId, + userId: interaction.user.id, + rating, + }); + }, +}; diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index d51f84454d..6ce5dfbb05 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -262,7 +262,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options throw new TitanBotError( 'Guild already has a Join to Create trigger configured', ErrorTypes.VALIDATION, - 'This server already has a Join to Create channel configured. Use `/jointocreate config` to modify it, or remove it before creating a new one.', + 'This server already has a Join to Create channel configured. Use `/jointocreate dashboard` to modify it, or remove it before creating a new one.', { guildId, existingTriggerChannelId: config.triggerChannels[0], diff --git a/src/services/leveling.js b/src/services/leveling.js index 01d38f47e1..6989fcece0 100644 --- a/src/services/leveling.js +++ b/src/services/leveling.js @@ -101,7 +101,7 @@ export async function getLeaderboard(client, guildId, limit = 10) { if (member.user.bot) continue; const data = await getUserLevelData(client, guildId, userId); - if (data && data.totalXp > 0) { + if (data && (data.totalXp > 0 || data.level > 0)) { leaderboard.push({ userId, username: member.user.username, @@ -518,3 +518,26 @@ export async function setUserLevel(client, guildId, userId, level) { + +export async function deleteUserLevelData(client, guildId, userId) { + try { + if (!guildId || !userId) { + throw new TitanBotError( + 'Guild ID and User ID are required', + ErrorTypes.VALIDATION + ); + } + + const key = `${guildId}:leveling:users:${userId}`; + await client.db.delete(key); + + logger.debug(`Deleted level data for user ${userId} in guild ${guildId}`); + } catch (error) { + logger.error(`Error deleting level data for user ${userId}:`, error); + if (error instanceof TitanBotError) throw error; + logger.warn(`Could not delete level data for user ${userId} in guild ${guildId}`); + } +} + + + diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.js index 96601a43e5..35cff02367 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.js @@ -290,12 +290,9 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { const data = await getReactionRoleMessage(client, guildId, messageId); if (!data) { - throw createError( - `Reaction role message not found: ${messageId}`, - ErrorTypes.CONFIGURATION, - 'No reaction role message found with that ID in this server.', - { guildId, messageId } - ); + // Data doesn't exist - this is fine, just return (idempotent delete) + logger.debug(`Reaction role message ${messageId} does not exist in guild ${guildId}, nothing to delete`); + return true; } await client.db.delete(key); @@ -427,8 +424,10 @@ export async function getAllReactionRoleMessages(client, guildId) { actualData = data; } - if (actualData) { + if (actualData && actualData.messageId && actualData.channelId) { messages.push(actualData); + } else if (actualData) { + logger.warn(`Skipping malformed reaction role data for guild ${guildId}:`, actualData); } } } catch (dataError) { @@ -437,7 +436,6 @@ export async function getAllReactionRoleMessages(client, guildId) { } } - logger.debug(`Retrieved ${messages.length} reaction role messages for guild ${guildId}`); return messages; } catch (error) { if (error.name === 'TitanBotError') { diff --git a/src/services/counterService.js b/src/services/serverstatsService.js similarity index 100% rename from src/services/counterService.js rename to src/services/serverstatsService.js diff --git a/src/services/ticket.js b/src/services/ticket.js index 8fd6b2bfb6..b38387bdbc 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -4,7 +4,10 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, - PermissionFlagsBits + PermissionFlagsBits, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + AttachmentBuilder, } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from '../utils/database.js'; @@ -134,15 +137,15 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso PermissionFlagsBits.ReadMessageHistory, ], }, - ...(ticketConfig.supportRoles?.map(roleId => ({ - id: roleId, + ...(config.ticketStaffRoleId ? [{ + id: config.ticketStaffRoleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.ReadMessageHistory, ], - })) || []), + }] : []), ], }); @@ -184,10 +187,10 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso .setStyle(ButtonStyle.Primary) .setEmoji('๐Ÿ™‹'), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); if (ticketConfig.enablePriority) { @@ -205,13 +208,16 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ); } - const messageContent = `${member.toString()}${ticketConfig.supportRoles?.length ? ' ' + ticketConfig.supportRoles.map(r => `<@&${r}>`).join(' ') : ''}`; + const staffMention = config.ticketStaffRoleId ? ` <@&${config.ticketStaffRoleId}>` : ''; + const messageContent = `${member.toString()}${staffMention}`; - await channel.send({ + const ticketMessage = await channel.send({ content: messageContent, embeds: [embed], components: [row] }); + + await ticketMessage.pin().catch(() => {}); await logTicketEvent({ client: guild.client, @@ -300,8 +306,36 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' color: '#e74c3c', footer: { text: `Ticket ID: ${ticketData.id}` } }); - + await ticketCreator.send({ embeds: [dmEmbed] }); + + // Post-close feedback survey โ€” separate DM message so it can be updated on submit + try { + const feedbackEmbed = createEmbed({ + title: 'โญ How was your support experience?', + description: `We'd love to know how we did with **${channel.name}**.\nSelect a rating below โ€” it only takes a second!`, + color: '#F1C40F', + footer: { text: 'Your feedback helps us improve.' }, + }); + + const feedbackSelect = new StringSelectMenuBuilder() + .setCustomId(`ticket_feedback:${channel.guild.id}:${channel.id}`) + .setPlaceholder('Select a rating...') + .addOptions( + new StringSelectMenuOptionBuilder().setLabel('โญ 1 โ€” Poor').setValue('1').setDescription('The support was unhelpful or slow.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญ 2 โ€” Below Average').setValue('2').setDescription('There was room for improvement.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญ 3 โ€” Average').setValue('3').setDescription('Support was okay.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญ 4 โ€” Good').setValue('4').setDescription('Support was helpful and friendly.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญโญ 5 โ€” Excellent').setValue('5').setDescription('Outstanding support experience!'), + ); + + await ticketCreator.send({ + embeds: [feedbackEmbed], + components: [new ActionRowBuilder().addComponents(feedbackSelect)], + }); + } catch (feedbackError) { + logger.warn(`Could not send feedback survey to ticket creator ${ticketData.userId}: ${feedbackError.message}`); + } } } catch (dmError) { logger.warn(`Could not send DM to ticket creator ${ticketData.userId}: ${dmError.message}`); @@ -469,10 +503,10 @@ export async function claimTicket(channel, claimer) { .setEmoji('๐Ÿ™‹') .setDisabled(true), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ @@ -631,10 +665,10 @@ export async function reopenTicket(channel, reopener) { .setEmoji('๐Ÿ™‹') .setDisabled(!!ticketData.claimedBy), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ @@ -692,6 +726,103 @@ export async function reopenTicket(channel, reopener) { } } +// Helper function to escape HTML special characters +function escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function generateTranscript(channel) { + try { + logger.debug('Generating transcript for channel', { + channelId: channel.id, + channelName: channel.name + }); + + // Fetch all messages by paginating + const messages = []; + let before = undefined; + let batch; + do { + batch = await channel.messages.fetch({ limit: 100, ...(before ? { before } : {}) }); + if (batch.size === 0) break; + messages.push(...batch.values()); + before = batch.last()?.id; + } while (batch.size === 100); + + messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + const escape = (str) => + String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const rows = messages.map((msg) => { + const ts = new Date(msg.createdTimestamp).toISOString().replace('T', ' ').slice(0, 19); + const author = escape(msg.author?.tag ?? msg.author?.username ?? 'Unknown'); + const content = escape(msg.content || (msg.embeds.length ? '[embed]' : '[attachment]')); + return `${ts}${author}${content}`; + }).join('\n'); + + const html = ` + + + + +Transcript โ€“ #${escape(channel.name)} + + + +

๐Ÿ“œ Transcript โ€“ #${escape(channel.name)}

+

${messages.length} message(s) exported on ${new Date().toUTCString()}

+ + + +${rows} + +
Timestamp (UTC)AuthorMessage
+ +`; + + const buffer = Buffer.from(html, 'utf8'); + const attachment = new AttachmentBuilder(buffer, { name: `ticket-${channel.id}.html` }); + + logger.info('โœ… Successfully generated transcript', { + channelId: channel.id, + channelName: channel.name, + messageCount: messages.length, + size: buffer.length + }); + + return attachment; + } catch (error) { + logger.error('โŒ Failed to generate transcript:', { + channelId: channel.id, + channelName: channel.name, + errorMessage: error.message, + errorName: error.name, + errorStack: error.stack + }); + return null; + } +} + export async function deleteTicket(channel, deleter) { try { const ticketData = await getTicketData(channel.guild.id, channel.id); @@ -722,13 +853,127 @@ export async function deleteTicket(channel, deleter) { } } }); - + + // Generate and send transcript if transcript channel is configured setTimeout(async () => { try { - await channel.delete('Ticket deleted permanently'); - logger.info(`Deleted ticket channel ${channel.name} (${channel.id})`); - } catch (deleteError) { - logger.error(`Failed to delete ticket channel ${channel.id}:`, deleteError); + logger.debug('Starting ticket deletion process', { + channelId: channel.id, + ticketId: ticketData.id + }); + + // Generate transcript + let attachment = null; + try { + attachment = await generateTranscript(channel); + if (attachment) { + logger.info('Transcript generated successfully, attempting to send', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } else { + logger.warn('Transcript generation returned null', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } + } catch (transcriptError) { + logger.error('Error during transcript generation', { + channelId: channel.id, + ticketNumber: ticketData.id, + error: transcriptError.message + }); + } + + // Send transcript to configured channel if it was generated + if (attachment) { + try { + const guildConfig = await getGuildConfig(channel.client, channel.guild.id); + if (!guildConfig.ticketTranscriptChannelId) { + logger.warn('No transcript channel configured, skipping transcript send', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } else { + const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); + + if (!transcriptChannel) { + logger.error('Could not fetch transcript channel', { + channelId: channel.id, + transcriptChannelId: guildConfig.ticketTranscriptChannelId + }); + } else if (!transcriptChannel.isSendable()) { + logger.error('Transcript channel exists but is not sendable', { + channelId: channel.id, + transcriptChannelId: transcriptChannel.id + }); + } else { + // Send transcript with embed + const transcriptEmbed = new EmbedBuilder() + .setTitle('๐Ÿ“œ Ticket Transcript') + .setDescription(`Transcript for ticket #${ticketData.id}`) + .setColor('#3498db') + .addFields( + { name: 'Ticket ID', value: `\`${ticketData.id}\``, inline: true }, + { name: 'Channel', value: `#${channel.name}`, inline: true }, + { name: 'Generated', value: ``, inline: false } + ); + + if (deleter?.username) { + transcriptEmbed.setFooter({ + text: `Deleted by: ${deleter.username}`, + iconURL: deleter.displayAvatarURL?.() + }); + } + + await transcriptChannel.send({ + embeds: [transcriptEmbed], + files: [attachment] + }); + + logger.info('โœ… Transcript sent successfully', { + channelId: channel.id, + ticketNumber: ticketData.id, + transcriptChannelId: transcriptChannel.id + }); + } + } + } catch (sendError) { + logger.error('Failed to send transcript to channel:', { + channelId: channel.id, + ticketNumber: ticketData.id, + error: sendError.message + }); + } + } + + // Delete the channel (regardless of transcript success) + try { + await channel.delete('Ticket deleted permanently'); + logger.info('โœ… Channel deleted', { + channelId: channel.id, + channelName: channel.name, + ticketNumber: ticketData.id + }); + } catch (deleteError) { + logger.error('โŒ Failed to delete ticket channel:', { + channelId: channel.id, + channelName: channel.name, + ticketNumber: ticketData.id, + errorMessage: deleteError.message, + errorCode: deleteError.code, + errorName: deleteError.name + }); + } + } catch (error) { + logger.error('โŒ Unexpected error during ticket deletion:', { + channelId: channel.id, + channelName: channel?.name, + ticketNumber: ticketData?.id, + errorMessage: error.message, + errorName: error.name, + errorStack: error.stack + }); } }, TICKET_DELETE_DELAY_MS); @@ -810,10 +1055,10 @@ export async function unclaimTicket(channel, unclaimer) { .setStyle(ButtonStyle.Primary) .setEmoji('๐Ÿ™‹'), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ diff --git a/src/utils/abuseProtection.js b/src/utils/abuseProtection.js index 0e5f7237bb..a6786daff9 100644 --- a/src/utils/abuseProtection.js +++ b/src/utils/abuseProtection.js @@ -32,7 +32,7 @@ const RISKY_COMMAND_NAMES = new Set([ 'lock', 'unlock', 'ticket', - 'rsetup' + 'reactroles' ]); const blockedAttemptStore = new Map(); diff --git a/src/utils/database.js b/src/utils/database.js index 50ab53a2ae..f59c240f07 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -630,7 +630,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND data->>'userId' = $2 - AND COALESCE(data->>'status', 'open') = 'open'`, + AND data->>'status' = 'open'`, [guildId, userId] ); @@ -1338,6 +1338,59 @@ export async function saveApplicationSettings(client, guildId, settings) { } } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Per-Application Settings (Questions & Log Channel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function getApplicationRoleSettingsKey(guildId, roleId) { + return `guild:${guildId}:applications:role:${roleId}:settings`; +} + +export async function getApplicationRoleSettings(client, guildId, roleId) { + try { + if (!client.db || typeof client.db.get !== "function") { + return { questions: null, logChannelId: null }; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + const settings = await client.db.get(key, {}); + return unwrapReplitData(settings) || { questions: null, logChannelId: null }; + } catch (error) { + logger.error(`Error getting application role settings for ${guildId}:${roleId}:`, error); + return { questions: null, logChannelId: null }; + } +} + +export async function saveApplicationRoleSettings(client, guildId, roleId, settings) { + try { + if (!client.db || typeof client.db.set !== "function") { + logger.error("Database client is not available for saveApplicationRoleSettings."); + return false; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + await client.db.set(key, settings); + return true; + } catch (error) { + logger.error(`Error saving application role settings for ${guildId}:${roleId}:`, error); + return false; + } +} + +export async function deleteApplicationRoleSettings(client, guildId, roleId) { + try { + if (!client.db || typeof client.db.delete !== "function") { + logger.error("Database client is not available for deleteApplicationRoleSettings."); + return false; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + await client.db.delete(key); + return true; + } catch (error) { + logger.error(`Error deleting application role settings for ${guildId}:${roleId}:`, error); + return false; + } +} + diff --git a/src/utils/interactionValidator.js b/src/utils/interactionValidator.js new file mode 100644 index 0000000000..e46470a204 --- /dev/null +++ b/src/utils/interactionValidator.js @@ -0,0 +1,158 @@ +/** + * Interaction Validator & Recovery System + * + * Prevents and handles "Unknown Interaction" (error 10062) errors + * by validating interaction state before responding and gracefully + * handling expired interactions. + */ + +import { logger } from './logger.js'; + +// Error code for expired/unknown interactions +const EXPIRED_INTERACTION_CODE = 10062; +const INTERACTION_NOT_REPLIED_CODE = 40060; + +/** + * Check if an interaction is still valid and can be responded to + * @param {Interaction} interaction - The interaction to validate + * @returns {boolean} True if the interaction can be responded to + */ +export function isInteractionValid(interaction) { + if (!interaction || !interaction.id || !interaction.token) { + return false; + } + + // Check if interaction is already handled + if (interaction.deferred || interaction.replied) { + return true; // Can still edit + } + + // Check timestamp - interactions expire after ~3 seconds without a response + const ageMs = Date.now() - interaction.createdTimestamp; + if (ageMs > 2800) { // 2.8 seconds buffer for safety + return false; + } + + return true; +} + +/** + * Safely defer an interaction with error recovery + * @param {Interaction} interaction - The interaction to defer + * @param {Object} options - Defer options + * @returns {Promise} True if deferral was successful + */ +export async function safeDeferInteraction(interaction, options = {}) { + try { + if (!isInteractionValid(interaction)) { + logger.warn('Interaction expired before deferral', { + event: 'interaction.expired_before_defer', + interactionId: interaction?.id, + age: Date.now() - (interaction?.createdTimestamp || 0) + }); + return false; + } + + if (interaction.deferred) { + return true; + } + + await interaction.deferUpdate(options); + return true; + } catch (error) { + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + logger.warn('Interaction expired during deferral', { + event: 'interaction.expired_during_defer', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id + }); + return false; + } + throw error; + } +} + +/** + * Safely show a modal on an interaction with error recovery + * @param {Interaction} interaction - The interaction to show modal on + * @param {Modal} modal - The modal to display + * @returns {Promise} True if modal was successfully shown + */ +export async function safeShowModal(interaction, modal) { + try { + if (!isInteractionValid(interaction)) { + logger.warn('Interaction expired before modal show', { + event: 'interaction.expired_before_modal', + interactionId: interaction?.id, + modalId: modal?.data?.custom_id + }); + return false; + } + + if (interaction.deferred || interaction.replied) { + logger.warn('Attempted to show modal on already-responded interaction', { + event: 'interaction.already_responded_modal', + customId: interaction?.customId + }); + return false; + } + + await interaction.showModal(modal); + return true; + } catch (error) { + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + logger.warn('Interaction expired during modal show', { + event: 'interaction.expired_during_modal', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id + }); + return false; + } + throw error; + } +} + +/** + * Wrapper for interaction handlers to catch expired interactions silently + * @param {Function} handler - The handler function + * @returns {Function} Wrapped handler that catches expired interactions + */ +export function withExpiredInteractionHandler(handler) { + return async (...args) => { + try { + return await handler(...args); + } catch (error) { + // Check if it's an expired interaction error + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + const interaction = args.find(arg => + arg && typeof arg === 'object' && (arg.id && arg.token) + ); + + logger.warn('Handler failed due to expired interaction', { + event: 'interaction.handler_expired', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id, + handlerName: handler.name || 'anonymous' + }); + + // Silently return instead of crashing + return null; + } + + // Re-throw non-expired-interaction errors + throw error; + } + }; +} + +export default { + isInteractionValid, + safeDeferInteraction, + safeShowModal, + withExpiredInteractionHandler, + EXPIRED_INTERACTION_CODE, + INTERACTION_NOT_REPLIED_CODE +}; diff --git a/src/utils/loggingUi.js b/src/utils/loggingUi.js index d460194071..5355ae7d3a 100644 --- a/src/utils/loggingUi.js +++ b/src/utils/loggingUi.js @@ -10,6 +10,61 @@ const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, return accumulator; }, {}); +const DASHBOARD_CATEGORY_EMOJIS = { + moderation: '๐Ÿ”จ', + ticket: '๐ŸŽซ', + message: 'โœ‰๏ธ', + role: '๐Ÿท๏ธ', + member: '๐Ÿ‘ฅ', + leveling: '๐Ÿ“ˆ', + reactionrole: '๐ŸŽญ', + giveaway: '๐ŸŽ', + counter: '๐Ÿ“Š', +}; + +function createDashboardCategoryButtons(enabledEvents = {}, loggingEnabled = false) { + const categories = ['moderation', 'ticket', 'message', 'role', 'member', 'leveling', 'reactionrole', 'giveaway', 'counter']; + const buttons = categories.map((category) => { + const wildcardDisabled = enabledEvents[`${category}.*`] === false; + const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; + const allEnabled = categoryEvents.length === 0 + ? true + : categoryEvents.every((t) => enabledEvents[t] !== false); + const isEnabled = loggingEnabled && !wildcardDisabled && allEnabled; + const emoji = DASHBOARD_CATEGORY_EMOJIS[category] || '๐Ÿ“Œ'; + const label = `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`; + return new ButtonBuilder() + .setCustomId(`log_dash_toggle:${category}.*`) + .setLabel(label) + .setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Danger); + }); + + const rows = []; + for (let i = 0; i < buttons.length; i += 5) { + rows.push(new ActionRowBuilder().addComponents(buttons.slice(i, i + 5))); + } + return rows; +} + +export function createLoggingDashboardComponents(enabledEvents, loggingEnabled = false) { + const categoryRows = createDashboardCategoryButtons(enabledEvents, loggingEnabled); + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('log_dash_toggle:audit_enabled') + .setLabel(loggingEnabled ? '๐Ÿงพ Audit: ON' : '๐Ÿงพ Audit: OFF') + .setStyle(loggingEnabled ? ButtonStyle.Success : ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('log_dash_toggle:all') + .setLabel('Toggle All') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('log_dash_refresh') + .setLabel('๐Ÿ”„ Refresh') + .setStyle(ButtonStyle.Primary), + ); + return [...categoryRows, actionRow]; +} + diff --git a/src/utils/ticketLogging.js b/src/utils/ticketLogging.js index 3521bf5d40..5df01b1e44 100644 --- a/src/utils/ticketLogging.js +++ b/src/utils/ticketLogging.js @@ -29,19 +29,6 @@ export async function logTicketEvent({ client, guildId, event }) { } const config = await getGuildConfig(client, guildId); - - const unifiedEventType = mapTicketEventType(event.type); - if (config.logging?.enabled === false) { - return; - } - if (unifiedEventType) { - if (config.logging?.enabledEvents?.[unifiedEventType] === false) { - return; - } - if (config.logging?.enabledEvents?.['ticket.*'] === false) { - return; - } - } const logChannelId = getLogChannelForEventType(config, event.type); if (!logChannelId) { @@ -83,24 +70,20 @@ export async function logTicketEvent({ client, guildId, event }) { function getLogChannelForEventType(config, eventType) { - const ticketLogging = config.ticketLogging || {}; - switch (eventType) { case 'transcript': - return ticketLogging.transcriptChannelId || config.logChannelId; - + return config.ticketTranscriptChannelId || null; + case 'open': case 'close': case 'delete': - return ticketLogging.lifecycleChannelId || config.logChannelId; - case 'claim': case 'unclaim': case 'priority': - return ticketLogging.lifecycleChannelId || config.logChannelId; - + return config.ticketLogsChannelId || null; + default: - return config.logChannelId; + return null; } } @@ -292,10 +275,9 @@ function getEventDisplayInfo(event) { export async function getTicketLoggingConfig(client, guildId) { const config = await getGuildConfig(client, guildId); return { - enabled: !!(config.ticketLogging?.lifecycleChannelId || config.ticketLogging?.transcriptChannelId), - lifecycleChannelId: config.ticketLogging?.lifecycleChannelId || null, - transcriptChannelId: config.ticketLogging?.transcriptChannelId || null, - fallbackChannelId: config.logChannelId || null + enabled: !!(config.ticketLogsChannelId || config.ticketTranscriptChannelId), + lifecycleChannelId: config.ticketLogsChannelId || null, + transcriptChannelId: config.ticketTranscriptChannelId || null, }; }