feat: nested Config system with section/key access pattern, Discord API abstraction start

This commit is contained in:
Nuno Duque Nunes 2026-06-11 02:42:30 +01:00
parent 1911cbe225
commit 17ff1d932f
61 changed files with 591 additions and 359 deletions

View file

@ -4,7 +4,7 @@ import {
REST, REST,
Routes, Routes,
} from "discord.js"; } from "discord.js";
import { cfg } from "../systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "../systems/users"; import { hasOfficerRole } from "../systems/users";
// Poll subcommands // Poll subcommands
@ -281,7 +281,7 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
const group = interaction.options.getSubcommandGroup(false); const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 // Officer-only commands
const officerOnlyGroups = ["poll", "result", "bringer"]; const officerOnlyGroups = ["poll", "result", "bringer"];

View file

@ -1,10 +1,16 @@
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; 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 { hasOfficerRole } from "../systems/users";
import { replyAndDelete } from "../utils"; import { replyAndDelete } from "../utils";
import { Nation } from "@types"; import { Nation } from "@types";
import { handleSetLayout } from "@subcommands/tg-config/set-layout"; 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 { export function buildTgConfigCommand(): SlashCommandBuilder {
const cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
.setName("tg-config") .setName("tg-config")
@ -121,50 +127,58 @@ export function buildTgConfigCommand(): SlashCommandBuilder {
} }
export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise<void> {
// 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); 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."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }
const group = interaction.options.getSubcommandGroup(true); const group = options.getSubcommandGroup(true);
const sub = interaction.options.getSubcommand(); const sub = options.getSubcommand();
const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => {
const key = ROLE_KEY_MAP[cfgKey];
if (action === "set") { if (action === "set") {
const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean); const roles = options.getString("roles", true).split(",").map((r: string) => r.trim()).filter(Boolean);
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`);
} }
if (action === "add") { if (action === "add") {
const role = interaction.options.getString("role", true).trim(); const role = options.getString("role", true).trim();
const roles = [...new Set([...cfg(cfgKey), role])]; const roles = [...new Set([...Config.get({ section: "roles", key }), role])];
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`);
} }
if (action === "remove") { if (action === "remove") {
const role = interaction.options.getString("role", true).trim(); const role = options.getString("role", true).trim();
const roles = cfg(cfgKey).filter((r: string) => r !== role); const roles = Config.get({ section: "roles", key }).filter((r: string) => r !== role);
setCfg(cfgKey, roles); Config.set({ section: "roles", key, value: roles });
return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`); return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`);
} }
if (action === "reset") { if (action === "reset") {
resetCfg(cfgKey); Config.reset({ section: "roles", key });
return void replyAndDelete(interaction, `✅ Roles reset to default.`); return void replyAndDelete(interaction, `✅ Roles reset to default.`);
} }
}; };
// ── message ──────────────────────────────────────────────────────────────── // ── message ────────────────────────────────────────────────────────────────
if (group === "message") { if (group === "message") {
if (sub === "set-lock") { setCfg("lockMessage", interaction.options.getString("message", true)); return void replyAndDelete(interaction, "✅ Lock message updated."); } 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") { resetCfg("lockMessage"); return void replyAndDelete(interaction, "✅ Lock message reset."); } if (sub === "reset-lock") { Config.reset({ section: "poll", key: "lockMessage" }); return void replyAndDelete(interaction, "✅ Lock message reset."); }
if (sub === "set-confirm") { if (sub === "set-confirm") {
const d = interaction.options.getString("decision", true); const d = options.getString("decision", true);
setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", 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.`); return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`);
} }
if (sub === "reset-confirm") { if (sub === "reset-confirm") {
const d = interaction.options.getString("decision", true); const d = options.getString("decision", true);
resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage"); const key = d === "yes" ? "confirmYes" : "confirmNo";
Config.reset({ section: "poll", key });
return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`); return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`);
} }
} }
@ -187,42 +201,42 @@ export async function handleTgConfigCommand(interaction: ChatInputCommandInterac
// ── channel ──────────────────────────────────────────────────────────────── // ── channel ────────────────────────────────────────────────────────────────
if (group === "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-poll") { Config.set({ section: "channels", key: "poll", value: options.getChannel().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-results") { Config.set({ section: "channels", key: "results", value: options.getChannel().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-score") { Config.set({ section: "channels", key: "score", value: options.getChannel().id }); return void replyAndDelete(interaction, "✅ Score channel updated."); }
} }
// ── slot ─────────────────────────────────────────────────────────────────── // ── slot ───────────────────────────────────────────────────────────────────
if (group === "slot") { if (group === "slot") {
if (sub === "add") { if (sub === "add") {
const hour = interaction.options.getInteger("hour", true); const hour = options.getInteger("hour", true);
const pollOpens = interaction.options.getString("poll_opens", true); const pollOpens = options.getString("poll_opens", true);
const slots = cfg("slots"); const slots = Config.get({ section: "poll", key: "slots" });
if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`); 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 }); slots.push({ tgHour: hour, pollOpens, closesAfter: Config.get({ section: "tg", key: "durationMinutes" }), active: true });
setCfg("slots", slots); Config.set({ section: "poll", key: "slots", value: slots });
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`);
} }
if (sub === "remove") { if (sub === "remove") {
const hour = interaction.options.getInteger("hour", true); const hour = options.getInteger("hour", true);
const slots = cfg("slots").filter((s) => s.tgHour !== hour); const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.tgHour !== hour);
setCfg("slots", slots); Config.set({ section: "poll", key: "slots", value: slots });
return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`); return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`);
} }
} }
// ── wrank ────────────────────────────────────────────────────────────────── // ── wrank ──────────────────────────────────────────────────────────────────
if (group === "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-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") { setCfg("wRankPostOnReset", interaction.options.getBoolean("enabled", true)); return void replyAndDelete(interaction, "✅ W.Rank post on reset 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 ───────────────────────────────────────────────────────────────────── // ── tg ─────────────────────────────────────────────────────────────────────
if (group === "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-score-window") { Config.set({ section: "tg", key: "scoreWindowHours", value: 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-duration") { Config.set({ section: "tg", key: "durationMinutes", value: 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-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"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source 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") { if (group === "poll") {

40
src/discord/channel.ts Normal file
View file

@ -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<TextChannel> {
return client.channels.fetch(id) as Promise<TextChannel>;
}
async function send({ channel, content, embeds, components }: {
channel: TextChannel;
content?: string;
embeds?: any[];
components?: any[];
}): Promise<void> {
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<void> {
const msg = await channel.messages.fetch(messageId);
await msg.edit({ content, embeds, components } as MessageEditOptions);
}
export const Channel = {
fetch,
send,
edit,
};

29
src/discord/guild.ts Normal file
View file

@ -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<GuildMember> {
return interaction.guild!.members.fetch(interaction.user.id);
}
async function fetchMember({ interaction, userId }: { interaction: AnyInteraction; userId: string }): Promise<GuildMember> {
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,
};

25
src/discord/index.ts Normal file
View file

@ -0,0 +1,25 @@
/**
* Discord abstraction layer over Discord.js API.
*
* Usage:
* import { Discord } from "@discord";
*
* Discord.Interaction.options<ChatInputCommandInteraction>(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,
};

View file

@ -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<T extends ChatInputCommandInteraction>(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<void> {
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<void> {
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,
};

View file

@ -1,7 +1,7 @@
import { AutocompleteInteraction } from "discord.js"; import { AutocompleteInteraction } from "discord.js";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { getCharacters } from "@systems/characters"; import { getCharacters } from "@systems/characters";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { CharacterRegistry } from "@registry/character-registry"; import { CharacterRegistry } from "@registry/character-registry";
import { UserRegistry } from "@registry/user-registry"; import { UserRegistry } from "@registry/user-registry";
@ -99,7 +99,7 @@ async function autocompleteSlots(
interaction: AutocompleteInteraction, interaction: AutocompleteInteraction,
focused: string focused: string
): Promise<void> { ): Promise<void> {
const slots = cfg("slots") const slots = Config.get({ section: "poll", key: "slots" })
.filter((s) => s.active) .filter((s) => s.active)
.map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) })) .map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) }))
.filter((s) => s.name.includes(focused)); .filter((s) => s.name.includes(focused));

View file

@ -5,7 +5,6 @@ import {
ActionRowBuilder, ActionRowBuilder,
TextChannel TextChannel
} from "discord.js"; } from "discord.js";
import { cfg } from "@systems/config";
import { pollReplyAndDelete } from "../utils"; import { pollReplyAndDelete } from "../utils";
import { resolveUser } from "@systems/users"; import { resolveUser } from "@systems/users";
import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveMessage, nowFormatted } from "@systems/messages";
@ -27,7 +26,7 @@ import { Benchmark } from "@systems/benchmark";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
const LOCK_AT = Config.get("lockAt"); const LOCK_AT = Config.get({ section: "poll", key: "lockAt" });
const clickCounts = new Map<string, { yes: number; no: number }>(); const clickCounts = new Map<string, { yes: number; no: number }>();
const _processingVotes = new Set<string>(); const _processingVotes = new Set<string>();
@ -78,7 +77,7 @@ async function handleCharacterConflict(
} }
const slot = [...polls.keys()][0]; 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 { buildCharSelectButtons } = require("@systems/charSelect");
const buttons = buildCharSelectButtons(userKey ?? "", { const buttons = buildCharSelectButtons(userKey ?? "", {
@ -245,7 +244,7 @@ export async function showActiveCharSwitching(interaction: ButtonInteraction): P
const { char, borrowedFrom } = getEffectiveCharacter(user.userKey); const { char, borrowedFrom } = getEffectiveCharacter(user.userKey);
bench.mark("getEffectiveCharacter"); bench.mark("getEffectiveCharacter");
if (char) { if (char) {
const starEmoji = Config.get("activeCharEmoji"); const starEmoji = Config.get({ section: "emoji", key: "activeChar" });
const borrowNote = borrowedFrom ? ` 🔗` : ""; const borrowNote = borrowedFrom ? ` 🔗` : "";
const buttons = buildCharSelectButtons(user.userKey, { const buttons = buildCharSelectButtons(user.userKey, {
customIdPrefix: `companion_switch:${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 // // Build slot selector — all valid slots, with the active TG pre-selected
// const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; // const validSlots = Config.get({ section: "poll", key: "slots" }).map((s) => s.tgHour) as number[];
// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; // const activeSlot = slot ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
// const select = new StringSelectMenuBuilder() // const select = new StringSelectMenuBuilder()
// .setCustomId(`score_slot_select:${user.userKey}`) // .setCustomId(`score_slot_select:${user.userKey}`)

View file

@ -1,9 +1,5 @@
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
import { loadConfig, cfg } from "@systems/config"; import { Config } from "@systems/config";
import { loadMessages } from "@systems/messages";
import { Emoji } from "@systems/emojis";
import { Char } from "@systems/characters";
import { WRank } from "@systems/wrank";
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
import { handleInteraction } from "@handlers/interactions"; import { handleInteraction } from "@handlers/interactions";
import { buildTgCommand } from "@commands/tg"; import { buildTgCommand } from "@commands/tg";
@ -35,7 +31,8 @@ async function registerCommands(): Promise<void> {
} }
async function onPollOpen(slot: TGSlot): Promise<void> { async function onPollOpen(slot: TGSlot): Promise<void> {
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."); if (!channel) return console.error("Poll channel not found.");
await postPoll(channel, slot); await postPoll(channel, slot);
} }
@ -47,7 +44,8 @@ async function onPollLock(slot: TGSlot): Promise<void> {
lockPoll(slot.tgHour); 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; if (!channel) return;
// Buttons disabled, no submit button yet — that comes at close // Buttons disabled, no submit button yet — that comes at close
@ -60,7 +58,8 @@ async function onPollClose(slot: TGSlot): Promise<void> {
const state = polls.get(slot.tgHour); const state = polls.get(slot.tgHour);
if (!state) return; 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; if (!channel) return;
await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true 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); for (const [slot, state] of restored) polls.set(slot, state);
// Re-render all restored poll messages // 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()) { for (const slot of polls.keys()) {
const state = polls.get(slot)!; const state = polls.get(slot)!;
await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null);

View file

@ -11,8 +11,8 @@ cron.schedule("0 20 * * *", async () => {
lockPoll(slot); lockPoll(slot);
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, cfg("lockMessage")); await updatePollMessage(channel, slot, Config.get({ section: "poll", key: "lockMessage" }));
console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`); console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`);
}); });
@ -21,7 +21,7 @@ cron.schedule("35 20 * * *", async () => {
const state = polls.get(slot); const state = polls.get(slot);
if (!state) return; 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 await updatePollMessage(channel, slot, undefined, true); // showSubmit = true
console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`); 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 ───────────────────────────────────────────────────── // ─── NOTE on future slots ─────────────────────────────────────────────────────
// //
// Right now only slot 20 has an active poll. When we add more votable 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. // dynamically, or make the cron time configurable in config.json.

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages"; import { getUsermapEntryById, setUsermapEntry, removeUsermapEntry } from "@systems/messages";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -9,7 +9,7 @@ import { getEffectiveCharacter } from "@systems/borrow";
export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleAdminUserMap(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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."); 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<void> { export async function handleAdminPollFixVoter(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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."); 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.yes);
updateEntry(state.no); 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); await updatePollMessage(channel, slot);
const { format } = require("@format"); const { format } = require("@format");
@ -147,7 +147,7 @@ export async function handleAdminPollFixVoter(interaction: ChatInputCommandInter
export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleAdminPollShowEntry(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { getCharacterByName } from "@systems/characters"; import { getCharacterByName } from "@systems/characters";
import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow"; import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
@ -58,7 +58,7 @@ async function acceptBorrow(
} }
} }
try { 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); await updatePollMessage(channel, slot);
} catch {} } catch {}
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; 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 group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id); 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) { if (nameArg && !isOfficer) {
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { addCharacter } from "../../systems/characters"; import { addCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -7,7 +7,7 @@ import { ClassKey, Nation } from "../../types";
export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharAdd(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);
const cls = interaction.options.getString("class", true) as ClassKey; const cls = interaction.options.getString("class", true) as ClassKey;

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; 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<void> { export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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); const requester = await resolveUser(member);
// Args: owner, charname, [username] (officer only — grants directly) // Args: owner, charname, [username] (officer only — grants directly)
@ -40,7 +40,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
if (slot !== undefined) { if (slot !== undefined) {
const state = polls.get(slot)!; 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 // Find the voter entry and update their character
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === requesterKey) { 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.`); 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( await sendBorrowRequestDM(
interaction.client, interaction.client,
ownerMember.user.id, ownerMember.user.id,

View file

@ -1,12 +1,12 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { removeCharacter } from "../../systems/characters"; import { removeCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharRemove(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setActiveCharacter } from "../../systems/characters"; import { setActiveCharacter } from "../../systems/characters";
import { setSessionBorrow } from "../../systems/borrow"; import { setSessionBorrow } from "../../systems/borrow";
@ -25,7 +25,7 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetActive(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setCharacterNation, getActiveCharacter } from "../../systems/characters"; import { setCharacterNation, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -7,7 +7,7 @@ import { Nation } from "../../types";
export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharSetNation(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const nation = interaction.options.getString("nation", true) as Nation; const nation = interaction.options.getString("nation", true) as Nation;
const charName = interaction.options.getString("char_name"); // optional, defaults to active const charName = interaction.options.getString("char_name"); // optional, defaults to active

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setCharacterStats, getActiveCharacter } from "../../systems/characters"; import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; 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); return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true);
// const member = await interaction.guild!.members.fetch(interaction.user.id); // 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 nameArg = interaction.options.getString("name");
// const charName = interaction.options.getString("char_name"); // const charName = interaction.options.getString("char_name");
// const atk = interaction.options.getInteger("atk") ?? undefined; // const atk = interaction.options.getInteger("atk") ?? undefined;

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName } from "../../systems/characters"; import { getCharacterByName } from "../../systems/characters";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -19,7 +19,7 @@ function loadRawChars(): any {
export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 user = await resolveUser(member);
const ownerArg = interaction.options.getString("owner"); const ownerArg = interaction.options.getString("owner");
@ -54,7 +54,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 user = await resolveUser(member);
const ownerArg = interaction.options.getString("owner"); const ownerArg = interaction.options.getString("owner");

View file

@ -6,7 +6,7 @@ import {
ActionRowBuilder, ActionRowBuilder,
EmbedBuilder, EmbedBuilder,
} from "discord.js"; } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate"; import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -70,7 +70,7 @@ function buildImpersonateButtons(
// Slash command handler // Slash command handler
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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."); return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
} }

View file

@ -1,6 +1,6 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg, setCfg, resetCfg } from "../../systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { Nation } from "../../types"; import { Nation } from "../../types";
@ -16,15 +16,16 @@ export async function handleConfirm(interaction: ChatInputCommandInteraction): P
state.confirmed = decision; state.confirmed = decision;
state.locked = true; 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) { if (oneTimeMsg) {
const cfgKey = decision === "yes" ? "confirmYesMessage" : "confirmNoMessage"; const isYes = decision === "yes";
const saved = cfg(cfgKey); const key = isYes ? "confirmYes" : "confirmNo";
setCfg(cfgKey, oneTimeMsg); const saved = Config.get({ section: "poll", key });
Config.set({ section: "poll", key, value: oneTimeMsg });
await updatePollMessage(channel, slot); await updatePollMessage(channel, slot);
if (saved !== undefined) setCfg(cfgKey, saved); Config.set({ section: "poll", key, value: saved });
else resetCfg(cfgKey);
} else { } else {
await updatePollMessage(channel, slot); await updatePollMessage(channel, slot);
} }
@ -34,7 +35,7 @@ export async function handleConfirm(interaction: ChatInputCommandInteraction): P
} }
async function tagRoles(channel: TextChannel, interaction: ChatInputCommandInteraction): Promise<void> { async function tagRoles(channel: TextChannel, interaction: ChatInputCommandInteraction): Promise<void> {
const roles = cfg("tagRoles"); const roles = Config.get({ section: "roles", key: "tag" });
const guild = interaction.guild!; const guild = interaction.guild!;
await guild.roles.fetch(); await guild.roles.fetch();
const mentions = roles const mentions = roles

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
import { getEffectiveCharacter } from "../../systems/borrow"; import { getEffectiveCharacter } from "../../systems/borrow";
import { nowFormatted, resolveMessage } from "../../systems/messages"; import { nowFormatted, resolveMessage } from "../../systems/messages";
@ -50,7 +50,7 @@ const entry: VoteEntry = {
state.no.set(syntheticId, entry); 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); await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`); 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}**.`); 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); await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`); return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`);
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { polls, lockPoll, updatePollMessage } from "@systems/poll"; import { polls, lockPoll, updatePollMessage } from "@systems/poll";
import { replyAndDelete } from "@utils"; 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 // Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron
lockPoll(slot); 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) { if (simulateClose) {
// Simulate TG end: show Submit Score button (same path as onPollClose cron) // Simulate TG end: show Submit Score button (same path as onPollClose cron)

View file

@ -1,10 +1,10 @@
import { ChatInputCommandInteraction, TextChannel, Collection, Message } from "discord.js"; import { ChatInputCommandInteraction, TextChannel, Collection, Message } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { polls } from "../../systems/poll"; import { polls } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
export async function handlePurge(interaction: ChatInputCommandInteraction): Promise<void> { export async function handlePurge(interaction: ChatInputCommandInteraction): Promise<void> {
const channelId = cfg("pollChannelId"); const channelId = Config.get({ section: "channels", key: "poll" });
const channel = await interaction.client.channels.fetch(channelId) as TextChannel; const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found."); if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found.");

View file

@ -3,12 +3,11 @@ import { loadMessages } from "@systems/messages";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters"; import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank"; import { loadWRank } from "@systems/wrank";
import { loadConfig, cfg } from "@systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence"; import { persist } from "@systems/pollPersistence";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
import { CharacterRegistry } from "@root/src/systems/registry/character-registry"; 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"; import { UserRegistry } from "@root/src/systems/registry/user-registry";
const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const; 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; 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("messages")) { loadMessages(); reloaded.push("messages"); }
if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); } if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); }
if (should("characters")) { loadCharacters(); reloaded.push("characters"); } 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 // Re-render active poll message(s) so embed reflects reloaded data
if (should("poll") || should("emojis") || should("all")) { if (should("poll") || should("emojis") || should("all")) {
const channelId = cfg("pollChannelId"); const channelId = Config.get({ section: "channels", key: "poll" });
if (channelId) { if (channelId) {
try { try {
// Restore from disk first if reloading poll specifically // Restore from disk first if reloading poll specifically

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { nowFormatted, resolveMessage } from "@systems/messages"; import { nowFormatted, resolveMessage } from "@systems/messages";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
@ -53,7 +53,7 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
injected++; 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); await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ Seeded **${injected}** player(s)${skipped > 0 ? `, skipped **${skipped}** (no active character)` : ""}.`); return void replyAndDelete(interaction, `✅ Seeded **${injected}** player(s)${skipped > 0 ? `, skipped **${skipped}** (no active character)` : ""}.`);
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll"; import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
import { hasOfficerRole } from "../../systems/users"; import { hasOfficerRole } from "../../systems/users";

View file

@ -1,5 +1,4 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config";
import { postPoll } from "../../systems/poll"; import { postPoll } from "../../systems/poll";
import { resetClickCounts } from "../../handlers/buttons"; import { resetClickCounts } from "../../handlers/buttons";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -8,7 +7,7 @@ import { Config } from "@systems/config";
export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleStart(interaction: ChatInputCommandInteraction): Promise<void> {
const slotArg = interaction.options.getString("slot"); 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; let slot: TGSlot | undefined;
if (slotArg) { if (slotArg) {
@ -20,7 +19,7 @@ export async function handleStart(interaction: ChatInputCommandInteraction): Pro
} }
if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured."); 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); console.log("pollChannelId:", channelId);
const channel = await interaction.client.channels.fetch(channelId) as TextChannel; const channel = await interaction.client.channels.fetch(channelId) as TextChannel;

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { polls } from "../../systems/poll"; import { polls } from "../../systems/poll";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -9,19 +9,19 @@ export async function handleStatus(interaction: ChatInputCommandInteraction): Pr
).join("\n") || "None"; ).join("\n") || "None";
const status = [ const status = [
`**Officer roles:** ${cfg("officerRoles").join(", ")}`, `**Officer roles:** ${Config.get({ section: "roles", key: "officer" }).join(", ")}`,
`**Config roles:** ${cfg("configRoles").join(", ")}`, `**Config roles:** ${Config.get({ section: "roles", key: "config" }).join(", ")}`,
`**Tag roles:** ${cfg("tagRoles").join(", ")}`, `**Tag roles:** ${Config.get({ section: "roles", key: "tag" }).join(", ")}`,
`**Lock message:** ${cfg("lockMessage")}`, `**Lock message:** ${Config.get({ section: "poll", key: "lockMessage" })}`,
`**Confirm yes:** ${cfg("confirmYesMessage")}`, `**Confirm yes:** ${Config.get({ section: "poll", key: "confirmYes" })}`,
`**Confirm no:** ${cfg("confirmNoMessage")}`, `**Confirm no:** ${Config.get({ section: "poll", key: "confirmNo" })}`,
`**Poll channel:** ${cfg("pollChannelId") || "not set"}`, `**Poll channel:** ${Config.get({ section: "channels", key: "poll" }) || "not set"}`,
`**Results channel:** ${cfg("resultsChannelId") || "not set"}`, `**Results channel:** ${Config.get({ section: "channels", key: "results" }) || "not set"}`,
`**Score channel:** ${cfg("scoreChannelId") || "not set"}`, `**Score channel:** ${Config.get({ section: "channels", key: "score" }) || "not set"}`,
`**Score window:** ${cfg("scoreWindowHours")}h`, `**Score window:** ${Config.get({ section: "tg", key: "scoreWindowHours" })}h`,
`**TG duration:** ${cfg("tgDurationMinutes")}min`, `**TG duration:** ${Config.get({ section: "tg", key: "durationMinutes" })}min`,
`**Nation source:** ${cfg("nationSource")}`, `**Nation source:** ${Config.get({ section: "nation", key: "source" })}`,
`**W.Rank goal:** ${cfg("wRankGoal")} TGs`, `**W.Rank goal:** ${Config.get({ section: "wrank", key: "goal" })} TGs`,
`**Timezone:** ${process.env.TZ ?? "Etc/GMT-2"}`, `**Timezone:** ${process.env.TZ ?? "Etc/GMT-2"}`,
`**Active polls:**\n${activePolls}`, `**Active polls:**\n${activePolls}`,
].join("\n"); ].join("\n");

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll"; import { polls, updatePollMessage } from "../../systems/poll";
import { loadMessages } from "../../systems/messages"; import { loadMessages } from "../../systems/messages";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -14,7 +14,7 @@ export async function handleUnlock(interaction: ChatInputCommandInteraction): Pr
state.locked = false; state.locked = false;
loadMessages(); 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); await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, "🔓 Poll unlocked!"); return void replyAndDelete(interaction, "🔓 Poll unlocked!");
} }

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
@ -9,7 +9,7 @@ import { TG } from "@systems/tg";
export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleRankGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
if (nameArg && !isOfficer) { 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."); if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
const week = TG.currentWeek(); const week = TG.currentWeek();
const goal = cfg("wRankGoal"); const goal = Config.get({ section: "wrank", key: "goal" });
const weekKey = WRank.weekKey(); const weekKey = WRank.weekKey();
for (const nation of ["capella", "procyon"] as const) { for (const nation of ["capella", "procyon"] as const) {

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { getEmoji } from "@systems/emojis"; import { getEmoji } from "@systems/emojis";
import { replyAndDelete } from "@utils"; import { replyAndDelete } from "@utils";
@ -12,7 +12,7 @@ import { TG } from "@systems/tg";
export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
const week = TG.currentWeek(); const week = TG.currentWeek();
const goal = cfg("wRankGoal"); const goal = Config.get({ section: "wrank", key: "goal" });
const weekKey = WRank.weekKey(); const weekKey = WRank.weekKey();
const formatNation = (nation: Nation): string => { const formatNation = (nation: Nation): string => {
@ -57,7 +57,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
) )
.setTimestamp(); .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; const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
await channel.send({ embeds: [embed] }); await channel.send({ embeds: [embed] });
return void replyAndDelete(interaction, "✅ Leaderboard posted."); return void replyAndDelete(interaction, "✅ Leaderboard posted.");

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { loadResult, todayString } from "@systems/history"; import { loadResult, todayString } from "@systems/history";
import { normalizeSlot, detectSlot } from "@systems/scores"; import { normalizeSlot, detectSlot } from "@systems/scores";
import { replyAndDelete } from "@src/utils"; import { replyAndDelete } from "@src/utils";
@ -13,7 +13,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
} else { } else {
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20;
} }
const result = loadResult(todayString(), slot); const result = loadResult(todayString(), slot);
@ -40,7 +40,7 @@ export async function handleResultPost(interaction: ChatInputCommandInteraction)
.setFooter({ text: `Source of truth: ${kd.source}` }) .setFooter({ text: `Source of truth: ${kd.source}` })
.setTimestamp(); .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; const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
await channel.send({ embeds: [embed] }); await channel.send({ embeds: [embed] });
return void replyAndDelete(interaction, "✅ Results posted."); return void replyAndDelete(interaction, "✅ Results posted.");

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { setNationKD } from "../../systems/history"; import { setNationKD } from "../../systems/history";
import { normalizeSlot, detectSlot } from "../../systems/scores"; import { normalizeSlot, detectSlot } from "../../systems/scores";
import { todayString } from "../../systems/history"; import { todayString } from "../../systems/history";
@ -17,7 +17,7 @@ export async function handleResultSet(interaction: ChatInputCommandInteraction):
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
} else { } 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); const result = setNationKD(todayString(), slot, nation, kills, deaths);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { loadResult, todayString } from "../../systems/history"; import { loadResult, todayString } from "../../systems/history";
import { normalizeSlot, detectSlot } from "../../systems/scores"; import { normalizeSlot, detectSlot } from "../../systems/scores";
import { replyAndDelete } from "../../utils"; import { replyAndDelete } from "../../utils";
@ -12,7 +12,7 @@ export async function handleResultView(interaction: ChatInputCommandInteraction)
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
} else { } else {
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" })[0]?.tgHour ?? 20;
} }
const result = loadResult(todayString(), slot); const result = loadResult(todayString(), slot);

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config"; import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users"; import { resolveUser, hasOfficerRole } from "../../systems/users";
import { normalizeSlot, detectSlot } from "../../systems/scores"; import { normalizeSlot, detectSlot } from "../../systems/scores";
import { loadResult, todayString } from "../../systems/history"; import { loadResult, todayString } from "../../systems/history";
@ -8,7 +8,7 @@ import { replyAndDelete } from "../../utils";
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const slotArg = interaction.options.getString("slot"); const slotArg = interaction.options.getString("slot");
@ -31,7 +31,7 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true);
} else { } 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); const result = loadResult(todayString(), slot);

View file

@ -1,24 +1,29 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { replyAndDelete } from "@utils"; 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<void> { export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<void> {
const options = Discord.Interaction.options<ChatInputCommandInteraction>(interaction);
const member = await interaction.guild!.members.fetch(interaction.user.id); const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles")); const isOfficer = User.hasOfficerRole({
const nameArg = interaction.options.getString("name"); member: member,
const ptsArg = interaction.options.getInteger("pts", true); officerRoles: Config.get({ section: "roles", key: "officer"
const slotArg = interaction.options.getString("slot"); })});
const kills = interaction.options.getInteger("k") ?? undefined; const nameArg = options.string({ key: "name" });
const deaths = interaction.options.getInteger("d") ?? undefined; const ptsArg = options.integer({ key: "pts", required: true });
const k = interaction.options.getInteger("k") ?? undefined; const slotArg = options.string({ key: "slot" });
const d = interaction.options.getInteger("d") ?? undefined; const k = options.integer({ key: "k" }) ?? undefined;
const atk = interaction.options.getInteger("atk") ?? undefined; const d = options.integer({ key: "d" }) ?? undefined;
const def = interaction.options.getInteger("def") ?? undefined; const atk = options.integer({ key: "atk" }) ?? undefined;
const heal = interaction.options.getInteger("heal") ?? undefined; const def = options.integer({ key: "def" }) ?? undefined;
const heal = options.integer({ key: "heal" }) ?? undefined;
let userKey: string | null; let userKey: string | null;
if (nameArg) { if (nameArg) {
@ -39,14 +44,14 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
slot = normalizeSlot(slotArg); slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
} else { } 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({ await submitScore({
userKey: borrowedFrom ?? userKey, userKey: borrowedFrom ?? userKey,
playedBy: borrowedFrom ? userKey : undefined, playedBy: borrowedFrom ? userKey : undefined,
characterName: char.name, characterName: char.name,
cls: char.class, cls: char.class.key,
nation: char.nation, nation: char.nation,
pts: ptsArg, pts: ptsArg,
k, k,
@ -58,10 +63,10 @@ export async function handleScoreSet(interaction: ChatInputCommandInteraction):
submittedByOfficer: isOfficer && !!nameArg, submittedByOfficer: isOfficer && !!nameArg,
}); });
const scoreEmoji = getEmoji("score") || "📊"; const scoreEmoji = Emoji.get("score") || "📊";
const kdEmoji = getEmoji("kd") || "⚔️"; const kdEmoji = Emoji.get("kd") || "⚔️";
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; 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 = [ const statsNote = [
atk !== undefined ? `ATK: ${atk}` : null, atk !== undefined ? `ATK: ${atk}` : null,
def !== undefined ? `DEF: ${def}` : null, def !== undefined ? `DEF: ${def}` : null,

View file

@ -1,4 +1,4 @@
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow"; import { getEffectiveCharacter } from "@systems/borrow";
import { format } from "@format"; import { format } from "@format";
@ -47,7 +47,7 @@ export namespace score {
return { ok: false, message: `❌ Could not parse slot "${input.slot}".` }; return { ok: false, message: `❌ Could not parse slot "${input.slot}".` };
} }
} else { } 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({ await submitScore({

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js"; import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { resolveUser, hasOfficerRole } from "@systems/users"; import { resolveUser, hasOfficerRole } from "@systems/users";
import { getCharacterByName } from "@systems/characters"; import { getCharacterByName } from "@systems/characters";
import { Character } from "@systems/character"; import { Character } from "@systems/character";
@ -27,7 +27,7 @@ function findSharedChar(userKey: string, charName: string): { ownerKey: string;
export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSwitch(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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 nameArg = interaction.options.getString("name");
const charName = interaction.options.getString("char_name", true); const charName = interaction.options.getString("char_name", true);

View file

@ -5,12 +5,17 @@ import { replyAndDelete } from "@utils";
import { hasOfficerRole } from "@systems/users"; import { hasOfficerRole } from "@systems/users";
export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> { export async function handleSetLayout(interaction: ChatInputCommandInteraction): Promise<void> {
// 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); 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); 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)) { if (!PollUI.setLayout(name)) {
const available = PollUI.layouts() 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, return void replyAndDelete(interaction,
`✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true `✅ 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<void> { export async function autocompleteLayout(interaction: any): Promise<void> {
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() const choices = PollUI.layouts()
.filter((l) => l.name.includes(focused)) .filter((l) => l.name.includes(focused))
.map((l) => ({ name: `${l.name}${l.description}`, value: l.name })); .map((l) => ({ name: `${l.name}${l.description}`, value: l.name }));

View file

@ -1,7 +1,7 @@
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
import path from "path"; import path from "path";
import { BorrowRequest } from "@src/types"; import { BorrowRequest } from "@src/types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { Char } from "@systems/characters"; import { Char } from "@systems/characters";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
import { Paths } from "@helpers/paths"; import { Paths } from "@helpers/paths";
@ -56,7 +56,8 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
export function addPendingRequest(request: BorrowRequest): void { export function addPendingRequest(request: BorrowRequest): void {
const key = requestKey(request.ownerKey, request.requesterKey); 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); pendingRequests.set(key, request);
if (expiry > 0) { if (expiry > 0) {
setTimeout(() => { setTimeout(() => {

View file

@ -11,7 +11,7 @@
*/ */
import { Nation, Character } from "@types"; import { Nation, Character } from "@types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { WRank, WRankWeek } from "@systems/wrank"; import { WRank, WRankWeek } from "@systems/wrank";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@ -63,7 +63,7 @@
* Called after weekly reset the top ranked player with >= goal TGs becomes Bringer. * Called after weekly reset the top ranked player with >= goal TGs becomes Bringer.
*/ */
update({ week }: { week?: WRankWeek }): void { 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]) { for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = NATION_BRINGER_KEY[nation]; const key = NATION_BRINGER_KEY[nation];
const _week = week ?? WRank.currentWeek(); const _week = week ?? WRank.currentWeek();

View file

@ -11,7 +11,7 @@
ActionRowBuilder ActionRowBuilder
} from "discord.js"; } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { setActiveCharacter, getCharacters } from "@systems/characters"; import { setActiveCharacter, getCharacters } from "@systems/characters";
import { import {
getEffectiveCharacter, getEffectiveCharacter,
@ -158,7 +158,7 @@
else state.no.set(voteId, voteEntry); else state.no.set(voteId, voteEntry);
try { 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!); await updatePollMessage(channel, slot!);
} catch {} } catch {}
} }

View file

@ -3,171 +3,172 @@ import { Store } from "@systems/store";
import { Paths } from "@paths"; import { Paths } from "@paths";
import { Runtime } from "@systems/runtime"; import { Runtime } from "@systems/runtime";
// ─── Runtime ───────────────────────────────────────────────────────────────── // ─── Runtime ─────────────────────────────────────────────────────────────────
Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 }); Runtime.phase("load", () => Config.load(), { name: "Config.load", priority: -1 });
// ─── Sub-interfaces ─────────────────────────────────────────────────────────── // ─── Section interfaces (internal) ───────────────────────────────────────────
interface ChannelConfig { interface ChannelConfig {
pollChannelId: string; poll: string;
resultsChannelId: string; results: string;
scoreChannelId: string; score: string;
} }
interface RoleConfig { interface RoleConfig {
officerRoles: string[]; officer: string[];
configRoles: string[]; config: string[];
tagRoles: string[]; tag: string[];
} }
interface PollConfig { interface PollConfig {
pollLayout: string; layout: string;
pollEphemeralEnabled: boolean; ephemeralEnabled: boolean;
commandEphemeralEnabled: boolean; commandEphemeralEnabled: boolean;
ephemeralDeleteMs: number; ephemeralDeleteMs: number;
lockAt: number; lockAt: number;
lockMessage: string; lockMessage: string;
confirmYesMessage: string; confirmYes: string;
confirmNoMessage: string; confirmNo: string;
charDisplayFormat: string; charDisplayFormat: string;
showClassInMessages: boolean; showClassInMessages: boolean;
showLevelInMessages: boolean; showLevelInMessages: boolean;
showNoInNationField: boolean; showNoInNationField: boolean;
showNationTotalsInHeader: boolean; showNationTotalsInHeader:boolean;
autoVoteOnConflictSwitch: boolean; autoVoteOnConflict: boolean;
reclaimNotifyBorrower: boolean; reclaimNotifyBorrower: boolean;
conflictReclaimBehavior: string; conflictReclaimBehavior: string;
slots: TGSlot[]; slots: TGSlot[];
scoreWindowHours: number;
tgDurationMinutes: number;
} }
interface WRankConfig { interface WRankConfig {
wRankGoal: number; goal: number;
wRankPostOnReset: boolean; postOnReset: boolean;
wRankYellowColor: string; yellowColor: string;
wRankGrayColor: string; grayColor: string;
deltaUpColor: string; deltaUpColor: string;
deltaDownColor: string; deltaDownColor:string;
} }
interface BringerConfig { interface BringerConfig {
stormBringerColor: string; stormColor: string;
luminousBringerColor: string; luminousColor: string;
} }
interface ImpersonateConfig { interface ImpersonateConfig {
impersonateResetOnPoll: boolean; resetOnPoll: boolean;
impersonateIndicator: boolean; indicator: boolean;
} }
interface EmojiConfig { interface EmojiConfig {
activeCharEmoji: string; activeChar: string;
emojiDonorGuilds: string[]; donorGuilds: string[];
}
interface LoggingConfig {
logLevel: string;
} }
interface NationConfig { interface NationConfig {
nationSource: Nation; source: Nation;
} }
interface BorrowConfig { interface BorrowConfig {
borrowRequestExpiryMs: number; requestExpiryMs: number;
} }
// ─── BotConfig — merged, all fields optional ────────────────────────────────── interface TGConfig {
scoreWindowHours: number;
durationMinutes: number;
}
export type BotConfig = Partial< // ─── Section map ──────────────────────────────────────────────────────────────
ChannelConfig &
RoleConfig & export interface SectionMap {
PollConfig & channels: ChannelConfig;
WRankConfig & roles: RoleConfig;
BringerConfig & poll: PollConfig;
ImpersonateConfig & wrank: WRankConfig;
EmojiConfig & bringer: BringerConfig;
LoggingConfig & impersonate:ImpersonateConfig;
NationConfig & emoji: EmojiConfig;
BorrowConfig nation: NationConfig;
>; borrow: BorrowConfig;
tg: TGConfig;
}
export type ConfigSection = keyof SectionMap;
// ─── Defaults ───────────────────────────────────────────────────────────────── // ─── Defaults ─────────────────────────────────────────────────────────────────
function getDefaults(): Required<BotConfig> { function getDefaults(): SectionMap {
return { return {
// Channels channels: {
pollChannelId: "", poll: "",
resultsChannelId: "", results: "",
scoreChannelId: "", score: "",
},
// Roles roles: {
officerRoles: ["Ice King"], officer: ["Ice King"],
configRoles: ["Ice King"], config: ["Ice King"],
tagRoles: ["Ice King", "Ice", "Rebellion"], tag: ["Ice King", "Ice", "Rebellion"],
},
// Poll poll: {
pollLayout: "default", layout: "default",
pollEphemeralEnabled: false, ephemeralEnabled: false,
commandEphemeralEnabled: true, commandEphemeralEnabled: true,
ephemeralDeleteMs: 0, ephemeralDeleteMs: 0,
lockAt: 10, lockAt: 10,
lockMessage: "🔒 This poll has been locked.", lockMessage: "🔒 This poll has been locked.",
confirmYesMessage: "⚔️ TG is confirmed for tonight!", confirmYes: "⚔️ TG is confirmed for tonight!",
confirmNoMessage: "❌ TG is cancelled for tonight.", confirmNo: "❌ TG is cancelled for tonight.",
charDisplayFormat: "{wrank} {class} {level} {name}", charDisplayFormat: "{wrank} {class} {level} {name}",
showClassInMessages: false, showClassInMessages: false,
showLevelInMessages: false, showLevelInMessages: false,
showNoInNationField: false, showNoInNationField: false,
showNationTotalsInHeader: false, showNationTotalsInHeader:false,
autoVoteOnConflictSwitch: true, autoVoteOnConflict: true,
reclaimNotifyBorrower: true, reclaimNotifyBorrower: true,
conflictReclaimBehavior: "revert", conflictReclaimBehavior: "revert",
slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }], slots: [{ tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }],
scoreWindowHours: 2, },
tgDurationMinutes: 35, wrank: {
goal: 7,
// W.Rank postOnReset: false,
wRankGoal: 7, yellowColor: "#BA7517",
wRankPostOnReset: false, grayColor: "#888888",
wRankYellowColor: "#BA7517", deltaUpColor: "#A32D2D",
wRankGrayColor: "#888888", deltaDownColor:"#185FA5",
deltaUpColor: "#A32D2D", },
deltaDownColor: "#185FA5", bringer: {
stormColor: "#185FA5",
// Bringer luminousColor: "#8B4CB8",
stormBringerColor: "#185FA5", },
luminousBringerColor: "#8B4CB8", impersonate: {
resetOnPoll: false,
// Impersonate indicator: true,
impersonateResetOnPoll: false, },
impersonateIndicator: true, emoji: {
activeChar: "⭐",
// Emoji donorGuilds: [],
activeCharEmoji: "⭐", },
emojiDonorGuilds: [], nation: {
source: Nation.Procyon,
// Logging },
logLevel: "info", borrow: {
requestExpiryMs: 0,
// Nation },
nationSource: Nation.Procyon, tg: {
scoreWindowHours: 2,
// Borrow durationMinutes: 35,
borrowRequestExpiryMs: 0, },
}; };
} }
// ─── State ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
let _cfg: BotConfig = {}; let _cfg: Partial<SectionMap> = {};
// ─── Config namespace ───────────────────────────────────────────────────────── // ─── Config namespace ─────────────────────────────────────────────────────────
export const Config = { export const Config = {
load(): void { load(): void {
_cfg = Store.readOrDefault(Paths.data("config.json"), {}); _cfg = Store.readOrDefault<Partial<SectionMap>>(Paths.data("config.json"), {});
}, },
save(): void { save(): void {
@ -178,28 +179,41 @@ export const Config = {
Config.load(); Config.load();
}, },
get<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] { get<S extends ConfigSection, K extends keyof SectionMap[S]>(
return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required<BotConfig>[K]; { section, key }: { section: S; key: K }
): SectionMap[S][K] {
const sectionData = _cfg[section] as Partial<SectionMap[S]> | undefined;
const value = sectionData?.[key];
const def = getDefaults()[section][key];
return (value !== undefined ? value : def) as SectionMap[S][K];
}, },
set<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void { set<S extends ConfigSection, K extends keyof SectionMap[S]>(
_cfg[key] = value; { 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(); Config.save();
}, },
reset<K extends keyof BotConfig>(key: K): void { reset<S extends ConfigSection, K extends keyof SectionMap[S]>(
delete _cfg[key]; { section, key }: { section: S; key: K }
): void {
if (_cfg[section]) delete (_cfg[section] as any)[key];
Config.save(); Config.save();
}, },
all(): Required<BotConfig> { /** Get an entire section (merged with defaults) */
return { ...getDefaults(), ..._cfg }; section<S extends ConfigSection>(section: S): SectionMap[S] {
return { ...getDefaults()[section], ...(_cfg[section] ?? {}) } as SectionMap[S];
}, },
};
// ─── Legacy aliases — remove after full migration ──────────────────────────── all(): SectionMap {
export const cfg = <K extends keyof BotConfig>(key: K) => Config.get(key); const defaults = getDefaults();
export const setCfg = <K extends keyof BotConfig>(key: K, value: BotConfig[K]) => Config.set(key, value); const result = { ...defaults } as any;
export const resetCfg = <K extends keyof BotConfig>(key: K) => Config.reset(key); for (const section of Object.keys(defaults) as ConfigSection[]) {
export const loadConfig = () => Config.load(); result[section] = { ...defaults[section], ...(_cfg[section] ?? {}) };
export const saveConfig = () => Config.save(); }
return result as SectionMap;
},
};

