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",
|
"latest": "v0.9.1",
|
||||||
"versions": ["v0.8", "v0.7", "v0.6", "v0.5", "v0.4", "v0.3", "v0.2", "v0.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 { ScoreInjectCommands } from "@subcommands/admin/score-inject";
|
||||||
import { ResultCommands } from "@subcommands/admin/result-post";
|
import { ResultCommands } from "@subcommands/admin/result-post";
|
||||||
import { TestAlignCommands } from "@subcommands/admin/test-align";
|
import { TestAlignCommands } from "@subcommands/admin/test-align";
|
||||||
|
import { AnnouncementCommands } from "../subcommands/admin/announcement";
|
||||||
|
|
||||||
export function buildTgAdminCommand(): SlashCommandBuilder {
|
export function buildTgAdminCommand(): SlashCommandBuilder {
|
||||||
const cmd = new 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;
|
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") return ResultCommands.leaderboardPost(interaction);
|
||||||
if (group === "leaderboard" && sub === "post-highlights") return ResultCommands.leaderboardHighlights(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);
|
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 { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
|
||||||
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { AnnouncementCommands } from "../subcommands/admin/announcement";
|
||||||
|
|
||||||
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
// ─── Usermap cache ────────────────────────────────────────────────────────────
|
||||||
let _usermapCache: Record<string, any> | null = null;
|
let _usermapCache: Record<string, any> | null = null;
|
||||||
|
|
@ -145,6 +146,8 @@ export async function handleAutocomplete(interaction: AutocompleteInteraction):
|
||||||
return await autocompleteLayout(interaction); // poll default
|
return await autocompleteLayout(interaction); // poll default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (optionName === "id") return await AnnouncementCommands.autocomplete(interaction);
|
||||||
|
|
||||||
await interaction.respond([]);
|
await interaction.respond([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[autocomplete] error:", 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -14,6 +14,7 @@ interface ChannelConfig {
|
||||||
score: string;
|
score: string;
|
||||||
updates: string;
|
updates: string;
|
||||||
leaderboard: string;
|
leaderboard: string;
|
||||||
|
announcements: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleConfig {
|
interface RoleConfig {
|
||||||
|
|
@ -119,7 +120,8 @@ function getDefaults(): SectionMap {
|
||||||
results: "",
|
results: "",
|
||||||
score: "",
|
score: "",
|
||||||
updates: "",
|
updates: "",
|
||||||
leaderboard: ""
|
leaderboard: "",
|
||||||
|
announcements: ""
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
officer: ["Ice King"],
|
officer: ["Ice King"],
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue