tg-bot-ts/scripts/upload-emojis.ts
Nuno Duque Nunes 9e8877483d feat: Leaderboard & Result systems with aligned columns, call/confirm-no commands, persistent message slots
- TextAlign: column alignment for embeds using real gg sans font metrics
- EmbedHelpers: per-player grid/column layouts immune to 1024-char field limit
- Layout: domain-aware formatting wrapper (wrank, bringer, cockroach, tgCount)
- PersistentMessage: multi-slot support for independently-updatable embeds
- Leaderboard: weekly rankings + highlights embed (most kills/deaths, next Bringer)
- Result: per-TG breakdown with wRankAtSubmission snapshot for historical accuracy
- /tg call, /tg poll confirm-no, /tg-admin score-inject, result/leaderboard post commands
- Fix: CharacterRegistry wasn't hydrating ownerKey, breaking K/D bot-wide
- Fix: Leaderboard.buildEntries used current week instead of passed-in week param
- /tg-admin test-align: permanent calibration tool for embed text alignment

Includes data/emojis/anima-mastery.json for new combat stat icons.
2026-06-20 03:04:52 +01:00

273 lines
No EOL
11 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";
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();
}
}
Config.load();
const TOKEN = process.env.DISCORD_TOKEN!;
const DONOR_GUILD_IDS = Config.get({ section: "emoji", key: "donorGuilds" });
if (!TOKEN || DONOR_GUILD_IDS.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}`,
"anima-mastery_stats": (f) => `anima_${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);
}