View file

@ -6,7 +6,6 @@ import {
ButtonInteraction, ButtonInteraction,
TextChannel, TextChannel,
} from "discord.js"; } from "discord.js";
import { cfg } from "@systems/config";
import { getCharacters, setActiveCharacter } from "@systems/characters"; import { getCharacters, setActiveCharacter } from "@systems/characters";
import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
import { getImpersonation } from "@systems/impersonate"; import { getImpersonation } from "@systems/impersonate";
@ -22,8 +21,8 @@ import { Config } from "@systems/config";
// ─── Config ─────────────────────────────────────────────────────────────────── // ─── Config ───────────────────────────────────────────────────────────────────
const RECLAIM_STYLE = ButtonStyle.Secondary; const RECLAIM_STYLE = ButtonStyle.Secondary;
const SWITCH_STYLE = ButtonStyle.Secondary; const SWITCH_STYLE = ButtonStyle.Secondary;
const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch"); const AUTO_VOTE_ON_SWITCH = Config.get({ section: "poll", key: "autoVoteOnConflict" });
const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower"); const RECLAIM_NOTIFY_BORROWER = Config.get({ section: "poll", key: "reclaimNotifyBorrower" });
// ─── State ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
const pendingConflicts = new Map<string, { const pendingConflicts = new Map<string, {
@ -181,7 +180,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
publicMessage: publicMsg ?? undefined, publicMessage: publicMsg ?? undefined,
}); });
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!); await updatePollMessage(channel, slot!);
} }
@ -207,7 +206,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
const charName = parts[3]; const charName = parts[3];
const ownerId = parts[4]; const ownerId = parts[4];
const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; const reclaimBehavior = Config.get({ section: "poll", key: "conflictReclaimBehavior" });
const slot = [...polls.keys()][0]; const slot = [...polls.keys()][0];
const state = slot !== undefined ? polls.get(slot) : null; const state = slot !== undefined ? polls.get(slot) : null;
@ -268,7 +267,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
publicMessage: publicMsg ?? undefined, publicMessage: publicMsg ?? undefined,
}); });
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!); await updatePollMessage(channel, slot!);
console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER); console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER);

