Naposledy aktivní 1 month ago

nuno revidoval tento gist 1 month ago. Přejít na revizi

1 file changed, 273 insertions

gistfile1.txt(vytvořil soubor)

@@ -0,0 +1,273 @@
1 + const {
2 + Client,
3 + GatewayIntentBits,
4 + ButtonBuilder,
5 + ButtonStyle,
6 + ActionRowBuilder,
7 + EmbedBuilder,
8 + REST,
9 + Routes,
10 + SlashCommandBuilder,
11 + } = require("discord.js");
12 + const cron = require("node-cron");
13 + const fs = require("fs");
14 +
15 + // ─── Config ────────────────────────────────────────────────────────────────
16 + const TOKEN = process.env.DISCORD_TOKEN;
17 + const CHANNEL_ID = process.env.CHANNEL_ID;
18 + const CLIENT_ID = process.env.CLIENT_ID;
19 + const GUILD_ID = process.env.GUILD_ID;
20 + const TG_HOUR = 20;
21 + const POST_HOUR = 10;
22 + const TIMEZONE = "Etc/GMT-2"; // change to e.g. "Europe/Lisbon"
23 + const LOCK_AT = 10; // clicks before a user is locked
24 + const OFFICER_ROLES = ["Ice King"]; // roles allowed to use /tg-poll
25 + // ────────────────────────────────────────────────────────────────────────────
26 +
27 + let MESSAGES = loadMessages();
28 +
29 + function loadMessages() {
30 + try {
31 + return JSON.parse(fs.readFileSync("./messages.json", "utf8"));
32 + } catch (err) {
33 + console.error("Failed to load messages.json:", err);
34 + return { public: { yes: [], no: [], users: {} }, ephemeral: { yes: [], no: [], users: {} } };
35 + }
36 + }
37 +
38 + const client = new Client({
39 + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
40 + });
41 +
42 + // vote entry: { username, votedAt, previousNoAt?, previousYesAt?, publicMessage? }
43 + let votes = { yes: new Map(), no: new Map() };
44 + let locks = new Map(); // userId → { yes: number, no: number }
45 + let pollLocked = false;
46 + let currentPollMessageId = null;
47 +
48 + function nowFormatted() {
49 + return new Date().toLocaleTimeString("en-GB", {
50 + timeZone: TIMEZONE,
51 + hour: "2-digit",
52 + minute: "2-digit",
53 + });
54 + }
55 +
56 + // Resolves the best message for a given block (public/ephemeral), vote type, click count, username
57 + function resolveMessage(block, voteType, clicks, username) {
58 + const userPool = MESSAGES[block]?.users?.[username]?.[voteType];
59 + const globalPool = MESSAGES[block]?.[voteType] ?? [];
60 +
61 + function bestMatch(pool) {
62 + if (!pool || pool.length === 0) return null;
63 + const sorted = [...pool].filter((m) => m.clicks <= clicks).sort((a, b) => b.clicks - a.clicks);
64 + return sorted[0]?.message ?? null;
65 + }
66 +
67 + return bestMatch(userPool) ?? bestMatch(globalPool) ?? null;
68 + }
69 +
70 + function hasOfficerRole(member) {
71 + return member.roles.cache.some((r) => OFFICER_ROLES.includes(r.name));
72 + }
73 +
74 + function formatVoters(map) {
75 + if (map.size === 0) return "—";
76 + return [...map.values()]
77 + .map((v) => {
78 + let line = `${v.username} · ${v.votedAt}`;
79 + if (v.previousNoAt) line += ` (changed their mind from No at ${v.previousNoAt})`;
80 + if (v.previousYesAt) line += ` (switched from Yes at ${v.previousYesAt})`;
81 + if (v.publicMessage) line += `\n*${v.publicMessage}*`;
82 + return line;
83 + })
84 + .join("\n");
85 + }
86 +
87 + function buildEmbed() {
88 + return new EmbedBuilder()
89 + .setTitle(`⚔️ TG — Tonight?${pollLocked ? " 🔒" : ""}`)
90 + .setDescription(`Is **TG happening tonight at ${TG_HOUR}:00**?\n`)
91 + .setColor(pollLocked ? 0x888888 : 0xe8a317)
92 + .addFields(
93 + { name: `✅ Yes (${votes.yes.size})`, value: formatVoters(votes.yes), inline: true },
94 + { name: `❌ No (${votes.no.size})`, value: formatVoters(votes.no), inline: true }
95 + )
96 + .setFooter({
97 + text: pollLocked
98 + ? "Poll is locked • Ice King can unlock with /tg-poll unlock"
99 + : "Vote updates live • Anyone can vote • You can switch your vote",
100 + })
101 + .setTimestamp();
102 + }
103 +
104 + function buildButtons() {
105 + const yesBtn = new ButtonBuilder()
106 + .setCustomId("tg_yes")
107 + .setLabel("✅ Yes")
108 + .setStyle(ButtonStyle.Success)
109 + .setDisabled(pollLocked);
110 +
111 + const noBtn = new ButtonBuilder()
112 + .setCustomId("tg_no")
113 + .setLabel("❌ No")
114 + .setStyle(ButtonStyle.Danger)
115 + .setDisabled(pollLocked);
116 +
117 + return new ActionRowBuilder().addComponents(yesBtn, noBtn);
118 + }
119 +
120 + async function updatePollMessage(channel) {
121 + if (!currentPollMessageId) return;
122 + try {
123 + const msg = await channel.messages.fetch(currentPollMessageId);
124 + await msg.edit({ embeds: [buildEmbed()], components: [buildButtons()] });
125 + } catch (err) {
126 + console.error("Failed to update poll message:", err);
127 + }
128 + }
129 +
130 + async function postDailyPoll() {
131 + try {
132 + const channel = await client.channels.fetch(CHANNEL_ID);
133 + if (!channel) return console.error("Channel not found.");
134 +
135 + votes = { yes: new Map(), no: new Map() };
136 + locks = new Map();
137 + pollLocked = false;
138 + currentPollMessageId = null;
139 +
140 + const msg = await channel.send({
141 + embeds: [buildEmbed()],
142 + components: [buildButtons()],
143 + });
144 + currentPollMessageId = msg.id;
145 + console.log(`[${new Date().toISOString()}] Poll posted.`);
146 + } catch (err) {
147 + console.error("Failed to post poll:", err);
148 + }
149 + }
150 +
151 + async function registerCommands() {
152 + const command = new SlashCommandBuilder()
153 + .setName("tg-poll")
154 + .setDescription("Manage the TG poll (Ice King only)")
155 + .addSubcommand((sub) =>
156 + sub.setName("start").setDescription("Post a fresh TG poll")
157 + )
158 + .addSubcommand((sub) =>
159 + sub.setName("unlock").setDescription("Unlock a locked TG poll")
160 + );
161 +
162 + const rest = new REST({ version: "10" }).setToken(TOKEN);
163 + await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), {
164 + body: [command.toJSON()],
165 + });
166 + console.log("Slash commands registered.");
167 + }
168 +
169 + client.on("interactionCreate", async (interaction) => {
170 + // ── Slash commands ──────────────────────────────────────────────────────
171 + if (interaction.isChatInputCommand() && interaction.commandName === "tg-poll") {
172 + const member = await interaction.guild.members.fetch(interaction.user.id);
173 +
174 + if (!hasOfficerRole(member)) {
175 + return interaction.reply({
176 + content: "❌ You don't have permission to use this command.",
177 + ephemeral: true,
178 + });
179 + }
180 +
181 + const sub = interaction.options.getSubcommand();
182 +
183 + if (sub === "start") {
184 + await interaction.reply({ content: "⚔️ Posting a fresh TG poll...", ephemeral: true });
185 + await postDailyPoll();
186 + return;
187 + }
188 +
189 + if (sub === "unlock") {
190 + if (!pollLocked) {
191 + return interaction.reply({ content: "ℹ️ The poll isn't locked.", ephemeral: true });
192 + }
193 + pollLocked = false;
194 + MESSAGES = loadMessages();
195 + const channel = await client.channels.fetch(CHANNEL_ID);
196 + await updatePollMessage(channel);
197 + return interaction.reply({ content: "🔓 Poll unlocked!", ephemeral: true });
198 + }
199 + }
200 +
201 + // ── Button interactions ─────────────────────────────────────────────────
202 + if (!interaction.isButton()) return;
203 + if (!["tg_yes", "tg_no"].includes(interaction.customId)) return;
204 +
205 + if (pollLocked) return interaction.deferUpdate();
206 +
207 + const userId = interaction.user.id;
208 + const member = await interaction.guild.members.fetch(userId);
209 + const username = member.nickname ?? interaction.user.username;
210 + const votedYes = interaction.customId === "tg_yes";
211 + const now = nowFormatted();
212 +
213 + if (!locks.has(userId)) locks.set(userId, { yes: 0, no: 0 });
214 + const userLocks = locks.get(userId);
215 +
216 + // Check if user is locked on this button
217 + if (votedYes && userLocks.yes >= LOCK_AT) return interaction.deferUpdate();
218 + if (!votedYes && userLocks.no >= LOCK_AT) return interaction.deferUpdate();
219 +
220 + // Increment click counter
221 + if (votedYes) userLocks.yes += 1;
222 + else userLocks.no += 1;
223 +
224 + const clicks = votedYes ? userLocks.yes : userLocks.no;
225 +
226 + // Resolve messages
227 + const publicMsg = resolveMessage("public", votedYes ? "yes" : "no", clicks, username);
228 + const ephemeralMsg = resolveMessage("ephemeral", votedYes ? "yes" : "no", clicks, username);
229 +
230 + if (votedYes) {
231 + const previousNo = votes.no.get(userId);
232 + votes.no.delete(userId);
233 + votes.yes.set(userId, {
234 + username,
235 + votedAt: now,
236 + previousNoAt: previousNo ? previousNo.votedAt : null,
237 + publicMessage: publicMsg,
238 + });
239 + } else {
240 + const previousYes = votes.yes.get(userId);
241 + votes.yes.delete(userId);
242 + votes.no.set(userId, {
243 + username,
244 + votedAt: now,
245 + previousYesAt: previousYes ? previousYes.votedAt : null,
246 + publicMessage: publicMsg,
247 + });
248 + }
249 +
250 + const locked = clicks >= LOCK_AT;
251 + if (locked) pollLocked = true;
252 +
253 + await interaction.reply({
254 + content: ephemeralMsg
255 + ? `${ephemeralMsg}${locked ? "\n🔒 *You've been locked in.*" : ""}`
256 + : locked
257 + ? "🔒 You've been locked in."
258 + : votedYes ? "✅ Vote registered!" : "❌ Vote registered!",
259 + ephemeral: true,
260 + });
261 +
262 + const channel = await client.channels.fetch(CHANNEL_ID);
263 + await updatePollMessage(channel);
264 + });
265 +
266 + client.once("clientReady", async () => {
267 + console.log(`Logged in as ${client.user.tag}`);
268 + await registerCommands();
269 + cron.schedule(`0 ${POST_HOUR} * * *`, () => postDailyPoll());
270 + console.log(`Poll scheduled daily at ${POST_HOUR}:00.`);
271 + });
272 +
273 + client.login(TOKEN);
Novější Starší