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

270 lines
No EOL
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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";
// 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[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
.split(",").map((id) => id.trim()).filter(Boolean);
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);
}