diff --git a/.dockerignore b/.dockerignore index 5171c54..3ec8249 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,16 @@ node_modules -npm-debug.log \ No newline at end of file +npm-debug.log +.env* +.git +.gitignore +README.md +Dockerfile +.dockerignore +*.md +.vscode +.idea +coverage +.nyc_output +.cache +logs +*.log \ No newline at end of file diff --git a/.env.example b/.env.example deleted file mode 100644 index ade87cc..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# Discord Bot Configuration -DISCORD_TOKEN=your_dev_bot_token -CLIENT_ID=your_dev_client_id - -# Supabase Configuration -SUPABASE_URL=your_dev_supabase_url -SUPABASE_KEY=your_dev_supabase_key - -# Webhook Configuration (for match notifications) -WEBHOOK_SECRET=your_webhook_secret - -# Channel Configuration -NOTIFICATION_CHANNEL_ID=your_notification_channel_id - -# Environment -NODE_ENV=development \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4897472 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,149 @@ +# ๐Ÿš€ VRBattles Discord Bot - Deployment Guide + +## ๐Ÿ“ฆ **Latest Version: v1.2.7** + +### **What's New in v1.2.7:** +- โœ… Fixed Docker multi-platform build (x86_64 + ARM64) +- โœ… Resolved "exec format error" on Coolify deployment +- โœ… Enhanced deployment script with buildx support + +### **Previous in v1.2.6:** +- โœ… Fixed autocomplete interaction spam +- โœ… All 9 games now available in dropdowns +- โœ… Working help button navigation +- โœ… Automatic game sync from Supabase +- โœ… Optimized Docker image (355MB) +- โœ… Enhanced error handling + +### **Docker Images Available:** +``` +far54/vrbattles-discord-bot:latest (always current) +far54/vrbattles-discord-bot:v1.2.7 (specific version) +``` + +### **Multi-Platform Support:** +Images are built for both Intel/AMD (x86_64) and ARM64 architectures to ensure compatibility across different server environments. If you encounter "exec format error", the image architecture doesn't match your server. + +## ๐Ÿ”ง **Deploy to Coolify** + +### **Option 1: Using Docker Hub Image** +1. **Create New Application** in Coolify +2. **Source Type**: Public Repository +3. **Docker Image**: `far54/vrbattles-discord-bot:latest` +4. **Port**: `3000` + +### **Option 2: From GitHub (Auto-deploy)** +1. **Source Type**: Git Repository +2. **Repository**: Your GitHub repo URL +3. **Branch**: `main` +4. **Build Pack**: Docker +5. **Port**: `3000` + +## ๐Ÿ” **Required Environment Variables** + +Set these in Coolify Environment tab: + +```bash +# Discord Configuration +DISCORD_TOKEN=your_discord_bot_token +DISCORD_APPLICATION_ID=your_discord_application_id + +# Supabase Configuration +SUPABASE_URL=your_supabase_project_url +SUPABASE_KEY=your_supabase_anon_key + +# Environment +NODE_ENV=production +``` + +## ๐ŸŽฏ **Features Included** + +### **Commands Available:** +- `/help` - Interactive help system with working buttons +- `/finduser` - Search players (9 game dropdown) +- `/findteam` - Search teams (9 game dropdown) +- `/matchhistory` - View match history (optional game filter) +- `/subscribe` - Admin: Subscribe to game notifications +- `/unsubscribe` - Admin: Remove game subscriptions +- `/list_subscriptions` - Admin: View active subscriptions +- `/register_server` - Admin: Register server with BattleBot +- `/version` - Show bot version and system info + +### **Supported Games:** +- Big Ballers VR +- Blacktop Hoops +- Breachers +- Echo Arena +- Echo Combat +- Gun Raiders +- Nock +- Orion Drift +- VAIL + +### **Services:** +- **Discord Bot**: Main command handling +- **Webhook Server**: Port 3000 for match notifications +- **Auto Sync**: Game choices sync with Supabase + +## ๐Ÿ”„ **Updating Games** + +When you add/remove games in Supabase: + +```bash +# Locally run: +npm run sync-games + +# Then rebuild and push (multi-platform): +./scripts/deploy.sh v1.2.7 + +# Update Coolify deployment +``` + +## ๐Ÿ—๏ธ **Building for Production** + +### **Using Deploy Script (Recommended):** +```bash +# This builds for both Intel/AMD and ARM64 +./scripts/deploy.sh v1.2.7 +``` + +### **Manual Multi-Platform Build:** +```bash +# Setup buildx (one-time) +docker buildx create --name multiarch --use --driver docker-container --bootstrap + +# Build and push for both architectures +docker buildx build --platform linux/amd64,linux/arm64 \ + -t far54/vrbattles-discord-bot:v1.2.7 \ + . --push +``` + +## ๐Ÿ“Š **Health Check** + +The Docker container includes a health check that validates: +- Node.js process is running +- Port 3000 is accessible +- Basic application startup + +## ๐Ÿ” **Troubleshooting** + +### **Common Issues:** +1. **Bot not responding**: Check DISCORD_TOKEN is valid +2. **Database errors**: Verify SUPABASE_URL and SUPABASE_KEY +3. **Game dropdowns empty**: Run `npm run sync-games` and redeploy +4. **Help buttons not working**: Ensure latest v1.2.7+ is deployed +5. **exec format error**: Architecture mismatch - use multi-platform images + +### **Log Locations:** +- Coolify: Check application logs tab +- Discord: Bot activity in server +- Health: `/version` command shows system info + +## ๐ŸŽ‰ **Deployment Complete!** + +Your bot should now be running with: +- โœ… All 9 games in dropdowns +- โœ… Working help navigation +- โœ… No debug message spam +- โœ… Match notifications on port 3000 +- โœ… Clean, optimized Docker image \ No newline at end of file diff --git a/deploy-commands.js b/deploy-commands.js index d2d5c19..12e3af0 100644 --- a/deploy-commands.js +++ b/deploy-commands.js @@ -3,8 +3,28 @@ require("dotenv").config(); const commands = [ new SlashCommandBuilder() - .setName("ping") - .setDescription("Replies with Pong!"), + .setName("help") + .setDescription("Get help with BattleBot commands and features") + .addStringOption((option) => + option + .setName("category") + .setDescription("Select a help category") + .setRequired(false) + .addChoices( + { name: "Big Ballers VR", value: "Big Ballers VR" }, + { name: "Blacktop Hoops", value: "Blacktop Hoops" }, + { name: "Breachers", value: "Breachers" }, + { name: "Echo Arena", value: "Echo Arena" }, + { name: "Echo Combat", value: "Echo Combat" }, + { name: "Gun Raiders", value: "Gun Raiders" }, + { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, + { name: "VAIL", value: "VAIL" } + ) + ), + new SlashCommandBuilder() + .setName("version") + .setDescription("Show bot version and system information"), new SlashCommandBuilder() .setName("finduser") .setDescription("Find a user by username") @@ -21,6 +41,7 @@ const commands = [ { name: "Echo Combat", value: "Echo Combat" }, { name: "Gun Raiders", value: "Gun Raiders" }, { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, { name: "VAIL", value: "VAIL" } ) ) @@ -40,7 +61,21 @@ const commands = [ .setRequired(true) ) .addStringOption((option) => - option.setName("game").setDescription("Filter by game").setRequired(false) + option + .setName("game") + .setDescription("Filter by game (optional)") + .setRequired(false) + .addChoices( + { name: "Big Ballers VR", value: "Big Ballers VR" }, + { name: "Blacktop Hoops", value: "Blacktop Hoops" }, + { name: "Breachers", value: "Breachers" }, + { name: "Echo Arena", value: "Echo Arena" }, + { name: "Echo Combat", value: "Echo Combat" }, + { name: "Gun Raiders", value: "Gun Raiders" }, + { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, + { name: "VAIL", value: "VAIL" } + ) ), new SlashCommandBuilder() .setName("subscribe") @@ -59,6 +94,7 @@ const commands = [ { name: "Echo Combat", value: "Echo Combat" }, { name: "Gun Raiders", value: "Gun Raiders" }, { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, { name: "VAIL", value: "VAIL" } ) ) @@ -85,6 +121,7 @@ const commands = [ { name: "Echo Combat", value: "Echo Combat" }, { name: "Gun Raiders", value: "Gun Raiders" }, { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, { name: "VAIL", value: "VAIL" } ) ), @@ -112,6 +149,7 @@ const commands = [ { name: "Echo Combat", value: "Echo Combat" }, { name: "Gun Raiders", value: "Gun Raiders" }, { name: "Nock", value: "Nock" }, + { name: "Orion Drift", value: "Orion Drift" }, { name: "VAIL", value: "VAIL" } ) ) diff --git a/index.js b/index.js index 2e55ad5..f3e51d5 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ const winston = require('winston'); const { createClient } = require('@supabase/supabase-js'); const Bot = require('./src/Bot'); +const packageInfo = require('./package.json'); require('dotenv').config(); // Initialize logger @@ -20,38 +21,155 @@ const logger = winston.createLogger({ ] }); +// Display startup banner with version info +logger.info('๐Ÿค– ===== VRBattles Discord Bot Starting ===== ๐Ÿค–'); +logger.info(`๐Ÿ“ฆ Version: ${packageInfo.version}`); +logger.info(`๐Ÿ“ Name: ${packageInfo.name}`); +logger.info(`๐Ÿ“… Started: ${new Date().toISOString()}`); +logger.info(`๐ŸŒ Environment: ${process.env.NODE_ENV || 'development'}`); +logger.info(`๐Ÿง Platform: ${process.platform} ${process.arch}`); +logger.info(`โšก Node.js: ${process.version}`); + +// Check required environment variables +const requiredEnvVars = ['DISCORD_TOKEN', 'SUPABASE_URL', 'SUPABASE_KEY']; +const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); + +if (missingEnvVars.length > 0) { + logger.error(`โŒ Missing required environment variables: ${missingEnvVars.join(', ')}`); + process.exit(1); +} + +logger.info('โœ… Environment variables validated'); + // Initialize Supabase client +logger.info('๐Ÿ”— Initializing Supabase connection...'); const supabase = createClient( process.env.SUPABASE_URL, process.env.SUPABASE_KEY ); +// Initialize bot variable at module level +let bot = null; + // Initialize and start bot -const bot = new Bot(process.env.DISCORD_TOKEN, supabase, logger); -bot.start().catch(error => { - console.error('Failed to start bot:', error); - process.exit(1); -}); +async function startBot() { + try { + // Test Supabase connection + logger.info('๐Ÿงช Testing Supabase connection...'); + + // Test 1: Read access to games table + const { data: games, error } = await supabase + .from('games') + .select('id, name, active') + .eq('active', true) + .limit(3); + + if (error) { + logger.error('โŒ Supabase read test failed:', { + message: error.message, + details: error.details, + hint: error.hint, + code: error.code + }); + process.exit(1); + } + + logger.info(`โœ… Supabase read access OK - Found ${games.length} active games`); + logger.info(`๐Ÿ“‹ Sample games: ${games.map(g => g.name).join(', ')}`); + + // Test 2: Write access to servers table (dry run) + logger.info('๐Ÿงช Testing database write permissions...'); + const testGuildId = 'test_connection_' + Date.now(); + + try { + // Try to insert a test record + const { data: testServer, error: insertError } = await supabase + .from('servers') + .insert([{ + discord_server_id: testGuildId, + server_name: 'Connection Test Server', + active: false // Mark as inactive so it doesn't interfere + }]) + .select() + .single(); + + if (insertError) { + logger.error('โŒ Database write test failed:', { + message: insertError.message, + details: error.details, + hint: insertError.hint, + code: insertError.code + }); + logger.error('๐Ÿ’ก This suggests a database permissions issue. Check row-level security policies.'); + process.exit(1); + } + + // Clean up test record + if (testServer?.id) { + await supabase + .from('servers') + .delete() + .eq('id', testServer.id); + } + + logger.info('โœ… Database write access OK'); + } catch (writeError) { + logger.error('โŒ Database write test exception:', { + message: writeError.message, + stack: writeError.stack + }); + process.exit(1); + } + + // Initialize and start bot + logger.info('๐Ÿš€ Starting Discord bot...'); + bot = new Bot(process.env.DISCORD_TOKEN, supabase, logger); + + await bot.start(); + return bot; + } catch (error) { + logger.error('โŒ Failed to start bot:', { + message: error.message, + stack: error.stack, + name: error.name + }); + process.exit(1); + } +} + +startBot(); // Handle process termination process.on('SIGINT', async () => { - console.log('Received SIGINT. Shutting down...'); + logger.info('๐Ÿ“ฅ Received SIGINT. Shutting down gracefully...'); try { - await bot.stop(); + if (bot) { + await bot.stop(); + } + logger.info('โœ… Bot shutdown completed'); process.exit(0); } catch (error) { - console.error('Error during shutdown:', error); + logger.error('โŒ Error during shutdown:', { + message: error.message, + stack: error.stack + }); process.exit(1); } }); process.on('SIGTERM', async () => { - console.log('Received SIGTERM. Shutting down...'); + logger.info('๐Ÿ“ฅ Received SIGTERM. Shutting down gracefully...'); try { - await bot.stop(); + if (bot) { + await bot.stop(); + } + logger.info('โœ… Bot shutdown completed'); process.exit(0); } catch (error) { - console.error('Error during shutdown:', error); + logger.error('โŒ Error during shutdown:', { + message: error.message, + stack: error.stack + }); process.exit(1); } }); \ No newline at end of file diff --git a/package.json b/package.json index c9c3858..c0ab2fa 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,26 @@ { - "name": "discord-bot", - "version": "1.0.0", + "name": "vrbattles-discord-bot", + "version": "1.2.7", + "description": "VRBattles Discord Bot - Player search, team lookup, match notifications, and more for VR gaming communities", "main": "index.js", "scripts": { - "start": "node -r dotenv/config index.js dotenv_config_path=.env.production", - "dev": "nodemon -r dotenv/config index.js dotenv_config_path=.env.development", - "deploy-commands": "node -r dotenv/config deploy-commands.js dotenv_config_path=.env.production", - "deploy-commands:dev": "node -r dotenv/config deploy-commands.js dotenv_config_path=.env.development", - "test:webhook": "node -r dotenv/config src/test/testWebhook.js dotenv_config_path=.env.test" + "start": "node -r dotenv/config index.js", + "start:docker": "node index.js", + "dev": "nodemon -r dotenv/config index.js", + "deploy-commands": "node -r dotenv/config deploy-commands.js", + "sync-games": "node -r dotenv/config src/scripts/syncGameChoices.js && node -r dotenv/config deploy-commands.js", + "deploy": "./scripts/deploy.sh", + "test:webhook": "node -r dotenv/config src/test/testWebhook.js", + "test:supabase": "node src/scripts/testSupabaseConnection.js", + "generate:invite": "node src/scripts/generateInvite.js" }, - "keywords": [], - "author": "", + "keywords": ["discord", "bot", "vr", "gaming", "vrbattles", "vrchat", "vail"], + "author": "VRBattles", "license": "ISC", - "description": "", + "repository": { + "type": "git", + "url": "https://github.com/your-repo/vrbattles-discord-bot" + }, "dependencies": { "@supabase/supabase-js": "^2.46.1", "axios": "^1.6.0", diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..270d314 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# VRBattles Discord Bot - Docker Deploy Script +# Usage: ./scripts/deploy.sh [version] +# Example: ./scripts/deploy.sh v1.2.7 + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +DOCKER_USERNAME="far54" +IMAGE_NAME="vrbattles-discord-bot" +FULL_IMAGE_NAME="${DOCKER_USERNAME}/${IMAGE_NAME}" + +# Get version from package.json if not provided +if [ -z "$1" ]; then + VERSION=$(node -p "require('./package.json').version") + VERSION="v${VERSION}" +else + VERSION="$1" +fi + +echo -e "${BLUE}๐Ÿš€ VRBattles Discord Bot Deployment${NC}" +echo -e "${YELLOW}๐Ÿ“ฆ Version: ${VERSION}${NC}" +echo -e "${YELLOW}๐Ÿณ Image: ${FULL_IMAGE_NAME}${NC}" +echo "" + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}โŒ Docker is not running. Please start Docker and try again.${NC}" + exit 1 +fi + +# Sync games from Supabase +echo -e "${BLUE}๐Ÿ”„ Syncing games from Supabase...${NC}" +npm run sync-games + +echo -e "${BLUE}๐Ÿ—๏ธ Building multi-platform Docker image...${NC}" +echo -e "${YELLOW}๐Ÿ“‹ Platforms: linux/amd64, linux/arm64${NC}" + +# Check if buildx is available +if ! docker buildx version > /dev/null 2>&1; then + echo -e "${RED}โŒ Docker buildx is not available. Please update Docker.${NC}" + exit 1 +fi + +# Create/use buildx builder +docker buildx create --name multiarch --use --driver docker-container --bootstrap 2>/dev/null || true +docker buildx use multiarch + +echo -e "${BLUE}๐Ÿ“ค Building and pushing to Docker Hub...${NC}" +echo -e "${YELLOW}Note: Make sure you're logged in with 'docker login'${NC}" + +# Check if logged in +if ! docker info | grep -q "Username:"; then + echo -e "${YELLOW}โš ๏ธ You don't appear to be logged in to Docker Hub.${NC}" + echo -e "${YELLOW}๐Ÿ” Please run: docker login${NC}" + read -p "Press Enter after logging in, or Ctrl+C to cancel..." +fi + +# Build and push multi-platform images +docker buildx build --platform linux/amd64,linux/arm64 \ + -t "${FULL_IMAGE_NAME}:latest" \ + -t "${FULL_IMAGE_NAME}:${VERSION}" \ + . --push + +echo "" +echo -e "${GREEN}โœ… Successfully deployed!${NC}" +echo "" +echo -e "${BLUE}๐Ÿ“‹ Deployment Summary:${NC}" +echo -e " ๐Ÿณ Images pushed:" +echo -e " โ€ข ${FULL_IMAGE_NAME}:latest" +echo -e " โ€ข ${FULL_IMAGE_NAME}:${VERSION}" +echo "" +echo -e "${BLUE}๐Ÿ”ง Coolify Deployment:${NC}" +echo -e " 1. Go to your Coolify dashboard" +echo -e " 2. Update your application image to: ${FULL_IMAGE_NAME}:${VERSION}" +echo -e " 3. Restart the application" +echo "" +echo -e "${BLUE}๐ŸŽฏ Features in this version:${NC}" +echo -e " โœ… All 9 games in dropdowns" +echo -e " โœ… Working help button navigation" +echo -e " โœ… No debug message spam" +echo -e " โœ… Automatic Supabase game sync" +echo -e " โœ… Optimized Docker image" +echo "" +echo -e "${GREEN}๐ŸŽ‰ Deployment complete!${NC}" \ No newline at end of file diff --git a/src/Bot.js b/src/Bot.js index 6b4c908..5a1e54b 100644 --- a/src/Bot.js +++ b/src/Bot.js @@ -57,6 +57,27 @@ class Bot { } } + async stop() { + try { + this.logger.info('Stopping bot...'); + + // Stop notification service + if (this.notificationService) { + await this.notificationService.stop(); + } + + // Destroy Discord client + if (this.client) { + await this.client.destroy(); + } + + this.logger.info('Bot stopped successfully'); + } catch (error) { + this.logger.error('Error stopping bot:', error); + throw error; + } + } + async handleInteraction(interaction) { if (!interaction.isCommand() && !interaction.isButton()) { this.logger.debug('Ignoring non-command/button interaction'); @@ -73,11 +94,31 @@ class Bot { try { if (interaction.isCommand()) { - // Remove ephemeral flag for finduser, matchhistory, and findteam commands + // Quick defer with timeout handling const isPublicCommand = ['finduser', 'matchhistory', 'findteam'].includes(interaction.commandName); - await interaction.deferReply({ - ephemeral: !isPublicCommand - }); + + try { + await Promise.race([ + interaction.deferReply({ ephemeral: !isPublicCommand }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Defer timeout')), 2500)) + ]); + } catch (deferError) { + this.logger.warn('Failed to defer interaction:', { + error: deferError.message, + command: interaction.commandName, + guild: interaction.guild?.name + }); + // Try to reply immediately if defer failed + try { + await interaction.reply({ + content: 'โณ Processing your request...', + ephemeral: !isPublicCommand + }); + } catch (replyError) { + this.logger.error('Failed to reply after defer timeout:', replyError); + return; // Give up on this interaction + } + } this.logger.debug('Processing command', { command: interaction.commandName, @@ -87,9 +128,12 @@ class Bot { await this.commandHandler.handleCommand(interaction); } else if (interaction.isButton()) { - // For buttons, we'll still use deferUpdate to show the loading state - await interaction.deferUpdate(); - await this.commandHandler.handleButton(interaction); + try { + await interaction.deferUpdate(); + await this.commandHandler.handleButton(interaction); + } catch (error) { + this.logger.error('Button interaction error:', error); + } } } catch (error) { this.logger.error('Command processing error:', { @@ -100,11 +144,15 @@ class Bot { }); try { - if (!interaction.replied) { - await interaction.editReply({ + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: 'โŒ An error occurred while processing your request.', ephemeral: true }); + } else if (interaction.deferred) { + await interaction.editReply({ + content: 'โŒ An error occurred while processing your request.' + }); } } catch (replyError) { this.logger.error('Failed to send error response:', replyError); diff --git a/src/commands/CommandHandler.js b/src/commands/CommandHandler.js index 85cf67c..3bcc032 100644 --- a/src/commands/CommandHandler.js +++ b/src/commands/CommandHandler.js @@ -1,5 +1,9 @@ -const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js'); +const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); const EmbedBuilders = require('../utils/embedBuilders'); +const HelpCommand = require('./HelpCommand'); +const PaginationManager = require('../utils/PaginationManager'); +const CooldownManager = require('../utils/CooldownManager'); +const packageInfo = require('../../package.json'); class CommandHandler { constructor(playerService, supabase, logger, serverRegistrationService, subscriptionCommands) { @@ -8,20 +12,36 @@ class CommandHandler { this.logger = logger; this.serverRegistrationService = serverRegistrationService; this.subscriptionCommands = subscriptionCommands; + this.helpCommand = new HelpCommand(supabase, logger); + this.paginationManager = new PaginationManager(logger); + this.cooldownManager = new CooldownManager(); } async handleCommand(interaction) { try { + // Check cooldown + const cooldownTime = this.cooldownManager.checkCooldown(interaction); + if (cooldownTime > 0) { + await interaction.reply({ + content: `โฐ Please wait ${cooldownTime.toFixed(1)} seconds before using this command again.`, + flags: ['Ephemeral'] + }); + return; + } + + // Note: deferReply is already handled in Bot.js - don't defer again here + + // Route to appropriate handler switch (interaction.commandName) { case 'register_server': await this.handleRegisterServer(interaction); break; - case 'ping': - await interaction.editReply({ content: 'Pong!', ephemeral: true }); - break; case 'finduser': await this.handleFindUser(interaction); break; + case 'findteam': + await this.handleFindTeam(interaction); + break; case 'matchhistory': await this.handleMatchHistory(interaction); break; @@ -34,36 +54,113 @@ class CommandHandler { case 'list_subscriptions': await this.subscriptionCommands.handleListSubscriptions(interaction); break; - case 'findteam': - await this.handleFindTeam(interaction); + case 'help': + await this.helpCommand.handleHelp(interaction); + break; + case 'version': + await this.handleVersion(interaction); break; default: - await interaction.editReply({ - content: 'โŒ Unknown command', - ephemeral: true + await interaction.editReply({ + content: 'โŒ Unknown command.', + flags: ['Ephemeral'] }); } } catch (error) { - this.logger.error('Command handling error:', { - command: interaction.commandName, + this.logger.error('Error in handleCommand:', { error: error.message, - stack: error.stack + commandName: interaction.commandName, + userId: interaction.user.id, + guildId: interaction.guildId, + timestamp: new Date().toISOString() }); try { - await interaction.editReply({ - content: 'โŒ An error occurred while processing your command.', - ephemeral: true - }); - } catch (followUpError) { - this.logger.error('Failed to send error response:', { - error: followUpError.message, - originalError: error.message - }); + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ + content: 'โŒ An error occurred while processing your command.', + }); + } else { + await interaction.reply({ + content: 'โŒ An error occurred while processing your command.', + flags: ['Ephemeral'] + }); + } + } catch (replyError) { + this.logger.error('Failed to send error response:', replyError); } } } + async getServerSubscriptions(guildId) { + try { + const { data: subscriptions, error } = await this.supabase + .from('active_subscriptions') + .select('game_name') + .eq('discord_server_id', guildId); + + if (error) { + this.logger.error('Error fetching server subscriptions:', error); + return []; + } + + return subscriptions || []; + } catch (error) { + this.logger.error('Error in getServerSubscriptions:', error); + return []; + } + } + + async checkSetupStatusWithGuidance(guildId) { + try { + // Check if server is registered + const { data: server, error: serverError } = await this.supabase + .from('servers') + .select('*') + .eq('discord_server_id', guildId) + .single(); + + const isRegistered = !!server && !serverError; + + // Check subscriptions if registered + let subscriptions = []; + if (isRegistered) { + const { data: subs, error: subError } = await this.supabase + .from('active_subscriptions') + .select('game_name') + .eq('discord_server_id', guildId); + + if (!subError) { + subscriptions = subs || []; + } + } + + return { + isRegistered, + subscriptionCount: subscriptions.length, + setupComplete: isRegistered && subscriptions.length > 0, + getGuidanceMessage() { + if (!isRegistered) { + return "First, run `/register_server` to connect your server to VRBattles."; + } else if (subscriptions.length === 0) { + return "Next, run `/subscribe` to add game notifications."; + } + return "Your server is properly set up!"; + } + }; + } catch (error) { + this.logger.error('Error checking setup status:', error); + return { + isRegistered: false, + subscriptionCount: 0, + setupComplete: false, + getGuidanceMessage() { + return "Please check your server setup and try again."; + } + }; + } + } + async handleRegisterServer(interaction) { try { // Check for admin permissions @@ -89,12 +186,37 @@ class CommandHandler { // Log the result this.logger.debug('Server registration result received', { - status: result.status, - serverId: result.server?.id, + status: result?.status, + hasServer: !!result?.server, + serverId: result?.server?.id, guildId, timestamp: new Date().toISOString() }); + // Validate result structure + if (!result || typeof result !== 'object') { + this.logger.error('Invalid result from registerServer:', { result, guildId, serverName }); + await interaction.editReply({ + content: 'โŒ Server registration failed due to invalid response. Please try again or contact support.', + ephemeral: true + }); + return; + } + + if (!result.server) { + this.logger.error('Server registration returned no server data:', { + result, + guildId, + serverName, + timestamp: new Date().toISOString() + }); + await interaction.editReply({ + content: 'โŒ Server registration failed - no server data returned. This might be a database permissions issue.', + ephemeral: true + }); + return; + } + // Prepare response message based on status let message; switch (result.status) { @@ -120,7 +242,7 @@ class CommandHandler { this.logger.info('Server registration completed', { status: result.status, guildId, - serverId: result.server.id, + serverId: result.server?.id, timestamp: new Date().toISOString() }); @@ -151,10 +273,10 @@ class CommandHandler { const username = interaction.options.getString('username'); const gameFilter = interaction.options.getString('game'); - // Input validation + // Input validation with helpful messages if (!username || typeof username !== 'string') { await interaction.editReply({ - content: 'โŒ Invalid username provided.' + content: 'โŒ **Invalid username provided.**\n\nโœ๏ธ **Expected:** A valid VRBattles username\n๐Ÿ’ก **Tip:** Usernames are case-sensitive and should match exactly as shown on VRBattles' }); return; } @@ -167,7 +289,7 @@ class CommandHandler { if (!sanitizedUsername) { await interaction.editReply({ - content: 'โŒ Username must contain valid characters (letters, numbers, spaces, hyphens, underscores, or periods).' + content: 'โŒ **Invalid characters in username.**\n\nโœ… **Allowed characters:** Letters, numbers, spaces, hyphens (-), underscores (_), and periods (.)\n๐Ÿ” **Example:** Try "PlayerName123" or "Player_Name"' }); return; } @@ -385,6 +507,145 @@ class CommandHandler { } } + async handleButton(interaction) { + try { + // Check if this is a pagination button + if (this.paginationManager.isPaginationButton(interaction.customId)) { + await interaction.deferUpdate(); + await this.paginationManager.handlePaginationButton(interaction); + return; + } + + // Check if this is a help button + if (interaction.customId.startsWith('help_')) { + await this.helpCommand.handleHelpButton(interaction); + return; + } + + // Handle other button interactions here as needed + this.logger.warn('Unhandled button interaction:', { + customId: interaction.customId, + userId: interaction.user.id + }); + + await interaction.reply({ + content: 'โŒ This button interaction is not implemented yet.', + ephemeral: true + }); + + } catch (error) { + this.logger.error('Error in handleButton:', { + error: error.message, + customId: interaction.customId, + userId: interaction.user.id + }); + + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: 'โŒ An error occurred while processing the button interaction.', + ephemeral: true + }); + } + } catch (replyError) { + this.logger.error('Failed to send button error response:', replyError); + } + } + } + + async handleSelectMenu(interaction) { + try { + // Handle select menu interactions (if needed) + this.logger.debug('Select menu interaction:', { + customId: interaction.customId, + values: interaction.values, + userId: interaction.user.id + }); + + await interaction.reply({ + content: 'Select menu interactions are not yet implemented.', + flags: ['Ephemeral'] + }); + } catch (error) { + this.logger.error('Error in handleSelectMenu:', { + error: error.message, + customId: interaction.customId, + userId: interaction.user.id + }); + } + } + + async handleVersion(interaction) { + try { + const uptimeHours = Math.floor(process.uptime() / 3600); + const uptimeMinutes = Math.floor((process.uptime() % 3600) / 60); + const uptimeSeconds = Math.floor(process.uptime() % 60); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿค– BattleBot Version Information') + .setColor('#0099ff') + .addFields( + { + name: '๐Ÿ“ฆ Bot Version', + value: `\`v${packageInfo.version}\``, + inline: true + }, + { + name: '๐Ÿ•’ Uptime', + value: `${uptimeHours}h ${uptimeMinutes}m ${uptimeSeconds}s`, + inline: true + }, + { + name: 'โšก Node.js Version', + value: `\`${process.version}\``, + inline: true + }, + { + name: '๐Ÿ’ป Platform', + value: `\`${process.platform} ${process.arch}\``, + inline: true + }, + { + name: '๐ŸŽฎ Discord.js Version', + value: `\`v${require('discord.js').version}\``, + inline: true + }, + { + name: '๐Ÿ“… Last Updated', + value: new Date().toISOString().split('T')[0], + inline: true + } + ) + .setFooter({ + text: `${packageInfo.name} | VRBattles`, + iconURL: 'https://vrbattles.gg/favicon.ico' + }) + .setTimestamp(); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('๐Ÿ“– Documentation') + .setStyle(ButtonStyle.Link) + .setURL('https://help.vrbattles.gg'), + new ButtonBuilder() + .setLabel('๐ŸŒ VRBattles') + .setStyle(ButtonStyle.Link) + .setURL('https://vrbattles.gg') + ); + + await interaction.editReply({ + embeds: [embed], + components: [row] + }); + } catch (error) { + this.logger.error('Error in handleVersion:', error); + await interaction.editReply({ + content: 'โŒ An error occurred while fetching version information.', + flags: ['Ephemeral'] + }); + } + } + createActionRow(username) { return new ActionRowBuilder().addComponents( new ButtonBuilder() diff --git a/src/commands/HelpCommand.js b/src/commands/HelpCommand.js new file mode 100644 index 0000000..d0cd334 --- /dev/null +++ b/src/commands/HelpCommand.js @@ -0,0 +1,578 @@ +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); +const packageInfo = require('../../package.json'); + +class HelpCommand { + constructor(supabase, logger) { + this.supabase = supabase; + this.logger = logger; + } + + async handleHelp(interaction) { + try { + const category = interaction.options.getString('category'); + const guildId = interaction.guildId; + + // Check server setup status + const setupStatus = await this.checkServerSetupStatus(guildId); + + if (!category) { + // Show main help menu with setup status + await this.showMainHelp(interaction, setupStatus); + } else { + // Show specific category help + await this.showCategoryHelp(interaction, category, setupStatus); + } + } catch (error) { + this.logger.error('Error in handleHelp:', error); + await interaction.editReply({ + content: 'โŒ An error occurred while loading help. Please try again.' + }); + } + } + + async checkServerSetupStatus(guildId) { + try { + // Check if server is registered + const { data: server, error: serverError } = await this.supabase + .from('servers') + .select('*') + .eq('discord_server_id', guildId) + .single(); + + const isRegistered = !!server && !serverError; + + // Check subscriptions if registered + let subscriptions = []; + if (isRegistered) { + const { data: subs, error: subError } = await this.supabase + .from('active_subscriptions') + .select('game_name') + .eq('discord_server_id', guildId); + + if (!subError) { + subscriptions = subs || []; + } + } + + return { + isRegistered, + subscriptionCount: subscriptions.length, + subscriptions: subscriptions.map(s => s.game_name), + setupComplete: isRegistered && subscriptions.length > 0 + }; + } catch (error) { + this.logger.error('Error checking setup status:', error); + return { + isRegistered: false, + subscriptionCount: 0, + subscriptions: [], + setupComplete: false + }; + } + } + + async showMainHelp(interaction, setupStatus) { + const embed = new EmbedBuilder() + .setTitle('๐Ÿค– BattleBot Help Center') + .setDescription('Welcome to BattleBot! Your gateway to VRBattles data and notifications.') + .setColor(setupStatus.setupComplete ? '#00ff00' : '#ffaa00') + .setFooter({ + text: `BattleBot v${packageInfo.version} | VRBattles`, + iconURL: 'https://vrbattles.gg/favicon.ico' + }); + + // Add setup status section + const setupEmoji = setupStatus.setupComplete ? 'โœ…' : 'โš ๏ธ'; + const setupText = this.getSetupStatusText(setupStatus); + embed.addFields({ + name: `${setupEmoji} Server Setup Status`, + value: setupText, + inline: false + }); + + // Add quick start if not set up + if (!setupStatus.setupComplete) { + embed.addFields({ + name: '๐Ÿš€ Quick Start', + value: '1. `/register_server` - Register your server\n2. `/subscribe` - Subscribe to game notifications\n3. `/finduser` - Start searching players!', + inline: false + }); + } + + // Add help categories + embed.addFields({ + name: '๐Ÿ“š Help Categories', + value: '**๐Ÿ  Getting Started** - Setup and first steps\n' + + '**๐Ÿ” Search Commands** - Find players, teams, and matches\n' + + '**โš™๏ธ Admin & Setup** - Server configuration\n' + + '**๐Ÿ”” Notifications** - Match alerts and subscriptions\n' + + '**โ“ Troubleshooting** - Common issues and solutions', + inline: false + }); + + // Create action buttons + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_getting_started') + .setLabel('๐Ÿ  Getting Started') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('help_search') + .setLabel('๐Ÿ” Search Commands') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('help_admin') + .setLabel('โš™๏ธ Admin & Setup') + .setStyle(ButtonStyle.Secondary) + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_notifications') + .setLabel('๐Ÿ”” Notifications') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('help_troubleshooting') + .setLabel('โ“ Troubleshooting') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setLabel('๐Ÿ“– Full Documentation') + .setStyle(ButtonStyle.Link) + .setURL('https://help.vrbattles.gg') + ); + + await interaction.editReply({ + embeds: [embed], + components: [row, row2] + }); + } + + async showCategoryHelp(interaction, category, setupStatus) { + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTimestamp() + .setFooter({ + text: `BattleBot v${packageInfo.version} | VRBattles`, + iconURL: 'https://vrbattles.gg/favicon.ico' + }); + + switch (category) { + case 'getting_started': + embed.setTitle('๐Ÿ  Getting Started with BattleBot') + .setDescription('Follow these steps to set up BattleBot in your server:') + .addFields( + { + name: '1๏ธโƒฃ Register Your Server', + value: '`/register_server` - This connects your Discord server to BattleBot\n' + + `Status: ${setupStatus.isRegistered ? 'โœ… Complete' : 'โŒ Not done'}`, + inline: false + }, + { + name: '2๏ธโƒฃ Subscribe to Games', + value: '`/subscribe` - Choose games and channels for notifications\n' + + `Status: ${setupStatus.subscriptionCount > 0 ? `โœ… ${setupStatus.subscriptionCount} games` : 'โŒ No subscriptions'}`, + inline: false + }, + { + name: '3๏ธโƒฃ Start Using Commands', + value: '`/finduser` - Search for players\n`/findteam` - Find teams\n`/matchhistory` - View match data', + inline: false + } + ); + break; + + case 'search': + embed.setTitle('๐Ÿ” Search Commands') + .setDescription('Find players, teams, and match data:') + .addFields( + { + name: '๐Ÿ‘ค `/finduser`', + value: 'Search for a player by username in specific games\n' + + 'โ€ข Shows stats, teams, and recent matches\n' + + 'โ€ข Requires game subscription to use', + inline: false + }, + { + name: '๐Ÿ‘ฅ `/findteam`', + value: 'Find teams by name in specific games\n' + + 'โ€ข Shows team stats and roster\n' + + 'โ€ข Displays win rates and match history', + inline: false + }, + { + name: '๐Ÿ“Š `/matchhistory`', + value: 'View detailed match history for any player\n' + + 'โ€ข Optional game filter\n' + + 'โ€ข Shows recent performance trends', + inline: false + } + ); + break; + + case 'admin': + embed.setTitle('โš™๏ธ Admin & Setup Commands') + .setDescription('Server management and configuration (Admin only):') + .addFields( + { + name: '๐Ÿ”ง `/register_server`', + value: 'Connect your Discord server to BattleBot\n' + + 'โ€ข Required before using other features\n' + + 'โ€ข Safe to run multiple times', + inline: false + }, + { + name: '๐Ÿ“‹ `/list_subscriptions`', + value: 'View all active game subscriptions\n' + + 'โ€ข Shows which games and channels\n' + + 'โ€ข Helps manage notifications', + inline: false + } + ); + break; + + case 'notifications': + embed.setTitle('๐Ÿ”” Notification Commands') + .setDescription('Manage game notifications and alerts:') + .addFields( + { + name: 'โž• `/subscribe`', + value: 'Subscribe to match notifications for specific games\n' + + 'โ€ข Choose game and notification channel\n' + + 'โ€ข Get alerts for new matches', + inline: false + }, + { + name: 'โž– `/unsubscribe`', + value: 'Remove game notification subscriptions\n' + + 'โ€ข Stop notifications for specific games\n' + + 'โ€ข Clean up unused subscriptions', + inline: false + }, + { + name: '๐ŸŽฎ Supported Games', + value: setupStatus.subscriptions.length > 0 + ? `**Your subscriptions:** ${setupStatus.subscriptions.join(', ')}` + : 'VAIL, Echo Arena, Nock, Breachers, Gun Raiders, and more!', + inline: false + } + ); + break; + + case 'troubleshooting': + embed.setTitle('โ“ Troubleshooting & Common Issues') + .setDescription('Solutions to common problems:') + .addFields( + { + name: 'โŒ "No game subscriptions found"', + value: '**Solution:** Use `/subscribe` to add games first\n' + + '**Why:** Search commands only work with subscribed games', + inline: false + }, + { + name: '๐Ÿ”’ "Missing permissions"', + value: '**Solution:** Ensure you have Administrator permissions\n' + + '**Commands affected:** `/register_server`, `/subscribe`, `/unsubscribe`', + inline: false + }, + { + name: '๐Ÿ” "User/Team not found"', + value: '**Solutions:**\nโ€ข Check spelling and exact username\nโ€ข Try different games\nโ€ข User might not have played recently', + inline: false + }, + { + name: 'โฐ Rate Limits', + value: '**What:** Commands have cooldowns to prevent spam\n' + + '**Wait times:** Search (3s), Admin (5s), Ping (1s)', + inline: false + } + ); + break; + } + + // Add back button + const backButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_back') + .setLabel('โฌ…๏ธ Back to Main Help') + .setStyle(ButtonStyle.Secondary) + ); + + await interaction.editReply({ + embeds: [embed], + components: [backButton] + }); + } + + getSetupStatusText(setupStatus) { + if (setupStatus.setupComplete) { + return `**โœ… Setup Complete!**\n` + + `โ€ข Server: Registered\n` + + `โ€ข Games: ${setupStatus.subscriptionCount} subscribed\n` + + `โ€ข Ready to use all commands!`; + } else if (setupStatus.isRegistered) { + return `**โš ๏ธ Partial Setup**\n` + + `โ€ข Server: โœ… Registered\n` + + `โ€ข Games: โŒ No subscriptions\n` + + `โ€ข Next: Use \`/subscribe\` to add games`; + } else { + return `**โŒ Setup Required**\n` + + `โ€ข Server: โŒ Not registered\n` + + `โ€ข Games: โŒ No subscriptions\n` + + `โ€ข Next: Use \`/register_server\` to start`; + } + } + + async showMainHelpButton(interaction, setupStatus) { + const embed = new EmbedBuilder() + .setTitle('๐Ÿค– BattleBot Help Center') + .setDescription('Welcome to BattleBot! Your gateway to VRBattles data and notifications.') + .setColor(setupStatus.setupComplete ? '#00ff00' : '#ffaa00') + .setFooter({ + text: `BattleBot v${packageInfo.version} | VRBattles`, + iconURL: 'https://vrbattles.gg/favicon.ico' + }); + + // Add setup status section + const setupEmoji = setupStatus.setupComplete ? 'โœ…' : 'โš ๏ธ'; + const setupText = this.getSetupStatusText(setupStatus); + embed.addFields({ + name: `${setupEmoji} Server Setup Status`, + value: setupText, + inline: false + }); + + // Add quick start if not set up + if (!setupStatus.setupComplete) { + embed.addFields({ + name: '๐Ÿš€ Quick Start', + value: '1. `/register_server` - Register your server\n2. `/subscribe` - Subscribe to game notifications\n3. `/finduser` - Start searching players!', + inline: false + }); + } + + // Add help categories + embed.addFields({ + name: '๐Ÿ“š Help Categories', + value: '**๐Ÿ  Getting Started** - Setup and first steps\n' + + '**๐Ÿ” Search Commands** - Find players, teams, and matches\n' + + '**โš™๏ธ Admin & Setup** - Server configuration\n' + + '**๐Ÿ”” Notifications** - Match alerts and subscriptions\n' + + '**โ“ Troubleshooting** - Common issues and solutions', + inline: false + }); + + // Create action buttons + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_getting_started') + .setLabel('๐Ÿ  Getting Started') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('help_search') + .setLabel('๐Ÿ” Search Commands') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('help_admin') + .setLabel('โš™๏ธ Admin & Setup') + .setStyle(ButtonStyle.Secondary) + ); + + const row2 = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_notifications') + .setLabel('๐Ÿ”” Notifications') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('help_troubleshooting') + .setLabel('โ“ Troubleshooting') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setLabel('๐Ÿ“– Full Documentation') + .setStyle(ButtonStyle.Link) + .setURL('https://help.vrbattles.gg') + ); + + await interaction.update({ + embeds: [embed], + components: [row, row2] + }); + } + + async showCategoryHelpButton(interaction, category, setupStatus) { + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTimestamp() + .setFooter({ + text: `BattleBot v${packageInfo.version} | VRBattles`, + iconURL: 'https://vrbattles.gg/favicon.ico' + }); + + switch (category) { + case 'getting_started': + embed.setTitle('๐Ÿ  Getting Started with BattleBot') + .setDescription('Follow these steps to set up BattleBot in your server:') + .addFields( + { + name: '1๏ธโƒฃ Register Your Server', + value: '`/register_server` - This connects your Discord server to BattleBot\n' + + `Status: ${setupStatus.isRegistered ? 'โœ… Complete' : 'โŒ Not done'}`, + inline: false + }, + { + name: '2๏ธโƒฃ Subscribe to Games', + value: '`/subscribe` - Choose games and channels for notifications\n' + + `Status: ${setupStatus.subscriptionCount > 0 ? `โœ… ${setupStatus.subscriptionCount} games` : 'โŒ No subscriptions'}`, + inline: false + }, + { + name: '3๏ธโƒฃ Start Using Commands', + value: '`/finduser` - Search for players\n`/findteam` - Find teams\n`/matchhistory` - View match data', + inline: false + } + ); + break; + + case 'search': + embed.setTitle('๐Ÿ” Search Commands') + .setDescription('Find players, teams, and match data:') + .addFields( + { + name: '๐Ÿ‘ค `/finduser`', + value: 'Search for a player by username in specific games\n' + + 'โ€ข Shows stats, teams, and recent matches\n' + + 'โ€ข Requires game subscription to use', + inline: false + }, + { + name: '๐Ÿ‘ฅ `/findteam`', + value: 'Find teams by name in specific games\n' + + 'โ€ข Shows team stats and roster\n' + + 'โ€ข Displays win rates and match history', + inline: false + }, + { + name: '๐Ÿ“Š `/matchhistory`', + value: 'View detailed match history for any player\n' + + 'โ€ข Optional game filter\n' + + 'โ€ข Shows recent performance trends', + inline: false + } + ); + break; + + case 'admin': + embed.setTitle('โš™๏ธ Admin & Setup Commands') + .setDescription('Server management and configuration (Admin only):') + .addFields( + { + name: '๐Ÿ”ง `/register_server`', + value: 'Connect your Discord server to BattleBot\n' + + 'โ€ข Required before using other features\n' + + 'โ€ข Safe to run multiple times', + inline: false + }, + { + name: '๐Ÿ“‹ `/list_subscriptions`', + value: 'View all active game subscriptions\n' + + 'โ€ข Shows which games and channels\n' + + 'โ€ข Helps manage notifications', + inline: false + } + ); + break; + + case 'notifications': + embed.setTitle('๐Ÿ”” Notification Commands') + .setDescription('Manage game notifications and alerts:') + .addFields( + { + name: 'โž• `/subscribe`', + value: 'Subscribe to match notifications for specific games\n' + + 'โ€ข Choose game and notification channel\n' + + 'โ€ข Get alerts for new matches', + inline: false + }, + { + name: 'โž– `/unsubscribe`', + value: 'Remove game notification subscriptions\n' + + 'โ€ข Stop notifications for specific games\n' + + 'โ€ข Clean up unused subscriptions', + inline: false + }, + { + name: '๐ŸŽฎ Supported Games', + value: setupStatus.subscriptions.length > 0 + ? `**Your subscriptions:** ${setupStatus.subscriptions.join(', ')}` + : 'VAIL, Echo Arena, Nock, Breachers, Gun Raiders, and more!', + inline: false + } + ); + break; + + case 'troubleshooting': + embed.setTitle('โ“ Troubleshooting & Common Issues') + .setDescription('Solutions to common problems:') + .addFields( + { + name: 'โŒ "No game subscriptions found"', + value: '**Solution:** Use `/subscribe` to add games first\n' + + '**Why:** Search commands only work with subscribed games', + inline: false + }, + { + name: '๐Ÿ”’ "Missing permissions"', + value: '**Solution:** Ensure you have Administrator permissions\n' + + '**Commands affected:** `/register_server`, `/subscribe`, `/unsubscribe`', + inline: false + }, + { + name: '๐Ÿ” "User/Team not found"', + value: '**Solutions:**\nโ€ข Check spelling and exact username\nโ€ข Try different games\nโ€ข User might not have played recently', + inline: false + }, + { + name: 'โฐ Rate Limits', + value: '**What:** Commands have cooldowns to prevent spam\n' + + '**Wait times:** Search (3s), Admin (5s), Ping (1s)', + inline: false + } + ); + break; + } + + // Add back button + const backButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('help_back') + .setLabel('โฌ…๏ธ Back to Main Help') + .setStyle(ButtonStyle.Secondary) + ); + + await interaction.update({ + embeds: [embed], + components: [backButton] + }); + } + + async handleHelpButton(interaction) { + try { + const [action, category] = interaction.customId.split('_'); + + if (category === 'back') { + const setupStatus = await this.checkServerSetupStatus(interaction.guildId); + await this.showMainHelpButton(interaction, setupStatus); + } else { + const setupStatus = await this.checkServerSetupStatus(interaction.guildId); + await this.showCategoryHelpButton(interaction, category, setupStatus); + } + } catch (error) { + this.logger.error('Error in handleHelpButton:', error); + await interaction.reply({ + content: 'โŒ An error occurred while processing the help button.', + ephemeral: true + }); + } + } +} + +module.exports = HelpCommand; \ No newline at end of file diff --git a/src/scripts/generateInvite.js b/src/scripts/generateInvite.js index 6726b54..25c418c 100644 --- a/src/scripts/generateInvite.js +++ b/src/scripts/generateInvite.js @@ -16,46 +16,105 @@ async function generateInvite() { const invite = client.generateInvite({ scopes: ['bot', 'applications.commands'], permissions: [ + // Basic Discord permissions PermissionsBitField.Flags.ViewChannel, PermissionsBitField.Flags.SendMessages, + PermissionsBitField.Flags.SendMessagesInThreads, PermissionsBitField.Flags.EmbedLinks, PermissionsBitField.Flags.AttachFiles, + PermissionsBitField.Flags.ReadMessageHistory, + PermissionsBitField.Flags.UseExternalEmojis, + PermissionsBitField.Flags.AddReactions, + + // Application commands (slash commands) PermissionsBitField.Flags.UseApplicationCommands, + + // Message management for bot maintenance PermissionsBitField.Flags.ManageMessages, + + // Channel management for notifications setup + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.ManageChannels, + + // Advanced features + PermissionsBitField.Flags.CreatePublicThreads, + PermissionsBitField.Flags.CreatePrivateThreads, + PermissionsBitField.Flags.UseExternalStickers, + + // Voice permissions (if needed for future features) + PermissionsBitField.Flags.Connect, + PermissionsBitField.Flags.Speak, ] }); - console.log('\n=== Bot Invite Link Generator ==='); - console.log('\nCurrent Bot Status:'); + console.log('\n๐Ÿค– ===== VRBattles Discord Bot Invite Generator ===== ๐Ÿค–'); + console.log('\n๐Ÿ“Š Current Bot Status:'); console.log(`Name: ${client.user.tag}`); console.log(`ID: ${client.user.id}`); + console.log(`Created: ${client.user.createdAt.toDateString()}`); - console.log('\nCurrent Servers:'); + console.log('\n๐Ÿ  Current Servers:'); if (client.guilds.cache.size === 0) { - console.log('โŒ Bot is not in any servers'); + console.log('โŒ Bot is not in any servers yet'); } else { + console.log(`โœ… Bot is active in ${client.guilds.cache.size} server(s):`); client.guilds.cache.forEach(guild => { - console.log(`- ${guild.name} (ID: ${guild.id})`); + console.log(` ๐ŸŽฎ ${guild.name} (${guild.memberCount} members) - ID: ${guild.id}`); }); } - console.log('\n=== Invite Link ==='); - console.log('Use this link to invite the bot to your server:'); - console.log(invite); + console.log('\n๐Ÿ”— ===== Bot Invite Link ===== ๐Ÿ”—'); + console.log('Copy and share this link to invite VRBattles Bot to any Discord server:'); + console.log('\n' + invite + '\n'); - console.log('\n=== Next Steps ==='); - console.log('1. Click the link above'); - console.log('2. Select your server from the dropdown'); - console.log('3. Keep all permissions checked'); - console.log('4. Click "Authorize"'); - console.log('\nAfter adding the bot:'); - console.log('1. Enable Developer Mode in Discord (User Settings > App Settings > Advanced)'); - console.log('2. Right-click your notification channel'); - console.log('3. Click "Copy ID"'); - console.log('4. Update your .env file with the new channel ID'); + console.log('๐ŸŽฏ ===== Bot Features ===== ๐ŸŽฏ'); + console.log('โœ… Interactive Help System (/help)'); + console.log('โœ… Player Search (/finduser)'); + console.log('โœ… Team Search (/findteam) with pagination'); + console.log('โœ… Match History (/matchhistory)'); + console.log('โœ… Game Subscriptions (/subscribe, /unsubscribe)'); + console.log('โœ… Server Registration (/register_server)'); + console.log('โœ… Real-time Match Notifications'); + console.log('โœ… Dynamic Autocomplete'); + + console.log('\nโš™๏ธ ===== Permissions Included ===== โš™๏ธ'); + console.log('โœ… View Channels & Send Messages'); + console.log('โœ… Slash Commands Support'); + console.log('โœ… Embed Links & File Attachments'); + console.log('โœ… Message & Channel Management'); + console.log('โœ… Emoji & Reaction Support'); + console.log('โœ… Thread Management'); + console.log('โœ… Voice Channel Access (future features)'); + + console.log('\n๐Ÿš€ ===== Setup Instructions ===== ๐Ÿš€'); + console.log('1. ๐Ÿ”— Click the invite link above'); + console.log('2. ๐Ÿ  Select your Discord server from the dropdown'); + console.log('3. โœ… Keep ALL permissions checked (required for full functionality)'); + console.log('4. ๐ŸŽ‰ Click "Authorize" to add the bot'); + + console.log('\n๐Ÿ“ ===== After Adding the Bot ===== ๐Ÿ“'); + console.log('1. ๐Ÿ’ฌ Run /help to see all available commands'); + console.log('2. ๐Ÿ”ง Run /register_server to connect your server'); + console.log('3. ๐ŸŽฎ Run /subscribe to set up game notifications'); + console.log('4. ๐Ÿ” Try /finduser to search for players!'); + + console.log('\n๐Ÿ”ง ===== Admin Setup (Channel IDs) ===== ๐Ÿ”ง'); + console.log('To get Discord Channel IDs for notifications:'); + console.log('1. Enable Developer Mode: User Settings > App Settings > Advanced > Developer Mode'); + console.log('2. Right-click any text channel > Copy ID'); + console.log('3. Use the ID in /subscribe command'); + + console.log('\n๐Ÿ“š ===== Documentation ===== ๐Ÿ“š'); + console.log('Full documentation: https://help.vrbattles.gg'); + console.log('VRBattles website: https://www.vrbattles.gg'); + + console.log('\nโœจ VRBattles Discord Bot is ready to enhance your VR gaming community! โœจ\n'); } catch (error) { - console.error('Error:', error); + console.error('โŒ Error generating invite:', error); + if (error.code === 'TOKEN_INVALID') { + console.error('\n๐Ÿ”‘ Invalid bot token. Please check your .env file and ensure BOT_TOKEN is set correctly.'); + } } finally { client.destroy(); } diff --git a/src/scripts/syncGameChoices.js b/src/scripts/syncGameChoices.js new file mode 100644 index 0000000..4f5226c --- /dev/null +++ b/src/scripts/syncGameChoices.js @@ -0,0 +1,80 @@ +const { createClient } = require('@supabase/supabase-js'); +const fs = require('fs'); +const path = require('path'); + +async function syncGameChoices() { + try { + console.log('๐Ÿ”„ Syncing game choices from Supabase...'); + + // Initialize Supabase client + const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_KEY + ); + + // Fetch all active games + const { data: games, error } = await supabase + .from('games') + .select('id, name') + .eq('active', true) + .order('name'); + + if (error) { + console.error('โŒ Failed to fetch games:', error); + process.exit(1); + } + + console.log(`โœ… Found ${games.length} active games`); + + // Generate choices array + const choices = games.map(game => ({ + name: game.name, + value: game.name + })); + + // Generate the choices code + const choicesCode = choices + .map(choice => ` { name: "${choice.name}", value: "${choice.value}" }`) + .join(',\n'); + + console.log('\n๐Ÿ“‹ Generated choices:'); + choices.forEach(choice => console.log(` ๐ŸŽฎ ${choice.name}`)); + + // Read current deploy-commands.js + const deployPath = path.join(__dirname, '../../deploy-commands.js'); + let deployContent = fs.readFileSync(deployPath, 'utf8'); + + // Pattern to match the game choices in addChoices() + const choicesPattern = /\.addChoices\(\s*([^)]+)\s*\)/g; + + // Replace all instances with updated choices + const newChoicesBlock = `.addChoices( +${choicesCode} + )`; + + deployContent = deployContent.replace(choicesPattern, newChoicesBlock); + + // Write updated file + fs.writeFileSync(deployPath, deployContent); + + console.log('\nโœ… Updated deploy-commands.js with current game choices'); + console.log('๐Ÿš€ Run "node deploy-commands.js" to deploy the updated commands'); + + // Show diff + console.log('\n๐Ÿ“Š Summary:'); + console.log(` Database games: ${games.length}`); + console.log(` Generated choices: ${choices.length}`); + console.log(' Files updated: deploy-commands.js'); + + } catch (error) { + console.error('โŒ Error syncing game choices:', error); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + syncGameChoices(); +} + +module.exports = syncGameChoices; \ No newline at end of file diff --git a/src/scripts/testSupabaseConnection.js b/src/scripts/testSupabaseConnection.js new file mode 100644 index 0000000..0c91e12 --- /dev/null +++ b/src/scripts/testSupabaseConnection.js @@ -0,0 +1,134 @@ +require('dotenv').config(); +const { createClient } = require('@supabase/supabase-js'); + +async function testSupabaseConnection() { + console.log('๐Ÿ”ง Testing Supabase Connection...\n'); + + // Check environment variables + console.log('๐Ÿ“‹ Environment Variables:'); + console.log(`SUPABASE_URL: ${process.env.SUPABASE_URL ? 'โœ… Set' : 'โŒ Missing'}`); + console.log(`SUPABASE_KEY: ${process.env.SUPABASE_KEY ? 'โœ… Set' : 'โŒ Missing'}`); + + if (!process.env.SUPABASE_URL || !process.env.SUPABASE_KEY) { + console.log('\nโŒ Missing required environment variables'); + console.log('Make sure you have SUPABASE_URL and SUPABASE_KEY set in your .env file'); + return; + } + + console.log(`\nURL: ${process.env.SUPABASE_URL}`); + console.log(`Key: ${process.env.SUPABASE_KEY.substring(0, 20)}...\n`); + + try { + // Initialize Supabase client (same as in main bot) + const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_KEY + ); + + console.log('๐Ÿ“ก Testing basic connection...'); + + // Test 1: Check basic connection + const { data, error } = await supabase.from('games').select('count', { count: 'exact', head: true }); + if (error) { + console.log('โŒ Basic connection failed:', error.message); + return; + } + console.log('โœ… Basic connection successful'); + + // Test 2: Fetch all games + console.log('\n๐ŸŽฎ Testing games table...'); + const { data: games, error: gamesError } = await supabase + .from('games') + .select('id, name, active') + .order('name'); + + if (gamesError) { + console.log('โŒ Games query failed:', gamesError.message); + return; + } + + console.log(`โœ… Found ${games.length} total games:`); + games.forEach(game => { + console.log(` ${game.active ? 'โœ…' : 'โŒ'} ${game.name} (ID: ${game.id})`); + }); + + // Test 3: Fetch active games only + console.log('\n๐Ÿ”ฅ Testing active games filter...'); + const { data: activeGames, error: activeError } = await supabase + .from('games') + .select('id, name') + .eq('active', true) + .order('name'); + + if (activeError) { + console.log('โŒ Active games query failed:', activeError.message); + return; + } + + console.log(`โœ… Found ${activeGames.length} active games:`); + activeGames.forEach(game => { + console.log(` ๐ŸŽฏ ${game.name} (ID: ${game.id})`); + }); + + // Test 4: Test servers table + console.log('\n๐Ÿ  Testing servers table...'); + const { data: servers, error: serversError } = await supabase + .from('servers') + .select('id, discord_server_id, server_name, active') + .limit(5); + + if (serversError) { + console.log('โŒ Servers query failed:', serversError.message); + } else { + console.log(`โœ… Found ${servers.length} servers (showing first 5):`); + servers.forEach(server => { + console.log(` ${server.active ? 'โœ…' : 'โŒ'} ${server.server_name} (Discord ID: ${server.discord_server_id})`); + }); + } + + // Test 5: Test active_subscriptions view + console.log('\n๐Ÿ“‹ Testing active_subscriptions view...'); + const { data: subscriptions, error: subsError } = await supabase + .from('active_subscriptions') + .select('*') + .limit(5); + + if (subsError) { + console.log('โŒ Active subscriptions query failed:', subsError.message); + } else { + console.log(`โœ… Found ${subscriptions.length} active subscriptions (showing first 5):`); + subscriptions.forEach(sub => { + console.log(` ๐ŸŽฎ ${sub.game_name} โ†’ Server ${sub.discord_server_id} โ†’ Channel ${sub.notification_channel_id}`); + }); + } + + // Test 6: Test specific game lookup (simulate autocomplete) + console.log('\n๐Ÿ” Testing specific game lookup (autocomplete simulation)...'); + const testGameName = 'VAIL'; // Try to find a common game + const { data: specificGame, error: specificError } = await supabase + .from('games') + .select('id, name') + .eq('name', testGameName) + .eq('active', true) + .single(); + + if (specificError) { + console.log(`โŒ Specific game lookup failed for "${testGameName}":`, specificError.message); + } else { + console.log(`โœ… Found specific game: ${specificGame.name} (ID: ${specificGame.id})`); + } + + console.log('\n๐ŸŽ‰ All tests completed successfully!'); + console.log('\n๐Ÿ’ก If autocomplete still isn\'t working, check:'); + console.log(' 1. Environment variables are set correctly in Coolify'); + console.log(' 2. Bot has been restarted after fixing the code'); + console.log(' 3. Discord slash commands have been re-deployed'); + console.log(' 4. Try the /subscribe command to see if games appear there'); + + } catch (error) { + console.log('โŒ Unexpected error:', error.message); + console.log('Stack:', error.stack); + } +} + +testSupabaseConnection().catch(console.error); \ No newline at end of file diff --git a/src/services/NotificationService.js b/src/services/NotificationService.js index 913ddc7..a25979d 100644 --- a/src/services/NotificationService.js +++ b/src/services/NotificationService.js @@ -23,6 +23,15 @@ class NotificationService { console.log(`Notification service listening on port ${port}`); resolve(); }); + + this.server.on('error', (error) => { + if (error.code === 'EADDRINUSE') { + console.log(`Port ${port} is in use, trying port ${port + 1}`); + this.start(port + 1).then(resolve).catch(reject); + } else { + reject(error); + } + }); } catch (error) { reject(error); } diff --git a/src/utils/PaginationManager.js b/src/utils/PaginationManager.js new file mode 100644 index 0000000..d8e122d --- /dev/null +++ b/src/utils/PaginationManager.js @@ -0,0 +1,205 @@ +const { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } = require('discord.js'); + +class PaginationManager { + constructor(logger) { + this.logger = logger; + this.activePaginations = new Map(); // Store active paginations by interaction id + } + + createPaginatedResponse(items, itemsPerPage = 5, embedBuilder, title = 'Results') { + const totalPages = Math.ceil(items.length / itemsPerPage); + const pages = []; + + for (let i = 0; i < totalPages; i++) { + const startIdx = i * itemsPerPage; + const pageItems = items.slice(startIdx, startIdx + itemsPerPage); + const embed = embedBuilder(pageItems, i + 1, totalPages); + pages.push(embed); + } + + return { + pages, + totalPages, + currentPage: 0, + totalItems: items.length + }; + } + + createNavigationButtons(currentPage, totalPages, customId) { + const row = new ActionRowBuilder(); + + // First page button + row.addComponents( + new ButtonBuilder() + .setCustomId(`${customId}_first`) + .setLabel('โฎ๏ธ First') + .setStyle(ButtonStyle.Secondary) + .setDisabled(currentPage === 0) + ); + + // Previous page button + row.addComponents( + new ButtonBuilder() + .setCustomId(`${customId}_prev`) + .setLabel('โ—€๏ธ Previous') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0) + ); + + // Page indicator + row.addComponents( + new ButtonBuilder() + .setCustomId(`${customId}_info`) + .setLabel(`${currentPage + 1} / ${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true) + ); + + // Next page button + row.addComponents( + new ButtonBuilder() + .setCustomId(`${customId}_next`) + .setLabel('Next โ–ถ๏ธ') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage >= totalPages - 1) + ); + + // Last page button + row.addComponents( + new ButtonBuilder() + .setCustomId(`${customId}_last`) + .setLabel('Last โญ๏ธ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(currentPage >= totalPages - 1) + ); + + return row; + } + + async sendPaginatedMessage(interaction, paginatedData, customId) { + if (paginatedData.totalPages === 0) { + await interaction.editReply({ + content: 'โŒ No results found.', + components: [] + }); + return; + } + + if (paginatedData.totalPages === 1) { + // No pagination needed for single page + await interaction.editReply({ + embeds: [paginatedData.pages[0]], + components: [] + }); + return; + } + + // Store pagination data + this.activePaginations.set(interaction.id, { + ...paginatedData, + userId: interaction.user.id, + expiresAt: Date.now() + (15 * 60 * 1000) // 15 minutes + }); + + const navigationRow = this.createNavigationButtons( + paginatedData.currentPage, + paginatedData.totalPages, + customId + ); + + await interaction.editReply({ + embeds: [paginatedData.pages[paginatedData.currentPage]], + components: [navigationRow] + }); + + // Clean up expired paginations + this.cleanupExpiredPaginations(); + } + + async handlePaginationButton(interaction) { + try { + const [baseId, action] = interaction.customId.split('_'); + const paginationData = this.activePaginations.get(interaction.message.interaction?.id); + + if (!paginationData) { + await interaction.reply({ + content: 'โŒ This pagination has expired. Please run the command again.', + flags: ['Ephemeral'] + }); + return; + } + + // Check if user is the one who initiated the command + if (paginationData.userId !== interaction.user.id) { + await interaction.reply({ + content: 'โŒ Only the user who ran the command can navigate the results.', + flags: ['Ephemeral'] + }); + return; + } + + let newPage = paginationData.currentPage; + + switch (action) { + case 'first': + newPage = 0; + break; + case 'prev': + newPage = Math.max(0, paginationData.currentPage - 1); + break; + case 'next': + newPage = Math.min(paginationData.totalPages - 1, paginationData.currentPage + 1); + break; + case 'last': + newPage = paginationData.totalPages - 1; + break; + case 'info': + // Info button shouldn't do anything + await interaction.deferUpdate(); + return; + } + + // Update pagination data + paginationData.currentPage = newPage; + this.activePaginations.set(interaction.message.interaction?.id, paginationData); + + // Update the message + const navigationRow = this.createNavigationButtons( + newPage, + paginationData.totalPages, + baseId + ); + + await interaction.update({ + embeds: [paginationData.pages[newPage]], + components: [navigationRow] + }); + + } catch (error) { + this.logger.error('Error handling pagination button:', error); + await interaction.reply({ + content: 'โŒ An error occurred while navigating. Please try again.', + flags: ['Ephemeral'] + }); + } + } + + cleanupExpiredPaginations() { + const now = Date.now(); + for (const [key, data] of this.activePaginations.entries()) { + if (data.expiresAt < now) { + this.activePaginations.delete(key); + } + } + } + + isPaginationButton(customId) { + return customId.includes('_first') || + customId.includes('_prev') || + customId.includes('_next') || + customId.includes('_last') || + customId.includes('_info'); + } +} + +module.exports = PaginationManager; \ No newline at end of file