tg-bot-ts/src/systems/poll.ts

160 lines
No EOL
6.1 KiB
TypeScript

import {
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
TextChannel,
GuildMember,
} from "discord.js";
import { PollState, VoteEntry, Nation, TGSlot } from "@src/types";
import { Emoji } from "@systems/emojis";
import { Nations } from "@systems/nations";
import { format } from "@format";
import { persist } from "@systems/pollPersistence"
import { clearSessionBorrows, getEffectiveCharacter } from "@systems/borrow";
import { clearAllImpersonations } from "@systems/impersonate";
import { Attendance } from "@systems/attendance";
import { PollUI } from "@ui/poll";
// ─── Poll state ───────────────────────────────────────────────────────────────
export const polls: Map<number, PollState> = new Map();
const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = new Map();
export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void {
const e = publicOverrides.get(userId) ?? {};
e[voteType] = message;
publicOverrides.set(userId, e);
}
export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void {
if (!voteType) { publicOverrides.delete(userId); return; }
const e = publicOverrides.get(userId);
if (e) delete e[voteType];
}
export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void {
const e = ephemeralOverrides.get(userId) ?? {};
e[voteType] = message;
ephemeralOverrides.set(userId, e);
}
export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void {
if (!voteType) { ephemeralOverrides.delete(userId); return; }
const e = ephemeralOverrides.get(userId);
if (e) delete e[voteType];
}
export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined {
return publicOverrides.get(userId)?.[voteType];
}
export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined {
return ephemeralOverrides.get(userId)?.[voteType];
}
export function resetPollOverrides(): void {
publicOverrides.clear();
ephemeralOverrides.clear();
}
export function lockPoll(slot: number): void {
const state = polls.get(slot);
if (!state) return;
state.locked = true;
// Snapshot the userKeys that were in yes at lock time
state.lockedYesKeys = new Set(
[...state.yes.values()]
.map((e) => e.userKey)
.filter((k): k is string => !!k)
);
persist.save(polls)
Attendance.snapshot(slot, state.lockedYesKeys);
}
// ─── Embed building ───────────────────────────────────────────────────────────
export function buildButtons(
disabled: boolean,
showSubmit?: boolean
): ActionRowBuilder<ButtonBuilder>[] {
if (showSubmit) {
const scoreEmoji = Emoji.get("score");
const submitBtn = new ButtonBuilder()
.setCustomId("tg_score_submit")
.setLabel("Submit Score")
.setStyle(ButtonStyle.Secondary);
if (scoreEmoji) submitBtn.setEmoji(format.emoji(scoreEmoji) ?? scoreEmoji);
return [new ActionRowBuilder<ButtonBuilder>().addComponents(submitBtn)];
}
const yesBtn = new ButtonBuilder()
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
const noBtn = new ButtonBuilder()
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
return [new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn)];
}
export async function updatePollMessage(
channel: TextChannel,
slot: number,
overrideLockMsg?: string,
showSubmit?: boolean
): Promise<void> {
const state = polls.get(slot);
if (!state?.messageId) return;
console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} messageId=${state.messageId}`);
const buttons = buildButtons(state.locked || state.confirmed !== null, showSubmit);
console.log(`[updatePollMessage] components rows=${buttons.length}`);
try {
const msg = await channel.messages.fetch(state.messageId);
await msg.edit({ embeds: [PollUI.buildEmbed(state, { overrideLockMsg })], components: buttons });
} catch (err) {
console.error("Failed to update poll message:", err);
}
}
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
resetPollOverrides();
persist.clear();
clearSessionBorrows();
clearAllImpersonations();
const state: PollState = {
messageId: null, slot: slot.tgHour,
yes: new Map(), no: new Map(),
locked: false, confirmed: null,
};
polls.set(slot.tgHour, state);
const msg = await channel.send({ embeds: [PollUI.buildEmbed(state)], components: buildButtons(false) });
state.messageId = msg.id;
console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`);
persist.save(polls)
}
export function createVoteEntry(
userId: string,
member: GuildMember,
userKey: string | null,
discordUsername: string
): Omit<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
const serverNickname = member.nickname ?? null;
const globalNickname = member.user.globalName ?? null;
const displayName = serverNickname ?? globalNickname ?? discordUsername;
const { char, borrowedFrom: bf } = userKey
? getEffectiveCharacter(userKey)
: { char: null, borrowedFrom: null };
console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
return {
userKey: userKey ?? (undefined as any),
displayName,
characterName: char?.name,
characterClass: char?.class,
characterLevel: char?.level,
characterNation: char?.nation ?? (Nations.resolve(member, userKey) ?? undefined),
borrowedFrom: bf ?? undefined,
};
}