diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..17b2d71c --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,51 @@ +name: Docker Publish + +on: + push: + branches: [ "main" ] + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main" ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..576813ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +# Create app directory +WORKDIR /usr/src/app + +# Install app dependencies +# A wildcard is used to ensure both package.json AND package-lock.json are copied +COPY package*.json ./ + +# Install only production dependencies +RUN npm ci --omit=dev + +# Bundle app source +COPY . . + +# Expose the health check port from src/app.js +EXPOSE 3000 + +# Start the bot +CMD [ "npm", "start" ] diff --git a/README.md b/README.md index b551a0b0..2be169d5 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,36 @@ TitanBot offers a complete suite of tools for Discord server management and comm For a detailed step-by-step setup guide, watch our comprehensive video tutorial: [**TitanBot Setup Tutorial**](https://www.youtube.com/@TouchDisc) +## 🐳 Docker Deployment (Recommended) + +TitanBot is fully containerized for easy deployment. + +### Using Docker Compose + +1. **Clone the repository:** + ```bash + git clone https://github.com/codebymitch/TitanBot.git + cd TitanBot + ``` + +2. **Configure environment variables:** + Create a `.env` file from `.env.example` and fill in your bot details and PostgreSQL credentials. + +3. **Start the containers:** + ```bash + docker-compose up -d + ``` + +This will start both the bot and a persistent PostgreSQL database. + +### Using GitHub Container Registry + +The bot is automatically published to GitHub Container Registry on every push to main. + +```bash +docker pull ghcr.io/codebymitch/titanbot:main +``` + ## ⚙️ Manual Installation Steps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b2fb7b98 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + bot: + build: . + container_name: titanbot + restart: unless-stopped + environment: + - NODE_ENV=production + - DISCORD_TOKEN=${DISCORD_TOKEN} + - CLIENT_ID=${CLIENT_ID} + - GUILD_ID=${GUILD_ID} + - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + - PORT=3000 + depends_on: + db: + condition: service_healthy + networks: + - titan-network + + db: + image: postgres:15-alpine + container_name: titanbot-db + restart: unless-stopped + environment: + - POSTGRES_USER=${POSTGRES_USER:-titanbot} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} + - POSTGRES_DB=${POSTGRES_DB:-titanbot} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - titan-network + +networks: + titan-network: + driver: bridge + +volumes: + postgres_data: diff --git a/src/commands/Fun/filp.js b/src/commands/Fun/flip.js similarity index 100% rename from src/commands/Fun/filp.js rename to src/commands/Fun/flip.js diff --git a/src/handlers/giveawayButtons.js b/src/handlers/giveawayButtons.js index 8fe6be5b..a23431f9 100644 --- a/src/handlers/giveawayButtons.js +++ b/src/handlers/giveawayButtons.js @@ -7,6 +7,7 @@ import { saveGiveaway, isGiveawayEnded } from '../utils/giveaways.js'; +import { Mutex } from '../utils/mutex.js'; import { selectWinners, isUserRateLimited, @@ -38,77 +39,79 @@ export const giveawayJoinHandler = { recordUserInteraction(interaction.user.id, interaction.message.id); - const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); - const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); - - if (!giveaway) { - throw new TitanBotError( - 'Giveaway not found in database', - ErrorTypes.VALIDATION, - 'This giveaway is no longer active.', - { messageId: interaction.message.id, guildId: interaction.guildId } - ); - } - - - const endedByTime = isGiveawayEnded(giveaway); - const endedByFlag = giveaway.ended || giveaway.isEnded; - - if (endedByTime || endedByFlag) { - return interaction.reply({ - embeds: [ - errorEmbed( - 'Giveaway Ended', - 'This giveaway has already ended.' - ) - ], - flags: MessageFlags.Ephemeral + const lockKey = `giveaway:${interaction.message.id}`; + await Mutex.runExclusive(lockKey, async () => { + const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); + const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); + + if (!giveaway) { + throw new TitanBotError( + 'Giveaway not found in database', + ErrorTypes.VALIDATION, + 'This giveaway is no longer active.', + { messageId: interaction.message.id, guildId: interaction.guildId } + ); + } + + // Double check end status inside lock + const endedByTime = isGiveawayEnded(giveaway); + const endedByFlag = giveaway.ended || giveaway.isEnded; + + if (endedByTime || endedByFlag) { + return interaction.reply({ + embeds: [ + errorEmbed( + 'Giveaway Ended', + 'This giveaway has already ended.' + ) + ], + flags: MessageFlags.Ephemeral + }); + } + + const participants = giveaway.participants || []; + const userId = interaction.user.id; + + // Check if user already joined + if (participants.includes(userId)) { + return interaction.reply({ + embeds: [ + errorEmbed( + 'Already Entered', + 'You have already entered this giveaway! 🎉' + ) + ], + flags: MessageFlags.Ephemeral + }); + } + + // Atomically update participants + participants.push(userId); + giveaway.participants = participants; + + await saveGiveaway(client, interaction.guildId, giveaway); + + logger.debug(`User ${interaction.user.tag} joined giveaway ${interaction.message.id}`); + + // Send response + const updatedEmbed = createGiveawayEmbed(giveaway, 'active'); + const updatedRow = createGiveawayButtons(false); + + await interaction.message.edit({ + embeds: [updatedEmbed], + components: [updatedRow] }); - } - const participants = giveaway.participants || []; - const userId = interaction.user.id; - - - if (participants.includes(userId)) { - return interaction.reply({ + await interaction.reply({ embeds: [ - errorEmbed( - 'Already Entered', - 'You have already entered this giveaway! 🎉' + successEmbed( + 'Success! You have entered the giveaway! 🎉', + `Good luck! There are now ${participants.length} entry/entries.` ) ], flags: MessageFlags.Ephemeral }); - } - - - participants.push(userId); - giveaway.participants = participants; - - await saveGiveaway(client, interaction.guildId, giveaway); - - logger.debug(`User ${interaction.user.tag} joined giveaway ${interaction.message.id}`); - - - const updatedEmbed = createGiveawayEmbed(giveaway, 'active'); - const updatedRow = createGiveawayButtons(false); - - await interaction.message.edit({ - embeds: [updatedEmbed], - components: [updatedRow] }); - - await interaction.reply({ - embeds: [ - successEmbed( - 'Success! You have entered the giveaway! 🎉', - `Good luck! There are now ${participants.length} entry/entries.` - ) - ], - flags: MessageFlags.Ephemeral - }); - } catch (error) { logger.error('Error in giveaway join handler:', error); await handleInteractionError(interaction, error, { diff --git a/src/services/economyService.js b/src/services/economyService.js index 3a843a5a..aaffcb24 100644 --- a/src/services/economyService.js +++ b/src/services/economyService.js @@ -208,10 +208,27 @@ class EconomyService { receiverData.wallet = receiverNext; try { - await Promise.all([ - setEconomyData(client, guildId, senderId, senderData), - setEconomyData(client, guildId, receiverId, receiverData) - ]); + // Step 1: Deduct from sender + await setEconomyData(client, guildId, senderId, senderData); + + try { + // Step 2: Add to receiver + await setEconomyData(client, guildId, receiverId, receiverData); + } catch (receiverError) { + // ROLLBACK: Try to restore sender's money if receiver update fails + logger.error(`[ECONOMY_CRITICAL] Failed to credit receiver ${receiverId}. Attempting rollback for sender ${senderId}...`, receiverError); + + senderData.wallet = walletBefore; + try { + await setEconomyData(client, guildId, senderId, senderData); + logger.info(`[ECONOMY_ROLLBACK] Successfully rolled back sender ${senderId} after receiver credit failure.`); + } catch (rollbackError) { + logger.error(`[ECONOMY_FATAL] ROLLBACK FAILED for sender ${senderId}! Data is now inconsistent.`, rollbackError); + // At this point, manual intervention is needed. + } + + throw receiverError; + } logger.info(`[ECONOMY_TRANSACTION] Money transferred`, { type: 'transfer', diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 10017bc4..f4fd0f90 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -272,6 +272,9 @@ export function selectWinners(participants, winnerCount) { return []; } + // Ensure participants are unique + const uniqueParticipants = [...new Set(participants)]; + if (!Number.isInteger(winnerCount) || winnerCount < 1) { throw new TitanBotError( 'Invalid winner count for selection', @@ -281,11 +284,11 @@ export function selectWinners(participants, winnerCount) { ); } - const requested = Math.min(winnerCount, participants.length); + const requested = Math.min(winnerCount, uniqueParticipants.length); try { - - const shuffled = [...participants]; + // Shuffle the unique participants using Fisher-Yates + const shuffled = [...uniqueParticipants]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; diff --git a/src/services/leveling.js b/src/services/leveling.js index 6989fcec..a79e1654 100644 --- a/src/services/leveling.js +++ b/src/services/leveling.js @@ -69,6 +69,20 @@ export function getLevelFromXp(xp) { +/** + * Calculate the total XP required for a specific level and current XP + * @param {number} level - The target level + * @param {number} currentXp - Current XP progress towards next level + * @returns {number} Total accumulated XP + */ +export function calculateTotalXp(level, currentXp = 0) { + let total = currentXp; + for (let i = 0; i < level; i++) { + total += getXpForLevel(i); + } + return total; +} + export async function getLeaderboard(client, guildId, limit = 10) { try { @@ -390,7 +404,7 @@ export async function addLevels(client, guildId, userId, levels) { } const newXp = 0; - const newTotalXp = userData.totalXp + (getXpForLevel(newLevel) - getXpForLevel(userData.level)); + const newTotalXp = calculateTotalXp(newLevel, newXp); userData.level = newLevel; userData.xp = newXp; @@ -443,7 +457,7 @@ export async function removeLevels(client, guildId, userId, levels) { const newLevel = Math.max(MIN_LEVEL, userData.level - levels); const newXp = 0; - const newTotalXp = getXpForLevel(newLevel) + newXp; + const newTotalXp = calculateTotalXp(newLevel, newXp); userData.level = newLevel; userData.xp = newXp; @@ -495,7 +509,7 @@ export async function setUserLevel(client, guildId, userId, level) { const userData = await getUserLevelData(client, guildId, userId); const newXp = 0; - const newTotalXp = getXpForLevel(level) + newXp; + const newTotalXp = calculateTotalXp(level, newXp); userData.level = level; userData.xp = newXp; diff --git a/src/services/moderationService.js b/src/services/moderationService.js index eac390e8..74cbe677 100644 --- a/src/services/moderationService.js +++ b/src/services/moderationService.js @@ -93,7 +93,7 @@ export class ModerationService { logger.debug('Target not in guild, proceeding with ban'); } - + // Hierarchy check if (targetMember) { const botCheck = this.validateBotHierarchy(guild.client, targetMember, 'ban'); if (!botCheck.valid) { @@ -104,8 +104,25 @@ export class ModerationService { if (!modCheck.valid) { throw new TitanBotError(modCheck.error, ErrorTypes.PERMISSION, modCheck.error); } + } else { + // If target is not in guild, we can't check their roles easily. + // As a safety measure, only allow users with ManageGuild or Administrator to ban non-members. + const isOwner = guild.ownerId === moderator.id; + const hasHighPerms = moderator.permissions.has([ + PermissionFlagsBits.ManageGuild, + PermissionFlagsBits.Administrator + ]); + + if (!isOwner && !hasHighPerms) { + throw new TitanBotError( + 'You do not have sufficient permissions to ban users who are not in the server.', + ErrorTypes.PERMISSION, + 'You need "Manage Server" or "Administrator" permissions to ban users not currently in the guild.' + ); + } } + await guild.members.ban(user.id, { reason }); diff --git a/src/services/ticket.js b/src/services/ticket.js index 38816024..0e2f6147 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -74,7 +74,7 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso const config = await getGuildConfig(guild.client, guild.id); const ticketConfig = config.tickets || {}; - const maxTicketsPerUser = config.maxTicketsPerUser || 3; + const maxTicketsPerUser = config.maxTicketsPerUser ?? 3; const currentTicketCount = await getUserTicketCount(guild.id, member.id); if (currentTicketCount >= maxTicketsPerUser) { diff --git a/src/services/xpSystem.js b/src/services/xpSystem.js index 7f9cce58..efb92529 100644 --- a/src/services/xpSystem.js +++ b/src/services/xpSystem.js @@ -5,6 +5,7 @@ import { logger } from '../utils/logger.js'; import { getLevelingConfig, getXpForLevel, getUserLevelData, saveUserLevelData } from './leveling.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; +import { Mutex } from '../utils/mutex.js'; @@ -15,92 +16,105 @@ import { logEvent, EVENT_TYPES } from './loggingService.js'; export async function addXp(client, guild, member, xpToAdd) { - try { - - if (!xpToAdd || xpToAdd <= 0) { - return { success: false, reason: 'Invalid XP amount' }; - } + const lockKey = `leveling:${guild.id}:${member.user.id}`; + return await Mutex.runExclusive(lockKey, async () => { + try { + // XP Logic... + if (!xpToAdd || xpToAdd <= 0) { + return { success: false, reason: 'Invalid XP amount' }; + } - const config = await getLevelingConfig(client, guild.id); - - if (!config.enabled) { - return { success: false, reason: 'Leveling is disabled in this server' }; - } - - const levelData = await getUserLevelData(client, guild.id, member.user.id); - - levelData.xp += xpToAdd; - levelData.totalXp += xpToAdd; - levelData.lastMessage = Date.now(); - - const xpNeededForNextLevel = getXpForLevel(levelData.level + 1); - let didLevelUp = false; - - - if (levelData.xp >= xpNeededForNextLevel) { - levelData.level += 1; - levelData.xp = levelData.xp - xpNeededForNextLevel; - didLevelUp = true; - - logger.info(`🎉 ${member.user.tag} leveled up to level ${levelData.level} in ${guild.name}`); + const config = await getLevelingConfig(client, guild.id); - - if (config.roleRewards && config.roleRewards[levelData.level]) { - await awardRoleReward(guild, member, config.roleRewards[levelData.level], levelData.level); + if (!config.enabled) { + return { success: false, reason: 'Leveling is disabled in this server' }; } + const levelData = await getUserLevelData(client, guild.id, member.user.id); + + levelData.xp += xpToAdd; + levelData.totalXp += xpToAdd; + levelData.lastMessage = Date.now(); - if (config.announceLevelUp) { - await sendLevelUpAnnouncement(guild, member, levelData, config); + // Handle multi-level jumps + let xpNeededForNextLevel = getXpForLevel(levelData.level); + let didLevelUp = false; + const initialLevel = levelData.level; + + while (levelData.xp >= xpNeededForNextLevel && levelData.level < 1000) { + levelData.xp -= xpNeededForNextLevel; + levelData.level += 1; + didLevelUp = true; + xpNeededForNextLevel = getXpForLevel(levelData.level); + + logger.info(`🎉 ${member.user.tag} leveled up to level ${levelData.level} in ${guild.name}`); + + // Award role rewards for each level if applicable + if (config.roleRewards && config.roleRewards[levelData.level]) { + await awardRoleReward(guild, member, config.roleRewards[levelData.level], levelData.level); + } } - - try { - await logEvent({ - client, - guildId: guild.id, - eventType: EVENT_TYPES.LEVELING_LEVELUP, - data: { - description: `${member.user.tag} reached level ${levelData.level}`, - userId: member.user.id, - fields: [ - { - name: '👤 Member', - value: `${member.user.tag} (${member.user.id})`, - inline: true - }, - { - name: '📊 New Level', - value: levelData.level.toString(), - inline: true - }, - { - name: '✨ Total XP', - value: levelData.totalXp.toString(), - inline: true - } - ] - } - }); - } catch { + if (didLevelUp) { + // If they leveled up, we only announce once (to the highest level reached) + if (config.announceLevelUp) { + await sendLevelUpAnnouncement(guild, member, levelData, config); + } + + // Log the levelup event (once for the highest level reached) + try { + await logEvent({ + client, + guildId: guild.id, + eventType: EVENT_TYPES.LEVELING_LEVELUP, + data: { + description: `${member.user.tag} reached level ${levelData.level}`, + userId: member.user.id, + fields: [ + { + name: '👤 Member', + value: `${member.user.tag} (${member.user.id})`, + inline: true + }, + { + name: '📊 New Level', + value: levelData.level.toString(), + inline: true + }, + { + name: '📈 Levels Gained', + value: (levelData.level - initialLevel).toString(), + inline: true + }, + { + name: '✨ Total XP', + value: levelData.totalXp.toString(), + inline: true + } + ] + } + }); + } catch (logError) { + logger.debug('Failed to log leveling event:', logError.message); + } } + + await saveUserLevelData(client, guild.id, member.user.id, levelData); + + return { + success: true, + level: levelData.level, + xp: levelData.xp, + totalXp: levelData.totalXp, + xpNeeded: getXpForLevel(levelData.level + 1), + leveledUp: didLevelUp + }; + + } catch (error) { + logger.error('Error adding XP:', error); + return { success: false, error: error.message }; } - - await saveUserLevelData(client, guild.id, member.user.id, levelData); - - return { - success: true, - level: levelData.level, - xp: levelData.xp, - totalXp: levelData.totalXp, - xpNeeded: getXpForLevel(levelData.level + 1), - leveledUp: didLevelUp - }; - - } catch (error) { - logger.error('Error adding XP:', error); - return { success: false, error: error.message }; - } + }); } diff --git a/src/utils/mutex.js b/src/utils/mutex.js new file mode 100644 index 00000000..ddf243be --- /dev/null +++ b/src/utils/mutex.js @@ -0,0 +1,39 @@ +/** + * Simple mutex implementation to prevent race conditions + */ +const locks = new Map(); + +export const Mutex = { + /** + * Executes a task exclusively for a given key. + * @param {string} key - Unique resource identifier + * @param {Function} task - Async function to run + */ + async runExclusive(key, task) { + // Wait for existing lock if it exists + const currentLock = locks.get(key) || Promise.resolve(); + + const nextLock = (async () => { + try { + await currentLock; + } catch (error) { + // Ignore previous task errors + } + return await task(); + })(); + + // Store next lock in map + locks.set(key, nextLock); + + // Cleanup after completion only if this is the latest lock + const cleanup = () => { + if (locks.get(key) === nextLock) { + locks.delete(key); + } + }; + + nextLock.then(cleanup, cleanup); + + return nextLock; + } +};