const dotenv = require("dotenv"); const fs = require("fs"); const path = require("path"); const { Client, GatewayIntentBits, EmbedBuilder, SlashCommandBuilder, REST, Routes, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ModalBuilder, TextInputBuilder, TextInputStyle, } = require("discord.js"); const { pool, ensureDatabase } = require("../db"); const { GAMEMODES, GAMEMODE_LABELS, REGIONS, USERNAME_REGEX, isValidGamemode, isValidRegion, isValidTier, normalizeGamemode, normalizeRegion, normalizeTier, tierToPoints, } = require("../config"); dotenv.config({ path: path.join(__dirname, '..', '..', '.env') }); const TOKEN = process.env.DISCORD_BOT_TOKEN; const CLIENT_ID = process.env.DISCORD_CLIENT_ID; const GUILD_ID = process.env.DISCORD_GUILD_ID; if (!TOKEN) { throw new Error("DISCORD_BOT_TOKEN is required in .env"); } if (!CLIENT_ID) { throw new Error("DISCORD_CLIENT_ID is required in .env for slash command registration"); } const TIER_CHOICES = ["HT1", "LT1", "HT2", "LT2", "HT3", "LT3", "HT4", "LT4", "HT5", "LT5"]; const RESULTS_CHANNEL_ID = "1348582624930693140"; const ROLE_MAP_PATH = path.join(__dirname, "role-map.json"); const QUEUE_CONFIG_PATH = path.join(__dirname, "queue-config.json"); const TESTER_ROLE_IDS = ["1348444272302755931", "1348444228811755621"]; const TICKET_VIEWER_ROLE_IDS = [ "1348750741493383321", "1348444185488789586", "1348444108137566272", "1449436246735585301", "1348443964767735888", "1348444789409976401", ]; const QUEUE_LIMIT = 20; const TESTER_INACTIVITY_MS = 30 * 60 * 1000; const TEST_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; const TICKET_TOPIC_PREFIX = "eaglertiers-ticket:"; const queueState = Object.fromEntries( GAMEMODES.map((gamemode) => [ gamemode, { open: false, messageId: null, messageChannelId: null, queue: [], testers: new Map(), }, ]) ); const client = new Client({ intents: [GatewayIntentBits.Guilds], }); function addGamemodeChoices(option) { for (const mode of GAMEMODES) { option.addChoices({ name: GAMEMODE_LABELS[mode] || mode, value: mode }); } return option; } const slashCommands = [ new SlashCommandBuilder() .setName("profile") .setDescription("Show a player's full profile") .setDMPermission(false) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) .addStringOption((option) => option.setName("username").setDescription("Minecraft username").setRequired(true) ), new SlashCommandBuilder() .setName("settier") .setDescription("Set a player's tier result and log it to results") .setDMPermission(false) .addUserOption((option) => option.setName("discord_user").setDescription("Discord user being tested").setRequired(true) ) .addStringOption((option) => option.setName("ingame_name").setDescription("In-game username").setRequired(true) ) .addStringOption((option) => { option.setName("region").setDescription("Player region").setRequired(true); for (const region of REGIONS) { option.addChoices({ name: region, value: region }); } return option; }) .addStringOption((option) => addGamemodeChoices(option.setName("gamemode").setDescription("Gamemode").setRequired(true)) ) .addStringOption((option) => { option.setName("tier").setDescription("Tier").setRequired(true); for (const tier of TIER_CHOICES) { option.addChoices({ name: tier, value: tier }); } return option; }), new SlashCommandBuilder() .setName("removetier") .setDescription("Remove a player's tier for one gamemode") .setDMPermission(false) .addUserOption((option) => option.setName("discord_user").setDescription("Discord user being tested").setRequired(true) ) .addStringOption((option) => option.setName("ingame_name").setDescription("In-game username").setRequired(true) ) .addStringOption((option) => addGamemodeChoices(option.setName("gamemode").setDescription("Gamemode").setRequired(true)) ), new SlashCommandBuilder() .setName("start") .setDescription("Mark yourself available and open queue for a gamemode") .setDMPermission(false) .addStringOption((option) => addGamemodeChoices(option.setName("gamemode").setDescription("Gamemode").setRequired(true)) ), new SlashCommandBuilder() .setName("stop") .setDescription("Mark yourself unavailable for a gamemode queue") .setDMPermission(false) .addStringOption((option) => addGamemodeChoices(option.setName("gamemode").setDescription("Gamemode").setRequired(true)) ), new SlashCommandBuilder() .setName("next") .setDescription("Take the next player from the queue and open a ticket") .setDMPermission(false) .addStringOption((option) => addGamemodeChoices(option.setName("gamemode").setDescription("Gamemode").setRequired(true)) ), new SlashCommandBuilder() .setName("close") .setDescription("Close the current test ticket") .setDMPermission(false), new SlashCommandBuilder() .setName("skip") .setDescription("Skip the player in the current ticket (no cooldown) and close ticket") .setDMPermission(false), new SlashCommandBuilder() .setName("ticket") .setDescription("Manage access for the current ticket") .setDMPermission(false) .addSubcommand((subcommand) => subcommand .setName("add") .setDescription("Add a user to the current ticket") .addUserOption((option) => option.setName("user").setDescription("User to add").setRequired(true) ) ) .addSubcommand((subcommand) => subcommand .setName("remove") .setDescription("Remove a user from the current ticket") .addUserOption((option) => option.setName("user").setDescription("User to remove").setRequired(true) ) ), ].map((cmd) => cmd.toJSON()); function emptyTierMap() { return Object.fromEntries(GAMEMODES.map((mode) => [mode, null])); } function loadJsonFile(filePath, fallback = {}) { try { const raw = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""); return JSON.parse(raw); } catch (error) { console.warn(`Could not read ${filePath}: ${error.message}`); return fallback; } } function loadRoleMap() { return loadJsonFile(ROLE_MAP_PATH, {}); } function loadQueueConfig() { const config = loadJsonFile(QUEUE_CONFIG_PATH, {}); return Object.fromEntries( GAMEMODES.map((gamemode) => { const node = config?.[gamemode] || {}; return [ gamemode, { queueChannelId: String(node.queueChannelId || "").trim(), ticketCategoryId: String(node.ticketCategoryId || "").trim(), pingRoleId: String(node.pingRoleId || "").trim(), }, ]; }) ); } async function pingQueueRoleForNewTester(guild, gamemode, testerUserId) { const cfg = loadQueueConfig()[gamemode]; const pingRoleId = String(cfg?.pingRoleId || "").trim(); const queueChannelId = String(cfg?.queueChannelId || "").trim(); if (!pingRoleId || !queueChannelId) { return; } const channel = await guild.channels.fetch(queueChannelId).catch(() => null); if (!channel || !channel.isTextBased()) { return; } const pingMessage = await channel.send({ content: `<@&${pingRoleId}>`, allowedMentions: { roles: [pingRoleId], users: [] }, }); setTimeout(async () => { await pingMessage.delete().catch(() => null); }, 30 * 1000); } function getGamemodeRoleIds(roleMap, gamemode) { const byTier = roleMap?.[gamemode] || {}; return TIER_CHOICES.map((tier) => String(byTier[tier] || "").trim()).filter(Boolean); } function getTierRoleId(roleMap, gamemode, tier) { return String(roleMap?.[gamemode]?.[tier] || "").trim(); } function formatTierLabel(tier) { const normalized = String(tier || "").toUpperCase(); const band = normalized.startsWith("HT") ? "High Tier" : "Low Tier"; const number = normalized.slice(-1); return `${band} ${number}`; } function sanitizeChannelName(value) { return String(value || "player") .toLowerCase() .replace(/[^a-z0-9_-]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 32) || "player"; } function formatDuration(ms) { const totalMinutes = Math.max(Math.ceil(ms / 60000), 1); const days = Math.floor(totalMinutes / (60 * 24)); const hours = Math.floor((totalMinutes % (60 * 24)) / 60); const minutes = totalMinutes % 60; const parts = []; if (days > 0) { parts.push(`${days}d`); } if (hours > 0) { parts.push(`${hours}h`); } if (minutes > 0 && days === 0) { parts.push(`${minutes}m`); } return parts.join(" ") || "1m"; } async function getLatestTicketCreatedAt(discordUserId, gamemode) { const [rows] = await pool.query( `SELECT created_at FROM queue_test_tickets WHERE discord_user_id = ? AND gamemode = ? ORDER BY created_at DESC LIMIT 1`, [discordUserId, gamemode] ); return rows[0]?.created_at ? new Date(rows[0].created_at) : null; } function formatLastTicketDate(date) { if (!date) { return "Never (first ticket for this gamemode)"; } const now = Date.now(); const diffMs = Math.max(now - date.getTime(), 0); const daysAgo = Math.floor(diffMs / (24 * 60 * 60 * 1000)); const dayLabel = daysAgo === 1 ? "day" : "days"; const unix = Math.floor(date.getTime() / 1000); return ` (${daysAgo} ${dayLabel} ago)`; } function getMemberTierForGamemodeFromRoles(member, gamemode) { const roleMap = loadRoleMap(); const modeMap = roleMap?.[gamemode] || {}; const matchedTiers = TIER_CHOICES.filter((tier) => { const roleId = String(modeMap[tier] || "").trim(); return roleId && member.roles.cache.has(roleId); }); if (matchedTiers.length === 0) { return "Untested"; } matchedTiers.sort((a, b) => tierToPoints(b) - tierToPoints(a)); return matchedTiers[0]; } async function recordQueueTicket({ discordUserId, testerDiscordId, gamemode, ticketChannelId }) { await pool.query( `INSERT INTO queue_test_tickets (discord_user_id, tester_discord_id, gamemode, ticket_channel_id) VALUES (?, ?, ?, ?)`, [discordUserId, testerDiscordId, gamemode, ticketChannelId] ); } function buildQueueJoinModal(gamemode) { const modal = new ModalBuilder() .setCustomId(`queue_join_form:${gamemode}`) .setTitle(`${GAMEMODE_LABELS[gamemode] || gamemode} Queue Entry`); const ignInput = new TextInputBuilder() .setCustomId("ingame_username") .setLabel("What is your in-game username?") .setPlaceholder("Example: awesomepvper123") .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(16); const serverInput = new TextInputBuilder() .setCustomId("preferred_server") .setLabel("What is your preferred testing server?") .setPlaceholder("Example: wss://crackedpvp.club") .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(120); const regionInput = new TextInputBuilder() .setCustomId("region") .setLabel("What is your region?") .setPlaceholder("Example: NA, EU, AS, AU") .setRequired(true) .setStyle(TextInputStyle.Short) .setMaxLength(2); modal.addComponents( new ActionRowBuilder().addComponents(ignInput), new ActionRowBuilder().addComponents(serverInput), new ActionRowBuilder().addComponents(regionInput) ); return modal; } function queueRowTemplate(gamemode) { const customId = `queue_join:${gamemode}`; return new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(customId) .setLabel("Join Queue") .setStyle(ButtonStyle.Primary) ); } function renderQueueEmbed(gamemode) { const state = queueState[gamemode]; const label = GAMEMODE_LABELS[gamemode] || gamemode; const testers = Array.from(state.testers.keys()); const queueLines = state.queue.length ? state.queue .map( (entry, idx) => `**${idx + 1}.** <@${entry.userId}>${entry.ign ? ` - \`${entry.ign}\`` : ""}` ) .join("\n") : "No players in queue."; return new EmbedBuilder() .setColor(state.open ? 0x3ba55c : 0x2b2d31) .setTitle(`${label} Testing Queue`) .setDescription(state.open ? "Queue is **OPEN**." : "Queue is **CLOSED**.") .addFields( { name: `Available Testers (${testers.length})`, value: testers.length ? testers.map((id) => `<@${id}>`).join(", ") : "None", }, { name: `Queue (${state.queue.length}/${QUEUE_LIMIT})`, value: queueLines, } ) .setFooter({ text: "Use the button below to join the queue." }) .setTimestamp(); } async function registerSlashCommands() { const rest = new REST({ version: "10" }).setToken(TOKEN); if (GUILD_ID) { await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { body: slashCommands, }); console.log(`Registered slash commands for guild ${GUILD_ID}`); return; } await rest.put(Routes.applicationCommands(CLIENT_ID), { body: slashCommands, }); console.log("Registered global slash commands (can take up to 1 hour to appear)"); } async function canUseTesterCommands(interaction) { if (interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { return true; } if (!interaction.guild) { return false; } const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); if (!member) { return false; } return TESTER_ROLE_IDS.some((roleId) => member.roles.cache.has(roleId)); } async function getHydratedPlayers() { const [players] = await pool.query("SELECT id, username FROM players ORDER BY username ASC"); const [ranks] = await pool.query("SELECT player_id, gamemode, tier FROM player_ranks"); const map = new Map(); for (const p of players) { map.set(p.id, { id: p.id, username: p.username, tiers: emptyTierMap(), totalPoints: 0, }); } for (const r of ranks) { const target = map.get(r.player_id); if (!target) { continue; } target.tiers[r.gamemode] = r.tier; target.totalPoints += tierToPoints(r.tier); } return Array.from(map.values()) .sort((a, b) => { if (b.totalPoints !== a.totalPoints) { return b.totalPoints - a.totalPoints; } return a.username.localeCompare(b.username); }) .map((entry, index) => ({ ...entry, position: index + 1 })); } async function findPlayerByUsername(username) { const [rows] = await pool.query( "SELECT id, username, region FROM players WHERE LOWER(username) = LOWER(?) LIMIT 1", [username] ); return rows[0] || null; } async function upsertPlayerRegionByUsername(username, region) { await pool.query( `INSERT INTO players (username, region) VALUES (?, ?) ON DUPLICATE KEY UPDATE region = VALUES(region)`, [username, region] ); return findPlayerByUsername(username); } async function syncDiscordTierRoles({ interaction, discordUserId, gamemode, tierToAssign, }) { if (!interaction.guild) { return { ok: false, reason: "This command must be used in a server (guild)." }; } const roleMap = loadRoleMap(); const member = await interaction.guild.members.fetch(discordUserId).catch(() => null); if (!member) { return { ok: false, reason: "Could not find that Discord user in this server." }; } const allGamemodeRoleIds = getGamemodeRoleIds(roleMap, gamemode); const roleIdsToRemove = allGamemodeRoleIds.filter((roleId) => member.roles.cache.has(roleId)); try { if (roleIdsToRemove.length > 0) { await member.roles.remove( roleIdsToRemove, `EaglerTiers ${gamemode} tier update by ${interaction.user.tag}` ); } } catch (error) { return { ok: false, reason: `Failed removing old ${gamemode} tier roles: ${error.message}`, }; } if (!tierToAssign) { return { ok: true, removed: roleIdsToRemove.length, addedRoleId: null }; } const roleIdToAdd = getTierRoleId(roleMap, gamemode, tierToAssign); if (!roleIdToAdd) { return { ok: false, reason: `Role ID not configured for ${gamemode} ${tierToAssign} in server/discord/role-map.json.`, }; } try { await member.roles.add(roleIdToAdd, `EaglerTiers ${gamemode} ${tierToAssign} set by ${interaction.user.tag}`); } catch (error) { return { ok: false, reason: `Failed adding role ${roleIdToAdd} for ${gamemode} ${tierToAssign}: ${error.message}`, }; } return { ok: true, removed: roleIdsToRemove.length, addedRoleId: roleIdToAdd }; } async function sendTierResultLog({ interaction, testerId, testedDiscordId, ign, gamemode, pointsBefore, pointsAfter, earnedTier, }) { const gamemodeLabel = GAMEMODE_LABELS[gamemode] || gamemode; const delta = pointsAfter - pointsBefore; const deltaLabel = delta >= 0 ? `+${delta}` : `${delta}`; const embed = new EmbedBuilder() .setColor(0xd4af37) .setTitle(`${ign}'s ${gamemodeLabel} PvP Test Result`) .setThumbnail(`https://render.crafty.gg/3d/bust/${encodeURIComponent(ign)}`) .addFields( { name: "Tester", value: `<@${testerId}>`, inline: true }, { name: "Discord User", value: `<@${testedDiscordId}>`, inline: true }, { name: "IGN", value: `\`${ign}\``, inline: true }, { name: "Gamemode", value: gamemodeLabel, inline: true }, { name: "Points Before", value: `${pointsBefore}`, inline: true }, { name: "Points After", value: `${pointsAfter} (${deltaLabel})`, inline: true }, { name: "Rank Earned", value: formatTierLabel(earnedTier), inline: false } ) .setTimestamp(); let channel = await client.channels.fetch(RESULTS_CHANNEL_ID, { force: true }).catch(() => null); if (!channel && interaction?.guild?.channels?.fetch) { channel = await interaction.guild.channels.fetch(RESULTS_CHANNEL_ID).catch(() => null); } if (!channel) { const reason = `Results channel ${RESULTS_CHANNEL_ID} not found. Make sure the bot is in that server and channel ID is correct.`; console.warn(reason); return { ok: false, reason }; } if (!channel.isTextBased()) { const reason = `Channel ${RESULTS_CHANNEL_ID} is not text-based (type: ${channel.type}).`; console.warn(reason); return { ok: false, reason }; } try { const sent = await channel.send({ embeds: [embed] }); console.log(`[settier] Results embed sent: ${sent.id}`); return { ok: true }; } catch (error) { const code = error?.code ? ` code=${error.code}` : ""; const reason = `Failed to send message to channel ${RESULTS_CHANNEL_ID}:${code} ${error.message}`; console.warn(reason); return { ok: false, reason }; } } async function upsertQueueMessage(guild, gamemode) { const cfg = loadQueueConfig()[gamemode]; const state = queueState[gamemode]; if (!cfg?.queueChannelId) { throw new Error(`Queue channel ID is not configured for ${gamemode} in queue-config.json`); } const channel = await guild.channels.fetch(cfg.queueChannelId).catch(() => null); if (!channel || !channel.isTextBased()) { throw new Error(`Queue channel ${cfg.queueChannelId} is missing or not text-based for ${gamemode}.`); } const embed = renderQueueEmbed(gamemode); const components = [queueRowTemplate(gamemode)]; components[0].components[0].setDisabled(!state.open || state.queue.length >= QUEUE_LIMIT); let message = null; if (state.messageId && state.messageChannelId === channel.id) { message = await channel.messages.fetch(state.messageId).catch(() => null); } // Recover the existing queue message after bot restarts so start/stop edits it // instead of posting a new queue card. if (!message) { const recent = await channel.messages.fetch({ limit: 50 }).catch(() => null); if (recent) { message = recent.find((m) => { if (m.author?.id !== client.user?.id) { return false; } const hasQueueButton = m.components?.some((row) => row.components?.some((comp) => comp.customId === `queue_join:${gamemode}`) ); return Boolean(hasQueueButton); }) || null; } } if (!message) { message = await channel.send({ embeds: [embed], components }); state.messageId = message.id; state.messageChannelId = channel.id; return; } await message.edit({ embeds: [embed], components }); } async function handleStartQueue(interaction) { const gamemode = normalizeGamemode(interaction.options.getString("gamemode", true)); if (!isValidGamemode(gamemode)) { await interaction.reply({ content: "Invalid gamemode.", ephemeral: true }); return; } const cfg = loadQueueConfig()[gamemode]; if (!cfg.queueChannelId) { await interaction.reply({ content: `Queue channel not configured for ${gamemode}. Set it in server/discord/queue-config.json.`, ephemeral: true, }); return; } const state = queueState[gamemode]; const wasAlreadyAvailable = state.testers.has(interaction.user.id); state.testers.set(interaction.user.id, Date.now()); state.open = true; try { await upsertQueueMessage(interaction.guild, gamemode); } catch (error) { await interaction.reply({ content: `Failed to open/update queue message: ${error.message}`, ephemeral: true, }); return; } if (!wasAlreadyAvailable) { await pingQueueRoleForNewTester(interaction.guild, gamemode, interaction.user.id).catch((error) => { console.warn(`Failed to send queue role ping for ${gamemode}: ${error.message}`); }); } await interaction.reply({ content: `You are now available for **${GAMEMODE_LABELS[gamemode] || gamemode}**. ` + `Queue is open in <#${cfg.queueChannelId}>.`, ephemeral: true, }); } async function handleStopQueue(interaction) { const gamemode = normalizeGamemode(interaction.options.getString("gamemode", true)); if (!isValidGamemode(gamemode)) { await interaction.reply({ content: "Invalid gamemode.", ephemeral: true }); return; } const state = queueState[gamemode]; state.testers.delete(interaction.user.id); if (state.testers.size === 0) { state.open = false; state.queue = []; } try { await upsertQueueMessage(interaction.guild, gamemode); } catch (error) { await interaction.reply({ content: `Failed to update queue message: ${error.message}`, ephemeral: true, }); return; } await interaction.reply({ content: state.testers.size === 0 ? `You are unavailable for **${GAMEMODE_LABELS[gamemode] || gamemode}**. No testers remain, queue closed and cleared.` : `You are unavailable for **${GAMEMODE_LABELS[gamemode] || gamemode}**.`, ephemeral: true, }); } async function handleNextQueue(interaction) { const gamemode = normalizeGamemode(interaction.options.getString("gamemode", true)); if (!isValidGamemode(gamemode)) { await interaction.reply({ content: "Invalid gamemode.", ephemeral: true }); return; } const cfg = loadQueueConfig()[gamemode]; if (!cfg.queueChannelId || !cfg.ticketCategoryId) { await interaction.reply({ content: `Queue/ticket config missing for ${gamemode}. Fill queueChannelId and ticketCategoryId in server/discord/queue-config.json.`, ephemeral: true, }); return; } const state = queueState[gamemode]; if (!state.testers.has(interaction.user.id)) { await interaction.reply({ content: `You are not marked available for ${GAMEMODE_LABELS[gamemode] || gamemode}. Use /start first.`, ephemeral: true, }); return; } if (!state.open) { await interaction.reply({ content: "Queue is not open.", ephemeral: true }); return; } if (state.queue.length === 0) { await interaction.reply({ content: "Queue is empty.", ephemeral: true }); return; } const nextEntry = state.queue.shift(); state.testers.set(interaction.user.id, Date.now()); const targetMember = await interaction.guild.members.fetch(nextEntry.userId).catch(() => null); if (!targetMember) { try { await upsertQueueMessage(interaction.guild, gamemode); } catch (error) { await interaction.reply({ content: `Skipped user, but failed to update queue message: ${error.message}`, ephemeral: true, }); return; } await interaction.reply({ content: "Next queued user is no longer in this server. Skipped.", ephemeral: true }); return; } const channelName = `${gamemode}-${sanitizeChannelName(nextEntry.ign || targetMember.user.username)}`; const permissionOverwrites = [ { id: interaction.guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel], }, { id: targetMember.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], }, { id: interaction.user.id, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, PermissionFlagsBits.ManageChannels, ], }, ...TESTER_ROLE_IDS.map((roleId) => ({ id: roleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], })), ...Array.from(new Set(TICKET_VIEWER_ROLE_IDS)).map((roleId) => ({ id: roleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory, ], })), ]; const ticket = await interaction.guild.channels.create({ name: channelName, type: ChannelType.GuildText, parent: cfg.ticketCategoryId, topic: `${TICKET_TOPIC_PREFIX}${gamemode}`, permissionOverwrites, }); const previousTicketDate = await getLatestTicketCreatedAt(targetMember.id, gamemode); const previousTier = getMemberTierForGamemodeFromRoles(targetMember, gamemode); const ign = nextEntry.ign || targetMember.user.username; const openEmbed = new EmbedBuilder() .setColor(0xd4af37) .setTitle(`${GAMEMODE_LABELS[gamemode] || gamemode} Testing Ticket Opened`) .setThumbnail(`https://render.crafty.gg/3d/bust/${encodeURIComponent(ign)}`) .addFields( { name: "Player Username", value: `\`${ign}\``, inline: false }, { name: "Tester", value: `<@${interaction.user.id}>`, inline: false }, { name: "Last Ticket Date", value: formatLastTicketDate(previousTicketDate), inline: false }, { name: "Previous Tier", value: previousTier, inline: false }, { name: "Gamemode", value: GAMEMODE_LABELS[gamemode] || gamemode, inline: false }, { name: "Preferred Server", value: nextEntry.preferredServer || "Not provided", inline: false }, { name: "Region", value: nextEntry.region || "NA", inline: false } ) .setTimestamp(); await ticket.send({ embeds: [openEmbed] }); await recordQueueTicket({ discordUserId: targetMember.id, testerDiscordId: interaction.user.id, gamemode, ticketChannelId: ticket.id, }); try { await upsertQueueMessage(interaction.guild, gamemode); } catch (error) { await interaction.reply({ content: `Ticket opened, but failed updating queue message: ${error.message}`, ephemeral: true, }); return; } await interaction.reply({ content: `Opened ticket ${ticket} for <@${targetMember.id}> from the **${GAMEMODE_LABELS[gamemode] || gamemode}** queue.`, ephemeral: false, }); } async function handleCloseTicket(interaction) { const channel = interaction.channel; const topic = String(channel?.topic || ""); if (!channel || channel.type !== ChannelType.GuildText || !topic.startsWith(TICKET_TOPIC_PREFIX)) { await interaction.reply({ content: "Use /close inside a queue ticket channel.", ephemeral: true }); return; } await interaction.reply("Ticket closing in 5 seconds..."); setTimeout(async () => { try { await channel.delete("Queue ticket closed by tester"); } catch (error) { console.warn(`Failed to close ticket ${channel.id}: ${error.message}`); } }, 5000); } async function handleSkipTicket(interaction) { const channel = interaction.channel; const topic = String(channel?.topic || ""); if (!channel || channel.type !== ChannelType.GuildText || !topic.startsWith(TICKET_TOPIC_PREFIX)) { await interaction.reply({ content: "Use /skip inside a queue ticket channel.", ephemeral: true }); return; } await pool.query("DELETE FROM queue_test_tickets WHERE ticket_channel_id = ?", [channel.id]); await interaction.reply("Player skipped (no cooldown set). Ticket closing in 5 seconds..."); setTimeout(async () => { try { await channel.delete("Queue ticket skipped by tester"); } catch (error) { console.warn(`Failed to close skipped ticket ${channel.id}: ${error.message}`); } }, 5000); } async function handleTicketAccess(interaction) { const channel = interaction.channel; const topic = String(channel?.topic || ""); if (!channel || channel.type !== ChannelType.GuildText || !topic.startsWith(TICKET_TOPIC_PREFIX)) { await interaction.reply({ content: "Use this command inside a queue ticket channel.", ephemeral: true }); return; } const sub = interaction.options.getSubcommand(true); const targetUser = interaction.options.getUser("user", true); if (targetUser.bot) { await interaction.reply({ content: "Bot users cannot be added/removed via this command.", ephemeral: true }); return; } if (targetUser.id === interaction.guild.roles.everyone.id) { await interaction.reply({ content: "Invalid user.", ephemeral: true }); return; } if (sub === "add") { await channel.permissionOverwrites.edit(targetUser.id, { ViewChannel: true, SendMessages: true, ReadMessageHistory: true, }); await interaction.reply({ content: `Added <@${targetUser.id}> to this ticket.`, ephemeral: false }); return; } if (sub === "remove") { await channel.permissionOverwrites.delete(targetUser.id).catch(() => null); await interaction.reply({ content: `Removed explicit ticket access for <@${targetUser.id}>.` + ` If they still see the channel, they likely have access through a role.`, ephemeral: false, }); } } async function handleQueueJoinButton(interaction) { const customId = interaction.customId; if (!customId.startsWith("queue_join:")) { return; } const gamemode = normalizeGamemode(customId.split(":")[1]); if (!isValidGamemode(gamemode)) { await interaction.reply({ content: "Invalid queue gamemode.", ephemeral: true }); return; } const cfg = loadQueueConfig()[gamemode]; const state = queueState[gamemode]; if (!cfg.queueChannelId || interaction.channelId !== cfg.queueChannelId) { await interaction.reply({ content: "This queue button is not valid in this channel.", ephemeral: true }); return; } if (!state.open) { await interaction.reply({ content: "Queue is closed right now.", ephemeral: true }); return; } if (state.queue.some((q) => q.userId === interaction.user.id)) { await interaction.reply({ content: "You are already in this queue.", ephemeral: true }); return; } if (state.queue.length >= QUEUE_LIMIT) { await interaction.reply({ content: "Queue is full (20/20).", ephemeral: true }); return; } const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); const isAdmin = Boolean(member?.permissions?.has(PermissionFlagsBits.Administrator)); if (!isAdmin) { const latest = await getLatestTicketCreatedAt(interaction.user.id, gamemode); if (latest) { const remainingMs = TEST_COOLDOWN_MS - (Date.now() - latest.getTime()); if (remainingMs > 0) { await interaction.reply({ content: `You already tested **${GAMEMODE_LABELS[gamemode] || gamemode}** recently. You can queue again in **${formatDuration(remainingMs)}**.`, ephemeral: true, }); return; } } } await interaction.showModal(buildQueueJoinModal(gamemode)); } async function handleQueueJoinModalSubmit(interaction) { const customId = interaction.customId; if (!customId.startsWith("queue_join_form:")) { return; } const gamemode = normalizeGamemode(customId.split(":")[1]); if (!isValidGamemode(gamemode)) { await interaction.reply({ content: "Invalid queue gamemode.", ephemeral: true }); return; } const cfg = loadQueueConfig()[gamemode]; const state = queueState[gamemode]; if (!cfg.queueChannelId || interaction.channelId !== cfg.queueChannelId) { await interaction.reply({ content: "This queue form is not valid in this channel.", ephemeral: true }); return; } if (!state.open) { await interaction.reply({ content: "Queue is closed right now.", ephemeral: true }); return; } if (state.queue.some((q) => q.userId === interaction.user.id)) { await interaction.reply({ content: "You are already in this queue.", ephemeral: true }); return; } if (state.queue.length >= QUEUE_LIMIT) { await interaction.reply({ content: "Queue is full (20/20).", ephemeral: true }); return; } const ign = String(interaction.fields.getTextInputValue("ingame_username") || "").trim(); const preferredServer = String(interaction.fields.getTextInputValue("preferred_server") || "").trim(); const rawRegion = String(interaction.fields.getTextInputValue("region") || "").trim(); const region = rawRegion.toUpperCase(); if (!USERNAME_REGEX.test(ign)) { await interaction.reply({ content: "Invalid in-game username. Use 3-16 characters: letters, numbers, underscore.", ephemeral: true, }); return; } if (!preferredServer) { await interaction.reply({ content: "Preferred testing server is required.", ephemeral: true }); return; } if (!["NA", "EU", "AS", "AU"].includes(region)) { await interaction.reply({ content: "Invalid region. You must type exactly one of: `NA`, `EU`, `AS`, `AU`.", ephemeral: true, }); return; } const member = await interaction.guild.members.fetch(interaction.user.id).catch(() => null); const isAdmin = Boolean(member?.permissions?.has(PermissionFlagsBits.Administrator)); if (!isAdmin) { const latest = await getLatestTicketCreatedAt(interaction.user.id, gamemode); if (latest) { const remainingMs = TEST_COOLDOWN_MS - (Date.now() - latest.getTime()); if (remainingMs > 0) { await interaction.reply({ content: `You already tested **${GAMEMODE_LABELS[gamemode] || gamemode}** recently. You can queue again in **${formatDuration(remainingMs)}**.`, ephemeral: true, }); return; } } } state.queue.push({ userId: interaction.user.id, joinedAt: Date.now(), ign, preferredServer, region, }); await upsertQueueMessage(interaction.guild, gamemode); await interaction.reply({ content: `Joined queue at position **${state.queue.length}** for **${GAMEMODE_LABELS[gamemode] || gamemode}**.`, ephemeral: true, }); } async function pruneInactiveTesters() { for (const gamemode of GAMEMODES) { const state = queueState[gamemode]; if (state.testers.size === 0) { continue; } const now = Date.now(); let removed = 0; for (const [userId, lastNextAt] of state.testers.entries()) { if (now - lastNextAt > TESTER_INACTIVITY_MS) { state.testers.delete(userId); removed += 1; } } if (removed === 0) { continue; } if (state.testers.size === 0) { state.open = false; state.queue = []; } const guild = client.guilds.cache.get(GUILD_ID); if (guild) { await upsertQueueMessage(guild, gamemode).catch((error) => { console.warn(`Failed updating queue message for ${gamemode}: ${error.message}`); }); } } } client.on("ready", () => { console.log(`Discord bot logged in as ${client.user.tag}`); setInterval(() => { pruneInactiveTesters().catch((error) => { console.warn(`Queue inactivity check failed: ${error.message}`); }); }, 60 * 1000); }); client.on("interactionCreate", async (interaction) => { try { if (interaction.isButton()) { await handleQueueJoinButton(interaction); return; } if (interaction.isModalSubmit()) { await handleQueueJoinModalSubmit(interaction); return; } if (!interaction.isChatInputCommand()) { return; } const cmd = interaction.commandName; const isTesterCommand = ["settier", "removetier", "start", "stop", "next", "close", "skip", "ticket"].includes(cmd); if (isTesterCommand) { const allowed = await canUseTesterCommands(interaction); if (!allowed) { await interaction.reply({ content: "You need Administrator permission or one of the allowed tester roles to use this command.", ephemeral: true, }); return; } } else if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { await interaction.reply({ content: "You need Administrator permission to use this command.", ephemeral: true, }); return; } if (cmd === "start") { await handleStartQueue(interaction); return; } if (cmd === "stop") { await handleStopQueue(interaction); return; } if (cmd === "next") { await handleNextQueue(interaction); return; } if (cmd === "close") { await handleCloseTicket(interaction); return; } if (cmd === "skip") { await handleSkipTicket(interaction); return; } if (cmd === "ticket") { await handleTicketAccess(interaction); return; } if (cmd === "settier") { const discordUser = interaction.options.getUser("discord_user", true); const username = String(interaction.options.getString("ingame_name", true)).trim(); const region = normalizeRegion(interaction.options.getString("region", true)); const gamemode = normalizeGamemode(interaction.options.getString("gamemode", true)); const tier = normalizeTier(interaction.options.getString("tier", true)); if (!USERNAME_REGEX.test(username)) { await interaction.reply({ content: "Invalid in-game name. Use 3-16 characters: letters, numbers, underscore.", ephemeral: true, }); return; } if (!isValidGamemode(gamemode)) { await interaction.reply({ content: `Invalid gamemode. Use: ${GAMEMODES.join(", ")}`, ephemeral: true }); return; } if (!isValidRegion(region)) { await interaction.reply({ content: `Invalid region. Use: ${REGIONS.join(", ")}`, ephemeral: true }); return; } if (!isValidTier(tier)) { await interaction.reply({ content: "Invalid tier. Use one of: HT1, LT1, HT2, LT2, HT3, LT3, HT4, LT4, HT5, LT5.", ephemeral: true, }); return; } const player = await upsertPlayerRegionByUsername(username, region); if (!player) { await interaction.reply({ content: "Failed to create or find player after upsert.", ephemeral: true, }); return; } const [beforeRows] = await pool.query( "SELECT tier FROM player_ranks WHERE player_id = ? AND gamemode = ? LIMIT 1", [player.id, gamemode] ); const [allRankRows] = await pool.query( "SELECT gamemode, tier FROM player_ranks WHERE player_id = ?", [player.id] ); const previousTier = beforeRows[0]?.tier || null; const previousTierPoints = tierToPoints(previousTier); const newTierPoints = tierToPoints(tier); const overallPointsBefore = allRankRows.reduce((sum, row) => sum + tierToPoints(row.tier), 0); const overallPointsAfter = overallPointsBefore - previousTierPoints + newTierPoints; await pool.query( `INSERT INTO player_ranks (player_id, gamemode, tier) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE tier = VALUES(tier), updated_at = CURRENT_TIMESTAMP`, [player.id, gamemode, tier] ); const roleSync = await syncDiscordTierRoles({ interaction, discordUserId: discordUser.id, gamemode, tierToAssign: tier, }); if (!roleSync.ok) { await interaction.reply( `Set **${username}** to **${tier}** in **${GAMEMODE_LABELS[gamemode] || gamemode}**, but role sync failed.\nReason: ${roleSync.reason}` ); return; } const logResult = await sendTierResultLog({ interaction, testerId: interaction.user.id, testedDiscordId: discordUser.id, ign: username, gamemode, pointsBefore: overallPointsBefore, pointsAfter: overallPointsAfter, earnedTier: tier, }); if (!logResult?.ok) { await interaction.reply( `Set **${username}** to **${tier}** in **${GAMEMODE_LABELS[gamemode] || gamemode}**, but failed to post in <#${RESULTS_CHANNEL_ID}>.\nReason: ${logResult?.reason || "unknown"}` ); return; } await interaction.reply( `Set **${username}** to **${tier}** in **${GAMEMODE_LABELS[gamemode] || gamemode}** (region: **${region}**) and posted the result log.` ); return; } if (cmd === "removetier") { const discordUser = interaction.options.getUser("discord_user", true); const username = String(interaction.options.getString("ingame_name", true)).trim(); const gamemode = normalizeGamemode(interaction.options.getString("gamemode", true)); if (!USERNAME_REGEX.test(username)) { await interaction.reply({ content: "Invalid in-game name. Use 3-16 characters: letters, numbers, underscore.", ephemeral: true, }); return; } if (!isValidGamemode(gamemode)) { await interaction.reply({ content: `Invalid gamemode. Use: ${GAMEMODES.join(", ")}`, ephemeral: true }); return; } const player = await findPlayerByUsername(username); if (!player) { await interaction.reply({ content: "Player not found.", ephemeral: true }); return; } const [result] = await pool.query( "DELETE FROM player_ranks WHERE player_id = ? AND gamemode = ?", [player.id, gamemode] ); const roleSync = await syncDiscordTierRoles({ interaction, discordUserId: discordUser.id, gamemode, tierToAssign: null, }); if (!roleSync.ok) { await interaction.reply( `Removed tier for **${username}** in **${GAMEMODE_LABELS[gamemode] || gamemode}**, but role sync failed.\nReason: ${roleSync.reason}` ); return; } if (result.affectedRows === 0 && (roleSync.removed || 0) === 0) { await interaction.reply({ content: "That player has no tier for this gamemode.", ephemeral: true }); return; } await interaction.reply( `Removed tier for **${username}** in **${GAMEMODE_LABELS[gamemode] || gamemode}**.` ); return; } if (cmd === "profile") { const username = String(interaction.options.getString("username", true)).trim(); const hydrated = await getHydratedPlayers(); const player = hydrated.find((p) => p.username.toLowerCase() === username.toLowerCase()); if (!player) { await interaction.reply({ content: "Player not found.", ephemeral: true }); return; } const tierLines = GAMEMODES.map((mode) => { const value = player.tiers[mode] || "Untested"; return `**${mode}**: ${value}`; }).join("\n"); const embed = new EmbedBuilder() .setTitle(`${player.username} Profile`) .setColor(0xd4af37) .setThumbnail(`https://render.crafty.gg/3d/bust/${encodeURIComponent(player.username)}`) .addFields( { name: "Overall Position", value: `#${player.position}`, inline: true }, { name: "Total Points", value: `${player.totalPoints}`, inline: true }, { name: "Ranks", value: tierLines } ) .setTimestamp(); await interaction.reply({ embeds: [embed] }); } } catch (error) { if (error && error.code === "ER_DUP_ENTRY") { if (interaction.replied || interaction.deferred) { await interaction.followUp({ content: "That username already exists.", ephemeral: true }); } else { await interaction.reply({ content: "That username already exists.", ephemeral: true }); } return; } console.error("Bot command failed:", error); if (interaction.replied || interaction.deferred) { await interaction.followUp({ content: "Command failed due to an internal error.", ephemeral: true }); return; } await interaction.reply({ content: "Command failed due to an internal error.", ephemeral: true }); } }); async function start() { await ensureDatabase(); await registerSlashCommands(); await client.login(TOKEN); } start().catch((error) => { if (String(error?.message || "").toLowerCase().includes("disallowed intents")) { console.error( "Failed to start bot: Used disallowed intents. Enable required intents in Discord Developer Portal > Your App > Bot." ); process.exit(1); } console.error("Failed to start bot:", error); process.exit(1); });