diff --git a/.env.example b/.env.example deleted file mode 100644 index 978ac4c..0000000 --- a/.env.example +++ /dev/null @@ -1 +0,0 @@ -BOT_TOKEN= \ No newline at end of file diff --git a/README.md b/README.md index 5bb1642..86f1a0a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# {name} +# Discord Template -{name} is a [Discord](https://discord.com/) bot, made with [discord.js](https://discord.js.org/) and written in typescript. +Discord Template is a [Discord](https://discord.com/) bot with same "default" features, made with [discord.js](https://discord.js.org/) and written in typescript. ## Installation diff --git a/docker-compose.yml b/docker-compose.yml index f933ee2..4ab7b67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,5 @@ services: image: ghcr.io/RobinSch/discord-template:latest restart: always environment: - - BOT_TOKEN= \ No newline at end of file + - BOT_TOKEN=${BOT_TOKEN} + - POSTGRES_URL=${POSTGRES_URL} \ No newline at end of file diff --git a/package.json b/package.json index 728efd1..2e3a404 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "discord-template", "version": "1.0.0", - "description": "A Discord.js Bot template", + "description": "A template for your Discord bot ", "main": "src/index.ts", "scripts": { "start": "npm run build && node dist/index.js", @@ -14,14 +14,16 @@ "dependencies": { "@types/node": "^20.11.20", "discord.js": "^14.14.1", - "typescript": "^5.3.3" + "pg": "^8.11.3", + "typescript": "^5.3.3", + "uuid": "^9.0.1" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/parser": "^7.0.2", - "eslint": "^8.56.0", - "eslint-config-prettier": "^8.10.0", - "eslint-plugin-prettier": "^5.1.3", + "eslint": "^8.56.0", + "eslint-config-prettier": "^8.10.0", + "eslint-plugin-prettier": "^5.1.3", "prettier": "^3.2.5" } } diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..c378662 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,32 @@ +import { ChannelType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('add') + .setDescription('Add a member to a support ticket') + .addUserOption((option) => option.setName('member').setDescription('The member to give access to this support ticket').setRequired(true)), + + category: 'Tickets', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + + const { rows: currentSupportRows } = await query('SELECT FROM supports WHERE channel = $1 AND guild = $2;', [interaction.channelId, interaction.guildId]); + if (currentSupportRows.length === 0) return interaction.client.error.ONLY_IN_TICKET(interaction, false); + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + + await interaction.channel.permissionOverwrites.create(memberID, { + ViewChannel: true, + ReadMessageHistory: true, + SendMessages: true, + }); + + return interaction.editReply(`You added ${member.displayName} to this support ticket!`); + }, +}; diff --git a/src/commands/ban.ts b/src/commands/ban.ts new file mode 100644 index 0000000..47500d9 --- /dev/null +++ b/src/commands/ban.ts @@ -0,0 +1,61 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('ban') + .setDescription('Ban a member') + .addUserOption((option) => option.setName('member').setDescription('The member to ban').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to ban').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + if (!member.bannable) return interaction.editReply('I have no permissions to ban this member!'); + + member.send(`You are banned for ${reason} in ${interaction.guild.name}, goodbye!`).catch(); + + try { + member.ban({ reason }); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); + } + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO bans (id, guild, member, reason, moderator, time) VALUES ($1,$2,$3,$4,$5,$6);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + ]); + return interaction.editReply(`Banned ${member.displayName} for ${reason}!`); + }, +}; diff --git a/src/commands/close.ts b/src/commands/close.ts new file mode 100644 index 0000000..567e037 --- /dev/null +++ b/src/commands/close.ts @@ -0,0 +1,28 @@ +import { ChannelType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; + +import { closeTicket } from '../utils/tickets'; + +export default { + data: new SlashCommandBuilder().setName('close').setDescription('Close a support or mail ticket'), + + category: 'Tickets', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + + await interaction.editReply({ content: 'This support or mail ticket will be closed!' }); + + const result = await closeTicket(interaction.channelId, interaction.guildId, interaction.channel); + if (!result.success) return interaction.client.error.ONLY_IN_TICKET(interaction, true); + else if (result.embed && result.memberID) { + // Ticket was a mail ticket + try { + const member = await interaction.client.users.fetch(result.memberID); + await member.send({ embeds: [result.embed] }); + } catch (e) { + // + } + } + }, +}; diff --git a/src/commands/delwarn.ts b/src/commands/delwarn.ts new file mode 100644 index 0000000..3bfb9ae --- /dev/null +++ b/src/commands/delwarn.ts @@ -0,0 +1,36 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('delwarn') + .setDescription('Delete a warning') + .addStringOption((option) => option.setName('warning').setDescription('The ID of the warning').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to delete a warning').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const warningID = interaction.options.getString('warning', true); + if (!warningID) return interaction.client.error.INVALID_INPUT(interaction, 'warning', 'should be nonzero'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const { rows: warningRows } = await query('SELECT guild, member, reason FROM warnings WHERE id = $1;', [warningID]); + if (warningRows[0].length === 0 || warningRows[0].guild !== interaction.guildId) return interaction.editReply('This warning does not exists!'); + if (warningRows[0].member === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'warning', 'should not be a warning of yours'); + + await query('DELETE FROM warnings WHERE id = $1 AND guild = $2;', [warningID, interaction.guildId]); + return interaction.editReply(`Deleted warning "${warningRows[0].reason}" for ${reason}!`); + }, +}; diff --git a/src/commands/giveaway.ts b/src/commands/giveaway.ts new file mode 100644 index 0000000..ab2a4ef --- /dev/null +++ b/src/commands/giveaway.ts @@ -0,0 +1,62 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; + +import { createEmbed } from '../utils/embed'; +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('giveaway') + .setDescription('Create a giveaway') + .addStringOption((option) => option.setName('prize').setDescription('The prize of the giveaway').setRequired(true).setMinLength(2).setMaxLength(512)) + .addIntegerOption((option) => + option.setName('minutes').setDescription('The amount of minutes you want to mute this user for').setMinValue(1).setMaxValue(59) + ) + .addIntegerOption((option) => option.setName('hours').setDescription('The amount of hours you want to mute this user for').setMinValue(1).setMaxValue(168)) + .addIntegerOption((option) => option.setName('winners').setDescription('The amount of winners').setMinValue(1).setMaxValue(25)) + .addRoleOption((option) => option.setName('role').setDescription('Required role to enter')) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageEvents), + + category: 'General', + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) return interaction.client.error.NO_PERMISSION(interaction); + + const prize = interaction.options.getString('prize', true); + const winnersAmount = interaction.options.getInteger('winners') || 1; + const roleRequirement = interaction.options.getRole('role'); + + const hours = interaction.options.getInteger('hours'); + if (hours && (hours < 1 || hours > 168)) return interaction.client.error.INVALID_INPUT(interaction, 'hours', 'should be between 1 and 168'); + const minutes = interaction.options.getInteger('minutes'); + if (minutes && (minutes < 1 || minutes > 59)) return interaction.client.error.INVALID_INPUT(interaction, 'minutes', 'should be between 1 and 59'); + const minutesToAdd = (minutes || 0) + (hours || 0) * 60; + if (minutesToAdd < 1 || minutesToAdd > Number.MAX_SAFE_INTEGER) + return interaction.client.error.INVALID_INPUT(interaction, 'hours/minutes', 'should be nonzero'); + + if (prize.length < 2 || prize.length > 512) + return interaction.client.error.INVALID_INPUT(interaction, 'prize', 'should have a length of max 512 characters'); + if (winnersAmount < 1 || winnersAmount > 25) return interaction.client.error.INVALID_INPUT(interaction, 'winners', 'should be between 1 and 25'); + + const endTime = interaction.createdTimestamp + minutesToAdd * 60 * 1000; + + const embed = await createEmbed(); + embed.setTitle(`🎉 ${winnersAmount} Winner${winnersAmount > 1 ? 's' : ''} 🎉`); + embed.setDescription(` +Prize: **${prize}**\n\nEnds in ()! +${roleRequirement ? `\nMust have: ${roleRequirement}` : ''} + `); + + const sentResponse = await interaction.editReply({ embeds: [embed] }); + await sentResponse.react('🎉'); + + return query('INSERT INTO giveaways (message, channel, endtime, prize, winners, role) VALUES ($1,$2,$3,$4,$5,$6);', [ + sentResponse.id, + sentResponse.channelId, + new Date(endTime), + prize, + winnersAmount, + roleRequirement?.id || null, + ]); + }, +}; diff --git a/src/commands/kick.ts b/src/commands/kick.ts new file mode 100644 index 0000000..916a3aa --- /dev/null +++ b/src/commands/kick.ts @@ -0,0 +1,61 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a member') + .addUserOption((option) => option.setName('member').setDescription('The member to kick').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to kick').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + if (!member.kickable) return interaction.editReply('I have no permissions to kick this member!'); + + member.send(`You are kicked for ${reason} in ${interaction.guild.name}, goodbye!`).catch(); + + try { + await member.kick(reason); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); + } + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO kicks (id, guild, member, reason, moderator, time) VALUES ($1,$2,$3,$4,$5,$6);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + ]); + return interaction.editReply(`Kicked ${member.displayName} for ${reason}!`); + }, +}; diff --git a/src/commands/mute.ts b/src/commands/mute.ts new file mode 100644 index 0000000..90182a5 --- /dev/null +++ b/src/commands/mute.ts @@ -0,0 +1,85 @@ +import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Mute a member') + .addUserOption((option) => option.setName('member').setDescription('The member to mute').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to mute').setRequired(true).setMaxLength(512).setMinLength(2)) + .addIntegerOption((option) => + option.setName('minutes').setDescription('The amount of minutes you want to mute this user for').setMinValue(1).setMaxValue(59) + ) + .addIntegerOption((option) => option.setName('hours').setDescription('The amount of hours you want to mute this user for').setMinValue(1).setMaxValue(168)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageRoles)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const hours = interaction.options.getInteger('hours'); + if (hours && (hours < 1 || hours > 168)) return interaction.client.error.INVALID_INPUT(interaction, 'hours', 'should be between 1 and 168'); + const minutes = interaction.options.getInteger('minutes'); + if (minutes && (minutes < 1 || minutes > 59)) return interaction.client.error.INVALID_INPUT(interaction, 'minutes', 'should be between 1 and 59'); + + const minutesToAdd = (minutes || 0) + (hours || 0) * 60; + const endTime = minutesToAdd !== 0 ? interaction.createdTimestamp + minutesToAdd * 60 * 1000 : null; + const endTimeString = endTime ? `until ` : ''; + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + if (!member.manageable) return interaction.editReply('I have no permissions to mute this member!'); + + member + .send(`You are muted for ${reason} ${endTimeString}in ${interaction.guild.name}, do not let it happen again!`) + .catch(() => interaction.channel?.send({ content: `${member}, You are muted for ${reason} ${endTimeString}, do not let it happen again!` }).catch()); + + let mutedRole: string | undefined; + const { rows: guildRows } = await query('SELECT muterole FROM guilds WHERE id = $1;', [interaction.guildId]); + if (guildRows[0].length !== 0 && guildRows[0].muterole) mutedRole = guildRows[0].muterole; + else mutedRole = interaction.guild.roles.cache.find((role) => role.name.toLowerCase() == 'muted' || role.name.toLowerCase() == 'mute')?.id; + if (!mutedRole) return interaction.client.error.CUSTOM(interaction, 'I can not find the muted role!'); + + if (member.roles.cache.has(mutedRole)) return interaction.client.error.CUSTOM(interaction, 'This user is already muted!'); + + try { + await member.roles.add(mutedRole, reason); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); + } + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO mutes (id, guild, member, reason, moderator, time, endtime) VALUES ($1,$2,$3,$4,$5,$6,$7);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + endTime ? new Date(endTime) : null, + ]); + return interaction.editReply(`Muted ${member.displayName} for ${reason} ${endTimeString}!`); + }, +}; diff --git a/src/commands/open.ts b/src/commands/open.ts new file mode 100644 index 0000000..faebc9e --- /dev/null +++ b/src/commands/open.ts @@ -0,0 +1,67 @@ +import { ChannelType, ChatInputCommandInteraction, OverwriteType, PermissionFlagsBits, SlashCommandBuilder, TextChannel } from 'discord.js'; + +import { createEmbed } from '../utils/embed'; +import { query } from '../utils/postgres'; +import { closeTicket } from '../utils/tickets'; + +export default { + data: new SlashCommandBuilder().setName('open').setDescription('Open a support ticket'), + + category: 'Tickets', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + + const member = interaction.member; + const memberID = member.id; + + const { rows: currentSupportRows } = await query('SELECT channel FROM supports WHERE member = $1 AND guild = $2;', [memberID, interaction.guildId]); + let channel: TextChannel | undefined; + + if (currentSupportRows.length !== 0) { + try { + const possibleChannel = await interaction.guild.channels.fetch(currentSupportRows[0].channel); + if (possibleChannel?.type === ChannelType.GuildText) channel = possibleChannel; + } catch (e) { + // Channel is manually deleted + await closeTicket(currentSupportRows[0].channel, interaction.guildId, channel); + } + } + + if (!channel) { + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + const { rows: guildRows } = await query('SELECT mailcategory FROM guilds WHERE id = $1', [interaction.guildId]); + + let parent = null; + if (guildRows.length !== 0) parent = guildRows[0].mailcategory; + + channel = await interaction.guild.channels.create({ + name: interaction.member.displayName + '-support', + type: ChannelType.GuildText, + parent, + permissionOverwrites: [ + { + type: OverwriteType.Role, + id: interaction.guildId, + deny: PermissionFlagsBits.ViewChannel, + }, + { + type: OverwriteType.Member, + id: memberID, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory], + }, + ], + }); + + const embed = await createEmbed(); + embed.setDescription('Your support ticket has been created. Please provide as much details as you can!'); + channel?.send({ embeds: [embed] }); + + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO supports (member, channel, guild) VALUES ($1, $2, $3);', [memberID, channel.id, interaction.guildId]); + } + + return interaction.editReply({ content: `Your support ticket is: <#${channel.id}>` }); + }, +}; diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 1262441..289b4f8 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,13 +1,11 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; export default { - data: new SlashCommandBuilder() - .setName('ping') - .setDescription('Get the ping of the bot'), + data: new SlashCommandBuilder().setName('ping').setDescription('Get the ping of the bot'), category: 'General', - async execute(interaction) { + async execute(interaction: ChatInputCommandInteraction) { const start = Date.now(); await interaction.deferReply(); const end = Date.now(); diff --git a/src/commands/remove.ts b/src/commands/remove.ts new file mode 100644 index 0000000..61f86f0 --- /dev/null +++ b/src/commands/remove.ts @@ -0,0 +1,28 @@ +import { ChannelType, ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('remove') + .setDescription('Remove a member from a support ticket') + .addUserOption((option) => option.setName('member').setDescription('The member to remove access from this support ticket').setRequired(true)), + + category: 'Tickets', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + + const { rows: currentSupportRows } = await query('SELECT FROM supports WHERE channel = $1 AND guild = $2;', [interaction.channelId, interaction.guildId]); + if (currentSupportRows.length === 0) return interaction.client.error.ONLY_IN_TICKET(interaction, false); + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + + await interaction.channel.permissionOverwrites.delete(memberID); + + return interaction.editReply(`You removed ${member.displayName} from this support ticket!`); + }, +}; diff --git a/src/commands/unban.ts b/src/commands/unban.ts new file mode 100644 index 0000000..daa74b9 --- /dev/null +++ b/src/commands/unban.ts @@ -0,0 +1,57 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('unban') + .setDescription('Unban a member') + .addUserOption((option) => option.setName('member').setDescription('The member to unban').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to unban').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getUser('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + if (!interaction.guild.members.me?.permissions.has(PermissionFlagsBits.BanMembers)) + return interaction.editReply('I have no permissions to unban this member!'); + + try { + await interaction.guild.members.unban(member, reason); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); + } + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO unbans (id, guild, member, reason, moderator, time) VALUES ($1,$2,$3,$4,$5,$6);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + ]); + return interaction.editReply(`Unbanned ${member.displayName} for ${reason}!`); + }, +}; diff --git a/src/commands/unmute.ts b/src/commands/unmute.ts new file mode 100644 index 0000000..6c62948 --- /dev/null +++ b/src/commands/unmute.ts @@ -0,0 +1,72 @@ +import { ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Unmute a member') + .addUserOption((option) => option.setName('member').setDescription('The member to unmute').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to unmute').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild()) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageRoles)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + if (!member.manageable) return interaction.editReply('I have no permissions to unmute this member!'); + + member + .send(`You are unmuted for ${reason} in ${interaction.guild.name}, make sure to be nice again!`) + .catch(() => interaction.channel?.send({ content: `${member}, You are unmuted for ${reason}, make sure to be nice again!` }).catch()); + + let mutedRole: string | undefined; + const { rows: guildRows } = await query('SELECT muterole FROM guilds WHERE id = $1;', [interaction.guildId]); + if (guildRows[0].length !== 0 && guildRows[0].muterole) mutedRole = guildRows[0].muterole; + else mutedRole = interaction.guild.roles.cache.find((role) => role.name.toLowerCase() == 'muted' || role.name.toLowerCase() == 'mute')?.id; + if (!mutedRole) return interaction.client.error.CUSTOM(interaction, 'I can not find the muted role!'); + + if (!member.roles.cache.has(mutedRole)) return interaction.client.error.CUSTOM(interaction, 'This user is not muted!'); + + try { + await member.roles.remove(mutedRole, reason); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); + } + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO unmutes (id, guild, member, reason, moderator, time) VALUES ($1,$2,$3,$4,$5,$6);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + ]); + await query('UPDATE mutes SET unmuted = true WHERE member = $1 AND guild = $2;', [memberID, interaction.guildId]); + return interaction.editReply(`Unmuted ${member.displayName} for ${reason}!`); + }, +}; diff --git a/src/commands/warn.ts b/src/commands/warn.ts new file mode 100644 index 0000000..8906d33 --- /dev/null +++ b/src/commands/warn.ts @@ -0,0 +1,55 @@ +import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { v4 as uuidv4 } from 'uuid'; + +import { query } from '../utils/postgres'; + +export default { + data: new SlashCommandBuilder() + .setName('warn') + .setDescription('Warn a member') + .addUserOption((option) => option.setName('member').setDescription('The member to warn').setRequired(true)) + .addStringOption((option) => option.setName('reason').setDescription('The reason to warn').setRequired(true).setMaxLength(512).setMinLength(2)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const reason = interaction.options.getString('reason', true); + if (reason.length < 2 || reason.length > 512) return interaction.client.error.INVALID_INPUT(interaction, 'reason', 'should be 512 characters or less'); + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + member + .send(`You are warned for ${reason} in ${interaction.guild.name}, do not let it happen again!`) + .catch(() => interaction.channel?.send({ content: `${member}, you are warned for ${reason}, do not let it happen again!` }).catch()); + + await query('INSERT INTO guilds (id) VALUES ($1) ON CONFLICT DO NOTHING;', [interaction.guildId]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [memberID]); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [moderatorID]); + + const id = uuidv4(); + + await query('INSERT INTO warnings (id, guild, member, reason, moderator, time) VALUES ($1,$2,$3,$4,$5,$6);', [ + id, + interaction.guildId, + memberID, + reason, + moderatorID, + interaction.createdAt, + ]); + return interaction.editReply(`Warned ${member.displayName} for ${reason}!`); + }, +}; diff --git a/src/commands/warnings.ts b/src/commands/warnings.ts new file mode 100644 index 0000000..ec549c8 --- /dev/null +++ b/src/commands/warnings.ts @@ -0,0 +1,57 @@ +import { ChannelType, ChatInputCommandInteraction, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; + +import { query } from '../utils/postgres'; +import { createEmbed } from '../utils/embed'; + +export default { + data: new SlashCommandBuilder() + .setName('warnings') + .setDescription('List the warnings of a particular user') + .addUserOption((option) => option.setName('member').setDescription('The member to view the warnings from').setRequired(true)) + .addIntegerOption((option) => option.setName('page').setDescription('The page to view').setMinValue(1)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + category: 'Moderation', + guildOnly: true, + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.inCachedGuild() || interaction.channel?.type !== ChannelType.GuildText) return interaction.client.error.ONLY_IN_GUILD(interaction); + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) return interaction.client.error.NO_PERMISSION(interaction); + + const moderator = interaction.member; + const moderatorID = moderator.id; + + const member = interaction.options.getMember('member'); + if (!member) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should be nonzero'); + const memberID = member.id; + if (memberID === moderatorID) return interaction.client.error.INVALID_INPUT(interaction, 'member', 'should not be you'); + + const page = interaction.options.getInteger('page') || 1; + if (page < 1 || page > Number.MAX_SAFE_INTEGER) return interaction.client.error.INVALID_INPUT(interaction, 'page', 'should be a valid page number'); + + const positionDifference = moderator.roles.highest.comparePositionTo(member.roles.highest); + if (positionDifference <= 0) return interaction.client.error.NO_PERMISSION(interaction); + + const { rows: warningRows } = await query('SELECT * FROM warnings WHERE member = $1 AND guild = $2 ORDER BY time ASC LIMIT 11 OFFSET $3;', [ + memberID, + interaction.guildId, + (page - 1) * 10, + ]); + if (warningRows.length === 0) return interaction.editReply({ content: 'This member has no warnings (yet)!' }); + const nextPage = warningRows.length === 11; + if (nextPage) warningRows.pop(); // remove extra + + const embed = await createEmbed(); + embed.setTitle(`Warnings for ${member.displayName}`); + if (nextPage) embed.setTitle('There are more warnings on the next page!'); + + for (const warning of warningRows) { + embed.addFields({ + name: `Warning at `, + value: `Warned for: ${warning.reason}\nID: \`${warning.id}\``, + }); + } + + return interaction.editReply({ embeds: [embed] }); + }, +}; diff --git a/src/events/ClientReady.ts b/src/events/ClientReady.ts new file mode 100644 index 0000000..07729ac --- /dev/null +++ b/src/events/ClientReady.ts @@ -0,0 +1,129 @@ +import { ChannelType, Client, Events } from 'discord.js'; + +import { fetchReactedUsers } from '../utils/giveaway'; +import { query } from '../utils/postgres'; +import { createEmbed } from '../utils/embed'; + +const MAIL_GUILDID = process.env.MAIL_GUILDID; +const MAIL_CATEGORYID = process.env.MAIL_CATEGORYID; + +export default { + name: Events.ClientReady, + once: true, + + async execute(client: Client) { + if (!client.user) return; + const clientID = client.user.id; + + client.user.setActivity('DM for support'); + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [clientID]); + + if (MAIL_GUILDID) { + try { + client.mailguild = await client.guilds.fetch(MAIL_GUILDID); + + if (MAIL_CATEGORYID) { + const mailcategory = await client.mailguild.channels.fetch(MAIL_CATEGORYID); + if (mailcategory?.type === ChannelType.GuildCategory) client.mailcategory = mailcategory; + } + } catch (error) { + console.log('Invalid mail guild ID!'); + } + } + + setInterval(async () => { + const now = new Date(); + + const { rows: muteRows } = await query(`SELECT id, guild, member FROM mutes WHERE endtime < $1 AND unmuted IS NULL;`, [now]); + for (const mute of muteRows) { + const muteID = mute.id; + + // TODO: make sure user leave + join back doesn't bypass mute, so add the muted role back when user joins and endtime hasn't passed yet + + try { + console.log('auto unmute'); + const guild = await client.guilds.fetch(mute.guild); + const member = await guild.members.fetch(mute.member); + + await query('INSERT INTO unmutes (id, guild, member, reason, moderator, time) VALUES ($1, $2, $3, $4, $5, $6);', [ + muteID, + mute.guild, + mute.member, + 'Automatic unmute', + clientID, + now, + ]); + + let mutedRole; + const { rows: guildRows } = await query('SELECT muterole FROM guilds WHERE id = $1;', [mute.guild]); + if (guildRows[0].length !== 0 && guildRows[0].muterole) mutedRole = guildRows[0].muterole; + else mutedRole = guild.roles.cache.find((role) => role.name.toLowerCase() == 'muted' || role.name.toLowerCase() == 'mute'); + + if (mutedRole && member.manageable) { + member.send(`You are unmuted in ${guild.name}, make sure to be nice again!`).catch(); + await member.roles.remove(mutedRole, 'Automatic unmute'); + await query('UPDATE mutes SET unmuted = true WHERE id = $1;', [muteID]); + } + } catch (e) { + // non existing guild or member + } + } + + const { rows: giveawayRows } = await query('SELECT prize, winners, message, channel, role FROM giveaways WHERE endtime < $1;', [now]); + for (const giveaway of giveawayRows) { + const messageID: string = giveaway.message; + const channelID: string = giveaway.channel; + const roleRequirement: string = giveaway.role || null; + + try { + const channel = await client.channels.fetch(channelID); + if (channel?.type !== ChannelType.GuildText) throw new Error('Invalid channel'); + + const message = await channel.messages.fetch(messageID); + const elegibleUsers = await fetchReactedUsers(message.reactions.cache.get('🎉')); + let users: string[] = []; + + if (roleRequirement) { + for (const user of elegibleUsers) { + const member = await channel.guild.members.fetch(user); + const hasRequiredRole = member.roles.cache.has(roleRequirement); + + if (hasRequiredRole) users.push(user); + } + } else users = elegibleUsers; + + await query('DELETE FROM giveaways WHERE message = $1 AND channel = $2;', [messageID, channelID]); + + const prize: string = giveaway.prize; + const winnersAmount: number = users.length < giveaway.winners ? users.length : giveaway.winners; + + const winners: string[] = []; + + for (let i = 0; i < winnersAmount; i++) { + const winner = users[Math.floor(Math.random() * users.length)]; + + if (winners.includes(winner)) i--; + else winners.push(winner); + } + + const embed = await createEmbed(); + embed.setTitle('🎉 The giveaway has ended 🎉'); + + const winnersList = '<@' + winners.map((u) => u.toString()).join('>, <@') + '>'; + + if (winners.length === 0) { + embed.setDescription(`Prize: ${prize}.\n\nWinner${winnersAmount > 1 ? 's' : ''}: None (no valid users found that reacted)`); + } else { + await message.reply(`🎉-Congrats to ${winnersList} for winning ${prize}!`); + embed.setDescription(`Prize: ${prize}.\n\nWinner${winnersAmount > 1 ? 's' : ''}: ${winnersList}`); + } + + return message.edit({ embeds: [embed] }); + } catch (e) { + // TODO: something went wrong? + await query('DELETE FROM giveaways WHERE message = $1 AND channel = $2;', [messageID, channelID]); + } + } + }, 60000); + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 17ab6f8..591bcc7 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -4,23 +4,16 @@ export default { name: Events.InteractionCreate, async execute(interaction: BaseInteraction) { - if (!interaction.isChatInputCommand() || !interaction.inCachedGuild()) - return; + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName !== 'ping') await interaction.deferReply(); const command = interaction.client.commands.get(interaction.commandName); - if (!['ping', 'donate'].includes(interaction.commandName)) - await interaction.deferReply(); - if (['donate'].includes(interaction.commandName)) - await interaction.deferReply({ ephemeral: true }); - if (!command) return + if (!command) return interaction.client.error.NO_COMMAND(interaction); try { await command.execute(interaction); - console.log( - `[COMMAND] ${interaction.guild.name}(${interaction.guild.id}) - ${interaction.user.tag}(${interaction.user.id}) executed ${interaction.commandName}` - ); - } catch (error: any) { - return console.log(error); + } catch (error) { + return interaction.client.error.UNKNOWN_ERROR(interaction, error); } }, }; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 4b15f7a..eddbec3 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,15 +1,73 @@ -import { Events } from 'discord.js'; +import { ChannelType, Events, Message, OverwriteType, PermissionFlagsBits, TextChannel } from 'discord.js'; + +import { createEmbed } from '../utils/embed'; +import { query } from '../utils/postgres'; +import { closeTicket } from '../utils/tickets'; export default { name: Events.MessageCreate, - listener: async (_client, message) => { - if ( - message.guild === null || - message.member?.user.bot || - message.member?.user.id === undefined - ) - return; - console.log(`Message from ${message.author.tag} in ${message.channel.id}`); + async execute(message: Message) { + const client = message.client; + + if (message.channel.type === ChannelType.DM && !message.author.bot && client.mailguild) { + const { rows: currentMailRows } = await query('SELECT channel FROM mails WHERE member = $1;', [message.author.id]); + let channel: TextChannel | undefined; + + if (currentMailRows.length !== 0) { + try { + const possibleChannel = await client.channels.fetch(currentMailRows[0].channel); + if (possibleChannel?.type === ChannelType.GuildText) channel = possibleChannel; + } catch (e) { + // Channel is manually deleted + await closeTicket(currentMailRows[0].channel, client.mailguild.id, channel); + } + } + + if (!channel) { + channel = await client.mailguild.channels.create({ + name: message.author.username + '-mail', + type: ChannelType.GuildText, + parent: client.mailcategory, + permissionOverwrites: [ + { + type: OverwriteType.Role, + id: client.mailguild.id, + deny: PermissionFlagsBits.ViewChannel, + }, + ], + }); + + const embed = await createEmbed(); + embed.setDescription('Your mail ticket has been created. To reply, just send a message in my DM.'); + embed.setFooter({ text: 'Messages with a ✅ have been successfully sent!' }); + await message.author.send({ embeds: [embed] }).catch(); + + await query('INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING;', [message.author.id]); + + await query('INSERT INTO mails (member, channel) VALUES ($1, $2);', [message.author.id, channel.id]); + } + + let webhook = (await channel.fetchWebhooks()).find((webhook) => webhook.name === 'mail'); + if (!webhook) webhook = await channel.createWebhook({ name: 'mail' }); + + await webhook.send({ + content: message.content, + username: message.author.username, + avatarURL: message.author.displayAvatarURL(), + }); + await message.react('✅'); + } else if (message.channel.type === ChannelType.GuildText && !message.author.bot && client.mailguild) { + const { rows: currentMailRows } = await query('SELECT member FROM mails WHERE channel = $1;', [message.channelId]); + if (currentMailRows.length > 0) { + try { + const member = await client.users.fetch(currentMailRows[0].member); + await member.send(message.content); + await message.react('✅'); + } catch (e) { + await message.react('❌'); + } + } + } }, }; diff --git a/src/events/ready.ts b/src/events/ready.ts deleted file mode 100644 index 7bc6489..0000000 --- a/src/events/ready.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Client, Events } from 'discord.js'; - -export default { - name: Events.ClientReady, - once: true, - - execute(client: Client) { - console.log(`[READY] ${client.user?.tag} is up and ready to go!`); - console.log( - `[READY] ${client.guilds.cache.size} servers, ${client.users.cache.size} members` - ); - }, -}; diff --git a/src/index.ts b/src/index.ts index d824dae..0a5124b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,43 +1,60 @@ -import 'dotenv/config.js'; -import { Client, Collection, GatewayIntentBits } from 'discord.js'; +import { ChatInputCommandInteraction, Client, Collection, GatewayIntentBits, Partials, SlashCommandBuilder } from 'discord.js'; import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { start } from './utils/postgres'; + +type CommandData = { + data: SlashCommandBuilder; + category: string; + execute: (interaction: ChatInputCommandInteraction) => undefined; +}; + declare module 'discord.js' { interface Client { - commands: Collection; - categories: Collection; + commands: Collection; + error: typeof import('./utils/error').default; + mailguild: Guild | null; + mailcategory: CategoryChannel | null; } } -const client = new Client({ intents: [GatewayIntentBits.Guilds] }); -const commands: any = []; +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages], + partials: [Partials.Channel], // Channel is required for DM messages +}); +const commands: SlashCommandBuilder[] = []; (async () => { + client.error = (await import('./utils/error')).default; client.commands = new Collection(); - client.categories = new Collection(); + // Load all commands const commandFiles = await readdir(join(__dirname, './commands')); for (const file of commandFiles) { const command = (await import(`./commands/${file}`)).default; commands.push(command.data.toJSON()); client.commands.set(command.data.name, command); - if (client.categories[command.category]) - client.categories[command.category].push(command.data.name); - else client.categories[command.category] = [command.data.name]; } + // Load all events const eventFiles = await readdir(join(__dirname, './events')); for (const file of eventFiles) { const event = (await import(`./events/${file}`)).default; client.on(event.name, (...args) => event.execute(...args)); } + + start(); })(); client.on('ready', async () => { - await client.application?.commands.set([]); - await client.application?.commands.set(commands); + // await client.application?.commands.set([]); + client.application?.commands.set(commands).catch((e) => { + console.log(e); + }); }); client.login(process.env.BOT_TOKEN); + +export { client }; diff --git a/src/utils/embed.ts b/src/utils/embed.ts new file mode 100644 index 0000000..c81e667 --- /dev/null +++ b/src/utils/embed.ts @@ -0,0 +1,13 @@ +import { EmbedBuilder } from 'discord.js'; + +/** + * Create a default embed + * @returns The embed + */ +export async function createEmbed(): Promise { + const embed = new EmbedBuilder(); + // embed.setTimestamp(); + // embed.setFooter({ text: `Requested by ${interaction.user.tag}`, iconURL: interaction.user.displayAvatarURL() }); + embed.setColor('#0ba884'); + return embed; +} diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..8ccf4cf --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,97 @@ +import { ButtonInteraction, ChatInputCommandInteraction, DiscordjsError, StringSelectMenuInteraction } from 'discord.js'; +import { createEmbed } from './embed'; + +export default { + /** + * ERROR: Invalid (interaction) command + * @param interaction The interaction to followUp to + */ + NO_COMMAND: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction) { + const embed = await createEmbed(); + embed.setTitle('❌ Error!'); + embed.setDescription('That command does not exists!'); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: User has entered an invalid value + * @param interaction The interaction to followUp to + * @param option The option which is violated + * @param restriction The restriction which is violated + */ + INVALID_INPUT: async function ( + interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction, + option: string, + restriction: string + ) { + const embed = await createEmbed(); + if (!restriction) embed.setDescription(`It seems like your input for the option **${option}** was wrong, please try again.`); + else embed.setDescription(`It seems like your input for the option **${option}** violated the restriction ${restriction}, please try again.`); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: Command can only be run in a guild (server) + * @param interaction The interaction to followUp to + */ + ONLY_IN_GUILD: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction) { + const embed = await createEmbed(); + embed.setDescription(`You can only run this command in a server (text channel)!`); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: Command can only be run in a ticket + * @param interaction The interaction to followUp to + */ + ONLY_IN_TICKET: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction, mail: boolean = false) { + const embed = await createEmbed(); + embed.setDescription(`You can only run this command in a valid support ${mail ? ' or mail ' : ''}ticket!`); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: You don't have permission to do that + * @param interaction The interaction to followUp to + */ + NO_PERMISSION: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction) { + const embed = await createEmbed(); + embed.setDescription(`It seems like you do not have enough permissions to do that.`); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: Custom error. Leave error empty to send default one + * @param interaction The interaction to followUp to + * @param error The error to show, leave empty for general error message + */ + CUSTOM: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction, error?: string) { + if (!error) error = 'It seems like something went wrong, please try again!'; + + const embed = await createEmbed(); + embed.setTitle('❌ Error!'); + embed.setDescription(error); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, + + /** + * ERROR: Unknown error. Preferably use the CUSTOM error code and not this + * @param interaction The interaction to followUp to + * @param error The error message/code + */ + UNKNOWN_ERROR: async function (interaction: ChatInputCommandInteraction | ButtonInteraction | StringSelectMenuInteraction, error: unknown) { + const embed = await createEmbed(); + embed.setTitle('❌ Error!'); + if (error instanceof DiscordjsError) error = error.message; + embed.setDescription(`An unknown error occurred.\n\`\`\`ts\n${error}\`\`\``); + embed.setFields([{ name: 'Error code', value: 'UNKNOWN_ERROR' }]); + embed.setColor('#FF0000'); + interaction.followUp({ embeds: [embed] }); + }, +}; diff --git a/src/utils/giveaway.ts b/src/utils/giveaway.ts new file mode 100644 index 0000000..e8941d2 --- /dev/null +++ b/src/utils/giveaway.ts @@ -0,0 +1,15 @@ +import { MessageReaction } from 'discord.js'; + +export async function fetchReactedUsers(reaction: MessageReaction | undefined, after?: string): Promise { + if (!reaction) return []; + + const opts = { limit: 100, after }; + const users = (await reaction.users.fetch(opts)).filter((user) => !user.bot).map((user) => user.id); + if (users.length === 0) return []; + + const last = users[users.length - 1]; + if (!last) return []; + + const next = await fetchReactedUsers(reaction, last); + return users.concat(next); +} diff --git a/src/utils/postgres.ts b/src/utils/postgres.ts new file mode 100644 index 0000000..33dac7c --- /dev/null +++ b/src/utils/postgres.ts @@ -0,0 +1,191 @@ +import { Pool } from 'pg'; + +const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, +}); + +/** + * Query the database + * @param text The SQL query + * @param params The params to insert into the SQL query + * @returns The result of the query + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function query(text: string, params): Promise<{ rows: any[]; rowCount: number }> { + return pool.query(text, params); +} + +/** + * Query the database, make sure if one query fails that all of them fail + * @param queries The queries + * @returns the result of the query + */ +export async function safeQuery(queries: { query: string; params: (string | number)[] }[]): Promise { + const client = await pool.connect(); + let success = true; + + try { + await client.query('BEGIN'); + await Promise.all(queries.map(async (query) => await client.query(query.query, query.params))); + await client.query('COMMIT'); + } catch (e) { + success = false; + await client.query('ROLLBACK'); + } finally { + client.release(); + } + + return success; +} + +export async function start() { + await query( + `CREATE TABLE if not exists users ( + id varchar(64), + PRIMARY KEY(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists guilds ( + id varchar(64), + muterole varchar(64), + mailcategory varchar(64), + PRIMARY KEY(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists bans ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + PRIMARY KEY(id), + CONSTRAINT ban_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT ban_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT ban_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists kicks ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + PRIMARY KEY(id), + CONSTRAINT kick_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT kick_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT kick_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists mutes ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + endtime timestamp, + unmuted boolean, + PRIMARY KEY(id), + CONSTRAINT mute_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT mute_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT mute_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists unbans ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + PRIMARY KEY(id), + CONSTRAINT unban_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT unban_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT unban_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists unmutes ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + PRIMARY KEY(id), + CONSTRAINT unmute_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT unmute_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT unmute_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists warnings ( + id varchar(40), + guild varchar(64), + member varchar(64), + reason varchar(512), + moderator varchar(64), + time timestamp, + PRIMARY KEY(id), + CONSTRAINT warn_guild FOREIGN KEY(guild) REFERENCES guilds(id), + CONSTRAINT warn_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT warn_moderator FOREIGN KEY(moderator) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists supports ( + member varchar(64), + channel varchar(64), + guild varchar(64), + PRIMARY KEY(member, guild), + CONSTRAINT support_member FOREIGN KEY(member) REFERENCES users(id), + CONSTRAINT support_guild FOREIGN KEY(guild) REFERENCES guilds(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists mails ( + member varchar(64), + channel varchar(64), + PRIMARY KEY(member), + CONSTRAINT mail_member FOREIGN KEY(member) REFERENCES users(id) + );`, + [] + ); + await query( + `CREATE TABLE if not exists giveaways ( + message varchar(64), + channel varchar(64), + endtime timestamp, + prize varchar(512), + winners int, + role varchar(64), + PRIMARY KEY(message) + );`, + [] + ); + // await query( + // `CREATE TABLE if not exists levels ( + // guild varchar(64), + // member varchar(64), + // xp int, + // level int + // );`, + // [] + // ); +} diff --git a/src/utils/tickets.ts b/src/utils/tickets.ts new file mode 100644 index 0000000..0549a17 --- /dev/null +++ b/src/utils/tickets.ts @@ -0,0 +1,32 @@ +import { TextChannel } from 'discord.js'; + +import { createEmbed } from './embed'; +import { query } from './postgres'; + +export async function closeTicket(channelID: string, guildID: string, channel?: TextChannel) { + let supportOrMail; + + const { rows: currentSupportRows } = await query('SELECT FROM supports WHERE channel = $1 AND guild = $2;', [channelID, guildID]); + if (currentSupportRows.length !== 0) supportOrMail = 'support'; + + const { rows: currentMailRows } = await query('SELECT member FROM mails WHERE channel = $1;', [channelID]); + if (currentMailRows.length !== 0) supportOrMail = 'mail'; + + if (supportOrMail === 'support') { + await query('DELETE FROM supports WHERE channel = $1 AND guild = $2;', [channelID, guildID]); + if (channel) await channel.delete('support ticket closed'); + + return { success: true }; + } else if (supportOrMail === 'mail') { + const embed = await createEmbed(); + embed.setDescription('Your mail ticket has been closed.'); + embed.setFooter({ text: 'Please do not send a reply to this message, unless you wish to open a new ticket.' }); + + await query('DELETE FROM mails WHERE channel = $1;', [channelID]); + if (channel) await channel.delete('mail ticket closed'); + + return { success: true, embed, memberID: currentMailRows[0].member }; + } else { + return { success: false }; + } +}