add Announcements system for upcoming features and general announcements
This commit is contained in:
parent
049ea7b77f
commit
78504b9f39
9 changed files with 344 additions and 9 deletions
3
data/announcements/.posted.json
Normal file
3
data/announcements/.posted.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"001-leaderboards-results-live": "1518473077330284676"
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"id": "001-leaderboards-results-live",
|
||||
"title": "🏆 Leaderboards & TG Results are now live!",
|
||||
"date": "2026-06-22",
|
||||
"intro": "Two new things you'll start seeing in the server:",
|
||||
"color": "#e8a317",
|
||||
"sections": [
|
||||
{
|
||||
"label": "Weekly Leaderboard",
|
||||
"emoji": "<:score:1511906491903250525>",
|
||||
"items": [
|
||||
{ "text": "A live, always-updated ranking for the week" },
|
||||
{ "text": "Every score you submit updates it instantly — check it anytime to see where you and your nation stand" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "TG Results",
|
||||
"emoji": "<:anima_atk:1517702182710018179>",
|
||||
"items": [
|
||||
{ "text": "A clean breakdown of how everyone did after each TG — score, kills, deaths, and combat stats when recorded" },
|
||||
{ "text": "Updates live as players submit, so you don't need to wait for everyone to finish" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"channelLinks": [
|
||||
{ "label": "<:score:1511906491903250525> Weekly Leaderboard", "channelKey": "leaderboard" },
|
||||
{ "label": "<:anima_atk:1517702182710018179> TG Results", "channelKey": "results" }
|
||||
],
|
||||
"imageUrl": null
|
||||
}
|
||||
29
data/updates/v0.9.1/update.json
Normal file
29
data/updates/v0.9.1/update.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"version": "v0.9.1",
|
||||
"date": "2026-06-22",
|
||||
"title": "Leaderboard & Result Live Updates Fix",
|
||||
"layout": "default",
|
||||
"sections": [
|
||||
{
|
||||
"type": "fix",
|
||||
"label": "Fixes",
|
||||
"emoji": "🔧",
|
||||
"items": [
|
||||
{ "text": "Fixed Leaderboard and Result not updating automatically when players submitted scores" },
|
||||
{ "text": "Fixed TG Result never posting when a borrowed/shared character was used — attendance is now correctly tracked to the actual player, not just the character's owner" },
|
||||
{ "text": "Fixed ATK / DEF / Heal stats not displaying on some Result layouts" },
|
||||
{ "text": "Fixed a data inconsistency that could cause class icons to disappear from W.Rank or TG history entries" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "technical",
|
||||
"label": "Under the hood",
|
||||
"emoji": "🛠️",
|
||||
"items": [
|
||||
{ "text": "Unified score submission onto a single internal system — previously two parallel systems existed, and only one of them triggered live updates" },
|
||||
{ "text": "Consolidated duplicate internal data types that had drifted out of sync with each other" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"examples": []
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"latest": "v0.8",
|
||||
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"]
|
||||
"latest": "v0.9.1",
|
||||
"versions": ["v0.1", "v0.2", "v0.3", "v0.4", "v0.5", "v0.6", "v0.7", "v0.8", "v0.9", "v0.9.1"]
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { UpdatesCommands } from "@subcommands/admin/updates";
|
|||
import { ScoreInjectCommands } from "@subcommands/admin/score-inject";
|
||||
import { ResultCommands } from "@subcommands/admin/result-post";
|
||||
import { TestAlignCommands } from "@subcommands/admin/test-align";
|
||||
import { AnnouncementCommands } from "../subcommands/admin/announcement";
|
||||
|
||||
export function buildTgAdminCommand(): SlashCommandBuilder {
|
||||
const cmd = new SlashCommandBuilder()
|
||||
|
|
@ -166,6 +167,26 @@ export function buildTgAdminCommand(): SlashCommandBuilder {
|
|||
)
|
||||
)
|
||||
|
||||
cmd.addSubcommandGroup((g) => g
|
||||
.setName("announcement")
|
||||
.setDescription("Manage announcements")
|
||||
.addSubcommand((s) => s
|
||||
.setName("post")
|
||||
.setDescription("Post an announcement")
|
||||
.addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true))
|
||||
.addBooleanOption((o) => o.setName("force").setDescription("Repost even if already posted"))
|
||||
)
|
||||
.addSubcommand((s) => s
|
||||
.setName("preview")
|
||||
.setDescription("Preview an announcement")
|
||||
.addStringOption((o) => o.setName("id").setDescription("Announcement ID").setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s
|
||||
.setName("list")
|
||||
.setDescription("List all announcements")
|
||||
)
|
||||
)
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
|
@ -195,5 +216,9 @@ export async function handleTgAdminCommand(interaction: ChatInputCommandInteract
|
|||
if (group === "leaderboard" && sub === "post") return ResultCommands.leaderboardPost(interaction);
|
||||
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(interaction);
|
||||
|
||||
if (group === "announcement" && sub === "post") return AnnouncementCommands.post(interaction);
|
||||
if (group === "announcement" && sub === "preview") return AnnouncementCommands.preview(interaction);
|
||||
if (group === "announcement" && sub === "list") return AnnouncementCommands.list(interaction);
|
||||
|
||||
if (group === null && sub === "test-align") return TestAlignCommands.handle(interaction);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { ResultCommands } from "@subcommands/admin/result-post";
|
|||
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
|
||||
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
||||
import fs from "fs";
|
||||
import { AnnouncementCommands } from "../subcommands/admin/announcement";
|
||||
|
||||
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
||||
let _usermapCache: Record<string, any> | null = null;
|
||||
|
|
@ -145,6 +146,8 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
|
|||
return await autocompleteLayout(interaction); // poll default
|
||||
}
|
||||
|
||||
if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction);
|
||||
|
||||
await interaction.respond([]);
|
||||
} catch (err) {
|
||||
console.error("[autocomplete] error:", err);
|
||||
|
|
|
|||
71
src/subcommands/admin/announcement.ts
Normal file
71
src/subcommands/admin/announcement.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { Announcements } from "@systems/announcements";
|
||||
import { Discord } from "@discord";
|
||||
|
||||
export async function handleAnnouncementPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
const id = opts.string({ key: "id", required: true })!;
|
||||
const force = opts.boolean({ key: "force" }) ?? false;
|
||||
|
||||
const result = await Announcements.post({ id, client: interaction.client, force });
|
||||
|
||||
if (!result.ok) {
|
||||
await Discord.Interaction.editReply(interaction, { content: `❌ ${result.reason}` });
|
||||
return;
|
||||
}
|
||||
|
||||
await Discord.Interaction.editReply(interaction, { content: `✅ Announcement \`${id}\` posted.` });
|
||||
}
|
||||
|
||||
export async function handleAnnouncementPreview(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
|
||||
const opts = Discord.Interaction.options(interaction);
|
||||
const id = opts.string({ key: "id", required: true })!;
|
||||
|
||||
const embed = Announcements.buildEmbed({ id });
|
||||
if (!embed) {
|
||||
await Discord.Interaction.editReply(interaction, { content: `❌ Announcement \`${id}\` not found.` });
|
||||
return;
|
||||
}
|
||||
|
||||
await Discord.Interaction.editReply(interaction, { content: "", embeds: [embed] });
|
||||
}
|
||||
|
||||
export async function handleAnnouncementList(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
await Discord.Interaction.deferReply(interaction, { ephemeral: true });
|
||||
|
||||
const ids = Announcements.list();
|
||||
const lines = ids.map((id) => {
|
||||
const a = Announcements.get({ id });
|
||||
const posted = Announcements.isPosted({ id }) ? "✅ posted" : "⬜ not posted";
|
||||
return `\`${id}\` — ${a?.title ?? "?"} (${posted})`;
|
||||
});
|
||||
|
||||
await Discord.Interaction.editReply(interaction, {
|
||||
content: lines.length > 0 ? lines.join("\n") : "No announcements found.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function autocompleteAnnouncementId(interaction: any): Promise<void> {
|
||||
console.time("autocomplete-announcement");
|
||||
const focused = interaction.options.getFocused().toLowerCase();
|
||||
const choices = Announcements.list()
|
||||
.filter((id) => id.toLowerCase().includes(focused))
|
||||
.map((id) => {
|
||||
const a = Announcements.get({ id });
|
||||
return { name: `${id} — ${a?.title ?? ""}`.slice(0, 100), value: id };
|
||||
})
|
||||
.slice(0, 25);
|
||||
console.timeEnd("autocomplete-announcement");
|
||||
await interaction.respond(choices);
|
||||
}
|
||||
|
||||
export const AnnouncementCommands = {
|
||||
post: handleAnnouncementPost,
|
||||
preview: handleAnnouncementPreview,
|
||||
list: handleAnnouncementList,
|
||||
autocomplete: autocompleteAnnouncementId,
|
||||
};
|
||||
172
src/systems/announcements.ts
Normal file
172
src/systems/announcements.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* Announcements — one-off celebratory/feature-launch posts.
|
||||
*
|
||||
* Unlike Updates (versioned, can be reposted/edited) or PersistentMessage
|
||||
* (edit-in-place), each Announcement is posted FRESH exactly once and
|
||||
* stays as its own permanent message in the channel — no editing.
|
||||
*
|
||||
* Usage:
|
||||
* import { Announcements } from "@systems/announcements";
|
||||
*
|
||||
* Announcements.list()
|
||||
* Announcements.get({ id: "001-leaderboards-results-live" })
|
||||
* await Announcements.post({ id: "001-leaderboards-results-live", client })
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Client, EmbedBuilder, TextChannel } from "discord.js";
|
||||
import { Paths } from "@paths";
|
||||
import { Store } from "@systems/store";
|
||||
import { Emoji } from "@systems/emojis";
|
||||
import { Config } from "@systems/config";
|
||||
import { Logger } from "@systems/logger";
|
||||
|
||||
const log = Logger.for("announcements");
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AnnouncementItem {
|
||||
text: string;
|
||||
emojiKey?: string | null;
|
||||
}
|
||||
|
||||
interface AnnouncementSection {
|
||||
label: string;
|
||||
emoji: string;
|
||||
items: AnnouncementItem[];
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
intro?: string;
|
||||
sections: AnnouncementSection[];
|
||||
channelLinks?: { label: string; channelKey: string }[]; // channelKey -> Config.channels.{key}
|
||||
imageUrl?: string;
|
||||
color?: string; // hex string, e.g. "#e8a317"
|
||||
}
|
||||
|
||||
// ─── Paths ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function announcementsDir(): string {
|
||||
return Paths.data("announcements");
|
||||
}
|
||||
|
||||
function announcementDir(id: string): string {
|
||||
return path.join(announcementsDir(), id);
|
||||
}
|
||||
|
||||
// ─── Posted-tracking (so we never accidentally double-post the same one) ────
|
||||
|
||||
function postedPath(): string {
|
||||
return path.join(announcementsDir(), ".posted.json");
|
||||
}
|
||||
|
||||
function getPosted(): Record<string, string> {
|
||||
return Store.readOrDefault<Record<string, string>>(postedPath(), {});
|
||||
}
|
||||
|
||||
function markPosted(id: string, messageId: string): void {
|
||||
const posted = getPosted();
|
||||
posted[id] = messageId;
|
||||
Store.write(postedPath(), posted);
|
||||
}
|
||||
|
||||
// ─── Embed building ───────────────────────────────────────────────────────────
|
||||
|
||||
function resolveChannelLink(channelKey: string): string {
|
||||
try {
|
||||
const channelId = Config.get({ section: "channels", key: channelKey as any });
|
||||
return channelId ? `<#${channelId}>` : `#${channelKey}`;
|
||||
} catch {
|
||||
return `#${channelKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnouncementEmbed(a: Announcement): EmbedBuilder {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(a.title)
|
||||
.setColor((a.color ?? "#e8a317") as any)
|
||||
.setTimestamp();
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (a.intro) {
|
||||
lines.push(a.intro, "");
|
||||
}
|
||||
|
||||
for (const section of a.sections) {
|
||||
if (section.label) lines.push(`${section.emoji} **${section.label}**`);
|
||||
for (const item of section.items) {
|
||||
const bullet = item.emojiKey ? (Emoji.get(item.emojiKey) || "•") : "•";
|
||||
lines.push(`${bullet} ${Emoji.resolveTokens(item.text)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (a.channelLinks?.length) {
|
||||
const links = a.channelLinks
|
||||
.map((l) => `${l.label}: ${resolveChannelLink(l.channelKey)}`)
|
||||
.join("\n");
|
||||
lines.push(links);
|
||||
}
|
||||
|
||||
embed.setDescription(lines.join("\n").trim());
|
||||
if (a.imageUrl) embed.setImage(a.imageUrl);
|
||||
embed.setFooter({ text: a.date });
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
// ─── Namespace ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Announcements = {
|
||||
list(): string[] {
|
||||
const dir = announcementsDir();
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => fs.statSync(path.join(dir, f)).isDirectory())
|
||||
.sort();
|
||||
},
|
||||
|
||||
get({ id }: { id: string }): Announcement | null {
|
||||
return Store.read<Announcement>(path.join(announcementDir(id), "announcement.json"));
|
||||
},
|
||||
|
||||
isPosted({ id }: { id: string }): boolean {
|
||||
return !!getPosted()[id];
|
||||
},
|
||||
|
||||
buildEmbed({ id }: { id: string }): EmbedBuilder | null {
|
||||
const a = Announcements.get({ id });
|
||||
return a ? buildAnnouncementEmbed(a) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Post an announcement. By default refuses to re-post one already
|
||||
* posted (announcements are meant to be one-off) — pass force:true
|
||||
* to deliberately repost as a NEW message anyway.
|
||||
*/
|
||||
async post({ id, client, force = false }: { id: string; client: Client; force?: boolean }): Promise<{ ok: boolean; reason?: string }> {
|
||||
const a = Announcements.get({ id });
|
||||
if (!a) return { ok: false, reason: `Announcement "${id}" not found.` };
|
||||
|
||||
if (!force && Announcements.isPosted({ id })) {
|
||||
return { ok: false, reason: `Announcement "${id}" was already posted. Use force to repost anyway.` };
|
||||
}
|
||||
|
||||
const channelId = Config.get({ section: "channels", key: "announcements" });
|
||||
if (!channelId) return { ok: false, reason: "announcements channel not configured." };
|
||||
|
||||
const channel = await client.channels.fetch(channelId) as TextChannel;
|
||||
const embed = buildAnnouncementEmbed(a);
|
||||
const msg = await channel.send({ embeds: [embed] });
|
||||
|
||||
markPosted(id, msg.id);
|
||||
log.info(`Posted announcement ${id} (${msg.id})`);
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
|
@ -9,11 +9,12 @@ Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }
|
|||
// ─── Section interfaces (internal) ───────────────────────────────────────────
|
||||
|
||||
interface ChannelConfig {
|
||||
poll: string;
|
||||
results: string;
|
||||
score: string;
|
||||
updates: string;
|
||||
leaderboard: string;
|
||||
poll: string;
|
||||
results: string;
|
||||
score: string;
|
||||
updates: string;
|
||||
leaderboard: string;
|
||||
announcements: string;
|
||||
}
|
||||
|
||||
interface RoleConfig {
|
||||
|
|
@ -119,7 +120,8 @@ function getDefaults(): SectionMap {
|
|||
results: "",
|
||||
score: "",
|
||||
updates: "",
|
||||
leaderboard: ""
|
||||
leaderboard: "",
|
||||
announcements: ""
|
||||
},
|
||||
roles: {
|
||||
officer: ["Ice King"],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue