211 lines
No EOL
7.3 KiB
TypeScript
211 lines
No EOL
7.3 KiB
TypeScript
/**
|
|
* Bulk emoji upload script
|
|
* Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
|
|
*
|
|
* Distributes emojis across a pool of donor servers (round-robin by available capacity).
|
|
* Each emoji is unique across all servers — no duplicates.
|
|
* Automatically updates messages/emojis.json with the uploaded emoji IDs.
|
|
*
|
|
* Required .env vars:
|
|
* DISCORD_TOKEN — bot token
|
|
* EMOJI_DONOR_GUILDS — comma-separated donor server IDs
|
|
* e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333
|
|
*/
|
|
|
|
import { REST, Routes } from "discord.js";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
// Load .env manually since we're outside the bot runtime
|
|
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[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
|
|
.split(",")
|
|
.map((id) => id.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!TOKEN) {
|
|
console.error("❌ DISCORD_TOKEN must be set in .env");
|
|
process.exit(1);
|
|
}
|
|
if (DONOR_GUILD_IDS.length === 0) {
|
|
console.error("❌ EMOJI_DONOR_GUILDS must be set in .env (comma-separated guild IDs)");
|
|
process.exit(1);
|
|
}
|
|
|
|
const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads");
|
|
const emojisPath = path.join(__dirname, "../messages/emojis.json");
|
|
|
|
if (!fs.existsSync(emojiDir)) {
|
|
console.error(`❌ Emoji directory not found: ${emojiDir}`);
|
|
console.error(` Create it and place your emoji PNG files inside.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
|
|
|
interface GuildEmojiSlot {
|
|
guildId: string;
|
|
name: string; // guild name for display
|
|
existing: Map<string, string>; // emojiName → emojiId
|
|
capacity: number;
|
|
}
|
|
|
|
// Compute max emojis based on Nitro boost tier
|
|
function maxEmojisForTier(premiumTier: number): number {
|
|
switch (premiumTier) {
|
|
case 1: return 100;
|
|
case 2: return 150;
|
|
case 3: return 250;
|
|
default: return 50;
|
|
}
|
|
}
|
|
|
|
async function fetchGuildSlots(guildIds: string[]): Promise<GuildEmojiSlot[]> {
|
|
const slots: GuildEmojiSlot[] = [];
|
|
|
|
for (const guildId of guildIds) {
|
|
try {
|
|
const [guild, emojis] = await Promise.all([
|
|
rest.get(Routes.guild(guildId)) as Promise<any>,
|
|
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
|
|
]);
|
|
|
|
const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0);
|
|
const existingMap = new Map(emojis.map((e: any) => [e.name, e.id]));
|
|
const capacity = maxEmojis - emojis.length;
|
|
const guildName = guild.name ?? guildId;
|
|
|
|
console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`);
|
|
slots.push({ guildId, name: guildName, existing: existingMap, capacity });
|
|
} catch (err: any) {
|
|
console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return slots;
|
|
}
|
|
|
|
async function uploadEmojis(): Promise<void> {
|
|
const files = fs.readdirSync(emojiDir).filter((f) =>
|
|
[".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase())
|
|
);
|
|
|
|
if (files.length === 0) {
|
|
console.error("❌ No image files found in the emoji directory.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Load existing emojis.json
|
|
let emojiMap: Record<string, string> = {};
|
|
try {
|
|
emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8"));
|
|
} catch {
|
|
console.warn("⚠️ Could not load emojis.json — will create fresh mapping.");
|
|
}
|
|
|
|
console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`);
|
|
console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`);
|
|
|
|
const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS);
|
|
|
|
if (guildSlots.length === 0) {
|
|
console.error("❌ No accessible donor guilds found. Check EMOJI_DONOR_GUILDS and bot membership.");
|
|
process.exit(1);
|
|
}
|
|
|
|
// Build global deduplication map across all donor guilds
|
|
const globalExisting = new Map<string, string>(); // emojiName → formatted string
|
|
for (const slot of guildSlots) {
|
|
for (const [name, id] of slot.existing) {
|
|
globalExisting.set(name, `<:${name}:${id}>`);
|
|
}
|
|
}
|
|
|
|
const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0);
|
|
console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`);
|
|
|
|
if (totalCapacity === 0) {
|
|
console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS.");
|
|
process.exit(1);
|
|
}
|
|
|
|
let uploaded = 0;
|
|
let skipped = 0;
|
|
let failed = 0;
|
|
|
|
// Round-robin slot picker — distributes load evenly across guilds
|
|
let slotIndex = 0;
|
|
function nextAvailableSlot(): GuildEmojiSlot | null {
|
|
const start = slotIndex;
|
|
do {
|
|
const slot = guildSlots[slotIndex % guildSlots.length];
|
|
slotIndex++;
|
|
if (slot.capacity > 0) return slot;
|
|
} while (slotIndex % guildSlots.length !== start % guildSlots.length);
|
|
// Fallback: find any with capacity (in case loop exited without finding one)
|
|
return guildSlots.find((s) => s.capacity > 0) ?? null;
|
|
}
|
|
|
|
for (const file of files) {
|
|
const emojiName = path.basename(file, path.extname(file));
|
|
const filePath = path.join(emojiDir, file);
|
|
const ext = path.extname(file).toLowerCase();
|
|
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
|
|
|
|
// Already exists in the pool — update map and skip
|
|
if (globalExisting.has(emojiName)) {
|
|
emojiMap[emojiName] = globalExisting.get(emojiName)!;
|
|
console.log(`⏭️ Already exists: ${emojiName} → ${emojiMap[emojiName]}`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const slot = nextAvailableSlot();
|
|
if (!slot) {
|
|
console.error(`❌ All slots full — could not upload: ${emojiName}`);
|
|
console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`);
|
|
failed++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
|
const result = await rest.post(Routes.guildEmojis(slot.guildId), {
|
|
body: { name: emojiName, image: base64 },
|
|
}) as any;
|
|
|
|
const formatted = `<:${emojiName}:${result.id}>`;
|
|
emojiMap[emojiName] = formatted;
|
|
slot.capacity--;
|
|
|
|
console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`);
|
|
uploaded++;
|
|
|
|
// Rate limit buffer
|
|
await new Promise((r) => setTimeout(r, 600));
|
|
} catch (err: any) {
|
|
console.error(`❌ Failed: ${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`);
|
|
console.log(`\nSlot usage after upload:`);
|
|
for (const slot of guildSlots) {
|
|
const used = slot.existing.size + (uploaded > 0 ? slot.existing.size : 0);
|
|
console.log(` ${slot.name}: ${slot.capacity} slots remaining`);
|
|
}
|
|
}
|
|
|
|
uploadEmojis().catch(console.error); |