gistfile1.txt
· 9.7 KiB · Text
Eredeti
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);
| 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); |