diff --git a/.env_EXAMPLE b/.env_EXAMPLE new file mode 100644 index 0000000..48667dd --- /dev/null +++ b/.env_EXAMPLE @@ -0,0 +1,8 @@ +BOT_TOKEN= +CLIENT_ID= +SHEET_ID= +DB_NAME= +DB_PORT= +DB_HOST= +DB_USER= +DB_PASS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28a76fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +node_modules/ +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..498bfd8 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Tochi's Slave + +A simple discord bot for the Limited Edition legion 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 + +Its best to run the bot in a docker container + +## Features + +- Birthday + - Set/Remove/Update/Check your birthday + - Everyone gets notified when the day arrives +- Blacklist + - Blacklist a player with a given reason + - Updates a global message + - Can check against individual players diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b3ffda --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "discord.js": "^14.15.3", + "dotenv": "^16.4.5", + "node-cron": "^3.0.3", + "pg": "^8.12.0" + }, + "scripts": { + "test": "jest" + } +} diff --git a/src/database/birthdaydb.js b/src/database/birthdaydb.js new file mode 100644 index 0000000..1e47bd8 --- /dev/null +++ b/src/database/birthdaydb.js @@ -0,0 +1,103 @@ +const { client } = require('./database') + +const CreateBirthday = async (user, birthday) => { + try { + const alreadyReportedResult = await client.query(` + SELECT discorduser FROM birthday + WHERE discorduser = $1;`, + [user] + ) + + if (alreadyReportedResult.rows.length === 1) { + return alreadyReportedResult.rows[0] + } + + const userResult = await client.query(` + SELECT * FROM discorduser + WHERE name = $1;`, + [user] + ) + + if (userResult.rows.length === 0) { + await client.query(` + INSERT INTO discorduser (name) + VALUES ($1);`, + [user] + ) + } + + await client.query(` + INSERT INTO birthday (date, discorduser) + VALUES ($1, $2);`, + [new Date(birthday.split('.').reverse().join('-')).toISOString().slice(0, 10), user] + ) + + return true + } catch (error) { + console.error('Error creating birthday entry:', error) + return false + } +} + +const ReadBirthday = async (user) => { + try { + if (user) { + const res = await client.query(` + SELECT * FROM birthday + WHERE discorduser = $1;`, + [user] + ) + return res.rows + } else { + const res = await client.query(` + SELECT * FROM birthday`, + ) + return res.rows + } + } catch (error) { + console.error('Error reading birthday table:', error) + return [] + } +} + +const UpdateBirthday = async (user, birthday) => { + try { + if (!birthday) return false + + await client.query(` + UPDATE birthday + SET birthday = $2 + WHERE discorduser = $1;`, + [user, birthday] + ) + + return true + } catch (error) { + console.error('Error updating birthday table:', error) + return false + } +} + +const DeleteBirthday = async (user) => { + try { + if (!user) return false + + await client.query(` + DELETE FROM birthday + WHERE discorduser = $1;`, + [user] + ) + + return true + } catch (error) { + console.error('Error deleting birthday table:', error) + return false + } +} + +module.exports = { + CreateBirthday, + ReadBirthday, + UpdateBirthday, + DeleteBirthday, +} diff --git a/src/database/blacklistdb.js b/src/database/blacklistdb.js new file mode 100644 index 0000000..d782ef6 --- /dev/null +++ b/src/database/blacklistdb.js @@ -0,0 +1,101 @@ +const { client } = require('./database') + +const CreateBlacklist = async (reportedUser, reason, reportedByUser) => { + try { + const alreadyReportedResult = await client.query(` + SELECT name FROM blacklist + WHERE name = $1`, + [reportedUser] + ) + + if (alreadyReportedResult.rows.length === 1) { + return alreadyReportedResult.rows[0] + } + + const userResult = await client.query(` + SELECT * FROM discorduser + WHERE name = $1;`, + [reportedByUser] + ) + + if (userResult.rows.length === 0) { + await client.query(` + INSERT INTO discorduser (name) + VALUES ($1);`, + [reportedByUser] + ) + } + + await client.query(` + INSERT INTO blacklist (name, reason, reportedby) + VALUES ($1, $2, $3);`, + [reportedUser, reason, reportedByUser] + ) + + return true + } catch (error) { + console.error('Error creating blacklist entry:', error) + return false + } +} + +const ReadBlacklist = async (user) => { + try { + if (user) { + const res = await client.query(` + SELECT * FROM blacklist + WHERE name=$1`, + [user] + ) + return res.rows[0] || false + } else { + const res = await client.query(` + SELECT * FROM blacklist` + ) + return res.rows + } + } catch (error) { + console.error('Error reading blacklist table:', error) + return [] + } +} + +const UpdateBlacklist = async (reportedUser, reason) => { + try { + if (!reportedUser) return false + + await client.query(` + UPDATE blacklist + SET reason = $2 + WHERE name = $1;`, + [reportedUser, reason] + ) + return true + } catch (error) { + console.error('Error updating blacklist table:', error) + return false + } +} + +const DeleteBlacklist = async (user) => { + try { + if (!user) return false + + await client.query(` + DELETE FROM blacklist + WHERE name = $1;`, + [user] + ) + return true + } catch (error) { + console.error('Error deleting blacklist table:', error) + return false + } +} + +module.exports = { + CreateBlacklist, + ReadBlacklist, + UpdateBlacklist, + DeleteBlacklist, +} diff --git a/src/database/database.js b/src/database/database.js new file mode 100644 index 0000000..abd1d88 --- /dev/null +++ b/src/database/database.js @@ -0,0 +1,25 @@ +const { Client } = require('pg') +require('dotenv').config() + +const DB_USER = process.env.DB_USER +const DB_HOST = process.env.DB_HOST +const DB_NAME = process.env.DB_NAME +const DB_PASS = process.env.DB_PASS + +const client = new Client({ + connectionString: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_NAME}` +}) + +const connectDatabase = async () => { + try { + await client.connect() + console.log('Database connected') + } catch (error) { + console.error('Database connection error:', err) + } +} + +module.exports = { + client, + connectDatabase, +} diff --git a/src/discord/discordClient.js b/src/discord/discordClient.js new file mode 100644 index 0000000..a0db8d1 --- /dev/null +++ b/src/discord/discordClient.js @@ -0,0 +1,330 @@ +const { Client, GatewayIntentBits, EmbedBuilder, Partials, Routes } = require('discord.js') +const { REST } = require('@discordjs/rest') +const { SlashCommandBuilder } = require('@discordjs/builders') + +const { + handleBlacklistAdd, + handleBlacklistCheck, + handleBlacklistShow, +} = require('../features/blacklist') +const { + handleBirthdayAdd, + handleBirthdayUpdate, + handleBirthdayCheck, + handleBirthdayDelete, +} = require('../features/birthday') + +const { startBirthdayCheckCron } = require('../tasks/checkBirthday') + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + ], + partials: [ + Partials.Channel, + ] +}) + +const createBlacklistEmbeds = (entries) => { + const embeds = []; + let currentEmbed = new EmbedBuilder() + .setTitle('Blacklisted Players') + .setColor('#EF9A9A'); + + let currentEntries = []; + let first = true; + + const breakTextIntoLines = (text, maxLength) => { + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + 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] + ' '; + } + } + + if (currentLine) { + lines.push(currentLine.trim()); + } + + 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 (currentEntries.length > 0) { + addEntryToEmbed(currentEntries); + } + + embeds.push(currentEmbed); + + return embeds; +}; + + +const updateGlobalMessage = async () => { + try { + let targetChannel = null; + + for (const [_, oauthGuild] of await client.guilds.fetch()) { + targetChannel = (await (await oauthGuild.fetch()).channels.fetch()).find(ch => ch.name === 'blacklist') + break; + } + + if (!targetChannel) { + 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 blacklistEntries = await handleBlacklistShow(); + const embeds = createBlacklistEmbeds(blacklistEntries); + + for (const embed of embeds) + await targetChannel.send({ embeds: [embed] }); + + } catch (error) { + console.error('Error updating global message:', error); + } +}; + +client.on('interactionCreate', async interaction => { + if (!interaction.isCommand()) return + + switch (interaction.commandName) { + case 'blacklist': + const reportedUser = interaction.options.getString('player') + const reason = interaction.options.getString('reason') + const reportedByUser = interaction.user.username + + const res = await handleBlacklistAdd(reportedUser, reason, reportedByUser) + if (res) { + if (res.name === reportedUser) + interaction.reply({ content: `This user has already been reported`, ephemeral: true }) + else { + interaction.reply({ content: `Player ** ${reportedUser}** had been successfully reported for ${reason}`, ephemeral: true }) + updateGlobalMessage(interaction) + } + } else + interaction.reply({ content: `ERROR trying to add the player to the blacklist, please contact @Crylia`, ephemeral: true }) + + break + case 'blacklist-check-player': + const player = interaction.options.getString('player') + + 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: `** ${player}** is not blacklisted.`, ephemeral: true }) + + break + case 'birthday': + const user = interaction.user.username + const birthday = interaction.options.getString('birthday') + + // Matches format xx.xx.xxxx, later dd.mm.yyyy + const match = birthday.match(/^(\d{2})\.(\d{2})\.(\d{4})$/) + if (!match) { + await interaction.reply({ content: 'Invalid date format. Please use dd.mm.yyyy.', ephemeral: true }) + return + } + + const day = parseInt(match[1], 10) + const month = parseInt(match[2], 10) + const year = parseInt(match[3], 10) + + // Validates dd.mm ae legit, year doesnt matter for the birthday + const isValidDate = (day, month, year) => { + if (month < 1 || month > 12) return false + + const daysInMonth = [31, ((year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + return day > 0 && day <= daysInMonth[month - 1] + } + + if (!isValidDate(day, month, year)) { + await interaction.reply({ content: 'Invalid date. Please enter a valid birthday as dd.mm.yyyy.', ephemeral: true }) + return + } + + await handleBirthdayCheck(user, birthday).length > 0 ? + await handleBirthdayUpdate(user, birthday) : + handleBirthdayAdd(user, birthday) ? + await interaction.reply({ content: `Set ${birthday} as your birthday.Everyone will be notified once the day arrives!`, ephemeral: true }) : + await interaction.reply({ content: `Something went wrong when setting / updating your birthday, please contact @crylia`, ephemeral: true }) + break + case 'birthday-check': + const birthdayCheck = await handleBirthdayCheck(interaction.user.username) + birthdayCheck ? + await interaction.reply({ content: `Your birthday is currently set to ${new Date(birthdayCheck[0].date).toLocaleDateString('de-DE')}.`, ephemeral: true }) : + await interaction.reply({ content: "You don't have a birthday set. Use the`birthday` command to set one.", ephemeral: true }) + break + case 'birthday-delete': + await handleBirthdayDelete(interaction.user.username) ? + 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 + default: + break + } +}) + +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) + updateGlobalMessage() +}) + +const connectDiscord = async () => { + try { + console.log('Started refreshing application (/) commands.') + + const commands = [ + new SlashCommandBuilder() + .setName('birthday') + .setDescription('Set yourself a birthday') + .addStringOption(option => + option.setName('birthday') + .setDescription('Set (or overwrite) your birthday as dd.mm.yyyy (e.g. 01.12.1999)') + .setRequired(true) + ), + new SlashCommandBuilder() + .setName('birthday-check') + .setDescription('Check your set birthday (only you will see the date)'), + new SlashCommandBuilder() + .setName('birthday-delete') + .setDescription('Delete your birthday, nobody will know when your birthday arrives :('), + new SlashCommandBuilder() + .setName('blacklist') + .setDescription('Add a player to a blacklist with a reason') + .addStringOption(option => + option.setName('player') + .setDescription('The in-game name of the player') + .setRequired(true) + ) + .addStringOption(option => + option.setName('reason') + .setDescription('Explain what happened, why should this player be blacklisted') + .setRequired(true) + ), + new SlashCommandBuilder() + .setName('blacklist-check-player') + .setDescription('Check if a player is blacklisted') + .addStringOption(option => + option.setName('player') + .setDescription('The in-game name of the player') + .setRequired(true) + ) + ].map(command => command.toJSON()) + + await rest.put( + Routes.applicationCommands(process.env.CLIENT_ID), + { body: commands }, + ) + console.log('Successfully reloaded application (/) commands.') + } catch (error) { + console.error(error) + } + try { + client.login(process.env.BOT_TOKEN) + } catch (error) { + console.error('Error logging in to Discord:', error) + } +} + +module.exports = { client, connectDiscord } diff --git a/src/features/birthday.js b/src/features/birthday.js new file mode 100644 index 0000000..942d7e7 --- /dev/null +++ b/src/features/birthday.js @@ -0,0 +1,43 @@ +const { CreateBirthday, ReadBirthday, UpdateBirthday, DeleteBirthday } = require('../database/birthdaydb') + +const handleBirthdayAdd = async (user, birthday) => { + if (!user || !birthday) return false + + const result = await CreateBirthday(user, birthday) + + return result +} + +const handleBirthdayCheck = async (user) => { + if (!user) return false + + const result = await ReadBirthday(user) + + return result +} + +const handleBirthdayDelete = async (user) => { + if (!user) return false + + const result = await DeleteBirthday(user) + + return result +} + +const handleBirthdayUpdate = async (user, birthday) => { + if (!user || !birthday) return false + + const result = await UpdateBirthday(user, birthday) + + return result +} + +const handleBirthdayGetAll = async () => await ReadBirthday() + +module.exports = { + handleBirthdayAdd, + handleBirthdayCheck, + handleBirthdayDelete, + handleBirthdayUpdate, + handleBirthdayGetAll, +} diff --git a/src/features/blacklist.js b/src/features/blacklist.js new file mode 100644 index 0000000..a308d8f --- /dev/null +++ b/src/features/blacklist.js @@ -0,0 +1,25 @@ +const { CreateBlacklist, ReadBlacklist, UpdateBlacklist, DeleteBlacklist } = require('../database/blacklistdb') + +const handleBlacklistAdd = async (reportedUser, reason, reportedByUser) => { + if (!reportedUser || !reason || !reportedByUser) return false + + const result = await CreateBlacklist(reportedUser, reason, reportedByUser) + + return result +} + +const handleBlacklistCheck = async (user) => { + if (!user) return false + + const result = await ReadBlacklist(user) + + return result +} + +const handleBlacklistShow = async () => await ReadBlacklist() + +module.exports = { + handleBlacklistAdd, + handleBlacklistCheck, + handleBlacklistShow, +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..58bfc8b --- /dev/null +++ b/src/index.js @@ -0,0 +1,11 @@ +const { connectDiscord } = require('./discord/discordClient') +const { connectDatabase } = require('./database/database'); + +(async () => { + try { + await connectDatabase() + await connectDiscord() + } catch (error) { + console.log(error) + } +})() diff --git a/src/tasks/checkBirthday.js b/src/tasks/checkBirthday.js new file mode 100644 index 0000000..1fa660a --- /dev/null +++ b/src/tasks/checkBirthday.js @@ -0,0 +1,41 @@ +const cron = require('node-cron'); +const { handleBirthdayGetAll } = require('../features/birthday'); + +const startBirthdayCheckCron = async (client) => { + cron.schedule('0 20 * * *', async () => { + try { + console.log('Running birthday check...'); + + for (const [guildId, oauthGuild] of await client.guilds.fetch()) { + const guild = await oauthGuild.fetch() + const birthdayChannel = (await guild.channels.fetch()).find(ch => ch.name === 'main') + + if (!birthdayChannel) continue + + const birthdays = await handleBirthdayGetAll(); + console.log("birthdays:", birthdays) + if (birthdays.length < 1) continue + + let message = '🎉 **Today\'s Birthdays!** 🎉\n\n'; + for (const birthday of birthdays) { + const birthdayDate = new Date(birthday.date); + if (birthdayDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) === new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })) { + (await guild.members.fetch()).forEach(user => { + if (user.user.username === birthday.discorduser) { + message += `🎂 **Happy Birthday, <@${user.id}>!** 🎂\nWishing you a fantastic day filled with joy and surprises! 🎁🎈\n\n**Everyone, make sure to wish <@${user.id}> a wonderful birthday!** 🎊🎉\n\n`; + } + }) + } + } + console.log(message) + if (message !== '🎉 **Today\'s Birthdays!** 🎉\n\n') { + await birthdayChannel.send(message); + } + } + } catch (error) { + console.log("Error in scheduled birthday check:", error); + } + }); +}; + +module.exports = { startBirthdayCheckCron };