tg-bot-ts/src/index.ts
Nuno Duque Nunes fc4f322885 feature: shorthand stat input, admin score tools, and sleep check system
- 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
2026-07-30 04:16:42 +01:00

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);