From 677e74764c69d7a42db319093141e337bf822164 Mon Sep 17 00:00:00 2001 From: crylia Date: Thu, 12 Sep 2024 05:13:56 +0200 Subject: [PATCH] Add roles management per reaction on a message and event reminder with role ping --- .env_EXAMPLE | 1 - README.md | 2 +- src/database/eventdb.js | 49 ++++++++ src/database/staticdb.js | 59 +++++++++ src/discord/discordClient.js | 210 ++++++++++++-------------------- src/features/reactionPerRole.js | 206 +++++++++++++++++++++++++++++++ src/features/static.js | 31 +++++ src/tasks/eventReminder.js | 80 ++++++++++++ src/tasks/staticReminder.js | 0 9 files changed, 507 insertions(+), 131 deletions(-) create mode 100644 src/database/eventdb.js create mode 100644 src/database/staticdb.js create mode 100644 src/features/reactionPerRole.js create mode 100644 src/features/static.js create mode 100644 src/tasks/eventReminder.js create mode 100644 src/tasks/staticReminder.js diff --git a/.env_EXAMPLE b/.env_EXAMPLE index 48667dd..8a256cc 100644 --- a/.env_EXAMPLE +++ b/.env_EXAMPLE @@ -1,6 +1,5 @@ BOT_TOKEN= CLIENT_ID= -SHEET_ID= DB_NAME= DB_PORT= DB_HOST= diff --git a/README.md b/README.md index e02a77c..51baac4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Tochi's Discord Bot -A simple discord bot for the Limited Edition legion in the game Aion Classic that interfaces with a PostgreSQL database +A simple discord bot for the Limited Edition guild in the game Aion Classic that interfaces with a PostgreSQL database The bot is made to run on a single server and will thus share the same database diff --git a/src/database/eventdb.js b/src/database/eventdb.js new file mode 100644 index 0000000..a03203a --- /dev/null +++ b/src/database/eventdb.js @@ -0,0 +1,49 @@ +const { client } = require('./database') + +const ReadEvents = async () => { + try { + const res = await client.query(` + SELECT * FROM event_times;` + ) + return res.rows + } catch (error) { + console.error('Error reading event entries:', error) + return false + } +} + +const GetEventRole = async () => { + try { + const res = await client.query(` + SELECT * FROM event_role_view;` + ) + const rolesEventMap = new Map() + res.rows.forEach(row => rolesEventMap.set(row.event_name, row.role)) + + return rolesEventMap + } catch (error) { + console.error(error) + return false + } +} + +const GetIconRole = async () => { + try { + const res = await client.query(` + SELECT role, icon_name FROM event_roles;` + ) + const rolesEventMap = new Map() + res.rows.forEach(row => rolesEventMap.set(row.icon_name, row.role)) + + return rolesEventMap + } catch (error) { + console.error(error) + return false + } +} + +module.exports = { + ReadEvents, + GetEventRole, + GetIconRole, +} diff --git a/src/database/staticdb.js b/src/database/staticdb.js new file mode 100644 index 0000000..b4593a1 --- /dev/null +++ b/src/database/staticdb.js @@ -0,0 +1,59 @@ +const { client } = require('./database') + +const CreateStatic = async (name, creator, members, size) => { + try { + await client.query(` + INSERT INTO static (name, creator, size) + VALUES ($1, $2, $3) + RETURNING id;`, + [name, creator, size] + ) + + const staticId = result.rows[0].id; + + const memberValues = members.map(member => `(${staticId}, '${member}')`).join(',') + + await client.query(` + INSERT INTO static_members (static_id, member) + VALUES ${memberValues};` + ) + + return true + } catch (error) { + console.error('Error creating static entry:', error) + return false + } +} + +const ReadStatic = async (name) => { + try { + const res = await client.query(` + SELECT * FROM static + WHERE name = $1;`, + [name] + ) + return res.rows + } catch (error) { + console.error('Error reading static entry:', error) + return false + } +} + +const DeleteStatic = async (name) => { + try { + await client.query(` + DELETE FROM static + WHERE name = $1;`, + [name] + ) + } catch (error) { + console.error('Error deleting static entry:', error) + return false + } +} + +module.exports = { + CreateStatic, + ReadStatic, + DeleteStatic, +} diff --git a/src/discord/discordClient.js b/src/discord/discordClient.js index a0db8d1..6f6dab8 100644 --- a/src/discord/discordClient.js +++ b/src/discord/discordClient.js @@ -1,12 +1,18 @@ const { Client, GatewayIntentBits, EmbedBuilder, Partials, Routes } = require('discord.js') const { REST } = require('@discordjs/rest') const { SlashCommandBuilder } = require('@discordjs/builders') +const { + initReactionPerRole, + messageReactionAdd, + messageReactionRemove, +} = require('../features/reactionPerRole') const { handleBlacklistAdd, handleBlacklistCheck, handleBlacklistShow, } = require('../features/blacklist') + const { handleBirthdayAdd, handleBirthdayUpdate, @@ -15,172 +21,97 @@ const { } = require('../features/birthday') const { startBirthdayCheckCron } = require('../tasks/checkBirthday') +const { startEventCheckCron } = require('../tasks/eventReminder') const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions, ], partials: [ Partials.Channel, ] }) -const createBlacklistEmbeds = (entries) => { - const embeds = []; - let currentEmbed = new EmbedBuilder() - .setTitle('Blacklisted Players') - .setColor('#EF9A9A'); +const createBlacklistEmbeds = (playerEntries, maxChars = 30) => { + const embeds = [] + let embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Blacklist (Page 1)') + .setDescription('Players who have been blacklisted and the reasons.') - let currentEntries = []; - let first = true; + let fieldCount = 0 + let pageIndex = 1 - const breakTextIntoLines = (text, maxLength) => { - const words = text.split(' '); - const lines = []; - let currentLine = ''; + playerEntries.forEach(([playerName, reason]) => { + let splitReason = [] + while (reason.length > maxChars) { + let splitIndex = reason.lastIndexOf(' ', maxChars) + if (splitIndex === -1) splitIndex = maxChars + splitReason.push(reason.substring(0, splitIndex)) + reason = reason.substring(splitIndex + 1) + } + splitReason.push(reason) - for (let i = 0; i < words.length; i++) { - if (words[i].length > maxLength) { - const splitWord = words[i].match(new RegExp(`.{1,${maxLength}}`, 'g')) || []; - for (let j = 0; j < splitWord.length; j++) { - if (j === 0 && currentLine) { - lines.push(currentLine.trim()); - currentLine = ''; - } - lines.push(splitWord[j]); - } - } else if ((currentLine + words[i]).length <= maxLength) { - currentLine += words[i] + ' '; - } else { - lines.push(currentLine.trim()); - currentLine = words[i] + ' '; - } + const nameField = playerName || '\u200B' + const valueField = splitReason.join('\n').trim() || '\u200B' + + if (valueField !== '\u200B') { + embed.addFields({ name: nameField, value: valueField, inline: true }) + fieldCount++ } - if (currentLine) { - lines.push(currentLine.trim()); + if (fieldCount >= 25) { + embeds.push(embed) + pageIndex++ + embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Blacklist (Page ${pageIndex})`) + .setDescription('Players who have been blacklisted and the reasons.') + fieldCount = 0 } + }) - return lines; - }; - - const addEntryToEmbed = (entriesGroup) => { - let playerNames = ''; - let reasons = ''; - let reportedBys = ''; - - for (let i = 0; i < entriesGroup.length; i++) { - const entry = entriesGroup[i]; - const name = entry.name || 'Unknown'; - const reason = entry.reason || 'No reason provided'; - - const nameLines = breakTextIntoLines(name, 20); - const reasonLines = breakTextIntoLines(reason, 30); - - const maxLines = Math.max(nameLines.length, reasonLines.length); - - for (let j = 0; j < maxLines; j++) { - playerNames += (j < nameLines.length ? nameLines[j] : '\u200B') + '\n'; - reasons += (j < reasonLines.length ? reasonLines[j] : '\u200B') + '\n'; - reportedBys += (j === 0 ? entry.reportedby || 'Unknown' : '\u200B') + '\n'; - } - } - - let newFields = []; - if (first) { - newFields = [ - { name: 'Player', value: playerNames, inline: true }, - { name: 'Reason', value: reasons, inline: true }, - { name: 'Reported By', value: reportedBys, inline: true } - ]; - first = false; - } else { - newFields = [ - { name: '\u200B', value: playerNames, inline: true }, - { name: '\u200B', value: reasons, inline: true }, - { name: '\u200B', value: reportedBys, inline: true } - ]; - } - - for (const field of newFields) { - currentEmbed.addFields(field); - } - }; - - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - - const tempEntries = [...currentEntries, entry]; - let tempPlayerNames = ''; - let tempReasons = ''; - let tempReportedBys = ''; - - for (let j = 0; j < tempEntries.length; j++) { - const name = tempEntries[j].name || 'Unknown'; - const reason = tempEntries[j].reason || 'No reason provided'; - const nameLines = breakTextIntoLines(name, 20); - const reasonLines = breakTextIntoLines(reason, 30); - - const maxLines = Math.max(nameLines.length, reasonLines.length); - - for (let k = 0; k < maxLines; k++) { - tempPlayerNames += (k < nameLines.length ? nameLines[k] : '\u200B') + '\n'; - tempReasons += (k < reasonLines.length ? reasonLines[k] : '\u200B') + '\n'; - tempReportedBys += (k === 0 ? tempEntries[j].reportedby || 'Unknown' : '\u200B') + '\n'; - } - } - - const exceedsLimit = (str) => str.length > 1024; - - if (exceedsLimit(tempPlayerNames) || exceedsLimit(tempReasons) || exceedsLimit(tempReportedBys)) { - if (currentEntries.length > 0) { - addEntryToEmbed(currentEntries); - currentEntries = []; - } - } - - currentEntries.push(entry); + if (fieldCount > 0) { + embeds.push(embed) } - if (currentEntries.length > 0) { - addEntryToEmbed(currentEntries); - } - - embeds.push(currentEmbed); - - return embeds; -}; - + return embeds +} const updateGlobalMessage = async () => { try { - let targetChannel = null; + let targetChannel = null for (const [_, oauthGuild] of await client.guilds.fetch()) { targetChannel = (await (await oauthGuild.fetch()).channels.fetch()).find(ch => ch.name === 'blacklist') - break; + break } if (!targetChannel) { - console.error('Channel with name "blacklist" not found.'); - return; + console.error('Channel with name "blacklist" not found.') + return } - const messages = await targetChannel.messages.fetch({ limit: 100 }); - await Promise.all(messages.map(msg => msg.delete())); + const messages = await targetChannel.messages.fetch({ limit: 100 }) + await Promise.all(messages.map(msg => msg.delete())) - const blacklistEntries = await handleBlacklistShow(); - const embeds = createBlacklistEmbeds(blacklistEntries); + const blacklistEntries = await handleBlacklistShow() + + const playerEntries = blacklistEntries.map(entry => [entry.name, entry.reason]) + + const embeds = createBlacklistEmbeds(playerEntries) for (const embed of embeds) - await targetChannel.send({ embeds: [embed] }); + await targetChannel.send({ embeds: [embed] }) } catch (error) { - console.error('Error updating global message:', error); + console.error('Error updating global message:', error) } -}; +} client.on('interactionCreate', async interaction => { if (!interaction.isCommand()) return @@ -208,7 +139,7 @@ client.on('interactionCreate', async interaction => { const reason2 = await handleBlacklistCheck(player) reason2 ? - await interaction.reply({ content: `** ${reason2.name}** is blacklisted for: ** ${reason2.reason || 'No reason provided.'}** (by ${reason2.reportedby || 'unknown'}).`, ephemeral: true }) : + await interaction.reply({ content: `** ${reason2.name}** is blacklisted for: ** ${reason2.reason || 'No reason provided.'}**`, ephemeral: true }) : await interaction.reply({ content: `** ${player}** is not blacklisted.`, ephemeral: true }) break @@ -257,17 +188,38 @@ client.on('interactionCreate', async interaction => { await interaction.reply({ content: "Your birthday has been deleted.", ephemeral: true }) : await interaction.reply({ content: "You don't have a birthday set.", ephemeral: true }) break + case 'static-create': + + break + case 'static-delete': + + break + + case 'static-show': + + break default: break } }) +client.on('messageReactionAdd', async (reaction, user) => { + messageReactionAdd(user, reaction) +}) + +client.on('messageReactionRemove', async (reaction, user) => { + messageReactionRemove(user, reaction) +}) + + const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN) client.once('ready', async () => { console.log(`Logged in as ${client.user.tag} `) startBirthdayCheckCron(client) + startEventCheckCron(client) updateGlobalMessage() + initReactionPerRole(client) }) const connectDiscord = async () => { diff --git a/src/features/reactionPerRole.js b/src/features/reactionPerRole.js new file mode 100644 index 0000000..1694154 --- /dev/null +++ b/src/features/reactionPerRole.js @@ -0,0 +1,206 @@ +const { GetIconRole } = require('../database/eventdb') +const cron = require('node-cron'); + +let reminderMessageID = null +let rolesMap = new Map() + +const areMapsEqual = (map1, map2) => { + if (map1.size !== map2.size) return false + + for (const [key, value] of map1) + if (map2.get(key) !== value) return false + + return true +} + +const editReactionMessage = async (client) => { + try { + for (const [_, oauthGuild] of await client.guilds.fetch()) { + const guild = await oauthGuild.fetch() + const rolesChannel = (await guild.channels.fetch()).find(ch => ch.name === 'roles') + + if (!rolesChannel) { + console.log("Channel not found") + return + } + const message = await rolesChannel.messages.fetch(reminderMessageID) + + const newRolesFromDB = await GetIconRole() + + if (areMapsEqual(rolesMap, newRolesFromDB)) { + console.log(`No changes to the roles, quitting`) + return + } + + rolesMap = newRolesFromDB + + const currentLines = message.content.split('\n').slice(1) + + const currentRoleMap = new Map() + currentLines.forEach(line => { + if (!line.trim()) return + const [icon, roleName] = line.split(': ') + const iconName = icon.match(/<:(\w+):\d+>/)[1] + currentRoleMap.set(iconName, roleName.replace(/`/g, '')) + }) + + const rolesToAdd = [], rolesToRemove = [] + + for (const [iconName, roleName] of rolesMap) + if (!currentRoleMap.has(iconName)) + rolesToAdd.push({ icon: iconName, name: roleName }) + + for (const [iconName, roleName] of currentRoleMap) + if (!rolesMap.has(iconName)) + rolesToRemove.push({ icon: iconName, name: roleName }) + + const emojis = await guild.emojis.fetch() + + let updatedContent = 'React to get your roles!\n\n' + rolesMap.forEach((roleName, iconName) => { + const emoji = emojis.find(e => e.name === iconName) + if (emoji) + updatedContent += `${emoji}: \`${roleName}\`\n` + else + console.log(`Couldn't find emoji ${iconName}`) + }) + + await message.edit(updatedContent) + + for (const role of rolesToAdd) { + const icon = emojis.find(e => e.name === role.icon) + if (icon) + await message.react(`${icon}`) + } + + for (const role of rolesToRemove) { + const reaction = message.reactions.cache.find(r => r.emoji.name === role.icon) + if (!reaction) continue + + await reaction.remove() + + const roleToRemove = message.guild.roles.cache.find(r => r.name === role.name) + if (!roleToRemove) continue + + const members = await message.guild.members.fetch(); + const membersWithRole = members.filter(member => member.roles.cache.has(roleToRemove.id)); + + for (const member of membersWithRole.values()) + await member.roles.remove(roleToRemove) + } + } + } catch (error) { + console.log(error) + } +} + +const createReactionMessage = async (client) => { + try { + for (const [_, oauthGuild] of await client.guilds.fetch()) { + const guild = await oauthGuild.fetch() + const rolesChannel = (await guild.channels.fetch()).find(ch => ch.name === 'roles') + + if (!rolesChannel) { + console.log("Channel not found") + return + } + + const fetchedMessage = await rolesChannel.messages.fetch({ limit: 1 }) + if (fetchedMessage.size > 0) { + reminderMessageID = fetchedMessage.first().id + console.log('Reminder MessageID: ', reminderMessageID) + return + } + + const emojis = await guild.emojis.fetch() + + let message = 'React to get your roles!\n\n' + for (const [iconName, roleName] of rolesMap) { + const emoji = emojis.find(e => e.name === iconName) + if (emoji) + message += `<:${emoji.name}:${emoji.id}> : \`${roleName}\`\n` + else + console.log(`Emoji for ${iconName} not found`) + } + const sentMessage = await rolesChannel.send(message) + + for (const [iconName, _] of rolesMap) { + const emoji = emojis.find(e => e.name === iconName) + if (emoji) await sentMessage.react(emoji.id) + } + + reminderMessageID = sentMessage.id + } + } catch (error) { + console.log(error) + } +} + +const messageReactionAdd = async (user, reaction) => { + if (user.id === '1280557738530963506') return + + const { message, emoji } = reaction + const guild = message.guild + const member = await guild.members.fetch(user.id) + if (!member) return + + if (message.id === reminderMessageID) { + const roleMap = await GetIconRole() + + const roleName = roleMap.get(emoji.name) + if (roleName) { + try { + const roles = await guild.roles.fetch() + const role = roles.find(r => r.name === roleName) + + if (role && !member.roles.cache.has(role.id)) + await member.roles.add(role) + + } catch (error) { + console.error(`Error fetching role for ${roleName}`, error) + } + } + } +} + +const messageReactionRemove = async (user, reaction) => { + const { message, emoji } = reaction + const guild = message.guild + + const member = await guild.members.fetch(user.id) + if (!member) return + + if (message.id === reminderMessageID) { + const roleMap = await GetIconRole() + + const roleName = roleMap.get(emoji.name) + if (roleName) { + try { + const roles = await guild.roles.fetch() + const role = roles.find(r => r.name === roleName) + + if (role && member.roles.cache.has(role.id)) + await member.roles.remove(role) + + } catch (error) { + console.error(`Error fetching role for ${roleName}`, error) + } + } + } +} + +const initReactionPerRole = async (client) => { + rolesMap = await GetIconRole() + + await createReactionMessage(client) + + cron.schedule('0 * * * *', async () => { + await editReactionMessage(client) + }) +} + +module.exports = { + initReactionPerRole, + messageReactionAdd, + messageReactionRemove, +} diff --git a/src/features/static.js b/src/features/static.js new file mode 100644 index 0000000..fe5efb9 --- /dev/null +++ b/src/features/static.js @@ -0,0 +1,31 @@ +const { CreateStatic, ReadStatic, DeleteStatic } = require('../database/staticdb') + +const handleStaticAdd = async (name, creator, members, size) => { + if (!name || !createor || !members || !size) return false + + const result = await CreateStatic(name, creator, members, size) + + return result +} + +const handleStaticGet = async (name) => { + if (!name) return false; + + const result = await ReadStatic(name) + + return result +} + +const handleStaticDelete = async (name) => { + if (!name) return false + + const result = await DeleteStatic(name) + + return result +} + +module.exports = { + handleStaticAdd, + handleStaticGet, + handleStaticDelete, +} diff --git a/src/tasks/eventReminder.js b/src/tasks/eventReminder.js new file mode 100644 index 0000000..0c8b7dc --- /dev/null +++ b/src/tasks/eventReminder.js @@ -0,0 +1,80 @@ +const cron = require('node-cron'); +const { ReadEvents, GetEventRole } = require('../database/eventdb') +const { EmbedBuilder } = require('discord.js') + +let eventCache = [] + +const FetchEvents = async () => { + const res = await ReadEvents() + + eventCache = res +} + +const convertToISO = (time, date) => { + const utcDate = new Date(`${date}T${time}`); + utcDate.setHours(utcDate.getHours()); + return utcDate.toISOString().split('.')[0]; +}; + +const convertToUTC = (time) => { + const [hours, minutes, seconds] = time.split(':').map(Number); + const date = new Date(); + date.setUTCHours(hours - 2, minutes, seconds); + return date.toISOString().split('.')[0]; +}; + +const isReminderTime = (eventStartUTC) => { + const now = new Date(); + const [eventHours, eventMinutes] = eventStartUTC.split('T')[1].split(':').map(Number); + const eventStartDateTime = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), eventHours, eventMinutes)); + const reminderTime = new Date(eventStartDateTime.getTime() - 15 * 60 * 1000); + console.log(now, reminderTime, eventStartDateTime) + return now >= reminderTime && now < eventStartDateTime; +}; + +const getDayOfWeek = () => new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate())).toLocaleDateString('en-US', { weekday: 'long' }); + +const startEventCheckCron = async (client) => { + cron.schedule('45 * * * *', async () => { + console.log('Checking for events...') + if (!eventCache.length) { + await FetchEvents(); + } + + const rolesEventMap = await GetEventRole() + + for (const [guildId, oauthGuild] of await client.guilds.fetch()) { + const guild = await oauthGuild.fetch() + const channel = (await guild.channels.fetch()).find(ch => ch.name === 'reminder') + + if (!channel) continue + + eventCache.forEach(event => { + let { schedule_id, event_name, start_time, end_time, day_of_week } = event; + console.log(!((!day_of_week || day_of_week === getDayOfWeek()) && isReminderTime(convertToUTC(start_time)))) + // Abbort if its not time to send a reminder + if (!((!day_of_week || day_of_week === getDayOfWeek()) && isReminderTime(convertToUTC(start_time)))) return; + + const timeComparisonLink = `https://www.timeanddate.com/worldclock/fixedtime.html?iso=${convertToISO(start_time, new Date().toISOString().split('T')[0])}&msg=${encodeURIComponent(event_name)}`; + + console.log("Sending event...") + console.log(rolesEventMap.get(event_name)) + const embed = new EmbedBuilder() + .setTitle(event_name) + .setDescription(`${event_name} starts at **${start_time} CEST** and ends at **${end_time} CEST**. \n<@${rolesEventMap.get(event_name)}>\n`) + .addFields( + { name: 'Day of Week', value: day_of_week ? day_of_week : 'Daily', inline: true }, + { name: 'Compare Time', value: `[Click here to compare event time to your local time](${timeComparisonLink})`, inline: true } + ) + .setFooter({ text: `Schedule ID: ${schedule_id}` }) + .setColor('#00FF00'); + + channel.send({ embeds: [embed] }).catch(console.error); + }); + } + }); +} + +module.exports = { + startEventCheckCron, +} diff --git a/src/tasks/staticReminder.js b/src/tasks/staticReminder.js new file mode 100644 index 0000000..e69de29