first commit
This commit is contained in:
commit
d94c5c6cc0
134 files changed
+23720
No files matched your search
@@ -0,0 +1,249 @@
|
||||
// =============================================
|
||||
// DISCORD ROLE EMOJI ADDER + ROLE ICON SETTER
|
||||
// =============================================
|
||||
// !addicons — fetches skin icons and uploads as server emojis
|
||||
// !setroleicons — applies uploaded emojis as role icons
|
||||
// !removeoldemojis — removes all skin emojis so you can re-add fresh
|
||||
//
|
||||
// Run with: node "add-icons.js"
|
||||
// =============================================
|
||||
|
||||
require('dotenv').config();
|
||||
const { Client, GatewayIntentBits, PermissionsBitField } = require('discord.js');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent
|
||||
]
|
||||
});
|
||||
|
||||
// Direct cosmetic IDs — all verified to return valid icons from fortnite-api.com
|
||||
// overrideUrl = use a specific variant image instead of the default icon
|
||||
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' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
// ── fetch with timeout so nothing ever hangs ──────────────────────────────────
|
||||
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 from fortnite-api.com by direct ID ───────────────────────────
|
||||
async function fetchIconUrl(cosmeticId) {
|
||||
const url = `https://fortnite-api.com/v2/cosmetics/br/${cosmeticId}`;
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (!res.ok) throw new Error(`API returned ${res.status} for ${cosmeticId}`);
|
||||
const json = await res.json();
|
||||
if (!json.data?.images?.icon) throw new Error(`No icon in response for ${cosmeticId}`);
|
||||
return json.data.images.icon;
|
||||
}
|
||||
|
||||
// ── download image and upscale to 256x256 for crisp Discord emoji ─────────────
|
||||
async function downloadAndProcess(imageUrl) {
|
||||
const res = await fetchWithTimeout(imageUrl, 15000);
|
||||
if (!res.ok) throw new Error(`Image download failed: ${res.status}`);
|
||||
const raw = Buffer.from(await res.arrayBuffer());
|
||||
// Upscale to 256x256 with Lanczos resampling + sharpening
|
||||
return await sharp(raw)
|
||||
.resize(256, 256, { fit: 'cover', position: 'centre', kernel: 'lanczos3' })
|
||||
.sharpen({ sigma: 0.8, m1: 1.0, m2: 2.0 })
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// ── create emoji with automatic rate-limit retry ──────────────────────────────
|
||||
async function createEmojiSafe(guild, buffer, name, reason) {
|
||||
for (let attempt = 1; attempt <= 6; attempt++) {
|
||||
try {
|
||||
return await guild.emojis.create({ attachment: buffer, name, reason });
|
||||
} catch (err) {
|
||||
const isRateLimit = err.status === 429 || err.httpStatus === 429 ||
|
||||
(err.message || '').toLowerCase().includes('rate limit');
|
||||
if (isRateLimit) {
|
||||
const wait = ((err.retryAfter || 5) * 1000) + 1000;
|
||||
console.warn(` ⏳ Rate limited on "${name}", waiting ${wait}ms (attempt ${attempt}/6)...`);
|
||||
await new Promise(r => setTimeout(r, wait));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`Gave up creating emoji "${name}" after 6 attempts`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
client.once('ready', () => {
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
console.log('Commands: !addicons | !setroleicons | !removeoldemojis');
|
||||
});
|
||||
|
||||
// ── !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.');
|
||||
|
||||
await message.reply('Removing old skin emojis...');
|
||||
const names = new Set(roleIconMap.map(e => e.emoji.toLowerCase()));
|
||||
let removed = 0;
|
||||
|
||||
// Fetch fresh emoji list
|
||||
await message.guild.emojis.fetch();
|
||||
|
||||
for (const emoji of message.guild.emojis.cache.values()) {
|
||||
if (!names.has(emoji.name.toLowerCase())) continue;
|
||||
try {
|
||||
await emoji.delete('Replacing with corrected icons');
|
||||
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. Now run \`!addicons\`.`);
|
||||
});
|
||||
|
||||
// ── !addicons ─────────────────────────────────────────────────────────────────
|
||||
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.');
|
||||
|
||||
await message.reply(`Starting — uploading ${roleIconMap.length} skin icons. This takes a few minutes, watch the terminal for progress.`);
|
||||
|
||||
// Always fetch a fresh copy of existing emojis
|
||||
await message.guild.emojis.fetch();
|
||||
const existingNames = new Set(
|
||||
message.guild.emojis.cache.map(e => e.name.toLowerCase())
|
||||
);
|
||||
|
||||
let added = 0, skipped = 0, failed = 0;
|
||||
|
||||
for (const entry of roleIconMap) {
|
||||
if (existingNames.has(entry.emoji.toLowerCase())) {
|
||||
console.log(` ⏭ Already exists: ${entry.emoji}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
process.stdout.write(` 📥 ${entry.name} — fetching... `);
|
||||
try {
|
||||
const iconUrl = entry.overrideUrl || await fetchIconUrl(entry.id);
|
||||
process.stdout.write(`downloading... `);
|
||||
const buffer = await downloadAndProcess(iconUrl);
|
||||
process.stdout.write(`uploading... `);
|
||||
await createEmojiSafe(message.guild, buffer, entry.emoji, `Skin icon: ${entry.name}`);
|
||||
console.log(`✅`);
|
||||
added++;
|
||||
} catch (err) {
|
||||
console.log(`❌ ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
// 2s gap between uploads to stay under Discord rate limits
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
message.channel.send(
|
||||
`Done! ✅ Added **${added}** emojis. ` +
|
||||
`⏭ Skipped **${skipped}** (already existed). ` +
|
||||
`❌ **${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.');
|
||||
|
||||
await message.reply('Applying emojis to roles...');
|
||||
|
||||
// Refresh both caches
|
||||
await message.guild.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()
|
||||
);
|
||||
if (!role) {
|
||||
console.log(` ⏭ Role not found: ${entry.name}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const emoji = message.guild.emojis.cache.find(
|
||||
e => e.name.toLowerCase() === entry.emoji.toLowerCase()
|
||||
);
|
||||
if (!emoji) {
|
||||
console.log(` ⚠ Emoji not found for: ${entry.name} — run !addicons first`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await role.setIcon(emoji);
|
||||
console.log(` ✅ ${entry.name} → :${entry.emoji}:`);
|
||||
updated++;
|
||||
} catch (err) {
|
||||
console.error(` ❌ Failed "${entry.name}": ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
message.channel.send(
|
||||
`Done! ✅ Set icons on **${updated}** roles. ` +
|
||||
`⏭ Skipped **${skipped}**. ❌ **${failed}** failed.`
|
||||
);
|
||||
});
|
||||
|
||||
client.login(process.env.TOKEN);
|
||||
+2029
File diff suppressed because it is too large.
Load diff
+402
@@ -0,0 +1,402 @@
|
||||
// =============================================
|
||||
// FORTNITE SKIN VERIFY BOT
|
||||
// =============================================
|
||||
// Verifies a user's Fortnite locker via Epic Games
|
||||
// OAuth and auto-assigns Discord roles based on
|
||||
// the skins and styles they own.
|
||||
//
|
||||
// Run with: node verify.js
|
||||
// Users type: !verify in your server
|
||||
// =============================================
|
||||
|
||||
require('dotenv').config();
|
||||
const { Client, GatewayIntentBits, PermissionsBitField, EmbedBuilder } = require('discord.js');
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
GatewayIntentBits.DirectMessages,
|
||||
],
|
||||
partials: ['CHANNEL'],
|
||||
});
|
||||
|
||||
// ─── Epic Games API tokens (from RiftCheckerBot source) ───────────────────────
|
||||
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';
|
||||
|
||||
// ─── Role → skin/style mapping ────────────────────────────────────────────────
|
||||
// Each entry defines what cosmetic ID (and optionally style tag) a user must
|
||||
// own to receive the role. styleTag means they need that specific variant unlocked.
|
||||
const ROLE_REQUIREMENTS = [
|
||||
// OG Season 1 skins — style required for the OG variant
|
||||
{
|
||||
role: 'OG Renegade Raider',
|
||||
id: 'cid_028_athena_commando_f',
|
||||
styleTag: 'Mat3', // Black & Gold style = OG
|
||||
anyStyle: false,
|
||||
},
|
||||
{
|
||||
role: 'OG Aerial Assault Trooper',
|
||||
id: 'cid_017_athena_commando_m',
|
||||
styleTag: 'Stage2', // upgraded style = OG unlock
|
||||
anyStyle: false,
|
||||
},
|
||||
{
|
||||
role: 'OG Ghoul Trooper',
|
||||
id: 'cid_029_athena_commando_f_halloween',
|
||||
styleTag: 'Mat3', // PINK Ghoul = OG
|
||||
anyStyle: false,
|
||||
},
|
||||
{
|
||||
role: 'OG Skull Trooper',
|
||||
id: 'cid_030_athena_commando_m_halloween',
|
||||
styleTag: 'Mat1', // PURPLE GLOW = OG
|
||||
anyStyle: false,
|
||||
},
|
||||
|
||||
// Exclusive skins — just owning them is enough
|
||||
{ role: 'Black Knight', id: 'cid_035_athena_commando_m_medieval' },
|
||||
{ role: 'Double Helix', id: 'cid_183_athena_commando_m_modernmilitaryred' },
|
||||
{ role: 'Honor Guard', id: 'cid_342_athena_commando_m_streetracermetallic' },
|
||||
{ role: 'Wonder', id: 'cid_434_athena_commando_f_stealthhonor' },
|
||||
{ role: 'Surf Strider', id: 'cid_783_athena_commando_m_aquajacket' },
|
||||
{ role: 'Stealth Reflex', id: 'cid_386_athena_commando_m_streetopsstealth' },
|
||||
{ role: 'Dark Vertex', id: 'cid_371_athena_commando_m_speedymidnight' },
|
||||
{ role: 'Royale Bomber', id: 'cid_113_athena_commando_m_blueace' },
|
||||
{ role: 'Wildcat', id: 'cid_757_athena_commando_f_wildcat' },
|
||||
{ role: 'Clyde', id: 'character_unbrokentrash' },
|
||||
{ role: 'Eon', id: 'cid_174_athena_commando_f_carbidewhite' },
|
||||
{ role: 'Huntmaster Saber', id: 'character_masterkeyorder' },
|
||||
{ role: 'Dark Skully', id: 'cid_850_athena_commando_f_skullbritecube' },
|
||||
{ role: 'Galaxy', id: 'cid_175_athena_commando_m_celestial' },
|
||||
{ role: 'Ikonik', id: 'cid_313_athena_commando_m_kpopfashion' },
|
||||
{ role: 'Glow', id: 'cid_479_athena_commando_f_davinci' },
|
||||
{ role: 'Rogue Spider Knight', id: 'cid_516_athena_commando_m_blackwidowrogue' },
|
||||
{ role: 'Thrilldiver', id: 'character_summerunsuitable' },
|
||||
{ role: 'Freediver', id: 'character_coolsuitable' },
|
||||
{ role: 'Neo Versa', id: 'cid_441_athena_commando_f_cyberscavengerblue' },
|
||||
{ role: 'Cobalt Snowfoot', id: 'character_snowninjadark' },
|
||||
{ role: 'Florin', id: 'character_asterorder' },
|
||||
{ role: "What's Wrong, Toona Fish?", id: 'cid_a_201_athena_commando_m_teriyakifishtoon' },
|
||||
{ role: 'Cameo Needs Loot', id: 'cid_571_athena_commando_f_sheath' },
|
||||
{ role: 'Barricade', id: 'character_rankedphantom' },
|
||||
|
||||
// Fully Golden Peely = base Peely skin (golden style is a level-up variant)
|
||||
{ role: 'Fully Golden Peely', id: 'cid_349_athena_commando_m_banana' },
|
||||
];
|
||||
|
||||
// ─── STW Founders check (from common_core profile) ────────────────────────────
|
||||
// founder packs give specific items in common_core
|
||||
const STW_FOUNDER_IDS = [
|
||||
'cid_095_athena_commando_m_founder', // Standard Founder
|
||||
'cid_096_athena_commando_f_founder', // Standard Founder female
|
||||
];
|
||||
const STW_ULTIMATE_IDS = [
|
||||
'tiertoken_collectionbook_ultimate', // Ultimate founder token
|
||||
];
|
||||
|
||||
// ─── pending verifications: Map<discordUserId, { messageChannelId, guildId }> ──
|
||||
const pending = new Map();
|
||||
|
||||
// ─── Helper: get switch token access token ────────────────────────────────────
|
||||
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',
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// ─── Helper: create device code ───────────────────────────────────────────────
|
||||
async function createDeviceCode(accessToken) {
|
||||
const res = await fetch(EPIC_DEVICE_AUTH, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `bearer ${accessToken}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Helper: poll until user completes login ──────────────────────────────────
|
||||
async function waitForLogin(deviceCode, maxWaitMs = 300000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
await new Promise(r => setTimeout(r, 10000)); // poll every 10s
|
||||
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; // link expired
|
||||
}
|
||||
// authorization_pending = still waiting, keep polling
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Helper: exchange token for iOS client token ──────────────────────────────
|
||||
async function exchangeForIosToken(accessToken) {
|
||||
// Get exchange code
|
||||
const exRes = await fetch(EPIC_EXCHANGE, {
|
||||
headers: { 'Authorization': `bearer ${accessToken}` },
|
||||
});
|
||||
const exData = await exRes.json();
|
||||
|
||||
// Use exchange code with iOS token
|
||||
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();
|
||||
}
|
||||
|
||||
// ─── Helper: get athena locker (owned skins + styles) ─────────────────────────
|
||||
async function getLockerData(accountId, accessToken) {
|
||||
const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=athena&rvn=-1`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Helper: get common_core (STW founders check) ────────────────────────────
|
||||
async function getCommonCoreData(accountId, accessToken) {
|
||||
const url = `https://fortnite-public-service-prod11.ol.epicgames.com/fortnite/api/game/v2/profile/${accountId}/client/QueryProfile?profileId=common_core&rvn=-1`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Helper: build owned items map from athena profile ────────────────────────
|
||||
function buildOwnedMap(athenaData) {
|
||||
const owned = {}; // id (lowercase) -> Set of owned style tags
|
||||
const items = athenaData?.profileChanges?.[0]?.profile?.items || {};
|
||||
for (const item of Object.values(items)) {
|
||||
const tpl = item.templateId || '';
|
||||
if (!tpl.toLowerCase().startsWith('athena') && !tpl.toLowerCase().startsWith('character')) continue;
|
||||
const rawId = tpl.split(':')[1] || '';
|
||||
const id = rawId.toLowerCase();
|
||||
if (!owned[id]) owned[id] = new Set();
|
||||
const variants = item.attributes?.variants || [];
|
||||
for (const v of variants) {
|
||||
for (const tag of (v.owned || [])) {
|
||||
owned[id].add(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
// ─── Helper: check which roles the user qualifies for ────────────────────────
|
||||
function getRolesForLocker(ownedMap, commonCoreItems) {
|
||||
const earned = [];
|
||||
|
||||
for (const req of ROLE_REQUIREMENTS) {
|
||||
const id = req.id.toLowerCase();
|
||||
if (!ownedMap[id]) continue; // doesn't own this skin
|
||||
|
||||
if (req.styleTag) {
|
||||
// Must own the specific style
|
||||
if (ownedMap[id].has(req.styleTag)) {
|
||||
earned.push(req.role);
|
||||
}
|
||||
} else {
|
||||
// Just owning the skin is enough
|
||||
earned.push(req.role);
|
||||
}
|
||||
}
|
||||
|
||||
// STW Founders check via common_core
|
||||
const ccItems = commonCoreItems || {};
|
||||
const ccIds = Object.values(ccItems).map(i => (i.templateId || '').toLowerCase());
|
||||
|
||||
const hasFounder = STW_FOUNDER_IDS.some(id => ccIds.some(cc => cc.includes(id.toLowerCase())));
|
||||
const hasUltimate = STW_ULTIMATE_IDS.some(id => ccIds.some(cc => cc.includes(id.toLowerCase())));
|
||||
|
||||
// Also check for founder purchase via mtx receipts
|
||||
const hasMtxFounder = ccIds.some(cc => cc.includes('founder'));
|
||||
|
||||
if (hasUltimate) {
|
||||
earned.push('Ultimate STW Founders');
|
||||
earned.push('STW Founders'); // ultimate implies standard too
|
||||
} else if (hasFounder || hasMtxFounder) {
|
||||
earned.push('STW Founders');
|
||||
}
|
||||
|
||||
return [...new Set(earned)]; // deduplicate
|
||||
}
|
||||
|
||||
// ─── Bot event handlers ───────────────────────────────────────────────────────
|
||||
client.once('ready', () => {
|
||||
console.log(`Logged in as ${client.user.tag}`);
|
||||
console.log('Users can type !verify to verify their Fortnite account.');
|
||||
});
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
if (message.author.bot) return;
|
||||
if (message.content !== '!verify') return;
|
||||
|
||||
const userId = message.author.id;
|
||||
const guild = message.guild;
|
||||
|
||||
if (!guild) return;
|
||||
|
||||
// Prevent duplicate verifications
|
||||
if (pending.has(userId)) {
|
||||
return message.reply('You already have a verification in progress. Check your DMs for the login link.');
|
||||
}
|
||||
|
||||
await message.reply('Check your DMs for a login link to verify your Fortnite account!');
|
||||
|
||||
try {
|
||||
// Step 1: get switch token
|
||||
const switchToken = await getSwitchAccessToken();
|
||||
|
||||
// Step 2: create device code
|
||||
const deviceData = await createDeviceCode(switchToken);
|
||||
if (!deviceData.user_code || !deviceData.device_code) {
|
||||
return message.author.send('❌ Failed to generate a login link. Please try again later.');
|
||||
}
|
||||
|
||||
const loginUrl = `https://www.epicgames.com/activate?userCode=${deviceData.user_code}`;
|
||||
|
||||
// Step 3: DM the user the link
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x5865F2)
|
||||
.setTitle('Fortnite Account Verification')
|
||||
.setDescription(
|
||||
`Click the link below to log into your Epic Games account.\n\n` +
|
||||
`**[Click here to verify →](${loginUrl})**\n\n` +
|
||||
`Your code: \`${deviceData.user_code}\`\n\n` +
|
||||
`This link expires in **5 minutes**. Once you log in, the bot will automatically check your locker and assign your roles.`
|
||||
)
|
||||
.setFooter({ text: 'Your login is read-only. The bot only checks your cosmetics.' });
|
||||
|
||||
await message.author.send({ embeds: [embed] });
|
||||
|
||||
pending.set(userId, { guildId: guild.id, channelId: message.channel.id });
|
||||
|
||||
// Step 4: poll for completion
|
||||
const tokenData = await waitForLogin(deviceData.device_code);
|
||||
pending.delete(userId);
|
||||
|
||||
if (!tokenData) {
|
||||
return message.author.send('❌ Login link expired or was not completed. Run `!verify` again to try again.');
|
||||
}
|
||||
|
||||
// Step 5: exchange for iOS token (gives us full API access)
|
||||
const iosData = await exchangeForIosToken(tokenData.access_token);
|
||||
if (!iosData.access_token) {
|
||||
return message.author.send('❌ Authentication failed. Please try again.');
|
||||
}
|
||||
|
||||
const accountId = iosData.account_id;
|
||||
const displayName = iosData.displayName || 'Unknown';
|
||||
const accessToken = iosData.access_token;
|
||||
|
||||
await message.author.send(`✅ Logged in as **${displayName}**! Checking your locker...`);
|
||||
|
||||
// Step 6: fetch locker data
|
||||
const [athenaData, commonCoreData] = await Promise.all([
|
||||
getLockerData(accountId, accessToken),
|
||||
getCommonCoreData(accountId, accessToken),
|
||||
]);
|
||||
|
||||
if (!athenaData?.profileChanges) {
|
||||
return message.author.send('❌ Failed to read your locker. Please try again.');
|
||||
}
|
||||
|
||||
// Step 7: figure out which roles they earn
|
||||
const ownedMap = buildOwnedMap(athenaData);
|
||||
const ccItems = commonCoreData?.profileChanges?.[0]?.profile?.items || {};
|
||||
const earnedRoleNames = getRolesForLocker(ownedMap, ccItems);
|
||||
|
||||
// Step 8: assign roles in the guild
|
||||
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 in the server.');
|
||||
}
|
||||
|
||||
const assigned = [];
|
||||
const notFound = [];
|
||||
|
||||
for (const roleName of earnedRoleNames) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Step 9: send result
|
||||
if (assigned.length === 0) {
|
||||
await message.author.send(
|
||||
`✅ Verification complete for **${displayName}**!\n\n` +
|
||||
`No matching exclusive roles were found for your account. ` +
|
||||
`If you think this is wrong, contact a server admin.`
|
||||
);
|
||||
} else {
|
||||
await message.author.send(
|
||||
`✅ Verification complete for **${displayName}**!\n\n` +
|
||||
`**Roles assigned (${assigned.length}):**\n` +
|
||||
assigned.map(r => `• ${r}`).join('\n') +
|
||||
(notFound.length ? `\n\n⚠️ These roles were earned but not found in the server: ${notFound.join(', ')}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
// Also send a confirmation in the original channel
|
||||
const ch = guild.channels.cache.get(message.channel.id);
|
||||
if (ch) {
|
||||
ch.send(`✅ <@${userId}> has been verified and received **${assigned.length}** role(s).`).catch(() => null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
pending.delete(userId);
|
||||
console.error('Verify error:', err);
|
||||
message.author.send('❌ An unexpected error occurred during verification. Please try again.').catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(process.env.TOKEN);
|
||||
Reference in new issue
Block a user