Остання активність 1 month ago

nuno ревизій цього gist 1 month ago. До ревизії

1 file changed, 280 insertions

gistfile1.txt(файл створено)

@@ -0,0 +1,280 @@
1 + import {
2 + Client,
3 + ButtonBuilder,
4 + ButtonStyle,
5 + ActionRowBuilder,
6 + EmbedBuilder,
7 + ButtonInteraction,
8 + TextChannel,
9 + } from "discord.js";
10 + import { cfg } from "./config";
11 + import { getCharacters, getCharacterByName, setActiveCharacter } from "./characters";
12 + import { clearSessionBorrowForUser, setPersistentPreference, getEffectiveCharacter } from "./borrow";
13 + import { polls, updatePollMessage, createVoteEntry } from "./poll";
14 + import { resolveMessage, nowFormatted } from "./messages";
15 + import { getClassEmoji } from "./emojis";
16 + import { Character } from "../types";
17 +
18 + const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
19 +
20 + // Stores pending conflict resolutions: buttonId → { ownerUsermapKey, borrowerUsermapKey, charName, ownerId }
21 + const pendingConflicts = new Map<string, {
22 + ownerUsermapKey: string;
23 + borrowerUsermapKey: string;
24 + charName: string;
25 + ownerId: string;
26 + page: number;
27 + }>();
28 +
29 + function formatChar(char: Character): string {
30 + const emoji = getClassEmoji(char.class) || char.class;
31 + return `${emoji} ${char.level} ${char.name}`;
32 + }
33 +
34 + // Parse <:name:id> or unicode emoji string for use with ButtonBuilder.setEmoji()
35 + function parseEmoji(emojiStr: string): { name: string; id: string } | string | null {
36 + if (!emojiStr) return null;
37 + const match = emojiStr.match(/^<:(\w+):(\d+)>$/);
38 + if (match) return { name: match[1], id: match[2] };
39 + return emojiStr; // unicode fallback
40 + }
41 +
42 + // For button labels — emoji via setEmoji(), text only in label
43 + function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
44 + const emojiStr = getClassEmoji(char.class);
45 + const emoji = parseEmoji(emojiStr);
46 + btn.setLabel(`${char.level} ${char.name}`);
47 + if (emoji) btn.setEmoji(emoji as any);
48 + return btn;
49 + }
50 +
51 + function buildConflictEmbed(
52 + borrowerKey: string,
53 + char: Character,
54 + ownerKey: string
55 + ): EmbedBuilder {
56 + const charDisplay = formatChar(char);
57 + return new EmbedBuilder()
58 + .setTitle("⚠️ Character Conflict")
59 + .setDescription(
60 + `**${charDisplay}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.`
61 + )
62 + .setColor(0xe8a317);
63 + }
64 +
65 + function buildConflictButtons(
66 + ownerUsermapKey: string,
67 + borrowerUsermapKey: string,
68 + borrowedCharName: string,
69 + ownerId: string,
70 + allChars: Character[],
71 + page: number
72 + ): ActionRowBuilder<ButtonBuilder>[] {
73 + const PAGE_SIZE = 4; // leave 1 slot for reclaim on first row
74 + const otherChars = allChars.filter((c) => c.name !== borrowedCharName);
75 + const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
76 + const hasMore = otherChars.length > (page + 1) * PAGE_SIZE;
77 + const hasPrev = page > 0;
78 +
79 + const rows: ActionRowBuilder<ButtonBuilder>[] = [];
80 +
81 + // Row 1: char switch buttons
82 + const charButtons = pageChars.map((char) => {
83 + const id = `conflict_switch:${ownerUsermapKey}:${borrowerUsermapKey}:${char.name}:${ownerId}`;
84 + pendingConflicts.set(id, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page });
85 + return applyCharToButton(
86 + new ButtonBuilder().setCustomId(id).setStyle(ButtonStyle.Secondary),
87 + char
88 + );
89 + });
90 +
91 + if (charButtons.length > 0) {
92 + rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...charButtons));
93 + }
94 +
95 + // Row 2: reclaim + pagination
96 + const reclaimId = `conflict_reclaim:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}`;
97 + pendingConflicts.set(reclaimId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page });
98 +
99 + const borrowed = allChars.find((c) => c.name === borrowedCharName);
100 + const reclaimBtn = borrowed
101 + ? applyCharToButton(
102 + new ButtonBuilder().setCustomId(reclaimId).setStyle(ButtonStyle.Danger),
103 + borrowed
104 + ).setLabel(`↩️ ${borrowed.level} ${borrowed.name}`)
105 + : new ButtonBuilder().setCustomId(reclaimId).setLabel(`↩️ Reclaim ${borrowedCharName}`).setStyle(ButtonStyle.Danger);
106 +
107 + const navButtons: ButtonBuilder[] = [reclaimBtn];
108 +
109 + if (hasPrev) {
110 + const prevId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page - 1}`;
111 + pendingConflicts.set(prevId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page - 1 });
112 + navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary));
113 + }
114 + if (hasMore) {
115 + const nextId = `conflict_page:${ownerUsermapKey}:${borrowerUsermapKey}:${borrowedCharName}:${ownerId}:${page + 1}`;
116 + pendingConflicts.set(nextId, { ownerUsermapKey, borrowerUsermapKey, charName: borrowedCharName, ownerId, page: page + 1 });
117 + navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary));
118 + }
119 +
120 + rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navButtons));
121 + return rows;
122 + }
123 +
124 + // Show conflict embed to owner
125 + export async function showConflictEmbed(
126 + interaction: ButtonInteraction,
127 + ownerUsermapKey: string,
128 + borrowerUsermapKey: string,
129 + borrowedChar: Character,
130 + allOwnerChars: Character[]
131 + ): Promise<void> {
132 + const embed = buildConflictEmbed(borrowerUsermapKey, borrowedChar, ownerUsermapKey);
133 + const buttons = buildConflictButtons(
134 + ownerUsermapKey, borrowerUsermapKey, borrowedChar.name,
135 + interaction.user.id, allOwnerChars, 0
136 + );
137 + await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true });
138 + }
139 +
140 + // Handle conflict button interactions
141 + export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
142 + const { customId } = interaction;
143 +
144 + if (customId.startsWith("conflict_page:")) {
145 + const parts = customId.split(":");
146 + const ownerKey = parts[1];
147 + const borrowerKey = parts[2];
148 + const charName = parts[3];
149 + const ownerId = parts[4];
150 + const page = parseInt(parts[5]);
151 +
152 + const allChars = getCharacters(ownerKey);
153 + const borrowed = allChars.find((c) => c.name === charName);
154 + if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true });
155 +
156 + const embed = buildConflictEmbed(borrowerKey, borrowed, ownerKey);
157 + const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page);
158 + await interaction.update({ embeds: [embed], components: buttons });
159 + return;
160 + }
161 +
162 + if (customId.startsWith("conflict_switch:")) {
163 + const parts = customId.split(":");
164 + const ownerKey = parts[1];
165 + const borrowerKey = parts[2];
166 + const newCharName = parts[3];
167 + const ownerId = parts[4];
168 +
169 + // Switch owner to the selected char
170 + setActiveCharacter(ownerKey, newCharName);
171 + clearSessionBorrowForUser(ownerKey);
172 +
173 + const slot = [...polls.keys()][0];
174 + const state = slot !== undefined ? polls.get(slot) : null;
175 +
176 + if (state && AUTO_VOTE_ON_SWITCH) {
177 + // Auto-vote Yes for owner with new char
178 + const guild = interaction.guild!;
179 + const member = await guild.members.fetch(ownerId);
180 + const { char } = getEffectiveCharacter(ownerKey);
181 + const now = nowFormatted();
182 + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
183 +
184 + state.yes.set(ownerId, {
185 + userKey: ownerKey,
186 + displayName: member.nickname ?? member.user.globalName ?? member.user.username,
187 + characterName: char?.name,
188 + characterClass: char?.class,
189 + characterLevel: char?.level,
190 + characterNation: char?.nation,
191 + votedAt: now,
192 + publicMessage: publicMsg ?? undefined,
193 + });
194 +
195 + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
196 + await updatePollMessage(channel, slot!);
197 + }
198 +
199 + await interaction.update({
200 + embeds: [new EmbedBuilder()
201 + .setTitle("✅ Switched")
202 + .setDescription(`Switched to **${newCharName}**${AUTO_VOTE_ON_SWITCH ? " and voted Yes." : "."}`)
203 + .setColor(0x57f287)],
204 + components: [],
205 + });
206 + return;
207 + }
208 +
209 + if (customId.startsWith("conflict_reclaim:")) {
210 + const parts = customId.split(":");
211 + const ownerKey = parts[1];
212 + const borrowerKey = parts[2];
213 + const charName = parts[3];
214 + const ownerId = parts[4];
215 +
216 + const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; // "revert" | "remove"
217 + const slot = [...polls.keys()][0];
218 + const state = slot !== undefined ? polls.get(slot) : null;
219 +
220 + if (state) {
221 + // Find borrower's vote entry
222 + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
223 + if (entry.userKey === borrowerKey) {
224 + if (reclaimBehavior === "remove") {
225 + state.yes.delete(id);
226 + state.no.delete(id);
227 + } else {
228 + // Revert borrower to their own active char
229 + clearSessionBorrowForUser(borrowerKey);
230 + const { char: ownChar } = getEffectiveCharacter(borrowerKey);
231 + if (ownChar) {
232 + entry.characterName = ownChar.name;
233 + entry.characterClass = ownChar.class;
234 + entry.characterLevel = ownChar.level;
235 + entry.characterNation = ownChar.nation;
236 + entry.borrowedFrom = undefined;
237 + } else {
238 + // No own char — remove from poll
239 + state.yes.delete(id);
240 + state.no.delete(id);
241 + }
242 + }
243 + break;
244 + }
245 + }
246 +
247 + // Owner joins with their character
248 + const guild = interaction.guild!;
249 + const member = await guild.members.fetch(ownerId);
250 + setActiveCharacter(ownerKey, charName);
251 + clearSessionBorrowForUser(ownerKey);
252 + const { char } = getEffectiveCharacter(ownerKey);
253 + const now = nowFormatted();
254 + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
255 +
256 + state.yes.set(ownerId, {
257 + userKey: ownerKey,
258 + displayName: member.nickname ?? member.user.globalName ?? member.user.username,
259 + characterName: char?.name,
260 + characterClass: char?.class,
261 + characterLevel: char?.level,
262 + characterNation: char?.nation,
263 + votedAt: now,
264 + publicMessage: publicMsg ?? undefined,
265 + });
266 +
267 + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
268 + await updatePollMessage(channel, slot!);
269 + }
270 +
271 + await interaction.update({
272 + embeds: [new EmbedBuilder()
273 + .setTitle("↩️ Reclaimed")
274 + .setDescription(`**${charName}** has been reclaimed from **${borrowerKey}** and you've been added to the poll.`)
275 + .setColor(0x57f287)],
276 + components: [],
277 + });
278 + return;
279 + }
280 + }
Новіше Пізніше