nuno revidoval tento gist 2 weeks ago. Přejít na revizi
1 file changed, 274 insertions
gistfile1.txt(vytvořil soubor)
| @@ -0,0 +1,274 @@ | |||
| 1 | + | /** | |
| 2 | + | * Bulk emoji upload script with subdirectory support and round-robin distribution. | |
| 3 | + | * | |
| 4 | + | * Usage: | |
| 5 | + | * Upload: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] | |
| 6 | + | * Delete: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts --delete <pattern> | |
| 7 | + | * Pattern can be a prefix (e.g. "wrank_up") or exact name (e.g. "wrank_up_1") | |
| 8 | + | * Multiple patterns: --delete wrank_up wrank_down wrank_gold | |
| 9 | + | * | |
| 10 | + | * Directory naming conventions: | |
| 11 | + | * - Files in root dir → name = filename without extension | |
| 12 | + | * - Files in subdir → name determined by DIR_NAME_MAP or default (dirname_filename) | |
| 13 | + | * - Passthrough dirs → name = filename only (no prefix) | |
| 14 | + | * | |
| 15 | + | * Required .env vars: | |
| 16 | + | * DISCORD_TOKEN — bot token | |
| 17 | + | * EMOJI_DONOR_GUILDS — comma-separated donor server IDs | |
| 18 | + | */ | |
| 19 | + | ||
| 20 | + | import { REST, Routes } from "discord.js"; | |
| 21 | + | import fs from "fs"; | |
| 22 | + | import path from "path"; | |
| 23 | + | import { Config } from "@systems/config"; | |
| 24 | + | ||
| 25 | + | // Load .env | |
| 26 | + | const envPath = path.join(__dirname, "../.env"); | |
| 27 | + | if (fs.existsSync(envPath)) { | |
| 28 | + | for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { | |
| 29 | + | const [key, ...rest] = line.split("="); | |
| 30 | + | if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim(); | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | const TOKEN = process.env.DISCORD_TOKEN!; | |
| 35 | + | const DONOR_GUILD_IDS: string[] = Config.get({ section: "emoji", key: "donorGuilds" }); | |
| 36 | + | ||
| 37 | + | Config.load(); | |
| 38 | + | ||
| 39 | + | const donorGuilds = Config.get({ section: "emoji", key: "donorGuilds" }); | |
| 40 | + | ||
| 41 | + | if (!TOKEN || donorGuilds.length === 0) { | |
| 42 | + | console.error("❌ DISCORD_TOKEN must be set in .env and emoji.donorGuilds must be configured in config.json"); | |
| 43 | + | process.exit(1); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | const emojiDir = path.join(__dirname, "../emoji-uploads"); | |
| 47 | + | const emojisPath = path.join(__dirname, "../messages/emojis.json"); | |
| 48 | + | const rest = new REST({ version: "10" }).setToken(TOKEN); | |
| 49 | + | ||
| 50 | + | // ─── Naming config ───────────────────────────────────────────────────────────── | |
| 51 | + | ||
| 52 | + | // Dirs listed here use filename only — no dir prefix | |
| 53 | + | const PASSTHROUGH_DIRS: string[] = ["classes", "nations", "misc"]; | |
| 54 | + | ||
| 55 | + | // Custom naming functions per dir — (filename without ext) → emoji name | |
| 56 | + | const DIR_NAME_MAP: Record<string, (filename: string) => string> = { | |
| 57 | + | "wrank": (f) => `wrank_${f}`, | |
| 58 | + | "wrank_gold": (f) => `wrank_${f}_gold`, | |
| 59 | + | "wrank_up": (f) => `wrank_up_${f}`, | |
| 60 | + | "wrank_down": (f) => `wrank_down_${f}`, | |
| 61 | + | "wrank_x": (f) => `wrank_x_${f}`, | |
| 62 | + | }; | |
| 63 | + | ||
| 64 | + | function resolveEmojiName(dirName: string, filename: string): string { | |
| 65 | + | if (PASSTHROUGH_DIRS.includes(dirName)) return filename; | |
| 66 | + | if (DIR_NAME_MAP[dirName]) return DIR_NAME_MAP[dirName](filename); | |
| 67 | + | return `${dirName}_${filename}`; // default: dirname_filename | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // ─── File discovery ──────────────────────────────────────────────────────────── | |
| 71 | + | ||
| 72 | + | interface EmojiFile { | |
| 73 | + | emojiName: string; | |
| 74 | + | filePath: string; | |
| 75 | + | mimeType: string; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | const IMAGE_EXTS = [".png", ".jpg", ".gif", ".webp"]; | |
| 79 | + | ||
| 80 | + | function mimeFor(ext: string): string { | |
| 81 | + | if (ext === ".gif") return "image/gif"; | |
| 82 | + | if (ext === ".webp") return "image/webp"; | |
| 83 | + | return "image/png"; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | function scanDir(dir: string, parentDirName?: string): EmojiFile[] { | |
| 87 | + | const results: EmojiFile[] = []; | |
| 88 | + | if (!fs.existsSync(dir)) return results; | |
| 89 | + | ||
| 90 | + | for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 91 | + | const fullPath = path.join(dir, entry.name); | |
| 92 | + | if (entry.isDirectory()) { | |
| 93 | + | results.push(...scanDir(fullPath, entry.name)); | |
| 94 | + | } else { | |
| 95 | + | const ext = path.extname(entry.name).toLowerCase(); | |
| 96 | + | if (!IMAGE_EXTS.includes(ext)) continue; | |
| 97 | + | const filename = path.basename(entry.name, ext); | |
| 98 | + | const emojiName = parentDirName | |
| 99 | + | ? resolveEmojiName(parentDirName, filename) | |
| 100 | + | : filename; | |
| 101 | + | results.push({ emojiName, filePath: fullPath, mimeType: mimeFor(ext) }); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return results; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // ─── Guild helpers ───────────────────────────────────────────────────────────── | |
| 108 | + | ||
| 109 | + | interface GuildSlot { | |
| 110 | + | guildId: string; | |
| 111 | + | name: string; | |
| 112 | + | existing: Map<string, string>; // emojiName → emojiId | |
| 113 | + | capacity: number; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | function maxEmojisForTier(tier: number): number { | |
| 117 | + | return [50, 100, 150, 250][tier] ?? 50; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | async function fetchGuildSlots(): Promise<GuildSlot[]> { | |
| 121 | + | const slots: GuildSlot[] = []; | |
| 122 | + | for (const guildId of DONOR_GUILD_IDS) { | |
| 123 | + | try { | |
| 124 | + | const [guild, emojis] = await Promise.all([ | |
| 125 | + | rest.get(Routes.guild(guildId)) as Promise<any>, | |
| 126 | + | rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>, | |
| 127 | + | ]); | |
| 128 | + | const max = maxEmojisForTier(guild.premium_tier ?? 0); | |
| 129 | + | const existing = new Map(emojis.map((e: any) => [e.name, e.id])); | |
| 130 | + | const capacity = max - emojis.length; | |
| 131 | + | console.log(`🏠 ${guild.name} (${guildId}): ${emojis.length}/${max} emojis, ${capacity} free`); | |
| 132 | + | slots.push({ guildId, name: guild.name, existing, capacity }); | |
| 133 | + | } catch (err: any) { | |
| 134 | + | console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return slots; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // ─── Upload ──────────────────────────────────────────────────────────────────── | |
| 141 | + | ||
| 142 | + | async function upload(): Promise<void> { | |
| 143 | + | const files = scanDir(emojiDir); | |
| 144 | + | if (files.length === 0) { | |
| 145 | + | console.error(`❌ No image files found in ${emojiDir}`); | |
| 146 | + | process.exit(1); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | let emojiMap: Record<string, string> = {}; | |
| 150 | + | try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} | |
| 151 | + | ||
| 152 | + | console.log(`\n📁 Found ${files.length} file(s)\n🔍 Scanning donor servers...\n`); | |
| 153 | + | const slots = await fetchGuildSlots(); | |
| 154 | + | if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } | |
| 155 | + | ||
| 156 | + | // Build global dedup map | |
| 157 | + | const globalExisting = new Map<string, string>(); | |
| 158 | + | for (const slot of slots) { | |
| 159 | + | for (const [name, id] of slot.existing) { | |
| 160 | + | globalExisting.set(name, `<:${name}:${id}>`); | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | const totalCapacity = slots.reduce((s, g) => s + g.capacity, 0); | |
| 165 | + | console.log(`\n📊 ${globalExisting.size} existing · ${totalCapacity} slots free\n`); | |
| 166 | + | ||
| 167 | + | let slotIndex = 0; | |
| 168 | + | function nextSlot(): GuildSlot | null { | |
| 169 | + | const start = slotIndex; | |
| 170 | + | do { | |
| 171 | + | const s = slots[slotIndex % slots.length]; | |
| 172 | + | slotIndex++; | |
| 173 | + | if (s.capacity > 0) return s; | |
| 174 | + | } while (slotIndex % slots.length !== start % slots.length); | |
| 175 | + | return slots.find((s) => s.capacity > 0) ?? null; | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | let uploaded = 0, skipped = 0, failed = 0; | |
| 179 | + | ||
| 180 | + | for (const file of files) { | |
| 181 | + | if (globalExisting.has(file.emojiName)) { | |
| 182 | + | emojiMap[file.emojiName] = globalExisting.get(file.emojiName)!; | |
| 183 | + | console.log(`⏭️ Exists: ${file.emojiName} → ${emojiMap[file.emojiName]}`); | |
| 184 | + | skipped++; | |
| 185 | + | continue; | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | const slot = nextSlot(); | |
| 189 | + | if (!slot) { | |
| 190 | + | console.error(`❌ No slots available for: ${file.emojiName}`); | |
| 191 | + | failed++; | |
| 192 | + | continue; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | try { | |
| 196 | + | const base64 = `data:${file.mimeType};base64,${fs.readFileSync(file.filePath).toString("base64")}`; | |
| 197 | + | const result = await rest.post(Routes.guildEmojis(slot.guildId), { | |
| 198 | + | body: { name: file.emojiName, image: base64 }, | |
| 199 | + | }) as any; | |
| 200 | + | ||
| 201 | + | const formatted = `<:${file.emojiName}:${result.id}>`; | |
| 202 | + | emojiMap[file.emojiName] = formatted; | |
| 203 | + | slot.capacity--; | |
| 204 | + | console.log(`✅ Uploaded: ${file.emojiName} → ${formatted} [${slot.name}]`); | |
| 205 | + | uploaded++; | |
| 206 | + | ||
| 207 | + | await new Promise((r) => setTimeout(r, 600)); | |
| 208 | + | } catch (err: any) { | |
| 209 | + | console.error(`❌ Failed: ${file.emojiName} — ${err.message}`); | |
| 210 | + | failed++; | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); | |
| 215 | + | console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); | |
| 216 | + | console.log(`💾 messages/emojis.json updated`); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // ─── Delete ──────────────────────────────────────────────────────────────────── | |
| 220 | + | ||
| 221 | + | async function deleteEmojis(patterns: string[]): Promise<void> { | |
| 222 | + | console.log(`\n🗑️ Deleting emojis matching: ${patterns.join(", ")}`); | |
| 223 | + | console.log(`🔍 Scanning donor servers...\n`); | |
| 224 | + | ||
| 225 | + | const slots = await fetchGuildSlots(); | |
| 226 | + | if (slots.length === 0) { console.error("❌ No accessible donor servers."); process.exit(1); } | |
| 227 | + | ||
| 228 | + | let emojiMap: Record<string, string> = {}; | |
| 229 | + | try { emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); } catch {} | |
| 230 | + | ||
| 231 | + | let deleted = 0, failed = 0; | |
| 232 | + | ||
| 233 | + | for (const slot of slots) { | |
| 234 | + | for (const [name, id] of slot.existing) { | |
| 235 | + | const matches = patterns.some((p) => name === p || name.startsWith(`${p}_`) || name.startsWith(p)); | |
| 236 | + | if (!matches) continue; | |
| 237 | + | ||
| 238 | + | try { | |
| 239 | + | await rest.delete(Routes.guildEmoji(slot.guildId, id)); | |
| 240 | + | console.log(`🗑️ Deleted: ${name} [${slot.name}]`); | |
| 241 | + | slot.existing.delete(name); | |
| 242 | + | delete emojiMap[name]; | |
| 243 | + | deleted++; | |
| 244 | + | await new Promise((r) => setTimeout(r, 300)); | |
| 245 | + | } catch (err: any) { | |
| 246 | + | console.error(`❌ Failed to delete ${name}: ${err.message}`); | |
| 247 | + | failed++; | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); | |
| 253 | + | console.log(`\n📊 ${deleted} deleted · ${failed} failed`); | |
| 254 | + | console.log(`💾 messages/emojis.json updated`); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // ─── Entry point ─────────────────────────────────────────────────────────────── | |
| 258 | + | ||
| 259 | + | const args = process.argv.slice(2); | |
| 260 | + | ||
| 261 | + | if (args[0] === "--delete") { | |
| 262 | + | const patterns = args.slice(1); | |
| 263 | + | if (patterns.length === 0) { | |
| 264 | + | console.error("❌ Specify at least one pattern: --delete <pattern> [pattern2] ..."); | |
| 265 | + | console.error(" Examples:"); | |
| 266 | + | console.error(" --delete wrank_up (deletes wrank_up_1, wrank_up_2, ...)"); | |
| 267 | + | console.error(" --delete wrank_up_1 (deletes exact match)"); | |
| 268 | + | console.error(" --delete wrank_up wrank_down wrank_gold"); | |
| 269 | + | process.exit(1); | |
| 270 | + | } | |
| 271 | + | deleteEmojis(patterns).catch(console.error); | |
| 272 | + | } else { | |
| 273 | + | upload().catch(console.error); | |
| 274 | + | } | |
Novější
Starší