diff --git a/src/commands/ban.ts b/src/commands/ban.ts new file mode 100644 index 0000000..c7619c2 --- /dev/null +++ b/src/commands/ban.ts @@ -0,0 +1,104 @@ +import { ApplicationIntegrationType, BitFieldResolvable, ChatInputCommandInteraction, Client, GuildBasedChannel, GuildMember, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles } from 'discord.js'; +import { channelIsInStaffCategory, deferInteraction, handleWhoIsInteraction } from '../utils'; +import { getRecordMessageFromDiscordId } from '../utils/record-utils'; +import { Database } from '../shared/Database'; + +export default { + name: 'ban', + data: new SlashCommandBuilder() + .setName('ban') + .setDescription('Bans a user.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addUserOption(option => + option + .setName('user') + .setDescription('The discord user to ban.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the ban') + .setRequired(false) + ) + .addNumberOption(option => + option + .setName('hours') + .setDescription('The duration of the ban, added with other options (0 for permanent).') + .setRequired(false) + ) + .addNumberOption(option => + option + .setName('minutes') + .setDescription('The duration of the ban, added with other options (0 for permanent).') + .setRequired(false) + ) + .addNumberOption(option => + option + .setName('seconds') + .setDescription('The duration of the ban, added with other options (0 for permanent).') + .setRequired(false) + ) + .addNumberOption(option => + option + .setName('delete-message-days') + .setDescription('How far back to delete messages (in days, default: 0 days).') + .setRequired(false) + .setMinValue(0) + .setMaxValue(7) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await deferInteraction(interaction); + + if (!interaction.guild) return interaction.editReply('This command must be used in a server'); + + if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.'); + + const user = interaction.options.getUser('user', true); + const reason = interaction.options.getString('reason') ?? ''; + + const hours = (interaction.options.getNumber('hours') ?? 0) * 3.6e+6; + const minutes = (interaction.options.getNumber('minutes') ?? 0) * 60000; + const seconds = (interaction.options.getNumber('seconds') ?? 0) * 1000; + + const duration = hours + minutes + seconds; + + const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400; + + let banMember: GuildMember | null = null; + + try { + banMember = await interaction.guild.members.fetch(user.id); + } catch (e) { + // Member not in server. + } + + const member = await interaction.guild.members.fetch(interaction.user.id); + + if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) { + return await interaction.editReply('You do not have permission to ban this user.'); + } + + if (banMember && !banMember.bannable) { + return await interaction.editReply('I do not have permission to ban this user.'); + } + + const expiresAt = new Date(Date.now() + duration); + + if (duration > 0) await Database.putBan(user.id, expiresAt); + + try { + await interaction.guild.bans.create(user, { + reason: (reason + ` Banned by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(), + deleteMessageSeconds: deleteMessageDays + }); + } catch (e) { + console.error(e); + return await interaction.editReply("Error banning user (couldn't ban)."); + } + + await interaction.editReply('Banned.'); + } +}; \ No newline at end of file diff --git a/src/commands/softban.ts b/src/commands/softban.ts index b69efde..4d95804 100644 --- a/src/commands/softban.ts +++ b/src/commands/softban.ts @@ -1,4 +1,4 @@ -import { ApplicationIntegrationType, BitFieldResolvable, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { ApplicationIntegrationType, BitFieldResolvable, ChatInputCommandInteraction, Client, GuildBasedChannel, GuildMember, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { channelIsInStaffCategory, deferInteraction, handleWhoIsInteraction } from '../utils'; import { getRecordMessageFromDiscordId } from '../utils/record-utils'; @@ -26,6 +26,7 @@ export default { option .setName('days') .setDescription('How far back to delete messages (in days, default: 7 days).') + .setRequired(false) .setMinValue(0) .setMaxValue(7) ), @@ -40,7 +41,14 @@ export default { const reason = interaction.options.getString('reason') ?? ''; const seconds = (interaction.options.getNumber('days') ?? 7) * 86400; - const banMember = await interaction.guild.members.fetch(user.id); + let banMember: GuildMember | null = null; + + try { + banMember = await interaction.guild.members.fetch(user.id); + } catch (e) { + // Member not in server. + } + const member = await interaction.guild.members.fetch(interaction.user.id); if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) { diff --git a/src/events/handle-ban-remove.ts b/src/events/handle-ban-remove.ts new file mode 100644 index 0000000..4b1bc2d --- /dev/null +++ b/src/events/handle-ban-remove.ts @@ -0,0 +1,6 @@ +import { GuildBan } from 'discord.js'; +import { Database } from '../shared/Database'; + +export async function handleBanRemove(ban: GuildBan) { + await Database.removeBan(ban.user.id); +} \ No newline at end of file diff --git a/src/events/handle-guild-create.ts b/src/events/handle-guild-create.ts index fbaf65a..4193769 100644 --- a/src/events/handle-guild-create.ts +++ b/src/events/handle-guild-create.ts @@ -1,6 +1,7 @@ +import { Guild } from 'discord.js'; import { Database } from '../shared/Database'; -export async function handleGuildCreate(guild) { +export async function handleGuildCreate(guild: Guild) { try { if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id); } catch (e) { diff --git a/src/events/index.ts b/src/events/index.ts index 46befba..87fe1ae 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,4 +1,5 @@ export * from './handle-audit-log-create'; +export * from './handle-ban-remove'; export * from './handle-guild-create'; export * from './handle-member-join'; export * from './handle-message'; diff --git a/src/index.ts b/src/index.ts index 1c5c5fc..dce2bdf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,11 @@ import 'source-map-support/register'; import { Client as DiscordClient, GatewayIntentBits, Guild, MessageFlags, Partials } from 'discord.js'; import { config } from './config'; import { Handler } from './types'; -import { initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; +import { checkExpiredBans, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils'; import { initializeDiscordJoiner } from './discord-joiner'; import { Database } from './shared/Database'; -import { handleAuditLogCreate, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events'; -import { ticketCooldownMap } from './shared/ticket-cooldown'; +import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events'; +import { pruneOldTickets, ticketCooldownMap } from './shared/ticket-cooldown'; import { openRedisClient } from './shared/RedisClient'; let ready = false; @@ -138,13 +138,11 @@ client.on('ready', async () => { await initializeDiscordJoiner(); // Prune ticket cooldowns that are expired every day - setInterval(() => { - const keys = Array.from(ticketCooldownMap.keys()); - for (const key of keys) { - if (Date.now() >= ticketCooldownMap.get(key)!) - ticketCooldownMap.delete(key); - } - }, 8.64e+7); + setInterval(pruneOldTickets, 8.64e+7); + + // Check for expired bans every 5 minutes + checkExpiredBans(client); + setInterval(checkExpiredBans.bind(null, client), 300000); ready = true; @@ -152,6 +150,7 @@ client.on('ready', async () => { }); client.on('guildAuditLogEntryCreate', handleAuditLogCreate); +client.on('guildBanRemove', handleBanRemove); client.on('guildCreate', handleGuildCreate); client.on('guildMemberAdd', handleMemberJoin); client.on('messageCreate', handleMessageCreate); diff --git a/src/shared/Database.ts b/src/shared/Database.ts index ad1d8af..c54175c 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -3,7 +3,7 @@ import { open, Database as SqliteDatabase } from 'sqlite'; import { config } from '../config'; import DiscordOAuth2 from 'discord-oauth2'; import { serializeMessage, wait } from '../utils'; -import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note } from '../types'; +import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban } from '../types'; import { Message } from '../events'; const DB_SCHEMA = ` @@ -60,6 +60,12 @@ const DB_SCHEMA = ` mod_id TEXT, timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) ); + + CREATE TABLE IF NOT EXISTS bans ( + id INTEGER PRIMARY KEY, + user_id TEXT, + expires_at datetime + ); `; export class Database { @@ -284,4 +290,24 @@ export class Database { } // -- END NOTES -- + + // -- START BANS -- + + static async putBan(userId: string, expiresAt: Date) { + await Database.db.run('INSERT INTO bans(user_id, expires_at) VALUES (?, ?)', userId, expiresAt); + } + + static async getExpiredBans(date: Date): Promise { + return await Database.db.all('SELECT * from bans WHERE expires_at <= ?', date); + } + + static async pruneExpiredBans(date: Date) { + await Database.db.all('DELETE from bans WHERE expires_at <= ?', date); + } + + static async removeBan(userId: string) { + await Database.db.run('DELETE from bans WHERE user_id = ?', userId); + } + + // -- END BANS -- } \ No newline at end of file diff --git a/src/shared/ticket-cooldown.ts b/src/shared/ticket-cooldown.ts index ea974c1..be8d8ba 100644 --- a/src/shared/ticket-cooldown.ts +++ b/src/shared/ticket-cooldown.ts @@ -1 +1,9 @@ -export const ticketCooldownMap = new Map(); \ No newline at end of file +export const ticketCooldownMap = new Map(); + +export function pruneOldTickets() { + const keys = Array.from(ticketCooldownMap.keys()); + for (const key of keys) { + if (Date.now() >= ticketCooldownMap.get(key)!) + ticketCooldownMap.delete(key); + } +} \ No newline at end of file diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index f1544b4..ab394dd 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -38,4 +38,10 @@ export type Note = { reason: string mod_id: string timestamp: string +} + +export type Ban = { + id: number + user_id: string + expires_at: string } \ No newline at end of file diff --git a/src/utils/ban-utils.ts b/src/utils/ban-utils.ts new file mode 100644 index 0000000..edd31e0 --- /dev/null +++ b/src/utils/ban-utils.ts @@ -0,0 +1,22 @@ +import { Client } from 'discord.js'; +import { Database } from '../shared/Database'; +import { config } from '../config'; + +export async function checkExpiredBans(client: Client) { + const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!); + + if (!guild) return; + + const date = new Date(); + + for (const ban of await Database.getExpiredBans(date)) { + try { + await guild.bans.remove(ban.user_id); + } catch (e) { + console.error(`Error unbanning user: ${ban.user_id}`); + console.error(e); + } + } + + await Database.pruneExpiredBans(date); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index c3ed727..5a73839 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './alt-utils'; export * from './array-utils'; export * from './audit-log-utils'; +export * from './ban-utils'; export * from './channel-utils'; export * from './commands'; export * from './e621-utils';