Последняя активность 1 month ago

nuno ревизий этого фрагмента 1 month ago. К ревизии

1 file changed, 197 insertions

buttons.ts(файл создан)

@@ -0,0 +1,197 @@
1 + import { ButtonInteraction, TextChannel } from "discord.js";
2 + import { cfg } from "@systems/config";
3 + import { pollReplyAndDelete } from "../utils";
4 + import { resolveUser } from "@systems/users";
5 + import { resolveMessage, nowFormatted } from "@systems/messages";
6 + import { resolveNation } from "@systems/nations";
7 + import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
8 + import { showConflictEmbed } from "@systems/conflict";
9 + import { getCharacters } from "@systems/characters";
10 + import { getImpersonation } from "@systems/impersonate";
11 + import { format } from "@format";
12 + import { Character } from "@src/types";
13 +
14 + const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
15 +
16 + const clickCounts = new Map<string, { yes: number; no: number }>();
17 +
18 + // ─── Helpers ──────────────────────────────────────────────────────────────────
19 +
20 + function isCharacterInPoll(
21 + state: ReturnType<typeof polls.get>,
22 + charName: string,
23 + excludeVoteId: string,
24 + excludeUserKey: string = ""
25 + ): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } {
26 + if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined };
27 + for (const [otherId, entry] of state.yes.entries()) {
28 + if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) {
29 + return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom };
30 + }
31 + }
32 + return { found: false, entryUserKey: null, borrowedFrom: undefined };
33 + }
34 +
35 + function isCharacterOwner(userKey: string | null, charName: string): boolean {
36 + if (!userKey) return false;
37 + return getCharacters(userKey).some((c) => c.name === charName);
38 + }
39 +
40 + async function handleCharacterConflict(
41 + interaction: ButtonInteraction,
42 + userKey: string | null,
43 + char: Character,
44 + entryUserKey: string | null,
45 + clicks: { yes: number; no: number },
46 + votedYes: boolean
47 + ): Promise<boolean> {
48 + // Decrement click since we're blocking this vote
49 + if (votedYes) clicks.yes -= 1;
50 + else clicks.no -= 1;
51 +
52 + const isOwner = isCharacterOwner(userKey, char.name);
53 +
54 + if (isOwner && userKey) {
55 + const allChars = getCharacters(userKey);
56 + const borrowedChar = allChars.find((c) => c.name === char.name);
57 + if (borrowedChar && entryUserKey) {
58 + await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars);
59 + return true;
60 + }
61 + }
62 +
63 + const slot = [...polls.keys()][0];
64 + const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
65 +
66 + await interaction.followUp({
67 + content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
68 + // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
69 + ephemeral: true
70 + });
71 + return true;
72 + }
73 +
74 + // ─── Main button handler ──────────────────────────────────────────────────────
75 +
76 + export async function handleButton(interaction: ButtonInteraction): Promise<void> {
77 + if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
78 +
79 + try {
80 + await interaction.deferUpdate();
81 + } catch {
82 + return;
83 + }
84 +
85 + const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
86 + if (slot === undefined) return;
87 +
88 + const state = polls.get(slot)!;
89 + if (state.locked || state.confirmed !== null) return;
90 +
91 + const userId = interaction.user.id;
92 + const member = interaction.guild!.members.cache.get(userId)
93 + ?? await interaction.guild!.members.fetch(userId);
94 + const user = await resolveUser(member);
95 + const votedYes = interaction.customId === "tg_yes";
96 + const now = nowFormatted();
97 +
98 + const impersonating = getImpersonation(userId);
99 + const voteId = impersonating ? `impersonated:${impersonating}` : userId;
100 + const lookupUsername = user.lookupUsername ?? user.discordUsername;
101 +
102 + // Nation check
103 + const nation = resolveNation(member, user.userKey);
104 + if (!nation) {
105 + const capella = format.nation("Capella");
106 + const procyon = format.nation("Procyon");
107 + await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
108 + return;
109 + }
110 +
111 + // Click tracking
112 + if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
113 + const clicks = clickCounts.get(voteId)!;
114 +
115 + if (votedYes && clicks.yes >= LOCK_AT) return;
116 + if (!votedYes && clicks.no >= LOCK_AT) return;
117 +
118 + // Ignore same vote
119 + if (votedYes && state.yes.has(voteId)) return;
120 + if (!votedYes && state.no.has(voteId)) return;
121 +
122 + // Increment click (may be decremented in conflict handler)
123 + if (votedYes) clicks.yes += 1;
124 + else clicks.no += 1;
125 +
126 + const clickCount = votedYes ? clicks.yes : clicks.no;
127 +
128 + // Resolve messages
129 + const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
130 + ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
131 +
132 + const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
133 + ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
134 +
135 + const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
136 +
137 + // Character conflict check — applies to both Yes and No
138 + if (baseEntry.characterName) {
139 + const conflictChar = {
140 + name: baseEntry.characterName!,
141 + class: baseEntry.characterClass!,
142 + level: baseEntry.characterLevel!,
143 + nation: baseEntry.characterNation!,
144 + active: false, // not needed for display
145 + };
146 +
147 + const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
148 + state, baseEntry.characterName, voteId, user.userKey ?? ""
149 + );
150 + if (found) {
151 + await handleCharacterConflict(
152 + interaction, user.userKey, conflictChar,
153 + entryUserKey, clicks, votedYes
154 + );
155 + return;
156 + }
157 + }
158 +
159 + // Register vote
160 + if (votedYes) {
161 + const previousNo = state.no.get(voteId);
162 + state.no.delete(voteId);
163 + state.yes.set(voteId, {
164 + ...baseEntry,
165 + discordId: userId,
166 + votedAt: now,
167 + previousNoAt: previousNo?.votedAt,
168 + publicMessage: publicMsg ?? undefined,
169 + });
170 + } else {
171 + const previousYes = state.yes.get(voteId);
172 + state.yes.delete(voteId);
173 + state.no.set(voteId, {
174 + ...baseEntry,
175 + votedAt: now,
176 + discordId: userId,
177 + previousYesAt: previousYes?.votedAt,
178 + publicMessage: publicMsg ?? undefined,
179 + });
180 + }
181 +
182 + const locked = clickCount >= LOCK_AT;
183 + if (locked) state.locked = true;
184 +
185 + const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
186 + const msgContent = ephemeralMsg
187 + ? `${ephemeralMsg}${lockedSuffix}`
188 + : locked ? "🔒 You've been locked in." : null;
189 + await pollReplyAndDelete(interaction, msgContent);
190 +
191 + const channel = interaction.channel as TextChannel;
192 + await updatePollMessage(channel, slot);
193 + }
194 +
195 + export function resetClickCounts(): void {
196 + clickCounts.clear();
197 + }
Новее Позже