tg-bot-ts/src/systems/updates.ts

285 lines
No EOL
9.4 KiB
TypeScript

/**
* 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<VersionsIndex>(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<string, VoteEntry>();
const no = new Map<string, VoteEntry>();
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<UpdateEntry>(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<ExamplePollState>(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<void> {
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<void> {
const entry = Updates.get({ version });
if (!entry) {
await interaction.editReply("❌ Version not found.");
return;
}
const embeds = Updates.buildEmbeds(entry);
await interaction.editReply({ embeds });
},
};