tg-bot-ts/scripts/upload-emojis.ts

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);