const { Client, GatewayIntentBits, ButtonBuilder, ButtonStyle, ActionRowBuilder, EmbedBuilder, REST, Routes, SlashCommandBuilder, } = require("discord.js"); const cron = require("node-cron"); const fs = require("fs"); // ─── Config ──────────────────────────────────────────────────────────────── const TOKEN = process.env.DISCORD_TOKEN; const CHANNEL_ID = process.env.CHANNEL_ID; const CLIENT_ID = process.env.CLIENT_ID; const GUILD_ID = process.env.GUILD_ID; const TG_HOUR = 20; const POST_HOUR = 10; const TIMEZONE = "Etc/GMT-2"; // change to e.g. "Europe/Lisbon" const LOCK_AT = 10; // clicks before a user is locked const OFFICER_ROLES = ["Ice King"]; // roles allowed to use /tg-poll // ──────────────────────────────────────────────────────────────────────────── let MESSAGES = loadMessages(); function loadMessages() { try { return JSON.parse(fs.readFileSync("./messages.json", "utf8")); } catch (err) { console.error("Failed to load messages.json:", err); return { public: { yes: [], no: [], users: {} }, ephemeral: { yes: [], no: [], users: {} } }; } } const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], }); // vote entry: { username, votedAt, previousNoAt?, previousYesAt?, publicMessage? } let votes = { yes: new Map(), no: new Map() }; let locks = new Map(); // userId → { yes: number, no: number } let pollLocked = false; let currentPollMessageId = null; function nowFormatted() { return new Date().toLocaleTimeString("en-GB", { timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit", }); } // Resolves the best message for a given block (public/ephemeral), vote type, click count, username function resolveMessage(block, voteType, clicks, username) { const userPool = MESSAGES[block]?.users?.[username]?.[voteType]; const globalPool = MESSAGES[block]?.[voteType] ?? []; function bestMatch(pool) { if (!pool || pool.length === 0) return null; const sorted = [...pool].filter((m) => m.clicks <= clicks).sort((a, b) => b.clicks - a.clicks); return sorted[0]?.message ?? null; } return bestMatch(userPool) ?? bestMatch(globalPool) ?? null; } function hasOfficerRole(member) { return member.roles.cache.some((r) => OFFICER_ROLES.includes(r.name)); } function formatVoters(map) { if (map.size === 0) return "—"; return [...map.values()] .map((v) => { let line = `${v.username} · ${v.votedAt}`; if (v.previousNoAt) line += ` (changed their mind from No at ${v.previousNoAt})`; if (v.previousYesAt) line += ` (switched from Yes at ${v.previousYesAt})`; if (v.publicMessage) line += `\n*${v.publicMessage}*`; return line; }) .join("\n"); } function buildEmbed() { return new EmbedBuilder() .setTitle(`⚔️ TG — Tonight?${pollLocked ? " 🔒" : ""}`) .setDescription(`Is **TG happening tonight at ${TG_HOUR}:00**?\n`) .setColor(pollLocked ? 0x888888 : 0xe8a317) .addFields( { name: `✅ Yes (${votes.yes.size})`, value: formatVoters(votes.yes), inline: true }, { name: `❌ No (${votes.no.size})`, value: formatVoters(votes.no), inline: true } ) .setFooter({ text: pollLocked ? "Poll is locked • Ice King can unlock with /tg-poll unlock" : "Vote updates live • Anyone can vote • You can switch your vote", }) .setTimestamp(); } function buildButtons() { const yesBtn = new ButtonBuilder() .setCustomId("tg_yes") .setLabel("✅ Yes") .setStyle(ButtonStyle.Success) .setDisabled(pollLocked); const noBtn = new ButtonBuilder() .setCustomId("tg_no") .setLabel("❌ No") .setStyle(ButtonStyle.Danger) .setDisabled(pollLocked); return new ActionRowBuilder().addComponents(yesBtn, noBtn); } async function updatePollMessage(channel) { if (!currentPollMessageId) return; try { const msg = await channel.messages.fetch(currentPollMessageId); await msg.edit({ embeds: [buildEmbed()], components: [buildButtons()] }); } catch (err) { console.error("Failed to update poll message:", err); } } async function postDailyPoll() { try { const channel = await client.channels.fetch(CHANNEL_ID); if (!channel) return console.error("Channel not found."); votes = { yes: new Map(), no: new Map() }; locks = new Map(); pollLocked = false; currentPollMessageId = null; const msg = await channel.send({ embeds: [buildEmbed()], components: [buildButtons()], }); currentPollMessageId = msg.id; console.log(`[${new Date().toISOString()}] Poll posted.`); } catch (err) { console.error("Failed to post poll:", err); } } async function registerCommands() { const command = new SlashCommandBuilder() .setName("tg-poll") .setDescription("Manage the TG poll (Ice King only)") .addSubcommand((sub) => sub.setName("start").setDescription("Post a fresh TG poll") ) .addSubcommand((sub) => sub.setName("unlock").setDescription("Unlock a locked TG poll") ); const rest = new REST({ version: "10" }).setToken(TOKEN); await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { body: [command.toJSON()], }); console.log("Slash commands registered."); } client.on("interactionCreate", async (interaction) => { // ── Slash commands ────────────────────────────────────────────────────── if (interaction.isChatInputCommand() && interaction.commandName === "tg-poll") { const member = await interaction.guild.members.fetch(interaction.user.id); if (!hasOfficerRole(member)) { return interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true, }); } const sub = interaction.options.getSubcommand(); if (sub === "start") { await interaction.reply({ content: "⚔️ Posting a fresh TG poll...", ephemeral: true }); await postDailyPoll(); return; } if (sub === "unlock") { if (!pollLocked) { return interaction.reply({ content: "ℹ️ The poll isn't locked.", ephemeral: true }); } pollLocked = false; MESSAGES = loadMessages(); const channel = await client.channels.fetch(CHANNEL_ID); await updatePollMessage(channel); return interaction.reply({ content: "🔓 Poll unlocked!", ephemeral: true }); } } // ── Button interactions ───────────────────────────────────────────────── if (!interaction.isButton()) return; if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; if (pollLocked) return interaction.deferUpdate(); const userId = interaction.user.id; const member = await interaction.guild.members.fetch(userId); const username = member.nickname ?? interaction.user.username; const votedYes = interaction.customId === "tg_yes"; const now = nowFormatted(); if (!locks.has(userId)) locks.set(userId, { yes: 0, no: 0 }); const userLocks = locks.get(userId); // Check if user is locked on this button if (votedYes && userLocks.yes >= LOCK_AT) return interaction.deferUpdate(); if (!votedYes && userLocks.no >= LOCK_AT) return interaction.deferUpdate(); // Increment click counter if (votedYes) userLocks.yes += 1; else userLocks.no += 1; const clicks = votedYes ? userLocks.yes : userLocks.no; // Resolve messages const publicMsg = resolveMessage("public", votedYes ? "yes" : "no", clicks, username); const ephemeralMsg = resolveMessage("ephemeral", votedYes ? "yes" : "no", clicks, username); if (votedYes) { const previousNo = votes.no.get(userId); votes.no.delete(userId); votes.yes.set(userId, { username, votedAt: now, previousNoAt: previousNo ? previousNo.votedAt : null, publicMessage: publicMsg, }); } else { const previousYes = votes.yes.get(userId); votes.yes.delete(userId); votes.no.set(userId, { username, votedAt: now, previousYesAt: previousYes ? previousYes.votedAt : null, publicMessage: publicMsg, }); } const locked = clicks >= LOCK_AT; if (locked) pollLocked = true; await interaction.reply({ content: ephemeralMsg ? `${ephemeralMsg}${locked ? "\n🔒 *You've been locked in.*" : ""}` : locked ? "🔒 You've been locked in." : votedYes ? "✅ Vote registered!" : "❌ Vote registered!", ephemeral: true, }); const channel = await client.channels.fetch(CHANNEL_ID); await updatePollMessage(channel); }); client.once("clientReady", async () => { console.log(`Logged in as ${client.user.tag}`); await registerCommands(); cron.schedule(`0 ${POST_HOUR} * * *`, () => postDailyPoll()); console.log(`Poll scheduled daily at ${POST_HOUR}:00.`); }); client.login(TOKEN);