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/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js index 8baf355914..81662b4cc4 100644 --- a/src/commands/Birthday/modules/birthday_list.js +++ b/src/commands/Birthday/modules/birthday_list.js @@ -1,6 +1,7 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getAllBirthdays } from '../../../services/birthdayService.js'; +import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; @@ -30,22 +31,52 @@ export default { color: 'info' }); - let birthdayList = `**${sortedBirthdays.length} birthdays in ${interaction.guild.name}**\n\n`; - sortedBirthdays.forEach((birthday, index) => { - const member = interaction.guild.members.cache.get(birthday.userId); - const userName = member ? member.user.username : `User ${birthday.userId}`; - birthdayList += `${index + 1}. **${userName}** - ${birthday.monthName} ${birthday.day}\n`; - }); + // Batch fetch to verify which users are still in the guild + const userIds = sortedBirthdays.map(b => b.userId); + const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); + + let birthdayList = ''; + let displayIndex = 0; + const staleUserIds = []; + + for (const birthday of sortedBirthdays) { + if (fetchedMembers && !fetchedMembers.has(birthday.userId)) { + staleUserIds.push(birthday.userId); + continue; + } + displayIndex++; + birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; + } + + // Clean up birthday entries for members who left the server + if (fetchedMembers && staleUserIds.length > 0) { + for (const userId of staleUserIds) { + deleteBirthday(client, guildId, userId).catch(() => null); + } + } + + if (displayIndex === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'โŒ No Birthdays', + description: 'No birthdays have been set by current server members.', + color: 'error' + })] + }); + } + + birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - embed.setDescription(birthdayList || "No birthdays found"); - embed.setFooter({ text: `Total: ${sortedBirthdays.length} birthdays` }); + embed.setDescription(birthdayList); + embed.setFooter({ text: `Total: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}` }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Birthday list retrieved successfully', { userId: interaction.user.id, guildId, - birthdayCount: sortedBirthdays.length, + birthdayCount: displayIndex, + staleRemoved: staleUserIds.length, commandName: 'birthday_list' }); } catch (error) { diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js index 73bba8969b..778d392e22 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.js @@ -1,6 +1,7 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; +import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; @@ -31,11 +32,15 @@ export default { color: 'info' }); - for (let i = 0; i < next5.length; i++) { - const birthday = next5[i]; + let displayIndex = 0; + for (const birthday of next5) { const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - const userName = member ? member.user.username : `User ${birthday.userId}`; - + if (!member) { + deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); + continue; + } + displayIndex++; + let timeUntil = ''; if (birthday.daysUntil === 0) { timeUntil = '๐ŸŽ‰ **Today!**'; @@ -46,12 +51,24 @@ export default { } embed.addFields({ - name: `${i + 1}. ${userName}`, - value: `๐Ÿ“… **Date:** ${birthday.monthName} ${birthday.day}\nโฐ **Time:** ${timeUntil}`, + name: `${displayIndex}. ${member.displayName}`, + value: `<@${birthday.userId}>\n๐Ÿ“… **Date:** ${birthday.monthName} ${birthday.day}\nโฐ **Time:** ${timeUntil}`, inline: false }); } + if (displayIndex === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'โŒ No Upcoming Birthdays', + description: 'No upcoming birthdays found for current server members.', + color: 'error' + }) + ] + }); + } + embed.setFooter({ text: 'Use /birthday set to add your birthday!', iconURL: interaction.guild.iconURL() @@ -62,7 +79,7 @@ export default { logger.info('Next birthdays retrieved successfully', { userId: interaction.user.id, guildId: interaction.guildId, - upcomingCount: next5.length, + upcomingCount: displayIndex, commandName: 'next_birthdays' }); } catch (error) { diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js index 9048d6ea58..d967116901 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.js @@ -1,8 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; - -import { getEconomyKey } from '../../utils/database.js'; -import { botConfig } from '../../config/bot.js'; +import { createEmbed } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -11,17 +8,6 @@ export default { data: new SlashCommandBuilder() .setName("eleaderboard") .setDescription("View the server's top 10 richest users.") - .addStringOption((option) => - option - .setName("sort_by") - .setDescription("The metric to sort the leaderboard by.") - .addChoices( - { name: "Net Worth (Cash + Bank)", value: "net_worth" }, - { name: "Cash", value: "cash" }, - { name: "Bank", value: "bank" }, - ) - .setRequired(false), - ) .setDMPermission(false), @@ -30,11 +16,10 @@ export default { if (!deferred) return; const guildId = interaction.guildId; - const sortBy = interaction.options.getString("sort_by") || "net_worth"; - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId, sortBy }); + logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - const prefix = `guild:${guildId}:economy:`; + const prefix = `economy:${guildId}:`; let allKeys = await client.db.list(prefix); @@ -59,19 +44,12 @@ export default { if (userData) { allUserData.push({ userId: userId, - cash: userData.wallet || 0, - bank: userData.bank || 0, net_worth: (userData.wallet || 0) + (userData.bank || 0), }); } } - allUserData.sort((a, b) => { - if (sortBy === "net_worth") return b.net_worth - a.net_worth; - if (sortBy === "cash") return b.cash - a.cash; - if (sortBy === "bank") return b.bank - a.bank; -return b.net_worth - a.net_worth; - }); + allUserData.sort((a, b) => b.net_worth - a.net_worth); const topUsers = allUserData.slice(0, 10); const userRank = @@ -82,44 +60,28 @@ return b.net_worth - a.net_worth; for (let i = 0; i < topUsers.length; i++) { const user = topUsers[i]; - const member = await interaction.guild.members - .fetch(user.userId) - .catch(() => null); - const username = member - ? member.user.username - : `Unknown User (${user.userId})`; const rank = i + 1; const emoji = rankEmoji[i] || `**#${rank}**`; - const value = - sortBy === "net_worth" - ? user.net_worth - : sortBy === "cash" - ? user.cash - : user.bank; leaderboardEntries.push( - `${emoji} **${username}** - $${value.toLocaleString()}`, + `${emoji} <@${user.userId}> - ๐Ÿฆ ${user.net_worth.toLocaleString()}`, ); } - const fieldNameMap = { - net_worth: "Net Worth (Cash + Bank)", - cash: "Cash Balance", - bank: "Bank Balance", - }; - logger.info(`[ECONOMY] Leaderboard generated`, { guildId, - sortBy, userCount: allUserData.length, userRank }); - const embed = createEmbed( - `๐Ÿ‘‘ Economy Leaderboard (${fieldNameMap[sortBy]})`, - leaderboardEntries.join("\n"), - ).setFooter({ - text: `Your Rank: ${userRank > 0 ? userRank : "Not Ranked"} | Data sorted by ${fieldNameMap[sortBy]}`, + const description = leaderboardEntries.length > 0 + ? leaderboardEntries.join("\n") + : "No economy data is available for this server yet."; + + const embed = createEmbed({ + title: `Economy Leaderboard`, + description, + footer: `Your Rank: ${userRank > 0 ? `#${userRank}` : "No ranking data available"}`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js new file mode 100644 index 0000000000..95e056573a --- /dev/null +++ b/src/commands/Tools/embedbuilder.js @@ -0,0 +1,1178 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ChannelSelectMenuBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, + ChannelType, + 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 { getColor } from '../../config/bot.js'; + +// โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const MAX_FIELDS = 25; +const IDLE_TIMEOUT = 900_000; // 15 minutes + +const COLOR_PRESETS = [ + { label: 'Primary (Blue)', value: '#336699', emoji: '๐Ÿ”ต' }, + { label: 'Success (Green)', value: '#57F287', emoji: '๐ŸŸข' }, + { label: 'Error (Red)', value: '#ED4245', emoji: '๐Ÿ”ด' }, + { label: 'Warning (Yellow)', value: '#FEE75C', emoji: '๐ŸŸก' }, + { label: 'Info (Bright Blue)', value: '#3498DB', emoji: '๐Ÿ’™' }, + { label: 'Blurple (Discord)', value: '#5865F2', emoji: '๐ŸŸฃ' }, + { label: 'Fuchsia', value: '#EB459E', emoji: '๐Ÿ’œ' }, + { label: 'Gold', value: '#F1C40F', emoji: '๐ŸŸ ' }, + { label: 'White', value: '#FFFFFF', emoji: 'โšช' }, + { label: 'Dark', value: '#202225', emoji: 'โšซ' }, + { label: 'Custom Hex...', value: '__custom__', emoji: '๐ŸŽจ' }, +]; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function isValidUrl(str) { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function isValidHex(str) { + return /^#[0-9A-Fa-f]{6}$/.test(str); +} + +// โ”€โ”€โ”€ Embed Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Builds the live preview embed from current state. + */ +function buildPreviewEmbed(state) { + const embed = new EmbedBuilder(); + + if (state.title) embed.setTitle(state.title.substring(0, 256)); + if (state.description) embed.setDescription(state.description.substring(0, 4096)); + + try { + embed.setColor(state.color || getColor('primary')); + } catch { + embed.setColor(getColor('primary')); + } + + if (state.author?.name) { + const obj = { name: state.author.name.substring(0, 256) }; + if (state.author.iconUrl && isValidUrl(state.author.iconUrl)) obj.iconURL = state.author.iconUrl; + if (state.author.url && isValidUrl(state.author.url)) obj.url = state.author.url; + embed.setAuthor(obj); + } + + if (state.footer?.text) { + const obj = { text: state.footer.text.substring(0, 2048) }; + if (state.footer.iconUrl && isValidUrl(state.footer.iconUrl)) obj.iconURL = state.footer.iconUrl; + embed.setFooter(obj); + } + + if (state.thumbnail && isValidUrl(state.thumbnail)) embed.setThumbnail(state.thumbnail); + if (state.image && isValidUrl(state.image)) embed.setImage(state.image); + if (state.timestamp) embed.setTimestamp(); + + if (state.fields.length > 0) embed.addFields(state.fields.slice(0, 25)); + + // Ensure the embed renders if completely empty + if ( + !state.title && + !state.description && + state.fields.length === 0 && + !state.author?.name + ) { + embed.setDescription('*(Empty โ€” use the menu below to add content)*'); + } + + return embed; +} + +/** + * Builds the status/control dashboard embed (shown below the preview). + */ +function buildDashboardEmbed(state) { + const trunc = (str, n) => + str.length > n ? str.substring(0, n) + 'โ€ฆ' : str; + + const lines = [ + `**Title** โ€บ ${state.title ? `\`${trunc(state.title, 40)}\`` : '`Not set`'}`, + `**Description** โ€บ ${state.description ? `${state.description.length} character(s)` : '`Not set`'}`, + `**Color** โ€บ ${state.color ? `\`${state.color}\`` : '`Default`'}`, + `**Author** โ€บ ${state.author?.name ? `\`${trunc(state.author.name, 30)}\`` : '`Not set`'}`, + `**Footer** โ€บ ${state.footer?.text ? `\`${trunc(state.footer.text, 30)}\`` : '`Not set`'}`, + `**Thumbnail** โ€บ ${state.thumbnail ? 'โœ… Set' : '`Not set`'}`, + `**Image** โ€บ ${state.image ? 'โœ… Set' : '`Not set`'}`, + `**Timestamp** โ€บ ${state.timestamp ? 'โœ… Enabled' : '`Disabled`'}`, + `**Fields** โ€บ ${state.fields.length} / ${MAX_FIELDS}`, + ]; + + return new EmbedBuilder() + .setTitle('๐Ÿ› ๏ธ Embed Builder โ€” Control Panel') + .setDescription(lines.join('\n')) + .setColor(getColor('info')) + .setFooter({ text: 'The preview above updates live ยท Closes after 5 min of inactivity' }); +} + +/** + * Builds the main action select menu. + */ +function buildMainMenu(state) { + const select = new StringSelectMenuBuilder() + .setCustomId('eb_menu') + .setPlaceholder('Choose an action...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Content') + .setDescription('Set the title and description') + .setValue('edit_content') + .setEmoji('โœ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Color') + .setDescription('Pick a preset or enter a custom hex code') + .setValue('set_color') + .setEmoji('๐ŸŽจ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Author') + .setDescription('Configure the author block at the top of the embed') + .setValue('set_author') + .setEmoji('๐Ÿ‘ค'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Footer') + .setDescription('Configure the footer text and icon') + .setValue('set_footer') + .setEmoji('๐Ÿ“„'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Images') + .setDescription('Set the thumbnail or large banner image') + .setValue('set_images') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) + .setDescription('Add a new inline or block field') + .setValue('add_field') + .setEmoji('โž•'), + ); + + if (state.fields.length > 0) { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Field') + .setDescription('Modify the name, value, or inline setting of a field') + .setValue('edit_field') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Remove Field') + .setDescription('Delete a field from the embed') + .setValue('remove_field') + .setEmoji('โž–'), + ); + + if (state.fields.length >= 2) { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Reorder Fields') + .setDescription('Move a field up or down in the list') + .setValue('reorder_fields') + .setEmoji('โ†•๏ธ'), + ); + } + } + + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(state.timestamp ? 'Disable Timestamp' : 'Enable Timestamp') + .setDescription('Toggle the automatic timestamp in the footer') + .setValue('toggle_timestamp') + .setEmoji('๐Ÿ•'), + new StringSelectMenuOptionBuilder() + .setLabel('Post Embed') + .setDescription('Send the finished embed to a channel') + .setValue('post_embed') + .setEmoji('๐Ÿ“ค'), + new StringSelectMenuOptionBuilder() + .setLabel('JSON / Raw Data') + .setDescription('View the raw JSON for this embed') + .setValue('json_export') + .setEmoji('๐Ÿ“‹'), + new StringSelectMenuOptionBuilder() + .setLabel('Reset Everything') + .setDescription('Clear all fields and start over') + .setValue('reset_all') + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + return select; +} + +/** + * Updates the dashboard message with the latest state. + */ +async function refreshDashboard(interaction, state) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [buildPreviewEmbed(state), buildDashboardEmbed(state)], + components: [new ActionRowBuilder().addComponents(buildMainMenu(state))], + }); +} + +// โ”€โ”€โ”€ Option Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleEditContent(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_content') + .setTitle('Edit Content') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('eb_title') + .setLabel('Title (max 256 characters)') + .setStyle(TextInputStyle.Short) + .setValue(state.title || '') + .setMaxLength(256) + .setRequired(false) + .setPlaceholder('My Embed Title'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('eb_description') + .setLabel('Description (max 4000 characters)') + .setStyle(TextInputStyle.Paragraph) + .setValue(state.description ? state.description.substring(0, 4000) : '') + .setMaxLength(4000) + .setRequired(false) + .setPlaceholder('Write your embed description here...'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_content' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + // Defer immediately to avoid interaction timeout + await submitted.deferUpdate().catch(() => {}); + + state.title = submitted.fields.getTextInputValue('eb_title').trim() || null; + state.description = submitted.fields.getTextInputValue('eb_description').trim() || null; + + await refreshDashboard(rootInteraction, state); +} + +async function handleSetColor(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate().catch(() => {}); + + const colorSelect = new StringSelectMenuBuilder() + .setCustomId('eb_color_pick') + .setPlaceholder('Choose a color...') + .addOptions( + COLOR_PRESETS.map(c => + new StringSelectMenuOptionBuilder() + .setLabel(c.label) + .setValue(c.value) + .setEmoji(c.emoji) + .setDescription(c.value !== '__custom__' ? c.value : 'Enter your own #RRGGBB value'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŽจ Set Color') + .setDescription( + 'Select a preset color or choose **Custom Hex** to enter your own `#RRGGBB` value.', + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(colorSelect)], + flags: MessageFlags.Ephemeral, + }); + + const colorCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_color_pick', + time: 60_000, + max: 1, + }); + + colorCollector.on('collect', async colorInter => { + const picked = colorInter.values[0]; + + if (picked === '__custom__') { + const hexModal = new ModalBuilder() + .setCustomId('eb_custom_hex') + .setTitle('Custom Color') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('hex_value') + .setLabel('Hex Color Code') + .setStyle(TextInputStyle.Short) + .setPlaceholder('#5865F2') + .setMaxLength(7) + .setMinLength(7) + .setRequired(true), + ), + ); + + await colorInter.showModal(hexModal); + + const hexSubmit = await colorInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_custom_hex' && i.user.id === colorInter.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!hexSubmit) return; + + const hex = hexSubmit.fields.getTextInputValue('hex_value').trim(); + if (!isValidHex(hex)) { + await hexSubmit.reply({ + embeds: [ + errorEmbed( + 'Invalid Hex', + `\`${hex}\` is not a valid hex color. Use the format \`#RRGGBB\` (e.g. \`#5865F2\`).`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.color = hex; + await hexSubmit.deferUpdate().catch(() => {}); + } else { + state.color = picked; + await colorInter.deferUpdate().catch(() => {}); + } + + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleSetAuthor(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_author') + .setTitle('Set Author') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_name') + .setLabel('Author Name (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.name || '') + .setMaxLength(256) + .setRequired(false) + .setPlaceholder('Your Name'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_icon') + .setLabel('Author Icon URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.iconUrl || '') + .setRequired(false) + .setPlaceholder('https://example.com/icon.png'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_url') + .setLabel('Author Link URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.url || '') + .setRequired(false) + .setPlaceholder('https://example.com'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_author' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('author_name').trim(); + const iconUrl = submitted.fields.getTextInputValue('author_icon').trim(); + const url = submitted.fields.getTextInputValue('author_url').trim(); + + if (iconUrl && !isValidUrl(iconUrl)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Author icon URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (url && !isValidUrl(url)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Author link URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.author = name ? { name, iconUrl: iconUrl || null, url: url || null } : null; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleSetFooter(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_footer') + .setTitle('Set Footer') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('footer_text') + .setLabel('Footer Text (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(state.footer?.text || '') + .setMaxLength(2048) + .setRequired(false) + .setPlaceholder('Built with TitanBot'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('footer_icon') + .setLabel('Footer Icon URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.footer?.iconUrl || '') + .setRequired(false) + .setPlaceholder('https://example.com/icon.png'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_footer' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const text = submitted.fields.getTextInputValue('footer_text').trim(); + const iconUrl = submitted.fields.getTextInputValue('footer_icon').trim(); + + if (iconUrl && !isValidUrl(iconUrl)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Footer icon URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.footer = text ? { text, iconUrl: iconUrl || null } : null; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleSetImages(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate().catch(() => {}); + + const imageSelect = new StringSelectMenuBuilder() + .setCustomId('eb_image_pick') + .setPlaceholder('What would you like to change?') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Set Thumbnail') + .setDescription('Small image displayed in the top-right corner') + .setValue('set_thumbnail') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Large Image') + .setDescription('Full-width banner image at the bottom') + .setValue('set_image') + .setEmoji('๐Ÿ“ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Clear Thumbnail') + .setDescription('Remove the current thumbnail') + .setValue('clear_thumbnail') + .setEmoji('๐Ÿ—‘๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Clear Large Image') + .setDescription('Remove the current large image') + .setValue('clear_image') + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ–ผ๏ธ Set Images') + .setDescription('Choose which image to set or remove.') + .addFields( + { name: 'Thumbnail', value: state.thumbnail ? `[View](${state.thumbnail})` : '`Not set`', inline: true }, + { name: 'Large Image', value: state.image ? `[View](${state.image})` : '`Not set`', inline: true }, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(imageSelect)], + flags: MessageFlags.Ephemeral, + }); + + const imgMenuCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_image_pick', + time: 60_000, + max: 1, + }); + + imgMenuCollector.on('collect', async imgInter => { + const pick = imgInter.values[0]; + + if (pick === 'clear_thumbnail') { + state.thumbnail = null; + await imgInter.deferUpdate(); + await refreshDashboard(rootInteraction, state); + return; + } + if (pick === 'clear_image') { + state.image = null; + await imgInter.deferUpdate(); + await refreshDashboard(rootInteraction, state); + return; + } + + const isThumb = pick === 'set_thumbnail'; + + const urlModal = new ModalBuilder() + .setCustomId('eb_image_url') + .setTitle(isThumb ? 'Set Thumbnail' : 'Set Large Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_url') + .setLabel('Image URL') + .setStyle(TextInputStyle.Short) + .setValue(isThumb ? (state.thumbnail || '') : (state.image || '')) + .setRequired(true) + .setPlaceholder('https://example.com/image.png'), + ), + ); + + await imgInter.showModal(urlModal); + + const submitted = await imgInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_image_url' && i.user.id === imgInter.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!submitted) return; + + const url = submitted.fields.getTextInputValue('image_url').trim(); + if (!isValidUrl(url)) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid URL', 'Image URL must be a valid `https://` link to a publicly accessible image.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (isThumb) state.thumbnail = url; + else state.image = url; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleAddField(selectInteraction, rootInteraction, state) { + if (state.fields.length >= MAX_FIELDS) { + await selectInteraction.deferUpdate(); + await selectInteraction.followUp({ + embeds: [errorEmbed('Fields Full', `Embeds can have a maximum of ${MAX_FIELDS} fields.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId('eb_add_field') + .setTitle('Add Field') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_name') + .setLabel('Field Name (max 256 characters)') + .setStyle(TextInputStyle.Short) + .setMaxLength(256) + .setRequired(true) + .setPlaceholder('Field Title'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_value') + .setLabel('Field Value (max 1024 characters)') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1024) + .setRequired(true) + .setPlaceholder('Field content goes here...'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_inline') + .setLabel('Inline? Type "yes" to place side-by-side') + .setStyle(TextInputStyle.Short) + .setValue('no') + .setMaxLength(3) + .setRequired(false) + .setPlaceholder('yes or no'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_add_field' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('field_name').trim(); + const value = submitted.fields.getTextInputValue('field_value').trim(); + const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); + const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + + state.fields.push({ name, value, inline }); + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleEditField(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_edit_field_pick') + .setPlaceholder('Select a field to edit...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 80)}${f.value.length > 80 ? 'โ€ฆ' : ''} ยท ${f.inline ? 'Inline' : 'Block'}`, + ) + .setValue(String(i)) + .setEmoji('๐Ÿ“'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ Edit Field') + .setDescription('Select the field you want to modify.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_edit_field_pick', + time: 60_000, + max: 1, + }); + + pickCollector.on('collect', async pickInter => { + const idx = parseInt(pickInter.values[0], 10); + const field = state.fields[idx]; + if (!field) { await pickInter.deferUpdate(); return; } + + const modal = new ModalBuilder() + .setCustomId('eb_edit_field_modal') + .setTitle(`Edit Field ${idx + 1}`) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_name') + .setLabel('Field Name') + .setStyle(TextInputStyle.Short) + .setValue(field.name) + .setMaxLength(256) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_value') + .setLabel('Field Value') + .setStyle(TextInputStyle.Paragraph) + .setValue(field.value.substring(0, 4000)) + .setMaxLength(1024) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_inline') + .setLabel('Inline? (yes / no)') + .setStyle(TextInputStyle.Short) + .setValue(field.inline ? 'yes' : 'no') + .setMaxLength(3) + .setRequired(false), + ), + ); + + await pickInter.showModal(modal); + + const submitted = await pickInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_edit_field_modal' && i.user.id === pickInter.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('field_name').trim(); + const value = submitted.fields.getTextInputValue('field_value').trim(); + const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); + const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + + state.fields[idx] = { name, value, inline }; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleRemoveField(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_remove_field_pick') + .setPlaceholder('Select a field to remove...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 90)}${f.value.length > 90 ? 'โ€ฆ' : ''}`, + ) + .setValue(String(i)) + .setEmoji('โž–'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Field') + .setDescription('Select the field you want to delete.') + .setColor(getColor('warning')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_remove_field_pick', + time: 60_000, + max: 1, + }); + + removeCollector.on('collect', async removeInter => { + await removeInter.deferUpdate(); + const idx = parseInt(removeInter.values[0], 10); + state.fields.splice(idx, 1); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleReorderFields(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_reorder_pick') + .setPlaceholder('Select a field to move...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 90)}${f.value.length > 90 ? 'โ€ฆ' : ''}`, + ) + .setValue(String(i)) + .setEmoji('โ†•๏ธ'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โ†•๏ธ Reorder Fields') + .setDescription('Select a field, then use the arrows to move it up or down.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_reorder_pick', + time: 60_000, + max: 1, + }); + + pickCollector.on('collect', async pickInter => { + await pickInter.deferUpdate(); + const sourceIdx = parseInt(pickInter.values[0], 10); + + const upBtn = new ButtonBuilder() + .setCustomId('eb_reorder_up') + .setLabel('Move Up') + .setStyle(ButtonStyle.Primary) + .setEmoji('โฌ†๏ธ') + .setDisabled(sourceIdx === 0); + + const downBtn = new ButtonBuilder() + .setCustomId('eb_reorder_down') + .setLabel('Move Down') + .setStyle(ButtonStyle.Primary) + .setEmoji('โฌ‡๏ธ') + .setDisabled(sourceIdx === state.fields.length - 1); + + const cancelBtn = new ButtonBuilder() + .setCustomId('eb_reorder_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + await pickInter.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โ†•๏ธ Move Field') + .setDescription( + `Moving **${state.fields[sourceIdx].name}** โ€” currently at position **${sourceIdx + 1}** of **${state.fields.length}**.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(upBtn, downBtn, cancelBtn)], + flags: MessageFlags.Ephemeral, + }); + + const dirCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === selectInteraction.user.id && + ['eb_reorder_up', 'eb_reorder_down', 'eb_reorder_cancel'].includes(i.customId), + time: 30_000, + max: 1, + }); + + dirCollector.on('collect', async dirInter => { + await dirInter.deferUpdate(); + if (dirInter.customId === 'eb_reorder_cancel') return; + + const targetIdx = + dirInter.customId === 'eb_reorder_up' ? sourceIdx - 1 : sourceIdx + 1; + + if (targetIdx < 0 || targetIdx >= state.fields.length) return; + + const temp = state.fields[sourceIdx]; + state.fields[sourceIdx] = state.fields[targetIdx]; + state.fields[targetIdx] = temp; + + await refreshDashboard(rootInteraction, state); + }); + }); +} + +async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) { + if ( + !state.title && + !state.description && + state.fields.length === 0 && + !state.author?.name + ) { + await selectInteraction.deferUpdate(); + await selectInteraction.followUp({ + embeds: [ + errorEmbed( + 'Empty Embed', + 'Add at least a title, description, or field before posting.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + await selectInteraction.deferUpdate(); + + const chanSelect = new ChannelSelectMenuBuilder() + .setCustomId('eb_post_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ค Post Embed') + .setDescription('Select the channel where this embed will be sent.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(chanSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_post_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInter => { + await chanInter.deferUpdate(); + const channel = chanInter.channels.first(); + + if (!channel) { + await chanInter.followUp({ + embeds: [errorEmbed('No Channel', 'Could not resolve the selected channel.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const perms = channel.permissionsFor(guild.members.me); + if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { + await chanInter.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **Send Messages** and **Embed Links** permissions in ${channel} to post there.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const finalEmbed = buildPreviewEmbed(state); + + // Remove the placeholder description before sending + if (finalEmbed.data.description === '*(Empty โ€” use the menu below to add content)*') { + finalEmbed.setDescription(null); + } + + await channel.send({ embeds: [finalEmbed] }); + + await chanInter.followUp({ + embeds: [successEmbed('โœ… Embed Sent', `Your embed has been posted to ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + }); +} + +async function handleJsonExport(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const previewEmbed = buildPreviewEmbed(state); + const json = JSON.stringify(previewEmbed.toJSON(), null, 2); + + if (json.length <= 3980) { + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Embed JSON') + .setDescription(`\`\`\`json\n${json}\n\`\`\``) + .setColor(getColor('info')), + ], + flags: MessageFlags.Ephemeral, + }); + } else { + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Embed JSON') + .setDescription('The JSON is too long to display inline โ€” see the attached file.') + .setColor(getColor('info')), + ], + files: [ + { + attachment: Buffer.from(json, 'utf-8'), + name: 'embed.json', + }, + ], + flags: MessageFlags.Ephemeral, + }); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + data: new SlashCommandBuilder() + .setName('embedbuilder') + .setDescription('Build and post a fully custom embed with live preview') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + async execute(interaction) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferSuccess) return; + + const guild = interaction.guild; + + // Builder state โ€” holds every embed property being constructed + const state = { + title: null, + description: null, + color: getColor('primary'), + author: null, + footer: null, + thumbnail: null, + image: null, + timestamp: false, + fields: [], + }; + + await refreshDashboard(interaction, state); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === 'eb_menu', + time: IDLE_TIMEOUT, + }); + + collector.on('collect', async ci => { + try { + switch (ci.values[0]) { + case 'edit_content': + await handleEditContent(ci, interaction, state); + break; + case 'set_color': + await handleSetColor(ci, interaction, state); + break; + case 'set_author': + await handleSetAuthor(ci, interaction, state); + break; + case 'set_footer': + await handleSetFooter(ci, interaction, state); + break; + case 'set_images': + await handleSetImages(ci, interaction, state); + break; + case 'add_field': + await handleAddField(ci, interaction, state); + break; + case 'edit_field': + await handleEditField(ci, interaction, state); + break; + case 'remove_field': + await handleRemoveField(ci, interaction, state); + break; + case 'reorder_fields': + await handleReorderFields(ci, interaction, state); + break; + case 'toggle_timestamp': + state.timestamp = !state.timestamp; + await ci.deferUpdate(); + await refreshDashboard(interaction, state); + break; + case 'post_embed': + await handlePostEmbed(ci, interaction, state, guild); + break; + case 'json_export': + await handleJsonExport(ci, interaction, state); + break; + case 'reset_all': + state.title = null; + state.description = null; + state.color = getColor('primary'); + state.author = null; + state.footer = null; + state.thumbnail = null; + state.image = null; + state.timestamp = false; + state.fields = []; + await ci.deferUpdate(); + await refreshDashboard(interaction, state); + break; + default: + await ci.deferUpdate(); + } + } catch (error) { + logger.error('Error in embedbuilder 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(() => {}); + } + }); + + collector.on('end', async (_, reason) => { + if (reason === 'time') { + await InteractionHelper.safeEditReply(interaction, { components: [] }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in embedbuilder:', error); + throw new TitanBotError( + `embedbuilder failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the embed builder.', + ); + } + }, +}; diff --git a/src/config/bot.js b/src/config/bot.js index b416b8df94..36e588cd42 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -2,68 +2,107 @@ import { logger } from '../utils/logger.js'; export const botConfig = { - - + // ========================= + // BOT PRESENCE (what users see under the bot name) + // ========================= + // `status` options: + // - "online" = green dot + // - "idle" = yellow moon + // - "dnd" = red do-not-disturb + // - "invisible" = appears offline presence: { + // Current online state shown on Discord. status: "online", + + // Activity lines shown under the bot name. + // `type` number mapping from Discord: + // 0 = Playing + // 1 = Streaming + // 2 = Listening + // 3 = Watching + // 4 = Custom + // 5 = Competing activities: [ { - name: "/help | Titan Bot", + // Text users will see (example: "Playing /help | Titan Bot"). + name: "Made with โค๏ธ", + // Activity type number (0 = Playing). type: 0, }, ], }, - + // ========================= + // COMMAND BEHAVIOR + // ========================= commands: { - prefix: "!", + // Bot owner user IDs (comma-separated in OWNER_IDS env var). + // Owners can access owner/admin-level bot commands. owners: process.env.OWNER_IDS?.split(",") || [], + + // Default wait time between command uses (in seconds). defaultCooldown: 3, + + // If true, old commands are removed before re-registering. deleteCommands: false, + + // Optional server ID used for testing slash commands quickly. testGuildId: process.env.TEST_GUILD_ID, }, - - prefix: "!", - - + // ========================= + // APPLICATIONS SYSTEM + // ========================= applications: { + // Default questions shown when someone fills out an application. defaultQuestions: [ { question: "What is your name?", required: true }, { question: "How old are you?", required: true }, { question: "Why do you want to join?", required: true }, ], + + // Embed colors by application status. statusColors: { pending: "#FFA500", approved: "#00FF00", denied: "#FF0000", }, + + // How long users must wait before submitting another application (hours). applicationCooldown: 24, + + // Auto-delete denied applications after this many days. deleteDeniedAfter: 7, + + // Auto-delete approved applications after this many days. deleteApprovedAfter: 30, + + // Role IDs allowed to manage applications. managerRoles: [], // Will be populated from environment or database }, - + // ========================= + // EMBED COLORS & BRANDING + // ========================= // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors embeds: { colors: { - + // Main brand colors. primary: "#336699", secondary: "#2F3136", - + // Standard status colors for success/error/warning/info messages. success: "#57F287", error: "#ED4245", warning: "#FEE75C", info: "#3498DB", - + // Neutral utility colors. light: "#FFFFFF", dark: "#202225", gray: "#99AAB5", - + // Discord-style palette shortcuts. blurple: "#5865F2", green: "#57F287", yellow: "#FEE75C", @@ -71,7 +110,7 @@ export const botConfig = { red: "#ED4245", black: "#000000", - + // Feature-specific colors. giveaway: { active: "#57F287", ended: "#ED4245", @@ -86,7 +125,7 @@ export const botConfig = { birthday: "#E91E63", moderation: "#9B59B6", - + // Ticket priority color mapping. priority: { none: "#95A5A6", low: "#3498db", @@ -96,44 +135,78 @@ export const botConfig = { }, }, footer: { + // Default footer text used in bot embeds. text: "Titan Bot", + // Footer icon URL (null = no icon). icon: null, }, + // Default thumbnail URL for embeds (null = no thumbnail). thumbnail: null, author: { + // Optional default embed author block. name: null, icon: null, url: null, }, }, - + // ========================= + // ECONOMY SETTINGS + // ========================= economy: { currency: { + // Currency display name. name: "coins", + // Plural display name. namePlural: "coins", + // Currency symbol shown in balances. symbol: "$", }, + + // Starting balance for new users. startingBalance: 0, + + // Maximum bank amount before upgrades (if upgrades are used). baseBankCapacity: 100000, + + // Daily reward amount. dailyAmount: 100, + + // Work command random payout range. workMin: 10, workMax: 100, + + // Beg command random payout range. begMin: 5, begMax: 50, + + // Chance to succeed when robbing (0.4 = 40%). robSuccessRate: 0.4, + + // Jail time after failed rob (milliseconds). + // 3600000 = 1 hour. robFailJailTime: 3600000, }, - + // ========================= + // SHOP SETTINGS + // ========================= + // Add shop defaults here when needed. shop: { }, - + // ========================= + // TICKET SYSTEM + // ========================= tickets: { + // Category ID where new tickets are created (null = no forced category). defaultCategory: null, + + // Role IDs allowed to manage/support tickets. supportRoles: [], + + // Priority options users/staff can assign. priorities: { none: { emoji: "โšช", @@ -161,109 +234,169 @@ export const botConfig = { label: "Urgent", }, }, + + // Default priority for new tickets. defaultPriority: "none", + + // Category ID where closed tickets are archived. archiveCategory: null, + + // Channel ID where ticket logs are sent. logChannel: null, }, - + // ========================= + // GIVEAWAY SETTINGS + // ========================= giveaways: { + // Default giveaway duration in milliseconds. + // 86400000 = 24 hours. defaultDuration: 86400000, + + // Allowed winner count range. minimumWinners: 1, maximumWinners: 10, + + // Allowed giveaway duration range in milliseconds. + // 300000 = 5 minutes. minimumDuration: 300000, + // 2592000000 = 30 days. maximumDuration: 2592000000, + + // Role IDs allowed to host giveaways. allowedRoles: [], + + // Role IDs that bypass giveaway restrictions. bypassRoles: [], }, - + // ========================= + // BIRTHDAY SETTINGS + // ========================= birthday: { + // Role ID given to users on their birthday. defaultRole: null, + + // Channel ID where birthday announcements are posted. announcementChannel: null, + + // Timezone used to calculate birthday dates. timezone: "UTC", }, - + // ========================= + // VERIFICATION SETTINGS + // ========================= verification: { - + // Message shown when posting the verification panel. defaultMessage: "Click the button below to verify yourself and gain access to the server!", - - + + // Text on the verification button. defaultButtonText: "Verify", - - + + // Automatic verification behavior. autoVerify: { - + // How automatic verification decides who is auto-approved: + // - "none" = everyone is auto-verified immediately + // - "account_age" = account must be older than set days + // - "server_size" = auto-verify everyone only in smaller servers defaultCriteria: "none", - + // Days used when `defaultCriteria` is `account_age`. defaultAccountAgeDays: 7, + + // Member count threshold used when `defaultCriteria` is `server_size`. + // Example: 1000 means auto-verify if server has fewer than 1000 members. serverSizeThreshold: 1000, - - + + // Allowed safety limits for account-age requirements. + // 1 = minimum day, 365 = maximum days. minAccountAge: 1, maxAccountAge: 365, - - + + // If true, user receives a DM after verification. sendDMNotification: true, - - + + // Human-readable descriptions for each criteria mode. criteria: { account_age: "Account must be older than specified days", server_size: "All users if server has less than 1000 members", none: "All users immediately" } }, - - - + + // Minimum time between verification attempts (milliseconds). + // 5000 = 5 seconds. verificationCooldown: 5000, - - + + // Maximum failed attempts allowed inside the time window below. maxVerificationAttempts: 3, + + // Time window for counting attempts (milliseconds). + // 60000 = 1 minute. attemptWindow: 60000, - + // In-memory safety limits (helps avoid unbounded memory growth). maxCooldownEntries: 10000, maxAttemptEntries: 10000, + // Cleanup frequency for cooldown/attempt maps (milliseconds). + // 300000 = 5 minutes. cooldownCleanupInterval: 300000, + // Maximum metadata payload size for audit entries (bytes). maxAuditMetadataBytes: 4096, + // Maximum number of audit entries kept in memory. maxInMemoryAuditEntries: 1000, - - - logAllVerifications: true, - keepAuditTrail: true + // If true, log every verification action. + logAllVerifications: true, + // If true, preserve verification audit history. + keepAuditTrail: true, }, - + // ========================= + // WELCOME / GOODBYE MESSAGES + // ========================= welcome: { + // Welcome template posted when a user joins. + // Placeholders: {user}, {server}, {memberCount} defaultWelcomeMessage: "Welcome {user} to {server}! We now have {memberCount} members!", + // Goodbye template posted when a user leaves. + // Placeholders: {user}, {memberCount} defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", + // Channel ID for welcome messages. defaultWelcomeChannel: null, + // Channel ID for goodbye messages. defaultGoodbyeChannel: null, }, - + // ========================= + // COUNTER CHANNELS + // ========================= counters: { defaults: { + // Default naming/description templates for counter entries. name: "{name} Counter", description: "Server {name} counter", + // Channel type used for counters (typically "voice"). type: "voice", + // Channel name format. `{count}` is replaced automatically. channelName: "{name}-{count}", }, permissions: { + // Default denied permissions for the counter channel. deny: ["VIEW_CHANNEL"], + // Default allowed permissions for the counter channel. allow: ["VIEW_CHANNEL", "CONNECT", "SPEAK"], }, messages: { + // Default response messages for counter actions. created: "โœ… Created counter **{name}**", deleted: "๐Ÿ—‘๏ธ Deleted counter **{name}**", updated: "๐Ÿ”„ Updated counter **{name}**", }, types: { + // Built-in counter types and how each count is calculated. members: { name: "๐Ÿ‘ฅ Members", description: "Total members in the server", @@ -284,7 +417,9 @@ export const botConfig = { }, }, - + // ========================= + // GENERIC BOT MESSAGES + // ========================= messages: { noPermission: "You do not have permission to use this command.", cooldownActive: "Please wait {time} before using this command again.", @@ -295,27 +430,30 @@ export const botConfig = { maintenanceMode: "The bot is currently in maintenance mode.", }, - + // ========================= + // FEATURE TOGGLES + // ========================= + // Set any feature to `false` to disable it globally. features: { - + // Core systems. economy: true, leveling: true, moderation: true, logging: true, welcome: true, - + // Community engagement systems. tickets: true, giveaways: true, birthday: true, counter: true, - + // Security and self-service systems. verification: true, reactionRoles: true, joinToCreate: true, - + // Utility/quality-of-life modules. voice: true, search: true, tools: true, diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index aee86a555e..0310155716 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -5,6 +5,7 @@ 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 { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; export default { @@ -149,6 +150,21 @@ export default { logger.debug('Error updating counters on member join:', error); } + // Restore birthday data if the member previously left + try { + const backupKey = `guild:${guild.id}:birthdays:left`; + const backup = (await member.client.db.get(backupKey)) || {}; + if (backup[user.id]) { + const { month, day } = backup[user.id]; + await dbSetBirthday(member.client, guild.id, user.id, month, day); + delete backup[user.id]; + await member.client.db.set(backupKey, backup); + logger.debug(`Birthday restored for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error restoring birthday on member join:', error); + } + } catch (error) { logger.error('Error in guildMemberAdd event:', error); } diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index 12f768b206..a48aa6121f 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -4,6 +4,7 @@ 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 { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; export default { @@ -120,6 +121,21 @@ export default { logger.debug('Error updating counters on member leave:', error); } + // Backup and remove birthday data when a member leaves + try { + const birthdays = await getGuildBirthdays(member.client, guild.id); + if (birthdays[user.id]) { + const backupKey = `guild:${guild.id}:birthdays:left`; + const backup = (await member.client.db.get(backupKey)) || {}; + backup[user.id] = birthdays[user.id]; + await member.client.db.set(backupKey, backup); + await deleteBirthday(member.client, guild.id, user.id); + logger.debug(`Birthday backed up and removed for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error handling birthday on member leave:', error); + } + } catch (error) { logger.error('Error in guildMemberRemove event:', error); } diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index bb4ecd1689..7e8d76d902 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -3,10 +3,6 @@ import { pgConfig } from '../config/postgres.js'; import { logger } from './logger.js'; import { assertAllowlistedIdentifier, quoteIdentifier } from './sqlIdentifiers.js'; -/** - * PostgreSQL Database wrapper for Titan Bot - * Replaces Redis with PostgreSQL for better data persistence and querying - */ class PostgreSQLDatabase { constructor() { this.pool = null; @@ -775,36 +771,6 @@ class PostgreSQLDatabase { } } - /** - * Parse a Redis-style key to determine type and components - * Maps Redis-style hierarchical keys to specific database tables and operations - * - * KEY FORMATS SUPPORTED: - * - Guild Config: 'guild:{guildId}:config' โ†’ guild_config - * - Guild Birthdays: 'guild:{guildId}:birthdays' โ†’ guild_birthdays - * - Guild Giveaways: 'guild:{guildId}:giveaways' โ†’ guild_giveaways - * - Welcome Config: 'guild:{guildId}:welcome' โ†’ welcome_config - * - Leveling Config: 'guild:{guildId}:leveling:config' โ†’ leveling_config - * - User Levels: 'guild:{guildId}:leveling:users:{userId}' โ†’ user_level - * - Economy: 'guild:{guildId}:economy:{userId}' โ†’ economy - * - AFK Status: 'guild:{guildId}:afk:{userId}' โ†’ afk_status - * - Tickets: 'guild:{guildId}:ticket:{channelId}' โ†’ ticket - * - Counters: 'counters:{guildId}' โ†’ counters - * - Temp Data: 'temp:*' โ†’ temp_data (auto-TTL) - * - Cache Data: 'cache:*' โ†’ cache_data (auto-TTL) - * - * @param {string} key - The Redis-style key to parse (colon-separated format) - * @returns {Object} Parsed key information with type, IDs, and full key - * @example - * // Guild config key - * parseKey('guild:123456789:config') - * // Returns: { type: 'guild_config', guildId: '123456789', fullKey: 'guild:123456789:config' } - * - * @example - * // User economy key - * parseKey('guild:123456789:economy:987654321') - * // Returns: { type: 'economy', guildId: '123456789', userId: '987654321', fullKey: '...' } - */ parseKey(key) { if (key.startsWith('temp:')) { @@ -915,12 +881,20 @@ class PostgreSQLDatabase { ); return userLevelResult.rows.length > 0 ? userLevelResult.rows[0] : defaultValue; - case 'economy': + case 'economy': { const economyResult = await this.pool.query( `SELECT balance, bank, data FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId] ); - return economyResult.rows.length > 0 ? economyResult.rows[0] : defaultValue; + if (economyResult.rows.length === 0) return defaultValue; + const row = economyResult.rows[0]; + // Return the full data blob when available (contains wallet, bank, etc.) + // Fall back to constructing a compatible object from the columns + if (row.data && typeof row.data === 'object' && Object.keys(row.data).length > 0) { + return row.data; + } + return { wallet: row.balance ?? 0, bank: row.bank ?? 0 }; + } case 'afk_status': const afkResult = await this.pool.query( @@ -1099,7 +1073,7 @@ class PostgreSQLDatabase { VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) ON CONFLICT (guild_id, user_id) DO UPDATE SET balance = $3, bank = $4, data = $5, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, parsedKey.userId, value.balance || 0, value.bank || 0, value.data || {}] + [parsedKey.guildId, parsedKey.userId, value.wallet ?? value.balance ?? 0, value.bank ?? 0, value] ); return true;