- /tg-admin result post-all [slot:] — posts/updates every TG result for a slot (default 20:00) in one pass, not one at a time - Officers can add a late-arriving player to a poll (locked or not) so they can submit a score instead of hitting "You weren't in this TG": /tg poll add-attendee (single, autocomplete) and /tg-admin poll add-attendees (modal, comma/newline-separated, for adding several at once — Discord modals can't hold select menus, only text inputs) - fix: /tg-admin result post, leaderboard post, and leaderboard post-highlights had no permission check at all — any guild member could invoke them - fix: --register never exited after registering slash commands, so it kept running as a full second bot instance forever. Every docker exec --register run stacked another live process on top of the container's real one, each independently connected to Discord — root cause of /tg poll start posting multiple polls and vote/submit state acting confused (confirmed 3 live processes via docker top, cleaned up) - fix: bot startup poll-restore logic had the same stale-button- resurrection bug already fixed in /tg poll reload, in a sibling code path that got missed — now respects scoreSubmitOpen/buttonsRemoved there too instead of re-deriving from locked/confirmed - rename: officer -> moderator throughout (Config.roles, hasOfficerRole, all "officer only" text, /tg-config roles set/add/remove/reset-* commands). Config.load() auto-migrates a legacy roles.officer key on read so existing deployed config.json files don't silently reset. Slash command names changed — re-register after deploying. - data/updates v0.10.3 changelog for the above
292 lines
No EOL
19 KiB
TypeScript
292 lines
No EOL
19 KiB
TypeScript
import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js";
|
|
import { Config, SectionMap } from "../systems/config";
|
|
import { hasModeratorRole } from "../systems/users";
|
|
import { replyAndDelete } from "../utils";
|
|
import { Nation } from "@types";
|
|
import { handleSetLayout } from "@subcommands/tg-config/set-layout";
|
|
import { SetResultLayoutCommands } from "@subcommands/tg-config/set-result-layout";
|
|
import { SetLeaderboardLayoutCommands } from "@subcommands/tg-config/set-leaderboard-layout";
|
|
import { SetLayoutCommands } from "@subcommands/tg-config/set-layout";
|
|
|
|
const ROLE_KEY_MAP: Record<"moderatorRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", keyof SectionMap["roles"]> = {
|
|
moderatorRoles: "moderator",
|
|
configRoles: "config",
|
|
tagRoles: "tag",
|
|
sleepCheckRoles: "sleepCheck",
|
|
};
|
|
|
|
export function buildTgConfigCommand(): SlashCommandBuilder {
|
|
const cmd = new SlashCommandBuilder()
|
|
.setName("tg-config")
|
|
.setDescription("Configure the TG bot");
|
|
|
|
const strOpt = (name: string, desc: string, req = true) =>
|
|
(o: any) => o.setName(name).setDescription(desc).setRequired(req);
|
|
|
|
const decisionOpt = (o: any) => o.setName("decision").setDescription("yes or no").setRequired(true)
|
|
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" });
|
|
|
|
const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true)
|
|
.addChoices({ name: "Capella", value: Nation.Capella }, { name: "Procyon", value: Nation.Procyon });
|
|
|
|
const roleOpt = strOpt("role", "Role name");
|
|
const rolesOpt = strOpt("roles", "Comma-separated role names");
|
|
|
|
// ── message group ──────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("message")
|
|
.setDescription("Configure bot messages")
|
|
.addSubcommand((s) => s.setName("set-lock").setDescription("Set default lock message")
|
|
.addStringOption(strOpt("message", "New lock message")))
|
|
.addSubcommand((s) => s.setName("reset-lock").setDescription("Reset lock message to default"))
|
|
.addSubcommand((s) => s.setName("set-confirm").setDescription("Set default confirm message")
|
|
.addStringOption(decisionOpt)
|
|
.addStringOption(strOpt("message", "New confirm message")))
|
|
.addSubcommand((s) => s.setName("reset-confirm").setDescription("Reset confirm message to default")
|
|
.addStringOption(decisionOpt))
|
|
);
|
|
|
|
// ── roles group ────────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("roles")
|
|
.setDescription("Configure bot roles")
|
|
.addSubcommand((s) => s.setName("set-moderator").setDescription("Set moderator roles (comma-separated)").addStringOption(rolesOpt))
|
|
.addSubcommand((s) => s.setName("add-moderator").setDescription("Add a moderator role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("remove-moderator").setDescription("Remove a moderator role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("reset-moderator").setDescription("Reset moderator roles to default"))
|
|
.addSubcommand((s) => s.setName("set-config").setDescription("Set config roles (comma-separated)").addStringOption(rolesOpt))
|
|
.addSubcommand((s) => s.setName("add-config").setDescription("Add a config role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("remove-config").setDescription("Remove a config role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("reset-config").setDescription("Reset config roles to default"))
|
|
.addSubcommand((s) => s.setName("set-tag").setDescription("Set tag roles (comma-separated)").addStringOption(rolesOpt))
|
|
.addSubcommand((s) => s.setName("add-tag").setDescription("Add a tag role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("remove-tag").setDescription("Remove a tag role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("reset-tag").setDescription("Reset tag roles to default"))
|
|
.addSubcommand((s) => s.setName("set-sleep-check").setDescription("Set sleep-check roles (comma-separated)").addStringOption(rolesOpt))
|
|
.addSubcommand((s) => s.setName("add-sleep-check").setDescription("Add a sleep-check role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("remove-sleep-check").setDescription("Remove a sleep-check role").addStringOption(roleOpt))
|
|
.addSubcommand((s) => s.setName("reset-sleep-check").setDescription("Reset sleep-check roles to default (none)"))
|
|
);
|
|
|
|
// ── channel group ──────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("channel")
|
|
.setDescription("Configure bot channels")
|
|
.addSubcommand((s) => s.setName("set-poll").setDescription("Set poll channel")
|
|
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-results").setDescription("Set results channel")
|
|
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-score").setDescription("Set score channel")
|
|
.addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true)))
|
|
);
|
|
|
|
// ── slot group ─────────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("slot")
|
|
.setDescription("Configure TG slots")
|
|
.addSubcommand((s) => s.setName("add").setDescription("Add a TG slot")
|
|
.addIntegerOption((o) => o.setName("hour").setDescription("TG hour (0-23)").setRequired(true))
|
|
.addStringOption(strOpt("poll_opens", "Poll open time e.g. 10:00")))
|
|
.addSubcommand((s) => s.setName("remove").setDescription("Remove a TG slot")
|
|
.addIntegerOption((o) => o.setName("hour").setDescription("TG hour").setRequired(true)))
|
|
);
|
|
|
|
// ── wrank group ────────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("wrank")
|
|
.setDescription("Configure W.Rank settings")
|
|
.addSubcommand((s) => s.setName("set-goal").setDescription("Set W.Rank TG goal")
|
|
.addIntegerOption((o) => o.setName("goal").setDescription("Number of TGs").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-post-on-reset").setDescription("Post leaderboard on weekly reset?")
|
|
.addBooleanOption((o) => o.setName("enabled").setDescription("true/false").setRequired(true)))
|
|
);
|
|
|
|
// ── tg group ───────────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("tg")
|
|
.setDescription("Configure TG settings")
|
|
.addSubcommand((s) => s.setName("set-score-window").setDescription("Set score submission window (hours)")
|
|
.addNumberOption((o) => o.setName("hours").setDescription("Hours").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-duration").setDescription("Set TG duration (minutes)")
|
|
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-no-display").setDescription("Where to show No voters")
|
|
.addStringOption((o) => o.setName("mode").setDescription("inline or messages").setRequired(true)
|
|
.addChoices({ name: "Inline under nation", value: "inline" }, { name: "Messages section only", value: "messages" })))
|
|
.addSubcommand((s) => s.setName("set-nation-source").setDescription("Set source of truth nation")
|
|
.addStringOption(nationOpt))
|
|
.addSubcommand((s) => s.setName("set-sleep-check-minutes").setDescription("Minutes before TG to run the sleep check (default 20)")
|
|
.addIntegerOption((o) => o.setName("minutes").setDescription("Minutes before TG start").setRequired(true)))
|
|
.addSubcommand((s) => s.setName("set-max-stat-value").setDescription("Sanity ceiling for ATK/DEF/Heal shorthand input (default 50,000,000)")
|
|
.addIntegerOption((o) => o.setName("value").setDescription("Max allowed value").setRequired(true)))
|
|
);
|
|
|
|
// ── poll group ───────────────────────────────────────────────────────────────
|
|
cmd.addSubcommandGroup((g) => g
|
|
.setName("poll")
|
|
.setDescription("Configure Poll Settings")
|
|
.addSubcommand((s) => s
|
|
.setName("set-layout")
|
|
.setDescription("Change the poll display layout")
|
|
.addStringOption((o) => o
|
|
.setName("layout")
|
|
.setDescription("Layout name")
|
|
.setRequired(true)
|
|
.setAutocomplete(true)
|
|
))
|
|
);
|
|
|
|
// In poll group or as separate groups:
|
|
cmd.addSubcommand((s) => s
|
|
.setName("set-result-layout")
|
|
.setDescription("Change the TG result display layout")
|
|
.addStringOption((o) => o
|
|
.setName("layout").setDescription("Layout name")
|
|
.setRequired(true).setAutocomplete(true)
|
|
)
|
|
);
|
|
cmd.addSubcommand((s) => s
|
|
.setName("set-leaderboard-layout")
|
|
.setDescription("Change the leaderboard display layout")
|
|
.addStringOption((o) => o
|
|
.setName("layout").setDescription("Layout name")
|
|
.setRequired(true).setAutocomplete(true)
|
|
)
|
|
);
|
|
|
|
return cmd;
|
|
}
|
|
|
|
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 (!hasModeratorRole(member, Config.get({ section: "roles", key: "config" }))) {
|
|
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
|
}
|
|
|
|
const group = options.getSubcommandGroup();
|
|
const sub = options.getSubcommand();
|
|
|
|
const roleSubcommand = (cfgKey: "moderatorRoles" | "configRoles" | "tagRoles" | "sleepCheckRoles", action: string) => {
|
|
const key = ROLE_KEY_MAP[cfgKey];
|
|
if (action === "set") {
|
|
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 = 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 = 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") {
|
|
Config.reset({ section: "roles", key });
|
|
return void replyAndDelete(interaction, `✅ Roles reset to default.`);
|
|
}
|
|
};
|
|
|
|
// ── message ────────────────────────────────────────────────────────────────
|
|
if (group === "message") {
|
|
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 = 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 = options.getString("decision", true);
|
|
const key = d === "yes" ? "confirmYes" : "confirmNo";
|
|
Config.reset({ section: "poll", key });
|
|
return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`);
|
|
}
|
|
}
|
|
|
|
// ── roles ──────────────────────────────────────────────────────────────────
|
|
if (group === "roles") {
|
|
if (sub === "set-moderator") return roleSubcommand("moderatorRoles", "set");
|
|
if (sub === "add-moderator") return roleSubcommand("moderatorRoles", "add");
|
|
if (sub === "remove-moderator") return roleSubcommand("moderatorRoles", "remove");
|
|
if (sub === "reset-moderator") return roleSubcommand("moderatorRoles", "reset");
|
|
if (sub === "set-config") return roleSubcommand("configRoles", "set");
|
|
if (sub === "add-config") return roleSubcommand("configRoles", "add");
|
|
if (sub === "remove-config") return roleSubcommand("configRoles", "remove");
|
|
if (sub === "reset-config") return roleSubcommand("configRoles", "reset");
|
|
if (sub === "set-tag") return roleSubcommand("tagRoles", "set");
|
|
if (sub === "add-tag") return roleSubcommand("tagRoles", "add");
|
|
if (sub === "remove-tag") return roleSubcommand("tagRoles", "remove");
|
|
if (sub === "reset-tag") return roleSubcommand("tagRoles", "reset");
|
|
if (sub === "set-sleep-check") return roleSubcommand("sleepCheckRoles", "set");
|
|
if (sub === "add-sleep-check") return roleSubcommand("sleepCheckRoles", "add");
|
|
if (sub === "remove-sleep-check") return roleSubcommand("sleepCheckRoles", "remove");
|
|
if (sub === "reset-sleep-check") return roleSubcommand("sleepCheckRoles", "reset");
|
|
}
|
|
|
|
// ── channel ────────────────────────────────────────────────────────────────
|
|
if (group === "channel") {
|
|
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 = 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: 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 = 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") { 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") { 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 (sub === "set-sleep-check-minutes") {
|
|
Config.set({ section: "poll", key: "sleepCheckMinutesBefore", value: options.getInteger("minutes", true)! });
|
|
return void replyAndDelete(interaction, "✅ Sleep check timing updated. Restart the bot (or wait for the next scheduled reload) for the new cron time to take effect.");
|
|
}
|
|
if (sub === "set-max-stat-value") {
|
|
const value = options.getInteger("value", true)!;
|
|
Config.set({ section: "tg", key: "maxStatValue", value });
|
|
return void replyAndDelete(interaction, `✅ ATK/DEF/Heal sanity ceiling set to ${value.toLocaleString()}.`);
|
|
}
|
|
}
|
|
|
|
if (group === "poll") {
|
|
if (sub === "set-layout") return handleSetLayout(interaction);
|
|
}
|
|
|
|
if (group === null && sub === "set-result-layout") return SetResultLayoutCommands.handle(interaction);
|
|
if (group === null && sub === "set-leaderboard-layout") return SetLeaderboardLayoutCommands.handle(interaction);
|
|
if (group === null && sub === "set-layout") return SetLayoutCommands.handle(interaction);
|
|
} |