View file

@ -2,8 +2,8 @@ import { UserRegistry } from "@registry/user-registry";
import { UsermapEntry } from "@types"; import { UsermapEntry } from "@types";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
const IMPERSONATE_RESET_ON_POLL = Config.get("impersonateResetOnPoll"); const IMPERSONATE_RESET_ON_POLL = Config.get({ section: "impersonate", key: "resetOnPoll" });
const IMPERSONATE_INDICATOR = Config.get("impersonateIndicator"); const IMPERSONATE_INDICATOR = Config.get({ section: "impersonate", key: "indicator" });
// realDiscordId → userKey being impersonated // realDiscordId → userKey being impersonated
const impersonations = new Map<string, string>(); const impersonations = new Map<string, string>();

View file

@ -7,15 +7,12 @@ import {
GuildMember, GuildMember,
} from "discord.js"; } from "discord.js";
import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; import { PollState, VoteEntry, Nation, TGSlot } from "@src/types";
import { cfg } from "@systems/config";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { WRank } from "@systems/wrank";
import { format } from "@format"; import { format } from "@format";
import { persist } from "@systems/pollPersistence" import { persist } from "@systems/pollPersistence"
import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow"; import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow";
import { clearAllImpersonations } from "@systems/impersonate"; import { clearAllImpersonations } from "@systems/impersonate";
import { Bringer } from "@systems/bringer";
import { Attendance } from "@systems/attendance"; import { Attendance } from "@systems/attendance";
import { PollUI } from "@ui/poll"; import { PollUI } from "@ui/poll";

