/** * Updates — manages bot changelog/update posts to #updates channel. * * Usage: * import { Updates } from "@systems/updates"; * * Updates.list() * Updates.latest() * Updates.get({ version: "v0.8" }) * Updates.post({ version: "v0.8", client }) * Updates.preview({ version: "v0.8", interaction }) */ import fs from "fs"; import path from "path"; import { Client, EmbedBuilder, TextChannel, MessageFlags, ChatInputCommandInteraction } from "discord.js"; import { Store } from "@systems/store"; import { Paths } from "@paths"; import { Emoji } from "@systems/emojis"; import { Config } from "@systems/config"; import { Logger } from "@systems/logger"; import { PollUI } from "@ui/poll"; import { Nation, VoteEntry, PollState } from "@types"; import { WRank } from "@systems/wrank"; import { Leaves } from "@systems/leaves"; import { PersistentMessage } from "@systems/persistent-message"; import { TGKey } from "@systems/tg-key"; const log = Logger.for("updates"); // ─── Types ──────────────────────────────────────────────────────────────────── interface UpdateItem { text: string; emojiKey?: string | null; } interface UpdateSection { type: "new" | "improvement" | "fix" | "technical"; label: string; emoji: string; items: UpdateItem[]; } interface UpdateExample { caption: string; type: "poll"; layout: string; file: string; } export interface UpdateEntry { version: string; date: string; title: string; layout: string; sections: UpdateSection[]; examples: UpdateExample[]; } interface VersionsIndex { latest: string; versions: string[]; } interface ExamplePollState { slot: number; locked: boolean; confirmed: "yes" | "no" | null; yes: VoteEntry[]; no: VoteEntry[]; wrank?: { characterName: string; userKey: string; nation: Nation; currentRank: number; previousRank?: number; weeklyPoints: number; tgCount: number; }[]; leaves?: { characterName: string; historyKey: TGKey; }[]; } // ─── Paths ──────────────────────────────────────────────────────────────────── function updatesDir(): string { return Paths.data("updates"); } function versionDir(version: string): string { return path.join(updatesDir(), version); } function messageIdsPath(): string { return path.join(updatesDir(), ".message-ids.json"); } // ─── Versions cache ─────────────────────────────────────────────────────────── let _versionsCache: string[] | null = null; let _latestCache: string | null = null; function loadVersionsIndex(): void { if (_versionsCache) return; const index = Store.read(path.join(updatesDir(), "versions.json")); _versionsCache = index?.versions ?? []; _latestCache = index?.latest ?? null; } // ─── Embed building ─────────────────────────────────────────────────────────── function buildUpdateEmbed(entry: UpdateEntry): EmbedBuilder { const embed = new EmbedBuilder() .setTitle(`⚔️ The Arbiter — ${entry.version} · ${entry.date}`) .setColor(0xe8a317) .setTimestamp(); const lines: string[] = []; for (const section of entry.sections) { lines.push(`${section.emoji} **${section.label}**`); for (const item of section.items) { const emojiStr = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•"; // Only resolve tokens if text contains our <:key:> pattern (no ID) const resolvedText = item.text.includes("<:") && item.text.includes(":>") ? Emoji.resolveTokens(item.text) : item.text; lines.push(`${emojiStr} ${resolvedText}`); } lines.push(""); } embed.setDescription(lines.join("\n").trim()); embed.setFooter({ text: `${entry.version} — ${entry.title}` }); return embed; } function buildExamplePollState(exampleData: ExamplePollState): PollState { const yes = new Map(); const no = new Map(); for (const entry of exampleData.yes) { yes.set(entry.userKey ?? entry.displayName ?? entry.characterName ?? "", entry); } for (const entry of (exampleData.no ?? [])) { no.set(entry.userKey ?? entry.displayName ?? "", entry); } return { slot: exampleData.slot, locked: exampleData.locked, confirmed: exampleData.confirmed, yes, no }; } function injectMockWrank(exampleData: ExamplePollState): (() => void) | null { if (!exampleData.wrank?.length) return null; const week = WRank.currentWeek(); const original = JSON.parse(JSON.stringify(week.entries)); week.entries[Nation.Capella] = []; week.entries[Nation.Procyon] = []; for (const e of exampleData.wrank) { const nation = e.nation === Nation.Capella ? Nation.Capella : Nation.Procyon; week.entries[nation].push({ userKey: e.userKey, characterName: e.characterName, class: "WI" as any, nation, weeklyPoints: e.weeklyPoints, tgCount: e.tgCount, currentRank: e.currentRank, previousRank: e.previousRank, }); } return () => { week.entries[Nation.Capella] = original[Nation.Capella] ?? []; week.entries[Nation.Procyon] = original[Nation.Procyon] ?? []; }; } function injectMockLeaves(exampleData: ExamplePollState): (() => void) | null { if (!exampleData.leaves?.length) return null; for (const l of exampleData.leaves) { Leaves.mark({ characterName: l.characterName, ownerKey: "example", historyKey: l.historyKey as any, markedBy: "example" }); } return () => { for (const l of exampleData.leaves!) { Leaves.unmark({ characterName: l.characterName, historyKey: l.historyKey as any }); } }; } // ─── Updates namespace ──────────────────────────────────────────────────────── export const Updates = { list(): string[] { loadVersionsIndex(); return _versionsCache ?? []; }, latest(): string | null { loadVersionsIndex(); return _latestCache; }, get({ version }: { version: string }): UpdateEntry | null { return Store.read(path.join(versionDir(version), "update.json")); }, buildEmbeds(entry: UpdateEntry): EmbedBuilder[] { const embeds: EmbedBuilder[] = [buildUpdateEmbed(entry)]; for (const example of entry.examples) { const examplePath = path.join(versionDir(entry.version), example.file); const exampleData = Store.read(examplePath); if (!exampleData) continue; const cleanupWrank = injectMockWrank(exampleData); const cleanupLeaves = injectMockLeaves(exampleData); try { PollUI.setLayout(example.layout); const state = buildExamplePollState(exampleData); const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: `🪲 ${example.caption}`, historyKey: exampleData.leaves?.[0]?.historyKey }); exampleEmbed.setTitle(`📋 Example — ${example.caption}`); embeds.push(exampleEmbed); } finally { cleanupWrank?.(); cleanupLeaves?.(); PollUI.setLayout(Config.get({ section: "poll", key: "layout" })); } } return embeds; }, async post({ version, client }: { version: string; client: Client }): Promise { const entry = Updates.get({ version }); if (!entry) { log.error(`Version ${version} not found`); return; } const channelId = Config.get({ section: "channels", key: "updates" }); if (!channelId) { log.error("updates channel not configured"); return; } const channel = await client.channels.fetch(channelId) as TextChannel; const embeds = Updates.buildEmbeds(entry); const messageId = PersistentMessage.get({ store: "updates", key: version }); log.debug(`post: version=${version} messageId=${messageId} idsPath=${messageIdsPath()}`); if (messageId) { try { const msg = await channel.messages.fetch(messageId); await msg.edit({ embeds }); log.info(`Edited ${version} (${messageId})`); return; } catch { log.warn(`Could not edit ${messageId}, posting new`); PersistentMessage.delete({ store: "updates", key: version }); } } const msg = await channel.send({ embeds }); PersistentMessage.set({ store: "updates", key: version, messageId: msg.id }); log.info(`Posted ${version} (${msg.id})`); }, async preview({ version, interaction }: { version: string; interaction: ChatInputCommandInteraction; }): Promise { const entry = Updates.get({ version }); if (!entry) { await interaction.editReply("❌ Version not found."); return; } const embeds = Updates.buildEmbeds(entry); await interaction.editReply({ embeds }); }, };