nuno revisó este gist 1 month ago. Ir a la revisión
1 file changed, 273 insertions
gistfile1.txt(archivo creado)
| @@ -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); | |
Siguiente
Anterior