1474 lines
45 KiB
JavaScript
1474 lines
45 KiB
JavaScript
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 `<t:${unix}:F> (${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);
|
|
});
|