270 lines
No EOL
10 KiB
TypeScript
270 lines
No EOL
10 KiB
TypeScript
/**
|
||
* 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("emojiDonorGuilds");
|
||
|
||
if (!TOKEN || DONOR_GUILD_IDS.length === 0) {
|
||
console.error("❌ DISCORD_TOKEN and EMOJI_DONOR_GUILDS must be set in .env");
|
||
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);
|
||
} |