最終更新 1 month ago

buttons.ts Raw
1import { ButtonInteraction, TextChannel } from "discord.js";
2import { cfg } from "../systems/config";
3import { pollReplyAndDelete } from "../utils";
4import { resolveUser } from "../systems/users";
5import { resolveMessage, nowFormatted } from "../systems/messages";
6import { resolveNation } from "../systems/nations";
7import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "../systems/poll";
8import { showConflictEmbed, handleConflictButton } from "../systems/conflict";
9import { getCharacters } from "../systems/characters";
10
11const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
12
13const clickCounts = new Map<string, { yes: number; no: number }>();
14
15export async function handleButton(interaction: ButtonInteraction): Promise<void> {
16 if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
17
18 // Defer immediately to avoid 3s timeout
19 await interaction.deferUpdate();
20
21 const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
22 if (slot === undefined) return;
23
24 const state = polls.get(slot)!;
25 if (state.locked || state.confirmed !== null) return;
26
27 const userId = interaction.user.id;
28 const member = await interaction.guild!.members.fetch(userId);
29 const user = await resolveUser(member);
30 const votedYes = interaction.customId === "tg_yes";
31 const now = nowFormatted();
32
33 // Check nation — block if no nation
34 const nation = resolveNation(member, user.userKey);
35 if (!nation) {
36 await pollReplyAndDelete(interaction, "❌ You must be in Capella or Procyon to vote.");
37 return;
38 }
39
40 // Click tracking
41 if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 });
42 const clicks = clickCounts.get(userId)!;
43
44 if (votedYes && clicks.yes >= LOCK_AT) return;
45 if (!votedYes && clicks.no >= LOCK_AT) return;
46
47 // Ignore same vote
48 if (votedYes && state.yes.has(userId)) return;
49 if (!votedYes && state.no.has(userId)) return;
50
51 if (votedYes) clicks.yes += 1;
52 else clicks.no += 1;
53
54 const clickCount = votedYes ? clicks.yes : clicks.no;
55
56 // Resolve messages — officer override takes priority
57 const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no")
58 ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
59
60 const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no")
61 ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
62
63 const baseEntry = createVoteEntry(userId, member, user.userKey, user.discordUsername);
64
65 // Check if character is already in use by another voter
66 if (baseEntry.characterName) {
67 for (const [otherId, entry] of [...state.yes.entries(), ...state.no.entries()]) {
68 if (otherId !== userId && entry.characterName === baseEntry.characterName) {
69 // Check if this user is the owner of the character
70 const borrowerKey = entry.borrowedFrom ? entry.userKey : null;
71 const ownerKey = entry.borrowedFrom ?? entry.userKey;
72 const isOwner = user.userKey === ownerKey;
73
74 if (isOwner && baseEntry.userKey) {
75 // Owner trying to reclaim — show conflict embed
76 const allChars = getCharacters(baseEntry.userKey);
77 const borrowedChar = allChars.find((c) => c.name === baseEntry.characterName);
78 if (borrowedChar) {
79 await showConflictEmbed(interaction, baseEntry.userKey, entry.userKey, borrowedChar, allChars);
80 return;
81 }
82 }
83
84 await pollReplyAndDelete(interaction,
85 `❌ **${baseEntry.characterName}** is already in the poll by another player.`
86 );
87 return;
88 }
89 }
90 }
91
92 if (votedYes) {
93 const previousNo = state.no.get(userId);
94 state.no.delete(userId);
95 state.yes.set(userId, {
96 ...baseEntry,
97 votedAt: now,
98 previousNoAt: previousNo?.votedAt,
99 publicMessage: publicMsg ?? undefined,
100 });
101 } else {
102 const previousYes = state.yes.get(userId);
103 state.yes.delete(userId);
104 state.no.set(userId, {
105 ...baseEntry,
106 votedAt: now,
107 previousYesAt: previousYes?.votedAt,
108 publicMessage: publicMsg ?? undefined,
109 });
110 }
111
112 const locked = clickCount >= LOCK_AT;
113 if (locked) state.locked = true;
114
115 // Send poll ephemeral follow-up
116 const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
117 const msgContent = ephemeralMsg
118 ? `${ephemeralMsg}${lockedSuffix}`
119 : locked ? "🔒 You've been locked in." : null;
120 await pollReplyAndDelete(interaction, msgContent);
121
122 const channel = interaction.channel as TextChannel;
123 await updatePollMessage(channel, slot);
124}
125
126export function resetClickCounts(): void {
127 clickCounts.clear();
128}