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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { getCharacterByName } from "@systems/characters";
import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "@systems/borrow";
import { polls, updatePollMessage } from "@systems/poll";
@ -58,7 +58,7 @@ async function acceptBorrow(
}
}
try {
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot);
} catch {}
}

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
@ -11,7 +11,7 @@ export async function handleCharActive(interaction: ChatInputCommandInteraction)
const group = interaction.options.getSubcommandGroup(false);
const sub = interaction.options.getSubcommand();
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
if (nameArg && !isOfficer) {
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
}

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
@ -9,7 +9,7 @@ import { getUsermapEntry, getUsermapEntryById } from "@src/systems/messages";
export async function handleCharBorrow(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const requester = await resolveUser(member);
// Args: owner, charname, [username] (officer only — grants directly)
@ -40,7 +40,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
const slot = [...polls.keys()][0];
if (slot !== undefined) {
const state = polls.get(slot)!;
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
// Find the voter entry and update their character
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
if (entry.userKey === requesterKey) {
@ -78,7 +78,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`);
}
const fallbackChannel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const fallbackChannel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await sendBorrowRequestDM(
interaction.client,
ownerMember.user.id,

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { setCharacterStats, getActiveCharacter } from "../../systems/characters";
import { replyAndDelete } from "../../utils";
@ -8,7 +8,7 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
return void replyAndDelete(interaction, "⚠️ Character stats system is being redesigned. Coming soon.", true);
// const member = await interaction.guild!.members.fetch(interaction.user.id);
// const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
// const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
// const nameArg = interaction.options.getString("name");
// const charName = interaction.options.getString("char_name");
// const atk = interaction.options.getInteger("atk") ?? undefined;

View file

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

View file

@ -6,7 +6,7 @@ import {
ActionRowBuilder,
EmbedBuilder,
} from "discord.js";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { hasOfficerRole } from "@systems/users";
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
import { replyAndDelete } from "@utils";
@ -70,7 +70,7 @@ function buildImpersonateButtons(
// Slash command handler
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
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.");
}

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll";
import { getEffectiveCharacter } from "../../systems/borrow";
import { nowFormatted, resolveMessage } from "../../systems/messages";
@ -50,7 +50,7 @@ const entry: VoteEntry = {
state.no.set(syntheticId, entry);
}
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`);
}
@ -76,7 +76,7 @@ export async function handleRemoveVote(interaction: ChatInputCommandInteraction)
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`);
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`);
}

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { polls, lockPoll, updatePollMessage } from "@systems/poll";
import { replyAndDelete } from "@utils";
@ -12,7 +12,7 @@ export async function handleLock(interaction: ChatInputCommandInteraction): Prom
// Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron
lockPoll(slot);
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
if (simulateClose) {
// Simulate TG end: show Submit Score button (same path as onPollClose cron)

View file

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

View file

@ -3,12 +3,11 @@ import { loadMessages } from "@systems/messages";
import { Emoji } from "@systems/emojis";
import { loadCharacters } from "@systems/characters";
import { loadWRank } from "@systems/wrank";
import { loadConfig, cfg } from "@systems/config";
import { Config } from "@systems/config";
import { polls, updatePollMessage } from "@systems/poll";
import { persist } from "@systems/pollPersistence";
import { replyAndDelete } from "@utils";
import { CharacterRegistry } from "@root/src/systems/registry/character-registry";
import { invalidateUsermapCache } from "@root/src/handlers/autocomplete";
import { UserRegistry } from "@root/src/systems/registry/user-registry";
const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const;
@ -21,7 +20,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr
const should = (k: Reloadable) => target === "all" || target === k;
if (should("config")) { loadConfig(); reloaded.push("config"); }
if (should("config")) { Config.load(); reloaded.push("config"); }
if (should("messages")) { loadMessages(); reloaded.push("messages"); }
if (should("emojis")) { Emoji.load(); reloaded.push("emojis"); }
if (should("characters")) { loadCharacters(); reloaded.push("characters"); }
@ -29,7 +28,7 @@ export async function handleReload(interaction: ChatInputCommandInteraction): Pr
// Re-render active poll message(s) so embed reflects reloaded data
if (should("poll") || should("emojis") || should("all")) {
const channelId = cfg("pollChannelId");
const channelId = Config.get({ section: "channels", key: "poll" });
if (channelId) {
try {
// Restore from disk first if reloading poll specifically

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { polls, updatePollMessage } from "../../systems/poll";
import { loadMessages } from "../../systems/messages";
import { replyAndDelete } from "../../utils";
@ -14,7 +14,7 @@ export async function handleUnlock(interaction: ChatInputCommandInteraction): Pr
state.locked = false;
loadMessages();
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await interaction.client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot);
return void replyAndDelete(interaction, "🔓 Poll unlocked!");
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ChatInputCommandInteraction } from "discord.js";
import { cfg } from "../../systems/config";
import { Config } from "../../systems/config";
import { resolveUser, hasOfficerRole } from "../../systems/users";
import { normalizeSlot, detectSlot } from "../../systems/scores";
import { loadResult, todayString } from "../../systems/history";
@ -8,7 +8,7 @@ import { replyAndDelete } from "../../utils";
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
const member = await interaction.guild!.members.fetch(interaction.user.id);
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
const isOfficer = hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }));
const nameArg = interaction.options.getString("name");
const slotArg = interaction.options.getString("slot");
@ -31,7 +31,7 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
slot = normalizeSlot(slotArg);
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true);
} else {
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
}
const result = loadResult(todayString(), slot);

