From 17ff1d932fc5402488f9dde226ea775d045ec565 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 11 Jun 2026 02:42:30 +0100 Subject: [PATCH] feat: nested Config system with section/key access pattern, Discord API abstraction start --- src/commands/tg.ts | 4 +- src/commands/tgConfig.ts | 86 ++++--- src/discord/channel.ts | 40 +++ src/discord/guild.ts | 29 +++ src/discord/index.ts | 25 ++ src/discord/interaction.ts | 89 +++++++ src/handlers/autocomplete.ts | 4 +- src/handlers/buttons.ts | 11 +- src/index.ts | 18 +- src/scheduler.ts | 8 +- src/subcommands/admin/userMap.ts | 10 +- src/subcommands/char/accept.ts | 4 +- src/subcommands/char/active.ts | 4 +- src/subcommands/char/add.ts | 4 +- src/subcommands/char/borrow.ts | 8 +- src/subcommands/char/remove.ts | 4 +- src/subcommands/char/setActive.ts | 4 +- src/subcommands/char/setNation.ts | 4 +- src/subcommands/char/setStats.ts | 4 +- src/subcommands/char/share.ts | 6 +- src/subcommands/impersonate.ts | 4 +- src/subcommands/poll/confirm.ts | 19 +- src/subcommands/poll/inject.ts | 6 +- src/subcommands/poll/lock.ts | 4 +- src/subcommands/poll/purge.ts | 4 +- src/subcommands/poll/reload.ts | 7 +- src/subcommands/poll/seed.ts | 4 +- src/subcommands/poll/setMessage.ts | 2 +- src/subcommands/poll/start.ts | 5 +- src/subcommands/poll/status.ts | 28 +-- src/subcommands/poll/unlock.ts | 4 +- src/subcommands/rank/get.ts | 6 +- src/subcommands/rank/post.ts | 6 +- src/subcommands/result/post.ts | 6 +- src/subcommands/result/set.ts | 4 +- src/subcommands/result/view.ts | 4 +- src/subcommands/score/get.ts | 6 +- src/subcommands/score/set.ts | 41 ++-- src/subcommands/score/submitCore.ts | 4 +- src/subcommands/switch.ts | 4 +- src/subcommands/tg-config/set-layout.ts | 18 +- src/systems/borrow.ts | 5 +- src/systems/bringer.ts | 4 +- src/systems/character.ts | 4 +- src/systems/config.ts | 284 ++++++++++++---------- src/systems/conflict.ts | 11 +- src/systems/impersonate.ts | 4 +- src/systems/poll.ts | 3 - src/systems/scheduler.ts | 6 +- src/systems/scheduler/index.ts | 4 +- src/systems/scheduler/midnight-cleanup.ts | 4 +- src/systems/scores.ts | 8 +- src/systems/slots.ts | 2 +- src/systems/tg.ts | 5 +- src/systems/users.ts | 10 +- src/systems/wrank.ts | 4 +- src/ui/poll/index.ts | 2 +- src/ui/poll/layouts/default.ts | 14 +- src/ui/poll/layouts/side-by-side.ts | 14 +- src/utils.ts | 6 +- tsconfig.json | 4 +- 61 files changed, 591 insertions(+), 359 deletions(-) create mode 100644 src/discord/channel.ts create mode 100644 src/discord/guild.ts create mode 100644 src/discord/index.ts create mode 100644 src/discord/interaction.ts diff --git a/src/commands/tg.ts b/src/commands/tg.ts index 4f532cb..25973b6 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -4,7 +4,7 @@ import { REST, Routes, } from "discord.js"; -import { cfg } from "../systems/config"; +import { Config } from "@systems/config"; import { hasOfficerRole } from "../systems/users"; // Poll subcommands @@ -281,7 +281,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction): const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); // Officer-only commands const officerOnlyGroups = ["poll", "result", "bringer"]; diff --git a/src/commands/tgConfig.ts b/src/commands/tgConfig.ts index b6fcc74..2b676da 100644 --- a/src/commands/tgConfig.ts +++ b/src/commands/tgConfig.ts @@ -1,10 +1,16 @@ import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; -import { cfg, setCfg, resetCfg } from "../systems/config"; +import { Config, SectionMap } from "../systems/config"; import { hasOfficerRole } from "../systems/users"; import { replyAndDelete } from "../utils"; import { Nation } from "@types"; import { handleSetLayout } from "@subcommands/tg-config/set-layout"; +const ROLE_KEY_MAP: Record<"officerRoles" | "configRoles" | "tagRoles", keyof SectionMap["roles"]> = { + officerRoles: "officer", + configRoles: "config", + tagRoles: "tag", +}; + export function buildTgConfigCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() .setName("tg-config") @@ -121,50 +127,58 @@ export function buildTgConfigCommand(): SlashCommandBuilder { } export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise { + // discord.js types CommandInteractionOptionResolver with Omit<> which hides + // methods like getString/getInteger at the type level despite them existing at runtime + // Needs to be cast as any, since Discord.js has issues with the type + const options = interaction.options as any; + const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, cfg("configRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "config" }))) { return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); } - const group = interaction.options.getSubcommandGroup(true); - const sub = interaction.options.getSubcommand(); + const group = options.getSubcommandGroup(true); + const sub = options.getSubcommand(); const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { + const key = ROLE_KEY_MAP[cfgKey]; if (action === "set") { - const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean); - setCfg(cfgKey, roles); + const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean); + Config.set({ section: "roles", key, value: roles }); return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`); } if (action === "add") { - const role = interaction.options.getString("role", true).trim(); - const roles = [...new Set([...cfg(cfgKey), role])]; - setCfg(cfgKey, roles); + const role = options.getString("role", true).trim(); + const roles = [...new Set([...Config.get({ section: "roles", key }), role])]; + Config.set({ section: "roles", key, value: roles }); return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`); } if (action === "remove") { - const role = interaction.options.getString("role", true).trim(); - const roles = cfg(cfgKey).filter((r: string) => r !== role); - setCfg(cfgKey, roles); + const role = options.getString("role", true).trim(); + const roles = Config.get({ section: "roles", key }).filter((r: string) => r !== role); + Config.set({ section: "roles", key, value: roles }); return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`); } if (action === "reset") { - resetCfg(cfgKey); + Config.reset({ section: "roles", key }); return void replyAndDelete(interaction, `✅ Roles reset to default.`); } }; // ── message ──────────────────────────────────────────────────────────────── if (group === "message") { - if (sub === "set-lock") { setCfg("lockMessage", interaction.options.getString("message", true)); return void replyAndDelete(interaction, "✅ Lock message updated."); } - if (sub === "reset-lock") { resetCfg("lockMessage"); return void replyAndDelete(interaction, "✅ Lock message reset."); } + if (sub === "set-lock") { Config.set({ section: "poll", key: "lockMessage", value: options.getString("message", true) }); return void replyAndDelete(interaction, "✅ Lock message updated."); } + if (sub === "reset-lock") { Config.reset({ section: "poll", key: "lockMessage" }); return void replyAndDelete(interaction, "✅ Lock message reset."); } if (sub === "set-confirm") { - const d = interaction.options.getString("decision", true); - setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", true)); + const d = options.getString("decision", true); + const key = d === "yes" ? "confirmYes" : "confirmNo"; + Config.set({ section: "poll", key, value: options.getString("message", true) }); return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`); } if (sub === "reset-confirm") { - const d = interaction.options.getString("decision", true); - resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage"); + const d = options.getString("decision", true); + const key = d === "yes" ? "confirmYes" : "confirmNo"; + Config.reset({ section: "poll", key }); return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`); } } @@ -187,42 +201,42 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac // ── channel ──────────────────────────────────────────────────────────────── if (group === "channel") { - if (sub === "set-poll") { setCfg("pollChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Poll channel updated."); } - if (sub === "set-results") { setCfg("resultsChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Results channel updated."); } - if (sub === "set-score") { setCfg("scoreChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Score channel updated."); } + if (sub === "set-poll") { Config.set({ section: "channels", key: "poll", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Poll channel updated."); } + if (sub === "set-results") { Config.set({ section: "channels", key: "results", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Results channel updated."); } + if (sub === "set-score") { Config.set({ section: "channels", key: "score", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Score channel updated."); } } // ── slot ─────────────────────────────────────────────────────────────────── if (group === "slot") { if (sub === "add") { - const hour = interaction.options.getInteger("hour", true); - const pollOpens = interaction.options.getString("poll_opens", true); - const slots = cfg("slots"); + const hour = options.getInteger("hour", true); + const pollOpens = options.getString("poll_opens", true); + const slots = Config.get({ section: "poll", key: "slots" }); if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`); - slots.push({ tgHour: hour, pollOpens, closesAfter: cfg("tgDurationMinutes"), active: true }); - setCfg("slots", slots); + slots.push({ tgHour: hour, pollOpens, closesAfter: Config.get({ section: "tg", key: "durationMinutes" }), active: true }); + Config.set({ section: "poll", key: "slots", value: slots }); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`); } if (sub === "remove") { - const hour = interaction.options.getInteger("hour", true); - const slots = cfg("slots").filter((s) => s.tgHour !== hour); - setCfg("slots", slots); + const hour = options.getInteger("hour", true); + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.tgHour !== hour); + Config.set({ section: "poll", key: "slots", value: slots }); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`); } } // ── wrank ────────────────────────────────────────────────────────────────── if (group === "wrank") { - if (sub === "set-goal") { setCfg("wRankGoal", interaction.options.getInteger("goal", true)); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); } - if (sub === "set-post-on-reset") { setCfg("wRankPostOnReset", interaction.options.getBoolean("enabled", true)); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); } + if (sub === "set-goal") { Config.set({ section: "wrank", key: "goal", value: options.getInteger("goal", true)! }); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); } + if (sub === "set-post-on-reset") { Config.set({ section: "wrank", key: "postOnReset", value: options.getBoolean("enabled", true)! }); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); } } // ── tg ───────────────────────────────────────────────────────────────────── if (group === "tg") { - if (sub === "set-score-window") { setCfg("scoreWindowHours", interaction.options.getNumber("hours", true)); return void replyAndDelete(interaction, "✅ Score window updated."); } - if (sub === "set-duration") { setCfg("tgDurationMinutes", interaction.options.getInteger("minutes", true)); return void replyAndDelete(interaction, "✅ TG duration updated."); } - if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); } - if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); } + if (sub === "set-score-window") { Config.set({ section: "tg", key: "scoreWindowHours", value: options.getNumber("hours", true)! }); return void replyAndDelete(interaction, "✅ Score window updated."); } + if (sub === "set-duration") { Config.set({ section: "tg", key: "durationMinutes", value: options.getInteger("minutes", true)! }); return void replyAndDelete(interaction, "✅ TG duration updated."); } + if (sub === "set-no-display") { Config.set({ section: "poll", key: "showNoInNationField", value: options.getString("mode", true) === "inline" }); return void replyAndDelete(interaction, "✅ No voter display updated."); } + if (sub === "set-nation-source"){ Config.set({ section: "nation", key: "source", value: options.getString("nation", true) as Nation }); return void replyAndDelete(interaction, "✅ Nation source updated."); } } if (group === "poll") { diff --git a/src/discord/channel.ts b/src/discord/channel.ts new file mode 100644 index 0000000..a27c297 --- /dev/null +++ b/src/discord/channel.ts @@ -0,0 +1,40 @@ +/** + * Discord.Channel — channel fetching and messaging. + */ + +import { + Client, + TextChannel, + MessageCreateOptions, + MessageEditOptions, +} from "discord.js"; + +async function fetch({ client, id }: { client: Client; id: string }): Promise { + return client.channels.fetch(id) as Promise; +} + +async function send({ channel, content, embeds, components }: { + channel: TextChannel; + content?: string; + embeds?: any[]; + components?: any[]; +}): Promise { + await channel.send({ content, embeds, components } as MessageCreateOptions); +} + +async function edit({ channel, messageId, content, embeds, components }: { + channel: TextChannel; + messageId: string; + content?: string; + embeds?: any[]; + components?: any[]; +}): Promise { + const msg = await channel.messages.fetch(messageId); + await msg.edit({ content, embeds, components } as MessageEditOptions); +} + +export const Channel = { + fetch, + send, + edit, +}; diff --git a/src/discord/guild.ts b/src/discord/guild.ts new file mode 100644 index 0000000..9c22ae3 --- /dev/null +++ b/src/discord/guild.ts @@ -0,0 +1,29 @@ +/** + * Discord.Guild — guild and member operations. + */ + +import { + ChatInputCommandInteraction, + ButtonInteraction, + GuildMember, +} from "discord.js"; + +type AnyInteraction = ChatInputCommandInteraction | ButtonInteraction; + +async function member({ interaction }: { interaction: AnyInteraction }): Promise { + return interaction.guild!.members.fetch(interaction.user.id); +} + +async function fetchMember({ interaction, userId }: { interaction: AnyInteraction; userId: string }): Promise { + return interaction.guild!.members.fetch(userId); +} + +function hasRole({ member, roles }: { member: GuildMember; roles: string[] }): boolean { + return member.roles.cache.some((r) => roles.includes(r.name)); +} + +export const Guild = { + member, + fetchMember, + hasRole, +}; diff --git a/src/discord/index.ts b/src/discord/index.ts new file mode 100644 index 0000000..83cc469 --- /dev/null +++ b/src/discord/index.ts @@ -0,0 +1,25 @@ +/** + * Discord — abstraction layer over Discord.js API. + * + * Usage: + * import { Discord } from "@discord"; + * + * Discord.Interaction.options(i).string({ key: "name" }) + * Discord.Guild.member({ interaction }) + * Discord.Channel.fetch({ client, id }) + */ + +export { Interaction } from "./interaction"; +export { Guild } from "./guild"; +export { Channel } from "./channel"; + +// Top-level namespace for convenience +import { Interaction } from "./interaction"; +import { Guild } from "./guild"; +import { Channel } from "./channel"; + +export const Discord = { + Interaction, + Guild, + Channel, +}; diff --git a/src/discord/interaction.ts b/src/discord/interaction.ts new file mode 100644 index 0000000..12d98f6 --- /dev/null +++ b/src/discord/interaction.ts @@ -0,0 +1,89 @@ +/** + * Discord.Interaction — abstracts interaction option reading and replies. + */ + +import { + ChatInputCommandInteraction, + ButtonInteraction, + ModalSubmitInteraction, + GuildMember, + InteractionReplyOptions, + MessageFlags, +} from "discord.js"; + +type AnyInteraction = + | ChatInputCommandInteraction + | ButtonInteraction + | ModalSubmitInteraction; + +// ─── Options resolver ───────────────────────────────────────────────────────── + +export interface OptionParams { + key: string; + required?: boolean; +} + +export interface OptionsResolver { + string(params: OptionParams): string | null; + integer(params: OptionParams): number | null; + number(params: OptionParams): number | null; + boolean(params: OptionParams): boolean | null; + channel(params: OptionParams): any | null; + user(params: OptionParams): any | null; + subcommand(): string | null; + subcommandGroup(): string | null; +} + +function options(interaction: T): OptionsResolver { + const opts = interaction.options as any; + return { + string: ({ key, required = false }) => opts.getString(key, required), + integer: ({ key, required = false }) => opts.getInteger(key, required), + number: ({ key, required = false }) => opts.getNumber(key, required), + boolean: ({ key, required = false }) => opts.getBoolean(key, required), + channel: ({ key, required = false }) => opts.getChannel(key, required), + user: ({ key, required = false }) => opts.getUser(key, required), + subcommand: () => opts.getSubcommand(false), + subcommandGroup: () => opts.getSubcommandGroup(false), + }; +} + +// ─── Reply helpers ──────────────────────────────────────────────────────────── + +interface ReplyParams { + content: string; + ephemeral?: boolean; + components?: any[]; + embeds?: any[]; +} + +async function reply(interaction: AnyInteraction, params: ReplyParams): Promise { + const opts: InteractionReplyOptions = { + content: params.content, + components: params.components, + embeds: params.embeds, + flags: params.ephemeral ? MessageFlags.Ephemeral : undefined, + }; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(opts); + } else { + await interaction.reply(opts); + } +} + +async function followUp(interaction: AnyInteraction, params: ReplyParams): Promise { + await interaction.followUp({ + content: params.content, + components: params.components, + embeds: params.embeds, + flags: params.ephemeral ? MessageFlags.Ephemeral : undefined, + }); +} + +// ─── Namespace ──────────────────────────────────────────────────────────────── + +export const Interaction = { + options, + reply, + followUp, +}; diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts index 4888f2d..0738565 100644 --- a/src/handlers/autocomplete.ts +++ b/src/handlers/autocomplete.ts @@ -1,7 +1,7 @@ import { AutocompleteInteraction } from "discord.js"; import { resolveUser } from "@systems/users"; import { getCharacters } from "@systems/characters"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { Emoji } from "@systems/emojis"; import { CharacterRegistry } from "@registry/character-registry"; import { UserRegistry } from "@registry/user-registry"; @@ -99,7 +99,7 @@ async function autocompleteSlots( interaction: AutocompleteInteraction, focused: string ): Promise { - const slots = cfg("slots") + const slots = Config.get({ section: "poll", key: "slots" }) .filter((s) => s.active) .map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) })) .filter((s) => s.name.includes(focused)); diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index 0c662c0..9eeba74 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -5,7 +5,6 @@ import { ActionRowBuilder, TextChannel } from "discord.js"; -import { cfg } from "@systems/config"; import { pollReplyAndDelete } from "../utils"; import { resolveUser } from "@systems/users"; import { resolveMessage, nowFormatted } from "@systems/messages"; @@ -27,7 +26,7 @@ import { Benchmark } from "@systems/benchmark"; import { Config } from "@systems/config"; -const LOCK_AT = Config.get("lockAt"); +const LOCK_AT = Config.get({ section: "poll", key: "lockAt" }); const clickCounts = new Map(); const _processingVotes = new Set(); @@ -78,7 +77,7 @@ async function handleCharacterConflict( } const slot = [...polls.keys()][0]; - const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; + const slotHour = slot !== undefined ? polls.get(slot)?.slot : Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20; const { buildCharSelectButtons } = require("@systems/charSelect"); const buttons = buildCharSelectButtons(userKey ?? "", { @@ -245,7 +244,7 @@ export async function showActiveCharSwitching(interaction: ButtonInteraction): P const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); bench.mark("getEffectiveCharacter"); if (char) { - const starEmoji = Config.get("activeCharEmoji"); + const starEmoji = Config.get({ section: "emoji", key: "activeChar" }); const borrowNote = borrowedFrom ? ` 🔗` : ""; const buttons = buildCharSelectButtons(user.userKey, { customIdPrefix: `companion_switch:${user.userKey}`, @@ -316,8 +315,8 @@ export async function handleScoreSubmitButton(interaction: ButtonInteraction): P // } // // Build slot selector — all valid slots, with the active TG pre-selected -// const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; -// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; +// const validSlots = Config.get({ section: "poll", key: "slots" }).map((s) => s.tgHour) as number[]; +// const activeSlot = slot ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20; // const select = new StringSelectMenuBuilder() // .setCustomId(`score_slot_select:${user.userKey}`) diff --git a/src/index.ts b/src/index.ts index 1d1968a..17b9af6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,5 @@ import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; -import { loadConfig, cfg } from "@systems/config"; -import { loadMessages } from "@systems/messages"; -import { Emoji } from "@systems/emojis"; -import { Char } from "@systems/characters"; -import { WRank } from "@systems/wrank"; +import { Config } from "@systems/config"; import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; import { handleInteraction } from "@handlers/interactions"; import { buildTgCommand } from "@commands/tg"; @@ -35,7 +31,8 @@ async function registerCommands(): Promise { } async function onPollOpen(slot: TGSlot): Promise { - const channel = await client.channels.fetch(cfg("pollChannelId")) as any; + const channelId = Config.get({ section: "channels", key: "poll" }); + const channel = await client.channels.fetch(channelId) as any; if (!channel) return console.error("Poll channel not found."); await postPoll(channel, slot); } @@ -47,7 +44,8 @@ async function onPollLock(slot: TGSlot): Promise { lockPoll(slot.tgHour); - const channel = await client.channels.fetch(cfg("pollChannelId")) as any; + const channelId = Config.get({ section: "channels", key: "poll" }); + const channel = await client.channels.fetch(channelId) as any; if (!channel) return; // Buttons disabled, no submit button yet — that comes at close @@ -60,7 +58,8 @@ async function onPollClose(slot: TGSlot): Promise { const state = polls.get(slot.tgHour); if (!state) return; - const channel = await client.channels.fetch(cfg("pollChannelId")) as any; + const channelId = Config.get({ section: "channels", key: "poll" }); + const channel = await client.channels.fetch(channelId) as any; if (!channel) return; await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true @@ -79,7 +78,8 @@ client.once("clientReady", async () => { for (const [slot, state] of restored) polls.set(slot, state); // Re-render all restored poll messages - const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channelId = Config.get({ section: "channels", key: "poll" }); + const channel = await client.channels.fetch(channelId) as any; for (const slot of polls.keys()) { const state = polls.get(slot)!; await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); diff --git a/src/scheduler.ts b/src/scheduler.ts index b1fdcef..3635afb 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -11,8 +11,8 @@ cron.schedule("0 20 * * *", async () => { lockPoll(slot); - const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; - await updatePollMessage(channel, slot, cfg("lockMessage")); + const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; + await updatePollMessage(channel, slot, Config.get({ section: "poll", key: "lockMessage" })); console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`); }); @@ -21,7 +21,7 @@ cron.schedule("35 20 * * *", async () => { const state = polls.get(slot); if (!state) return; - const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot, undefined, true); // showSubmit = true console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`); }); @@ -29,5 +29,5 @@ cron.schedule("35 20 * * *", async () => { // ─── NOTE on future slots ───────────────────────────────────────────────────── // // Right now only slot 20 has an active poll. When we add more votable slots, -// pull the active slot from cfg("slots").filter(s => s.active) and schedule +// pull the active slot from Config.get({ section: "poll", key: "slots" }).filter(s => s.active) and schedule // dynamically, or make the cron time configurable in config.json. \ No newline at end of file diff --git a/src/subcommands/admin/userMap.ts b/src/subcommands/admin/userMap.ts index 75da1c4..bf78d5e 100644 --- a/src/subcommands/admin/userMap.ts +++ b/src/subcommands/admin/userMap.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { hasOfficerRole } from "@systems/users"; import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages"; import { replyAndDelete } from "@utils"; @@ -9,7 +9,7 @@ import { getEffectiveCharacter } from "@systems/borrow"; export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, cfg("officerRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); } @@ -90,7 +90,7 @@ export async function handleAdminUserMap(interaction: ChatInputCommandInteractio export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, cfg("officerRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); } @@ -138,7 +138,7 @@ export async function handleAdminPollFixVoter(interaction: ChatInputCommandInter updateEntry(state.yes); updateEntry(state.no); - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as any; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as any; await updatePollMessage(channel, slot); const { format } = require("@format"); @@ -147,7 +147,7 @@ export async function handleAdminPollFixVoter(interaction: ChatInputCommandInter export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, cfg("officerRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); } diff --git a/src/subcommands/char/accept.ts b/src/subcommands/char/accept.ts index 3c09dd5..a44eeaa 100644 --- a/src/subcommands/char/accept.ts +++ b/src/subcommands/char/accept.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { getCharacterByName } from "@systems/characters"; import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow"; import { polls, updatePollMessage } from "@systems/poll"; @@ -58,7 +58,7 @@ async function acceptBorrow( } } try { - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot); } catch {} } diff --git a/src/subcommands/char/active.ts b/src/subcommands/char/active.ts index 4550470..dbaaa15 100644 --- a/src/subcommands/char/active.ts +++ b/src/subcommands/char/active.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; @@ -11,7 +11,7 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction) const group = interaction.options.getSubcommandGroup(false); const sub = interaction.options.getSubcommand(); const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); if (nameArg && !isOfficer) { return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); } diff --git a/src/subcommands/char/add.ts b/src/subcommands/char/add.ts index d9bb741..1465dd6 100644 --- a/src/subcommands/char/add.ts +++ b/src/subcommands/char/add.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { addCharacter } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; @@ -7,7 +7,7 @@ import { ClassKey, Nation } from "../../types"; export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); const cls = interaction.options.getString("class", true) as ClassKey; diff --git a/src/subcommands/char/borrow.ts b/src/subcommands/char/borrow.ts index 7edc8ab..2f851b8 100644 --- a/src/subcommands/char/borrow.ts +++ b/src/subcommands/char/borrow.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; @@ -9,7 +9,7 @@ import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages"; export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const requester = await resolveUser(member); // Args: owner, charname, [username] (officer only — grants directly) @@ -40,7 +40,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) const slot = [...polls.keys()][0]; if (slot !== undefined) { const state = polls.get(slot)!; - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; // Find the voter entry and update their character for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { if (entry.userKey === requesterKey) { @@ -78,7 +78,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`); } - const fallbackChannel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const fallbackChannel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await sendBorrowRequestDM( interaction.client, ownerMember.user.id, diff --git a/src/subcommands/char/remove.ts b/src/subcommands/char/remove.ts index f08d9c0..edca764 100644 --- a/src/subcommands/char/remove.ts +++ b/src/subcommands/char/remove.ts @@ -1,12 +1,12 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { removeCharacter } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); diff --git a/src/subcommands/char/setActive.ts b/src/subcommands/char/setActive.ts index e22bb9c..926e652 100644 --- a/src/subcommands/char/setActive.ts +++ b/src/subcommands/char/setActive.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { setActiveCharacter } from "../../systems/characters"; import { setSessionBorrow } from "../../systems/borrow"; @@ -25,7 +25,7 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string; export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); diff --git a/src/subcommands/char/setNation.ts b/src/subcommands/char/setNation.ts index 41243a1..969c5a9 100644 --- a/src/subcommands/char/setNation.ts +++ b/src/subcommands/char/setNation.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { setCharacterNation, getActiveCharacter } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; @@ -7,7 +7,7 @@ import { Nation } from "../../types"; export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const nation = interaction.options.getString("nation", true) as Nation; const charName = interaction.options.getString("char_name"); // optional, defaults to active diff --git a/src/subcommands/char/setStats.ts b/src/subcommands/char/setStats.ts index 40074fb..ebaeeff 100644 --- a/src/subcommands/char/setStats.ts +++ b/src/subcommands/char/setStats.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { setCharacterStats, getActiveCharacter } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; @@ -8,7 +8,7 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true); // const member = await interaction.guild!.members.fetch(interaction.user.id); - // const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + // const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); // const nameArg = interaction.options.getString("name"); // const charName = interaction.options.getString("char_name"); // const atk = interaction.options.getInteger("atk") ?? undefined; diff --git a/src/subcommands/char/share.ts b/src/subcommands/char/share.ts index f6f7747..517ecae 100644 --- a/src/subcommands/char/share.ts +++ b/src/subcommands/char/share.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { getCharacterByName } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; @@ -19,7 +19,7 @@ function loadRawChars(): any { export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const user = await resolveUser(member); const ownerArg = interaction.options.getString("owner"); @@ -54,7 +54,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction): export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const user = await resolveUser(member); const ownerArg = interaction.options.getString("owner"); diff --git a/src/subcommands/impersonate.ts b/src/subcommands/impersonate.ts index 2118834..dc97b86 100644 --- a/src/subcommands/impersonate.ts +++ b/src/subcommands/impersonate.ts @@ -6,7 +6,7 @@ import { ActionRowBuilder, EmbedBuilder, } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { hasOfficerRole } from "@systems/users"; import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate"; import { replyAndDelete } from "@utils"; @@ -70,7 +70,7 @@ function buildImpersonateButtons( // Slash command handler export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, cfg("officerRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); } diff --git a/src/subcommands/poll/confirm.ts b/src/subcommands/poll/confirm.ts index 7a36b18..2fe7f47 100644 --- a/src/subcommands/poll/confirm.ts +++ b/src/subcommands/poll/confirm.ts @@ -1,6 +1,6 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg, setCfg, resetCfg } from "../../systems/config"; -import { polls, updatePollMessage } from "../../systems/poll"; +import { Config } from "@systems/config"; +import { polls, updatePollMessage } from "@systems/poll"; import { replyAndDelete } from "../../utils"; import { Nation } from "../../types"; @@ -16,15 +16,16 @@ export async function handleConfirm(interaction: ChatInputCommandInteraction): P state.confirmed = decision; state.locked = true; - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; if (oneTimeMsg) { - const cfgKey = decision === "yes" ? "confirmYesMessage" : "confirmNoMessage"; - const saved = cfg(cfgKey); - setCfg(cfgKey, oneTimeMsg); + const isYes = decision === "yes"; + const key = isYes ? "confirmYes" : "confirmNo"; + const saved = Config.get({ section: "poll", key }); + + Config.set({ section: "poll", key, value: oneTimeMsg }); await updatePollMessage(channel, slot); - if (saved !== undefined) setCfg(cfgKey, saved); - else resetCfg(cfgKey); + Config.set({ section: "poll", key, value: saved }); } else { await updatePollMessage(channel, slot); } @@ -34,7 +35,7 @@ export async function handleConfirm(interaction: ChatInputCommandInteraction): P } async function tagRoles(channel: TextChannel, interaction: ChatInputCommandInteraction): Promise { - const roles = cfg("tagRoles"); + const roles = Config.get({ section: "roles", key: "tag" }); const guild = interaction.guild!; await guild.roles.fetch(); const mentions = roles diff --git a/src/subcommands/poll/inject.ts b/src/subcommands/poll/inject.ts index eee0625..bd2a16c 100644 --- a/src/subcommands/poll/inject.ts +++ b/src/subcommands/poll/inject.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { polls, updatePollMessage } from "../../systems/poll"; import { getEffectiveCharacter } from "../../systems/borrow"; import { nowFormatted, resolveMessage } from "../../systems/messages"; @@ -50,7 +50,7 @@ const entry: VoteEntry = { state.no.set(syntheticId, entry); } - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot); return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`); } @@ -76,7 +76,7 @@ export async function handleRemoveVote(interaction: ChatInputCommandInteraction) if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`); - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot); return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`); } \ No newline at end of file diff --git a/src/subcommands/poll/lock.ts b/src/subcommands/poll/lock.ts index cff9ed6..5f1c400 100644 --- a/src/subcommands/poll/lock.ts +++ b/src/subcommands/poll/lock.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { polls, lockPoll, updatePollMessage } from "@systems/poll"; import { replyAndDelete } from "@utils"; @@ -12,7 +12,7 @@ export async function handleLock(interaction: ChatInputCommandInteraction): Prom // Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron lockPoll(slot); - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; if (simulateClose) { // Simulate TG end: show Submit Score button (same path as onPollClose cron) diff --git a/src/subcommands/poll/purge.ts b/src/subcommands/poll/purge.ts index 18ae701..ca5960e 100644 --- a/src/subcommands/poll/purge.ts +++ b/src/subcommands/poll/purge.ts @@ -1,10 +1,10 @@ import { ChatInputCommandInteraction, TextChannel, Collection, Message } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { polls } from "../../systems/poll"; import { replyAndDelete } from "../../utils"; export async function handlePurge(interaction: ChatInputCommandInteraction): Promise { - const channelId = cfg("pollChannelId"); + const channelId = Config.get({ section: "channels", key: "poll" }); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found."); diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts index a131bf0..3c0be2f 100644 --- a/src/subcommands/poll/reload.ts +++ b/src/subcommands/poll/reload.ts @@ -3,12 +3,11 @@ import { loadMessages } from "@systems/messages"; import { Emoji } from "@systems/emojis"; import { loadCharacters } from "@systems/characters"; import { loadWRank } from "@systems/wrank"; -import { loadConfig, cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { polls, updatePollMessage } from "@systems/poll"; import { persist } from "@systems/pollPersistence"; import { replyAndDelete } from "@utils"; import { CharacterRegistry } from "@root/src/systems/registry/character-registry"; -import { invalidateUsermapCache } from "@root/src/handlers/autocomplete"; import { UserRegistry } from "@root/src/systems/registry/user-registry"; const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const; @@ -21,7 +20,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr const should = (k: Reloadable) => target === "all" || target === k; - if (should("config")) { loadConfig(); reloaded.push("config"); } + if (should("config")) { Config.load(); reloaded.push("config"); } if (should("messages")) { loadMessages(); reloaded.push("messages"); } if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); } if (should("characters")) { loadCharacters(); reloaded.push("characters"); } @@ -29,7 +28,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr // Re-render active poll message(s) so embed reflects reloaded data if (should("poll") || should("emojis") || should("all")) { - const channelId = cfg("pollChannelId"); + const channelId = Config.get({ section: "channels", key: "poll" }); if (channelId) { try { // Restore from disk first if reloading poll specifically diff --git a/src/subcommands/poll/seed.ts b/src/subcommands/poll/seed.ts index 258f9cf..ecf9f18 100644 --- a/src/subcommands/poll/seed.ts +++ b/src/subcommands/poll/seed.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { polls, updatePollMessage } from "@systems/poll"; import { nowFormatted, resolveMessage } from "@systems/messages"; import { getEffectiveCharacter } from "@systems/borrow"; @@ -53,7 +53,7 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom injected++; } - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot); return void replyAndDelete(interaction, `✅ Seeded **${injected}** player(s)${skipped > 0 ? `, skipped **${skipped}** (no active character)` : ""}.`); } \ No newline at end of file diff --git a/src/subcommands/poll/setMessage.ts b/src/subcommands/poll/setMessage.ts index ab5bf95..1941a63 100644 --- a/src/subcommands/poll/setMessage.ts +++ b/src/subcommands/poll/setMessage.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll"; import { replyAndDelete } from "../../utils"; import { hasOfficerRole } from "../../systems/users"; diff --git a/src/subcommands/poll/start.ts b/src/subcommands/poll/start.ts index 9b20ac0..fac882c 100644 --- a/src/subcommands/poll/start.ts +++ b/src/subcommands/poll/start.ts @@ -1,5 +1,4 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; import { postPoll } from "../../systems/poll"; import { resetClickCounts } from "../../handlers/buttons"; import { replyAndDelete } from "../../utils"; @@ -8,7 +7,7 @@ import { Config } from "@systems/config"; export async function handleStart(interaction: ChatInputCommandInteraction): Promise { const slotArg = interaction.options.getString("slot"); - const slots = cfg("slots").filter((s) => s.active); + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); let slot: TGSlot | undefined; if (slotArg) { @@ -20,7 +19,7 @@ export async function handleStart(interaction: ChatInputCommandInteraction): Pro } if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured."); - const channelId = Config.get("pollChannelId"); + const channelId = Config.get({ section: "channels", key: "poll" }); console.log("pollChannelId:", channelId); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; diff --git a/src/subcommands/poll/status.ts b/src/subcommands/poll/status.ts index 96c03cb..90998a0 100644 --- a/src/subcommands/poll/status.ts +++ b/src/subcommands/poll/status.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { polls } from "../../systems/poll"; import { replyAndDelete } from "../../utils"; @@ -9,19 +9,19 @@ export async function handleStatus(interaction: ChatInputCommandInteraction): Pr ).join("\n") || "None"; const status = [ - `**Officer roles:** ${cfg("officerRoles").join(", ")}`, - `**Config roles:** ${cfg("configRoles").join(", ")}`, - `**Tag roles:** ${cfg("tagRoles").join(", ")}`, - `**Lock message:** ${cfg("lockMessage")}`, - `**Confirm yes:** ${cfg("confirmYesMessage")}`, - `**Confirm no:** ${cfg("confirmNoMessage")}`, - `**Poll channel:** ${cfg("pollChannelId") || "not set"}`, - `**Results channel:** ${cfg("resultsChannelId") || "not set"}`, - `**Score channel:** ${cfg("scoreChannelId") || "not set"}`, - `**Score window:** ${cfg("scoreWindowHours")}h`, - `**TG duration:** ${cfg("tgDurationMinutes")}min`, - `**Nation source:** ${cfg("nationSource")}`, - `**W.Rank goal:** ${cfg("wRankGoal")} TGs`, + `**Officer roles:** ${Config.get({ section: "roles", key: "officer" }).join(", ")}`, + `**Config roles:** ${Config.get({ section: "roles", key: "config" }).join(", ")}`, + `**Tag roles:** ${Config.get({ section: "roles", key: "tag" }).join(", ")}`, + `**Lock message:** ${Config.get({ section: "poll", key: "lockMessage" })}`, + `**Confirm yes:** ${Config.get({ section: "poll", key: "confirmYes" })}`, + `**Confirm no:** ${Config.get({ section: "poll", key: "confirmNo" })}`, + `**Poll channel:** ${Config.get({ section: "channels", key: "poll" }) || "not set"}`, + `**Results channel:** ${Config.get({ section: "channels", key: "results" }) || "not set"}`, + `**Score channel:** ${Config.get({ section: "channels", key: "score" }) || "not set"}`, + `**Score window:** ${Config.get({ section: "tg", key: "scoreWindowHours" })}h`, + `**TG duration:** ${Config.get({ section: "tg", key: "durationMinutes" })}min`, + `**Nation source:** ${Config.get({ section: "nation", key: "source" })}`, + `**W.Rank goal:** ${Config.get({ section: "wrank", key: "goal" })} TGs`, `**Timezone:** ${process.env.TZ ?? "Etc/GMT-2"}`, `**Active polls:**\n${activePolls}`, ].join("\n"); diff --git a/src/subcommands/poll/unlock.ts b/src/subcommands/poll/unlock.ts index 5853f42..b70e609 100644 --- a/src/subcommands/poll/unlock.ts +++ b/src/subcommands/poll/unlock.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { polls, updatePollMessage } from "../../systems/poll"; import { loadMessages } from "../../systems/messages"; import { replyAndDelete } from "../../utils"; @@ -14,7 +14,7 @@ export async function handleUnlock(interaction: ChatInputCommandInteraction): Pr state.locked = false; loadMessages(); - const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot); return void replyAndDelete(interaction, "🔓 Poll unlocked!"); } diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts index 4b11baa..fd4ceda 100644 --- a/src/subcommands/rank/get.ts +++ b/src/subcommands/rank/get.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { resolveUser, hasOfficerRole } from "@systems/users"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; @@ -9,7 +9,7 @@ import { TG } from "@systems/tg"; export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); if (nameArg && !isOfficer) { @@ -27,7 +27,7 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const week = TG.currentWeek(); - const goal = cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); const weekKey = WRank.weekKey(); for (const nation of ["capella", "procyon"] as const) { diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index 644b318..206e932 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { WRank } from "@systems/wrank"; import { getEmoji } from "@systems/emojis"; import { replyAndDelete } from "@utils"; @@ -12,7 +12,7 @@ import { TG } from "@systems/tg"; export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { const week = TG.currentWeek(); - const goal = cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); const weekKey = WRank.weekKey(); const formatNation = (nation: Nation): string => { @@ -57,7 +57,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): ) .setTimestamp(); - const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); + const channelId = Config.get({ section: "channels", key: "results" }) || Config.get({ section: "channels", key: "poll" }); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; await channel.send({ embeds: [embed] }); return void replyAndDelete(interaction, "✅ Leaderboard posted."); diff --git a/src/subcommands/result/post.ts b/src/subcommands/result/post.ts index 9bb21a1..772d31e 100644 --- a/src/subcommands/result/post.ts +++ b/src/subcommands/result/post.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { loadResult, todayString } from "@systems/history"; import { normalizeSlot, detectSlot } from "@systems/scores"; import { replyAndDelete } from "@src/utils"; @@ -13,7 +13,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction) slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20; } const result = loadResult(todayString(), slot); @@ -40,7 +40,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction) .setFooter({ text: `Source of truth: ${kd.source}` }) .setTimestamp(); - const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); + const channelId = Config.get({ section: "channels", key: "results" }) || Config.get({ section: "channels", key: "poll" }); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; await channel.send({ embeds: [embed] }); return void replyAndDelete(interaction, "✅ Results posted."); diff --git a/src/subcommands/result/set.ts b/src/subcommands/result/set.ts index e334588..2b6e080 100644 --- a/src/subcommands/result/set.ts +++ b/src/subcommands/result/set.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { setNationKD } from "../../systems/history"; import { normalizeSlot, detectSlot } from "../../systems/scores"; import { todayString } from "../../systems/history"; @@ -17,7 +17,7 @@ export async function handleResultSet(interaction: ChatInputCommandInteraction): slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20; } const result = setNationKD(todayString(), slot, nation, kills, deaths); diff --git a/src/subcommands/result/view.ts b/src/subcommands/result/view.ts index e7c70dc..fbb08f7 100644 --- a/src/subcommands/result/view.ts +++ b/src/subcommands/result/view.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { loadResult, todayString } from "../../systems/history"; import { normalizeSlot, detectSlot } from "../../systems/scores"; import { replyAndDelete } from "../../utils"; @@ -12,7 +12,7 @@ export async function handleResultView(interaction: ChatInputCommandInteraction) slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20; } const result = loadResult(todayString(), slot); diff --git a/src/subcommands/score/get.ts b/src/subcommands/score/get.ts index f91aa98..7eb8ec3 100644 --- a/src/subcommands/score/get.ts +++ b/src/subcommands/score/get.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; +import { Config } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { normalizeSlot, detectSlot } from "../../systems/scores"; import { loadResult, todayString } from "../../systems/history"; @@ -8,7 +8,7 @@ import { replyAndDelete } from "../../utils"; export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const slotArg = interaction.options.getString("slot"); @@ -31,7 +31,7 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction): slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true); } else { - slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20; } const result = loadResult(todayString(), slot); diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index caf271d..2e831e4 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -1,24 +1,29 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { resolveUser, hasOfficerRole } from "@systems/users"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { getEffectiveCharacter } from "@systems/borrow"; import { replyAndDelete } from "@utils"; -import { getEmoji } from "@systems/emojis"; +import { Emoji } from "@systems/emojis"; +import { Discord } from "@discord"; +import { User } from "@systems/users"; export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise { + const options = Discord.Interaction.options(interaction); + const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); - const nameArg = interaction.options.getString("name"); - const ptsArg = interaction.options.getInteger("pts", true); - const slotArg = interaction.options.getString("slot"); - const kills = interaction.options.getInteger("k") ?? undefined; - const deaths = interaction.options.getInteger("d") ?? undefined; - const k = interaction.options.getInteger("k") ?? undefined; - const d = interaction.options.getInteger("d") ?? undefined; - const atk = interaction.options.getInteger("atk") ?? undefined; - const def = interaction.options.getInteger("def") ?? undefined; - const heal = interaction.options.getInteger("heal") ?? undefined; + const isOfficer = User.hasOfficerRole({ + member: member, + officerRoles: Config.get({ section: "roles", key: "officer" + })}); + const nameArg = options.string({ key: "name" }); + const ptsArg = options.integer({ key: "pts", required: true }); + const slotArg = options.string({ key: "slot" }); + const k = options.integer({ key: "k" }) ?? undefined; + const d = options.integer({ key: "d" }) ?? undefined; + const atk = options.integer({ key: "atk" }) ?? undefined; + const def = options.integer({ key: "def" }) ?? undefined; + const heal = options.integer({ key: "heal" }) ?? undefined; let userKey: string | null; if (nameArg) { @@ -39,14 +44,14 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction): slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20; } await submitScore({ userKey: borrowedFrom ?? userKey, playedBy: borrowedFrom ? userKey : undefined, characterName: char.name, - cls: char.class, + cls: char.class.key, nation: char.nation, pts: ptsArg, k, @@ -58,10 +63,10 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction): submittedByOfficer: isOfficer && !!nameArg, }); - const scoreEmoji = getEmoji("score") || "📊"; - const kdEmoji = getEmoji("kd") || "⚔️"; + const scoreEmoji = Emoji.get("score") || "📊"; + const kdEmoji = Emoji.get("kd") || "⚔️"; const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; - const kdNote = kills !== undefined && deaths !== undefined ? `\n${kdEmoji} ${kills}/${deaths}` : ""; + const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; const statsNote = [ atk !== undefined ? `ATK: ${atk}` : null, def !== undefined ? `DEF: ${def}` : null, diff --git a/src/subcommands/score/submitCore.ts b/src/subcommands/score/submitCore.ts index 78f6d21..8eee99c 100644 --- a/src/subcommands/score/submitCore.ts +++ b/src/subcommands/score/submitCore.ts @@ -1,4 +1,4 @@ -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { getEffectiveCharacter } from "@systems/borrow"; import { format } from "@format"; @@ -47,7 +47,7 @@ export namespace score { return { ok: false, message: `❌ Could not parse slot "${input.slot}".` }; } } else { - slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20; } await submitScore({ diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index 9437f6a..b686047 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -1,5 +1,5 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { resolveUser, hasOfficerRole } from "@systems/users"; import { getCharacterByName } from "@systems/characters"; import { Character } from "@systems/character"; @@ -27,7 +27,7 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string; export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise { const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" })); const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); diff --git a/src/subcommands/tg-config/set-layout.ts b/src/subcommands/tg-config/set-layout.ts index c2cd632..eb028a1 100644 --- a/src/subcommands/tg-config/set-layout.ts +++ b/src/subcommands/tg-config/set-layout.ts @@ -5,12 +5,17 @@ import { replyAndDelete } from "@utils"; import { hasOfficerRole } from "@systems/users"; export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise { + // discord.js types CommandInteractionOptionResolver with Omit<> which hides + // methods like getString/getInteger at the type level despite them existing at runtime + // Needs to be cast as any, since Discord.js has issues with the type + const options = interaction.options as any; + const member = await interaction.guild!.members.fetch(interaction.user.id); - if (!hasOfficerRole(member, Config.get("officerRoles"))) { + if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) { return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true); } - const name = interaction.options.getString("layout", true); + const name = options.getString("layout", true); if (!PollUI.setLayout(name)) { const available = PollUI.layouts() @@ -21,7 +26,7 @@ export async function handleSetLayout(interaction: ChatInputCommandInteraction): ); } - Config.set("pollLayout", name); + Config.set({ section: "poll", key: "layout", value: name }); return void replyAndDelete(interaction, `✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true @@ -29,7 +34,12 @@ export async function handleSetLayout(interaction: ChatInputCommandInteraction): } export async function autocompleteLayout(interaction: any): Promise { - const focused = interaction.options.getFocused().toLowerCase(); + // discord.js types CommandInteractionOptionResolver with Omit<> which hides + // methods like getString/getInteger at the type level despite them existing at runtime + // Needs to be cast as any, since Discord.js has issues with the type + const options = interaction.options as any; + + const focused = options.getFocused().toLowerCase(); const choices = PollUI.layouts() .filter((l) => l.name.includes(focused)) .map((l) => ({ name: `${l.name} — ${l.description}`, value: l.name })); diff --git a/src/systems/borrow.ts b/src/systems/borrow.ts index 70a26a7..9a2cc1d 100644 --- a/src/systems/borrow.ts +++ b/src/systems/borrow.ts @@ -1,7 +1,7 @@ import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; import path from "path"; import { BorrowRequest } from "@src/types"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { Char } from "@systems/characters"; import { Store } from "@systems/store"; import { Paths } from "@helpers/paths"; @@ -56,7 +56,8 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] { export function addPendingRequest(request: BorrowRequest): void { const key = requestKey(request.ownerKey, request.requesterKey); - const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0; + const expiry = Config.get({ section: "borrow", key: "requestExpiryMs" }); + pendingRequests.set(key, request); if (expiry > 0) { setTimeout(() => { diff --git a/src/systems/bringer.ts b/src/systems/bringer.ts index b3222a2..5194ac6 100644 --- a/src/systems/bringer.ts +++ b/src/systems/bringer.ts @@ -11,7 +11,7 @@ */ import { Nation, Character } from "@types"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { WRank, WRankWeek } from "@systems/wrank"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -63,7 +63,7 @@ * Called after weekly reset — the top ranked player with >= goal TGs becomes Bringer. */ update({ week }: { week?: WRankWeek }): void { - const goal = cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of [Nation.Capella, Nation.Procyon]) { const key = NATION_BRINGER_KEY[nation]; const _week = week ?? WRank.currentWeek(); diff --git a/src/systems/character.ts b/src/systems/character.ts index bb2c9a0..e6c3ff6 100644 --- a/src/systems/character.ts +++ b/src/systems/character.ts @@ -11,7 +11,7 @@ ActionRowBuilder } from "discord.js"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { setActiveCharacter, getCharacters } from "@systems/characters"; import { getEffectiveCharacter, @@ -158,7 +158,7 @@ else state.no.set(voteId, voteEntry); try { - const channel = await (interaction as any).client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await (interaction as any).client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot!); } catch {} } diff --git a/src/systems/config.ts b/src/systems/config.ts index 9be9519..5908506 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -3,171 +3,172 @@ import { Store } from "@systems/store"; import { Paths } from "@paths"; import { Runtime } from "@systems/runtime"; -// ─── Runtime ────────────────────────────────────────────────────────────────── +// ─── Runtime ───────────────────────────────────────────────────────────────── Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }); -// ─── Sub-interfaces ─────────────────────────────────────────────────────────── +// ─── Section interfaces (internal) ─────────────────────────────────────────── interface ChannelConfig { - pollChannelId: string; - resultsChannelId: string; - scoreChannelId: string; + poll: string; + results: string; + score: string; } interface RoleConfig { - officerRoles: string[]; - configRoles: string[]; - tagRoles: string[]; + officer: string[]; + config: string[]; + tag: string[]; } interface PollConfig { - pollLayout: string; - pollEphemeralEnabled: boolean; - commandEphemeralEnabled: boolean; - ephemeralDeleteMs: number; - lockAt: number; - lockMessage: string; - confirmYesMessage: string; - confirmNoMessage: string; - charDisplayFormat: string; - showClassInMessages: boolean; - showLevelInMessages: boolean; - showNoInNationField: boolean; - showNationTotalsInHeader: boolean; - autoVoteOnConflictSwitch: boolean; - reclaimNotifyBorrower: boolean; - conflictReclaimBehavior: string; - slots: TGSlot[]; - scoreWindowHours: number; - tgDurationMinutes: number; + layout: string; + ephemeralEnabled: boolean; + commandEphemeralEnabled: boolean; + ephemeralDeleteMs: number; + lockAt: number; + lockMessage: string; + confirmYes: string; + confirmNo: string; + charDisplayFormat: string; + showClassInMessages: boolean; + showLevelInMessages: boolean; + showNoInNationField: boolean; + showNationTotalsInHeader:boolean; + autoVoteOnConflict: boolean; + reclaimNotifyBorrower: boolean; + conflictReclaimBehavior: string; + slots: TGSlot[]; } interface WRankConfig { - wRankGoal: number; - wRankPostOnReset: boolean; - wRankYellowColor: string; - wRankGrayColor: string; - deltaUpColor: string; - deltaDownColor: string; + goal: number; + postOnReset: boolean; + yellowColor: string; + grayColor: string; + deltaUpColor: string; + deltaDownColor:string; } interface BringerConfig { - stormBringerColor: string; - luminousBringerColor: string; + stormColor: string; + luminousColor: string; } interface ImpersonateConfig { - impersonateResetOnPoll: boolean; - impersonateIndicator: boolean; + resetOnPoll: boolean; + indicator: boolean; } interface EmojiConfig { - activeCharEmoji: string; - emojiDonorGuilds: string[]; -} - -interface LoggingConfig { - logLevel: string; + activeChar: string; + donorGuilds: string[]; } interface NationConfig { - nationSource: Nation; + source: Nation; } interface BorrowConfig { - borrowRequestExpiryMs: number; + requestExpiryMs: number; } -// ─── BotConfig — merged, all fields optional ────────────────────────────────── +interface TGConfig { + scoreWindowHours: number; + durationMinutes: number; +} -export type BotConfig = Partial< - ChannelConfig & - RoleConfig & - PollConfig & - WRankConfig & - BringerConfig & - ImpersonateConfig & - EmojiConfig & - LoggingConfig & - NationConfig & - BorrowConfig ->; +// ─── Section map ────────────────────────────────────────────────────────────── + +export interface SectionMap { + channels: ChannelConfig; + roles: RoleConfig; + poll: PollConfig; + wrank: WRankConfig; + bringer: BringerConfig; + impersonate:ImpersonateConfig; + emoji: EmojiConfig; + nation: NationConfig; + borrow: BorrowConfig; + tg: TGConfig; +} + +export type ConfigSection = keyof SectionMap; // ─── Defaults ───────────────────────────────────────────────────────────────── -function getDefaults(): Required { +function getDefaults(): SectionMap { return { - // Channels - pollChannelId: "", - resultsChannelId: "", - scoreChannelId: "", - - // Roles - officerRoles: ["Ice King"], - configRoles: ["Ice King"], - tagRoles: ["Ice King", "Ice", "Rebellion"], - - // Poll - pollLayout: "default", - pollEphemeralEnabled: false, - commandEphemeralEnabled: true, - ephemeralDeleteMs: 0, - lockAt: 10, - lockMessage: "🔒 This poll has been locked.", - confirmYesMessage: "⚔️ TG is confirmed for tonight!", - confirmNoMessage: "❌ TG is cancelled for tonight.", - charDisplayFormat: "{wrank} {class} {level} {name}", - showClassInMessages: false, - showLevelInMessages: false, - showNoInNationField: false, - showNationTotalsInHeader: false, - autoVoteOnConflictSwitch: true, - reclaimNotifyBorrower: true, - conflictReclaimBehavior: "revert", - slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], - scoreWindowHours: 2, - tgDurationMinutes: 35, - - // W.Rank - wRankGoal: 7, - wRankPostOnReset: false, - wRankYellowColor: "#BA7517", - wRankGrayColor: "#888888", - deltaUpColor: "#A32D2D", - deltaDownColor: "#185FA5", - - // Bringer - stormBringerColor: "#185FA5", - luminousBringerColor: "#8B4CB8", - - // Impersonate - impersonateResetOnPoll: false, - impersonateIndicator: true, - - // Emoji - activeCharEmoji: "⭐", - emojiDonorGuilds: [], - - // Logging - logLevel: "info", - - // Nation - nationSource: Nation.Procyon, - - // Borrow - borrowRequestExpiryMs: 0, + channels: { + poll: "", + results: "", + score: "", + }, + roles: { + officer: ["Ice King"], + config: ["Ice King"], + tag: ["Ice King", "Ice", "Rebellion"], + }, + poll: { + layout: "default", + ephemeralEnabled: false, + commandEphemeralEnabled: true, + ephemeralDeleteMs: 0, + lockAt: 10, + lockMessage: "🔒 This poll has been locked.", + confirmYes: "⚔️ TG is confirmed for tonight!", + confirmNo: "❌ TG is cancelled for tonight.", + charDisplayFormat: "{wrank} {class} {level} {name}", + showClassInMessages: false, + showLevelInMessages: false, + showNoInNationField: false, + showNationTotalsInHeader:false, + autoVoteOnConflict: true, + reclaimNotifyBorrower: true, + conflictReclaimBehavior: "revert", + slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], + }, + wrank: { + goal: 7, + postOnReset: false, + yellowColor: "#BA7517", + grayColor: "#888888", + deltaUpColor: "#A32D2D", + deltaDownColor:"#185FA5", + }, + bringer: { + stormColor: "#185FA5", + luminousColor: "#8B4CB8", + }, + impersonate: { + resetOnPoll: false, + indicator: true, + }, + emoji: { + activeChar: "⭐", + donorGuilds: [], + }, + nation: { + source: Nation.Procyon, + }, + borrow: { + requestExpiryMs: 0, + }, + tg: { + scoreWindowHours: 2, + durationMinutes: 35, + }, }; } // ─── State ──────────────────────────────────────────────────────────────────── -let _cfg: BotConfig = {}; +let _cfg: Partial = {}; // ─── Config namespace ───────────────────────────────────────────────────────── export const Config = { load(): void { - _cfg = Store.readOrDefault(Paths.data("config.json"), {}); + _cfg = Store.readOrDefault>(Paths.data("config.json"), {}); }, save(): void { @@ -178,28 +179,41 @@ export const Config = { Config.load(); }, - get(key: K): Required[K] { - return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required[K]; + get( + { section, key }: { section: S; key: K } + ): SectionMap[S][K] { + const sectionData = _cfg[section] as Partial | undefined; + const value = sectionData?.[key]; + const def = getDefaults()[section][key]; + return (value !== undefined ? value : def) as SectionMap[S][K]; }, - set(key: K, value: BotConfig[K]): void { - _cfg[key] = value; + set( + { section, key, value }: { section: S; key: K; value: SectionMap[S][K] } + ): void { + if (!_cfg[section]) (_cfg[section] as any) = {}; + (_cfg[section] as any)[key] = value; Config.save(); }, - reset(key: K): void { - delete _cfg[key]; + reset( + { section, key }: { section: S; key: K } + ): void { + if (_cfg[section]) delete (_cfg[section] as any)[key]; Config.save(); }, - all(): Required { - return { ...getDefaults(), ..._cfg }; + /** Get an entire section (merged with defaults) */ + section(section: S): SectionMap[S] { + return { ...getDefaults()[section], ...(_cfg[section] ?? {}) } as SectionMap[S]; }, -}; -// ─── Legacy aliases — remove after full migration ──────────────────────────── -export const cfg = (key: K) => Config.get(key); -export const setCfg = (key: K, value: BotConfig[K]) => Config.set(key, value); -export const resetCfg = (key: K) => Config.reset(key); -export const loadConfig = () => Config.load(); -export const saveConfig = () => Config.save(); \ No newline at end of file + all(): SectionMap { + const defaults = getDefaults(); + const result = { ...defaults } as any; + for (const section of Object.keys(defaults) as ConfigSection[]) { + result[section] = { ...defaults[section], ...(_cfg[section] ?? {}) }; + } + return result as SectionMap; + }, +}; \ No newline at end of file diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts index be55b59..38e13ff 100644 --- a/src/systems/conflict.ts +++ b/src/systems/conflict.ts @@ -6,7 +6,6 @@ import { ButtonInteraction, TextChannel, } from "discord.js"; -import { cfg } from "@systems/config"; import { getCharacters, setActiveCharacter } from "@systems/characters"; import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; import { getImpersonation } from "@systems/impersonate"; @@ -22,8 +21,8 @@ import { Config } from "@systems/config"; // ─── Config ─────────────────────────────────────────────────────────────────── const RECLAIM_STYLE = ButtonStyle.Secondary; const SWITCH_STYLE = ButtonStyle.Secondary; -const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch"); -const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower"); +const AUTO_VOTE_ON_SWITCH = Config.get({ section: "poll", key: "autoVoteOnConflict" }); +const RECLAIM_NOTIFY_BORROWER = Config.get({ section: "poll", key: "reclaimNotifyBorrower" }); // ─── State ──────────────────────────────────────────────────────────────────── const pendingConflicts = new Map(); diff --git a/src/systems/poll.ts b/src/systems/poll.ts index 9b632e8..c51cf5f 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -7,15 +7,12 @@ import { GuildMember, } from "discord.js"; import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; -import { cfg } from "@systems/config"; import { Emoji } from "@systems/emojis"; import { Nations } from "@systems/nations"; -import { WRank } from "@systems/wrank"; import { format } from "@format"; import { persist } from "@systems/pollPersistence" import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow"; import { clearAllImpersonations } from "@systems/impersonate"; -import { Bringer } from "@systems/bringer"; import { Attendance } from "@systems/attendance"; import { PollUI } from "@ui/poll"; diff --git a/src/systems/scheduler.ts b/src/systems/scheduler.ts index 1077703..67c020e 100644 --- a/src/systems/scheduler.ts +++ b/src/systems/scheduler.ts @@ -9,7 +9,7 @@ import cron from "node-cron"; import { Client, TextChannel } from "discord.js"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { TGSlot } from "@types"; import { polls, updatePollMessage } from "@systems/poll"; import { WRank } from "@systems/wrank"; @@ -40,7 +40,7 @@ stopAll(); const tz = process.env.TZ ?? "Etc/GMT-2"; - const slots = cfg("slots").filter((s) => s.active); + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); for (const slot of slots) { // Poll open @@ -73,7 +73,7 @@ const state = polls.get(slot.tgHour); if (!state?.locked) return; try { - const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot.tgHour, undefined, false); console.log(`[Scheduler] Submit Score button removed for ${slot.tgHour}:00`); } catch (err) { diff --git a/src/systems/scheduler/index.ts b/src/systems/scheduler/index.ts index bcd2a50..d0e00db 100644 --- a/src/systems/scheduler/index.ts +++ b/src/systems/scheduler/index.ts @@ -11,7 +11,7 @@ import cron from "node-cron"; import { Client, TextChannel } from "discord.js"; import { ScheduledJob } from "./types"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { TGSlot } from "@types"; // Import all jobs @@ -47,7 +47,7 @@ stopAll(); const tz = process.env.TZ ?? "Etc/GMT-2"; - const slots = cfg("slots").filter((s) => s.active); + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); console.log(`[Scheduler] Weekly reset scheduled: "0 0 * * 1" in ${tz}`); diff --git a/src/systems/scheduler/midnight-cleanup.ts b/src/systems/scheduler/midnight-cleanup.ts index 0f63b61..5eedb83 100644 --- a/src/systems/scheduler/midnight-cleanup.ts +++ b/src/systems/scheduler/midnight-cleanup.ts @@ -1,7 +1,7 @@ import { Client, TextChannel } from "discord.js"; import { ScheduledJob } from "@scheduler/types"; import { polls, updatePollMessage } from "@systems/poll"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; export const job: ScheduledJob = { name: "midnight-cleanup", @@ -10,7 +10,7 @@ export const job: ScheduledJob = { for (const [slot, state] of polls.entries()) { if (!state?.locked) continue; try { - const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + const channel = await client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel; await updatePollMessage(channel, slot, undefined, false); console.log(`[Scheduler] Submit Score button removed for ${slot}:00`); } catch {} diff --git a/src/systems/scores.ts b/src/systems/scores.ts index 0be3927..a728743 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -1,5 +1,5 @@ import { TGScore, Nation, ClassKey } from "../types"; -import { cfg } from "./config"; +import { Config } from "./config"; import { upsertScore, todayString } from "./history"; import { recordScore } from "./wrank"; @@ -33,9 +33,9 @@ export function normalizeSlot(input: string): number | null { // Detect which slot a submission belongs to based on current time export function detectSlot(): number | null { - const slots = cfg("slots").filter((s) => s.active); - const windowMs = cfg("scoreWindowHours") * 60 * 60 * 1000; - const durationMs = cfg("tgDurationMinutes") * 60 * 1000; + const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active); + const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000; + const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000; const now = Date.now(); for (const slot of slots) { diff --git a/src/systems/slots.ts b/src/systems/slots.ts index 0d4389c..f6dfc36 100644 --- a/src/systems/slots.ts +++ b/src/systems/slots.ts @@ -1,6 +1,6 @@ import cron from "node-cron"; import { Client, TextChannel } from "discord.js"; -import { cfg } from "./config"; +import { Config } from "./config"; import { TGSlot } from "../types"; import { polls, updatePollMessage } from "@systems/poll"; diff --git a/src/systems/tg.ts b/src/systems/tg.ts index fbc34fb..70bcd44 100644 --- a/src/systems/tg.ts +++ b/src/systems/tg.ts @@ -18,7 +18,8 @@ import { Score, TGScore, WeeklySummary } from "@systems/score"; import { Attendance } from "@systems/attendance"; import { Nations } from "@systems/nations"; - + import { Config } from "@systems/config"; + export const TG = { // ── Week ────────────────────────────────────────────────────────────────── @@ -40,7 +41,7 @@ const newWeek = WRank.currentWeek(); // ensures new week exists if (prevWeek) { - const goal = (require("@systems/config") as any).cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of [Nation.Capella, Nation.Procyon]) { const key = Nations.key(nation); const entries = prevWeek.entries[key]; diff --git a/src/systems/users.ts b/src/systems/users.ts index 2102b0a..14821dd 100644 --- a/src/systems/users.ts +++ b/src/systems/users.ts @@ -3,6 +3,7 @@ import { getImpersonation } from "@systems/impersonate"; import { ResolvedUser } from "@src/types"; import { getUsermapEntry, getUsermapEntryById } from "@systems/messages"; import { getActiveCharacter } from "@systems/characters"; +import { Discord } from "@discord"; // Resolves a full user context from a GuildMember + discord username export async function resolveUser(member: GuildMember): Promise { @@ -46,4 +47,11 @@ export function resolveByUsermapKey(key: string): { userKey: string; activeChara export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { return member.roles.cache.some((r) => officerRoles.includes(r.name)); -} \ No newline at end of file +} + +export const User = { + hasOfficerRole({member, officerRoles}: { member: GuildMember, officerRoles: string[] }): boolean { + return Discord.Guild.hasRole({ member: member, roles: officerRoles }); + return member.roles.cache.some((r) => officerRoles.includes(r.name)); + }, +}; \ No newline at end of file diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 0929efc..ef474ad 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -1,7 +1,7 @@ import fs from "fs"; import path from "path"; import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types"; -import { cfg } from "@systems/config"; +import { Config } from "@systems/config"; import { Bringer } from "@systems/bringer"; import { Nations } from "@systems/nations"; import { Store } from "@systems/store"; @@ -147,7 +147,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void { } function updateBringer(week: WRankWeek): void { - const goal = cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); for (const nation of ["capella", "procyon"] as const) { // Don't overwrite manual override if (nation === "capella" && week.bringer.capellaOverride) continue; diff --git a/src/ui/poll/index.ts b/src/ui/poll/index.ts index d253ca5..ef06175 100644 --- a/src/ui/poll/index.ts +++ b/src/ui/poll/index.ts @@ -51,7 +51,7 @@ export function discoverLayouts(): void { } function restoreLayout() { - const savedLayout = Config.get("pollLayout"); + const savedLayout = Config.get({ section: "poll", key: "layout" }); if (savedLayout && _layouts.has(savedLayout)) { _activeLayout = _layouts.get(savedLayout)!; diff --git a/src/ui/poll/layouts/default.ts b/src/ui/poll/layouts/default.ts index 106f0b2..28468bb 100644 --- a/src/ui/poll/layouts/default.ts +++ b/src/ui/poll/layouts/default.ts @@ -5,7 +5,7 @@ import { EmbedBuilder } from "discord.js"; import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; import { Emoji } from "@systems/emojis"; @@ -19,7 +19,7 @@ context: PollRowContext ): string { if (wRankEntry) { - const goal = cfg("wRankGoal"); + const goal = Config.get({ section: "wrank", key: "goal" }); return format.wrank.full(wRankEntry, { goal, brackets: true }); } if (!context.nationHasRank) return ""; @@ -28,7 +28,7 @@ } function formatRow(entry: VoteEntry, context: PollRowContext): string { - const cfgFormat = cfg("charDisplayFormat"); + const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); const nation = entry.characterNation; const wRankEntry = entry.characterName && entry.characterNation @@ -138,9 +138,9 @@ } function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string { - if (state.confirmed === "yes") return cfg("confirmYesMessage"); - if (state.confirmed === "no") return cfg("confirmNoMessage"); - if (state.locked) return overrideLockMsg ?? cfg("lockMessage"); + if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" }); + if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" }); + if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); return `❌ ${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; } @@ -151,7 +151,7 @@ }; const noVoters: VoteEntry[] = []; const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; - const showNoInline = (cfg as any)("showNoInNationField") ?? false; + const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" }); for (const entry of state.yes.values()) { const nation = entry.characterNation ?? Nation.Capella; diff --git a/src/ui/poll/layouts/side-by-side.ts b/src/ui/poll/layouts/side-by-side.ts index bfe94b0..1102a84 100644 --- a/src/ui/poll/layouts/side-by-side.ts +++ b/src/ui/poll/layouts/side-by-side.ts @@ -5,7 +5,7 @@ import { EmbedBuilder } from "discord.js"; import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; - import { cfg } from "@systems/config"; + import { Config } from "@systems/config"; import { WRank } from "@systems/wrank"; import { Bringer } from "@systems/bringer"; import { Emoji } from "@systems/emojis"; @@ -16,7 +16,7 @@ function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { if (wRankEntry) { - return format.wrank.full(wRankEntry, { goal: cfg("wRankGoal"), brackets: true }); + return format.wrank.full(wRankEntry, { goal: Config.get({ section: "wrank", key: "goal" }), brackets: true }); } if (!context.nationHasRank) return ""; if (context.nationHasDelta) return format.wrank.noRank(); @@ -24,7 +24,7 @@ } function formatRow(entry: VoteEntry, context: PollRowContext): string { - const cfgFormat = cfg("charDisplayFormat"); + const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" }); const nation = entry.characterNation; const wRankEntry = entry.characterName && entry.characterNation ? WRank.entry(entry.characterName, entry.characterNation) @@ -103,7 +103,7 @@ }; const noVoters: VoteEntry[] = []; const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; - const showNoInline = (cfg as any)("showNoInNationField") ?? false; + const showNoInline = Config.get({ section: "poll", key: "showNoInNationField" }); for (const entry of state.yes.values()) { const nation = entry.characterNation ?? Nation.Capella; @@ -158,9 +158,9 @@ // Footer let footer: string; - if (state.confirmed === "yes") footer = cfg("confirmYesMessage"); - else if (state.confirmed === "no") footer = cfg("confirmNoMessage"); - else if (state.locked) footer = options?.overrideLockMsg ?? cfg("lockMessage"); + if (state.confirmed === "yes") footer = Config.get({ section: "poll", key: "confirmYes" }); + else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" }); + else if (state.locked) footer = options?.overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" }); else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; embed.setFooter({ text: footer }); diff --git a/src/utils.ts b/src/utils.ts index e2e0520..03620c3 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,10 +2,10 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; import { Config } from "@systems/config"; // Poll vote confirmation messages (Yes/No button responses) -const POLL_EPHEMERAL_ENABLED = Config.get("pollEphemeralEnabled"); +const POLL_EPHEMERAL_ENABLED = Config.get({ section: "poll", key: "ephemeralEnabled" }); // Command output messages (score, rank, status etc.) — always on by default -const COMMAND_EPHEMERAL_ENABLED = Config.get("commandEphemeralEnabled"); -const EPHEMERAL_DELETE_MS = Config.get("ephemeralDeleteMs"); +const COMMAND_EPHEMERAL_ENABLED = Config.get({ section: "poll", key: "commandEphemeralEnabled" }); +const EPHEMERAL_DELETE_MS = Config.get({ section: "poll", key: "ephemeralDeleteMs" }); // For poll button responses export async function pollReplyAndDelete( diff --git a/tsconfig.json b/tsconfig.json index 1b0c580..bc8b761 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,9 @@ "@ui": ["src/ui/index"], "@ui/*": ["src/ui/*"], "@ui/poll": ["src/ui/poll/index"], - "@ui/types": ["src/ui/types"] + "@ui/types": ["src/ui/types"], + "@discord": ["src/discord/index"], + "@discord/*": ["src/discord/*"] } }, "include": ["src/**/*", "scripts/**/*"],