diff --git a/data/updates/.message-ids.json b/data/updates/.message-ids.json new file mode 100644 index 0000000..4fed52b --- /dev/null +++ b/data/updates/.message-ids.json @@ -0,0 +1,3 @@ +{ + "v0.1": "1515023605946257429" +} \ No newline at end of file diff --git a/src/systems/updates.ts b/src/systems/updates.ts index c05a125..bc3688f 100644 --- a/src/systems/updates.ts +++ b/src/systems/updates.ts @@ -1,5 +1,5 @@ /** - * Updates — manages bot changelog/update posts. + * Updates — manages bot changelog/update posts to #updates channel. * * Usage: * import { Updates } from "@systems/updates"; @@ -11,9 +11,9 @@ * Updates.preview({ version: "v0.8", interaction }) */ + import fs from "fs"; import path from "path"; - import { Client, EmbedBuilder, TextChannel, MessageFlags } from "discord.js"; - import { ChatInputCommandInteraction } from "discord.js"; + import { Client, EmbedBuilder, TextChannel, MessageFlags, ChatInputCommandInteraction } from "discord.js"; import { Store } from "@systems/store"; import { Paths } from "@paths"; import { Emoji } from "@systems/emojis"; @@ -21,7 +21,7 @@ import { Logger } from "@systems/logger"; import { PollUI } from "@ui/poll"; import { Nation, VoteEntry, PollState } from "@types"; - import { WRank, WRankEntry } from "@systems/wrank"; + import { WRank } from "@systems/wrank"; import { Leaves } from "@systems/leaves"; const log = Logger.for("updates"); @@ -30,7 +30,7 @@ interface UpdateItem { text: string; - emojiKey: string | null; + emojiKey?: string | null; } interface UpdateSection { @@ -48,13 +48,12 @@ } export interface UpdateEntry { - version: string; - date: string; - title: string; - layout: string; - messageId: string | null; - sections: UpdateSection[]; - examples: UpdateExample[]; + version: string; + date: string; + title: string; + layout: string; + sections: UpdateSection[]; + examples: UpdateExample[]; } interface VersionsIndex { @@ -63,12 +62,12 @@ } interface ExamplePollState { - slot: number; - locked: boolean; + slot: number; + locked: boolean; confirmed: "yes" | "no" | null; - yes: VoteEntry[]; - no: VoteEntry[]; - wrank?: { + yes: VoteEntry[]; + no: VoteEntry[]; + wrank?: { characterName: string; userKey: string; nation: Nation; @@ -83,9 +82,7 @@ }[]; } - let _versionsCache: string[] | null = null; - - // ─── Helpers ────────────────────────────────────────────────────────────────── + // ─── Paths ──────────────────────────────────────────────────────────────────── function updatesDir(): string { return Paths.data("updates"); @@ -95,67 +92,90 @@ return path.join(updatesDir(), version); } + function messageIdsPath(): string { + return path.join(updatesDir(), ".message-ids.json"); + } + + // ─── Message ID helpers ─────────────────────────────────────────────────────── + + function getMessageId(version: string): string | null { + const ids = Store.readOrDefault>(messageIdsPath(), {}); + return ids[version] ?? null; + } + + function saveMessageId(version: string, messageId: string): void { + const ids = Store.readOrDefault>(messageIdsPath(), {}); + ids[version] = messageId; + Store.write(messageIdsPath(), ids); + } + + // ─── 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(); - // Build description from sections 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) || "•") : "•"; - lines.push(`${emojiStr} ${item.text}`); - } + 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 { - // Build yes/no Maps 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) { + 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, - }; + return { slot: exampleData.slot, locked: exampleData.locked, confirmed: exampleData.confirmed, yes, no }; } function injectMockWrank(exampleData: ExamplePollState): (() => void) | null { if (!exampleData.wrank?.length) return null; - // We inject mock wrank entries temporarily into WRank's current week - // and return a cleanup function to restore the original state const week = WRank.currentWeek(); const original = JSON.parse(JSON.stringify(week.entries)); - // Temporarily replace entries with mock data 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, + userKey: e.userKey, characterName: e.characterName, class: "WI" as any, nation, @@ -166,7 +186,6 @@ }); } - // Return cleanup function return () => { week.entries[Nation.Capella] = original[Nation.Capella] ?? []; week.entries[Nation.Procyon] = original[Nation.Procyon] ?? []; @@ -175,20 +194,11 @@ function injectMockLeaves(exampleData: ExamplePollState): (() => void) | null { if (!exampleData.leaves?.length) return null; - - // Mark leaves temporarily for (const l of exampleData.leaves) { - Leaves.mark({ - characterName: l.characterName, - ownerKey: "example", - historyKey: l.historyKey as any, - markedBy: "example", - }); + Leaves.mark({ characterName: l.characterName, ownerKey: "example", historyKey: l.historyKey as any, markedBy: "example" }); } - return () => { - if (!exampleData.leaves) return; - for (const l of exampleData.leaves) { + for (const l of exampleData.leaves!) { Leaves.unmark({ characterName: l.characterName, historyKey: l.historyKey as any }); } }; @@ -197,53 +207,36 @@ // ─── Updates namespace ──────────────────────────────────────────────────────── export const Updates = { - list(): string[] { - if (!_versionsCache) { - const index = Store.read(path.join(updatesDir(), "versions.json")); - _versionsCache = index?.versions ?? []; - } - return _versionsCache; - }, + list(): string[] { + loadVersionsIndex(); + return _versionsCache ?? []; + }, latest(): string | null { - const index = Store.read(path.join(updatesDir(), "versions.json")); - return index?.latest ?? null; + loadVersionsIndex(); + return _latestCache; }, get({ version }: { version: string }): UpdateEntry | null { return Store.read(path.join(versionDir(version), "update.json")); }, - setMessageId({ version, messageId }: { version: string; messageId: string }): void { - const entry = Updates.get({ version }); - if (!entry) return; - entry.messageId = messageId; - Store.write(path.join(versionDir(version), "update.json"), entry); - }, - buildEmbeds(entry: UpdateEntry): EmbedBuilder[] { const embeds: EmbedBuilder[] = [buildUpdateEmbed(entry)]; - // Build example embeds for (const example of entry.examples) { const examplePath = path.join(versionDir(entry.version), example.file); const exampleData = Store.read(examplePath); if (!exampleData) continue; - // Inject mock data const cleanupWrank = injectMockWrank(exampleData); const cleanupLeaves = injectMockLeaves(exampleData); try { - // Set the layout PollUI.setLayout(example.layout); - - // Build the poll state - const state = buildExamplePollState(exampleData); - - // Build embed using the real poll UI - const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: example.caption }); - // exampleEmbed.setTitle(""); // no title for examples + const state = buildExamplePollState(exampleData); + const exampleEmbed = PollUI.buildEmbed(state, { overrideLockMsg: `🪲 ${example.caption}` }); + exampleEmbed.setTitle(`📋 Example — ${example.caption}`); embeds.push(exampleEmbed); } finally { cleanupWrank?.(); @@ -257,36 +250,31 @@ async post({ version, client }: { version: string; client: Client }): Promise { const entry = Updates.get({ version }); - if (!entry) { - log.error(`Version ${version} not found`); - return; - } + 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; - } + if (!channelId) { log.error("updates channel not configured"); return; } - const channel = await client.channels.fetch(channelId) as TextChannel; - const embeds = Updates.buildEmbeds(entry); + const channel = await client.channels.fetch(channelId) as TextChannel; + const embeds = Updates.buildEmbeds(entry); + const messageId = getMessageId(version); - if (entry.messageId) { - // Edit existing message + log.debug(`post: version=${version} messageId=${messageId} idsPath=${messageIdsPath()}`); + + if (messageId) { try { - const msg = await channel.messages.fetch(entry.messageId); + const msg = await channel.messages.fetch(messageId); await msg.edit({ embeds }); - log.info(`Edited update ${version} (${entry.messageId})`); + log.info(`Edited ${version} (${messageId})`); return; } catch { - log.warn(`Could not edit message ${entry.messageId}, posting new`); + log.warn(`Could not edit ${messageId}, posting new`); } } - // Post new message const msg = await channel.send({ embeds }); - Updates.setMessageId({ version, messageId: msg.id }); - log.info(`Posted update ${version} (${msg.id})`); + saveMessageId(version, msg.id); + log.info(`Posted ${version} (${msg.id})`); }, async preview({ version, interaction }: { @@ -295,11 +283,10 @@ }): Promise { const entry = Updates.get({ version }); if (!entry) { - await interaction.reply({ content: `❌ Version \`${version}\` not found.`, flags: MessageFlags.Ephemeral }); + await interaction.editReply("❌ Version not found."); return; } - const embeds = Updates.buildEmbeds(entry); - await interaction.reply({ embeds, flags: MessageFlags.Ephemeral }); + await interaction.editReply({ embeds }); }, }; \ No newline at end of file