View file

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

View file

@ -1,4 +1,4 @@
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
import { getEffectiveCharacter } from "@systems/borrow";
import { format } from "@format";
@ -47,7 +47,7 @@ export namespace score {
return { ok: false, message: `❌ Could not parse slot "${input.slot}".` };
}
} else {
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
slot = detectSlot() ?? Config.get({ section: "poll", key: "slots" }).find((s) => s.active)?.tgHour ?? 20;
}
await submitScore({

View file

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

View file

@ -5,12 +5,17 @@ import { replyAndDelete } from "@utils";
import { hasOfficerRole } from "@systems/users";
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);
if (!hasOfficerRole(member, Config.get("officerRoles"))) {
if (!hasOfficerRole(member, Config.get({ section: "roles", key: "officer" }))) {
return void replyAndDelete(interaction, "❌ Only officers can change the poll layout.", true);
}
const name = interaction.options.getString("layout", true);
const name = options.getString("layout", true);
if (!PollUI.setLayout(name)) {
const available = PollUI.layouts()
@ -21,7 +26,7 @@ export async function handleSetLayout(interaction: ChatInputCommandInteraction):
);
}
Config.set("pollLayout", name);
Config.set({ section: "poll", key: "layout", value: name });
return void replyAndDelete(interaction,
`✅ Poll layout set to \`${name}\`. Use \`/tg poll reload\` to apply.`, true
@ -29,7 +34,12 @@ export async function handleSetLayout(interaction: ChatInputCommandInteraction):
}
export async function autocompleteLayout(interaction: any): Promise<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()
.filter((l) => l.name.includes(focused))
.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 path from "path";
import { BorrowRequest } from "@src/types";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { Char } from "@systems/characters";
import { Store } from "@systems/store";
import { Paths } from "@helpers/paths";
@ -56,7 +56,8 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
export function addPendingRequest(request: BorrowRequest): void {
const key = requestKey(request.ownerKey, request.requesterKey);
const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
const expiry = Config.get({ section: "borrow", key: "requestExpiryMs" });
pendingRequests.set(key, request);
if (expiry > 0) {
setTimeout(() => {

View file

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

View file

@ -11,7 +11,7 @@
ActionRowBuilder
} from "discord.js";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { setActiveCharacter, getCharacters } from "@systems/characters";
import {
getEffectiveCharacter,
@ -158,7 +158,7 @@
else state.no.set(voteId, voteEntry);
try {
const channel = await (interaction as any).client.channels.fetch(cfg("pollChannelId")) as TextChannel;
const channel = await (interaction as any).client.channels.fetch(Config.get({ section: "channels", key: "poll" })) as TextChannel;
await updatePollMessage(channel, slot!);
} catch {}
}

View file

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

View file

@ -6,7 +6,6 @@ import {
ButtonInteraction,
TextChannel,
} from "discord.js";
import { cfg } from "@systems/config";
import { getCharacters, setActiveCharacter } from "@systems/characters";
import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
import { getImpersonation } from "@systems/impersonate";
@ -22,8 +21,8 @@ import { Config } from "@systems/config";
// ─── Config ───────────────────────────────────────────────────────────────────
const RECLAIM_STYLE = ButtonStyle.Secondary;
const SWITCH_STYLE = ButtonStyle.Secondary;
const AUTO_VOTE_ON_SWITCH = Config.get("autoVoteOnConflictSwitch");
const RECLAIM_NOTIFY_BORROWER = Config.get("reclaimNotifyBorrower");
const AUTO_VOTE_ON_SWITCH = Config.get({ section: "poll", key: "autoVoteOnConflict" });
const RECLAIM_NOTIFY_BORROWER = Config.get({ section: "poll", key: "reclaimNotifyBorrower" });
// ─── State ────────────────────────────────────────────────────────────────────
const pendingConflicts = new Map<string, {
@ -181,7 +180,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
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!);
}
@ -207,7 +206,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
const charName = parts[3];
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 state = slot !== undefined ? polls.get(slot) : null;
@ -268,7 +267,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom
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!);
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 { Config } from "@systems/config";
const IMPERSONATE_RESET_ON_POLL = Config.get("impersonateResetOnPoll");
const IMPERSONATE_INDICATOR = Config.get("impersonateIndicator");
const IMPERSONATE_RESET_ON_POLL = Config.get({ section: "impersonate", key: "resetOnPoll" });
const IMPERSONATE_INDICATOR = Config.get({ section: "impersonate", key: "indicator" });
// realDiscordId → userKey being impersonated
const impersonations = new Map<string, string>();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@
import { Score, TGScore, WeeklySummary } from "@systems/score";
import { Attendance } from "@systems/attendance";
import { Nations } from "@systems/nations";
import { Config } from "@systems/config";
export const TG = {
// ── Week ──────────────────────────────────────────────────────────────────
@ -40,7 +41,7 @@
const newWeek = WRank.currentWeek(); // ensures new week exists
if (prevWeek) {
const goal = (require("@systems/config") as any).cfg("wRankGoal");
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of [Nation.Capella, Nation.Procyon]) {
const key = Nations.key(nation);
const entries = prevWeek.entries[key];

View file

@ -3,6 +3,7 @@ import { getImpersonation } from "@systems/impersonate";
import { ResolvedUser } from "@src/types";
import { getUsermapEntry, getUsermapEntryById } from "@systems/messages";
import { getActiveCharacter } from "@systems/characters";
import { Discord } from "@discord";
// Resolves a full user context from a GuildMember + discord username
export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
@ -47,3 +48,10 @@ export function resolveByUsermapKey(key: string): { userKey: string; activeChara
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
return member.roles.cache.some((r) => officerRoles.includes(r.name));
}
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 path from "path";
import { HistoryKey, UserKey, CharName, Nation, ClassKey } from "@types";
import { cfg } from "@systems/config";
import { Config } from "@systems/config";
import { Bringer } from "@systems/bringer";
import { Nations } from "@systems/nations";
import { Store } from "@systems/store";
@ -147,7 +147,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void {
}
function updateBringer(week: WRankWeek): void {
const goal = cfg("wRankGoal");
const goal = Config.get({ section: "wrank", key: "goal" });
for (const nation of ["capella", "procyon"] as const) {
// Don't overwrite manual override
if (nation === "capella" && week.bringer.capellaOverride) continue;

View file

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

View file

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

View file

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

View file

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

View file

@ -36,7 +36,9 @@
"@ui": ["src/ui/index"],
"@ui/*": ["src/ui/*"],
"@ui/poll": ["src/ui/poll/index"],
"@ui/types": ["src/ui/types"]
"@ui/types": ["src/ui/types"],
"@discord": ["src/discord/index"],
"@discord/*": ["src/discord/*"]
}
},
"include": ["src/**/*", "scripts/**/*"],