Последняя активность 2 weeks ago

Версия d424172b9ef3aed3448ab91c2ce2dcbd587d237e

gistfile1.txt Исходник
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 }