1901 lines
106 KiB
JavaScript
1901 lines
106 KiB
JavaScript
// =============================================
|
||
// FORTNITE DISCORD BOT — ALL-IN-ONE
|
||
// =============================================
|
||
// Commands:
|
||
// !setuproles — create all roles
|
||
// !addicons — upload skin icons as server emojis
|
||
// !setroleicons — apply emojis to roles
|
||
// !removeoldemojis — delete old skin emojis
|
||
// !verify — verify Fortnite account & auto-assign roles
|
||
//
|
||
// Run with: node bot.js
|
||
// =============================================
|
||
|
||
require('dotenv').config();
|
||
const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder, REST, Routes, SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder } = require('discord.js');
|
||
const sharp = require('sharp');
|
||
|
||
const client = new Client({
|
||
intents: [
|
||
GatewayIntentBits.Guilds,
|
||
GatewayIntentBits.GuildMessages,
|
||
GatewayIntentBits.MessageContent,
|
||
GatewayIntentBits.DirectMessages,
|
||
],
|
||
partials: ['CHANNEL'],
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// ROLES TO CREATE
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const roles = [
|
||
{ name: 'Fully Golden Peely', color: 0xFFD700 },
|
||
{ name: 'OG Aerial Assault Trooper', color: 0x8A8A8A },
|
||
{ name: 'OG Ghoul Trooper', color: 0xA89DB8 },
|
||
{ name: 'OG Renegade Raider', color: 0x7B2D2D },
|
||
{ name: 'OG Skull Trooper', color: 0x9B59B6 },
|
||
{ name: 'Barricade', color: 0x9E9E9E },
|
||
{ name: 'Double Helix', color: 0xD0443C },
|
||
{ name: 'Honor Guard', color: 0x4A90D9 },
|
||
{ name: 'Wonder', color: 0x2ECC71 },
|
||
{ name: 'Surf Strider', color: 0x1ABC9C },
|
||
{ name: 'Stealth Reflex', color: 0xE8927C },
|
||
{ name: 'Dark Vertex', color: 0x8E44AD },
|
||
{ name: 'Royale Bomber', color: 0xE67E22 },
|
||
{ name: 'Wildcat', color: 0xE8832A },
|
||
{ name: 'Clyde', color: 0xE8832A },
|
||
{ name: 'Eon', color: 0x27AE60 },
|
||
{ name: 'Huntmaster Saber', color: 0xD4AC0D },
|
||
{ name: 'Dark Skully', color: 0x7D6B8A },
|
||
{ name: 'Galaxy', color: 0x5B7DD8 },
|
||
{ name: 'Ikonik', color: 0xD0443C },
|
||
{ name: 'Black Knight', color: 0xCCCCCC },
|
||
{ name: 'Glow', color: 0xBB8FCC },
|
||
{ name: 'Rogue Spider Knight', color: 0x2ECC71 },
|
||
{ name: 'Thrilldiver', color: 0x4A90D9 },
|
||
{ name: 'Freediver', color: 0x9E9E9E },
|
||
{ name: 'Neo Versa', color: 0x9B59B6 },
|
||
{ name: 'Cobalt Snowfoot', color: 0x1ABC9C },
|
||
{ name: 'Florin', color: 0x8E44AD },
|
||
{ name: "What's Wrong, Toona Fish?", color: 0xCCCCCC },
|
||
{ name: 'Cameo Needs Loot', color: 0x2ECC71 },
|
||
{ name: 'Victory!', color: 0x9B59B6 },
|
||
{ name: 'Collegiate items', color: 0x2ECC71 },
|
||
{ name: 'StreamElements', color: 0xF1C40F },
|
||
{ name: 'Llamas', color: 0xFF69B4 },
|
||
{ name: 'Repaz', color: 0xD4AC0D },
|
||
{ name: 'lollek', color: 0xFFFFFF },
|
||
{ name: 'Celebration Heart', color: 0x9B59B6 },
|
||
{ name: 'Funflower', color: 0xD4C227 },
|
||
{ name: 'Level 100', color: 0xBB44EE },
|
||
{ name: 'Alpha Banner', color: 0x777777 },
|
||
{ name: 'YouTube Banner', color: 0x777777 },
|
||
{ name: 'STW Founders', color: 0xE67E22 },
|
||
{ name: 'Ultimate STW Founders', color: 0x888888 },
|
||
{ name: 'Rare Usernames', color: 0xF1C40F },
|
||
{ name: 'Skin Collector', color: 0x888888 },
|
||
{ name: 'PS+ Pack Collector', color: 0x3498DB },
|
||
{ name: 'Spray Collector', color: 0xCCCCCC },
|
||
{ name: 'Rare Banners', color: 0x888888 },
|
||
{ name: 'Emote Collector', color: 0xCCCCCC },
|
||
// --- Exclusive Pickaxes ---
|
||
{ name: "Raider's Revenge", color: 0x7B2D2D },
|
||
{ name: 'Axe of Champions', color: 0xF1C40F },
|
||
{ name: 'Blade of Champions', color: 0xF1C40F },
|
||
{ name: 'Axecalibur', color: 0xCCCCCC },
|
||
{ name: 'EVA', color: 0x4A90D9 },
|
||
{ name: 'AC/DC', color: 0xF1C40F },
|
||
{ name: 'Resonator', color: 0x27AE60 },
|
||
{ name: 'Pinpoint', color: 0xD0443C },
|
||
{ name: 'Stellar Axe', color: 0x5B7DD8 },
|
||
{ name: 'Stealth Angular Axe', color: 0x9B59B6 },
|
||
{ name: 'Dark Razor', color: 0x7D6B8A },
|
||
{ name: 'Electri-claw', color: 0xE8832A },
|
||
{ name: 'Dark Splitter', color: 0x8E44AD },
|
||
{ name: 'Merry Mint Axe', color: 0x2ECC71 },
|
||
{ name: 'Indigo Inverter', color: 0x1ABC9C },
|
||
{ name: 'Rose Glow', color: 0xFF69B4 },
|
||
{ name: 'Controller', color: 0x3498DB },
|
||
{ name: 'Pneumatic Twin', color: 0x4A90D9 },
|
||
{ name: 'Perfect Point', color: 0x9E9E9E },
|
||
{ name: 'Petrol Pump', color: 0xE67E22 },
|
||
{ name: 'Future Relic Scythe', color: 0x8E44AD },
|
||
// --- Exclusive Emoticons ---
|
||
{ name: 'Skull Trooper Emoticon', color: 0x9B59B6 },
|
||
{ name: 'Maevys Emoticon', color: 0xD0443C },
|
||
{ name: 'ZetFar Emoticon', color: 0x4A90D9 },
|
||
// --- PlayStation Exclusives ---
|
||
{ name: 'Blue Team Leader', color: 0x3498DB }, // PS Plus Pack 1
|
||
{ name: 'Blue Striker', color: 0x3498DB }, // PS Plus Pack 2
|
||
{ name: 'Prodigy', color: 0x3498DB }, // PS Plus Pack 4
|
||
{ name: 'Carbon Commando', color: 0x3498DB }, // PS Plus Pack 5
|
||
{ name: 'Trilogy', color: 0x3498DB }, // PS Plus Pack 8
|
||
{ name: 'Point Patroller', color: 0x3498DB }, // PS Plus Pack 9
|
||
{ name: 'Cloud Striker', color: 0x3498DB }, // PS Plus Pack 12
|
||
{ name: 'Fixer', color: 0x3498DB }, // PS Plus Pack 14
|
||
{ name: 'Blizzard Bomber', color: 0x3498DB }, // PS Plus Pack 18
|
||
{ name: 'Jun-Hwan', color: 0x3498DB }, // Blue Phoenix Pack
|
||
{ name: 'Lina Scorch', color: 0x3498DB }, // Blazin Wheels Pack
|
||
{ name: 'Indigo Kuno', color: 0x9B59B6 }, // PS Generations Cup
|
||
// --- Twitch / Amazon Prime ---
|
||
{ name: 'Sub Commander', color: 0x9146FF }, // Twitch Prime Pack 1
|
||
{ name: 'Havoc', color: 0x9146FF }, // Twitch Prime Pack 1
|
||
{ name: 'Trailblazer', color: 0x9146FF }, // Twitch Prime Pack 2
|
||
// --- Intel / PC Exclusive ---
|
||
{ name: 'Reflex', color: 0x0071C5 }, // Intel bundle
|
||
// --- OG Battle Pass (C1S2) ---
|
||
{ name: 'Sparkle Specialist', color: 0xFF69B4 },
|
||
{ name: 'Royale Knight', color: 0xCCCCCC },
|
||
{ name: 'Blue Squire', color: 0x4A90D9 },
|
||
// --- OG Battle Pass (C1S3) ---
|
||
{ name: 'The Reaper', color: 0x888888 },
|
||
{ name: 'Elite Agent', color: 0x888888 },
|
||
{ name: 'Rogue Agent', color: 0x888888 },
|
||
// --- OG Battle Pass (C1S4) ---
|
||
{ name: 'Omega', color: 0x8E44AD },
|
||
// --- Chapter 2 Season 2 Battle Pass (golden styles) ---
|
||
{ name: 'Skye', color: 0xF1C40F },
|
||
{ name: 'TNTina', color: 0xF1C40F },
|
||
{ name: 'Meowscles', color: 0xF1C40F },
|
||
{ name: 'Midas', color: 0xFFD700 },
|
||
// --- Gaming Legends / Collab Exclusives ---
|
||
{ name: 'Travis Scott', color: 0xF1C40F }, // Icon Series, never returned
|
||
{ name: 'Astro Jack', color: 0xF1C40F }, // Icon Series, never returned
|
||
{ name: 'Kratos', color: 0xCCCCCC }, // PlayStation collab
|
||
{ name: 'Master Chief', color: 0x4A90D9 }, // Xbox collab
|
||
// --- World Cup / Tournament ---
|
||
{ name: 'World Warrior', color: 0xF1C40F }, // Fortnite World Cup 2019
|
||
// --- V-Bucks Card Promo ---
|
||
{ name: 'Color Splash Jellie', color: 0xFF69B4 },
|
||
// --- Save the World Founders ---
|
||
{ name: 'Rose Team Leader', color: 0xE67E22 },
|
||
{ name: 'Warpaint', color: 0xE67E22 },
|
||
// --- OT Banners ---
|
||
{ name: 'OT1 Banner', color: 0xFFD700 },
|
||
{ name: 'OT2 Banner', color: 0xFFD700 },
|
||
{ name: 'OT3 Banner', color: 0xFFD700 },
|
||
{ name: 'OT4 Banner', color: 0xFFD700 },
|
||
{ name: 'OT5 Banner', color: 0xFFD700 },
|
||
{ name: 'OT6 Banner', color: 0xFFD700 },
|
||
{ name: 'OT7 Banner', color: 0xFFD700 },
|
||
{ name: 'OT8 Banner', color: 0xFFD700 },
|
||
{ name: 'OT9 Banner', color: 0xFFD700 },
|
||
{ name: 'OT10 Banner', color: 0xFFD700 },
|
||
{ name: 'OT11 Banner', color: 0xFFD700 },
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// EMOJI / ICON MAP
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const roleIconMap = [
|
||
{ name: 'Fully Golden Peely', id: 'CID_701_Athena_Commando_M_BananaAgent', emoji: 'FullyGoldenPeely',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png' },
|
||
{ name: 'OG Aerial Assault Trooper', id: 'CID_017_Athena_Commando_M', emoji: 'AerialAssaultTrooper' },
|
||
{ name: 'OG Ghoul Trooper', id: 'CID_029_Athena_Commando_F_Halloween', emoji: 'GhoulTrooper',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png' },
|
||
{ name: 'OG Renegade Raider', id: 'CID_028_Athena_Commando_F', emoji: 'RenegadeRaider' },
|
||
{ name: 'OG Skull Trooper', id: 'CID_030_Athena_Commando_M_Halloween', emoji: 'SkullTrooper',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png' },
|
||
{ name: 'Barricade', id: 'Character_RankedPhantom', emoji: 'Barricade' },
|
||
{ name: 'Double Helix', id: 'CID_183_Athena_Commando_M_ModernMilitaryRed', emoji: 'DoubleHelix' },
|
||
{ name: 'Honor Guard', id: 'CID_342_Athena_Commando_M_StreetRacerMetallic', emoji: 'HonorGuard' },
|
||
{ name: 'Wonder', id: 'CID_434_Athena_Commando_F_StealthHonor', emoji: 'Wonder' },
|
||
{ name: 'Surf Strider', id: 'CID_783_Athena_Commando_M_AquaJacket', emoji: 'SurfStrider' },
|
||
{ name: 'Stealth Reflex', id: 'CID_386_Athena_Commando_M_StreetOpsStealth', emoji: 'StealthReflex' },
|
||
{ name: 'Dark Vertex', id: 'CID_371_Athena_Commando_M_SpeedyMidnight', emoji: 'DarkVertex' },
|
||
{ name: 'Royale Bomber', id: 'CID_113_Athena_Commando_M_BlueAce', emoji: 'RoyaleBomber' },
|
||
{ name: 'Wildcat', id: 'CID_757_Athena_Commando_F_WildCat', emoji: 'Wildcat' },
|
||
{ name: 'Clyde', id: 'Character_UnbrokenTrash', emoji: 'Clyde' },
|
||
{ name: 'Eon', id: 'CID_174_Athena_Commando_F_CarbideWhite', emoji: 'Eon' },
|
||
{ name: 'Huntmaster Saber', id: 'Character_MasterKeyOrder', emoji: 'HuntmasterSaber' },
|
||
{ name: 'Dark Skully', id: 'CID_850_Athena_Commando_F_SkullBriteCube', emoji: 'DarkSkully' },
|
||
{ name: 'Galaxy', id: 'CID_175_Athena_Commando_M_Celestial', emoji: 'Galaxy' },
|
||
{ name: 'Ikonik', id: 'CID_313_Athena_Commando_M_KpopFashion', emoji: 'Ikonik' },
|
||
// { name: 'Black Knight', id: 'CID_035_Athena_Commando_M_Medieval', emoji: 'BlackKnight' }, // temporarily disabled — upload stalls
|
||
{ name: 'Glow', id: 'CID_479_Athena_Commando_F_Davinci', emoji: 'Glow' },
|
||
{ name: 'Rogue Spider Knight', id: 'CID_516_Athena_Commando_M_BlackWidowRogue', emoji: 'RogueSpiderKnight' },
|
||
{ name: 'Thrilldiver', id: 'Character_SummerUnsuitable', emoji: 'Thrilldiver' },
|
||
{ name: 'Freediver', id: 'Character_CoolSuitable', emoji: 'Freediver' },
|
||
{ name: 'Neo Versa', id: 'CID_441_Athena_Commando_F_CyberScavengerBlue', emoji: 'NeoVersa' },
|
||
{ name: 'Cobalt Snowfoot', id: 'Character_SnowNinjaDark', emoji: 'CobaltSnowfoot' },
|
||
{ name: 'Florin', id: 'Character_AsterOrder', emoji: 'Florin' },
|
||
{ name: "What's Wrong, Toona Fish?", id: 'CID_A_201_Athena_Commando_M_TeriyakiFishToon', emoji: 'ToonaFish' },
|
||
{ name: 'Cameo Needs Loot', id: 'Emoji_S20_NeedLoot', emoji: 'Cameo',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s20_needloot/smallicon.png' },
|
||
|
||
// --- Exclusive Pickaxes ---
|
||
{ name: "Raider's Revenge", id: 'Pickaxe_Lockjaw', emoji: 'RaidersRevenge' },
|
||
{ name: 'Axe of Champions', id: 'Pickaxe_ID_376_FNCS', emoji: 'AxeOfChampions' },
|
||
{ name: 'Blade of Champions', id: 'Pickaxe_FNCS_S32', emoji: 'BladeOfChampions' },
|
||
{ name: 'Axecalibur', id: 'Pickaxe_ID_011_Medieval', emoji: 'Axecalibur' },
|
||
{ name: 'EVA', id: 'Pickaxe_ID_028_Space', emoji: 'EVA' },
|
||
{ name: 'AC/DC', id: 'Pickaxe_ID_013_Teslacoil', emoji: 'ACDC' },
|
||
{ name: 'Resonator', id: 'Pickaxe_ID_077_CarbideWhite', emoji: 'Resonator' },
|
||
{ name: 'Pinpoint', id: 'Pickaxe_ID_099_ModernMilitaryRed', emoji: 'Pinpoint' },
|
||
{ name: 'Stellar Axe', id: 'Pickaxe_ID_116_Celestial', emoji: 'StellarAxe' },
|
||
{ name: 'Stealth Angular Axe', id: 'Pickaxe_ID_189_StreetOpsStealth', emoji: 'StealthAngularAxe' },
|
||
{ name: 'Dark Razor', id: 'Pickaxe_ID_178_SpeedyMidnight', emoji: 'DarkRazor' },
|
||
{ name: 'Electri-claw', id: 'Pickaxe_ID_398_WildCatFemale', emoji: 'Electriclaw' },
|
||
{ name: 'Dark Splitter', id: 'Pickaxe_ID_461_SkullBriteCube', emoji: 'DarkSplitter' },
|
||
{ name: 'Merry Mint Axe', id: 'Pickaxe_ID_294_CandyCane', emoji: 'MerryMintAxe' },
|
||
{ name: 'Indigo Inverter', id: 'Pickaxe_SnowNinjaDark', emoji: 'IndigoInverter' },
|
||
{ name: 'Rose Glow', id: 'Pickaxe_ID_153_RoseLeader', emoji: 'RoseGlow' },
|
||
{ name: 'Controller', id: 'Pickaxe_ID_088_PSBurnout', emoji: 'Controller' },
|
||
{ name: 'Pneumatic Twin', id: 'Pickaxe_ID_256_TechOpsBlue', emoji: 'PneumaticTwin' },
|
||
{ name: 'Perfect Point', id: 'Pickaxe_ID_464_LongShortsMale', emoji: 'PerfectPoint' },
|
||
{ name: 'Petrol Pump', id: 'Pickaxe_RetroWheels', emoji: 'PetrolPump' },
|
||
{ name: 'Future Relic Scythe', id: 'Pickaxe_SnowSoldierFashion', emoji: 'FutureRelicScythe' },
|
||
|
||
// --- OT Banners (OT1–OT11) ---
|
||
{ name: 'OT1 Banner', id: 'OT1Banner', emoji: 'OT1Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot1banner/icon.png' },
|
||
{ name: 'OT2 Banner', id: 'OT2Banner', emoji: 'OT2Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot2banner/icon.png' },
|
||
{ name: 'OT3 Banner', id: 'OT3Banner', emoji: 'OT3Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot3banner/icon.png' },
|
||
{ name: 'OT4 Banner', id: 'OT4Banner', emoji: 'OT4Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot4banner/icon.png' },
|
||
{ name: 'OT5 Banner', id: 'OT5Banner', emoji: 'OT5Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot5banner/icon.png' },
|
||
{ name: 'OT6 Banner', id: 'OT6Banner', emoji: 'OT6Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot6banner/icon.png' },
|
||
{ name: 'OT7 Banner', id: 'OT7Banner', emoji: 'OT7Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot7banner/icon.png' },
|
||
{ name: 'OT8 Banner', id: 'OT8Banner', emoji: 'OT8Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot8banner/icon.png' },
|
||
{ name: 'OT9 Banner', id: 'OT9Banner', emoji: 'OT9Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot9banner/icon.png' },
|
||
{ name: 'OT10 Banner', id: 'OT10Banner', emoji: 'OT10Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot10banner/icon.png' },
|
||
{ name: 'OT11 Banner', id: 'OT11Banner', emoji: 'OT11Banner', bannerUrl: 'https://fortnite-api.com/images/banners/ot11banner/icon.png' },
|
||
|
||
// --- Exclusive Emoticons (in-game emoji cosmetics) ---
|
||
{ name: 'Skull Trooper Emoticon', id: 'Emoji_Skulltrooper', emoji: 'EmoticonSkullTrooper',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_skulltrooper/smallicon.png' },
|
||
{ name: 'Maevys Emoticon', id: 'Emoji_S22_Showdown_Mae', emoji: 'EmoticonMaevys',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_mae/smallicon.png' },
|
||
{ name: 'ZetFar Emoticon', id: 'Emoji_S22_Showdown_Zet', emoji: 'EmoticonZetFar',
|
||
overrideUrl: 'https://fortnite-api.com/images/cosmetics/br/emoji_s22_showdown_zet/smallicon.png' },
|
||
|
||
// --- PlayStation Exclusives ---
|
||
{ name: 'Blue Team Leader', id: 'CID_052_Athena_Commando_F_PSBlue', emoji: 'BlueTeamLeader' },
|
||
{ name: 'Blue Striker', id: 'CID_138_Athena_Commando_M_PSBurnout', emoji: 'BlueStriker' },
|
||
{ name: 'Prodigy', id: 'CID_296_Athena_Commando_M_Math', emoji: 'Prodigy' },
|
||
{ name: 'Carbon Commando', id: 'CID_360_Athena_Commando_M_TechOpsBlue', emoji: 'CarbonCommando' },
|
||
{ name: 'Trilogy', id: 'CID_657_Athena_Commando_F_TechOpsBlue', emoji: 'Trilogy' },
|
||
{ name: 'Point Patroller', id: 'CID_711_Athena_Commando_M_LongShorts', emoji: 'PointPatroller' },
|
||
{ name: 'Cloud Striker', id: 'CID_926_Athena_Commando_F_StreetFashionDiamond', emoji: 'CloudStriker' },
|
||
{ name: 'Fixer', id: 'CID_A_101_Athena_Commando_M_TacticalWoodlandBlue', emoji: 'Fixer' },
|
||
{ name: 'Blizzard Bomber', id: 'CID_A_421_Athena_Commando_F_BlizzardBomber', emoji: 'BlizzardBomber' },
|
||
{ name: 'Jun-Hwan', id: 'Character_BlueJet', emoji: 'JunHwan' },
|
||
{ name: 'Lina Scorch', id: 'Character_RetroWheels', emoji: 'LinaScorch' },
|
||
{ name: 'Indigo Kuno', id: 'CID_660_Athena_Commando_F_BandageNinjaBlue', emoji: 'IndigoKuno' },
|
||
|
||
// --- Twitch / Amazon Prime ---
|
||
{ name: 'Sub Commander', id: 'CID_085_Athena_Commando_M_Twitch', emoji: 'SubCommander' },
|
||
{ name: 'Havoc', id: 'CID_089_Athena_Commando_M_RetroGrey', emoji: 'Havoc' },
|
||
{ name: 'Trailblazer', id: 'CID_114_Athena_Commando_F_TacticalWoodland', emoji: 'Trailblazer' },
|
||
|
||
// --- Intel / PC Exclusive ---
|
||
{ name: 'Reflex', id: 'CID_259_Athena_Commando_M_StreetOps', emoji: 'Reflex' },
|
||
|
||
// --- OG Battle Pass ---
|
||
{ name: 'Sparkle Specialist', id: 'CID_039_Athena_Commando_F_Disco', emoji: 'SparkleSpecialist' },
|
||
{ name: 'Royale Knight', id: 'CID_033_Athena_Commando_F_Medieval', emoji: 'RoyaleKnight' },
|
||
{ name: 'Blue Squire', id: 'CID_032_Athena_Commando_M_Medieval', emoji: 'BlueSquire' },
|
||
{ name: 'The Reaper', id: 'CID_084_Athena_Commando_M_Assassin', emoji: 'TheReaper' },
|
||
{ name: 'Elite Agent', id: 'CID_083_Athena_Commando_F_Tactical', emoji: 'EliteAgent' },
|
||
{ name: 'Rogue Agent', id: 'CID_090_Athena_Commando_M_Tactical', emoji: 'RogueAgent' },
|
||
{ name: 'Omega', id: 'CID_116_Athena_Commando_M_CarbideBlack', emoji: 'Omega' },
|
||
|
||
// --- Ch2S2 Battle Pass gold styles ---
|
||
{ name: 'Skye', id: 'CID_690_Athena_Commando_F_Photographer', emoji: 'Skye' },
|
||
{ name: 'TNTina', id: 'CID_691_Athena_Commando_F_TNTina', emoji: 'TNTina' },
|
||
{ name: 'Meowscles', id: 'CID_693_Athena_Commando_M_BuffCat', emoji: 'Meowscles' },
|
||
{ name: 'Midas', id: 'CID_694_Athena_Commando_M_CatBurglar', emoji: 'Midas' },
|
||
|
||
// --- Gaming Legends / Collab ---
|
||
{ name: 'Travis Scott', id: 'CID_703_Athena_Commando_M_Cyclone', emoji: 'TravisScott' },
|
||
{ name: 'Astro Jack', id: 'CID_761_Athena_Commando_M_CycloneSpace', emoji: 'AstroJack' },
|
||
{ name: 'Kratos', id: 'CID_964_Athena_Commando_M_Historian_869BC', emoji: 'Kratos' },
|
||
{ name: 'Master Chief', id: 'CID_971_Athena_Commando_M_Jupiter_S0Z6M', emoji: 'MasterChief' },
|
||
|
||
// --- World Cup / Tournament ---
|
||
{ name: 'World Warrior', id: 'CID_478_Athena_Commando_F_WorldCup', emoji: 'WorldWarrior' },
|
||
|
||
// --- V-Bucks Card Promo ---
|
||
{ name: 'Color Splash Jellie', id: 'Character_TubaGummi', emoji: 'ColorSplashJellie' },
|
||
|
||
// --- Save the World Founders ---
|
||
{ name: 'Rose Team Leader', id: 'CID_096_Athena_Commando_F_Founder', emoji: 'RoseTeamLeader' },
|
||
{ name: 'Warpaint', id: 'CID_095_Athena_Commando_M_Founder', emoji: 'Warpaint' },
|
||
];
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// VERIFY ROLE REQUIREMENTS
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const ROLE_REQUIREMENTS = [
|
||
// OG Season 1 skins (style required)
|
||
{ role: 'OG Renegade Raider', id: 'cid_028_athena_commando_f', styleTag: 'Mat3' },
|
||
{ role: 'OG Aerial Assault Trooper', id: 'cid_017_athena_commando_m', styleTag: 'Stage2' },
|
||
{ role: 'OG Ghoul Trooper', id: 'cid_029_athena_commando_f_halloween', styleTag: 'Mat3' },
|
||
{ role: 'OG Skull Trooper', id: 'cid_030_athena_commando_m_halloween', styleTag: 'Mat1' },
|
||
// OG Battle Pass skins
|
||
{ role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' },
|
||
{ role: 'Blue Squire', id: 'cid_032_athena_commando_m_medieval' },
|
||
{ role: 'Royale Knight', id: 'cid_033_athena_commando_f_medieval' },
|
||
{ role: 'Sparkle Specialist', id: 'cid_039_athena_commando_f_disco' },
|
||
{ role: 'The Reaper', id: 'cid_084_athena_commando_m_assassin' },
|
||
{ role: 'Elite Agent', id: 'cid_083_athena_commando_f_tactical' },
|
||
{ role: 'Omega', id: 'cid_116_athena_commando_m_carbideblack', styleTag: 'Stage5' },
|
||
{ role: 'Carbide', id: 'cid_115_athena_commando_m_carbideblue', styleTag: 'Stage5' },
|
||
// Samsung bundles
|
||
{ role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' },
|
||
{ role: 'Stellar Axe', id: 'pickaxe_id_116_celestial' },
|
||
{ role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' },
|
||
{ role: 'Scenario Emote', id: 'eid_kpopdance03' },
|
||
{ role: 'Glow', id: 'cid_479_athena_commando_f_davinci' },
|
||
{ role: 'Levitate Emote', id: 'eid_davinci' },
|
||
// Device / Platform exclusives
|
||
{ role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' },
|
||
{ role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' },
|
||
{ role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' },
|
||
{ role: 'Resonator', id: 'pickaxe_id_077_carbidewhite' },
|
||
{ role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' },
|
||
{ role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' },
|
||
{ role: 'Pinpoint', id: 'pickaxe_id_099_modernmilitaryred' },
|
||
{ role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' },
|
||
{ role: 'Cobalt Snowfoot', id: 'character_snowninjadark' },
|
||
{ role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' },
|
||
{ role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' },
|
||
{ role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' },
|
||
{ role: 'Reflex', id: 'cid_259_athena_commando_m_streetops' },
|
||
{ role: 'Angular Axe', id: 'pickaxe_id_118_streetops' },
|
||
{ role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' },
|
||
{ role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' },
|
||
{ role: 'Huntmaster Saber', id: 'character_masterkeyorder' },
|
||
// PlayStation Plus exclusives
|
||
{ role: 'Blue Team Leader', id: 'cid_052_athena_commando_f_psblue' },
|
||
{ role: 'Blue Striker', id: 'cid_138_athena_commando_m_psburnout' },
|
||
{ role: 'Carbon Commando', id: 'cid_360_athena_commando_m_techopsblue' },
|
||
{ role: 'Prodigy', id: 'cid_296_athena_commando_m_math' },
|
||
{ role: 'Cloud Striker', id: 'cid_926_athena_commando_f_streetfashiondiamond' },
|
||
{ role: 'Fixer', id: 'cid_a_101_athena_commando_m_tacticalwoodlandblue' },
|
||
{ role: 'Point Patroller', id: 'cid_711_athena_commando_m_longshorts' },
|
||
// Twitch Prime exclusives
|
||
{ role: 'Sub Commander', id: 'cid_085_athena_commando_m_twitch' },
|
||
{ role: 'Havoc', id: 'cid_089_athena_commando_m_retrogrey' },
|
||
{ role: 'Trailblazer', id: 'cid_114_athena_commando_f_tacticalwoodland' },
|
||
// Exclusive Pickaxes
|
||
{ role: "Raider's Revenge", id: 'pickaxe_lockjaw', styleTag: 'Stage2' },
|
||
{ role: 'AC/DC', id: 'pickaxe_id_013_teslacoil' },
|
||
{ role: 'Merry Mint Axe', id: 'pickaxe_id_294_candycane' },
|
||
{ role: 'Tenderizer', id: 'pickaxe_id_044_tacticalurbanhammer' },
|
||
{ role: 'Axe of Champions', id: 'pickaxe_id_376_fncs' },
|
||
{ role: 'Blade of Champions', id: 'pickaxe_fncs_s32' },
|
||
// OG / Exclusive Emotes
|
||
{ role: 'Fresh Emote', id: 'eid_fresh' },
|
||
{ role: 'Rambunctious Emote', id: 'eid_zippy' },
|
||
{ role: 'Tidy Emote', id: 'eid_tidy' },
|
||
{ role: 'Floss Emote', id: 'eid_floss' },
|
||
{ role: 'Take the L Emote', id: 'eid_takethelathena' },
|
||
{ role: 'Orange Justice Emote', id: 'eid_goodvibes' },
|
||
{ role: 'Ride the Pony Emote', id: 'eid_ridethepony_athena' },
|
||
{ role: 'Pony Up Emote', id: 'eid_ridetheponymultiple' },
|
||
{ role: 'Evasive Maneuvers Emote', id: 'eid_temple' },
|
||
// Exclusive Emoticons
|
||
{ role: 'Cameo Needs Loot', id: 'emoji_s20_needloot' },
|
||
// Founder exclusives
|
||
{ role: 'Rose Team Leader', id: 'cid_096_athena_commando_f_founder' },
|
||
{ role: 'Warpaint', id: 'cid_095_athena_commando_m_founder' },
|
||
{ role: 'Fully Golden Peely', id: 'cid_701_athena_commando_m_bananaagent', styleTag: 'Stage4' },
|
||
// Samsung Galaxy bundle items
|
||
{ role: 'Galactic Disc', id: 'bid_138_celestial' },
|
||
{ role: 'Discovery Glider', id: 'glider_id_090_celestial' },
|
||
// IKONIK bundle - Scenario already added above
|
||
// Glow bundle - Levitate already added above
|
||
// EON bundle
|
||
{ role: 'Aurora Glider', id: 'glider_id_056_carbidewhite' },
|
||
// Dark Vertex bundle
|
||
{ role: 'Dark Deflector', id: 'bid_234_speedymidnight' },
|
||
{ role: 'Razor Edge', id: 'pickaxe_id_065_speedyred' },
|
||
{ role: 'Dark Forerunner Glider', id: 'glider_id_131_speedymidnight' },
|
||
// Double Helix bundle
|
||
{ role: 'Telemetry', id: 'bid_114_modernmilitaryred' },
|
||
{ role: 'Rotor Glider', id: 'glider_id_075_modernmilitaryred' },
|
||
// Neo Versa bundle
|
||
{ role: 'Neo Phrenzy', id: 'bid_288_cyberscavengerfemaleblue' },
|
||
// Royale Bomber bundle
|
||
{ role: 'Royale Air Glider', id: 'glider_id_105_snowboard' },
|
||
// Reflex bundle
|
||
{ role: 'Response Unit', id: 'bid_140_streetopsmale' },
|
||
{ role: 'Pivot Glider', id: 'glider_id_092_streetops' },
|
||
// Wildcat bundle
|
||
{ role: 'Sleek Strike', id: 'bid_521_wildcat' },
|
||
// Rogue Spider Knight bundle
|
||
{ role: 'Time Keeper', id: 'bid_395_phantom' },
|
||
// Stealth Reflex bundle
|
||
{ role: 'Stealth Response Unit', id: 'bid_249_streetopsstealth' },
|
||
// Aerial Assault One glider (S1 exclusive)
|
||
{ role: 'Aerial Assault One', id: 'glider_id_001', styleTag: 'Stage2' },
|
||
// Extra exclusive pickaxes from master list
|
||
{ role: 'Trusty No. 2', id: 'pickaxe_id_029_assassin' },
|
||
{ role: 'Party Animal', id: 'pickaxe_id_020_keg' },
|
||
{ role: 'Crowbar', id: 'pickaxe_id_092_bling' },
|
||
{ role: 'Instigator', id: 'pickaxe_id_039_tacticalblack' },
|
||
// Missing emotes from master list
|
||
{ role: "Widow's Pirouette Emote", id: 'eid_ashtonboardwalk' },
|
||
{ role: 'Hot Marat Emote', id: 'eid_wir' },
|
||
{ role: 'Kiss the Cup Emote', id: 'eid_trophycelebration' },
|
||
// Missing skins from master list
|
||
{ role: 'Gildedguy', id: 'cid_a_141_athena_commando_m_dojo' },
|
||
{ role: 'Tart Tycoon', id: 'cid_851_athena_commando_m_bittenhead' },
|
||
// Gliders
|
||
{ role: 'Mako Glider', id: 'glider_warthog' },
|
||
// Rare / STW / Creator banners (checked via homebase_banners in common_core)
|
||
{ role: 'Alpha Tester Banner', bannerId: 'OtherBanner51' },
|
||
{ role: 'World Cup Banner', bannerId: 'BRWorldCup2019' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner1' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner2' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner3' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner4' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner5' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner6' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner7' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner8' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner9' },
|
||
// STW Founder banners
|
||
{ role: 'STW Founder Banner', bannerId: 'FounderTier5Banner1' },
|
||
{ role: 'STW Founder Banner', bannerId: 'FounderTier4Banner1' },
|
||
{ role: 'STW Founder Banner', bannerId: 'FounderTier3Banner1' },
|
||
{ role: 'STW Founder Banner', bannerId: 'FounderTier2Banner1' },
|
||
{ role: 'STW Founder Banner', bannerId: 'FounderTier1Banner1' },
|
||
// STW Endurance banners
|
||
{ role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater04' },
|
||
{ role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater03' },
|
||
{ role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater02' },
|
||
{ role: 'STW Endurance Banner', bannerId: 'EnduranceWave30Theater01' },
|
||
// STW Survival / Wargames banners
|
||
{ role: 'STW Event Banner', bannerId: 'SurvivalBannerStorm2018Hard' },
|
||
{ role: 'STW Event Banner', bannerId: 'SurvivalBannerHolidayHard' },
|
||
{ role: 'STW Event Banner', bannerId: 'WargamesTprifts' },
|
||
{ role: 'STW Event Banner', bannerId: 'WargamesTiki' },
|
||
{ role: 'STW Event Banner', bannerId: 'WargamesPods' },
|
||
// STW Achievement banners
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementUnspeakableHorrors' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementWorldExplorer' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementGuardianAngel' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementPlaysWellWithOthers' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementTalentedBuilder' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementGoGnome' },
|
||
{ role: 'STW Achievement Banner', bannerId: 'AchievementLootLegend' },
|
||
// FNCS / Skirmish / World Cup banners
|
||
{ role: 'FNCS Banner', bannerId: 'BRS23_FNCS_Drops' },
|
||
{ role: 'FNCS Banner', bannerId: 'S11FNCS' },
|
||
{ role: 'Skirmish Banner', bannerId: 'BRSkirmishBushBandits' },
|
||
{ role: 'Skirmish Banner', bannerId: 'BRSkirmishDustyDogs' },
|
||
{ role: 'Skirmish Banner', bannerId: 'BRSkirmishFortKnights' },
|
||
{ role: 'Skirmish Banner', bannerId: 'BRSkirmishLuckyLlamas' },
|
||
{ role: 'Skirmish Banner', bannerId: 'BRSkirmishRiftRaiders' },
|
||
// Season Level 100 banners
|
||
{ role: 'Season Level 100 Banner', bannerId: 'BRS10Level100' },
|
||
{ role: 'Season Level 100 Banner', bannerId: 'BRS9Lvl100' },
|
||
{ role: 'Season Level 100 Banner', bannerId: 'BRS8Level100' },
|
||
// Creator / Influencer banners 10-58
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner10' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner11' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner12' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner13' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner14' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner15' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner16' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner17' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner18' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner19' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner20' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner21' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner22' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner23' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner24' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner25' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner26' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner27' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner28' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner29' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner30' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner31' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner32' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner33' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner34' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner35' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner36' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner37' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner38' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner39' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner40' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner41' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner42' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner43' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner44' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner45' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner46' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner47' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner48' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner49' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner50' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner51' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner52' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner53' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner54' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner55' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner56' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner57' },
|
||
{ role: 'Creator Banner', bannerId: 'InfluencerBanner58' },
|
||
// OT Banners
|
||
{ role: 'OT1 Banner', bannerId: 'OT1Banner' },
|
||
{ role: 'OT2 Banner', bannerId: 'OT2Banner' },
|
||
{ role: 'OT3 Banner', bannerId: 'OT3Banner' },
|
||
{ role: 'OT4 Banner', bannerId: 'OT4Banner' },
|
||
{ role: 'OT5 Banner', bannerId: 'OT5Banner' },
|
||
{ role: 'OT6 Banner', bannerId: 'OT6Banner' },
|
||
{ role: 'OT7 Banner', bannerId: 'OT7Banner' },
|
||
{ role: 'OT8 Banner', bannerId: 'OT8Banner' },
|
||
{ role: 'OT9 Banner', bannerId: 'OT9Banner' },
|
||
{ role: 'OT10 Banner', bannerId: 'OT10Banner' },
|
||
{ role: 'OT11 Banner', bannerId: 'OT11Banner' },
|
||
// STW Founders
|
||
{ role: 'STW Founders', id: 'cid_095_athena_commando_m_founder' },
|
||
{ role: 'Ultimate STW Founders', id: 'tiertoken_collectionbook_ultimate', stw: true },
|
||
]
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// EPIC OAUTH CONSTANTS
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const SWITCH_TOKEN = 'OThmN2U0MmMyZTNhNGY4NmE3NGViNDNmYmI0MWVkMzk6MGEyNDQ5YTItMDAxYS00NTFlLWFmZWMtM2U4MTI5MDFjNGQ3';
|
||
const IOS_TOKEN = 'M2Y2OWU1NmM3NjQ5NDkyYzhjYzI5ZjFhZjA4YThhMTI6YjUxZWU5Y2IxMjIzNGY1MGE2OWVmYTY3ZWY1MzgxMmU=';
|
||
const EPIC_OAUTH = 'https://account-public-service-prod.ol.epicgames.com/account/api/oauth/token';
|
||
const EPIC_OAUTH_PROD03 = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token';
|
||
const EPIC_EXCHANGE = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/exchange';
|
||
const EPIC_DEVICE_AUTH = 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/deviceAuthorization';
|
||
|
||
const pending = new Map();
|
||
const verifiedUsers = new Map(); // userId -> { guildId, assignedRoles: string[], epicName: string }
|
||
const LOG_CHANNEL_ID = '1517695849952706642';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// SHARED HELPERS
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
async function fetchWithTimeout(url, ms = 12000) {
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), ms);
|
||
try { return await fetch(url, { signal: ctrl.signal }); }
|
||
finally { clearTimeout(timer); }
|
||
}
|
||
|
||
// ─── get icon URL — build directly from known CDN pattern, no API roundtrip ──
|
||
async function fetchIconUrl(cosmeticId) {
|
||
// Direct CDN URL — fortnite-api.com images are always at this pattern
|
||
const url = `https://fortnite-api.com/images/cosmetics/br/${cosmeticId.toLowerCase()}/icon.png`;
|
||
const res = await fetchWithTimeout(url, 8000);
|
||
if (res.ok) return url;
|
||
// Fallback to API lookup if direct URL 404s
|
||
const apiRes = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`, 8000);
|
||
if (!apiRes.ok) throw new Error(`API ${apiRes.status} for ${cosmeticId}`);
|
||
const json = await apiRes.json();
|
||
if (!json.data?.images?.icon) throw new Error(`No icon for ${cosmeticId}`);
|
||
return json.data.images.icon;
|
||
}
|
||
|
||
async function downloadAndProcess(imageUrl) {
|
||
const res = await fetchWithTimeout(imageUrl, 15000);
|
||
if (!res.ok) throw new Error(`Download failed ${res.status}`);
|
||
const raw = Buffer.from(await res.arrayBuffer());
|
||
return sharp(raw)
|
||
.resize(128, 128, { fit: 'cover', position: 'centre', kernel: 'lanczos3' })
|
||
.sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 })
|
||
.png({ compressionLevel: 6 })
|
||
.toBuffer();
|
||
}
|
||
|
||
async function createEmojiSafe(guild, buffer, name, reason) {
|
||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||
try {
|
||
return await guild.emojis.create({ attachment: buffer, name, reason });
|
||
} catch (err) {
|
||
const isRL = err.status === 429 || err.httpStatus === 429 ||
|
||
(err.message || '').toLowerCase().includes('rate limit');
|
||
if (isRL) {
|
||
const wait = ((err.retryAfter || 5) * 1000) + 1000;
|
||
console.warn(` ⏳ Rate limited "${name}", waiting ${Math.ceil(wait/1000)}s...`);
|
||
await new Promise(r => setTimeout(r, wait));
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
throw new Error(`Gave up on emoji "${name}"`);
|
||
}
|
||
|
||
// ─── Epic helpers ─────────────────────────────────────────────────────────────
|
||
async function getSwitchAccessToken() {
|
||
const res = await fetch(EPIC_OAUTH, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `basic ${SWITCH_TOKEN}` },
|
||
body: 'grant_type=client_credentials',
|
||
});
|
||
return (await res.json()).access_token;
|
||
}
|
||
|
||
async function createDeviceCode(token) {
|
||
const res = await fetch(EPIC_DEVICE_AUTH, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
async function waitForLogin(deviceCode, maxMs = 300000) {
|
||
const start = Date.now();
|
||
while (Date.now() - start < maxMs) {
|
||
await new Promise(r => setTimeout(r, 10000));
|
||
const res = await fetch(EPIC_OAUTH_PROD03, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `basic ${SWITCH_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `grant_type=device_code&device_code=${deviceCode}`,
|
||
});
|
||
const data = await res.json();
|
||
if (res.status === 200 && data.access_token) return data;
|
||
const err = data.errorCode || '';
|
||
if (err.includes('not_found') || err.includes('expired')) return null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function exchangeForIosToken(accessToken) {
|
||
const exRes = await fetch(EPIC_EXCHANGE, { headers: { 'Authorization': `bearer ${accessToken}` } });
|
||
const exData = await exRes.json();
|
||
const res = await fetch(EPIC_OAUTH_PROD03, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `basic ${IOS_TOKEN}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: `grant_type=exchange_code&exchange_code=${exData.code}`,
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
async function getProfile(accountId, accessToken, profileId) {
|
||
const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=${profileId}&rvn=-1`;
|
||
const res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `bearer ${accessToken}`, 'Content-Type': 'application/json' },
|
||
body: '{}',
|
||
});
|
||
return res.json();
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// SKIN CHECKER IMAGE RENDERER (RiftCheckerBot style)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const { createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas');
|
||
const path = require('path');
|
||
|
||
// Register Fortnite font (Burbank Big Condensed Black) + Tahoma
|
||
GlobalFonts.registerFromPath(
|
||
path.join(__dirname, 'BurbankBigCondensed-Black.ttf'), 'Burbank'
|
||
);
|
||
GlobalFonts.registerFromPath('C:/Windows/Fonts/tahoma.ttf', 'Tahoma');
|
||
GlobalFonts.registerFromPath('C:/Windows/Fonts/tahomabd.ttf', 'TahomaB');
|
||
|
||
// Custom background URL — set with !setbg <url>, cleared with !clearbg
|
||
let customBgUrl = null;
|
||
|
||
const ICON = 96;
|
||
const COLS = 12; // wider rows = shorter images
|
||
const LABEL = 26;
|
||
const CELL_H = ICON + LABEL;
|
||
const PAD = 14;
|
||
const HDR_H = 72;
|
||
const FTR_H = 36;
|
||
|
||
const RC = {
|
||
common: '#c8c8c8', uncommon: '#60aa3a', rare: '#4990e2',
|
||
epic: '#874bd2', legendary: '#d3782e', mythic: '#ffd700',
|
||
icon: '#54ceec', marvel: '#e63c3c', dark: '#641464',
|
||
frozen: '#94dffc', lava: '#ca4d1d', shadow: '#505050',
|
||
gaming: '#1a8e48', slurp: '#00e9b0',
|
||
};
|
||
|
||
const OG_STYLE_ICONS = {
|
||
'cid_029_athena_commando_f_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_029_athena_commando_f_halloween/variants/material/mat3.png',
|
||
'cid_030_athena_commando_m_halloween': 'https://fortnite-api.com/images/cosmetics/br/cid_030_athena_commando_m_halloween/variants/clothingcolor/mat1.png',
|
||
'cid_028_athena_commando_f': 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png',
|
||
'cid_701_athena_commando_m_bananaagent': 'https://fortnite-api.com/images/cosmetics/br/cid_701_athena_commando_m_bananaagent/variants/progressive/stage4.png',
|
||
'cid_116_athena_commando_m_carbideblack': 'https://fortnite-api.com/images/cosmetics/br/cid_116_athena_commando_m_carbideblack/variants/parts/stage1.png',
|
||
'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png',
|
||
'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png',
|
||
'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png',
|
||
'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png',
|
||
};
|
||
|
||
// Cache the logo so we only fetch once per session
|
||
let _logoBuffer = null;
|
||
async function getLogoBuffer() {
|
||
if (_logoBuffer) return _logoBuffer;
|
||
try {
|
||
const res = await fetchWithTimeout(
|
||
'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png',
|
||
8000
|
||
);
|
||
if (res.ok) _logoBuffer = Buffer.from(await res.arrayBuffer());
|
||
} catch { _logoBuffer = null; }
|
||
return _logoBuffer;
|
||
}
|
||
|
||
const CACHE_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cache');
|
||
const RARITY_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'styles', 'rift', 'rarity');
|
||
const ICON_DIR = path.join(__dirname, 'RiftCheckerBot-main', 'cosmetic_icons');
|
||
const fs = require('fs');
|
||
|
||
// Cosmetics that have special OG variant images in local cache
|
||
// key = lowercase cosmetic ID, value = local cache filename
|
||
const LOCAL_OG_ICONS = {
|
||
'cid_029_athena_commando_f_halloween': 'pink_ghoul.png', // Pink Ghoul
|
||
'cid_030_athena_commando_m_halloween': 'purple_skull.png', // Purple Skull
|
||
'cid_028_athena_commando_f': 'og_rene.png', // Black & Gold Renegade Raider
|
||
'cid_017_athena_commando_m': 'og_aat.png', // OG Aerial Assault Trooper
|
||
'cid_116_athena_commando_m_carbideblack': 'omega_max.png', // Max Omega
|
||
'cid_701_athena_commando_m_bananaagent': 'gold_peely.png', // Gold Agent Peely
|
||
};
|
||
|
||
// Remote OG variant URLs for cosmetics not in local cache
|
||
const REMOTE_OG_ICONS = {
|
||
'pickaxe_lockjaw': 'https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png',
|
||
'glider_id_001': 'https://fortnite-api.com/images/cosmetics/br/glider_id_001/variants/particle/particle1.png',
|
||
'cid_694_athena_commando_m_catburglar': 'https://fortnite-api.com/images/cosmetics/br/cid_694_athena_commando_m_catburglar/variants/progressive/stage4.png',
|
||
'cid_693_athena_commando_m_buffcat': 'https://fortnite-api.com/images/cosmetics/br/cid_693_athena_commando_m_buffcat/variants/progressive/stage4.png',
|
||
'cid_691_athena_commando_f_tntina': 'https://fortnite-api.com/images/cosmetics/br/cid_691_athena_commando_f_tntina/variants/progressive/stage7.png',
|
||
'cid_690_athena_commando_f_photographer': 'https://fortnite-api.com/images/cosmetics/br/cid_690_athena_commando_f_photographer/variants/progressive/stage4.png',
|
||
};
|
||
|
||
// Load a rarity background image from local cache
|
||
const rarityImageCache = {};
|
||
async function getRarityImage(rarity) {
|
||
const key = (rarity || 'common').toLowerCase();
|
||
if (rarityImageCache[key]) return rarityImageCache[key];
|
||
const filePath = path.join(RARITY_DIR, `${key}.png`);
|
||
if (fs.existsSync(filePath)) {
|
||
try {
|
||
const img = await loadImage(fs.readFileSync(filePath));
|
||
rarityImageCache[key] = img;
|
||
return img;
|
||
} catch { /* fall through */ }
|
||
}
|
||
// fallback to common
|
||
const fallback = path.join(RARITY_DIR, 'common.png');
|
||
if (fs.existsSync(fallback)) {
|
||
const img = await loadImage(fs.readFileSync(fallback));
|
||
rarityImageCache['common'] = img;
|
||
return img;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Get cosmetic icon — local cache first, then remote
|
||
async function fetchSkinImageBuffer(cosmeticId, useOgStyle = false) {
|
||
const id = cosmeticId.toLowerCase();
|
||
|
||
// 1. Try local OG variant if requested
|
||
if (useOgStyle && LOCAL_OG_ICONS[id]) {
|
||
const localPath = path.join(CACHE_DIR, LOCAL_OG_ICONS[id]);
|
||
if (fs.existsSync(localPath)) return fs.readFileSync(localPath);
|
||
}
|
||
|
||
// 2. Try remote OG variant if requested
|
||
if (useOgStyle && REMOTE_OG_ICONS[id]) {
|
||
try {
|
||
const res = await fetchWithTimeout(REMOTE_OG_ICONS[id], 8000);
|
||
if (res.ok) return Buffer.from(await res.arrayBuffer());
|
||
} catch { /* fall through */ }
|
||
}
|
||
|
||
// 3. Check if this is a banner ID — fetch from banner API
|
||
const bannerPrefixes = ['ot','influencerbanner','foundertier','brs','survival','endurance','wargames','achievement','otherbanner','skirmish','worldcup'];
|
||
if (bannerPrefixes.some(p => id.startsWith(p))) {
|
||
// Try local RiftCheckerBot cache first
|
||
try {
|
||
const files = fs.readdirSync(CACHE_DIR);
|
||
const match = files.find(f => f.toLowerCase() === id + '.png' || f.toLowerCase() === cosmeticId + '.png');
|
||
if (match) return fs.readFileSync(path.join(CACHE_DIR, match));
|
||
} catch { /* fall through */ }
|
||
// Fetch from banner API
|
||
try {
|
||
const bMeta = await getBannerMeta();
|
||
const iconUrl = bMeta[id]?.icon;
|
||
if (iconUrl) {
|
||
const res = await fetchWithTimeout(iconUrl, 8000);
|
||
if (res.ok) return Buffer.from(await res.arrayBuffer());
|
||
}
|
||
} catch { /* fall through */ }
|
||
return null;
|
||
}
|
||
|
||
// 4. Try local cache with exact cosmetic ID match
|
||
try {
|
||
const files = fs.readdirSync(CACHE_DIR);
|
||
const baseName = cosmeticId + '.png';
|
||
const match = files.find(f => f.toLowerCase() === baseName.toLowerCase());
|
||
if (match) return fs.readFileSync(path.join(CACHE_DIR, match));
|
||
} catch { /* fall through */ }
|
||
|
||
// 5. Fortnite API CDN
|
||
const iconUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/icon.png`;
|
||
try {
|
||
const res = await fetchWithTimeout(iconUrl, 8000);
|
||
if (res.ok) return Buffer.from(await res.arrayBuffer());
|
||
} catch { /* fall through */ }
|
||
|
||
// 6. Fallback: smallIcon
|
||
const smallUrl = `https://fortnite-api.com/images/cosmetics/br/${id}/smallicon.png`;
|
||
try {
|
||
const res = await fetchWithTimeout(smallUrl, 8000);
|
||
if (res.ok) return Buffer.from(await res.arrayBuffer());
|
||
} catch { /* give up */ }
|
||
|
||
return null;
|
||
}
|
||
|
||
async function renderLockerImage(items, countLabel, subtitle, submittedBy = null, ogStyleOwned = null) {
|
||
if (!items || items.length === 0) return null;
|
||
|
||
const LOGO_SIZE = HDR_H - 8;
|
||
const rows = Math.ceil(items.length / COLS);
|
||
const cols = Math.min(items.length, COLS);
|
||
const W = Math.max(cols * ICON + PAD * 2, 400);
|
||
const H = HDR_H + rows * CELL_H + PAD + FTR_H;
|
||
|
||
const canvas = createCanvas(W, H);
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// ── Background ───────────────────────────────────────────────────────────
|
||
if (customBgUrl) {
|
||
try {
|
||
const res = await fetchWithTimeout(customBgUrl, 10000);
|
||
if (res.ok) {
|
||
let rawBuf = Buffer.from(await res.arrayBuffer());
|
||
const contentType = res.headers.get('content-type') || '';
|
||
const isGif = customBgUrl.toLowerCase().endsWith('.gif') || contentType.includes('gif');
|
||
// For GIFs extract first frame; for everything else just resize
|
||
const processed = await sharp(rawBuf, { animated: isGif })
|
||
.resize(W, H, { fit: 'cover', position: 'centre' })
|
||
.png()
|
||
.toBuffer();
|
||
ctx.drawImage(await loadImage(processed), 0, 0, W, H);
|
||
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
} else { throw new Error('bg fetch failed'); }
|
||
} catch {
|
||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||
bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18');
|
||
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
|
||
}
|
||
} else {
|
||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||
bg.addColorStop(0, '#1c1c28'); bg.addColorStop(1, '#0e0e18');
|
||
ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H);
|
||
}
|
||
|
||
// Grid lines
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 1;
|
||
for (let c = 0; c <= cols; c++) {
|
||
ctx.beginPath(); ctx.moveTo(PAD + c * ICON, HDR_H); ctx.lineTo(PAD + c * ICON, HDR_H + rows * CELL_H); ctx.stroke();
|
||
}
|
||
for (let r = 0; r <= rows; r++) {
|
||
ctx.beginPath(); ctx.moveTo(PAD, HDR_H + r * CELL_H); ctx.lineTo(PAD + cols * ICON, HDR_H + r * CELL_H); ctx.stroke();
|
||
}
|
||
|
||
// ── Logo ─────────────────────────────────────────────────────────────────
|
||
const logoBuf = await getLogoBuffer();
|
||
let logoEndX = PAD;
|
||
if (logoBuf) {
|
||
try {
|
||
const logoResized = await sharp(logoBuf).resize(LOGO_SIZE, LOGO_SIZE, { fit: 'cover' }).png().toBuffer();
|
||
const logoImg = await loadImage(logoResized);
|
||
const cx = PAD + LOGO_SIZE / 2, cy = 4 + LOGO_SIZE / 2, lr = LOGO_SIZE / 2;
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.clip();
|
||
ctx.drawImage(logoImg, PAD, 4, LOGO_SIZE, LOGO_SIZE);
|
||
ctx.restore();
|
||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.arc(cx, cy, lr, 0, Math.PI * 2); ctx.stroke();
|
||
logoEndX = PAD + LOGO_SIZE + 10;
|
||
} catch { logoEndX = PAD; }
|
||
}
|
||
|
||
// ── Header text ───────────────────────────────────────────────────────────
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = 'bold 26px TahomaB';
|
||
ctx.fillText(countLabel, logoEndX, 40);
|
||
ctx.fillStyle = '#aaaaaa';
|
||
ctx.font = '13px Tahoma';
|
||
ctx.fillText(subtitle.toUpperCase(), logoEndX, 60);
|
||
|
||
// ── Cosmetic grid ─────────────────────────────────────────────────────────
|
||
let idx = 0;
|
||
for (const item of items) {
|
||
const col = idx % COLS;
|
||
const row = Math.floor(idx / COLS);
|
||
const x = PAD + col * ICON;
|
||
const y = HDR_H + row * CELL_H;
|
||
const id = item.id.toLowerCase();
|
||
|
||
// Determine if we should show OG style for this item
|
||
const hasOgRequirement = !!OG_STYLE_REQUIREMENTS[id];
|
||
const ownsOgStyle = ogStyleOwned ? ogStyleOwned.has(id) : false;
|
||
const useOg = hasOgRequirement && ownsOgStyle;
|
||
|
||
// Effective rarity: mythic if OG style owned, otherwise use metaMap rarity
|
||
const effectiveRarity = useOg ? 'mythic' : (item.rarity || 'common');
|
||
|
||
// ── Draw rarity background from local cache ───────────────────────────
|
||
const rarityFilePath = path.join(RARITY_DIR, `${effectiveRarity}.png`);
|
||
if (fs.existsSync(rarityFilePath)) {
|
||
try {
|
||
const rarBuf = await sharp(fs.readFileSync(rarityFilePath))
|
||
.resize(ICON, ICON, { fit: 'fill' }).png().toBuffer();
|
||
ctx.drawImage(await loadImage(rarBuf), x, y, ICON, ICON);
|
||
} catch { /* skip */ }
|
||
} else {
|
||
ctx.fillStyle = (RC[effectiveRarity] || RC.common) + '28';
|
||
ctx.fillRect(x, y, ICON, ICON);
|
||
}
|
||
|
||
// ── Gradient border around the cell ──────────────────────────────────
|
||
const borderColor = RC[effectiveRarity] || RC.common;
|
||
// Top edge gradient (left to right)
|
||
const topGrad = ctx.createLinearGradient(x, y, x + ICON, y);
|
||
topGrad.addColorStop(0, borderColor + '00');
|
||
topGrad.addColorStop(0.3, borderColor + 'ff');
|
||
topGrad.addColorStop(0.7, borderColor + 'ff');
|
||
topGrad.addColorStop(1, borderColor + '00');
|
||
ctx.strokeStyle = topGrad; ctx.lineWidth = 2;
|
||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ICON, y); ctx.stroke();
|
||
|
||
// Bottom edge gradient
|
||
const botGrad = ctx.createLinearGradient(x, y + ICON, x + ICON, y + ICON);
|
||
botGrad.addColorStop(0, borderColor + '00');
|
||
botGrad.addColorStop(0.3, borderColor + 'ff');
|
||
botGrad.addColorStop(0.7, borderColor + 'ff');
|
||
botGrad.addColorStop(1, borderColor + '00');
|
||
ctx.strokeStyle = botGrad;
|
||
ctx.beginPath(); ctx.moveTo(x, y + ICON); ctx.lineTo(x + ICON, y + ICON); ctx.stroke();
|
||
|
||
// Left edge gradient (top to bottom)
|
||
const leftGrad = ctx.createLinearGradient(x, y, x, y + ICON);
|
||
leftGrad.addColorStop(0, borderColor + '00');
|
||
leftGrad.addColorStop(0.3, borderColor + 'ff');
|
||
leftGrad.addColorStop(0.7, borderColor + 'ff');
|
||
leftGrad.addColorStop(1, borderColor + '00');
|
||
ctx.strokeStyle = leftGrad;
|
||
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + ICON); ctx.stroke();
|
||
|
||
// Right edge gradient
|
||
const rightGrad = ctx.createLinearGradient(x + ICON, y, x + ICON, y + ICON);
|
||
rightGrad.addColorStop(0, borderColor + '00');
|
||
rightGrad.addColorStop(0.3, borderColor + 'ff');
|
||
rightGrad.addColorStop(0.7, borderColor + 'ff');
|
||
rightGrad.addColorStop(1, borderColor + '00');
|
||
ctx.strokeStyle = rightGrad;
|
||
ctx.beginPath(); ctx.moveTo(x + ICON, y); ctx.lineTo(x + ICON, y + ICON); ctx.stroke();
|
||
|
||
// ── Draw cosmetic icon ────────────────────────────────────────────────
|
||
const buf = await fetchSkinImageBuffer(item.id, useOg);
|
||
if (buf) {
|
||
try {
|
||
const resized = await sharp(buf).resize(ICON - 6, ICON - 6, { fit: 'cover' }).png().toBuffer();
|
||
ctx.drawImage(await loadImage(resized), x + 3, y + 3, ICON - 6, ICON - 6);
|
||
} catch { /* skip */ }
|
||
}
|
||
|
||
// ── Name label ────────────────────────────────────────────────────────
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = '10px Tahoma';
|
||
ctx.textAlign = 'center';
|
||
const maxW = ICON - 4;
|
||
let name = item.name || '';
|
||
while (name.length > 3 && ctx.measureText(name).width > maxW) name = name.slice(0, -1);
|
||
if (name !== item.name) name = name.slice(0, -1) + '..';
|
||
ctx.fillText(name, x + ICON / 2, y + ICON + 17, maxW);
|
||
ctx.textAlign = 'left';
|
||
idx++;
|
||
}
|
||
|
||
// ── Footer ────────────────────────────────────────────────────────────────
|
||
const footerY = H - FTR_H;
|
||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||
ctx.fillRect(0, footerY, W, FTR_H);
|
||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(0, footerY); ctx.lineTo(W, footerY); ctx.stroke();
|
||
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = 'bold 11px TahomaB';
|
||
ctx.fillText(submittedBy ? `Submitted by: ${submittedBy}` : 'Made by jitterbuxg', PAD, footerY + 14);
|
||
ctx.fillStyle = '#ffd700';
|
||
ctx.font = '10px Tahoma';
|
||
ctx.fillText('.gg/collecting', PAD, footerY + 28);
|
||
|
||
ctx.fillStyle = '#888888';
|
||
ctx.font = '10px Tahoma';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), W - PAD, footerY + 21);
|
||
ctx.textAlign = 'left';
|
||
|
||
return canvas.toBuffer('image/png');
|
||
}
|
||
|
||
// Banner metadata cache — fetched once from fortnite-api.com/v1/banners
|
||
let bannerMetaCache = null;
|
||
async function getBannerMeta() {
|
||
if (bannerMetaCache) return bannerMetaCache;
|
||
try {
|
||
const res = await fetchWithTimeout('https://fortnite-api.com/v1/banners', 10000);
|
||
if (res.ok) {
|
||
const json = await res.json();
|
||
const map = {};
|
||
for (const b of (json.data || [])) {
|
||
map[b.id.toLowerCase()] = {
|
||
name: b.devName || b.id,
|
||
icon: b.images?.icon || b.images?.smallIcon,
|
||
rarity: 'common',
|
||
};
|
||
}
|
||
bannerMetaCache = map;
|
||
return map;
|
||
}
|
||
} catch { /* ignore */ }
|
||
return {};
|
||
}
|
||
|
||
const BASE_EXCLUSIVE_BANNER_IDS = [
|
||
// OT Banners
|
||
'ot1banner','ot2banner','ot3banner','ot4banner','ot5banner',
|
||
'ot6banner','ot7banner','ot8banner','ot9banner','ot10banner','ot11banner',
|
||
// Alpha tester / rare
|
||
'otherbanner51',
|
||
// World Cup / FNCS / Skirmish
|
||
'brworldcup2019','brs9worldcup2019','brs23_fncs_drops','s11fncs',
|
||
'brskirmishbushbandits','brskirmishdustydogs','brskirmishfortknights',
|
||
'brskirmishluckyllamas','brskirmishriftraiders',
|
||
// Season level 100
|
||
'brs8level100','brs9lvl100','brs10level100',
|
||
];
|
||
|
||
function looksLikeBannerId(rawId) {
|
||
const id = String(rawId || '').trim().toLowerCase();
|
||
if (!id) return false;
|
||
return (
|
||
id.includes('banner') ||
|
||
id.startsWith('ot') ||
|
||
id.startsWith('brs') ||
|
||
id.startsWith('brseason') ||
|
||
id.startsWith('brworldcup') ||
|
||
id.startsWith('brskirmish') ||
|
||
id.startsWith('endurance') ||
|
||
id.startsWith('wargames') ||
|
||
id.startsWith('survival') ||
|
||
id.startsWith('achievement') ||
|
||
id.startsWith('starlight') ||
|
||
id.startsWith('winter2018') ||
|
||
id.startsWith('newsletter') ||
|
||
id.startsWith('horde')
|
||
);
|
||
}
|
||
|
||
function getExclusiveBannerIds() {
|
||
const ids = new Set(BASE_EXCLUSIVE_BANNER_IDS);
|
||
for (const req of ROLE_REQUIREMENTS) {
|
||
if (req.bannerId) ids.add(req.bannerId.toLowerCase());
|
||
}
|
||
|
||
try {
|
||
const listPath = path.join(__dirname, 'RiftCheckerBot-main', 'exclusive.txt');
|
||
const lines = fs.readFileSync(listPath, 'utf8').split(/\r?\n/);
|
||
for (const line of lines) {
|
||
const id = line.trim().toLowerCase();
|
||
if (looksLikeBannerId(id)) ids.add(id);
|
||
}
|
||
} catch { /* bundled list is optional */ }
|
||
|
||
return ids;
|
||
}
|
||
|
||
function commonCoreOwnsId(ccItems, wantedId) {
|
||
const needle = wantedId.toLowerCase();
|
||
return Object.values(ccItems || {}).some(item => {
|
||
const templateId = (item.templateId || '').toLowerCase();
|
||
const itemId = templateId.split(':').pop();
|
||
return itemId === needle || templateId.includes(needle);
|
||
});
|
||
}
|
||
|
||
function collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap = null) {
|
||
const items = [];
|
||
const seen = new Set();
|
||
|
||
for (const id of getExclusiveBannerIds()) {
|
||
if (seen.has(id) || !commonCoreOwnsId(ccItems, id)) continue;
|
||
seen.add(id);
|
||
|
||
const info = bannerMeta[id] || {};
|
||
const name = info.name || id;
|
||
items.push({ id, name, rarity: 'mythic' });
|
||
if (metaMap) metaMap[id] = { name, rarity: 'mythic' };
|
||
}
|
||
|
||
return items;
|
||
}
|
||
|
||
// Batch fetch cosmetic metadata (name + rarity) for up to 50 IDs at once
|
||
async function fetchCosmeticMeta(ids) {
|
||
const map = {};
|
||
if (!ids || ids.length === 0) return map;
|
||
for (let i = 0; i < ids.length; i += 50) {
|
||
const chunk = ids.slice(i, i + 50);
|
||
try {
|
||
const qs = chunk.map(id => `id=${encodeURIComponent(id)}`).join('&');
|
||
const res = await fetchWithTimeout(`https://fortnite-api.com/v2/cosmetics/br/search/ids?language=en&${qs}`, 10000);
|
||
if (!res.ok) continue;
|
||
const json = await res.json();
|
||
for (const item of (json.data || [])) {
|
||
map[item.id.toLowerCase()] = { name: item.name, rarity: item.rarity?.value || 'common' };
|
||
}
|
||
} catch { /* skip chunk on error */ }
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Cosmetics that have OG style requirements - id -> one or more owned style tags
|
||
const OG_STYLE_REQUIREMENTS = {
|
||
'cid_029_athena_commando_f_halloween': ['Mat3'], // Pink Ghoul
|
||
'cid_030_athena_commando_m_halloween': ['Mat1'], // Purple Skull
|
||
'cid_028_athena_commando_f': ['Mat3'], // Black & Gold Renegade Raider
|
||
'cid_017_athena_commando_m': ['Stage2'], // OG Aerial Assault Trooper
|
||
'cid_116_athena_commando_m_carbideblack': ['Stage5'], // Max Omega
|
||
'cid_701_athena_commando_m_bananaagent': ['Stage4'], // Gold Agent Peely
|
||
'pickaxe_lockjaw': ['Stage2'], // Black & Gold Raider's Revenge
|
||
'glider_id_001': ['Stage2'], // OG Aerial Assault One
|
||
};
|
||
|
||
function requiredStyleTags(reqOrTags) {
|
||
if (!reqOrTags) return [];
|
||
if (Array.isArray(reqOrTags)) return reqOrTags;
|
||
if (Array.isArray(reqOrTags.styleTags)) return reqOrTags.styleTags;
|
||
return reqOrTags.styleTag ? [reqOrTags.styleTag] : [];
|
||
}
|
||
|
||
function ownsAnyStyleTag(ownedStyleSet, tags) {
|
||
const wanted = requiredStyleTags(tags).map(tag => String(tag).toLowerCase());
|
||
if (wanted.length === 0) return true;
|
||
if (!ownedStyleSet) return false;
|
||
const owned = [...ownedStyleSet].map(tag => String(tag).toLowerCase());
|
||
return wanted.some(tag => owned.includes(tag));
|
||
}
|
||
|
||
function qualifiesForRequirement(req, ownedMap) {
|
||
const id = req.id.toLowerCase();
|
||
if (!ownedMap[id]) return false;
|
||
return ownsAnyStyleTag(ownedMap[id], req);
|
||
}
|
||
|
||
function uniqueIds(ids) {
|
||
return [...new Set(ids.filter(Boolean))];
|
||
}
|
||
|
||
function buildOwnedMap(athenaData) {
|
||
const owned = {}; // id -> Set of owned style tags
|
||
const ogStyleOwned = new Set();
|
||
const outfits = [];
|
||
const pickaxes = [];
|
||
const emotes = [];
|
||
const emoticons = []; // AthenaEmoji type (in-game emoticons, NOT emotes)
|
||
const backblings = [];
|
||
const gliders = [];
|
||
const sprays = [];
|
||
const items = athenaData?.profileChanges?.[0]?.profile?.items || {};
|
||
for (const item of Object.values(items)) {
|
||
const tpl = (item.templateId || '').toLowerCase();
|
||
if (!tpl.startsWith('athena') && !tpl.startsWith('character')) continue;
|
||
const id = tpl.split(':')[1] || '';
|
||
if (!owned[id]) owned[id] = new Set();
|
||
for (const v of (item.attributes?.variants || []))
|
||
for (const tag of (v.owned || [])) owned[id].add(tag);
|
||
|
||
if (tpl.startsWith('athenacharacter:') || tpl.startsWith('character:')) outfits.push(id);
|
||
else if (tpl.startsWith('athenapickaxe:')) pickaxes.push(id);
|
||
else if (tpl.startsWith('athenadance:')) emotes.push(id);
|
||
else if (tpl.startsWith('athenaemoji:')) emoticons.push(id);
|
||
else if (tpl.startsWith('athenabackpack:')) backblings.push(id);
|
||
else if (tpl.startsWith('athenaglider:')) gliders.push(id);
|
||
else if (tpl.startsWith('athenasprays:') || tpl.startsWith('athenaspray:')) sprays.push(id);
|
||
}
|
||
// Populate ogStyleOwned after all variants are collected
|
||
for (const id of Object.keys(owned)) {
|
||
if (OG_STYLE_REQUIREMENTS[id] && ownsAnyStyleTag(owned[id], OG_STYLE_REQUIREMENTS[id])) {
|
||
ogStyleOwned.add(id);
|
||
}
|
||
}
|
||
return {
|
||
owned,
|
||
ogStyleOwned,
|
||
outfits: uniqueIds(outfits),
|
||
pickaxes: uniqueIds(pickaxes),
|
||
emotes: uniqueIds(emotes),
|
||
emoticons: uniqueIds(emoticons),
|
||
backblings: uniqueIds(backblings),
|
||
gliders: uniqueIds(gliders),
|
||
sprays: uniqueIds(sprays),
|
||
};
|
||
}
|
||
|
||
function getRolesForLocker(ownedMap, ccItems) {
|
||
const earned = [];
|
||
for (const req of ROLE_REQUIREMENTS) {
|
||
if (req.bannerId) {
|
||
const has = Object.values(ccItems).some(i =>
|
||
(i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase())
|
||
);
|
||
if (has) earned.push(req.role);
|
||
continue;
|
||
}
|
||
if (req.stw) {
|
||
const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase());
|
||
if (ccIds.some(cc => cc.includes(req.id.toLowerCase()))) earned.push(req.role);
|
||
continue;
|
||
}
|
||
if (qualifiesForRequirement(req, ownedMap)) earned.push(req.role);
|
||
}
|
||
const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase());
|
||
const hasFounder = ccIds.some(cc => cc.includes('founder'));
|
||
const hasUltimate = ccIds.some(cc => cc.includes('tiertoken_collectionbook_ultimate'));
|
||
if (hasUltimate) { earned.push('Ultimate STW Founders'); earned.push('STW Founders'); }
|
||
else if (hasFounder) earned.push('STW Founders');
|
||
return [...new Set(earned)];
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// BOT READY
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.once('ready', async () => {
|
||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||
console.log('Commands: !setuproles | !addicons | !skipemoji | !stopemojis | !setroleicons | !removeoldemojis | !verify');
|
||
|
||
// Set bot avatar to Renegade Raider Black & Gold style
|
||
try {
|
||
const imgRes = await fetch('https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png');
|
||
const imgBuf = Buffer.from(await imgRes.arrayBuffer());
|
||
await client.user.setAvatar(imgBuf);
|
||
console.log('✅ Bot avatar set to Renegade Raider Black & Gold.');
|
||
} catch (err) {
|
||
console.warn('Could not set avatar (may already be set or rate limited):', err.message);
|
||
}
|
||
|
||
// Register /commands slash command to guild for instant availability
|
||
const rest = new REST({ version: '10' }).setToken(process.env.TOKEN);
|
||
const slashCommands = [
|
||
new SlashCommandBuilder()
|
||
.setName('commands')
|
||
.setDescription('List all bot commands')
|
||
.toJSON(),
|
||
];
|
||
try {
|
||
await rest.put(Routes.applicationGuildCommands(client.user.id, process.env.EMOJI_GUILD_ID), { body: slashCommands });
|
||
console.log('✅ Slash commands registered to guild.');
|
||
} catch (err) {
|
||
console.error('Failed to register slash commands:', err.message);
|
||
}
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// /commands slash command
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('interactionCreate', async (interaction) => {
|
||
if (!interaction.isChatInputCommand() || interaction.commandName !== 'commands') return;
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setColor(0x1a1a2e)
|
||
.setAuthor({
|
||
name: 'jitterbuxg',
|
||
iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png',
|
||
})
|
||
.setTitle('Bot Commands')
|
||
.setThumbnail('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png')
|
||
.addFields(
|
||
{
|
||
name: 'Admin Setup',
|
||
value: [
|
||
'`!setuproles` — Create all Fortnite roles',
|
||
'`!addicons` — Upload skin icons as server emojis',
|
||
'`!setroleicons` — Apply emojis as role icons',
|
||
'`!removeoldemojis` — Wipe all emojis for a fresh upload',
|
||
'`!setbg <url>` — Set custom background (supports GIFs)',
|
||
'`!clearbg` — Reset to default background',
|
||
].join('\n'),
|
||
inline: false,
|
||
},
|
||
{
|
||
name: 'During !addicons',
|
||
value: '`!skipemoji` — Skip current · `!stopemojis` — Cancel session',
|
||
inline: false,
|
||
},
|
||
{
|
||
name: 'Verification',
|
||
value: '`!verify` — Link your Epic account and auto-assign roles based on your locker',
|
||
inline: false,
|
||
},
|
||
{
|
||
name: 'What !verify checks',
|
||
value: [
|
||
'**OG Skins** — Renegade Raider (B&G), Ghoul Trooper (Pink), Skull Trooper (Purple), + more',
|
||
'**Exclusives** — Galaxy, Ikonik, Glow, Honor Guard, Double Helix, Wildcat, + more',
|
||
'**Pickaxes** — Raider\'s Revenge (B&G), Axe of Champions, AC/DC, + more',
|
||
'**Emotes** — Fresh, Floss, Take the L, Orange Justice, + more',
|
||
'**Banners** — OT1–OT11, Creator Banners, STW Founders, + more',
|
||
].join('\n'),
|
||
inline: false,
|
||
}
|
||
)
|
||
.setImage('https://fortnite-api.com/images/cosmetics/br/pickaxe_lockjaw/variants/particle/particle1.png')
|
||
.setFooter({ text: 'Made by jitterbuxg • .gg/collecting' })
|
||
.setTimestamp();
|
||
|
||
await interaction.reply({ embeds: [embed], ephemeral: false });
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !setbg / !clearbg — custom background for skin checker images
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot) return;
|
||
if (message.content.startsWith('!setbg ')) {
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
const url = message.content.slice(7).trim();
|
||
if (!url.startsWith('http')) return message.reply('Please provide a valid image URL.');
|
||
customBgUrl = url;
|
||
return message.reply(`Background set. All future skin checker images will use this background.`);
|
||
}
|
||
if (message.content === '!clearbg') {
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
customBgUrl = null;
|
||
return message.reply('Background cleared. Back to default dark gradient.');
|
||
}
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !setuproles
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!setuproles') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
|
||
await message.reply(`Creating up to ${roles.length} roles...`);
|
||
let created = 0, skipped = 0;
|
||
const existing = message.guild.roles.cache.map(r => r.name.toLowerCase());
|
||
|
||
for (const role of roles) {
|
||
if (existing.includes(role.name.toLowerCase())) { skipped++; continue; }
|
||
try {
|
||
await message.guild.roles.create({ name: role.name, color: role.color, reason: 'Bulk role setup' });
|
||
console.log(`Created: ${role.name}`);
|
||
created++;
|
||
await new Promise(r => setTimeout(r, 300));
|
||
} catch (err) {
|
||
console.error(`Failed "${role.name}": ${err.message}`);
|
||
}
|
||
}
|
||
message.channel.send(`Done! Created **${created}** roles, skipped **${skipped}** existing.`);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !removeoldemojis
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!removeoldemojis') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
|
||
const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null);
|
||
if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env');
|
||
|
||
await message.reply(`Removing ALL emojis from **${emojiGuild.name}**...`);
|
||
console.log(`🎯 Clearing all emojis from: ${emojiGuild.name} (${emojiGuild.id})`);
|
||
await emojiGuild.emojis.fetch();
|
||
|
||
const allEmojis = [...emojiGuild.emojis.cache.values()];
|
||
console.log(`📋 Found ${allEmojis.length} emojis to delete`);
|
||
let removed = 0;
|
||
|
||
for (const emoji of allEmojis) {
|
||
try {
|
||
await emoji.delete('Clearing all emojis for fresh upload');
|
||
console.log(`🗑 Deleted: ${emoji.name}`);
|
||
removed++;
|
||
await new Promise(r => setTimeout(r, 400));
|
||
} catch (err) {
|
||
console.error(`Failed to delete ${emoji.name}: ${err.message}`);
|
||
}
|
||
}
|
||
message.channel.send(`Removed **${removed}** emojis from **${emojiGuild.name}**. Run !addicons to re-upload.`);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !addicons + !skipemoji + !stopemojis
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
// Shared state for the active addicons session
|
||
let emojiSession = null; // { resolve, name, channel }
|
||
|
||
// !skipemoji — skip the currently stuck emoji
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!skipemoji') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return;
|
||
if (!emojiSession) return message.reply('No emoji upload is currently in progress.');
|
||
message.reply(`Skipping **${emojiSession.name}**...`);
|
||
emojiSession.resolve('skip');
|
||
});
|
||
|
||
// !stopemojis — stop the whole session
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!stopemojis') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator)) return;
|
||
if (!emojiSession) return message.reply('No emoji upload is currently in progress.');
|
||
message.reply('Stopping emoji upload session.');
|
||
emojiSession.resolve('stop');
|
||
});
|
||
|
||
// Lock to prevent !addicons running multiple times simultaneously
|
||
let addIconsRunning = false;
|
||
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!addicons') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
|
||
if (addIconsRunning)
|
||
return message.reply('An emoji upload is already in progress. Wait for it to finish or use `!stopemojis` to cancel.');
|
||
|
||
const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null);
|
||
if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env');
|
||
|
||
await message.reply(`Uploading ${roleIconMap.length} skin icons to **${emojiGuild.name}**.\nType \`!skipemoji\` to skip a stuck one, or \`!stopemojis\` to cancel.`);
|
||
await emojiGuild.emojis.fetch();
|
||
const existing = new Set(emojiGuild.emojis.cache.map(e => e.name.toLowerCase()));
|
||
let added = 0, skipped = 0, failed = 0;
|
||
|
||
for (const entry of roleIconMap) {
|
||
if (existing.has(entry.emoji.toLowerCase())) {
|
||
console.log(`⏭ Exists: ${entry.emoji}`);
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
// Set up skip/stop listener for this entry
|
||
let sessionResolve;
|
||
const skipPromise = new Promise(res => { sessionResolve = res; });
|
||
emojiSession = { resolve: sessionResolve, name: entry.name, channel: message.channel };
|
||
|
||
process.stdout.write(`📥 ${entry.name} — fetching... `);
|
||
try {
|
||
const iconUrl = entry.overrideUrl || entry.bannerUrl || await fetchIconUrl(entry.id);
|
||
process.stdout.write(`processing... `);
|
||
const buffer = await downloadAndProcess(iconUrl);
|
||
process.stdout.write(`uploading... `);
|
||
|
||
// Race the upload against a skip/stop signal
|
||
const result = await Promise.race([
|
||
createEmojiSafe(emojiGuild, buffer, entry.emoji, `Skin icon: ${entry.name}`)
|
||
.then(() => 'done'),
|
||
skipPromise,
|
||
]);
|
||
|
||
if (result === 'stop') {
|
||
console.log('🛑 Stopped by user.');
|
||
emojiSession = null;
|
||
return message.channel.send(`Stopped. Added **${added}**, skipped **${skipped}**, failed **${failed}**.`);
|
||
}
|
||
if (result === 'skip') {
|
||
console.log(`⏭ Skipped: ${entry.name}`);
|
||
skipped++;
|
||
emojiSession = null;
|
||
continue;
|
||
}
|
||
|
||
console.log(`✅`);
|
||
added++;
|
||
} catch (err) {
|
||
console.log(`❌ ${err.message}`);
|
||
failed++;
|
||
}
|
||
|
||
emojiSession = null;
|
||
await new Promise(r => setTimeout(r, 2000));
|
||
}
|
||
|
||
emojiSession = null;
|
||
message.channel.send(`Done. Added **${added}** emojis to **${emojiGuild.name}**. Skipped **${skipped}**. Failed **${failed}**.`);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !setroleicons
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!setroleicons') return;
|
||
if (!message.member.permissions.has(PermissionsBitField.Flags.Administrator))
|
||
return message.reply('Need Administrator permission.');
|
||
|
||
const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null);
|
||
if (!emojiGuild) return message.reply('Could not find the emoji server. Check EMOJI_GUILD_ID in .env');
|
||
|
||
await message.reply('Applying emojis to roles...');
|
||
await emojiGuild.emojis.fetch();
|
||
await message.guild.roles.fetch();
|
||
let updated = 0, skipped = 0, failed = 0;
|
||
|
||
for (const entry of roleIconMap) {
|
||
const role = message.guild.roles.cache.find(r => r.name.toLowerCase() === entry.name.toLowerCase());
|
||
const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === entry.emoji.toLowerCase());
|
||
if (!role) { console.log(`⏭ Role not found: ${entry.name}`); skipped++; continue; }
|
||
if (!emoji) { console.log(`⚠ Emoji missing: ${entry.emoji} — run !addicons first`); skipped++; continue; }
|
||
try {
|
||
await role.setIcon(emoji);
|
||
console.log(`✅ ${entry.name} → :${entry.emoji}:`);
|
||
updated++;
|
||
} catch (err) {
|
||
console.error(`❌ "${entry.name}": ${err.message}`);
|
||
failed++;
|
||
}
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
message.channel.send(`Done. Applied icons to **${updated}** roles. Skipped **${skipped}**. Failed **${failed}**.`);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !unverify — remove verification and strip roles
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!unverify') return;
|
||
|
||
const userId = message.author.id;
|
||
|
||
if (!verifiedUsers.has(userId))
|
||
return message.reply('You are not currently verified. Use `!verify` to link your account.');
|
||
|
||
const prev = verifiedUsers.get(userId);
|
||
const guild = client.guilds.cache.get(prev.guildId) || message.guild;
|
||
|
||
if (guild) {
|
||
try {
|
||
const member = await guild.members.fetch(userId).catch(() => null);
|
||
if (member) {
|
||
for (const roleName of prev.assignedRoles) {
|
||
const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase());
|
||
if (role && member.roles.cache.has(role.id)) {
|
||
await member.roles.remove(role, 'User unverified').catch(() => null);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Unverify role removal error:', err.message);
|
||
}
|
||
}
|
||
|
||
verifiedUsers.delete(userId);
|
||
message.reply(`Your verification as **${prev.epicName}** has been removed and your roles have been stripped. You can now use \`!verify\` to link a different account.`);
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// !verify
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
client.on('messageCreate', async (message) => {
|
||
if (message.author.bot || message.content !== '!verify') return;
|
||
|
||
const userId = message.author.id;
|
||
|
||
// Support DMs — if in DM, we need to find the guild separately
|
||
let guild = message.guild;
|
||
if (!guild) {
|
||
// Try to find the main guild from EMOJI_GUILD_ID
|
||
guild = client.guilds.cache.get(process.env.EMOJI_GUILD_ID) || client.guilds.cache.first();
|
||
if (!guild) return message.reply('Could not find the server. Please use !verify in the server.');
|
||
}
|
||
|
||
// Block if already verified
|
||
if (verifiedUsers.has(userId)) {
|
||
const prev = verifiedUsers.get(userId);
|
||
return message.reply(`You are already verified as **${prev.epicName}**. Use \`!unverify\` to unlink your account and verify a different one.`);
|
||
}
|
||
|
||
if (pending.has(userId))
|
||
return message.reply('You already have a verification in progress. Check your DMs.');
|
||
|
||
await message.reply('A login link has been sent to your DMs.');
|
||
|
||
try {
|
||
const switchToken = await getSwitchAccessToken();
|
||
const deviceData = await createDeviceCode(switchToken);
|
||
if (!deviceData.user_code || !deviceData.device_code)
|
||
return message.author.send('Failed to generate a login link. Try again later.');
|
||
|
||
const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`;
|
||
|
||
const embed = new EmbedBuilder()
|
||
.setColor(0x5865F2)
|
||
.setTitle('Fortnite Account Verification')
|
||
.setDescription(
|
||
`**[Click here to verify →](${loginUrl})**\n\n` +
|
||
`Code: \`${deviceData.user_code}\`\n\n` +
|
||
`Expires in **5 minutes**. The bot will check your locker and assign your roles automatically.`
|
||
)
|
||
.setFooter({ text: 'Read-only — the bot only checks your cosmetics.' });
|
||
|
||
await message.author.send({ embeds: [embed] });
|
||
pending.set(userId, { guildId: guild.id, channelId: message.channel.id });
|
||
|
||
const tokenData = await waitForLogin(deviceData.device_code);
|
||
pending.delete(userId);
|
||
|
||
if (!tokenData)
|
||
return message.author.send('Login expired. Use !verify to try again.');
|
||
|
||
const iosData = await exchangeForIosToken(tokenData.access_token);
|
||
if (!iosData.access_token)
|
||
return message.author.send('Authentication failed. Please try again.');
|
||
|
||
const { account_id: accountId, displayName = 'Unknown', access_token: accessToken } = iosData;
|
||
await message.author.send(`Logged in as **${displayName}**. Fetching locker...`);
|
||
|
||
const [athenaData, commonCoreData] = await Promise.all([
|
||
getProfile(accountId, accessToken, 'athena'),
|
||
getProfile(accountId, accessToken, 'common_core'),
|
||
]);
|
||
|
||
if (!athenaData?.profileChanges)
|
||
return message.author.send('Failed to read your locker. Please try again.');
|
||
|
||
const { owned: ownedMap, ogStyleOwned, outfits, pickaxes, emotes, emoticons, backblings, gliders, sprays } = buildOwnedMap(athenaData);
|
||
const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {};
|
||
const earnedRoles = getRolesForLocker(ownedMap, ccItems);
|
||
|
||
const member = await guild.members.fetch(userId).catch(() => null);
|
||
if (!member)
|
||
return message.author.send('Could not find you in the server. Make sure you are still a member.');
|
||
|
||
const assigned = [], notFound = [];
|
||
for (const roleName of earnedRoles) {
|
||
const role = guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase());
|
||
if (!role) { notFound.push(roleName); continue; }
|
||
if (!member.roles.cache.has(role.id))
|
||
await member.roles.add(role, 'Fortnite locker verification').catch(() => null);
|
||
assigned.push(roleName);
|
||
}
|
||
|
||
// Store verified state so they can't re-verify without !unverify
|
||
verifiedUsers.set(userId, { guildId: guild.id, assignedRoles: assigned, epicName: displayName });
|
||
|
||
const verifyEmbed = new EmbedBuilder()
|
||
.setColor(0x2B2D31)
|
||
.setTitle(`${displayName}'s Verification`)
|
||
.setDescription(
|
||
assigned.length === 0
|
||
? 'No exclusive roles found.'
|
||
: assigned.map(r => `— ${r}`).join('\n')
|
||
)
|
||
.addFields(
|
||
{ name: 'Total Outfits', value: String(outfits.length), inline: true },
|
||
{ name: 'Total Pickaxes', value: String(pickaxes.length), inline: true },
|
||
)
|
||
.setFooter({ text: 'Made by jitterbuxg' });
|
||
|
||
await message.author.send({ embeds: [verifyEmbed] });
|
||
|
||
// Send verification log to designated channel
|
||
try {
|
||
const logChannel = await client.channels.fetch(LOG_CHANNEL_ID).catch(() => null);
|
||
if (logChannel) {
|
||
// Fetch server emojis to use next to role names
|
||
const emojiGuild = await client.guilds.fetch(process.env.EMOJI_GUILD_ID).catch(() => null);
|
||
await emojiGuild?.emojis.fetch().catch(() => null);
|
||
|
||
const getServerEmoji = (roleName) => {
|
||
if (!emojiGuild) return '';
|
||
// Map role names to emoji names we uploaded
|
||
const emojiNameMap = {
|
||
'Galaxy': 'Galaxy', 'Ikonik': 'Ikonik', 'Glow': 'Glow',
|
||
'OG Renegade Raider': 'RenegadeRaider', 'OG Ghoul Trooper': 'GhoulTrooper',
|
||
'OG Skull Trooper': 'SkullTrooper', 'OG Aerial Assault Trooper': 'AerialAssaultTrooper',
|
||
'Black Knight': 'BlackKnight', 'Double Helix': 'DoubleHelix',
|
||
'Honor Guard': 'HonorGuard', 'Wonder': 'Wonder', 'Eon': 'Eon',
|
||
'Dark Vertex': 'DarkVertex', 'Neo Versa': 'NeoVersa',
|
||
'Royale Bomber': 'RoyaleBomber', 'Wildcat': 'Wildcat',
|
||
'Stealth Reflex': 'StealthReflex', 'Reflex': 'Reflex',
|
||
"Raider's Revenge": 'RaidersRevenge', 'Stellar Axe': 'StellarAxe',
|
||
'AC/DC': 'ACDC', 'Merry Mint Axe': 'MerryMintAxe',
|
||
'Axe of Champions': 'AxeOfChampions', 'Blade of Champions': 'BladeOfChampions',
|
||
'Fully Golden Peely': 'FullyGoldenPeely',
|
||
};
|
||
const emojiName = emojiNameMap[roleName];
|
||
if (!emojiName) return '';
|
||
const emoji = emojiGuild.emojis.cache.find(e => e.name.toLowerCase() === emojiName.toLowerCase());
|
||
return emoji ? `${emoji} ` : '';
|
||
};
|
||
|
||
const logEmbed = new EmbedBuilder()
|
||
.setColor(assigned.length > 0 ? 0xFFD700 : 0x444444)
|
||
.setAuthor({
|
||
name: displayName,
|
||
iconURL: 'https://fortnite-api.com/images/cosmetics/br/cid_028_athena_commando_f/variants/material/mat3.png',
|
||
})
|
||
.setTitle('Account Verified')
|
||
.setDescription(
|
||
assigned.length === 0
|
||
? 'No exclusive cosmetics found on this account.'
|
||
: assigned.map(r => `${getServerEmoji(r)}${r}`).join('\n')
|
||
)
|
||
.addFields(
|
||
{ name: 'Discord', value: message.author.username, inline: true },
|
||
{ name: 'Epic Account', value: displayName, inline: true },
|
||
{ name: 'Roles Given', value: String(assigned.length), inline: true },
|
||
)
|
||
.setFooter({ text: '.gg/collecting • Made by jitterbuxg' })
|
||
.setTimestamp();
|
||
await logChannel.send({ embeds: [logEmbed] });
|
||
}
|
||
} catch (err) {
|
||
console.error('Log channel error:', err.message);
|
||
}
|
||
|
||
// ── 1. Fetch metadata for all owned cosmetics ─────────────────────────
|
||
const allIds = [...outfits, ...pickaxes];
|
||
const metaMap = await fetchCosmeticMeta(allIds);
|
||
|
||
// Override rarity to mythic for known exclusives
|
||
for (const req of ROLE_REQUIREMENTS) {
|
||
if (!req.bannerId) {
|
||
const id = req.id.toLowerCase();
|
||
if (qualifiesForRequirement(req, ownedMap) && metaMap[id]) {
|
||
metaMap[id].rarity = 'mythic';
|
||
if (requiredStyleTags(req).length > 0) metaMap[id].name = req.role;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 2. Exclusives rendered image ──────────────────────────────────────
|
||
try {
|
||
const exclusiveItems = [];
|
||
const exclusiveSeen = new Set();
|
||
const addExclusiveItem = (item) => {
|
||
const key = item.id.toLowerCase();
|
||
if (exclusiveSeen.has(key)) return;
|
||
exclusiveSeen.add(key);
|
||
exclusiveItems.push(item);
|
||
};
|
||
|
||
// Exclusive cosmetics (skins, pickaxes, emotes, emoticons)
|
||
for (const req of ROLE_REQUIREMENTS) {
|
||
if (req.bannerId || req.stw) continue;
|
||
const id = req.id.toLowerCase();
|
||
if (!qualifiesForRequirement(req, ownedMap)) continue;
|
||
const meta = metaMap[id] || {};
|
||
addExclusiveItem({ id, name: req.role || meta.name || id, rarity: 'mythic' });
|
||
}
|
||
|
||
// Exclusive & STW banners - pulled from role requirements and bundled master list
|
||
const bannerMeta = await getBannerMeta();
|
||
for (const item of collectOwnedExclusiveBannerItems(ccItems, bannerMeta, metaMap)) {
|
||
addExclusiveItem(item);
|
||
}
|
||
|
||
if (exclusiveItems.length > 0) {
|
||
await message.author.send('Building your exclusive cosmetics image...');
|
||
const exclusiveImg = await renderLockerImage(
|
||
exclusiveItems,
|
||
`${exclusiveItems.length} (0)`,
|
||
'EXCLUSIVES',
|
||
message.author.username,
|
||
ogStyleOwned
|
||
);
|
||
if (exclusiveImg) {
|
||
const top5 = exclusiveItems.slice(0, 5).map(i => i.name).join(' + ');
|
||
const suffix = exclusiveItems.length > 5 ? ` + ${exclusiveItems.length - 5} more` : '';
|
||
await message.author.send({
|
||
content: `**${top5}${suffix}** (${outfits.length} skin count)`,
|
||
files: [{ attachment: exclusiveImg, name: 'exclusives.png' }],
|
||
});
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Exclusive image error:', err.message);
|
||
}
|
||
|
||
// ── 3. Full locker — all items, sorted by rarity, one image per category ──
|
||
try {
|
||
// Rarity sort order (matches RiftCheckerBot)
|
||
const RARITY_ORDER = ['mythic','legendary','dark','lava','slurp','starwars','marvel','frozen','gaminglegends','shadow','icon','dc','epic','rare','uncommon','common'];
|
||
const sortByRarity = (ids) => [...ids].sort((a, b) => {
|
||
const ra = RARITY_ORDER.indexOf(metaMap[a.toLowerCase()]?.rarity || 'common');
|
||
const rb = RARITY_ORDER.indexOf(metaMap[b.toLowerCase()]?.rarity || 'common');
|
||
return (ra === -1 ? 99 : ra) - (rb === -1 ? 99 : rb);
|
||
});
|
||
|
||
// Collect owned exclusive banners
|
||
const bannerMeta = await getBannerMeta();
|
||
const ownedExclusiveBannerIds = [];
|
||
for (const req of ROLE_REQUIREMENTS) {
|
||
if (!req.bannerId) continue;
|
||
const owned = Object.values(ccItems).some(i =>
|
||
(i.templateId || '').toLowerCase().includes(req.bannerId.toLowerCase())
|
||
);
|
||
if (owned) {
|
||
const bid = req.bannerId.toLowerCase();
|
||
if (!metaMap[bid]) {
|
||
metaMap[bid] = { name: bannerMeta[bid]?.name || req.role, rarity: 'mythic' };
|
||
} else {
|
||
metaMap[bid].rarity = 'mythic';
|
||
}
|
||
ownedExclusiveBannerIds.push(req.bannerId);
|
||
}
|
||
}
|
||
|
||
// Fetch metadata for all cosmetic categories
|
||
const allCategoryIds = [...outfits, ...pickaxes, ...emotes, ...emoticons, ...backblings, ...gliders, ...sprays];
|
||
const allMeta = await fetchCosmeticMeta(allCategoryIds);
|
||
for (const [k, v] of Object.entries(allMeta)) metaMap[k] = metaMap[k] || v;
|
||
|
||
const categories = [
|
||
{ ids: sortByRarity(outfits), label: 'OUTFITS', subtitle: `${outfits.length} outfits` },
|
||
{ ids: sortByRarity(pickaxes), label: 'PICKAXES', subtitle: `${pickaxes.length} pickaxes` },
|
||
{ ids: sortByRarity(emotes), label: 'EMOTES', subtitle: `${emotes.length} emotes` },
|
||
{ ids: sortByRarity(emoticons), label: 'EMOTICONS', subtitle: `${emoticons.length} emoticons` },
|
||
{ ids: sortByRarity(backblings), label: 'BACK BLINGS', subtitle: `${backblings.length} back blings` },
|
||
{ ids: sortByRarity(gliders), label: 'GLIDERS', subtitle: `${gliders.length} gliders` },
|
||
{ ids: sortByRarity(sprays), label: 'SPRAYS', subtitle: `${sprays.length} sprays` },
|
||
...(ownedExclusiveBannerIds.length > 0
|
||
? [{ ids: ownedExclusiveBannerIds, label: 'BANNERS', subtitle: `${ownedExclusiveBannerIds.length} banners` }]
|
||
: []),
|
||
].filter(c => c.ids.length > 0);
|
||
|
||
if (categories.length === 0) {
|
||
await message.author.send('No cosmetics found in locker.');
|
||
return;
|
||
}
|
||
|
||
await message.author.send(`Building your full locker (${categories.length} categories)...`);
|
||
|
||
for (const cat of categories) {
|
||
const items = cat.ids.map(id => ({
|
||
id,
|
||
name: metaMap[id.toLowerCase()]?.name || id,
|
||
rarity: metaMap[id.toLowerCase()]?.rarity || 'common',
|
||
}));
|
||
|
||
const img = await renderLockerImage(
|
||
items,
|
||
`${items.length} (0)`,
|
||
cat.label,
|
||
message.author.username,
|
||
ogStyleOwned
|
||
);
|
||
|
||
if (img) {
|
||
await message.author.send({
|
||
content: `**${displayName} — ${cat.subtitle}**`,
|
||
files: [{ attachment: img, name: `${cat.label.toLowerCase().replace(/\s/g, '_')}.png` }],
|
||
});
|
||
}
|
||
|
||
await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('Locker image error:', err.message);
|
||
}
|
||
|
||
} catch (err) {
|
||
pending.delete(userId);
|
||
console.error('Verify error:', err);
|
||
message.author.send('Unexpected error. Try again.').catch(() => null);
|
||
}
|
||
});
|
||
|
||
client.login(process.env.TOKEN);
|