View file

@ -9,7 +9,7 @@
import cron from "node-cron"; import cron from "node-cron";
import { Client, TextChannel } from "discord.js"; import { Client, TextChannel } from "discord.js";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { TGSlot } from "@types"; import { TGSlot } from "@types";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
@ -40,7 +40,7 @@
stopAll(); stopAll();
const tz = process.env.TZ ?? "Etc/GMT-2"; 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) { for (const slot of slots) {
// Poll open // Poll open
@ -73,7 +73,7 @@
const state = polls.get(slot.tgHour); const state = polls.get(slot.tgHour);
if (!state?.locked) return; if (!state?.locked) return;
try { 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); await updatePollMessage(channel, slot.tgHour, undefined, false);
console.log(`[Scheduler] Submit Score button removed for ${slot.tgHour}:00`); console.log(`[Scheduler] Submit Score button removed for ${slot.tgHour}:00`);
} catch (err) { } catch (err) {

View file

@ -11,7 +11,7 @@
import cron from "node-cron"; import cron from "node-cron";
import { Client, TextChannel } from "discord.js"; import { Client, TextChannel } from "discord.js";
import { ScheduledJob } from "./types"; import { ScheduledJob } from "./types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { TGSlot } from "@types"; import { TGSlot } from "@types";
// Import all jobs // Import all jobs
@ -47,7 +47,7 @@
stopAll(); stopAll();
const tz = process.env.TZ ?? "Etc/GMT-2"; 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}`); console.log(`[Scheduler] Weekly reset scheduled: "0 0 * * 1" in ${tz}`);

View file

@ -1,7 +1,7 @@
import { Client, TextChannel } from "discord.js"; import { Client, TextChannel } from "discord.js";
import { ScheduledJob } from "@scheduler/types"; import { ScheduledJob } from "@scheduler/types";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
export const job: ScheduledJob = { export const job: ScheduledJob = {
name: "midnight-cleanup", name: "midnight-cleanup",
@ -10,7 +10,7 @@ export const job: ScheduledJob = {
for (const [slot, state] of polls.entries()) { for (const [slot, state] of polls.entries()) {
if (!state?.locked) continue; if (!state?.locked) continue;
try { 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); await updatePollMessage(channel, slot, undefined, false);
console.log(`[Scheduler] Submit Score button removed for ${slot}:00`); console.log(`[Scheduler] Submit Score button removed for ${slot}:00`);
} catch {} } catch {}

View file

@ -1,5 +1,5 @@
import { TGScore, Nation, ClassKey } from "../types"; import { TGScore, Nation, ClassKey } from "../types";
import { cfg } from "./config"; import { Config } from "./config";
import { upsertScore, todayString } from "./history"; import { upsertScore, todayString } from "./history";
import { recordScore } from "./wrank"; 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 // Detect which slot a submission belongs to based on current time
export function detectSlot(): number | null { export function detectSlot(): number | null {
const slots = cfg("slots").filter((s) => s.active); const slots = Config.get({ section: "poll", key: "slots" }).filter((s) => s.active);
const windowMs = cfg("scoreWindowHours") * 60 * 60 * 1000; const windowMs = Config.get({ section: "tg", key: "scoreWindowHours" }) * 60 * 60 * 1000;
const durationMs = cfg("tgDurationMinutes") * 60 * 1000; const durationMs = Config.get({ section: "tg", key: "durationMinutes" }) * 60 * 1000;
const now = Date.now(); const now = Date.now();
for (const slot of slots) { for (const slot of slots) {

View file

@ -1,6 +1,6 @@
import cron from "node-cron"; import cron from "node-cron";
import { Client, TextChannel } from "discord.js"; import { Client, TextChannel } from "discord.js";
import { cfg } from "./config"; import { Config } from "./config";
import { TGSlot } from "../types"; import { TGSlot } from "../types";
import { polls, updatePollMessage } from "@systems/poll"; import { polls, updatePollMessage } from "@systems/poll";

View file

@ -18,7 +18,8 @@
import { Score, TGScore, WeeklySummary } from "@systems/score"; import { Score, TGScore, WeeklySummary } from "@systems/score";
import { Attendance } from "@systems/attendance"; import { Attendance } from "@systems/attendance";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Config } from "@systems/config";
export const TG = { export const TG = {
// ── Week ────────────────────────────────────────────────────────────────── // ── Week ──────────────────────────────────────────────────────────────────
@ -40,7 +41,7 @@
const newWeek = WRank.currentWeek(); // ensures new week exists const newWeek = WRank.currentWeek(); // ensures new week exists
if (prevWeek) { 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]) { for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = Nations.key(nation); const key = Nations.key(nation);
const entries = prevWeek.entries[key]; const entries = prevWeek.entries[key];

View file

@ -3,6 +3,7 @@ import { getImpersonation } from "@systems/impersonate";
import { ResolvedUser } from "@src/types"; import { ResolvedUser } from "@src/types";
import { getUsermapEntry, getUsermapEntryById } from "@systems/messages"; import { getUsermapEntry, getUsermapEntryById } from "@systems/messages";
import { getActiveCharacter } from "@systems/characters"; import { getActiveCharacter } from "@systems/characters";
import { Discord } from "@discord";
// Resolves a full user context from a GuildMember + discord username // Resolves a full user context from a GuildMember + discord username
export async function resolveUser(member: GuildMember): Promise<ResolvedUser> { export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
@ -46,4 +47,11 @@ export function resolveByUsermapKey(key: string): { userKey: string; activeChara
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
return member.roles.cache.some((r) => officerRoles.includes(r.name)); return member.roles.cache.some((r) => officerRoles.includes(r.name));
} }
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));
},
};

View file

@ -1,7 +1,7 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types"; import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations"; import { Nations } from "@systems/nations";
import { Store } from "@systems/store"; import { Store } from "@systems/store";
@ -147,7 +147,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void {
} }
function updateBringer(week: WRankWeek): 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) { for (const nation of ["capella", "procyon"] as const) {
// Don't overwrite manual override // Don't overwrite manual override
if (nation === "capella" && week.bringer.capellaOverride) continue; if (nation === "capella" && week.bringer.capellaOverride) continue;

View file

@ -51,7 +51,7 @@ export function discoverLayouts(): void {
} }
function restoreLayout() { function restoreLayout() {
const savedLayout = Config.get("pollLayout"); const savedLayout = Config.get({ section: "poll", key: "layout" });
if (savedLayout && _layouts.has(savedLayout)) { if (savedLayout && _layouts.has(savedLayout)) {
_activeLayout = _layouts.get(savedLayout)!; _activeLayout = _layouts.get(savedLayout)!;

View file

@ -5,7 +5,7 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; import { PollState, VoteEntry, Nation, WRankEntry } from "@types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
@ -19,7 +19,7 @@
context: PollRowContext context: PollRowContext
): string { ): string {
if (wRankEntry) { if (wRankEntry) {
const goal = cfg("wRankGoal"); const goal = Config.get({ section: "wrank", key: "goal" });
return format.wrank.full(wRankEntry, { goal, brackets: true }); return format.wrank.full(wRankEntry, { goal, brackets: true });
} }
if (!context.nationHasRank) return ""; if (!context.nationHasRank) return "";
@ -28,7 +28,7 @@
} }
function formatRow(entry: VoteEntry, context: PollRowContext): string { function formatRow(entry: VoteEntry, context: PollRowContext): string {
const cfgFormat = cfg("charDisplayFormat"); const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
const nation = entry.characterNation; const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation const wRankEntry = entry.characterName && entry.characterNation
@ -138,9 +138,9 @@
} }
function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string { function resolveFooter(state: PollState, noCount: number, overrideLockMsg?: string): string {
if (state.confirmed === "yes") return cfg("confirmYesMessage"); if (state.confirmed === "yes") return Config.get({ section: "poll", key: "confirmYes" });
if (state.confirmed === "no") return cfg("confirmNoMessage"); if (state.confirmed === "no") return Config.get({ section: "poll", key: "confirmNo" });
if (state.locked) return overrideLockMsg ?? cfg("lockMessage"); if (state.locked) return overrideLockMsg ?? Config.get({ section: "poll", key: "lockMessage" });
return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`; return `${noCount} • Vote updates live • Anyone can vote • /tg switch to change character`;
} }
@ -151,7 +151,7 @@
}; };
const noVoters: VoteEntry[] = []; const noVoters: VoteEntry[] = [];
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; 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()) { for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? Nation.Capella; const nation = entry.characterNation ?? Nation.Capella;

View file

@ -5,7 +5,7 @@
import { EmbedBuilder } from "discord.js"; import { EmbedBuilder } from "discord.js";
import { PollState, VoteEntry, Nation, WRankEntry } from "@types"; import { PollState, VoteEntry, Nation, WRankEntry } from "@types";
import { cfg } from "@systems/config"; import { Config } from "@systems/config";
import { WRank } from "@systems/wrank"; import { WRank } from "@systems/wrank";
import { Bringer } from "@systems/bringer"; import { Bringer } from "@systems/bringer";
import { Emoji } from "@systems/emojis"; import { Emoji } from "@systems/emojis";
@ -16,7 +16,7 @@
function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string { function formatWRank(wRankEntry: WRankEntry | null, context: PollRowContext): string {
if (wRankEntry) { 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.nationHasRank) return "";
if (context.nationHasDelta) return format.wrank.noRank(); if (context.nationHasDelta) return format.wrank.noRank();
@ -24,7 +24,7 @@
} }
function formatRow(entry: VoteEntry, context: PollRowContext): string { function formatRow(entry: VoteEntry, context: PollRowContext): string {
const cfgFormat = cfg("charDisplayFormat"); const cfgFormat = Config.get({ section: "poll", key: "charDisplayFormat" });
const nation = entry.characterNation; const nation = entry.characterNation;
const wRankEntry = entry.characterName && entry.characterNation const wRankEntry = entry.characterName && entry.characterNation
? WRank.entry(entry.characterName, entry.characterNation) ? WRank.entry(entry.characterName, entry.characterNation)
@ -103,7 +103,7 @@
}; };
const noVoters: VoteEntry[] = []; const noVoters: VoteEntry[] = [];
const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; 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()) { for (const entry of state.yes.values()) {
const nation = entry.characterNation ?? Nation.Capella; const nation = entry.characterNation ?? Nation.Capella;
@ -158,9 +158,9 @@
// Footer // Footer
let footer: string; let footer: string;
if (state.confirmed === "yes") footer = cfg("confirmYesMessage"); if (state.confirmed === "yes") footer = Config.get({ section: "poll", key: "confirmYes" });
else if (state.confirmed === "no") footer = cfg("confirmNoMessage"); else if (state.confirmed === "no") footer = Config.get({ section: "poll", key: "confirmNo" });
else if (state.locked) footer = options?.overrideLockMsg ?? cfg("lockMessage"); 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`; else footer = `${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`;
embed.setFooter({ text: footer }); embed.setFooter({ text: footer });

View file

@ -2,10 +2,10 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
import { Config } from "@systems/config"; import { Config } from "@systems/config";
// Poll vote confirmation messages (Yes/No button responses) // 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 // Command output messages (score, rank, status etc.) — always on by default
const COMMAND_EPHEMERAL_ENABLED = Config.get("commandEphemeralEnabled"); const COMMAND_EPHEMERAL_ENABLED = Config.get({ section: "poll", key: "commandEphemeralEnabled" });
const EPHEMERAL_DELETE_MS = Config.get("ephemeralDeleteMs"); const EPHEMERAL_DELETE_MS = Config.get({ section: "poll", key: "ephemeralDeleteMs" });
// For poll button responses // For poll button responses
export async function pollReplyAndDelete( export async function pollReplyAndDelete(

View file

@ -36,7 +36,9 @@
"@ui": ["src/ui/index"], "@ui": ["src/ui/index"],
"@ui/*": ["src/ui/*"], "@ui/*": ["src/ui/*"],
"@ui/poll": ["src/ui/poll/index"], "@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/**/*"], "include": ["src/**/*", "scripts/**/*"],