gistfile1.txt
· 10 KiB · Text
Originalformat
/**
* 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>
* 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();
}
}
const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS: string[] = Config.get({ section: "emoji", key: "donorGuilds" });
Config.load();
const donorGuilds = Config.get({ section: "emoji", key: "donorGuilds" });
if (!TOKEN || donorGuilds.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, (filename: string) => 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}`,
};
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<string, string>; // emojiName → emojiId
capacity: number;
}
function maxEmojisForTier(tier: number): number {
return [50, 100, 150, 250][tier] ?? 50;
}
async function fetchGuildSlots(): Promise<GuildSlot[]> {
const slots: GuildSlot[] = [];
for (const guildId of DONOR_GUILD_IDS) {
try {
const [guild, emojis] = await Promise.all([
rest.get(Routes.guild(guildId)) as Promise<any>,
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
]);
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<void> {
const files = scanDir(emojiDir);
if (files.length === 0) {
console.error(`❌ No image files found in ${emojiDir}`);
process.exit(1);
}
let emojiMap: Record<string, string> = {};
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<string, string>();
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<void> {
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<string, string> = {};
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 <pattern> [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);
}
| 1 | /** |
| 2 | * Bulk emoji upload script with subdirectory support and round-robin distribution. |
| 3 | * |
| 4 | * Usage: |
| 5 | * Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] |
| 6 | * Delete: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts --delete <pattern> |
| 7 | * Pattern can be a prefix (e.g. "wrank_up") or exact name (e.g. "wrank_up_1") |
| 8 | * Multiple patterns: --delete wrank_up wrank_down wrank_gold |
| 9 | * |
| 10 | * Directory naming conventions: |
| 11 | * - Files in root dir → name = filename without extension |
| 12 | * - Files in subdir → name determined by DIR_NAME_MAP or default (dirname_filename) |
| 13 | * - Passthrough dirs → name = filename only (no prefix) |
| 14 | * |
| 15 | * Required .env vars: |
| 16 | * DISCORD_TOKEN — bot token |
| 17 | * EMOJI_DONOR_GUILDS — comma-separated donor server IDs |
| 18 | */ |
| 19 | |
| 20 | import { REST, Routes } from "discord.js"; |
| 21 | import fs from "fs"; |
| 22 | import path from "path"; |
| 23 | import { Config } from "@systems/config"; |
| 24 | |
| 25 | // Load .env |
| 26 | const envPath = path.join(__dirname, "../.env"); |
| 27 | if (fs.existsSync(envPath)) { |
| 28 | for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { |
| 29 | const [key, ...rest] = line.split("="); |
| 30 | if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim(); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | const TOKEN = process.env.DISCORD_TOKEN!; |
| 35 | const DONOR_GUILD_IDS: string[] = Config.get({ section: "emoji", key: "donorGuilds" }); |
| 36 | |
| 37 | Config.load(); |
| 38 | |
| 39 | const donorGuilds = Config.get({ section: "emoji", key: "donorGuilds" }); |
| 40 | |
| 41 | if (!TOKEN || donorGuilds.length === 0) { |
| 42 | console.error("❌ DISCORD_TOKEN must be set in .env and emoji.donorGuilds must be configured in config.json"); |
| 43 | process.exit(1); |
| 44 | } |
| 45 | |
| 46 | const emojiDir = path.join(__dirname, "../emoji-uploads"); |
| 47 | const emojisPath = path.join(__dirname, "../messages/emojis.json"); |
| 48 | const rest = new REST({ version: "10" }).setToken(TOKEN); |
| 49 | |
| 50 | // ─── Naming config ───────────────────────────────────────────────────────────── |
| 51 | |
| 52 | // Dirs listed here use filename only — no dir prefix |
| 53 | const PASSTHROUGH_DIRS: string[] = ["classes", "nations", "misc"]; |
| 54 | |
| 55 | // Custom naming functions per dir — (filename without ext) → emoji name |
| 56 | const DIR_NAME_MAP: Record<string, (filename: string) => string> = { |
| 57 | "wrank": (f) => `wrank_${f}`, |
| 58 | "wrank_gold": (f) => `wrank_${f}_gold`, |
| 59 | "wrank_up": (f) => `wrank_up_${f}`, |
| 60 | "wrank_down": (f) => `wrank_down_${f}`, |
| 61 | "wrank_x": (f) => `wrank_x_${f}`, |
| 62 | }; |
| 63 | |
| 64 | function resolveEmojiName(dirName: string, filename: string): string { |
| 65 | if (PASSTHROUGH_DIRS.includes(dirName)) return filename; |
| 66 | if (DIR_NAME_MAP[dirName]) return DIR_NAME_MAP[dirName](filename); |
| 67 | return `${dirName}_${filename}`; // default: dirname_filename |
| 68 | } |
| 69 | |
| 70 | // ─── File discovery ──────────────────────────────────────────────────────────── |
| 71 | |
| 72 | interface EmojiFile { |
| 73 | emojiName: string; |
| 74 | filePath: string; |
| 75 | mimeType: string; |
| 76 | } |
| 77 | |
| 78 | const IMAGE_EXTS = [".png", ".jpg", ".gif", ".webp"]; |
| 79 | |
| 80 | function mimeFor(ext: string): string { |
| 81 | if (ext === ".gif") return "image/gif"; |
| 82 | if (ext === ".webp") return "image/webp"; |
| 83 | return "image/png"; |
| 84 | } |
| 85 | |
| 86 | function scanDir(dir: string, parentDirName?: string): EmojiFile[] { |
| 87 | const results: EmojiFile[] = []; |
| 88 | if (!fs.existsSync(dir)) return results; |
| 89 | |
| 90 | for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 91 | const fullPath = path.join(dir, entry.name); |
| 92 | if (entry.isDirectory()) { |
| 93 | results.push(...scanDir(fullPath, entry.name)); |
| 94 | } else { |
| 95 | const ext = path.extname(entry.name).toLowerCase(); |
| 96 | if (!IMAGE_EXTS.includes(ext)) continue; |
| 97 | const filename = path.basename(entry.name, ext); |
| 98 | const emojiName = parentDirName |
| 99 | ? resolveEmojiName(parentDirName, filename) |
| 100 | : filename; |
| 101 | results.push({ emojiName, filePath: fullPath, mimeType: mimeFor(ext) }); |
| 102 | } |
| 103 | } |
| 104 | return results; |
| 105 | } |
| 106 | |
| 107 | // ─── Guild helpers ───────────────────────────────────────────────────────────── |
| 108 | |
| 109 | interface GuildSlot { |
| 110 | guildId: string; |
| 111 | name: string; |
| 112 | existing: Map<string, string>; // emojiName → emojiId |
| 113 | capacity: number; |
| 114 | } |
| 115 | |
| 116 | function maxEmojisForTier(tier: number): number { |
| 117 | return [50, 100, 150, 250][tier] ?? 50; |
| 118 | } |
| 119 | |
| 120 | async function fetchGuildSlots(): Promise<GuildSlot[]> { |
| 121 | const slots: GuildSlot[] = []; |
| 122 | for (const guildId of DONOR_GUILD_IDS) { |
| 123 | try { |
| 124 | const [guild, emojis] = await Promise.all([ |
| 125 | rest.get(Routes.guild(guildId)) as Promise<any>, |
| 126 | rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>, |
| 127 | ]); |
| 128 | const max = maxEmojisForTier(guild.premium_tier ?? 0); |
| 129 | const existing = new Map(emojis.map((e: any) => [e.name, e.id])); |
| 130 | const capacity = max - emojis.length; |
| 131 | console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`); |
| 132 | slots.push({ guildId, name: guild.name, existing, capacity }); |
| 133 | } catch (err: any) { |
| 134 | console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); |
| 135 | } |
| 136 | } |
| 137 | return slots; |
| 138 | } |
| 139 | |
| 140 | // ─── Upload ──────────────────────────────────────────────────────────────────── |
| 141 | |
| 142 | async function upload(): Promise<void> { |
| 143 | const files = scanDir(emojiDir); |
| 144 | if (files.length === 0) { |
| 145 | console.error(`❌ No image files found in ${emojiDir}`); |
| 146 | process.exit(1); |
| 147 | } |
| 148 | |
| 149 | let emojiMap: Record<string, string> = {}; |
| 150 | try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} |
| 151 | |
| 152 | console.log(`\n📁 Found ${files.length} file(s)\n🔍 Scanning donor servers...\n`); |
| 153 | const slots = await fetchGuildSlots(); |
| 154 | if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } |
| 155 | |
| 156 | // Build global dedup map |
| 157 | const globalExisting = new Map<string, string>(); |
| 158 | for (const slot of slots) { |
| 159 | for (const [name, id] of slot.existing) { |
| 160 | globalExisting.set(name, `<:${name}:${id}>`); |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0); |
| 165 | console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`); |
| 166 | |
| 167 | let slotIndex = 0; |
| 168 | function nextSlot(): GuildSlot | null { |
| 169 | const start = slotIndex; |
| 170 | do { |
| 171 | const s = slots[slotIndex % slots.length]; |
| 172 | slotIndex++; |
| 173 | if (s.capacity > 0) return s; |
| 174 | } while (slotIndex % slots.length !== start % slots.length); |
| 175 | return slots.find((s) => s.capacity > 0) ?? null; |
| 176 | } |
| 177 | |
| 178 | let uploaded = 0, skipped = 0, failed = 0; |
| 179 | |
| 180 | for (const file of files) { |
| 181 | if (globalExisting.has(file.emojiName)) { |
| 182 | emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!; |
| 183 | console.log(`⏭️ Exists: ${file.emojiName} → ${emojiMap[file.emojiName]}`); |
| 184 | skipped++; |
| 185 | continue; |
| 186 | } |
| 187 | |
| 188 | const slot = nextSlot(); |
| 189 | if (!slot) { |
| 190 | console.error(`❌ No slots available for: ${file.emojiName}`); |
| 191 | failed++; |
| 192 | continue; |
| 193 | } |
| 194 | |
| 195 | try { |
| 196 | const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`; |
| 197 | const result = await rest.post(Routes.guildEmojis(slot.guildId), { |
| 198 | body: { name: file.emojiName, image: base64 }, |
| 199 | }) as any; |
| 200 | |
| 201 | const formatted = `<:${file.emojiName}:${result.id}>`; |
| 202 | emojiMap[file.emojiName] = formatted; |
| 203 | slot.capacity--; |
| 204 | console.log(`✅ Uploaded: ${file.emojiName} → ${formatted} [${slot.name}]`); |
| 205 | uploaded++; |
| 206 | |
| 207 | await new Promise((r) => setTimeout(r, 600)); |
| 208 | } catch (err: any) { |
| 209 | console.error(`❌ Failed: ${file.emojiName} — ${err.message}`); |
| 210 | failed++; |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); |
| 215 | console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); |
| 216 | console.log(`💾 messages/emojis.json updated`); |
| 217 | } |
| 218 | |
| 219 | // ─── Delete ──────────────────────────────────────────────────────────────────── |
| 220 | |
| 221 | async function deleteEmojis(patterns: string[]): Promise<void> { |
| 222 | console.log(`\n🗑️ Deleting emojis matching: ${patterns.join(", ")}`); |
| 223 | console.log(`🔍 Scanning donor servers...\n`); |
| 224 | |
| 225 | const slots = await fetchGuildSlots(); |
| 226 | if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } |
| 227 | |
| 228 | let emojiMap: Record<string, string> = {}; |
| 229 | try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} |
| 230 | |
| 231 | let deleted = 0, failed = 0; |
| 232 | |
| 233 | for (const slot of slots) { |
| 234 | for (const [name, id] of slot.existing) { |
| 235 | const matches = patterns.some((p) => name === p || name.startsWith(`${p}_`) || name.startsWith(p)); |
| 236 | if (!matches) continue; |
| 237 | |
| 238 | try { |
| 239 | await rest.delete(Routes.guildEmoji(slot.guildId, id)); |
| 240 | console.log(`🗑️ Deleted: ${name} [${slot.name}]`); |
| 241 | slot.existing.delete(name); |
| 242 | delete emojiMap[name]; |
| 243 | deleted++; |
| 244 | await new Promise((r) => setTimeout(r, 300)); |
| 245 | } catch (err: any) { |
| 246 | console.error(`❌ Failed to delete ${name}: ${err.message}`); |
| 247 | failed++; |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); |
| 253 | console.log(`\n📊 ${deleted} deleted · ${failed} failed`); |
| 254 | console.log(`💾 messages/emojis.json updated`); |
| 255 | } |
| 256 | |
| 257 | // ─── Entry point ─────────────────────────────────────────────────────────────── |
| 258 | |
| 259 | const args = process.argv.slice(2); |
| 260 | |
| 261 | if (args[0] === "--delete") { |
| 262 | const patterns = args.slice(1); |
| 263 | if (patterns.length === 0) { |
| 264 | console.error("❌ Specify at least one pattern: --delete <pattern> [pattern2] ..."); |
| 265 | console.error(" Examples:"); |
| 266 | console.error(" --delete wrank_up (deletes wrank_up_1, wrank_up_2, ...)"); |
| 267 | console.error(" --delete wrank_up_1 (deletes exact match)"); |
| 268 | console.error(" --delete wrank_up wrank_down wrank_gold"); |
| 269 | process.exit(1); |
| 270 | } |
| 271 | deleteEmojis(patterns).catch(console.error); |
| 272 | } else { |
| 273 | upload().catch(console.error); |
| 274 | } |