- ATK/DEF/Heal now accept K/M shorthand (500K, 1.4M, case-insensitive) via
a shared parser (@helpers/stat-value), used identically by the Submit
Score modal, /tg score set, and /tg-admin score-inject
- /tg-admin score-modal — officers can open the Submit Score modal on
behalf of any player; the modal's customId now carries the target
userKey so submission always resolves to the intended player rather
than whoever's Discord client is submitting it
- fix: /tg call now actually reveals the Submit Score button (was
recording the call but never opening submission)
- fix: /tg poll reload no longer resurrects Submit Score / Yes-No buttons
that had already been removed — poll state now tracks submission-open
and buttons-removed explicitly instead of re-deriving it from
locked/confirmed on every render
- fix: midnight cleanup now fully removes all poll buttons (not just
Submit Score) instead of leaving them disabled
- Sleep Check — players on a configured role get flagged (💤 shown on
their poll row) the moment they vote Yes; a scheduled per-slot job DMs
everyone currently flagged at a configurable time before TG (default
20 min). State changes and DM sending are intentionally decoupled —
flagging never itself sends a message. Officers can also flag/clear
manually via /tg-admin sleep-check set|clear
- data/updates/v0.9.2 and new v0.10 changelog entries for the above
- merged the two .claude reference docs into one, removed stale/fixed
items, documented the new systems and known deferred gaps
133 lines
No EOL
4.7 KiB
TypeScript
133 lines
No EOL
4.7 KiB
TypeScript
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
|
|
import { Config } from "@systems/config";
|
|
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
|
|
import { handleInteraction } from "@handlers/interactions";
|
|
import { buildTgCommand } from "@commands/tg";
|
|
import { buildTgConfigCommand } from "@commands/tgConfig";
|
|
import { TGSlot } from "@src/types";
|
|
import { persist } from "@systems/pollPersistence"
|
|
import { buildTgAdminCommand } from "@commands/tgAdmin";
|
|
import { Scheduler } from "@systems/scheduler";
|
|
import { Runtime, RuntimeEvents } from "@systems/runtime";
|
|
import { Leaderboard } from "@systems/leaderboard";
|
|
import { Result } from "@systems/result";
|
|
import { Attendance } from "@systems/attendance";
|
|
import { DiscordClient } from "@src/discord/client";
|
|
import { SleepCheck } from "@systems/sleepCheck";
|
|
|
|
const TOKEN = process.env.DISCORD_TOKEN!;
|
|
const CLIENT_ID = process.env.CLIENT_ID!;
|
|
const GUILD_ID = process.env.GUILD_ID!;
|
|
|
|
const client = new Client({
|
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
|
|
});
|
|
|
|
async function registerCommands(): Promise<void> {
|
|
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
|
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
|
|
body: [
|
|
buildTgCommand().toJSON(),
|
|
buildTgConfigCommand().toJSON(),
|
|
buildTgAdminCommand().toJSON(),
|
|
],
|
|
});
|
|
console.log("Slash commands registered.");
|
|
}
|
|
|
|
async function onPollOpen(slot: TGSlot): Promise<void> {
|
|
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);
|
|
}
|
|
|
|
// Fires at tgHour exactly (e.g. 20:00) — voting closes, lockedYesKeys snapshotted
|
|
async function onPollLock(slot: TGSlot): Promise<void> {
|
|
const state = polls.get(slot.tgHour);
|
|
if (!state || state.locked) return;
|
|
|
|
lockPoll(slot.tgHour);
|
|
|
|
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
|
|
await updatePollMessage(channel, slot.tgHour);
|
|
console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`);
|
|
}
|
|
|
|
// Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score.
|
|
// Fires regardless of whether /tg call already opened it early — harmless
|
|
// no-op in that case since scoreSubmitOpen is already true.
|
|
async function onPollClose(slot: TGSlot): Promise<void> {
|
|
const state = polls.get(slot.tgHour);
|
|
if (!state) return;
|
|
|
|
const channelId = Config.get({ section: "channels", key: "poll" });
|
|
const channel = await client.channels.fetch(channelId) as any;
|
|
if (!channel) return;
|
|
|
|
state.scoreSubmitOpen = true;
|
|
persist.save(polls);
|
|
|
|
await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true
|
|
console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`);
|
|
}
|
|
|
|
// Fires at tgHour - sleepCheckMinutesBefore (e.g. 19:40 for a 20:00 TG,
|
|
// default 20 min) — sweeps current Yes voters for the sleepCheck role.
|
|
async function onSleepCheck(slot: TGSlot): Promise<void> {
|
|
await SleepCheck.sweepPollForSleepCheck(client, slot);
|
|
}
|
|
|
|
client.on("interactionCreate", handleInteraction);
|
|
|
|
client.once("clientReady", async () => {
|
|
console.log(`Logged in as ${client.user!.tag}`);
|
|
|
|
await Runtime.start();
|
|
|
|
DiscordClient.set(client);
|
|
|
|
// Register event handlers
|
|
RuntimeEvents.on("scoreSubmitted", async ({ historyKey }) => {
|
|
await Leaderboard.update();
|
|
if (Attendance.allSubmitted(historyKey)) {
|
|
RuntimeEvents.emit("allScoresSubmitted", { historyKey });
|
|
}
|
|
});
|
|
|
|
RuntimeEvents.on("allScoresSubmitted", async ({ historyKey }) => {
|
|
await Result.post({ historyKey });
|
|
});
|
|
|
|
const restored = persist.load();
|
|
if (restored) {
|
|
for (const [slot, state] of restored) polls.set(slot, state);
|
|
|
|
// Re-render all restored poll messages
|
|
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);
|
|
}
|
|
console.log("Poll state restored and messages re-rendered.");
|
|
}
|
|
|
|
const guild = await client.guilds.fetch(GUILD_ID);
|
|
await guild.members.fetch();
|
|
console.log(`Member cache warmed: ${guild.members.cache.size} members`);
|
|
|
|
if (process.argv.includes("--register")) {
|
|
await registerCommands();
|
|
}
|
|
|
|
Scheduler.schedule(client, onPollOpen, onPollLock, onPollClose, onSleepCheck);
|
|
|
|
console.log("Bot ready.");
|
|
});
|
|
|
|
client.login(TOKEN); |