/** * Bulk emoji upload script with subdirectory support and round-robin distribution. * * Usage: * Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] * Delete: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts --delete * Pattern can be a prefix (e.g. "wrank_up") or exact name (e.g. "wrank_up_1") * Multiple patterns: --delete wrank_up wrank_down wrank_gold * * Directory naming conventions: * - Files in root dir → name = filename without extension * - Files in subdir → name determined by DIR_NAME_MAP or default (dirname_filename) * - Passthrough dirs → name = filename only (no prefix) * * Required .env vars: * DISCORD_TOKEN — bot token * EMOJI_DONOR_GUILDS — comma-separated donor server IDs */ import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; import { Config } from "@systems/config"; // Load .env const envPath = path.join(__dirname, "../.env"); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { const [key, ...rest] = line.split("="); if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim(); } } Config.load(); const TOKEN = process.env.DISCORD_TOKEN!; const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" }); if (!TOKEN || DONOR_GUILD_IDS.length === 0) { console.error("❌ DISCORD_TOKEN must be set in .env and emoji.donorGuilds must be configured in config.json"); process.exit(1); } const emojiDir = path.join(__dirname, "../emoji-uploads"); const emojisPath = path.join(__dirname, "../messages/emojis.json"); const rest = new REST({ version: "10" }).setToken(TOKEN); // ─── Naming config ───────────────────────────────────────────────────────────── // Dirs listed here use filename only — no dir prefix const PASSTHROUGH_DIRS: string[] = ["classes", "nations", "misc"]; // Custom naming functions per dir — (filename without ext) → emoji name const DIR_NAME_MAP: Record string> = { "wrank": (f) => `wrank_${f}`, "wrank_gold": (f) => `wrank_${f}_gold`, "wrank_up": (f) => `wrank_up_${f}`, "wrank_down": (f) => `wrank_down_${f}`, "wrank_x": (f) => `wrank_x_${f}`, "anima-mastery_stats": (f) => `anima_${f}`, }; function resolveEmojiName(dirName: string, filename: string): string { if (PASSTHROUGH_DIRS.includes(dirName)) return filename; if (DIR_NAME_MAP[dirName]) return DIR_NAME_MAP[dirName](filename); return `${dirName}_${filename}`; // default: dirname_filename } // ─── File discovery ──────────────────────────────────────────────────────────── interface EmojiFile { emojiName: string; filePath: string; mimeType: string; } const IMAGE_EXTS = [".png", ".jpg", ".gif", ".webp"]; function mimeFor(ext: string): string { if (ext === ".gif") return "image/gif"; if (ext === ".webp") return "image/webp"; return "image/png"; } function scanDir(dir: string, parentDirName?: string): EmojiFile[] { const results: EmojiFile[] = []; if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...scanDir(fullPath, entry.name)); } else { const ext = path.extname(entry.name).toLowerCase(); if (!IMAGE_EXTS.includes(ext)) continue; const filename = path.basename(entry.name, ext); const emojiName = parentDirName ? resolveEmojiName(parentDirName, filename) : filename; results.push({ emojiName, filePath: fullPath, mimeType: mimeFor(ext) }); } } return results; } // ─── Guild helpers ───────────────────────────────────────────────────────────── interface GuildSlot { guildId: string; name: string; existing: Map; // emojiName → emojiId capacity: number; } function maxEmojisForTier(tier: number): number { return [50, 100, 150, 250][tier] ?? 50; } async function fetchGuildSlots(): Promise { const slots: GuildSlot[] = []; for (const guildId of DONOR_GUILD_IDS) { try { const [guild, emojis] = await Promise.all([ rest.get(Routes.guild(guildId)) as Promise, rest.get(Routes.guildEmojis(guildId)) as Promise, ]); const max = maxEmojisForTier(guild.premium_tier ?? 0); const existing = new Map(emojis.map((e: any) => [e.name, e.id])); const capacity = max - emojis.length; console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`); slots.push({ guildId, name: guild.name, existing, capacity }); } catch (err: any) { console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); } } return slots; } // ─── Upload ──────────────────────────────────────────────────────────────────── async function upload(): Promise { const files = scanDir(emojiDir); if (files.length === 0) { console.error(`❌ No image files found in ${emojiDir}`); process.exit(1); } let emojiMap: Record = {}; try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} console.log(`\n📁 Found ${files.length} file(s)\n🔍 Scanning donor servers...\n`); const slots = await fetchGuildSlots(); if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } // Build global dedup map const globalExisting = new Map(); for (const slot of slots) { for (const [name, id] of slot.existing) { globalExisting.set(name, `<:${name}:${id}>`); } } const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0); console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`); let slotIndex = 0; function nextSlot(): GuildSlot | null { const start = slotIndex; do { const s = slots[slotIndex % slots.length]; slotIndex++; if (s.capacity > 0) return s; } while (slotIndex % slots.length !== start % slots.length); return slots.find((s) => s.capacity > 0) ?? null; } let uploaded = 0, skipped = 0, failed = 0; for (const file of files) { if (globalExisting.has(file.emojiName)) { emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!; console.log(`⏭️ Exists: ${file.emojiName} → ${emojiMap[file.emojiName]}`); skipped++; continue; } const slot = nextSlot(); if (!slot) { console.error(`❌ No slots available for: ${file.emojiName}`); failed++; continue; } try { const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`; const result = await rest.post(Routes.guildEmojis(slot.guildId), { body: { name: file.emojiName, image: base64 }, }) as any; const formatted = `<:${file.emojiName}:${result.id}>`; emojiMap[file.emojiName] = formatted; slot.capacity--; console.log(`✅ Uploaded: ${file.emojiName} → ${formatted} [${slot.name}]`); uploaded++; await new Promise((r) => setTimeout(r, 600)); } catch (err: any) { console.error(`❌ Failed: ${file.emojiName} — ${err.message}`); failed++; } } fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); console.log(`💾 messages/emojis.json updated`); } // ─── Delete ──────────────────────────────────────────────────────────────────── async function deleteEmojis(patterns: string[]): Promise { console.log(`\n🗑️ Deleting emojis matching: ${patterns.join(", ")}`); console.log(`🔍 Scanning donor servers...\n`); const slots = await fetchGuildSlots(); if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } let emojiMap: Record = {}; try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} let deleted = 0, failed = 0; for (const slot of slots) { for (const [name, id] of slot.existing) { const matches = patterns.some((p) => name === p || name.startsWith(`${p}_`) || name.startsWith(p)); if (!matches) continue; try { await rest.delete(Routes.guildEmoji(slot.guildId, id)); console.log(`🗑️ Deleted: ${name} [${slot.name}]`); slot.existing.delete(name); delete emojiMap[name]; deleted++; await new Promise((r) => setTimeout(r, 300)); } catch (err: any) { console.error(`❌ Failed to delete ${name}: ${err.message}`); failed++; } } } fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); console.log(`\n📊 ${deleted} deleted · ${failed} failed`); console.log(`💾 messages/emojis.json updated`); } // ─── Entry point ─────────────────────────────────────────────────────────────── const args = process.argv.slice(2); if (args[0] === "--delete") { const patterns = args.slice(1); if (patterns.length === 0) { console.error("❌ Specify at least one pattern: --delete [pattern2] ..."); console.error(" Examples:"); console.error(" --delete wrank_up (deletes wrank_up_1, wrank_up_2, ...)"); console.error(" --delete wrank_up_1 (deletes exact match)"); console.error(" --delete wrank_up wrank_down wrank_gold"); process.exit(1); } deleteEmojis(patterns).catch(console.error); } else { upload().catch(console.error); }