diff --git a/src/buttons/warning-next.ts b/src/buttons/warning-next.ts new file mode 100644 index 0000000..ede111b --- /dev/null +++ b/src/buttons/warning-next.ts @@ -0,0 +1,15 @@ +import { ButtonInteraction, Client } from 'discord.js'; +import { getWarningMessage } from '../utils/warning-utils'; + +export default { + name: 'warning-next', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getWarningMessage(userId, parseInt(page) + 1); + + if (!message) return; + + interaction.editReply(message); + } +}; \ No newline at end of file diff --git a/src/buttons/warning-previous.ts b/src/buttons/warning-previous.ts new file mode 100644 index 0000000..34f1c85 --- /dev/null +++ b/src/buttons/warning-previous.ts @@ -0,0 +1,15 @@ +import { ButtonInteraction, Client} from 'discord.js'; +import { getWarningMessage } from '../utils/warning-utils'; + +export default { + name: 'warning-previous', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getWarningMessage(userId, parseInt(page) - 1); + + if (!message) return; + + interaction.editReply(message); + } +}; \ No newline at end of file diff --git a/src/commands/phrases.ts b/src/commands/phrases.ts index 40b2cc1..ea1b901 100644 --- a/src/commands/phrases.ts +++ b/src/commands/phrases.ts @@ -147,7 +147,7 @@ async function dumpPhrases(interaction: ChatInputCommandInteraction) { } async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) { - await Database.addTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase); + await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase); interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`); } diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 707a3e4..2271984 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -65,12 +65,14 @@ export default { .setRequired(false) ), handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + let response = ''; const settings = await Database.getGuildSettings(interaction.guildId!); if (!settings) { - await Database.addGuild(interaction.guildId!); + await Database.putGuild(interaction.guildId!); } const generalChannel = interaction.options.getChannel('general-channel'); @@ -132,7 +134,7 @@ export default { const addCategory = interaction.options.getChannel('add-staff-category'); if (addCategory) { if (addCategory.type == ChannelType.GuildCategory) { - await Database.addGuildStaffCategory(interaction.guildId!, addCategory.id); + await Database.putGuildStaffCategory(interaction.guildId!, addCategory.id); response += `Added ${addCategory.toString()} as a staff category.\n`; } else { @@ -153,8 +155,8 @@ export default { } } - if (response.length == 0) return interaction.reply({ content: 'No settings provided.', flags: [MessageFlags.Ephemeral] }); + if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' }); - interaction.reply({ content: response, flags: [MessageFlags.Ephemeral] }); + interaction.editReply({ content: response }); } }; \ No newline at end of file diff --git a/src/commands/warnings.ts b/src/commands/warnings.ts new file mode 100644 index 0000000..9c139ee --- /dev/null +++ b/src/commands/warnings.ts @@ -0,0 +1,116 @@ +import { ApplicationCommandOptionType, ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; +import { channelIsInStaffCategory, getE621User } from '../utils'; +import { config } from '../config'; +import { getWarningMessage } from '../utils/warning-utils'; + +export default { + name: 'warnings', + data: new SlashCommandBuilder() + .setName('warnings') + .setDescription('Add, view, or remove user warnings.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add warnings to a user.') + .addUserOption(option => + option + .setName('user') + .setDescription('The user to add a warning to.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the warning.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove warnings from a user.') + .addUserOption(option => + option + .setName('user') + .setDescription('The user to remove a warning from.') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('warning') + .setDescription('The warning to remove.') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription("List a user's warnings") + .addUserOption(option => + option + .setName('user') + .setDescription('The user to list the warnings of.') + .setRequired(true) + ) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const subcommand = interaction.options.getSubcommand(true); + const user = interaction.options.getUser('user', true); + + const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel); + + if (isStaffChannel) await interaction.deferReply(); + else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + if (subcommand == 'add') { + const reason = interaction.options.getString('reason', true); + + await Database.putWarning(user.id, reason, interaction.user.id); + + if (isStaffChannel) interaction.editReply('Added warning.'); + else interaction.editReply({ content: 'Added warning.' }); + } else if (subcommand == 'remove') { + const warningId = interaction.options.getInteger('warning', true); + + if (await Database.removeWarning(warningId)) { + if (isStaffChannel) interaction.editReply('Removed warning.'); + else interaction.editReply({ content: 'Removed warning.' }); + } else { + if (isStaffChannel) interaction.editReply('Warning not found.'); + else interaction.editReply({ content: 'Warning not found.' }); + } + } else if (subcommand == 'list') { + const warningMessage = await getWarningMessage(user.id, 1); + + if (!warningMessage) return interaction.editReply(`No warnings found for <@${user.id}>`); + + interaction.editReply(warningMessage); + } + }, + autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { + const user = interaction.options.get('user'); + + if (!user || user.type != ApplicationCommandOptionType.User) return interaction.respond([]); + + const userId = user.value as string; + + const value = interaction.options.getFocused().toLowerCase(); + + const warnings = await Database.getWarnings(userId); + + const toRespond = warnings.filter(w => !value ? true : w.reason.toLowerCase().includes(value)); + if (toRespond.length > 25) toRespond.length = 25; + + interaction.respond(toRespond.map((w) => { + return { + name: w.reason.substring(0, 50), + value: w.id + }; + })); + } +}; \ No newline at end of file diff --git a/src/events/handle-message.ts b/src/events/handle-message.ts index e4d2f86..e9052b6 100644 --- a/src/events/handle-message.ts +++ b/src/events/handle-message.ts @@ -5,6 +5,7 @@ import { getE621Post, getE621PostByMd5, getPostUrl, hasBlacklistedTags } from '. import { Database } from '../shared/Database'; import { logDeletion, logEdit } from '../utils/message-logger'; import { isEdited } from '../utils/message-utils'; +import { channelIsInStaffCategory } from '../utils'; export type Message = OmitPartialGroupDMChannel>; export type Partial = OmitPartialGroupDMChannel; @@ -142,12 +143,11 @@ async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[ } async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise { - const staffCategories = await Database.getGuildStaffCategories(message.guildId!); - const blacklistedIds: number[] = []; const channel = await message.channel.fetch() as GuildTextBasedChannel; + const isStaffChannel = await channelIsInStaffCategory(channel); for (const post of posts) { if (hasBlacklistedTags(post)) { @@ -159,7 +159,7 @@ async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promis await message.delete(); - if (channel.parentId && staffCategories.includes(channel.parentId)) { + if (channel.parentId && isStaffChannel) { await message.channel.send({ content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to ${blacklistedIds.length == 1 ? `post ${blacklistedIds[0]}` : `posts \`${blacklistedIds.join('`, `')}\``}. See rule #5.b for more details.`, allowedMentions: { diff --git a/src/index.ts b/src/index.ts index be4fd2b..e8ed1e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import 'source-map-support/register'; -import { Client as DiscordClient, GatewayIntentBits, Guild, Partials } from 'discord.js'; +import { Client as DiscordClient, GatewayIntentBits, Guild, MessageFlags, Partials } from 'discord.js'; import { config } from './config'; import { Handler } from './types'; import { initIfNecessary, loadHandlersFrom, openRedisClient, refreshCommands } from './utils'; @@ -52,7 +52,7 @@ client.on('interactionCreate', async (interaction) => { ) interaction.reply({ content: 'Bot is still starting up. Please wait a few seconds.', - ephemeral: true, + flags: [MessageFlags.Ephemeral], }); return; @@ -84,7 +84,7 @@ client.on('interactionCreate', async (interaction) => { for (const button of buttons) { if (id == button.name) { - button.handler(client, interaction, interaction.customId.split('_')[1]); + button.handler(client, interaction, ...interaction.customId.split('_').slice(1)); return; } } @@ -94,7 +94,7 @@ client.on('interactionCreate', async (interaction) => { for (const modal of modals) { if (id == modal.name) { - modal.handler(client, interaction, interaction.customId.split('_')[1]); + modal.handler(client, interaction, ...interaction.customId.split('_').slice(1)); return; } } @@ -104,7 +104,7 @@ client.on('interactionCreate', async (interaction) => { for (const menu of menus) { if (id == menu.name) { - menu.handler(client, interaction, interaction.customId.split('_')[1]); + menu.handler(client, interaction, ...interaction.customId.split('_').slice(1)); return; } } @@ -151,5 +151,6 @@ client.on('threadCreate', handleThreadCreate); client.on('voiceStateUpdate', handleVoiceStateUpdate); client.on('error', console.error); +process.on('uncaughtException', console.error); client.login(config.DISCORD_TOKEN); \ No newline at end of file diff --git a/src/shared/Database.ts b/src/shared/Database.ts index 1c12d88..92fd526 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 } from '../types'; +import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Warning } from '../types'; import { Message } from '../events'; const DB_SCHEMA = ` @@ -52,6 +52,14 @@ const DB_SCHEMA = ` user_id TEXT NOT NULL, phrase TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS warnings ( + id INTEGER PRIMARY KEY, + user_id TEXT, + reason TEXT, + mod_id TEXT, + timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) + ); `; export class Database { @@ -75,6 +83,8 @@ export class Database { console.log('SQLite database ensured'); } + // -- START WHOIS -- + static async getE621Ids(discordId: string): Promise { const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId); @@ -87,7 +97,7 @@ export class Database { return ids.map(r => r.discord_id); } - static async getCombinedIds(id: string) { + static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> { const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(` WITH RECURSIVE rec AS ( SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ? @@ -98,18 +108,22 @@ export class Database { WHERE depth <= 5 AND rec.depth = depth ) SELECT DISTINCT user_id, discord_id FROM rec`, id, id); - return ids.map(r => ({ user_id: r.user_id.toString(), discord_id: r.discord_id })); + return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id })); } static async putUser(id: number, user: DiscordOAuth2.User) { await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username); } + // -- END WHOIS -- + + // -- START SETTINGS -- + static async getGuildSettings(guildId: string): Promise { return await Database.db.get('SELECT * FROM settings WHERE guild_id = ?', guildId); } - static async addGuild(guildId: string) { + static async putGuild(guildId: string) { await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId); } @@ -149,7 +163,7 @@ export class Database { return settings.staff_categories.split(','); } - static async addGuildStaffCategory(guildId: string, categoryId: string) { + static async putGuildStaffCategory(guildId: string, categoryId: string) { const categories = await Database.getGuildStaffCategories(guildId); categories.push(categoryId); @@ -174,6 +188,10 @@ export class Database { return true; } + // -- END SETTINGS -- + + // START MESSAGE LOGS -- + static async putMessage(message: Message): Promise { try { const serializedMessage = serializeMessage(message); @@ -206,6 +224,10 @@ export class Database { } } + // -- END MESSAGE LOGS -- + + // -- START TICKETS -- + static async putTicket(ticketId: number, messageId: string) { await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId); } @@ -215,7 +237,7 @@ export class Database { return ticket?.message_id; } - static async addTicketPhrase(userId: string, phrase: string) { + static async putTicketPhrase(userId: string, phrase: string) { await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase); } @@ -234,4 +256,24 @@ export class Database { cb(ticketPhrase); }); } + + // -- END TICKETS -- + + // -- START WARNINGS -- + + static async putWarning(userId: string, reason: string, modId: string) { + await Database.db.run('INSERT INTO warnings(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId); + } + + static async removeWarning(id: number): Promise { + const res = await Database.db.run('DELETE from warnings WHERE id = ?', id); + + return (res.changes ?? 0) > 0; + } + + static async getWarnings(userId: string): Promise { + return await Database.db.all('SELECT * from warnings WHERE user_id = ?', userId); + } + + // -- END WARNINGS -- } \ No newline at end of file diff --git a/src/types/database-types.ts b/src/types/database-types.ts index 876b89e..cc7c419 100644 --- a/src/types/database-types.ts +++ b/src/types/database-types.ts @@ -30,4 +30,12 @@ export type TicketPhrase = { id: number user_id: string phrase: string +} + +export type Warning = { + id: number + user_id: string + reason: string + mod_id: string + timestamp: string } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 2b5e46a..c1be8c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ export * from './command'; +export * from './database-types'; export * from './e621-types'; export * from './handler'; export * from './helper-types'; -export * from './database-types'; diff --git a/src/utils/channel-utils.ts b/src/utils/channel-utils.ts new file mode 100644 index 0000000..2fad384 --- /dev/null +++ b/src/utils/channel-utils.ts @@ -0,0 +1,8 @@ +import { Channel, GuildBasedChannel, GuildChannel, GuildTextBasedChannel, TextBasedChannel, VoiceBasedChannel } from 'discord.js'; +import { Database } from '../shared/Database'; + +export async function channelIsInStaffCategory(channel: GuildBasedChannel) { + const staffCategories = await Database.getGuildStaffCategories(channel.guildId); + + return staffCategories.includes(channel.parentId!); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index ba146dc..c4ac4bb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './array-utils'; export * from './audit-log-utils'; +export * from './channel-utils'; export * from './commands'; export * from './e621-ticket-listener'; export * from './e621-utils'; @@ -8,4 +9,6 @@ export * from './message-utils'; export * from './ms-to-human'; export * from './refresh-commands'; export * from './string-utils'; +export * from './ticket-utils'; export * from './wait'; +export * from './warning-utils'; diff --git a/src/utils/warning-utils.ts b/src/utils/warning-utils.ts new file mode 100644 index 0000000..7d19c93 --- /dev/null +++ b/src/utils/warning-utils.ts @@ -0,0 +1,51 @@ +import { ActionRowBuilder, APIEmbedField, ButtonBuilder, ButtonStyle, EmbedBuilder, GuildMember, MessageActionRowComponentBuilder, time } from 'discord.js'; +import { Database } from '../shared/Database'; +import { Warning } from '../types'; + +type MessageContent = { content: string, components: ActionRowBuilder[] }; + +const WARNINGS_PER_PAGE = 5; + +function getWarningText(warning: Warning): string { + const timestamp = time(new Date(warning.timestamp)); + + return `### Warning from <@${warning.mod_id}> (${timestamp}):\n${warning.reason}`; +} + +export async function getWarningMessage(userId: string, page: number): Promise { + const warnings = await Database.getWarnings(userId); + + page = page - 1; + + const maxPage = Math.floor(warnings.length / WARNINGS_PER_PAGE); + + if (warnings.length == 0) return null; + + const warningTexts: string[] = []; + + for (let i = page * WARNINGS_PER_PAGE; i < page * WARNINGS_PER_PAGE + WARNINGS_PER_PAGE; i++) { + if (i >= warnings.length) break; + + warningTexts.push(getWarningText(warnings[i])); + } + + const prevPage = new ButtonBuilder() + .setLabel('Previous Page') + .setCustomId(`warning-previous_${userId}_${page + 1}`) + .setDisabled(page == 0) + .setStyle(ButtonStyle.Primary); + + const nextPage = new ButtonBuilder() + .setLabel('Next Page') + .setCustomId(`warning-next_${userId}_${page + 1}`) + .setDisabled(page >= maxPage) + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder() + .addComponents(prevPage, nextPage); + + return { + content: `<@${userId}>'s Warnings\n` + warningTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`, + components: [row] + }; +} \ No newline at end of file