diff --git a/.dockerignore b/.dockerignore index e42559b..21c8afb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -/.git -/data -/dist +/.git +/data +/dist /node_modules \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0c61f83..3cf6e48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules -dist -data/* +node_modules +dist +data/* .env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..35c483d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.eol": "\n" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3f8621c..b46c16b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,21 @@ -FROM node:22-alpine AS build -WORKDIR /app - -COPY package*.json ./ -RUN npm install - -COPY . . -RUN npm run build - -# ---------- - -FROM node:22-alpine AS runtime -WORKDIR /app -ENV NODE_ENV=production - -COPY package*.json ./ -RUN npm install --omit=dev -COPY --from=build /app/dist ./dist - -USER node -CMD ["node", "./dist/index.js"] +FROM node:22-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# ---------- + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist + +USER node +CMD ["node", "./dist/index.js"] diff --git a/README.md b/README.md index 51e033b..f774194 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,51 @@ -## Bot Setup - -### Prerequisites -* Latest version of Docker ([download](https://docs.docker.com/get-docker)) -* Latest version of Docker Compose ([download](https://docs.docker.com/compose/install)) -* Git ([download](https://git-scm.com/downloads)) -* An [e621ng](https://github.com/e621ng/e621ng) instance ready to start -* A [discord bot application](https://discord.com/developers/applications) - - If you are on Windows Docker Compose is already included, you do not need to install it yourself. - If you are on Linux/MacOS you can probably use your package manager. - -### Discord application setup -1. Create a new application -2. Under the "Installation" sidebar - - For "Installation Contexts" select "Guild Install" - - Set the "Install Link" dropdown to "None" -3. Under the "OAuth2" sidebar - - Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening -4. Under the "Bot" sidebar - - It is recommended to disable "Public Bot" - - Enable "Server Members Intent" - - Enable "Message Content Intent" -5. Join the bot to the desired server using the following invite link, but replace the client id with your application's id: https://discord.com/oauth2/authorize?client_id=YOUR_APPLICATION_ID_HERE&scope=bot&permissions=395271335063 - -### Configuration -1. Copy the `.env.sample` file and rename it to `.env` -2. Enter your bot's token (found on the `Bot` page of the application setup) -3. Enter your application's client secret (found on the `OAuth2` page of the application setup) -4. Enter your bot's client id (application id, found on the `General Information` page of the application setup) -5. Enter your discord guild (server) id -6. Enter your discord joiner link secret (found in e621ng's `docker-compose.yml` file, or other environment file, it's passed as `DANBOORU_DISCORD_SECRET`. It defaults to `super_secret_for_url_discord`) -7. e621/e926 base url can be the same in development environments, but should be your e621ng's domain. Do not add trailing slashes -8. Enter the redis url of your e621ng instance. You may need to expose the port from docker manually in development enviornments. This can be done by adding a `ports` mapping to the `redis` service in e621ng's `docker-compose.yml` file. You should map `6379:6379` -9. Enter your desired port number. It is recommended to leave this at `8000`, if you select anything different you will need to map the correct port in `docker-compose.yml` - -### Installing dependencies -Run `npm i` to install all node dependencies. This is required to start the bot. - -### Starting the bot - -e621ng must be up for the bot to start properly and open the connection to the redis database. - -#### In development -You can use `npm run dev` to run the typescript without compiling. This will watch for changes and restart when they happen. - -If desired, you can also build the typescript using `npm run build` and then subsequently run it using `npm run start` - -#### In docker -1. Run `docker compose build` to build the image +## Bot Setup + +### Prerequisites +* Latest version of Docker ([download](https://docs.docker.com/get-docker)) +* Latest version of Docker Compose ([download](https://docs.docker.com/compose/install)) +* Git ([download](https://git-scm.com/downloads)) +* An [e621ng](https://github.com/e621ng/e621ng) instance ready to start +* A [discord bot application](https://discord.com/developers/applications) + + If you are on Windows Docker Compose is already included, you do not need to install it yourself. + If you are on Linux/MacOS you can probably use your package manager. + +### Discord application setup +1. Create a new application +2. Under the "Installation" sidebar + - For "Installation Contexts" select "Guild Install" + - Set the "Install Link" dropdown to "None" +3. Under the "OAuth2" sidebar + - Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening +4. Under the "Bot" sidebar + - It is recommended to disable "Public Bot" + - Enable "Server Members Intent" + - Enable "Message Content Intent" +5. Join the bot to the desired server using the following invite link, but replace the client id with your application's id: https://discord.com/oauth2/authorize?client_id=YOUR_APPLICATION_ID_HERE&scope=bot&permissions=395271335063 + +### Configuration +1. Copy the `.env.sample` file and rename it to `.env` +2. Enter your bot's token (found on the `Bot` page of the application setup) +3. Enter your application's client secret (found on the `OAuth2` page of the application setup) +4. Enter your bot's client id (application id, found on the `General Information` page of the application setup) +5. Enter your discord guild (server) id +6. Enter your discord joiner link secret (found in e621ng's `docker-compose.yml` file, or other environment file, it's passed as `DANBOORU_DISCORD_SECRET`. It defaults to `super_secret_for_url_discord`) +7. e621/e926 base url can be the same in development environments, but should be your e621ng's domain. Do not add trailing slashes +8. Enter the redis url of your e621ng instance. You may need to expose the port from docker manually in development enviornments. This can be done by adding a `ports` mapping to the `redis` service in e621ng's `docker-compose.yml` file. You should map `6379:6379` +9. Enter your desired port number. It is recommended to leave this at `8000`, if you select anything different you will need to map the correct port in `docker-compose.yml` + +### Installing dependencies +Run `npm i` to install all node dependencies. This is required to start the bot. + +### Starting the bot + +e621ng must be up for the bot to start properly and open the connection to the redis database. + +#### In development +You can use `npm run dev` to run the typescript without compiling. This will watch for changes and restart when they happen. + +If desired, you can also build the typescript using `npm run build` and then subsequently run it using `npm run start` + +#### In docker +1. Run `docker compose build` to build the image 2. Run `docker compose up` to start the bot. Changes to any files, including `.env` will require a rebuild \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d6d09eb..22e6745 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ -services: - hexerade: - restart: unless-stopped - build: . - env_file: '.env' - ports: - - "8000:8000" - volumes: - - ./data:/app/data +services: + hexerade: + restart: unless-stopped + build: . + env_file: '.env' + ports: + - "8000:8000" + volumes: + - ./data:/app/data diff --git a/eslint.config.js b/eslint.config.js index 877db1e..c11223c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,68 +1,68 @@ -const eslint = require('@eslint/js'); -const globals = require('globals'); -const tseslint = require('typescript-eslint'); -const stylistic = require('@stylistic/eslint-plugin'); - -const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js']; - -module.exports = tseslint.config( - { - ignores, - extends: [ - eslint.configs.recommended, - ...tseslint.configs.recommended - ] - }, - { - plugins: { - '@stylistic': stylistic - }, - ignores, - languageOptions: { - globals: { - ...globals.browser, - ...globals.node - } - }, - rules: { - 'no-empty': 'off', - 'prefer-const': ['error'], - 'no-async-promise-executor': 'off', - '@typescript-eslint/no-var-requires': 'off', - 'quotes': ['error', 'single', { 'avoidEscape': true }], - 'semi': ['error'], - '@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }], - '@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }], - '@stylistic/array-bracket-spacing': ['error', 'never'], - '@stylistic/block-spacing': ['error'], - '@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }], - '@stylistic/comma-dangle': ['error', { - 'arrays': 'only-multiline', - 'objects': 'only-multiline' - }], - '@stylistic/comma-spacing': ['error'], - '@stylistic/dot-location': ['error', 'property'], - '@stylistic/function-call-spacing': ['error', 'never'], - '@stylistic/keyword-spacing': ['error'], - '@stylistic/key-spacing': ['error'], - '@stylistic/no-trailing-spaces': ['error'], - '@stylistic/no-whitespace-before-property': ['error'], - '@stylistic/object-curly-newline': ['error', { - 'multiline': true, - 'consistent': true - }], - '@stylistic/operator-linebreak': ['error', 'before'], - '@stylistic/space-infix-ops': ['error'] - } - }, - { - ignores, - files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], - rules: { - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-namespace': 'off', - }, - } +const eslint = require('@eslint/js'); +const globals = require('globals'); +const tseslint = require('typescript-eslint'); +const stylistic = require('@stylistic/eslint-plugin'); + +const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js']; + +module.exports = tseslint.config( + { + ignores, + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended + ] + }, + { + plugins: { + '@stylistic': stylistic + }, + ignores, + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + }, + rules: { + 'no-empty': 'off', + 'prefer-const': ['error'], + 'no-async-promise-executor': 'off', + '@typescript-eslint/no-var-requires': 'off', + 'quotes': ['error', 'single', { 'avoidEscape': true }], + 'semi': ['error'], + '@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }], + '@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }], + '@stylistic/array-bracket-spacing': ['error', 'never'], + '@stylistic/block-spacing': ['error'], + '@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }], + '@stylistic/comma-dangle': ['error', { + 'arrays': 'only-multiline', + 'objects': 'only-multiline' + }], + '@stylistic/comma-spacing': ['error'], + '@stylistic/dot-location': ['error', 'property'], + '@stylistic/function-call-spacing': ['error', 'never'], + '@stylistic/keyword-spacing': ['error'], + '@stylistic/key-spacing': ['error'], + '@stylistic/no-trailing-spaces': ['error'], + '@stylistic/no-whitespace-before-property': ['error'], + '@stylistic/object-curly-newline': ['error', { + 'multiline': true, + 'consistent': true + }], + '@stylistic/operator-linebreak': ['error', 'before'], + '@stylistic/space-infix-ops': ['error'] + } + }, + { + ignores, + files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-namespace': 'off', + }, + } ); \ No newline at end of file diff --git a/src/buttons/claim-ticket.ts b/src/buttons/claim-ticket.ts index 5b77ba2..4fae40e 100644 --- a/src/buttons/claim-ticket.ts +++ b/src/buttons/claim-ticket.ts @@ -1,42 +1,42 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'claim-ticket', - handler: async function (client: Client, interaction: ButtonInteraction) { - const channel = await interaction.channel?.fetch(); - - if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); - - if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' }); - - if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to claim tickets.' }); - - const guild = await client.guilds.fetch(interaction.guildId!); - - const guildSettings = await Database.getGuildSettings(guild.id); - - if (!guildSettings || !guildSettings.private_help_role_id) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' }); - - const closeButton = new ButtonBuilder() - .setCustomId('close-ticket') - .setLabel('Click here if you no longer need help') - .setStyle(ButtonStyle.Danger); - - const unclaimButton = new ButtonBuilder() - .setCustomId('unclaim-ticket') - .setLabel('Unclaim ticket') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(closeButton, unclaimButton); - - await interaction.message.edit({ - content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`, - components: [row] - }); - - await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` }); - } +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'claim-ticket', + handler: async function (client: Client, interaction: ButtonInteraction) { + const channel = await interaction.channel?.fetch(); + + if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); + + if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' }); + + if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to claim tickets.' }); + + const guild = await client.guilds.fetch(interaction.guildId!); + + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_role_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' }); + + const closeButton = new ButtonBuilder() + .setCustomId('close-ticket') + .setLabel('Click here if you no longer need help') + .setStyle(ButtonStyle.Danger); + + const unclaimButton = new ButtonBuilder() + .setCustomId('unclaim-ticket') + .setLabel('Unclaim ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(closeButton, unclaimButton); + + await interaction.message.edit({ + content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`, + components: [row] + }); + + await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` }); + } }; \ No newline at end of file diff --git a/src/buttons/close-mod-ticket.ts b/src/buttons/close-mod-ticket.ts index ebe3978..aa42cda 100644 --- a/src/buttons/close-mod-ticket.ts +++ b/src/buttons/close-mod-ticket.ts @@ -1,31 +1,31 @@ -import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js'; - -export default { - name: 'close-mod-ticket', - handler: async function (client: Client, interaction: ButtonInteraction) { - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - const channel = await interaction.channel?.fetch(); - const guild = await interaction.guild?.fetch(); - const member = await guild?.members.fetch(interaction.user.id); - - if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread || !member) - return interaction.editReply({ content: 'Oops. Something went wrong. Please report this to a staff member.' }); - - if (!member.permissions.has(PermissionFlagsBits.KickMembers)) - return interaction.editReply({ content: 'Only staff members may close mod tickets.' }); - - await interaction.message.edit({ - content: interaction.message.content, - components: [] - }); - - await channel.send('This ticket has been closed by staff.'); - - await interaction.editReply({ content: 'Ticket closed.' }); - - channel.edit({ - archived: true, - locked: true - }); - } +import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js'; + +export default { + name: 'close-mod-ticket', + handler: async function (client: Client, interaction: ButtonInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const channel = await interaction.channel?.fetch(); + const guild = await interaction.guild?.fetch(); + const member = await guild?.members.fetch(interaction.user.id); + + if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread || !member) + return interaction.editReply({ content: 'Oops. Something went wrong. Please report this to a staff member.' }); + + if (!member.permissions.has(PermissionFlagsBits.KickMembers)) + return interaction.editReply({ content: 'Only staff members may close mod tickets.' }); + + await interaction.message.edit({ + content: interaction.message.content, + components: [] + }); + + await channel.send('This ticket has been closed by staff.'); + + await interaction.editReply({ content: 'Ticket closed.' }); + + channel.edit({ + archived: true, + locked: true + }); + } }; \ No newline at end of file diff --git a/src/buttons/close-ticket.ts b/src/buttons/close-ticket.ts index 4a731c9..d6a9fd0 100644 --- a/src/buttons/close-ticket.ts +++ b/src/buttons/close-ticket.ts @@ -1,28 +1,28 @@ -import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'close-ticket', - handler: async function (client: Client, interaction: ButtonInteraction) { - const channel = await interaction.channel?.fetch(); - - if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong. Please report this to a staff member.' }); - - await interaction.message.edit({ - content: interaction.message.content, - components: [] - }); - - await Database.closePrivateHelpTicket(channel.id); - - await channel.send(`This ticket has been closed by ${interaction.user}`); - - await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' }); - - channel.edit({ - archived: true, - locked: true - }); - } +import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'close-ticket', + handler: async function (client: Client, interaction: ButtonInteraction) { + const channel = await interaction.channel?.fetch(); + + if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong. Please report this to a staff member.' }); + + await interaction.message.edit({ + content: interaction.message.content, + components: [] + }); + + await Database.closePrivateHelpTicket(channel.id); + + await channel.send(`This ticket has been closed by ${interaction.user}`); + + await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' }); + + channel.edit({ + archived: true, + locked: true + }); + } }; \ No newline at end of file diff --git a/src/buttons/dev-watch.ts b/src/buttons/dev-watch.ts index ecaf201..3d85b7b 100644 --- a/src/buttons/dev-watch.ts +++ b/src/buttons/dev-watch.ts @@ -1,23 +1,23 @@ -import { ButtonInteraction, Client, MessageFlags } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'dev-watch', - handler: async function (client: Client, interaction: ButtonInteraction) { - const member = await interaction.guild!.members.fetch(interaction.user.id); - - if (!member) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' }); - - const settings = await Database.getGuildSettings(interaction.guild!.id); - - if (!settings || !settings.devwatch_role_id) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' }); - - if (member.roles.cache.has(settings.devwatch_role_id)) { - await member.roles.remove(settings.devwatch_role_id); - await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' }); - } else { - await member.roles.add(settings.devwatch_role_id); - await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' }); - } - } +import { ButtonInteraction, Client, MessageFlags } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'dev-watch', + handler: async function (client: Client, interaction: ButtonInteraction) { + const member = await interaction.guild!.members.fetch(interaction.user.id); + + if (!member) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' }); + + const settings = await Database.getGuildSettings(interaction.guild!.id); + + if (!settings || !settings.devwatch_role_id) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' }); + + if (member.roles.cache.has(settings.devwatch_role_id)) { + await member.roles.remove(settings.devwatch_role_id); + await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' }); + } else { + await member.roles.add(settings.devwatch_role_id); + await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' }); + } + } }; \ No newline at end of file diff --git a/src/buttons/note-next.ts b/src/buttons/note-next.ts index 44f1e59..dcca15f 100644 --- a/src/buttons/note-next.ts +++ b/src/buttons/note-next.ts @@ -1,15 +1,15 @@ -import { ButtonInteraction, Client } from 'discord.js'; -import { getNoteMessage } from '../utils'; - -export default { - name: 'note-next', - handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { - await interaction.deferUpdate(); - - const message = await getNoteMessage(userId, parseInt(page) + 1); - - if (!message) return; - - interaction.editReply(message); - } +import { ButtonInteraction, Client } from 'discord.js'; +import { getNoteMessage } from '../utils'; + +export default { + name: 'note-next', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getNoteMessage(userId, parseInt(page) + 1); + + if (!message) return; + + interaction.editReply(message); + } }; \ No newline at end of file diff --git a/src/buttons/note-previous.ts b/src/buttons/note-previous.ts index e756ee6..8e4b547 100644 --- a/src/buttons/note-previous.ts +++ b/src/buttons/note-previous.ts @@ -1,15 +1,15 @@ -import { ButtonInteraction, Client} from 'discord.js'; -import { getNoteMessage } from '../utils'; - -export default { - name: 'note-previous', - handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { - await interaction.deferUpdate(); - - const message = await getNoteMessage(userId, parseInt(page) - 1); - - if (!message) return; - - interaction.editReply(message); - } +import { ButtonInteraction, Client} from 'discord.js'; +import { getNoteMessage } from '../utils'; + +export default { + name: 'note-previous', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getNoteMessage(userId, parseInt(page) - 1); + + if (!message) return; + + interaction.editReply(message); + } }; \ No newline at end of file diff --git a/src/buttons/open-ticket-for-reported-message.ts b/src/buttons/open-ticket-for-reported-message.ts index 0ca2e5b..8a57b82 100644 --- a/src/buttons/open-ticket-for-reported-message.ts +++ b/src/buttons/open-ticket-for-reported-message.ts @@ -1,40 +1,40 @@ -import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js'; -import { createPrivateHelpTicketThread } from '../utils'; - -export default { - name: 'open-ticket-for-reported-message', - handler: async function (client: Client, interaction: ButtonInteraction) { - const message = await interaction.message.fetch(); - const guild = await interaction.guild!.fetch(); - - const reportEmbed = message.embeds[0]!; - - const regex = new RegExp(MessageMentions.UsersPattern); - - const reportedMessageUrl = reportEmbed.fields[0].value; - const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id; - const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : ''; - - const reporter = await guild.members.fetch(reporterId) ?? await client.users.fetch(reporterId); - - const thread = await createPrivateHelpTicketThread(client, guild, null, `Ticket creted by staff in response to message report: ${message.url}. Reported message: ${reportedMessageUrl}. ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`, `Ticket: Message Report From ${reporter.displayName}`, [reporterId, interaction.user.id]); - - if (thread) { - await interaction.reply({ - flags: [MessageFlags.Ephemeral], - content: `Ticket created: ${thread}` - }); - - const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket'); - if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1); - - reportEmbed.fields.push({ - name: 'Private Help Ticket', - value: thread.url, - inline: false - }); - - await message.edit({ embeds: [reportEmbed], components: [] }); - } - } +import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js'; +import { createPrivateHelpTicketThread } from '../utils'; + +export default { + name: 'open-ticket-for-reported-message', + handler: async function (client: Client, interaction: ButtonInteraction) { + const message = await interaction.message.fetch(); + const guild = await interaction.guild!.fetch(); + + const reportEmbed = message.embeds[0]!; + + const regex = new RegExp(MessageMentions.UsersPattern); + + const reportedMessageUrl = reportEmbed.fields[0].value; + const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id; + const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : ''; + + const reporter = await guild.members.fetch(reporterId) ?? await client.users.fetch(reporterId); + + const thread = await createPrivateHelpTicketThread(client, guild, null, `Ticket creted by staff in response to message report: ${message.url}. Reported message: ${reportedMessageUrl}. ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`, `Ticket: Message Report From ${reporter.displayName}`, [reporterId, interaction.user.id]); + + if (thread) { + await interaction.reply({ + flags: [MessageFlags.Ephemeral], + content: `Ticket created: ${thread}` + }); + + const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket'); + if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1); + + reportEmbed.fields.push({ + name: 'Private Help Ticket', + value: thread.url, + inline: false + }); + + await message.edit({ embeds: [reportEmbed], components: [] }); + } + } }; \ No newline at end of file diff --git a/src/buttons/private-help.ts b/src/buttons/private-help.ts index 6c3d991..82ef8fb 100644 --- a/src/buttons/private-help.ts +++ b/src/buttons/private-help.ts @@ -1,20 +1,20 @@ -import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js'; -import { canOpenPrivateHelpTicket, createTextInput } from '../utils'; - -export default { - name: 'private-help', - handler: async function (client: Client, interaction: ButtonInteraction) { - if (!(await canOpenPrivateHelpTicket(interaction.user.id))) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You can only have one open ticket at a time that is less than a day old.' }); - - const modal = new ModalBuilder() - .setCustomId('open-ticket-modal') - .setTitle('Get in contact'); - - const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10); - - modal.addLabelComponents(label); - - await interaction.showModal(modal); - } +import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js'; +import { canOpenPrivateHelpTicket, createTextInput } from '../utils'; + +export default { + name: 'private-help', + handler: async function (client: Client, interaction: ButtonInteraction) { + if (!(await canOpenPrivateHelpTicket(interaction.user.id))) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You can only have one open ticket at a time that is less than a day old.' }); + + const modal = new ModalBuilder() + .setCustomId('open-ticket-modal') + .setTitle('Get in contact'); + + const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10); + + modal.addLabelComponents(label); + + await interaction.showModal(modal); + } }; \ No newline at end of file diff --git a/src/buttons/records-next.ts b/src/buttons/records-next.ts index a2ae9b5..9d909f0 100644 --- a/src/buttons/records-next.ts +++ b/src/buttons/records-next.ts @@ -1,15 +1,15 @@ -import { ButtonInteraction, Client } from 'discord.js'; -import { getRecordMessageFromDiscordId } from '../utils'; - -export default { - name: 'records-next', - handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { - await interaction.deferUpdate(); - - const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!); - - if (!message) return; - - interaction.editReply(message); - } +import { ButtonInteraction, Client } from 'discord.js'; +import { getRecordMessageFromDiscordId } from '../utils'; + +export default { + name: 'records-next', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!); + + if (!message) return; + + interaction.editReply(message); + } }; \ No newline at end of file diff --git a/src/buttons/records-previous.ts b/src/buttons/records-previous.ts index dd54ac1..67b47e2 100644 --- a/src/buttons/records-previous.ts +++ b/src/buttons/records-previous.ts @@ -1,15 +1,15 @@ -import { ButtonInteraction, Client} from 'discord.js'; -import { getRecordMessageFromDiscordId } from '../utils'; - -export default { - name: 'records-previous', - handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { - await interaction.deferUpdate(); - - const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!); - - if (!message) return; - - interaction.editReply(message); - } +import { ButtonInteraction, Client} from 'discord.js'; +import { getRecordMessageFromDiscordId } from '../utils'; + +export default { + name: 'records-previous', + handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { + await interaction.deferUpdate(); + + const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!); + + if (!message) return; + + interaction.editReply(message); + } }; \ No newline at end of file diff --git a/src/buttons/unclaim-ticket.ts b/src/buttons/unclaim-ticket.ts index 7c320d5..9a9ff5a 100644 --- a/src/buttons/unclaim-ticket.ts +++ b/src/buttons/unclaim-ticket.ts @@ -1,44 +1,44 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'unclaim-ticket', - handler: async function (client: Client, interaction: ButtonInteraction) { - const channel = await interaction.channel?.fetch(); - - if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); - - if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' }); - - if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to unclaim this ticket.' }); - - if (!interaction.message.content.split('\n').at(-1)!.includes(`<@${interaction.user.id}>`)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You did not claim this ticket.' }); - - const guild = await client.guilds.fetch(interaction.guildId!); - - const guildSettings = await Database.getGuildSettings(guild.id); - - if (!guildSettings || !guildSettings.private_help_role_id) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' }); - - const closeButton = new ButtonBuilder() - .setCustomId('close-ticket') - .setLabel('Click here if you no longer need help') - .setStyle(ButtonStyle.Danger); - - const claimButton = new ButtonBuilder() - .setCustomId('claim-ticket') - .setLabel('Claim ticket') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(closeButton, claimButton); - - await interaction.message.edit({ - content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(), - components: [row] - }); - - await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] }); - } +import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'unclaim-ticket', + handler: async function (client: Client, interaction: ButtonInteraction) { + const channel = await interaction.channel?.fetch(); + + if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); + + if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' }); + + if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to unclaim this ticket.' }); + + if (!interaction.message.content.split('\n').at(-1)!.includes(`<@${interaction.user.id}>`)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You did not claim this ticket.' }); + + const guild = await client.guilds.fetch(interaction.guildId!); + + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_role_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' }); + + const closeButton = new ButtonBuilder() + .setCustomId('close-ticket') + .setLabel('Click here if you no longer need help') + .setStyle(ButtonStyle.Danger); + + const claimButton = new ButtonBuilder() + .setCustomId('claim-ticket') + .setLabel('Claim ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(closeButton, claimButton); + + await interaction.message.edit({ + content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(), + components: [row] + }); + + await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] }); + } }; \ No newline at end of file diff --git a/src/commands/ban.ts b/src/commands/ban.ts index cf1e3f5..d26b54d 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,141 +1,141 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js'; -import { Database } from '../shared/Database'; -import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils'; - -const mentionRegex = new RegExp(MessageMentions.UsersPattern); - -export default { - name: 'ban', - data: new SlashCommandBuilder() - .setName('ban') - .setDescription('Bans a user.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to ban.') - .setRequired(true) - ) - .addStringOption(option => - option - .setName('reason') - .setDescription('The reason for the ban') - .setRequired(false) - .setMaxLength(400) - ) - .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) - ) - .addBooleanOption(option => - option - .setName('full-ban') - .setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).') - .setRequired(false) - ), - 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 input = interaction.options.getString('user', true); - - const matches = mentionRegex.exec(input); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : input; - 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; - - const fullBan = interaction.options.getBoolean('full-ban') ?? false; - - let banMember: GuildMember | null = null; - - try { - banMember = await interaction.guild.members.fetch(idToUse); - } 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); - - await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan); - - try { - await interaction.guild.bans.create(idToUse, { - reason: (reason + ` ${fullBan ? 'Full banned' : '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)."); - } - - if (fullBan) { - const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild); - - await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt); - } - - await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`); - } -}; - -async function removeAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) { - for (const data of altData) { - if (data.type == 'discord') { - try { - if (!data.banned) { - await guild.members.kick(data.thisId as string, (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${moderator.username} (${moderator.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim()); - } - } catch (e) { - console.error(e); - } - } - - await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js'; +import { Database } from '../shared/Database'; +import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'ban', + data: new SlashCommandBuilder() + .setName('ban') + .setDescription('Bans a user.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to ban.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the ban') + .setRequired(false) + .setMaxLength(400) + ) + .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) + ) + .addBooleanOption(option => + option + .setName('full-ban') + .setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).') + .setRequired(false) + ), + 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 input = interaction.options.getString('user', true); + + const matches = mentionRegex.exec(input); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : input; + 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; + + const fullBan = interaction.options.getBoolean('full-ban') ?? false; + + let banMember: GuildMember | null = null; + + try { + banMember = await interaction.guild.members.fetch(idToUse); + } 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); + + await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan); + + try { + await interaction.guild.bans.create(idToUse, { + reason: (reason + ` ${fullBan ? 'Full banned' : '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)."); + } + + if (fullBan) { + const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild); + + await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt); + } + + await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`); + } +}; + +async function removeAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) { + for (const data of altData) { + if (data.type == 'discord') { + try { + if (!data.banned) { + await guild.members.kick(data.thisId as string, (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${moderator.username} (${moderator.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim()); + } + } catch (e) { + console.error(e); + } + } + + await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt); + } } \ No newline at end of file diff --git a/src/commands/cite.ts b/src/commands/cite.ts index e4cdb95..c4c03bb 100644 --- a/src/commands/cite.ts +++ b/src/commands/cite.ts @@ -1,48 +1,48 @@ -import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { Database } from '../shared/Database'; -import { KnowledgebaseItem } from '../types'; - -export default { - name: 'cite', - data: new SlashCommandBuilder() - .setName('cite') - .setDescription('Cite content from the knowledgebase.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addIntegerOption(option => - option - .setName('name') - .setDescription('The name of the entry.') - .setRequired(true) - .setAutocomplete(true) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); - - await interaction.deferReply(); - - const id = interaction.options.getInteger('name', true); - - const item = await Database.getFromKnowledgebase(id); - - if (!item) return interaction.editReply('Knowledgebase item not found.'); - - return interaction.editReply(item.content); - }, - autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { - if (!interaction.guild) return interaction.respond([]); - - const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); - - const value = interaction.options.getFocused(); - - const toRespond = items.filter(i => !value ? true : i.content.includes(value)); - if (toRespond.length > 25) toRespond.length = 25; - - interaction.respond(toRespond.map(p => ({ - name: p.name, - value: p.id - }))); - } +import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; +import { KnowledgebaseItem } from '../types'; + +export default { + name: 'cite', + data: new SlashCommandBuilder() + .setName('cite') + .setDescription('Cite content from the knowledgebase.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addIntegerOption(option => + option + .setName('name') + .setDescription('The name of the entry.') + .setRequired(true) + .setAutocomplete(true) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); + + await interaction.deferReply(); + + const id = interaction.options.getInteger('name', true); + + const item = await Database.getFromKnowledgebase(id); + + if (!item) return interaction.editReply('Knowledgebase item not found.'); + + return interaction.editReply(item.content); + }, + autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { + if (!interaction.guild) return interaction.respond([]); + + const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); + + const value = interaction.options.getFocused(); + + const toRespond = items.filter(i => !value ? true : i.content.includes(value)); + if (toRespond.length > 25) toRespond.length = 25; + + interaction.respond(toRespond.map(p => ({ + name: p.name, + value: p.id + }))); + } }; \ No newline at end of file diff --git a/src/commands/devwatch.ts b/src/commands/devwatch.ts index b0433b4..ca26eda 100644 --- a/src/commands/devwatch.ts +++ b/src/commands/devwatch.ts @@ -1,42 +1,42 @@ -import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; - -export default { - name: 'devwatch', - data: new SlashCommandBuilder() - .setName('devwatch') - .setDescription('Sends a dev watch role toggle button.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addStringOption(option => - option - .setName('content') - .setDescription('The content of the message.') - .setRequired(false) - ) - .addStringOption(option => - option - .setName('button-label') - .setDescription('The button label.') - .setRequired(false) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - if (!interaction.channel || !interaction.channel.isSendable()) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); - - const content = interaction.options.getString('content') ?? ''; - const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role'; - - const button = new ButtonBuilder() - .setCustomId('dev-watch') - .setStyle(ButtonStyle.Primary) - .setLabel(label); - - const row = new ActionRowBuilder() - .addComponents(button); - - await interaction.channel.send({ components: [row], content }); - - interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); - } +import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; + +export default { + name: 'devwatch', + data: new SlashCommandBuilder() + .setName('devwatch') + .setDescription('Sends a dev watch role toggle button.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addStringOption(option => + option + .setName('content') + .setDescription('The content of the message.') + .setRequired(false) + ) + .addStringOption(option => + option + .setName('button-label') + .setDescription('The button label.') + .setRequired(false) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + if (!interaction.channel || !interaction.channel.isSendable()) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); + + const content = interaction.options.getString('content') ?? ''; + const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role'; + + const button = new ButtonBuilder() + .setCustomId('dev-watch') + .setStyle(ButtonStyle.Primary) + .setLabel(label); + + const row = new ActionRowBuilder() + .addComponents(button); + + await interaction.channel.send({ components: [row], content }); + + interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); + } }; \ No newline at end of file diff --git a/src/commands/finduser.ts b/src/commands/finduser.ts index ec265cd..56ff584 100644 --- a/src/commands/finduser.ts +++ b/src/commands/finduser.ts @@ -1,42 +1,42 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { config } from '../config'; -import { deferInteraction, getDiscordAlts, getE621User } from '../utils'; - -export default { - name: 'finduser', - data: new SlashCommandBuilder() - .setName('finduser') - .setDescription("Find a user's discord account based on their e621 usernamename or id.") - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addStringOption(option => - option - .setName('user') - .setDescription('The e621 username or e621 id to find the discord user of.') - .setRequired(true) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - await deferInteraction(interaction); - - if (!interaction.guild) return interaction.editReply('This command must be used in a server'); - - const user = interaction.options.getString('user', true); - - try { - const e621User = await getE621User(user); - - if (!e621User) { - return interaction.editReply('I got lost along the way. Who again?'); - } - - const content = await getDiscordAlts(e621User.id, interaction.guild, 1, [e621User.id]); - - interaction.editReply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s e621 and discord account(s):\n${content}`); - } catch (e) { - console.error(e); - - interaction.editReply('I got lost in the net.'); - } - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { config } from '../config'; +import { deferInteraction, getDiscordAlts, getE621User } from '../utils'; + +export default { + name: 'finduser', + data: new SlashCommandBuilder() + .setName('finduser') + .setDescription("Find a user's discord account based on their e621 usernamename or id.") + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addStringOption(option => + option + .setName('user') + .setDescription('The e621 username or e621 id to find the discord user of.') + .setRequired(true) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await deferInteraction(interaction); + + if (!interaction.guild) return interaction.editReply('This command must be used in a server'); + + const user = interaction.options.getString('user', true); + + try { + const e621User = await getE621User(user); + + if (!e621User) { + return interaction.editReply('I got lost along the way. Who again?'); + } + + const content = await getDiscordAlts(e621User.id, interaction.guild, 1, [e621User.id]); + + interaction.editReply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s e621 and discord account(s):\n${content}`); + } catch (e) { + console.error(e); + + interaction.editReply('I got lost in the net.'); + } + } }; \ No newline at end of file diff --git a/src/commands/github-mapping.ts b/src/commands/github-mapping.ts index 337904a..2f330f5 100644 --- a/src/commands/github-mapping.ts +++ b/src/commands/github-mapping.ts @@ -1,87 +1,87 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { Database } from '../shared/Database'; - -const mentionRegex = new RegExp(MessageMentions.UsersPattern); - -export default { - name: 'github-mapping', - data: new SlashCommandBuilder() - .setName('github-mapping') - .setDescription('Maps github users to discord ids for releases.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add a user mapping.') - .addStringOption(option => - option - .setName('discord-user') - .setDescription('The discord user id, or mention, of the user.') - .setRequired(true) - ) - .addStringOption(option => - option - .setName('github-name') - .setDescription('The github username of the user (case sensitive).') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove a user mapping.') - .addStringOption(option => - option - .setName('discord-user') - .setDescription('The discord user id, or mention, of the user.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('List all github-discord mappings.') - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - - const subcommand = await interaction.options.getSubcommand(true); - - if (subcommand == 'add') { - const discordUserInput = interaction.options.getString('discord-user', true); - - const matches = mentionRegex.exec(discordUserInput); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : discordUserInput; - - const githubName = interaction.options.getString('github-name', true); - - const existingMappingId = await Database.getGithubFromDiscordId(idToUse); - const existingMappingName = await Database.getDiscordIdFromGithub(githubName); - - if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`); - if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`); - - Database.putGithubUserMapping(idToUse, githubName); - - return interaction.editReply('Mapping added.'); - } else if (subcommand == 'remove') { - const discordUserInput = interaction.options.getString('discord-user', true); - - const matches = mentionRegex.exec(discordUserInput); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : discordUserInput; - - Database.removeGithubUserMapping(idToUse); - return interaction.editReply('Mapping removed.'); - } else if (subcommand == 'list') { - const allMappings = await Database.getAllGithubUserMappings(); - - return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n')); - } - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'github-mapping', + data: new SlashCommandBuilder() + .setName('github-mapping') + .setDescription('Maps github users to discord ids for releases.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a user mapping.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('github-name') + .setDescription('The github username of the user (case sensitive).') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a user mapping.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List all github-discord mappings.') + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const subcommand = await interaction.options.getSubcommand(true); + + if (subcommand == 'add') { + const discordUserInput = interaction.options.getString('discord-user', true); + + const matches = mentionRegex.exec(discordUserInput); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : discordUserInput; + + const githubName = interaction.options.getString('github-name', true); + + const existingMappingId = await Database.getGithubFromDiscordId(idToUse); + const existingMappingName = await Database.getDiscordIdFromGithub(githubName); + + if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`); + if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`); + + Database.putGithubUserMapping(idToUse, githubName); + + return interaction.editReply('Mapping added.'); + } else if (subcommand == 'remove') { + const discordUserInput = interaction.options.getString('discord-user', true); + + const matches = mentionRegex.exec(discordUserInput); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : discordUserInput; + + Database.removeGithubUserMapping(idToUse); + return interaction.editReply('Mapping removed.'); + } else if (subcommand == 'list') { + const allMappings = await Database.getAllGithubUserMappings(); + + return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n')); + } + } }; \ No newline at end of file diff --git a/src/commands/knowledgebase.ts b/src/commands/knowledgebase.ts index 8394006..822c5e6 100644 --- a/src/commands/knowledgebase.ts +++ b/src/commands/knowledgebase.ts @@ -1,127 +1,127 @@ -import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js'; -import { Database } from '../shared/Database'; -import { KnowledgebaseItem } from '../types'; -import { createTextInput, deferInteraction, logCustomEvent } from '../utils'; - -export default { - name: 'knowledgebase', - data: new SlashCommandBuilder() - .setName('knowledgebase') - .setDescription('Access the compendium of knowledge.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add to the knowledgebase.') - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Purge knowledge from the universe.') - .addIntegerOption(option => - option - .setName('name') - .setDescription('The name of the entry to remove.') - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('edit') - .setDescription('Edit a knowledgebase entry.') - .addIntegerOption(option => - option - .setName('name') - .setDescription('The name of the entry to edit.') - .setRequired(true) - .setAutocomplete(true) - ) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); - - const subcommand = interaction.options.getSubcommand(true); - - if (subcommand == 'add') { - const modal = new ModalBuilder() - .setCustomId('add-knowledgebase-item-modal') - .setTitle('Add to knowledgebase'); - - const nameLabel = createTextInput('name', 'Knowledgebase Item Name', null, true, TextInputStyle.Short, 300, 1); - const contentLabel = createTextInput('content', 'Item Content', null, true, TextInputStyle.Paragraph, 2000, 1); - - modal.addLabelComponents(nameLabel, contentLabel); - - return await interaction.showModal(modal); - } else if (subcommand == 'remove') { - await deferInteraction(interaction); - const id = interaction.options.getInteger('name', true); - - const item = await Database.getFromKnowledgebase(id); - - if (!item) return interaction.editReply('Knowledgebase item not found.'); - - logCustomEvent(interaction.guild!, { - title: 'Knowledgebase Item Removed', - description: null, - color: 0xFF0000, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Name', - value: item.name, - inline: true - }, - { - name: 'Content', - value: item.content, - inline: true - } - ] - }); - - await Database.removeFromKnowledgebase(id); - - return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`); - } else if (subcommand == 'edit') { - const id = interaction.options.getInteger('name', true); - - const existingItem = await Database.getFromKnowledgebase(id); - - if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); - - const modal = new ModalBuilder() - .setCustomId(`edit-knowledgebase-item-modal_${id}`) - .setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`); - - const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1); - - modal.addLabelComponents(contentLabel); - - return await interaction.showModal(modal); - } - }, - autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { - if (!interaction.guild) return interaction.respond([]); - - const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); - - const value = interaction.options.getFocused(); - - const toRespond = items.filter(i => !value ? true : i.name.includes(value)); - if (toRespond.length > 25) toRespond.length = 25; - - interaction.respond(toRespond.map(p => ({ - name: p.name, - value: p.id - }))); - } +import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js'; +import { Database } from '../shared/Database'; +import { KnowledgebaseItem } from '../types'; +import { createTextInput, deferInteraction, logCustomEvent } from '../utils'; + +export default { + name: 'knowledgebase', + data: new SlashCommandBuilder() + .setName('knowledgebase') + .setDescription('Access the compendium of knowledge.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add to the knowledgebase.') + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Purge knowledge from the universe.') + .addIntegerOption(option => + option + .setName('name') + .setDescription('The name of the entry to remove.') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('edit') + .setDescription('Edit a knowledgebase entry.') + .addIntegerOption(option => + option + .setName('name') + .setDescription('The name of the entry to edit.') + .setRequired(true) + .setAutocomplete(true) + ) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); + + const subcommand = interaction.options.getSubcommand(true); + + if (subcommand == 'add') { + const modal = new ModalBuilder() + .setCustomId('add-knowledgebase-item-modal') + .setTitle('Add to knowledgebase'); + + const nameLabel = createTextInput('name', 'Knowledgebase Item Name', null, true, TextInputStyle.Short, 300, 1); + const contentLabel = createTextInput('content', 'Item Content', null, true, TextInputStyle.Paragraph, 2000, 1); + + modal.addLabelComponents(nameLabel, contentLabel); + + return await interaction.showModal(modal); + } else if (subcommand == 'remove') { + await deferInteraction(interaction); + const id = interaction.options.getInteger('name', true); + + const item = await Database.getFromKnowledgebase(id); + + if (!item) return interaction.editReply('Knowledgebase item not found.'); + + logCustomEvent(interaction.guild!, { + title: 'Knowledgebase Item Removed', + description: null, + color: 0xFF0000, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Name', + value: item.name, + inline: true + }, + { + name: 'Content', + value: item.content, + inline: true + } + ] + }); + + await Database.removeFromKnowledgebase(id); + + return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`); + } else if (subcommand == 'edit') { + const id = interaction.options.getInteger('name', true); + + const existingItem = await Database.getFromKnowledgebase(id); + + if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); + + const modal = new ModalBuilder() + .setCustomId(`edit-knowledgebase-item-modal_${id}`) + .setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`); + + const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1); + + modal.addLabelComponents(contentLabel); + + return await interaction.showModal(modal); + } + }, + autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { + if (!interaction.guild) return interaction.respond([]); + + const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); + + const value = interaction.options.getFocused(); + + const toRespond = items.filter(i => !value ? true : i.name.includes(value)); + if (toRespond.length > 25) toRespond.length = 25; + + interaction.respond(toRespond.map(p => ({ + name: p.name, + value: p.id + }))); + } }; \ No newline at end of file diff --git a/src/commands/link.ts b/src/commands/link.ts index e4f4025..ceda1f9 100644 --- a/src/commands/link.ts +++ b/src/commands/link.ts @@ -1,134 +1,134 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { config } from '../config'; -import { Database } from '../shared/Database'; -import { logCustomEvent, resolveUser } from '../utils'; - -const mentionRegex = new RegExp(MessageMentions.UsersPattern); - -export default { - name: 'link', - data: new SlashCommandBuilder() - .setName('link') - .setDescription('Manually link discord users and e621 users.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .setName('create') - .setDescription('Create a link.') - .addStringOption(option => - option - .setName('discord-user') - .setDescription('The discord user id, or mention, of the user.') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('e621-id') - .setDescription('The id of the e621 user.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove a link.') - .addStringOption(option => - option - .setName('discord-user') - .setDescription('The discord user id, or mention, of the user.') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('e621-id') - .setDescription('The id of the e621 user.') - .setRequired(true) - ) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - - const subcommand = await interaction.options.getSubcommand(true); - - const discordUserInput = interaction.options.getString('discord-user', true); - - const matches = mentionRegex.exec(discordUserInput); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : discordUserInput; - - const user = await resolveUser(client, idToUse, interaction.guild); - - if (!user) return interaction.editReply('User not found.'); - - const e621Id = interaction.options.getInteger('e621-id', true); - - if (subcommand == 'create') { - const existingLinks = await Database.getDiscordIds(e621Id); - - if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.'); - - await Database.putUser(e621Id, user); - - await logCustomEvent(interaction.guild!, { - title: 'Account Link Created', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'Admin', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Discord User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'E621 User', - value: `${config.E621_BASE_URL}/users/${e621Id}`, - inline: true - } - ] - }); - - interaction.editReply('Accounts linked'); - } else if (subcommand == 'remove') { - const existingLinks = await Database.getDiscordIds(e621Id); - - if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.'); - - await Database.removeUser(e621Id, user.id); - - await logCustomEvent(interaction.guild!, { - title: 'Account Link Removed', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'Admin', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Discord User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'E621 User', - value: `${config.E621_BASE_URL}/users/${e621Id}`, - inline: true - } - ] - }); - - interaction.editReply('Accounts unlinked'); - } - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { config } from '../config'; +import { Database } from '../shared/Database'; +import { logCustomEvent, resolveUser } from '../utils'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'link', + data: new SlashCommandBuilder() + .setName('link') + .setDescription('Manually link discord users and e621 users.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('create') + .setDescription('Create a link.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('e621-id') + .setDescription('The id of the e621 user.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a link.') + .addStringOption(option => + option + .setName('discord-user') + .setDescription('The discord user id, or mention, of the user.') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('e621-id') + .setDescription('The id of the e621 user.') + .setRequired(true) + ) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const subcommand = await interaction.options.getSubcommand(true); + + const discordUserInput = interaction.options.getString('discord-user', true); + + const matches = mentionRegex.exec(discordUserInput); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : discordUserInput; + + const user = await resolveUser(client, idToUse, interaction.guild); + + if (!user) return interaction.editReply('User not found.'); + + const e621Id = interaction.options.getInteger('e621-id', true); + + if (subcommand == 'create') { + const existingLinks = await Database.getDiscordIds(e621Id); + + if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.'); + + await Database.putUser(e621Id, user); + + await logCustomEvent(interaction.guild!, { + title: 'Account Link Created', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'Admin', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Discord User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'E621 User', + value: `${config.E621_BASE_URL}/users/${e621Id}`, + inline: true + } + ] + }); + + interaction.editReply('Accounts linked'); + } else if (subcommand == 'remove') { + const existingLinks = await Database.getDiscordIds(e621Id); + + if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.'); + + await Database.removeUser(e621Id, user.id); + + await logCustomEvent(interaction.guild!, { + title: 'Account Link Removed', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'Admin', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Discord User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'E621 User', + value: `${config.E621_BASE_URL}/users/${e621Id}`, + inline: true + } + ] + }); + + interaction.editReply('Accounts unlinked'); + } + } }; \ No newline at end of file diff --git a/src/commands/mod-ticket.ts b/src/commands/mod-ticket.ts index 9f82d29..14b1245 100644 --- a/src/commands/mod-ticket.ts +++ b/src/commands/mod-ticket.ts @@ -1,28 +1,28 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { openModTicketModal } from '../utils'; - -export default { - name: 'mod-ticket', - data: new SlashCommandBuilder() - .setName('mod-ticket') - .setDescription('Opens a mod private ticket and pulls the user into it.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) - .addUserOption(option => - option - .setName('user') - .setDescription('The user to pull in to the ticket.') - .setRequired(true) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - if (!interaction.guild) return interaction.editReply('This command must be used in a server.'); - - const user = interaction.options.getUser('user', true); - const member = await interaction.guild.members.fetch(user.id); - - if (!member) return interaction.editReply('Could not find member.'); - - openModTicketModal(interaction, member); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { openModTicketModal } from '../utils'; + +export default { + name: 'mod-ticket', + data: new SlashCommandBuilder() + .setName('mod-ticket') + .setDescription('Opens a mod private ticket and pulls the user into it.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + .addUserOption(option => + option + .setName('user') + .setDescription('The user to pull in to the ticket.') + .setRequired(true) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return interaction.editReply('This command must be used in a server.'); + + const user = interaction.options.getUser('user', true); + const member = await interaction.guild.members.fetch(user.id); + + if (!member) return interaction.editReply('Could not find member.'); + + openModTicketModal(interaction, member); + } }; \ No newline at end of file diff --git a/src/commands/name-sync.ts b/src/commands/name-sync.ts index f2e7d41..63ffeea 100644 --- a/src/commands/name-sync.ts +++ b/src/commands/name-sync.ts @@ -1,36 +1,36 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js'; -import { config } from '../config'; -import { syncName } from '../utils'; - -export default { - name: 'name-sync', - data: new SlashCommandBuilder() - .setName('name-sync') - .setDescription('Sync your discord nickname to your e621 name.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) - .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM) - .addIntegerOption(option => - option - .setName('id') - .setDescription('The id of the e621 user to sync your nickname to.') - .setRequired(false) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - const id = interaction.options.getInteger('id'); - - const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); - - if (!guild) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - const member = await guild.members.fetch(interaction.user.id); - - if (!member || !guild.members.me) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - await syncName(interaction, member, id); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js'; +import { config } from '../config'; +import { syncName } from '../utils'; + +export default { + name: 'name-sync', + data: new SlashCommandBuilder() + .setName('name-sync') + .setDescription('Sync your discord nickname to your e621 name.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) + .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM) + .addIntegerOption(option => + option + .setName('id') + .setDescription('The id of the e621 user to sync your nickname to.') + .setRequired(false) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const id = interaction.options.getInteger('id'); + + const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); + + if (!guild) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + const member = await guild.members.fetch(interaction.user.id); + + if (!member || !guild.members.me) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + await syncName(interaction, member, id); + } }; \ No newline at end of file diff --git a/src/commands/notes.ts b/src/commands/notes.ts index 9a81b6b..3957413 100644 --- a/src/commands/notes.ts +++ b/src/commands/notes.ts @@ -1,239 +1,239 @@ -import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { Database } from '../shared/Database'; -import { deferInteraction, logCustomEvent, resolveUser } from '../utils'; -import { getNoteMessage } from '../utils/note-utils'; - -const mentionRegex = new RegExp(MessageMentions.UsersPattern); - -export default { - name: 'notes', - data: new SlashCommandBuilder() - .setName('notes') - .setDescription('Add, view, or remove user notes.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add notes to a user.') - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to add a note to.') - .setRequired(true) - ) - .addStringOption(option => - option - .setName('reason') - .setDescription('The reason for the note.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('edit') - .setDescription('Edit notes on a user.') - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to edit the notes of.') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('note') - .setDescription('The note to edit.') - .setRequired(true) - .setAutocomplete(true) - ) - .addStringOption(option => - option - .setName('new-reason') - .setDescription('The new reason for the note.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove notes from a user.') - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to remove a note from.') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('note') - .setDescription('The note to remove.') - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription("List a user's notes") - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to list the notes of.') - .setRequired(true) - ) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - const subcommand = interaction.options.getSubcommand(true); - - const input = interaction.options.getString('user', true); - - const matches = mentionRegex.exec(input); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : input; - - await deferInteraction(interaction); - - const user = await resolveUser(client, idToUse, interaction.guild); - - if (!user) return interaction.editReply('User not found.'); - - if (subcommand == 'add') { - const reason = interaction.options.getString('reason', true); - - logCustomEvent(interaction.guild!, { - title: 'Note Added', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'Moderator', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'Note', - value: reason, - inline: true - } - ] - }); - - await Database.putNote(user.id, reason, interaction.user.id); - - interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`); - } else if (subcommand == 'remove') { - const noteId = interaction.options.getInteger('note', true); - - const notes = await Database.getNotes(user.id); - const note = notes.find(n => n.id == noteId); - - if (!note) return interaction.editReply('Note not found.'); - - logCustomEvent(interaction.guild!, { - title: 'Note Removed', - description: null, - color: 0xFF0000, - timestamp: new Date(), - fields: [ - { - name: 'Moderator', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'Note', - value: `${note.reason}\nBy: <@${note.mod_id}>`, - inline: true - } - ] - }); - - await Database.removeNote(noteId); - interaction.editReply('Removed note.'); - } else if (subcommand == 'edit') { - const noteId = interaction.options.getInteger('note', true); - - const notes = await Database.getNotes(user.id); - const note = notes.find(n => n.id == noteId); - - if (!note) return interaction.editReply('Note not found.'); - - const reason = interaction.options.getString('new-reason', true); - - logCustomEvent(interaction.guild!, { - title: 'Note Edited', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'Moderator', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'Old reason', - value: note.reason - }, - { - name: 'New reason', - value: reason, - inline: true - } - ] - }); - - await Database.editNote(noteId, note.reason, reason, interaction.user.id); - - interaction.editReply(`Note on <@${user.id}> (\`${user.username}\` | \`${user.id}\`) edited.\n\nNew reason:\n${reason}`); - } else if (subcommand == 'list') { - const noteMessage = await getNoteMessage(user.id, 1); - - if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`); - - interaction.editReply(noteMessage); - } - }, - autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { - const input = interaction.options.getString('user', true); - - const matches = mentionRegex.exec(input); - mentionRegex.lastIndex = 0; - - const idToUse = matches ? matches.groups!.id : input; - - if (!idToUse) return interaction.respond([]); - - const value = interaction.options.getFocused().toLowerCase(); - - const notes = await Database.getNotes(idToUse); - - const toRespond = notes.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 - }; - })); - } +import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; +import { deferInteraction, logCustomEvent, resolveUser } from '../utils'; +import { getNoteMessage } from '../utils/note-utils'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'notes', + data: new SlashCommandBuilder() + .setName('notes') + .setDescription('Add, view, or remove user notes.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add notes to a user.') + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to add a note to.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the note.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('edit') + .setDescription('Edit notes on a user.') + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to edit the notes of.') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('note') + .setDescription('The note to edit.') + .setRequired(true) + .setAutocomplete(true) + ) + .addStringOption(option => + option + .setName('new-reason') + .setDescription('The new reason for the note.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove notes from a user.') + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to remove a note from.') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('note') + .setDescription('The note to remove.') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription("List a user's notes") + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to list the notes of.') + .setRequired(true) + ) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const subcommand = interaction.options.getSubcommand(true); + + const input = interaction.options.getString('user', true); + + const matches = mentionRegex.exec(input); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : input; + + await deferInteraction(interaction); + + const user = await resolveUser(client, idToUse, interaction.guild); + + if (!user) return interaction.editReply('User not found.'); + + if (subcommand == 'add') { + const reason = interaction.options.getString('reason', true); + + logCustomEvent(interaction.guild!, { + title: 'Note Added', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'Moderator', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'Note', + value: reason, + inline: true + } + ] + }); + + await Database.putNote(user.id, reason, interaction.user.id); + + interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`); + } else if (subcommand == 'remove') { + const noteId = interaction.options.getInteger('note', true); + + const notes = await Database.getNotes(user.id); + const note = notes.find(n => n.id == noteId); + + if (!note) return interaction.editReply('Note not found.'); + + logCustomEvent(interaction.guild!, { + title: 'Note Removed', + description: null, + color: 0xFF0000, + timestamp: new Date(), + fields: [ + { + name: 'Moderator', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'Note', + value: `${note.reason}\nBy: <@${note.mod_id}>`, + inline: true + } + ] + }); + + await Database.removeNote(noteId); + interaction.editReply('Removed note.'); + } else if (subcommand == 'edit') { + const noteId = interaction.options.getInteger('note', true); + + const notes = await Database.getNotes(user.id); + const note = notes.find(n => n.id == noteId); + + if (!note) return interaction.editReply('Note not found.'); + + const reason = interaction.options.getString('new-reason', true); + + logCustomEvent(interaction.guild!, { + title: 'Note Edited', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'Moderator', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'Old reason', + value: note.reason + }, + { + name: 'New reason', + value: reason, + inline: true + } + ] + }); + + await Database.editNote(noteId, note.reason, reason, interaction.user.id); + + interaction.editReply(`Note on <@${user.id}> (\`${user.username}\` | \`${user.id}\`) edited.\n\nNew reason:\n${reason}`); + } else if (subcommand == 'list') { + const noteMessage = await getNoteMessage(user.id, 1); + + if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`); + + interaction.editReply(noteMessage); + } + }, + autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { + const input = interaction.options.getString('user', true); + + const matches = mentionRegex.exec(input); + mentionRegex.lastIndex = 0; + + const idToUse = matches ? matches.groups!.id : input; + + if (!idToUse) return interaction.respond([]); + + const value = interaction.options.getFocused().toLowerCase(); + + const notes = await Database.getNotes(idToUse); + + const toRespond = notes.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/commands/phrases.ts b/src/commands/phrases.ts index 3d022b6..46f9b06 100644 --- a/src/commands/phrases.ts +++ b/src/commands/phrases.ts @@ -1,252 +1,252 @@ -import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js'; -import { Database } from '../shared/Database'; -import { TicketPhrase } from '../types'; -import { logCustomEvent } from '../utils'; - -const MIN_PHRASE_LENGTH = 1; -const MAX_PHRASE_LENGTH = 512; - -type SubcommandGroup = 'admin' | 'personal'; -type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge'; - -export default { - name: 'phrases', - data: new SlashCommandBuilder() - .setName('phrases') - .setDescription('Manage notified phrases.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addSubcommandGroup(subcommandGroup => - subcommandGroup - .setName('admin') - .setDescription('Manage admin notified phrases.') - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add an admin notification phrase.') - .addStringOption(option => - option - .setName('phrase') - .setDescription('The phrase to add.') - .setRequired(true) - .setMinLength(MIN_PHRASE_LENGTH) - .setMaxLength(MAX_PHRASE_LENGTH) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove an admin notification phrase.') - .addNumberOption(option => - option - .setName('phrase') - .setDescription('The phrase to remove.') - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('Get a list of the current admin notification phrases.') - ) - ) - .addSubcommandGroup(subcommandGroup => - subcommandGroup - .setName('personal') - .setDescription('Manage personal notified phrases.') - .addSubcommand(subcommand => - subcommand - .setName('add') - .setDescription('Add a personal notification phrase.') - .addStringOption(option => - option - .setName('phrase') - .setDescription('The phrase to add.') - .setRequired(true) - .setMinLength(MIN_PHRASE_LENGTH) - .setMaxLength(MAX_PHRASE_LENGTH) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove a personal notification phrase.') - .addNumberOption(option => - option - .setName('phrase') - .setDescription('The phrase to remove.') - .setRequired(true) - .setAutocomplete(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('Get a list of the current personal notification phrases.') - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('dump') - .setDescription('List all notification phrases.') - ) - .addSubcommand(subcommand => - subcommand - .setName('purge') - .setDescription("Purge a user's phrases.") - .addUserOption(option => - option - .setName('user') - .setDescription('The user to purge the phrases of.') - .setRequired(true) - ) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; - const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand; - - switch (subcommand) { - case 'add': - return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!); - case 'remove': - return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!); - case 'list': - return listPhrases(interaction, subcommandGroup!); - case 'dump': - return dumpPhrases(interaction); - case 'purge': - return purgePhrases(interaction, interaction.options.getUser('user', true)); - } - }, - autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { - const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; - - if (!subcommandGroup) return interaction.respond([]); - - const value = interaction.options.getFocused(); - - const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id); - - const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value)); - if (toRespond.length > 25) toRespond.length = 25; - - interaction.respond(toRespond.map(p => ({ - name: p.phrase, - value: p.id - }))); - } -}; - -async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) { - const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id); - - const count = await Database.removeAllTicketPhrasesFor(user.id); - - logCustomEvent(interaction.guild!, { - title: 'Ticket Phrases Purged', - description: null, - color: 0xFF0000, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Target User', - value: `<@${user.id}>\n${user.username}`, - inline: true - }, - { - name: 'Count', - value: count.toString(), - inline: true - } - ] - }); - - interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`); -} - -async function dumpPhrases(interaction: ChatInputCommandInteraction) { - let content = ''; - - const guildSettings = await Database.getGuildSettings(interaction.guildId!); - - await Database.getAllTicketPhrases((phrase: TicketPhrase) => { - if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return; - - const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`; - - content += `${mention}: \`${phrase.phrase}\`\n`; - }); - - if (content.length == 0) return interaction.reply('No phrases found.'); - - interaction.reply('The following phrases are registered:\n\n' + content); -} - -async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) { - await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase); - - logCustomEvent(interaction.guild!, { - title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`, - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Phrase', - value: phrase, - inline: true - } - ] - }); - - interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`); -} - -async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) { - const phrase = await Database.getTicketPhrase(phraseId); - - if (!phrase) return interaction.reply('Phrase not found'); - - await Database.removeTicketPhrase(phraseId); - - logCustomEvent(interaction.guild!, { - title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`, - description: null, - color: 0xFF0000, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Phrase', - value: phrase.phrase, - inline: true - } - ] - }); - - interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`); -} - -async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) { - const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id); - - if (phrases.length == 0) return interaction.reply('No phrases registered'); - - interaction.reply(`The following phrases are registered:\n\n${phrases.map(p => (`- \`${p.phrase}\``)).join('\n')}`); +import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js'; +import { Database } from '../shared/Database'; +import { TicketPhrase } from '../types'; +import { logCustomEvent } from '../utils'; + +const MIN_PHRASE_LENGTH = 1; +const MAX_PHRASE_LENGTH = 512; + +type SubcommandGroup = 'admin' | 'personal'; +type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge'; + +export default { + name: 'phrases', + data: new SlashCommandBuilder() + .setName('phrases') + .setDescription('Manage notified phrases.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addSubcommandGroup(subcommandGroup => + subcommandGroup + .setName('admin') + .setDescription('Manage admin notified phrases.') + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add an admin notification phrase.') + .addStringOption(option => + option + .setName('phrase') + .setDescription('The phrase to add.') + .setRequired(true) + .setMinLength(MIN_PHRASE_LENGTH) + .setMaxLength(MAX_PHRASE_LENGTH) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove an admin notification phrase.') + .addNumberOption(option => + option + .setName('phrase') + .setDescription('The phrase to remove.') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('Get a list of the current admin notification phrases.') + ) + ) + .addSubcommandGroup(subcommandGroup => + subcommandGroup + .setName('personal') + .setDescription('Manage personal notified phrases.') + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a personal notification phrase.') + .addStringOption(option => + option + .setName('phrase') + .setDescription('The phrase to add.') + .setRequired(true) + .setMinLength(MIN_PHRASE_LENGTH) + .setMaxLength(MAX_PHRASE_LENGTH) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a personal notification phrase.') + .addNumberOption(option => + option + .setName('phrase') + .setDescription('The phrase to remove.') + .setRequired(true) + .setAutocomplete(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('Get a list of the current personal notification phrases.') + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('dump') + .setDescription('List all notification phrases.') + ) + .addSubcommand(subcommand => + subcommand + .setName('purge') + .setDescription("Purge a user's phrases.") + .addUserOption(option => + option + .setName('user') + .setDescription('The user to purge the phrases of.') + .setRequired(true) + ) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; + const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand; + + switch (subcommand) { + case 'add': + return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!); + case 'remove': + return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!); + case 'list': + return listPhrases(interaction, subcommandGroup!); + case 'dump': + return dumpPhrases(interaction); + case 'purge': + return purgePhrases(interaction, interaction.options.getUser('user', true)); + } + }, + autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { + const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; + + if (!subcommandGroup) return interaction.respond([]); + + const value = interaction.options.getFocused(); + + const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id); + + const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value)); + if (toRespond.length > 25) toRespond.length = 25; + + interaction.respond(toRespond.map(p => ({ + name: p.phrase, + value: p.id + }))); + } +}; + +async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) { + const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id); + + const count = await Database.removeAllTicketPhrasesFor(user.id); + + logCustomEvent(interaction.guild!, { + title: 'Ticket Phrases Purged', + description: null, + color: 0xFF0000, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Target User', + value: `<@${user.id}>\n${user.username}`, + inline: true + }, + { + name: 'Count', + value: count.toString(), + inline: true + } + ] + }); + + interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`); +} + +async function dumpPhrases(interaction: ChatInputCommandInteraction) { + let content = ''; + + const guildSettings = await Database.getGuildSettings(interaction.guildId!); + + await Database.getAllTicketPhrases((phrase: TicketPhrase) => { + if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return; + + const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`; + + content += `${mention}: \`${phrase.phrase}\`\n`; + }); + + if (content.length == 0) return interaction.reply('No phrases found.'); + + interaction.reply('The following phrases are registered:\n\n' + content); +} + +async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) { + await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase); + + logCustomEvent(interaction.guild!, { + title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`, + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Phrase', + value: phrase, + inline: true + } + ] + }); + + interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`); +} + +async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) { + const phrase = await Database.getTicketPhrase(phraseId); + + if (!phrase) return interaction.reply('Phrase not found'); + + await Database.removeTicketPhrase(phraseId); + + logCustomEvent(interaction.guild!, { + title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`, + description: null, + color: 0xFF0000, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Phrase', + value: phrase.phrase, + inline: true + } + ] + }); + + interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`); +} + +async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) { + const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id); + + if (phrases.length == 0) return interaction.reply('No phrases registered'); + + interaction.reply(`The following phrases are registered:\n\n${phrases.map(p => (`- \`${p.phrase}\``)).join('\n')}`); } \ No newline at end of file diff --git a/src/commands/private-help.ts b/src/commands/private-help.ts index 41140fd..e89cef8 100644 --- a/src/commands/private-help.ts +++ b/src/commands/private-help.ts @@ -1,45 +1,45 @@ -import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'private-help', - data: new SlashCommandBuilder() - .setName('private-help') - .setDescription('Setup a private help button.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addStringOption(option => - option - .setName('content') - .setDescription('The content of the message.') - .setRequired(false) - ) - .addStringOption(option => - option - .setName('button-label') - .setDescription('The button label.') - .setRequired(false) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - if (!interaction.channel || !interaction.channel.isSendable()) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); - - const content = interaction.options.getString('content') ?? ''; - const label = interaction.options.getString('button-label') ?? 'Get in contact'; - - const button = new ButtonBuilder() - .setCustomId('private-help') - .setStyle(ButtonStyle.Primary) - .setLabel(label); - - const row = new ActionRowBuilder() - .addComponents(button); - - await interaction.channel.send({ components: [row], content }); - - await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId); - - interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); - } +import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'private-help', + data: new SlashCommandBuilder() + .setName('private-help') + .setDescription('Setup a private help button.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addStringOption(option => + option + .setName('content') + .setDescription('The content of the message.') + .setRequired(false) + ) + .addStringOption(option => + option + .setName('button-label') + .setDescription('The button label.') + .setRequired(false) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + if (!interaction.channel || !interaction.channel.isSendable()) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); + + const content = interaction.options.getString('content') ?? ''; + const label = interaction.options.getString('button-label') ?? 'Get in contact'; + + const button = new ButtonBuilder() + .setCustomId('private-help') + .setStyle(ButtonStyle.Primary) + .setLabel(label); + + const row = new ActionRowBuilder() + .addComponents(button); + + await interaction.channel.send({ components: [row], content }); + + await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId); + + interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); + } }; \ No newline at end of file diff --git a/src/commands/records.ts b/src/commands/records.ts index e98589e..bb9b478 100644 --- a/src/commands/records.ts +++ b/src/commands/records.ts @@ -1,45 +1,45 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { deferInteraction } from '../utils'; -import { getRecordMessageFromDiscordId } from '../utils/record-utils'; - -export default { - name: 'records', - data: new SlashCommandBuilder() - .setName('records') - .setDescription("Get a user's on-site records.") - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addUserOption(option => - option - .setName('user') - .setDescription('The discord user to find the e621 user of.') - .setRequired(false) - ) - .addStringOption(option => - option - .setName('id') - .setDescription('The discord user id to find the e621 user of.') - .setRequired(false) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - await deferInteraction(interaction); - - if (!interaction.guild) return interaction.editReply('This command must be used in a server'); - - const user = interaction.options.getUser('user'); - const id = interaction.options.getString('id'); - - if (!user && !id) { - return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] }); - } - - const idToUse = (user?.id ?? id) as string; - - const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild); - - if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); - - interaction.editReply(recordMessage); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { deferInteraction } from '../utils'; +import { getRecordMessageFromDiscordId } from '../utils/record-utils'; + +export default { + name: 'records', + data: new SlashCommandBuilder() + .setName('records') + .setDescription("Get a user's on-site records.") + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addUserOption(option => + option + .setName('user') + .setDescription('The discord user to find the e621 user of.') + .setRequired(false) + ) + .addStringOption(option => + option + .setName('id') + .setDescription('The discord user id to find the e621 user of.') + .setRequired(false) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + await deferInteraction(interaction); + + if (!interaction.guild) return interaction.editReply('This command must be used in a server'); + + const user = interaction.options.getUser('user'); + const id = interaction.options.getString('id'); + + if (!user && !id) { + return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] }); + } + + const idToUse = (user?.id ?? id) as string; + + const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild); + + if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); + + interaction.editReply(recordMessage); + } }; \ No newline at end of file diff --git a/src/commands/rename.ts b/src/commands/rename.ts index 089507d..bfb7776 100644 --- a/src/commands/rename.ts +++ b/src/commands/rename.ts @@ -1,51 +1,51 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js'; -import { msToHuman } from '../utils'; -import { Database } from '../shared/Database'; - -export default { - name: 'rename', - data: new SlashCommandBuilder() - .setName('rename') - .setDescription('Rename the general channel.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addStringOption(option => - option - .setName('new-name') - .setDescription('The new name of the general channel.') - .setRequired(true) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - const guildSettings = await Database.getGuildSettings(interaction.guildId!); - - if (!guildSettings || !guildSettings.general_chat_id) { - return interaction.reply('No general chat id found.'); - } - - const name = interaction.options.getString('new-name', true); - - if (name.length > 100) { - return interaction.reply('Name must be less than 100 characters in length.'); - } - - const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!; - - if (!channel) { - return interaction.reply('No general chat id found.'); - } - - try { - await channel.setName(name); - - interaction.reply(`Renamed general to ${channel.name}`); - } catch (e: any) { - if (e instanceof RateLimitError) { - return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`); - } - - console.error(e); - return interaction.reply('An error has occurred.'); - } - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js'; +import { msToHuman } from '../utils'; +import { Database } from '../shared/Database'; + +export default { + name: 'rename', + data: new SlashCommandBuilder() + .setName('rename') + .setDescription('Rename the general channel.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addStringOption(option => + option + .setName('new-name') + .setDescription('The new name of the general channel.') + .setRequired(true) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const guildSettings = await Database.getGuildSettings(interaction.guildId!); + + if (!guildSettings || !guildSettings.general_chat_id) { + return interaction.reply('No general chat id found.'); + } + + const name = interaction.options.getString('new-name', true); + + if (name.length > 100) { + return interaction.reply('Name must be less than 100 characters in length.'); + } + + const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!; + + if (!channel) { + return interaction.reply('No general chat id found.'); + } + + try { + await channel.setName(name); + + interaction.reply(`Renamed general to ${channel.name}`); + } catch (e: any) { + if (e instanceof RateLimitError) { + return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`); + } + + console.error(e); + return interaction.reply('An error has occurred.'); + } + } }; \ No newline at end of file diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 3431448..658ac4f 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -1,303 +1,303 @@ -import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { Database } from '../shared/Database'; - -export default { - name: 'settings', - data: new SlashCommandBuilder() - .setName('settings') - .setDescription('Change server settings.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addChannelOption(option => - option - .setName('general-channel') - .setDescription('Set the general channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('tickets-channel') - .setDescription('Set the ticket logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('event-logs-channel') - .setDescription('Set the event logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('discord-logs-channel') - .setDescription('Set the discord logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('audit-logs-channel') - .setDescription('Set the audit logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('voice-logs-channel') - .setDescription('Set the voice logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('new-member-channel') - .setDescription('Set the new member logs channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('moderator-channel') - .setDescription('Set the site moderator channel.') - .setRequired(false) - ) - .addRoleOption(option => - option - .setName('admin-role') - .setDescription('Set the admin role.') - .setRequired(false) - ) - .addRoleOption(option => - option - .setName('private-helper-role') - .setDescription('Set the private helper role.') - .setRequired(false) - ) - .addRoleOption(option => - option - .setName('devwatch-role') - .setDescription('Set the DevWatch role.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('add-staff-category') - .setDescription('Add a category to staff categories.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('remove-staff-category') - .setDescription('Remove a category from staff categories.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('add-safe-channel') - .setDescription('Add a SFW channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('remove-safe-channel') - .setDescription('Remove a SFW channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('add-link-skip-channel') - .setDescription('Add a link skip channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('remove-link-skip-channel') - .setDescription('Remove a link skip channel.') - .setRequired(false) - ) - .addChannelOption(option => - option - .setName('github-release-channel') - .setDescription('Set the github release channel.') - .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.putGuild(interaction.guildId!); - } - - const generalChannel = interaction.options.getChannel('general-channel'); - - if (generalChannel) { - await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id); - - response += `General channel set to ${generalChannel}.\n`; - } - - const ticketsChannel = interaction.options.getChannel('tickets-channel'); - - if (ticketsChannel) { - await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id); - - response += `Tickets logs channel set to ${ticketsChannel}.\n`; - } - - const eventLogsChannel = interaction.options.getChannel('event-logs-channel'); - - if (eventLogsChannel) { - await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id); - - response += `Event logs channel set to ${eventLogsChannel}.\n`; - } - - const discordLogsChannel = interaction.options.getChannel('discord-logs-channel'); - - if (discordLogsChannel) { - await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id); - - response += `Discord logs channel set to ${discordLogsChannel}.\n`; - } - - const auditLogsChannel = interaction.options.getChannel('audit-logs-channel'); - - if (auditLogsChannel) { - await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id); - - response += `Audit logs channel set to ${auditLogsChannel}.\n`; - } - - const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel'); - - if (voiceLogsChannel) { - await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id); - - response += `Voice logs channel set to ${voiceLogsChannel}.\n`; - } - - const newMemberLogsChannel = interaction.options.getChannel('new-member-channel'); - - if (newMemberLogsChannel) { - await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id); - - response += `New member logs channel set to ${newMemberLogsChannel}.\n`; - } - - const moderatorChannel = interaction.options.getChannel('moderator-channel'); - - if (moderatorChannel) { - await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id); - - response += `Moderator channel set to ${moderatorChannel}.\n`; - } - - const adminRole = interaction.options.getRole('admin-role'); - - if (adminRole) { - await Database.setGuildAdminRole(interaction.guildId!, adminRole.id); - - response += `Admin role set to ${adminRole}.\n`; - } - - const privateHelperRole = interaction.options.getRole('private-helper-role'); - - if (privateHelperRole) { - await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id); - - response += `Private helper role set to ${privateHelperRole}.\n`; - } - - const devWatchRole = interaction.options.getRole('devwatch-role'); - - if (devWatchRole) { - await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id); - - response += `DevWatch role set to ${devWatchRole}.\n`; - } - - const addCategory = interaction.options.getChannel('add-staff-category'); - if (addCategory) { - if (addCategory.type == ChannelType.GuildCategory) { - await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id); - - response += `Added ${addCategory.toString()} as a staff category.\n`; - } else { - response += `Error adding staff category: ${addCategory.toString()} isn't a category.`; - } - } - - const removeCategory = interaction.options.getChannel('remove-staff-category'); - if (removeCategory) { - if (removeCategory.type == ChannelType.GuildCategory) { - if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) { - response += `Removed ${removeCategory.toString()} as a staff category\n`; - } else { - response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`; - } - } else { - response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`; - } - } - - const addSafeChannel = interaction.options.getChannel('add-safe-channel'); - if (addSafeChannel) { - if (addSafeChannel.type == ChannelType.GuildText) { - await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id); - - response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`; - } else { - response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`; - } - } - - const removeSafeChannel = interaction.options.getChannel('remove-safe-channel'); - if (removeSafeChannel) { - if (removeSafeChannel.type == ChannelType.GuildText) { - if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) { - response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`; - } else { - response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`; - } - } else { - response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`; - } - } - - const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel'); - if (addLinkSkipChannel) { - if (addLinkSkipChannel.type == ChannelType.GuildText) { - await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id); - - response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`; - } else { - response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`; - } - } - - const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel'); - if (removeLinkSkipChannel) { - if (removeLinkSkipChannel.type == ChannelType.GuildText) { - if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) { - response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`; - } else { - response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`; - } - } else { - response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`; - } - } - - const githubReleaseChannel = interaction.options.getChannel('github-release-channel'); - - if (githubReleaseChannel) { - await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id); - - response += `Github releases channel set to ${githubReleaseChannel}.\n`; - } - - if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' }); - - interaction.editReply({ content: response }); - } +import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { Database } from '../shared/Database'; + +export default { + name: 'settings', + data: new SlashCommandBuilder() + .setName('settings') + .setDescription('Change server settings.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addChannelOption(option => + option + .setName('general-channel') + .setDescription('Set the general channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('tickets-channel') + .setDescription('Set the ticket logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('event-logs-channel') + .setDescription('Set the event logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('discord-logs-channel') + .setDescription('Set the discord logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('audit-logs-channel') + .setDescription('Set the audit logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('voice-logs-channel') + .setDescription('Set the voice logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('new-member-channel') + .setDescription('Set the new member logs channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('moderator-channel') + .setDescription('Set the site moderator channel.') + .setRequired(false) + ) + .addRoleOption(option => + option + .setName('admin-role') + .setDescription('Set the admin role.') + .setRequired(false) + ) + .addRoleOption(option => + option + .setName('private-helper-role') + .setDescription('Set the private helper role.') + .setRequired(false) + ) + .addRoleOption(option => + option + .setName('devwatch-role') + .setDescription('Set the DevWatch role.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('add-staff-category') + .setDescription('Add a category to staff categories.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('remove-staff-category') + .setDescription('Remove a category from staff categories.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('add-safe-channel') + .setDescription('Add a SFW channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('remove-safe-channel') + .setDescription('Remove a SFW channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('add-link-skip-channel') + .setDescription('Add a link skip channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('remove-link-skip-channel') + .setDescription('Remove a link skip channel.') + .setRequired(false) + ) + .addChannelOption(option => + option + .setName('github-release-channel') + .setDescription('Set the github release channel.') + .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.putGuild(interaction.guildId!); + } + + const generalChannel = interaction.options.getChannel('general-channel'); + + if (generalChannel) { + await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id); + + response += `General channel set to ${generalChannel}.\n`; + } + + const ticketsChannel = interaction.options.getChannel('tickets-channel'); + + if (ticketsChannel) { + await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id); + + response += `Tickets logs channel set to ${ticketsChannel}.\n`; + } + + const eventLogsChannel = interaction.options.getChannel('event-logs-channel'); + + if (eventLogsChannel) { + await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id); + + response += `Event logs channel set to ${eventLogsChannel}.\n`; + } + + const discordLogsChannel = interaction.options.getChannel('discord-logs-channel'); + + if (discordLogsChannel) { + await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id); + + response += `Discord logs channel set to ${discordLogsChannel}.\n`; + } + + const auditLogsChannel = interaction.options.getChannel('audit-logs-channel'); + + if (auditLogsChannel) { + await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id); + + response += `Audit logs channel set to ${auditLogsChannel}.\n`; + } + + const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel'); + + if (voiceLogsChannel) { + await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id); + + response += `Voice logs channel set to ${voiceLogsChannel}.\n`; + } + + const newMemberLogsChannel = interaction.options.getChannel('new-member-channel'); + + if (newMemberLogsChannel) { + await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id); + + response += `New member logs channel set to ${newMemberLogsChannel}.\n`; + } + + const moderatorChannel = interaction.options.getChannel('moderator-channel'); + + if (moderatorChannel) { + await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id); + + response += `Moderator channel set to ${moderatorChannel}.\n`; + } + + const adminRole = interaction.options.getRole('admin-role'); + + if (adminRole) { + await Database.setGuildAdminRole(interaction.guildId!, adminRole.id); + + response += `Admin role set to ${adminRole}.\n`; + } + + const privateHelperRole = interaction.options.getRole('private-helper-role'); + + if (privateHelperRole) { + await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id); + + response += `Private helper role set to ${privateHelperRole}.\n`; + } + + const devWatchRole = interaction.options.getRole('devwatch-role'); + + if (devWatchRole) { + await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id); + + response += `DevWatch role set to ${devWatchRole}.\n`; + } + + const addCategory = interaction.options.getChannel('add-staff-category'); + if (addCategory) { + if (addCategory.type == ChannelType.GuildCategory) { + await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id); + + response += `Added ${addCategory.toString()} as a staff category.\n`; + } else { + response += `Error adding staff category: ${addCategory.toString()} isn't a category.`; + } + } + + const removeCategory = interaction.options.getChannel('remove-staff-category'); + if (removeCategory) { + if (removeCategory.type == ChannelType.GuildCategory) { + if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) { + response += `Removed ${removeCategory.toString()} as a staff category\n`; + } else { + response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`; + } + } else { + response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`; + } + } + + const addSafeChannel = interaction.options.getChannel('add-safe-channel'); + if (addSafeChannel) { + if (addSafeChannel.type == ChannelType.GuildText) { + await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id); + + response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`; + } else { + response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`; + } + } + + const removeSafeChannel = interaction.options.getChannel('remove-safe-channel'); + if (removeSafeChannel) { + if (removeSafeChannel.type == ChannelType.GuildText) { + if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) { + response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`; + } else { + response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`; + } + } else { + response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`; + } + } + + const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel'); + if (addLinkSkipChannel) { + if (addLinkSkipChannel.type == ChannelType.GuildText) { + await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id); + + response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`; + } else { + response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`; + } + } + + const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel'); + if (removeLinkSkipChannel) { + if (removeLinkSkipChannel.type == ChannelType.GuildText) { + if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) { + response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`; + } else { + response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`; + } + } else { + response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`; + } + } + + const githubReleaseChannel = interaction.options.getChannel('github-release-channel'); + + if (githubReleaseChannel) { + await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id); + + response += `Github releases channel set to ${githubReleaseChannel}.\n`; + } + + if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' }); + + interaction.editReply({ content: response }); + } }; \ No newline at end of file diff --git a/src/commands/softban.ts b/src/commands/softban.ts index 9e85dc8..676a7d8 100644 --- a/src/commands/softban.ts +++ b/src/commands/softban.ts @@ -1,81 +1,81 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { deferInteraction } from '../utils'; - -export default { - name: 'softban', - data: new SlashCommandBuilder() - .setName('softban') - .setDescription('Bans and immediately unbans a user to purge messages.') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) - .addUserOption(option => - option - .setName('user') - .setDescription('The discord user to softban.') - .setRequired(true) - ) - .addStringOption(option => - option - .setName('reason') - .setDescription('The reason for the softban') - .setRequired(false) - .setMaxLength(400) - ) - .addNumberOption(option => - option - .setName('days') - .setDescription('How far back to delete messages (in days, default: 7 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 seconds = (interaction.options.getNumber('days') ?? 7) * 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 softban this user.'); - } - - if (banMember && !banMember.bannable) { - return await interaction.editReply('I do not have permission to softban this user.'); - } - - try { - await interaction.guild.bans.create(user, { - reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(), - deleteMessageSeconds: seconds - }); - } catch (e) { - console.error(e); - return await interaction.editReply("Error softbanning user (couldn't ban)."); - } - - try { - await interaction.guild.bans.remove(user); - } catch (e) { - console.error(e); - return await interaction.editReply("Error softbanning user (couldn't remove ban)."); - } - - await interaction.editReply('Softban successful'); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { deferInteraction } from '../utils'; + +export default { + name: 'softban', + data: new SlashCommandBuilder() + .setName('softban') + .setDescription('Bans and immediately unbans a user to purge messages.') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + .addUserOption(option => + option + .setName('user') + .setDescription('The discord user to softban.') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the softban') + .setRequired(false) + .setMaxLength(400) + ) + .addNumberOption(option => + option + .setName('days') + .setDescription('How far back to delete messages (in days, default: 7 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 seconds = (interaction.options.getNumber('days') ?? 7) * 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 softban this user.'); + } + + if (banMember && !banMember.bannable) { + return await interaction.editReply('I do not have permission to softban this user.'); + } + + try { + await interaction.guild.bans.create(user, { + reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(), + deleteMessageSeconds: seconds + }); + } catch (e) { + console.error(e); + return await interaction.editReply("Error softbanning user (couldn't ban)."); + } + + try { + await interaction.guild.bans.remove(user); + } catch (e) { + console.error(e); + return await interaction.editReply("Error softbanning user (couldn't remove ban)."); + } + + await interaction.editReply('Softban successful'); + } }; \ No newline at end of file diff --git a/src/commands/whois.ts b/src/commands/whois.ts index aad3136..17e77fd 100644 --- a/src/commands/whois.ts +++ b/src/commands/whois.ts @@ -1,30 +1,30 @@ -import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; -import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; - -const mentionRegex = new RegExp(MessageMentions.UsersPattern); - -export default { - name: 'whois', - data: new SlashCommandBuilder() - .setName('whois') - .setDescription("Find a user's e621 account from their discord account, or vice versa.") - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .addStringOption(option => - option - .setName('user') - .setDescription('The discord user mention, or ID, to find the e621 user of.') - .setRequired(true) - ), - handler: async function (client: Client, interaction: ChatInputCommandInteraction) { - const input = interaction.options.getString('user', true); - - const matches = mentionRegex.exec(input); - mentionRegex.lastIndex = 0; - - const valueToUse = matches ? matches.groups!.id : input; - - handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); - } +import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; + +const mentionRegex = new RegExp(MessageMentions.UsersPattern); + +export default { + name: 'whois', + data: new SlashCommandBuilder() + .setName('whois') + .setDescription("Find a user's e621 account from their discord account, or vice versa.") + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addStringOption(option => + option + .setName('user') + .setDescription('The discord user mention, or ID, to find the e621 user of.') + .setRequired(true) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const input = interaction.options.getString('user', true); + + const matches = mentionRegex.exec(input); + mentionRegex.lastIndex = 0; + + const valueToUse = matches ? matches.groups!.id : input; + + handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); + } }; \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 9c7b24c..56024fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,27 +1,27 @@ -import dotenv from 'dotenv'; - -dotenv.config(); - -const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env; - -export const config = { - DISCORD_TOKEN, - DISCORD_CLIENT_ID, - DISCORD_CLIENT_SECRET, - DISCORD_GUILD_ID, - RELEASE_SECRET, - LINK_SECRET, - E621_BASE_URL, - E926_BASE_URL, - GIT_REPO_BASE_URL, - PORT: parseInt(PORT as string), - REDIS_URL, - DEV_MODE: process.env.npm_lifecycle_event == 'dev', - DEBUG: DEBUG == 'true' -}; - -for (const [key, val] of Object.entries(config)) { - if (val === undefined) { - throw new Error(`${key} is undefined in config`); - } +import dotenv from 'dotenv'; + +dotenv.config(); + +const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env; + +export const config = { + DISCORD_TOKEN, + DISCORD_CLIENT_ID, + DISCORD_CLIENT_SECRET, + DISCORD_GUILD_ID, + RELEASE_SECRET, + LINK_SECRET, + E621_BASE_URL, + E926_BASE_URL, + GIT_REPO_BASE_URL, + PORT: parseInt(PORT as string), + REDIS_URL, + DEV_MODE: process.env.npm_lifecycle_event == 'dev', + DEBUG: DEBUG == 'true' +}; + +for (const [key, val] of Object.entries(config)) { + if (val === undefined) { + throw new Error(`${key} is undefined in config`); + } } \ No newline at end of file diff --git a/src/context-menus/add-note.ts b/src/context-menus/add-note.ts index 8d90ea8..14f97ec 100644 --- a/src/context-menus/add-note.ts +++ b/src/context-menus/add-note.ts @@ -1,27 +1,27 @@ -import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; -import { createTextInput } from '../utils'; - -export default { - name: 'Add Note', - data: new ContextMenuCommandBuilder() - .setName('Add Note') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - const idToUse = interaction.targetUser.id; - - const member = await interaction.guild?.members.fetch(idToUse); - - const modal = new ModalBuilder() - .setCustomId(`add-note-modal_${idToUse}`) - .setTitle(`Adding note to ${member ? member.displayName : idToUse}`); - - const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2); - - modal.addLabelComponents(inputLabel); - - await interaction.showModal(modal); - } +import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; +import { createTextInput } from '../utils'; + +export default { + name: 'Add Note', + data: new ContextMenuCommandBuilder() + .setName('Add Note') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + const idToUse = interaction.targetUser.id; + + const member = await interaction.guild?.members.fetch(idToUse); + + const modal = new ModalBuilder() + .setCustomId(`add-note-modal_${idToUse}`) + .setTitle(`Adding note to ${member ? member.displayName : idToUse}`); + + const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2); + + modal.addLabelComponents(inputLabel); + + await interaction.showModal(modal); + } }; \ No newline at end of file diff --git a/src/context-menus/list-notes.ts b/src/context-menus/list-notes.ts index f561fcd..9035462 100644 --- a/src/context-menus/list-notes.ts +++ b/src/context-menus/list-notes.ts @@ -1,23 +1,23 @@ -import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; -import { deferInteraction, getNoteMessage } from '../utils'; - -export default { - name: 'List Notes', - data: new ContextMenuCommandBuilder() - .setName('List Notes') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - const idToUse = interaction.targetUser.id; - - await deferInteraction(interaction); - - const noteMessage = await getNoteMessage(idToUse, 1); - - if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`); - - interaction.editReply(noteMessage); - } +import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; +import { deferInteraction, getNoteMessage } from '../utils'; + +export default { + name: 'List Notes', + data: new ContextMenuCommandBuilder() + .setName('List Notes') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + const idToUse = interaction.targetUser.id; + + await deferInteraction(interaction); + + const noteMessage = await getNoteMessage(idToUse, 1); + + if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`); + + interaction.editReply(noteMessage); + } }; \ No newline at end of file diff --git a/src/context-menus/open-mod-ticket.ts b/src/context-menus/open-mod-ticket.ts index 5a74054..f68578a 100644 --- a/src/context-menus/open-mod-ticket.ts +++ b/src/context-menus/open-mod-ticket.ts @@ -1,19 +1,19 @@ -import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js'; -import { openModTicketModal } from '../utils'; - -export default { - name: 'Open Mod Ticket', - data: new ContextMenuCommandBuilder() - .setName('Open Mod Ticket') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - const member = await interaction.guild?.members.fetch(interaction.targetUser.id); - - if (!member) return interaction.editReply('Could not find member.'); - - openModTicketModal(interaction, member); - } +import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js'; +import { openModTicketModal } from '../utils'; + +export default { + name: 'Open Mod Ticket', + data: new ContextMenuCommandBuilder() + .setName('Open Mod Ticket') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + const member = await interaction.guild?.members.fetch(interaction.targetUser.id); + + if (!member) return interaction.editReply('Could not find member.'); + + openModTicketModal(interaction, member); + } }; \ No newline at end of file diff --git a/src/context-menus/records.ts b/src/context-menus/records.ts index 7b1beb0..c2f0e59 100644 --- a/src/context-menus/records.ts +++ b/src/context-menus/records.ts @@ -1,23 +1,23 @@ -import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; -import { deferInteraction, getRecordMessageFromDiscordId } from '../utils'; - -export default { - name: 'Get Records', - data: new ContextMenuCommandBuilder() - .setName('Get Records') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - await deferInteraction(interaction); - - const idToUse = interaction.targetUser.id; - - const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!); - - if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); - - interaction.editReply(recordMessage); - } +import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; +import { deferInteraction, getRecordMessageFromDiscordId } from '../utils'; + +export default { + name: 'Get Records', + data: new ContextMenuCommandBuilder() + .setName('Get Records') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + await deferInteraction(interaction); + + const idToUse = interaction.targetUser.id; + + const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!); + + if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); + + interaction.editReply(recordMessage); + } }; \ No newline at end of file diff --git a/src/context-menus/sync-name.ts b/src/context-menus/sync-name.ts index 3fc3c0d..5fcf94c 100644 --- a/src/context-menus/sync-name.ts +++ b/src/context-menus/sync-name.ts @@ -1,57 +1,57 @@ -import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; -import { config } from '../config'; -import { Database } from '../shared/Database'; -import { createTextInput, deferInteraction, syncName } from '../utils'; - -export default { - name: 'Sync Name', - data: new ContextMenuCommandBuilder() - .setName('Sync Name') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - const availableIds = await Database.getE621Ids(interaction.user.id); - - const idToUse = interaction.targetUser.id; - - const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); - - if (!guild) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - const member = await guild.members.fetch(idToUse); - - const interactionMember = await guild.members.fetch(interaction.user.id); - - if (!member || !interactionMember) { - return interaction.reply('An error has occurred. Please try again later.'); - } - - if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) { - return await interaction.editReply("You do not have permission to sync this user's name."); - } - - if (availableIds.length > 1) { - const modal = new ModalBuilder() - .setCustomId(`sync-name-modal_${idToUse}`) - .setTitle(`Syncing ${member ? member.displayName : idToUse}'s name`); - - const inputLabel = createTextInput('id', 'User has multiple linked accounts. Provide ID', null, false, TextInputStyle.Short, null, null); - - modal.addLabelComponents(inputLabel); - - return await interaction.showModal(modal); - } - - await deferInteraction(interaction); - - if (!guild.members.me) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - await syncName(interaction, member, null); - } +import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; +import { config } from '../config'; +import { Database } from '../shared/Database'; +import { createTextInput, deferInteraction, syncName } from '../utils'; + +export default { + name: 'Sync Name', + data: new ContextMenuCommandBuilder() + .setName('Sync Name') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + const availableIds = await Database.getE621Ids(interaction.user.id); + + const idToUse = interaction.targetUser.id; + + const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); + + if (!guild) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + const member = await guild.members.fetch(idToUse); + + const interactionMember = await guild.members.fetch(interaction.user.id); + + if (!member || !interactionMember) { + return interaction.reply('An error has occurred. Please try again later.'); + } + + if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) { + return await interaction.editReply("You do not have permission to sync this user's name."); + } + + if (availableIds.length > 1) { + const modal = new ModalBuilder() + .setCustomId(`sync-name-modal_${idToUse}`) + .setTitle(`Syncing ${member ? member.displayName : idToUse}'s name`); + + const inputLabel = createTextInput('id', 'User has multiple linked accounts. Provide ID', null, false, TextInputStyle.Short, null, null); + + modal.addLabelComponents(inputLabel); + + return await interaction.showModal(modal); + } + + await deferInteraction(interaction); + + if (!guild.members.me) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + await syncName(interaction, member, null); + } }; \ No newline at end of file diff --git a/src/context-menus/whois.ts b/src/context-menus/whois.ts index 10cdc2b..7cde233 100644 --- a/src/context-menus/whois.ts +++ b/src/context-menus/whois.ts @@ -1,17 +1,17 @@ -import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js'; -import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; - -export default { - name: 'Whois', - data: new ContextMenuCommandBuilder() - .setName('Whois') - .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) - .setContexts(InteractionContextType.Guild) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) - .setType(ApplicationCommandType.User), - handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { - const idToUse = interaction.targetUser.id; - - handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); - } +import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js'; +import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; + +export default { + name: 'Whois', + data: new ContextMenuCommandBuilder() + .setName('Whois') + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .setType(ApplicationCommandType.User), + handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { + const idToUse = interaction.targetUser.id; + + handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); + } }; \ No newline at end of file diff --git a/src/events/handle-audit-log-create.ts b/src/events/handle-audit-log-create.ts index 44471bb..f875e7e 100644 --- a/src/events/handle-audit-log-create.ts +++ b/src/events/handle-audit-log-create.ts @@ -1,94 +1,94 @@ -import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js'; -import { Database } from '../shared/Database'; -import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils'; - -const IGNORED_ACTIONS = [ - AuditLogEvent.MemberMove, - // Handled by automod. - AuditLogEvent.AutoModerationFlagToChannel -]; - -export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) { - if (!await shouldLog(entry, guild)) return; - const settings = await Database.getGuildSettings(guild.id); - - if (!settings || !settings.audit_logs_channel_id) return; - - const channel = await guild.channels.fetch(settings.audit_logs_channel_id); - - if (!channel || !channel.isSendable()) return; - - const fields: APIEmbedField[] = [ - { - name: 'Actor', - value: `<@${entry.executorId}>`, - inline: true - } - ]; - - if (entry.targetId) { - const targetType = getTargetType(entry.action); - fields.push({ - name: 'Target', - value: formatSnowflake(entry.targetId, targetType), - inline: true - }); - } - - if (entry.reason) { - fields.push({ - name: 'Reason', - value: entry.reason, - inline: true - }); - } - - if (entry.changes && entry.changes.length > 0) { - fields.push({ - name: 'Changes', - value: formatChanges(entry), - inline: false - }); - } - - if (entry.extra) { - fields.push({ - name: 'Options', - value: formatExtras(entry, guild), - inline: false - }); - } - - const embed = new EmbedBuilder() - .setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)]) - .setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp)) - .addFields(...fields); - - channel.send({ embeds: [embed] }); -} - -async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise { - if (!entry.executorId) return true; - - if (IGNORED_ACTIONS.some(a => entry.action == a)) return false; - - if (entry.action == AuditLogEvent.MemberRoleUpdate) { - return await shouldLogRoleChanges(entry as GuildAuditLogsEntry, guild); - } - - return true; -} - -async function shouldLogRoleChanges(entry: GuildAuditLogsEntry, guild: Guild): Promise { - for (const change of entry.changes) { - // Get role changes from the log. - const roles = (await Promise.all((change.new! as Pick[]).map(c => guild.roles.fetch(c.id)))); - - // Check if role is part of onboarding. - for (const role of roles) { - if (role && !role.flags.has(RoleFlags.InPrompt)) return true; - } - } - - return false; +import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js'; +import { Database } from '../shared/Database'; +import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils'; + +const IGNORED_ACTIONS = [ + AuditLogEvent.MemberMove, + // Handled by automod. + AuditLogEvent.AutoModerationFlagToChannel +]; + +export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) { + if (!await shouldLog(entry, guild)) return; + const settings = await Database.getGuildSettings(guild.id); + + if (!settings || !settings.audit_logs_channel_id) return; + + const channel = await guild.channels.fetch(settings.audit_logs_channel_id); + + if (!channel || !channel.isSendable()) return; + + const fields: APIEmbedField[] = [ + { + name: 'Actor', + value: `<@${entry.executorId}>`, + inline: true + } + ]; + + if (entry.targetId) { + const targetType = getTargetType(entry.action); + fields.push({ + name: 'Target', + value: formatSnowflake(entry.targetId, targetType), + inline: true + }); + } + + if (entry.reason) { + fields.push({ + name: 'Reason', + value: entry.reason, + inline: true + }); + } + + if (entry.changes && entry.changes.length > 0) { + fields.push({ + name: 'Changes', + value: formatChanges(entry), + inline: false + }); + } + + if (entry.extra) { + fields.push({ + name: 'Options', + value: formatExtras(entry, guild), + inline: false + }); + } + + const embed = new EmbedBuilder() + .setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)]) + .setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp)) + .addFields(...fields); + + channel.send({ embeds: [embed] }); +} + +async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise { + if (!entry.executorId) return true; + + if (IGNORED_ACTIONS.some(a => entry.action == a)) return false; + + if (entry.action == AuditLogEvent.MemberRoleUpdate) { + return await shouldLogRoleChanges(entry as GuildAuditLogsEntry, guild); + } + + return true; +} + +async function shouldLogRoleChanges(entry: GuildAuditLogsEntry, guild: Guild): Promise { + for (const change of entry.changes) { + // Get role changes from the log. + const roles = (await Promise.all((change.new! as Pick[]).map(c => guild.roles.fetch(c.id)))); + + // Check if role is part of onboarding. + for (const role of roles) { + if (role && !role.flags.has(RoleFlags.InPrompt)) return true; + } + } + + return false; } \ No newline at end of file diff --git a/src/events/handle-ban-remove.ts b/src/events/handle-ban-remove.ts index 4b1bc2d..edc9888 100644 --- a/src/events/handle-ban-remove.ts +++ b/src/events/handle-ban-remove.ts @@ -1,6 +1,6 @@ -import { GuildBan } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleBanRemove(ban: GuildBan) { - await Database.removeBan(ban.user.id); +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 4193769..65ebc27 100644 --- a/src/events/handle-guild-create.ts +++ b/src/events/handle-guild-create.ts @@ -1,10 +1,10 @@ -import { Guild } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleGuildCreate(guild: Guild) { - try { - if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id); - } catch (e) { - console.error(e); - } +import { Guild } from 'discord.js'; +import { Database } from '../shared/Database'; + +export async function handleGuildCreate(guild: Guild) { + try { + if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id); + } catch (e) { + console.error(e); + } } \ No newline at end of file diff --git a/src/events/handle-member-join.ts b/src/events/handle-member-join.ts index 302c3ae..b625bef 100644 --- a/src/events/handle-member-join.ts +++ b/src/events/handle-member-join.ts @@ -1,23 +1,23 @@ -import { GuildMember, GuildTextBasedChannel } from 'discord.js'; -import { Database } from '../shared/Database'; -import { getE621Alts } from '../utils'; - -export async function handleMemberJoin(member: GuildMember) { - const guildSettings = await Database.getGuildSettings(member.guild.id); - - if (guildSettings?.new_member_channel_id) { - const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel; - - if (channel) { - const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`; - - channel.send(content).catch(console.error); - - if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) { - const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel; - - if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error); - } - } - } +import { GuildMember, GuildTextBasedChannel } from 'discord.js'; +import { Database } from '../shared/Database'; +import { getE621Alts } from '../utils'; + +export async function handleMemberJoin(member: GuildMember) { + const guildSettings = await Database.getGuildSettings(member.guild.id); + + if (guildSettings?.new_member_channel_id) { + const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel; + + if (channel) { + const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`; + + channel.send(content).catch(console.error); + + if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) { + const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel; + + if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error); + } + } + } } \ No newline at end of file diff --git a/src/events/handle-message.ts b/src/events/handle-message.ts index ba99660..3e7e3a4 100644 --- a/src/events/handle-message.ts +++ b/src/events/handle-message.ts @@ -1,385 +1,385 @@ -import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js'; -import { config } from '../config'; -import { Database } from '../shared/Database'; -import { E621Post } from '../types'; -import { ALLOWED_MIMETYPES, artistIDRegex, blipIDRegex, calculateMD5FromURL, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, forumTopicIDRegex, getE621Post, getE621PostByMd5, getPostUrl, isEdited, isInSpoilerTags, issueRegex, logDeletion, logEdit, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '../utils'; - -export type Message = OmitPartialGroupDMChannel>; -export type Partial = OmitPartialGroupDMChannel; - -// TODO: I don't know of any good way to not hardcode this regex for e621 links. So I've provided two that may need to have the port altered. -const postRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+posts/+([0-9]+)', 'gi'); -const postShareRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+p/+([a-z0-9]+)', 'gi'); -const imageRegex = new RegExp('!?https?://(?:.*@)?static[0-9]*\\.(?:e621|e926)\\.net/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); - -const postRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+posts/+([0-9]+)', 'gi'); -const imageRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); - -const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi'); - -const regexTesters = [ - { runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) }, - { - runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => { - return parseInt(idString, 32); - }) - }, - { runInDev: false, regex: imageRegex, handler: imageHandler }, - { runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) }, - { runInDev: true, regex: imageRegex_DEV, handler: imageHandler }, - { runInDev: true, regex: postIDRegex, handler: postIdHandler }, - { runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') }, - { runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') }, - { runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') }, - { runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') }, - { runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') }, - { runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') }, - { runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') }, - { runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') }, - { runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') }, - { runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') }, - { runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler }, - { runInDev: true, regex: searchLinkRegex, handler: searchHandler }, - - { runInDev: true, regex: prRegex, handler: githubPullRequestHandler }, - { runInDev: true, regex: issueRegex, handler: githubIssueHandler }, -]; - -const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i; - -export async function handleMessageCreate(message: Message) { - if (message.author.bot) return; - if (message.inGuild()) await Database.putMessage(message); - - const responses: string[] = []; - - for (const test of regexTesters) { - if (config.DEV_MODE && !test.runInDev) continue; - - const hasMatches = test.regex.test(message.content); - test.regex.lastIndex = 0; - - if (hasMatches) { - const matches: RegExpExecArray[] = []; - let match: RegExpExecArray | null; - - while ((match = test.regex.exec(message.content)) != null) { - matches.push(match); - } - test.regex.lastIndex = 0; - - const response = await test.handler(message, matches.filter(uniqueRegexMatches)); - - if (response === false) return; - - if (response !== true) responses.push(response as string); - } - } - - for (const attachment of message.attachments.values()) { - const match = md5Regex.exec(attachment.name); - md5Regex.lastIndex = 0; - - const md5s: string[] = []; - - if (match) md5s.push(match[1]); - else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) { - const md5Data = await calculateMD5FromURL(attachment.url); - if (!md5Data) continue; - md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5); - } - - if (md5s.length == 0) continue; - - for (const md5 of md5s) { - const post = await getE621PostByMd5(md5); - - if (post) { - if (await blacklistIfNecessary(message, [post])) return; - - responses.push(`<${getPostUrl(post)}>`); - - continue; - } - } - } - - if (responses.length > 0) { - await message.reply(responses.join('\n')); - } -} - -export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) { - if (newMessage.author.bot) return; - - const loggedMessage = await Database.getMessageWithRetry(newMessage.id); - - if (!loggedMessage) { - if (newMessage.inGuild()) await Database.putMessage(newMessage); - - return; - } - - if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) { - await Database.putMessage(newMessage); - await logEdit(loggedMessage, newMessage); - } - - if (loggedMessage.content == newMessage.content) return; - - const responses: string[] = []; - - for (const test of regexTesters) { - if (config.DEV_MODE && !test.runInDev) continue; - - const hasMatches = test.regex.test(newMessage.content); - test.regex.lastIndex = 0; - - if (hasMatches) { - const oldMatches: RegExpExecArray[] = []; - const newMatches: RegExpExecArray[] = []; - let match: RegExpExecArray | null; - - while ((match = test.regex.exec(newMessage.content)) != null) { - newMatches.push(match); - } - test.regex.lastIndex = 0; - - while ((match = test.regex.exec(loggedMessage.content)) != null) { - oldMatches.push(match); - } - test.regex.lastIndex = 0; - - const properMatches: RegExpExecArray[] = []; - - for (const newMatch of newMatches) { - if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch); - } - - if (properMatches.length == 0) continue; - - const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches)); - - if (response === false) return; - - if (response !== true) responses.push(response as string); - } - } - - if (responses.length > 0) { - await newMessage.reply(responses.join('\n')); - } -} - -export async function handleMessageDelete(message: Message | PartialMessage) { - const loggedMessage = await Database.getMessageWithRetry(message.id); - - if (!loggedMessage) return; - - if (message.inGuild()) await logDeletion(loggedMessage, message); -} - -export async function handleBulkMessageDelete(messages: ReadonlyCollection, channel: GuildTextBasedChannel) { - for (const message of messages.values()) { - await handleMessageDelete(message); - } -} - -async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - let content = ''; - - for (const group of matchedGroups) { - content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`; - } - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - let content = ''; - - for (const group of matchedGroups) { - content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`; - } - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise { - const blacklistedIds: number[] = []; - - const channel = await message.channel.fetch() as GuildTextBasedChannel; - - const isStaffChannel = await channelIsInStaffCategory(channel); - - for (const post of posts) { - if (spoilerOrBlacklist(post).action == PostAction.Blacklist) { - blacklistedIds.push(post.id); - } - } - - if (blacklistedIds.length == 0) return false; - - await message.delete(); - - 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: { - users: [message.author.id] - } - }); - } else { - await message.channel.send({ - content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to young/cub content. See rule #5.b for more details.`, - allowedMentions: { - users: [message.author.id] - } - }); - } - - return true; -} - -async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - if (!message.guildId) return true; - - const posts: { post: E621Post, spoilered: boolean }[] = []; - - for (const match of matchedGroups) { - try { - const post = await getE621Post(match[1]); - if (post) posts.push({ - spoilered: isInSpoilerTags(message.content, match.index), - post - }); - } catch (e) { - console.error(e); - } - } - - if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false; - - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - const sfw = await channelIsSafe(message.channel as GuildBasedChannel); - - const content = posts.map((postData) => { - if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`; - - const shouldSpoiler = spoilerOrBlacklist(postData.post); - if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`; - - return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post); - }).join('\n'); - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise { - if (!message.guildId) return true; - - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n'); - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise { - if (!message.guildId) return true; - - const posts: E621Post[] = []; - - for (const match of matchedGroups) { - try { - const post = await getE621Post(transform ? transform(match[1]) : match[1]); - if (post) posts.push(post); - } catch (e) { - console.error(e); - } - } - - if (await blacklistIfNecessary(message, posts)) return false; - - return true; -} - -async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - if (!message.guildId) return true; - - const posts: E621Post[] = []; - - for (const match of matchedGroups) { - try { - const post = await getE621PostByMd5(match[1]); - if (post) posts.push(post); - } catch (e) { - console.error(e); - } - } - - if (await blacklistIfNecessary(message, posts)) return false; - - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n'); - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - let content = ''; - - for (const group of matchedGroups) { - content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`; - } - - if (content.trim().length > 0) return content.trim(); - - return true; -} - -async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { - const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); - - if (skip) return true; - - let content = ''; - - for (const group of matchedGroups) { - content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`; - } - - if (content.trim().length > 0) return content.trim(); - - return true; +import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js'; +import { config } from '../config'; +import { Database } from '../shared/Database'; +import { E621Post } from '../types'; +import { ALLOWED_MIMETYPES, artistIDRegex, blipIDRegex, calculateMD5FromURL, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, forumTopicIDRegex, getE621Post, getE621PostByMd5, getPostUrl, isEdited, isInSpoilerTags, issueRegex, logDeletion, logEdit, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '../utils'; + +export type Message = OmitPartialGroupDMChannel>; +export type Partial = OmitPartialGroupDMChannel; + +// TODO: I don't know of any good way to not hardcode this regex for e621 links. So I've provided two that may need to have the port altered. +const postRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+posts/+([0-9]+)', 'gi'); +const postShareRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+p/+([a-z0-9]+)', 'gi'); +const imageRegex = new RegExp('!?https?://(?:.*@)?static[0-9]*\\.(?:e621|e926)\\.net/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); + +const postRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+posts/+([0-9]+)', 'gi'); +const imageRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi'); + +const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi'); + +const regexTesters = [ + { runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) }, + { + runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => { + return parseInt(idString, 32); + }) + }, + { runInDev: false, regex: imageRegex, handler: imageHandler }, + { runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) }, + { runInDev: true, regex: imageRegex_DEV, handler: imageHandler }, + { runInDev: true, regex: postIDRegex, handler: postIdHandler }, + { runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') }, + { runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') }, + { runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') }, + { runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') }, + { runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') }, + { runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') }, + { runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') }, + { runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') }, + { runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') }, + { runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') }, + { runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler }, + { runInDev: true, regex: searchLinkRegex, handler: searchHandler }, + + { runInDev: true, regex: prRegex, handler: githubPullRequestHandler }, + { runInDev: true, regex: issueRegex, handler: githubIssueHandler }, +]; + +const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i; + +export async function handleMessageCreate(message: Message) { + if (message.author.bot) return; + if (message.inGuild()) await Database.putMessage(message); + + const responses: string[] = []; + + for (const test of regexTesters) { + if (config.DEV_MODE && !test.runInDev) continue; + + const hasMatches = test.regex.test(message.content); + test.regex.lastIndex = 0; + + if (hasMatches) { + const matches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + + while ((match = test.regex.exec(message.content)) != null) { + matches.push(match); + } + test.regex.lastIndex = 0; + + const response = await test.handler(message, matches.filter(uniqueRegexMatches)); + + if (response === false) return; + + if (response !== true) responses.push(response as string); + } + } + + for (const attachment of message.attachments.values()) { + const match = md5Regex.exec(attachment.name); + md5Regex.lastIndex = 0; + + const md5s: string[] = []; + + if (match) md5s.push(match[1]); + else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) { + const md5Data = await calculateMD5FromURL(attachment.url); + if (!md5Data) continue; + md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5); + } + + if (md5s.length == 0) continue; + + for (const md5 of md5s) { + const post = await getE621PostByMd5(md5); + + if (post) { + if (await blacklistIfNecessary(message, [post])) return; + + responses.push(`<${getPostUrl(post)}>`); + + continue; + } + } + } + + if (responses.length > 0) { + await message.reply(responses.join('\n')); + } +} + +export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) { + if (newMessage.author.bot) return; + + const loggedMessage = await Database.getMessageWithRetry(newMessage.id); + + if (!loggedMessage) { + if (newMessage.inGuild()) await Database.putMessage(newMessage); + + return; + } + + if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) { + await Database.putMessage(newMessage); + await logEdit(loggedMessage, newMessage); + } + + if (loggedMessage.content == newMessage.content) return; + + const responses: string[] = []; + + for (const test of regexTesters) { + if (config.DEV_MODE && !test.runInDev) continue; + + const hasMatches = test.regex.test(newMessage.content); + test.regex.lastIndex = 0; + + if (hasMatches) { + const oldMatches: RegExpExecArray[] = []; + const newMatches: RegExpExecArray[] = []; + let match: RegExpExecArray | null; + + while ((match = test.regex.exec(newMessage.content)) != null) { + newMatches.push(match); + } + test.regex.lastIndex = 0; + + while ((match = test.regex.exec(loggedMessage.content)) != null) { + oldMatches.push(match); + } + test.regex.lastIndex = 0; + + const properMatches: RegExpExecArray[] = []; + + for (const newMatch of newMatches) { + if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch); + } + + if (properMatches.length == 0) continue; + + const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches)); + + if (response === false) return; + + if (response !== true) responses.push(response as string); + } + } + + if (responses.length > 0) { + await newMessage.reply(responses.join('\n')); + } +} + +export async function handleMessageDelete(message: Message | PartialMessage) { + const loggedMessage = await Database.getMessageWithRetry(message.id); + + if (!loggedMessage) return; + + if (message.inGuild()) await logDeletion(loggedMessage, message); +} + +export async function handleBulkMessageDelete(messages: ReadonlyCollection, channel: GuildTextBasedChannel) { + for (const message of messages.values()) { + await handleMessageDelete(message); + } +} + +async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + let content = ''; + + for (const group of matchedGroups) { + content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`; + } + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + let content = ''; + + for (const group of matchedGroups) { + content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`; + } + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise { + const blacklistedIds: number[] = []; + + const channel = await message.channel.fetch() as GuildTextBasedChannel; + + const isStaffChannel = await channelIsInStaffCategory(channel); + + for (const post of posts) { + if (spoilerOrBlacklist(post).action == PostAction.Blacklist) { + blacklistedIds.push(post.id); + } + } + + if (blacklistedIds.length == 0) return false; + + await message.delete(); + + 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: { + users: [message.author.id] + } + }); + } else { + await message.channel.send({ + content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to young/cub content. See rule #5.b for more details.`, + allowedMentions: { + users: [message.author.id] + } + }); + } + + return true; +} + +async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + if (!message.guildId) return true; + + const posts: { post: E621Post, spoilered: boolean }[] = []; + + for (const match of matchedGroups) { + try { + const post = await getE621Post(match[1]); + if (post) posts.push({ + spoilered: isInSpoilerTags(message.content, match.index), + post + }); + } catch (e) { + console.error(e); + } + } + + if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false; + + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + const sfw = await channelIsSafe(message.channel as GuildBasedChannel); + + const content = posts.map((postData) => { + if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`; + + const shouldSpoiler = spoilerOrBlacklist(postData.post); + if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`; + + return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post); + }).join('\n'); + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise { + if (!message.guildId) return true; + + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n'); + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise { + if (!message.guildId) return true; + + const posts: E621Post[] = []; + + for (const match of matchedGroups) { + try { + const post = await getE621Post(transform ? transform(match[1]) : match[1]); + if (post) posts.push(post); + } catch (e) { + console.error(e); + } + } + + if (await blacklistIfNecessary(message, posts)) return false; + + return true; +} + +async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + if (!message.guildId) return true; + + const posts: E621Post[] = []; + + for (const match of matchedGroups) { + try { + const post = await getE621PostByMd5(match[1]); + if (post) posts.push(post); + } catch (e) { + console.error(e); + } + } + + if (await blacklistIfNecessary(message, posts)) return false; + + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n'); + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + let content = ''; + + for (const group of matchedGroups) { + content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`; + } + + if (content.trim().length > 0) return content.trim(); + + return true; +} + +async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise { + const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); + + if (skip) return true; + + let content = ''; + + for (const group of matchedGroups) { + content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`; + } + + if (content.trim().length > 0) return content.trim(); + + return true; } \ No newline at end of file diff --git a/src/events/handle-thread-create.ts b/src/events/handle-thread-create.ts index e1c047a..a3729da 100644 --- a/src/events/handle-thread-create.ts +++ b/src/events/handle-thread-create.ts @@ -1,11 +1,11 @@ -import { AnyThreadChannel } from 'discord.js'; - -export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) { - try { - await thread.join(); - } catch (e) { - console.error('Failed to join thread:'); - console.error(e); - } - +import { AnyThreadChannel } from 'discord.js'; + +export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) { + try { + await thread.join(); + } catch (e) { + console.error('Failed to join thread:'); + console.error(e); + } + } \ No newline at end of file diff --git a/src/events/handle-voice-state-update.ts b/src/events/handle-voice-state-update.ts index e42f8e3..61d6f2c 100644 --- a/src/events/handle-voice-state-update.ts +++ b/src/events/handle-voice-state-update.ts @@ -1,46 +1,46 @@ -import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { - // The logChannel declaration being inside is purposeful, as this event is fired a lot for users talking. - if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!); - } else if (oldState.channelId == null && newState.channelId != null) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendJoinMessage(logChannel, newState.member!, newState.channel!); - } else if (newState.channelId == null && oldState.channelId != null) { - const logChannel = await getVoiceLogsChannel(newState.guild); - if (!logChannel) return; - - await sendLeftMessage(logChannel, newState.member!, oldState.channel!); - } -} - -async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { - await channel.send(`${member} joined ${voiceChannel} at ${time()}`); -} - -async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { - await channel.send(`${member} left ${voiceChannel} at ${time()}`); -} - -async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) { - await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`); -} - -async function getVoiceLogsChannel(guild: Guild): Promise { - const settings = await Database.getGuildSettings(guild.id); - - if (!settings || !settings.voice_logs_channel_id) return; - - const channel = await guild.channels.fetch(settings.voice_logs_channel_id); - - if (!channel || !channel.isSendable()) return; - - return channel as GuildTextBasedChannel; +import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js'; +import { Database } from '../shared/Database'; + +export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { + // The logChannel declaration being inside is purposeful, as this event is fired a lot for users talking. + if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) { + const logChannel = await getVoiceLogsChannel(newState.guild); + if (!logChannel) return; + + await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!); + } else if (oldState.channelId == null && newState.channelId != null) { + const logChannel = await getVoiceLogsChannel(newState.guild); + if (!logChannel) return; + + await sendJoinMessage(logChannel, newState.member!, newState.channel!); + } else if (newState.channelId == null && oldState.channelId != null) { + const logChannel = await getVoiceLogsChannel(newState.guild); + if (!logChannel) return; + + await sendLeftMessage(logChannel, newState.member!, oldState.channel!); + } +} + +async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { + await channel.send(`${member} joined ${voiceChannel} at ${time()}`); +} + +async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { + await channel.send(`${member} left ${voiceChannel} at ${time()}`); +} + +async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) { + await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`); +} + +async function getVoiceLogsChannel(guild: Guild): Promise { + const settings = await Database.getGuildSettings(guild.id); + + if (!settings || !settings.voice_logs_channel_id) return; + + const channel = await guild.channels.fetch(settings.voice_logs_channel_id); + + if (!channel || !channel.isSendable()) return; + + return channel as GuildTextBasedChannel; } \ No newline at end of file diff --git a/src/events/index.ts b/src/events/index.ts index 87fe1ae..dca7ff2 100644 --- a/src/events/index.ts +++ b/src/events/index.ts @@ -1,7 +1,7 @@ -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'; -export * from './handle-thread-create'; -export * from './handle-voice-state-update'; +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'; +export * from './handle-thread-create'; +export * from './handle-voice-state-update'; diff --git a/src/modals/add-knowledgebase-item-modal.ts b/src/modals/add-knowledgebase-item-modal.ts index 6fba56d..74c4418 100644 --- a/src/modals/add-knowledgebase-item-modal.ts +++ b/src/modals/add-knowledgebase-item-modal.ts @@ -1,49 +1,49 @@ -import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { deferInteraction, logCustomEvent } from '../utils'; - -export default { - name: 'add-knowledgebase-item-modal', - handler: async function (client: Client, interaction: ModalSubmitInteraction) { - if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); - - await deferInteraction(interaction); - - const name = interaction.fields.getTextInputValue('name'); - const content = interaction.fields.getTextInputValue('content'); - - if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); - - const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name); - - if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`); - - logCustomEvent(interaction.guild!, { - title: 'Knowledgebase Item Added', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Name', - value: name, - inline: true - }, - { - name: 'Content', - value: content, - inline: true - } - ] - }); - - await Database.addToKnowledgebase(interaction.guild.id, name, content); - - return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`); - } +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { deferInteraction, logCustomEvent } from '../utils'; + +export default { + name: 'add-knowledgebase-item-modal', + handler: async function (client: Client, interaction: ModalSubmitInteraction) { + if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); + + await deferInteraction(interaction); + + const name = interaction.fields.getTextInputValue('name'); + const content = interaction.fields.getTextInputValue('content'); + + if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); + + const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name); + + if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`); + + logCustomEvent(interaction.guild!, { + title: 'Knowledgebase Item Added', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Name', + value: name, + inline: true + }, + { + name: 'Content', + value: content, + inline: true + } + ] + }); + + await Database.addToKnowledgebase(interaction.guild.id, name, content); + + return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`); + } }; \ No newline at end of file diff --git a/src/modals/add-note-modal.ts b/src/modals/add-note-modal.ts index 741521d..6347710 100644 --- a/src/modals/add-note-modal.ts +++ b/src/modals/add-note-modal.ts @@ -1,40 +1,40 @@ -import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { logCustomEvent } from '../utils'; - -export default { - name: 'add-note-modal', - handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { - const message = interaction.fields.getTextInputValue('note-message'); - - const user = await client.users.fetch(id); - - logCustomEvent(interaction.guild!, { - title: 'Note Added', - description: null, - color: 0x00FF00, - timestamp: new Date(), - fields: [ - { - name: 'Moderator', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'User', - value: `<@${id}>\n${user.username}`, - inline: true - }, - { - name: 'Note', - value: message, - inline: true - } - ] - }); - - await Database.putNote(id, message, interaction.user.id); - - interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' }); - } +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { logCustomEvent } from '../utils'; + +export default { + name: 'add-note-modal', + handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { + const message = interaction.fields.getTextInputValue('note-message'); + + const user = await client.users.fetch(id); + + logCustomEvent(interaction.guild!, { + title: 'Note Added', + description: null, + color: 0x00FF00, + timestamp: new Date(), + fields: [ + { + name: 'Moderator', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'User', + value: `<@${id}>\n${user.username}`, + inline: true + }, + { + name: 'Note', + value: message, + inline: true + } + ] + }); + + await Database.putNote(id, message, interaction.user.id); + + interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' }); + } }; \ No newline at end of file diff --git a/src/modals/edit-knowledgebase-item-modal.ts b/src/modals/edit-knowledgebase-item-modal.ts index 82943f4..52d612a 100644 --- a/src/modals/edit-knowledgebase-item-modal.ts +++ b/src/modals/edit-knowledgebase-item-modal.ts @@ -1,54 +1,54 @@ -import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { deferInteraction, logCustomEvent } from '../utils'; - -export default { - name: 'edit-knowledgebase-item-modal', - handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) { - if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); - - await deferInteraction(interaction); - - const id = parseInt(idString); - const content = interaction.fields.getTextInputValue('content'); - - const existingItem = await Database.getFromKnowledgebase(id); - - if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); - - if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); - - logCustomEvent(interaction.guild!, { - title: 'Knowledgebase Item Edited', - description: null, - color: 0xFFFF00, - timestamp: new Date(), - fields: [ - { - name: 'User', - value: `<@${interaction.user.id}>\n${interaction.user.username}`, - inline: true - }, - { - name: 'Name', - value: existingItem.name, - inline: true - }, - { - name: 'Old Content', - value: existingItem.content, - inline: true - }, - { - name: 'New Content', - value: content, - inline: true - } - ] - }); - - await Database.editKnowledgebaseItem(id, content); - - return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`); - } +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { deferInteraction, logCustomEvent } from '../utils'; + +export default { + name: 'edit-knowledgebase-item-modal', + handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) { + if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); + + await deferInteraction(interaction); + + const id = parseInt(idString); + const content = interaction.fields.getTextInputValue('content'); + + const existingItem = await Database.getFromKnowledgebase(id); + + if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); + + if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); + + logCustomEvent(interaction.guild!, { + title: 'Knowledgebase Item Edited', + description: null, + color: 0xFFFF00, + timestamp: new Date(), + fields: [ + { + name: 'User', + value: `<@${interaction.user.id}>\n${interaction.user.username}`, + inline: true + }, + { + name: 'Name', + value: existingItem.name, + inline: true + }, + { + name: 'Old Content', + value: existingItem.content, + inline: true + }, + { + name: 'New Content', + value: content, + inline: true + } + ] + }); + + await Database.editKnowledgebaseItem(id, content); + + return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`); + } }; \ No newline at end of file diff --git a/src/modals/open-mod-ticket.ts b/src/modals/open-mod-ticket.ts index 604388d..04af994 100644 --- a/src/modals/open-mod-ticket.ts +++ b/src/modals/open-mod-ticket.ts @@ -1,31 +1,31 @@ -import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { createPrivateHelpTicketThread } from '../utils'; - -const warning = '\n\n\nLeaving this thread without acknowledgement may result in punishment. Staff will close the thread when they deem your response acceptable.'; - -export default { - name: 'open-mod-ticket', - handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) { - const guild = await client.guilds.fetch(interaction.guildId!); - const member = await guild.members.fetch(userId); - - const guildSettings = await Database.getGuildSettings(guild.id); - - if (!guildSettings || !guildSettings.private_help_channel_id) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' }); - - const title = interaction.fields.getTextInputValue('title') ? interaction.fields.getTextInputValue('title') : `Mod Ticket For ${member.displayName}`; - const reason = interaction.fields.getTextInputValue('initial-message') + warning; - const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes'; - - const membersToAdd = [userId]; - - if (autoJoin) membersToAdd.push(interaction.user.id); - - const thread = await createPrivateHelpTicketThread(client, guild, null, reason, title, membersToAdd); - - if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Mod ticket created: ${thread}.${!autoJoin ? "You've selected not to auto join the thread. You will not be notified of messages sent there. You will have to check the thread periodically for user response." : ''}` }); - else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' }); - } +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { createPrivateHelpTicketThread } from '../utils'; + +const warning = '\n\n\nLeaving this thread without acknowledgement may result in punishment. Staff will close the thread when they deem your response acceptable.'; + +export default { + name: 'open-mod-ticket', + handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) { + const guild = await client.guilds.fetch(interaction.guildId!); + const member = await guild.members.fetch(userId); + + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_channel_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' }); + + const title = interaction.fields.getTextInputValue('title') ? interaction.fields.getTextInputValue('title') : `Mod Ticket For ${member.displayName}`; + const reason = interaction.fields.getTextInputValue('initial-message') + warning; + const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes'; + + const membersToAdd = [userId]; + + if (autoJoin) membersToAdd.push(interaction.user.id); + + const thread = await createPrivateHelpTicketThread(client, guild, null, reason, title, membersToAdd); + + if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Mod ticket created: ${thread}.${!autoJoin ? "You've selected not to auto join the thread. You will not be notified of messages sent there. You will have to check the thread periodically for user response." : ''}` }); + else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' }); + } }; \ No newline at end of file diff --git a/src/modals/open-ticket-modal.ts b/src/modals/open-ticket-modal.ts index 16d9306..75f8fcf 100644 --- a/src/modals/open-ticket-modal.ts +++ b/src/modals/open-ticket-modal.ts @@ -1,23 +1,23 @@ -import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { createPrivateHelpTicketThread } from '../utils'; - -export default { - name: 'open-ticket-modal', - handler: async function (client: Client, interaction: ModalSubmitInteraction) { - const guild = await client.guilds.fetch(interaction.guildId!); - const member = await guild.members.fetch(interaction.user.id); - - const guildSettings = await Database.getGuildSettings(guild.id); - - if (!guildSettings || !guildSettings.private_help_role_id) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' }); - - const reason = interaction.fields.getTextInputValue('ticket-message'); - - const thread = await createPrivateHelpTicketThread(client, guild, member, reason); - - if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` }); - else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' }); - } +import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { createPrivateHelpTicketThread } from '../utils'; + +export default { + name: 'open-ticket-modal', + handler: async function (client: Client, interaction: ModalSubmitInteraction) { + const guild = await client.guilds.fetch(interaction.guildId!); + const member = await guild.members.fetch(interaction.user.id); + + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_role_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' }); + + const reason = interaction.fields.getTextInputValue('ticket-message'); + + const thread = await createPrivateHelpTicketThread(client, guild, member, reason); + + if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` }); + else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' }); + } }; \ No newline at end of file diff --git a/src/modals/report-message.ts b/src/modals/report-message.ts index 9240af3..139b193 100644 --- a/src/modals/report-message.ts +++ b/src/modals/report-message.ts @@ -1,118 +1,118 @@ -import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils'; - -export default { - name: 'report-message', - handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) { - await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - - const guild = await client.guilds.fetch(interaction.guildId!); - const member = await guild.members.fetch(interaction.user.id); - const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel; - const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId); - - const additionalInfo = interaction.fields.getTextInputValue('additional-info'); - const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes'; - - if (!reportedMessageChannel || !reportedMessage) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' }); - - const guildSettings = await Database.getGuildSettings(interaction.guildId!); - - if (!guildSettings || !guildSettings.moderator_channel_id) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); - - const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id); - - if (!reportsChannel || !reportsChannel.isSendable()) - return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); - - const embed = new EmbedBuilder() - .setTitle('New Message Report!') - .setColor(0xFF0000) - .addFields( - { - name: 'Message', - value: reportedMessage.url, - inline: false - }, - { - name: 'Message Author', - value: reportedMessage.author.toString(), - inline: false - }, - { - name: 'Reporter', - value: member.toString(), - inline: false - }); - - if (additionalInfo) { - embed.addFields( - { - name: 'Additional Information', - value: additionalInfo, - inline: false - } - ); - } - - let replyContent = "Thanks for making a report! I've notified the moderators who can take further action."; - - const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id); - - if (wantsTicketButCantOpen) { - embed.addFields( - { - name: 'User Requested Private Ticket', - value: 'The user requested a private ticket be opened, but already has an open ticket. If additional information is needed, press the button below to open a ticket with the user.', - inline: false - } - ); - - replyContent += " Could not open private help ticket since you already have one open. Staff have been notified that you'd like to have a ticket opened, and can make one for you if they deem it necessary."; - } - - const openTicketButton = new ButtonBuilder() - .setCustomId('open-ticket-for-reported-message') - .setLabel('Open Private Ticket') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(openTicketButton); - - const reportMessage = await reportsChannel.send({ - embeds: [embed], - components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row] - }); - - await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] }); - - if (createPrivateHelpTicket && !wantsTicketButCantOpen) { - if (!guildSettings.private_help_channel_id) { - replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.'; - } else { - const thread = await createPrivateHelpTicketThread(client, guild, member, `Ticket created with message report (${reportMessage.url}). ${member}, use this channel to talk with staff privately about the reported message (${reportedMessage.url}). ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`); - if (thread) { - replyContent += ` Private help ticket created: ${thread}.`; - embed.addFields({ - name: 'Private Help Ticket', - value: thread.url, - inline: false - }); - - await reportMessage.edit({ embeds: [embed] }); - - } else { - replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.'; - await reportMessage.edit({ - embeds: [embed], - components: [row] - }); - } - } - } - - await interaction.editReply(replyContent); - } +import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils'; + +export default { + name: 'report-message', + handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + const guild = await client.guilds.fetch(interaction.guildId!); + const member = await guild.members.fetch(interaction.user.id); + const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel; + const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId); + + const additionalInfo = interaction.fields.getTextInputValue('additional-info'); + const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes'; + + if (!reportedMessageChannel || !reportedMessage) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' }); + + const guildSettings = await Database.getGuildSettings(interaction.guildId!); + + if (!guildSettings || !guildSettings.moderator_channel_id) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); + + const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id); + + if (!reportsChannel || !reportsChannel.isSendable()) + return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); + + const embed = new EmbedBuilder() + .setTitle('New Message Report!') + .setColor(0xFF0000) + .addFields( + { + name: 'Message', + value: reportedMessage.url, + inline: false + }, + { + name: 'Message Author', + value: reportedMessage.author.toString(), + inline: false + }, + { + name: 'Reporter', + value: member.toString(), + inline: false + }); + + if (additionalInfo) { + embed.addFields( + { + name: 'Additional Information', + value: additionalInfo, + inline: false + } + ); + } + + let replyContent = "Thanks for making a report! I've notified the moderators who can take further action."; + + const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id); + + if (wantsTicketButCantOpen) { + embed.addFields( + { + name: 'User Requested Private Ticket', + value: 'The user requested a private ticket be opened, but already has an open ticket. If additional information is needed, press the button below to open a ticket with the user.', + inline: false + } + ); + + replyContent += " Could not open private help ticket since you already have one open. Staff have been notified that you'd like to have a ticket opened, and can make one for you if they deem it necessary."; + } + + const openTicketButton = new ButtonBuilder() + .setCustomId('open-ticket-for-reported-message') + .setLabel('Open Private Ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(openTicketButton); + + const reportMessage = await reportsChannel.send({ + embeds: [embed], + components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row] + }); + + await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] }); + + if (createPrivateHelpTicket && !wantsTicketButCantOpen) { + if (!guildSettings.private_help_channel_id) { + replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.'; + } else { + const thread = await createPrivateHelpTicketThread(client, guild, member, `Ticket created with message report (${reportMessage.url}). ${member}, use this channel to talk with staff privately about the reported message (${reportedMessage.url}). ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`); + if (thread) { + replyContent += ` Private help ticket created: ${thread}.`; + embed.addFields({ + name: 'Private Help Ticket', + value: thread.url, + inline: false + }); + + await reportMessage.edit({ embeds: [embed] }); + + } else { + replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.'; + await reportMessage.edit({ + embeds: [embed], + components: [row] + }); + } + } + } + + await interaction.editReply(replyContent); + } }; \ No newline at end of file diff --git a/src/modals/sync-name-modal.ts b/src/modals/sync-name-modal.ts index b80e75b..27e9f65 100644 --- a/src/modals/sync-name-modal.ts +++ b/src/modals/sync-name-modal.ts @@ -1,34 +1,34 @@ -import { Client, ModalSubmitInteraction } from 'discord.js'; -import { config } from '../config'; -import { deferInteraction, syncName } from '../utils'; - -export default { - name: 'sync-name-modal', - handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { - await deferInteraction(interaction); - - const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0); - - console.log(e621Id); - - if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number'); - - const member = await interaction.guild?.members.fetch(id); - - if (!member) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); - - if (!guild) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - if (!guild.members.me) { - return interaction.editReply('An error has occurred. Please try again later.'); - } - - await syncName(interaction, member, e621Id); - } +import { Client, ModalSubmitInteraction } from 'discord.js'; +import { config } from '../config'; +import { deferInteraction, syncName } from '../utils'; + +export default { + name: 'sync-name-modal', + handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { + await deferInteraction(interaction); + + const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0); + + console.log(e621Id); + + if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number'); + + const member = await interaction.guild?.members.fetch(id); + + if (!member) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); + + if (!guild) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + if (!guild.members.me) { + return interaction.editReply('An error has occurred. Please try again later.'); + } + + await syncName(interaction, member, e621Id); + } }; \ No newline at end of file diff --git a/src/shared/Database.ts b/src/shared/Database.ts index 0672ce2..71bc95a 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -1,484 +1,484 @@ -import sqlite3 from 'sqlite3'; -import { open, Database as SqliteDatabase } from 'sqlite'; -import { serializeMessage, wait } from '../utils'; -import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types'; -import { Message } from '../events'; - -const DB_SCHEMA = ` - CREATE TABLE IF NOT EXISTS discord_names ( - id INTEGER PRIMARY KEY, - user_id INTEGER NOT NULL, - discord_id TEXT NOT NULL, - discord_username TEXT NOT NULL, - added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime')) - ); - - CREATE TABLE IF NOT EXISTS settings ( - guild_id TEXT PRIMARY KEY, - general_chat_id TEXT, - new_member_channel_id TEXT, - tickets_channel_id TEXT, - event_logs_channel_id TEXT, - discord_logs_channel_id TEXT, - audit_logs_channel_id TEXT, - voice_logs_channel_id TEXT, - admin_role_id TEXT, - private_help_role_id TEXT, - devwatch_role_id TEXT, - staff_categories TEXT, - safe_channels TEXT, - link_skip_channels TEXT, - github_release_channel TEXT, - moderator_channel_id TEXT, - private_help_channel_id TEXT - ); - - CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY ON CONFLICT REPLACE, - author_id TEXT NOT NULL, - author_name TEXT NOT NULL, - channel_id TEXT NOT NULL, - attachments TEXT NOT NULL, - stickers TEXT NOT NULL, - content TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id); - - CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id); - - CREATE TABLE IF NOT EXISTS tickets ( - id INTEGER PRIMARY KEY, - message_id TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS ticket_phrases ( - id INTEGER PRIMARY KEY, - user_id TEXT NOT NULL, - phrase TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS notes ( - id INTEGER PRIMARY KEY, - user_id TEXT, - reason TEXT, - mod_id TEXT, - timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) - ); - - CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id); - - CREATE TABLE IF NOT EXISTS note_edits ( - id INTEGER PRIMARY KEY, - note_id INTEGER, - mod_id TEXT, - previous_reason TEXT, - timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')), - FOREIGN KEY(note_id) REFERENCES notes(id) - ); - - CREATE TABLE IF NOT EXISTS bans ( - id INTEGER PRIMARY KEY, - user_id TEXT, - expires INTEGER, - expires_at datetime, - full_ban INTEGER - ); - - CREATE TABLE IF NOT EXISTS github_user_mapping ( - id INTEGER PRIMARY KEY, - discord_id TEXT, - github_username TEXT - ); - - CREATE TABLE IF NOT EXISTS knowledgebase ( - id INTEGER PRIMARY KEY, - guild_id TEXT NOT NULL, - name TEXT NOT NULL, - content TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS private_help_tickets ( - id INTEGER PRIMARY KEY, - thread_id TEXT NOT NULL, - user_id TEXT NOT NULL, - status INTEGER NOT NULL, - timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) - ); - - CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp); -`; - -export const enum PrivateHelpTicketStatus { - OPEN = 0, - CLOSED = 1 -} - -export class Database { - private static db: SqliteDatabase; - - static async open(file: string): Promise { - if (Database.db) return; - - Database.db = await open({ - filename: file, - driver: sqlite3.Database - }); - - console.log('SQLite database opened'); - - await Database.ensure(); - } - - private static async ensure() { - await Database.db.exec(DB_SCHEMA); - 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); - - return ids.map(r => r.user_id); - } - - static async getDiscordIds(e621Id: string | number): Promise { - // Perhaps this would be better and then we can return all the data: SELECT * FROM (SELECT * FROM discord_names WHERE user_id = ? ORDER BY id DESC) GROUP BY discord_id; - const ids = await Database.db.all<{ discord_id: string }[]>('SELECT DISTINCT discord_id FROM discord_names WHERE user_id = ?', e621Id); - - return ids.map(r => r.discord_id); - } - - 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 = ? - UNION - SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec - LEFT OUTER JOIN discord_names d2 ON rec.discord_id = d2.discord_id - LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id - WHERE depth <= 5 AND rec.depth = depth - ) SELECT DISTINCT user_id, discord_id FROM rec`, id, id); - - return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id })); - } - - static async putUser(id: number, user: { id: string, username: string }) { - await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username); - } - - static async removeUser(id: number, discordId: string) { - await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId); - } - - // -- END WHOIS -- - - // -- START SETTINGS -- - - static async getGuildSettings(guildId: string): Promise { - return await Database.db.get('SELECT * FROM settings WHERE guild_id = ?', guildId); - } - - static async putGuild(guildId: string) { - await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId); - } - - static async setGuildGeneralChatId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildTicketsLogsChannelId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildEventsLogsChannelId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildDiscordLogsChannelId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildAuditLogsChannelId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildVoiceLogsChannelId(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildNewMemberLogsChannel(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildModeratorChannel(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildAdminRole(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildPrivateHelperRole(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildDevWatchRole(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId); - } - - static async setGuildGithubReleaseChannel(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId); - } - - static async setPrivateHelpChannel(guildId: string, id: string) { - await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId); - } - - // Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues. - // But it does allow me to skip rewriting this a bunch. - static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise { - const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId); - - if (!settings || !settings[setting]) return []; - - return settings[setting].split(','); - } - - static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) { - const values = await Database.getGuildArraySetting(setting, guildId); - - if (values.indexOf(value) == -1) values.push(value); - - const newString = values.join(','); - - await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); - } - - static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise { - const values = await Database.getGuildArraySetting(setting, guildId); - - const index = values.indexOf(value); - if (index == -1) return false; - - values.splice(index, 1); - - const newString = values.join(','); - - await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); - - return true; - } - - // -- END SETTINGS -- - - // START MESSAGE LOGS -- - - static async putMessage(message: Message): Promise { - try { - const serializedMessage = serializeMessage(message); - - await Database.db.run(` - INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES - (:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content) - `, ...serializedMessage); - - return true; - } catch (e) { - console.error(e); - return false; - } - } - - static async getMessage(id: string): Promise { - return await Database.db.get('SELECT * FROM messages WHERE id = ?', id); - } - - static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise { - let tried = 0; - while (tried < retries) { - tried++; - const message = await Database.db.get('SELECT * FROM messages WHERE id = ?', id); - - if (message) return message; - - await wait(delay); - } - } - - // -- 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); - } - - static async removeTicket(ticketId: number) { - await Database.db.run('DELETE from tickets WHERE id = ?', ticketId); - } - - static async getTicketMessageId(ticketId: number): Promise { - const ticket = await Database.db.get>('SELECT message_id FROM tickets WHERE id = ?', ticketId); - return ticket?.message_id; - } - - static async putTicketPhrase(userId: string, phrase: string) { - await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase); - } - - static async getTicketPhrase(id: number): Promise { - return await Database.db.get('SELECT * FROM ticket_phrases WHERE id = ?', id); - } - - static async removeTicketPhrase(id: number) { - await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id); - } - - static async removeAllTicketPhrasesFor(id: string): Promise { - return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!; - } - - static async getTicketPhrasesFor(userId: string): Promise { - return await Database.db.all('SELECT * from ticket_phrases WHERE user_id = ?', userId); - } - - static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) { - await Database.db.each('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => { - if (err) return console.error(err); - - cb(ticketPhrase); - }); - } - - // -- END TICKETS -- - - // -- START NOTES -- - - static async putNote(userId: string, reason: string, modId: string) { - await Database.db.run('INSERT INTO notes(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId); - } - - static async editNote(id: number, oldReason: string, newReason: string, modId: string) { - await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, modId, id); - await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason); - } - - static async removeNote(id: number): Promise { - const res = await Database.db.run('DELETE from notes WHERE id = ?', id); - - return (res.changes ?? 0) > 0; - } - - static async getNotes(userId: string): Promise { - return await Database.db.all('SELECT * from notes WHERE user_id = ?', userId); - } - - // -- END NOTES -- - - // -- START BANS -- - - static async putBan(userId: string, expiresAt: Date | null, fullBan = false) { - await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan); - } - - static async getBan(userId: string): Promise { - return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId); - } - - static async getExpiredBans(date: Date): Promise { - return await Database.db.all('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); - } - - static async pruneExpiredBans(date: Date) { - await Database.db.all('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date); - } - - static async removeBan(userId: string) { - await Database.db.run('DELETE from bans WHERE user_id = ?', userId); - } - - // -- END BANS -- - - // -- START GITHUB USER MAPPING -- - - // github_user_mapping - static async putGithubUserMapping(discordId: string, githubUsername: string) { - await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername); - } - - static async getDiscordIdFromGithub(githubUsername: string): Promise { - const mapping = await Database.db.get>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername); - - return mapping?.discord_id ?? null; - } - - static async getGithubFromDiscordId(discordId: string): Promise { - const mapping = await Database.db.get>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId); - - return mapping?.github_username ?? null; - } - - static async getAllGithubUserMappings(): Promise { - return await Database.db.all('SELECT * from github_user_mapping'); - } - - static async removeGithubUserMapping(discordId: string) { - await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId); - } - - // -- END GITHUB USER MAPPING -- - - // -- START KNOWLEDGEBASE -- - - static async addToKnowledgebase(guildId: string, name: string, content: string) { - if (content.length > 2000) return; - - await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content); - } - - static async removeFromKnowledgebase(id: number) { - await Database.db.run('DELETE from knowledgebase WHERE id = ?', id); - } - - static async editKnowledgebaseItem(id: number, content: string) { - if (content.length > 2000) return; - - await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id); - } - - static async getFromKnowledgebaseByName(guildId: string, name: string): Promise { - return await Database.db.get('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name); - } - - static async getFromKnowledgebase(id: number): Promise { - return await Database.db.get('SELECT * from knowledgebase WHERE id = ?', id); - } - - static async getAllKnowledgebaseItems(guildId: string): Promise { - return await Database.db.all('SELECT * from knowledgebase WHERE guild_id = ?', guildId); - } - - // -- END KNOWLEDGEBASE -- - - // -- START PRIVATE HELP TICKETS -- - - static async createPrivateHelpTicket(userId: string, threadId: string) { - await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN); - } - - static async closePrivateHelpTicket(threadId: string) { - await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId); - } - - static async getLatestPrivateHelpTicketBy(userId: string): Promise { - return await Database.db.get('SELECT * from private_help_tickets WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1', userId); - } - - static async getAllOpenPrivateHelpTickets(): Promise { - return await Database.db.all('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN); - } - - // -- END PRIVATE HELP TICKETS +import sqlite3 from 'sqlite3'; +import { open, Database as SqliteDatabase } from 'sqlite'; +import { serializeMessage, wait } from '../utils'; +import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types'; +import { Message } from '../events'; + +const DB_SCHEMA = ` + CREATE TABLE IF NOT EXISTS discord_names ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + discord_id TEXT NOT NULL, + discord_username TEXT NOT NULL, + added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS settings ( + guild_id TEXT PRIMARY KEY, + general_chat_id TEXT, + new_member_channel_id TEXT, + tickets_channel_id TEXT, + event_logs_channel_id TEXT, + discord_logs_channel_id TEXT, + audit_logs_channel_id TEXT, + voice_logs_channel_id TEXT, + admin_role_id TEXT, + private_help_role_id TEXT, + devwatch_role_id TEXT, + staff_categories TEXT, + safe_channels TEXT, + link_skip_channels TEXT, + github_release_channel TEXT, + moderator_channel_id TEXT, + private_help_channel_id TEXT + ); + + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY ON CONFLICT REPLACE, + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + channel_id TEXT NOT NULL, + attachments TEXT NOT NULL, + stickers TEXT NOT NULL, + content TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id); + + CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id); + + CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY, + message_id TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ticket_phrases ( + id INTEGER PRIMARY KEY, + user_id TEXT NOT NULL, + phrase TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY, + user_id TEXT, + reason TEXT, + mod_id TEXT, + timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id); + + CREATE TABLE IF NOT EXISTS note_edits ( + id INTEGER PRIMARY KEY, + note_id INTEGER, + mod_id TEXT, + previous_reason TEXT, + timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')), + FOREIGN KEY(note_id) REFERENCES notes(id) + ); + + CREATE TABLE IF NOT EXISTS bans ( + id INTEGER PRIMARY KEY, + user_id TEXT, + expires INTEGER, + expires_at datetime, + full_ban INTEGER + ); + + CREATE TABLE IF NOT EXISTS github_user_mapping ( + id INTEGER PRIMARY KEY, + discord_id TEXT, + github_username TEXT + ); + + CREATE TABLE IF NOT EXISTS knowledgebase ( + id INTEGER PRIMARY KEY, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS private_help_tickets ( + id INTEGER PRIMARY KEY, + thread_id TEXT NOT NULL, + user_id TEXT NOT NULL, + status INTEGER NOT NULL, + timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp); +`; + +export const enum PrivateHelpTicketStatus { + OPEN = 0, + CLOSED = 1 +} + +export class Database { + private static db: SqliteDatabase; + + static async open(file: string): Promise { + if (Database.db) return; + + Database.db = await open({ + filename: file, + driver: sqlite3.Database + }); + + console.log('SQLite database opened'); + + await Database.ensure(); + } + + private static async ensure() { + await Database.db.exec(DB_SCHEMA); + 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); + + return ids.map(r => r.user_id); + } + + static async getDiscordIds(e621Id: string | number): Promise { + // Perhaps this would be better and then we can return all the data: SELECT * FROM (SELECT * FROM discord_names WHERE user_id = ? ORDER BY id DESC) GROUP BY discord_id; + const ids = await Database.db.all<{ discord_id: string }[]>('SELECT DISTINCT discord_id FROM discord_names WHERE user_id = ?', e621Id); + + return ids.map(r => r.discord_id); + } + + 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 = ? + UNION + SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec + LEFT OUTER JOIN discord_names d2 ON rec.discord_id = d2.discord_id + LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id + WHERE depth <= 5 AND rec.depth = depth + ) SELECT DISTINCT user_id, discord_id FROM rec`, id, id); + + return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id })); + } + + static async putUser(id: number, user: { id: string, username: string }) { + await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username); + } + + static async removeUser(id: number, discordId: string) { + await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId); + } + + // -- END WHOIS -- + + // -- START SETTINGS -- + + static async getGuildSettings(guildId: string): Promise { + return await Database.db.get('SELECT * FROM settings WHERE guild_id = ?', guildId); + } + + static async putGuild(guildId: string) { + await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId); + } + + static async setGuildGeneralChatId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildTicketsLogsChannelId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildEventsLogsChannelId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildDiscordLogsChannelId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildAuditLogsChannelId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildVoiceLogsChannelId(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildNewMemberLogsChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildModeratorChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildAdminRole(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildPrivateHelperRole(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildDevWatchRole(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId); + } + + static async setGuildGithubReleaseChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId); + } + + static async setPrivateHelpChannel(guildId: string, id: string) { + await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId); + } + + // Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues. + // But it does allow me to skip rewriting this a bunch. + static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise { + const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId); + + if (!settings || !settings[setting]) return []; + + return settings[setting].split(','); + } + + static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) { + const values = await Database.getGuildArraySetting(setting, guildId); + + if (values.indexOf(value) == -1) values.push(value); + + const newString = values.join(','); + + await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); + } + + static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise { + const values = await Database.getGuildArraySetting(setting, guildId); + + const index = values.indexOf(value); + if (index == -1) return false; + + values.splice(index, 1); + + const newString = values.join(','); + + await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); + + return true; + } + + // -- END SETTINGS -- + + // START MESSAGE LOGS -- + + static async putMessage(message: Message): Promise { + try { + const serializedMessage = serializeMessage(message); + + await Database.db.run(` + INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES + (:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content) + `, ...serializedMessage); + + return true; + } catch (e) { + console.error(e); + return false; + } + } + + static async getMessage(id: string): Promise { + return await Database.db.get('SELECT * FROM messages WHERE id = ?', id); + } + + static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise { + let tried = 0; + while (tried < retries) { + tried++; + const message = await Database.db.get('SELECT * FROM messages WHERE id = ?', id); + + if (message) return message; + + await wait(delay); + } + } + + // -- 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); + } + + static async removeTicket(ticketId: number) { + await Database.db.run('DELETE from tickets WHERE id = ?', ticketId); + } + + static async getTicketMessageId(ticketId: number): Promise { + const ticket = await Database.db.get>('SELECT message_id FROM tickets WHERE id = ?', ticketId); + return ticket?.message_id; + } + + static async putTicketPhrase(userId: string, phrase: string) { + await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase); + } + + static async getTicketPhrase(id: number): Promise { + return await Database.db.get('SELECT * FROM ticket_phrases WHERE id = ?', id); + } + + static async removeTicketPhrase(id: number) { + await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id); + } + + static async removeAllTicketPhrasesFor(id: string): Promise { + return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!; + } + + static async getTicketPhrasesFor(userId: string): Promise { + return await Database.db.all('SELECT * from ticket_phrases WHERE user_id = ?', userId); + } + + static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) { + await Database.db.each('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => { + if (err) return console.error(err); + + cb(ticketPhrase); + }); + } + + // -- END TICKETS -- + + // -- START NOTES -- + + static async putNote(userId: string, reason: string, modId: string) { + await Database.db.run('INSERT INTO notes(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId); + } + + static async editNote(id: number, oldReason: string, newReason: string, modId: string) { + await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, modId, id); + await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason); + } + + static async removeNote(id: number): Promise { + const res = await Database.db.run('DELETE from notes WHERE id = ?', id); + + return (res.changes ?? 0) > 0; + } + + static async getNotes(userId: string): Promise { + return await Database.db.all('SELECT * from notes WHERE user_id = ?', userId); + } + + // -- END NOTES -- + + // -- START BANS -- + + static async putBan(userId: string, expiresAt: Date | null, fullBan = false) { + await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan); + } + + static async getBan(userId: string): Promise { + return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId); + } + + static async getExpiredBans(date: Date): Promise { + return await Database.db.all('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); + } + + static async pruneExpiredBans(date: Date) { + await Database.db.all('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date); + } + + static async removeBan(userId: string) { + await Database.db.run('DELETE from bans WHERE user_id = ?', userId); + } + + // -- END BANS -- + + // -- START GITHUB USER MAPPING -- + + // github_user_mapping + static async putGithubUserMapping(discordId: string, githubUsername: string) { + await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername); + } + + static async getDiscordIdFromGithub(githubUsername: string): Promise { + const mapping = await Database.db.get>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername); + + return mapping?.discord_id ?? null; + } + + static async getGithubFromDiscordId(discordId: string): Promise { + const mapping = await Database.db.get>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId); + + return mapping?.github_username ?? null; + } + + static async getAllGithubUserMappings(): Promise { + return await Database.db.all('SELECT * from github_user_mapping'); + } + + static async removeGithubUserMapping(discordId: string) { + await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId); + } + + // -- END GITHUB USER MAPPING -- + + // -- START KNOWLEDGEBASE -- + + static async addToKnowledgebase(guildId: string, name: string, content: string) { + if (content.length > 2000) return; + + await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content); + } + + static async removeFromKnowledgebase(id: number) { + await Database.db.run('DELETE from knowledgebase WHERE id = ?', id); + } + + static async editKnowledgebaseItem(id: number, content: string) { + if (content.length > 2000) return; + + await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id); + } + + static async getFromKnowledgebaseByName(guildId: string, name: string): Promise { + return await Database.db.get('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name); + } + + static async getFromKnowledgebase(id: number): Promise { + return await Database.db.get('SELECT * from knowledgebase WHERE id = ?', id); + } + + static async getAllKnowledgebaseItems(guildId: string): Promise { + return await Database.db.all('SELECT * from knowledgebase WHERE guild_id = ?', guildId); + } + + // -- END KNOWLEDGEBASE -- + + // -- START PRIVATE HELP TICKETS -- + + static async createPrivateHelpTicket(userId: string, threadId: string) { + await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN); + } + + static async closePrivateHelpTicket(threadId: string) { + await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId); + } + + static async getLatestPrivateHelpTicketBy(userId: string): Promise { + return await Database.db.get('SELECT * from private_help_tickets WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1', userId); + } + + static async getAllOpenPrivateHelpTickets(): Promise { + return await Database.db.all('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN); + } + + // -- END PRIVATE HELP TICKETS } \ No newline at end of file diff --git a/src/shared/RedisClient.ts b/src/shared/RedisClient.ts index 15f5851..dcd317b 100644 --- a/src/shared/RedisClient.ts +++ b/src/shared/RedisClient.ts @@ -1,50 +1,50 @@ -import { createClient, SocketClosedUnexpectedlyError } from '@redis/client'; -import { Client } from 'discord.js'; -import { banUpdateHandler, ticketUpdateHandler } from '../utils'; - -let discordClient: Client; - -export async function openRedisClient(url: string, discClient: Client) { - const client = await createClient({ - url: `redis://${url}`, - socket: { - reconnectStrategy: 60000 - } - }); - - client.on('error', (error) => { - if (error.code == 'ECONNREFUSED') { - console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)"); - } else if (error instanceof SocketClosedUnexpectedlyError) { - console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.'); - } else { - console.error('Redis error:'); - console.error(error); - } - }); - - client.on('connect', () => { - console.log('Connected to redis database'); - }); - - client.on('reconnecting', () => { - console.log('Attempting to reconnect to redis database'); - }); - - client.once('connect', () => { - client.subscribe(['ticket_updates', 'ban_updates'], updateHandler); - }); - - client.connect(); - - discordClient = discClient; -} - -function updateHandler(data: string, channel: string) { - switch (channel) { - case 'ticket_updates': - return ticketUpdateHandler(discordClient, data); - case 'ban_updates': - return banUpdateHandler(discordClient, data); - } +import { createClient, SocketClosedUnexpectedlyError } from '@redis/client'; +import { Client } from 'discord.js'; +import { banUpdateHandler, ticketUpdateHandler } from '../utils'; + +let discordClient: Client; + +export async function openRedisClient(url: string, discClient: Client) { + const client = await createClient({ + url: `redis://${url}`, + socket: { + reconnectStrategy: 60000 + } + }); + + client.on('error', (error) => { + if (error.code == 'ECONNREFUSED') { + console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)"); + } else if (error instanceof SocketClosedUnexpectedlyError) { + console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.'); + } else { + console.error('Redis error:'); + console.error(error); + } + }); + + client.on('connect', () => { + console.log('Connected to redis database'); + }); + + client.on('reconnecting', () => { + console.log('Attempting to reconnect to redis database'); + }); + + client.once('connect', () => { + client.subscribe(['ticket_updates', 'ban_updates'], updateHandler); + }); + + client.connect(); + + discordClient = discClient; +} + +function updateHandler(data: string, channel: string) { + switch (channel) { + case 'ticket_updates': + return ticketUpdateHandler(discordClient, data); + case 'ban_updates': + return banUpdateHandler(discordClient, data); + } } \ No newline at end of file diff --git a/src/types/command.d.ts b/src/types/command.d.ts index 292b645..83e0b16 100644 --- a/src/types/command.d.ts +++ b/src/types/command.d.ts @@ -1,9 +1,9 @@ -import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js'; -import { Handler } from './handler'; - -export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder; - -export interface Command extends Handler { - data: CommandBuilder | ((client: Client) => Promise); - guilds?: string[]; +import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js'; +import { Handler } from './handler'; + +export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder; + +export interface Command extends Handler { + data: CommandBuilder | ((client: Client) => Promise); + guilds?: string[]; } \ No newline at end of file diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index 310d36c..2d078da 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -1,79 +1,79 @@ -export type LoggedMessage = { - id: string - author_id: string - author_name: string - channel_id: string - attachments: string - stickers: string - content: string -} - -export type GuildSettings = { - guild_id: string - general_chat_id?: string - new_member_channel_id?: string - tickets_channel_id?: string - event_logs_channel_id?: string - discord_logs_channel_id?: string - audit_logs_channel_id?: string - voice_logs_channel_id?: string - admin_role_id?: string - private_help_role_id?: string - devwatch_role_id?: string - staff_categories?: string - safe_channels?: string - link_skip_channels?: string - github_release_channel?: string - moderator_channel_id?: string - private_help_channel_id?: string -} - -export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; - -export type TicketMessage = { - id: number - message_id: string -} - -export type TicketPhrase = { - id: number - user_id: string - phrase: string -} - -export type Note = { - id: number - user_id: string - reason: string - mod_id: string - timestamp: string -} - -export type Ban = { - id: number - user_id: string - expires: 0 | 1 - expires_at: string - full_ban: 0 | 1 -} - -export type GithubUserMapping = { - id: number - discord_id: string - github_username: string -} - -export type KnowledgebaseItem = { - id: number - guild_id: string - name: string - content: string -} - -export type PrivateHelpTicket = { - id: number - user_id: string - thread_id: string - status: PrivateHelpTicketStatus - timestamp: string +export type LoggedMessage = { + id: string + author_id: string + author_name: string + channel_id: string + attachments: string + stickers: string + content: string +} + +export type GuildSettings = { + guild_id: string + general_chat_id?: string + new_member_channel_id?: string + tickets_channel_id?: string + event_logs_channel_id?: string + discord_logs_channel_id?: string + audit_logs_channel_id?: string + voice_logs_channel_id?: string + admin_role_id?: string + private_help_role_id?: string + devwatch_role_id?: string + staff_categories?: string + safe_channels?: string + link_skip_channels?: string + github_release_channel?: string + moderator_channel_id?: string + private_help_channel_id?: string +} + +export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; + +export type TicketMessage = { + id: number + message_id: string +} + +export type TicketPhrase = { + id: number + user_id: string + phrase: string +} + +export type Note = { + id: number + user_id: string + reason: string + mod_id: string + timestamp: string +} + +export type Ban = { + id: number + user_id: string + expires: 0 | 1 + expires_at: string + full_ban: 0 | 1 +} + +export type GithubUserMapping = { + id: number + discord_id: string + github_username: string +} + +export type KnowledgebaseItem = { + id: number + guild_id: string + name: string + content: string +} + +export type PrivateHelpTicket = { + id: number + user_id: string + thread_id: string + status: PrivateHelpTicketStatus + timestamp: string } \ No newline at end of file diff --git a/src/types/e621-types.d.ts b/src/types/e621-types.d.ts index 27a4678..c4bc61b 100644 --- a/src/types/e621-types.d.ts +++ b/src/types/e621-types.d.ts @@ -1,157 +1,157 @@ -export type E621User = { - wiki_page_version_count: number - artist_version_count: number - pool_version_count: number - forum_post_count: number - comment_count: number - flag_count: number - favorite_count: number - positive_feedback_count: number - neutral_feedback_count: number - negative_feedback_count: number - upload_limit: number - profile_about: string - profile_artinfo: string - id: number - created_at: string - name: string - level: number - base_upload_limit: number - post_upload_count: number - post_update_count: number - note_update_count: number - is_banned: boolean - can_approve_posts: boolean - can_upload_free: boolean - level_string: string - avatar_id: number -} - -export type E621Post = { - id: number - created_at: string - updated_at: string - file: E621File - preview: E621PreviewFile - sample: E621SampleFile - score: E621ScoreData - tags: E621Tags - locked_tags: string[] - change_seq: number - flags: E621FlagData - rating: 's' | 'q' | 'e' - fav_count: number - sources: string[] - pools: number[] - relationships: E621PostRelationships - approver_id: number - uploader_id: number - description: string - comment_count: number - is_favorited: boolean - has_notes: boolean - duration: number | null -} - -export type E621File = { - width: number - height: number - ext: 'png' | 'jpg' | 'mp4' | 'webm' - size: number - md5: string - url: string | null -} - -export type E621PreviewFile = { - width: number - height: number - url: string | null -} - -export type E621SampleFile = { - has: boolean - height: number - width: number - url: string | null - // Typing this will be a pain in the ass, so I skipped it for now. - alternates: any -} - -export type E621ScoreData = { - up: number - down: number - total: number -} - -export type E621Tags = { - general: string[] - artist: string[] - contributor: string[] - copyright: string[] - character: string[] - species: string[] - invalid: string[] - meta: string[] - lore: string[] -} - -export type E621FlagData = { - pending: boolean - flagged: boolean - note_locked: boolean - status_locked: boolean - rating_locked: boolean - deleted: boolean -} - -export type E621PostRelationships = { - parent_id: number | null - has_children: boolean - has_active_children: boolean - children: number[] -} - -export type Ticket = { - id: number - user_id: number - user: string - claimant: string | null - target?: string - accused_id?: number - target_id: number - status: 'pending' | 'partial' | 'approved' - category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki' - reason: string -}; - -export type TicketUpdate = { - action: 'claim' | 'create' | 'unclaim' | 'update' - ticket: Ticket -}; - -export type Ban = { - id: number - user_id: number - banner_id: number - expires_at: string - reason: string -}; - -export type BanUpdate = { - action: 'create' | 'update' | 'delete' - ban: Ban -}; - -export type RecordCategory = 'positive' | 'negative' | 'neutral' - -export type Record = { - id: number - user_id: number - creator_id: number - created_at: string - body: string - category: RecordCategory - updated_at: string - updater_id: number - is_deleted: boolean +export type E621User = { + wiki_page_version_count: number + artist_version_count: number + pool_version_count: number + forum_post_count: number + comment_count: number + flag_count: number + favorite_count: number + positive_feedback_count: number + neutral_feedback_count: number + negative_feedback_count: number + upload_limit: number + profile_about: string + profile_artinfo: string + id: number + created_at: string + name: string + level: number + base_upload_limit: number + post_upload_count: number + post_update_count: number + note_update_count: number + is_banned: boolean + can_approve_posts: boolean + can_upload_free: boolean + level_string: string + avatar_id: number +} + +export type E621Post = { + id: number + created_at: string + updated_at: string + file: E621File + preview: E621PreviewFile + sample: E621SampleFile + score: E621ScoreData + tags: E621Tags + locked_tags: string[] + change_seq: number + flags: E621FlagData + rating: 's' | 'q' | 'e' + fav_count: number + sources: string[] + pools: number[] + relationships: E621PostRelationships + approver_id: number + uploader_id: number + description: string + comment_count: number + is_favorited: boolean + has_notes: boolean + duration: number | null +} + +export type E621File = { + width: number + height: number + ext: 'png' | 'jpg' | 'mp4' | 'webm' + size: number + md5: string + url: string | null +} + +export type E621PreviewFile = { + width: number + height: number + url: string | null +} + +export type E621SampleFile = { + has: boolean + height: number + width: number + url: string | null + // Typing this will be a pain in the ass, so I skipped it for now. + alternates: any +} + +export type E621ScoreData = { + up: number + down: number + total: number +} + +export type E621Tags = { + general: string[] + artist: string[] + contributor: string[] + copyright: string[] + character: string[] + species: string[] + invalid: string[] + meta: string[] + lore: string[] +} + +export type E621FlagData = { + pending: boolean + flagged: boolean + note_locked: boolean + status_locked: boolean + rating_locked: boolean + deleted: boolean +} + +export type E621PostRelationships = { + parent_id: number | null + has_children: boolean + has_active_children: boolean + children: number[] +} + +export type Ticket = { + id: number + user_id: number + user: string + claimant: string | null + target?: string + accused_id?: number + target_id: number + status: 'pending' | 'partial' | 'approved' + category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki' + reason: string +}; + +export type TicketUpdate = { + action: 'claim' | 'create' | 'unclaim' | 'update' + ticket: Ticket +}; + +export type Ban = { + id: number + user_id: number + banner_id: number + expires_at: string + reason: string +}; + +export type BanUpdate = { + action: 'create' | 'update' | 'delete' + ban: Ban +}; + +export type RecordCategory = 'positive' | 'negative' | 'neutral' + +export type Record = { + id: number + user_id: number + creator_id: number + created_at: string + body: string + category: RecordCategory + updated_at: string + updater_id: number + is_deleted: boolean } \ No newline at end of file diff --git a/src/types/handler.d.ts b/src/types/handler.d.ts index 9514370..afd9698 100644 --- a/src/types/handler.d.ts +++ b/src/types/handler.d.ts @@ -1,10 +1,10 @@ -import { Client, Interaction } from 'discord.js'; - -type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise - -export interface Handler { - name: string; - handler: HandlerFunction; - init?: (client: Client) => Promise; - autoComplete?: HandlerFunction; +import { Client, Interaction } from 'discord.js'; + +type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise + +export interface Handler { + name: string; + handler: HandlerFunction; + init?: (client: Client) => Promise; + autoComplete?: HandlerFunction; } \ No newline at end of file diff --git a/src/types/helper-types.d.ts b/src/types/helper-types.d.ts index 304695b..83781b3 100644 --- a/src/types/helper-types.d.ts +++ b/src/types/helper-types.d.ts @@ -1,38 +1,38 @@ -import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js'; - -export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder[] }; - -export type RoleChangeLog = { - key: '$add' | '$remove', - old?: Pick[], - new?: Pick[] -} - -type ApplicationCommandPermission = { - type: 1 | 2 | 3, - permission: boolean, - id: string -} - -export type ApplicationCommandPermissionChangeLog = { - key: sting, - old?: ApplicationCommandPermission, - new?: ApplicationCommandPermission -} - -export type TimeoutChangeLog = { - key: 'communication_disabled_until', - old?: string, - new?: string -} - -export type PermissionsChangeLog = { - key: 'permissions' | 'allow' | 'deny', - old?: number, - new?: number -} - -export type PinExtras = { - channel: TextChannel, - messageId: string +import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js'; + +export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder[] }; + +export type RoleChangeLog = { + key: '$add' | '$remove', + old?: Pick[], + new?: Pick[] +} + +type ApplicationCommandPermission = { + type: 1 | 2 | 3, + permission: boolean, + id: string +} + +export type ApplicationCommandPermissionChangeLog = { + key: sting, + old?: ApplicationCommandPermission, + new?: ApplicationCommandPermission +} + +export type TimeoutChangeLog = { + key: 'communication_disabled_until', + old?: string, + new?: string +} + +export type PermissionsChangeLog = { + key: 'permissions' | 'allow' | 'deny', + old?: number, + new?: number +} + +export type PinExtras = { + channel: TextChannel, + messageId: string } \ No newline at end of file diff --git a/src/types/index.d.ts b/src/types/index.d.ts index bd9ddc2..6cdbdc1 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -3,4 +3,4 @@ export * from './database-types.d'; export * from './e621-types.d'; export * from './handler.d'; export * from './helper-types.d'; -export * from './scheduler.d'; \ No newline at end of file +export * from './scheduler.d'; diff --git a/src/utils/alt-utils.ts b/src/utils/alt-utils.ts index b7dbc18..61d79f4 100644 --- a/src/utils/alt-utils.ts +++ b/src/utils/alt-utils.ts @@ -1,106 +1,106 @@ -import { Guild } from 'discord.js'; -import { Database } from '../shared/Database'; -import { userIsBanned } from './e621-utils'; -import { config } from '../config'; - -export type AltData = { - type: 'e621' | 'discord' - thisId: number | string - banned: boolean - alts: AltData[] -}; - - -export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise { - const e621UserIds = await Database.getE621Ids(discordId); - - const toIgnore = ignore.concat(e621UserIds); - - let content = ''; - - for (const e621Id of e621UserIds) { - if (ignore.includes(e621Id)) continue; - - const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore); - - const banned = await userIsBanned(e621Id); - - content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`; - } - - return content; -} - -export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise { - const discordIds = await Database.getDiscordIds(e621Id); - - let content = ''; - - for (const discordId of discordIds) { - const alts = await getE621Alts(discordId, guild, depth + 1, ignore); - - let banned = false; - - // It's either this or fetch all the bans and sift through them for every discord alt. - try { - banned = !!(await guild.bans.fetch(discordId)); - } catch (e) { } - - content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`; - } - - return content; -} - -export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise { - return getE621AltData(discordId, guild); -} - -export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise { - return getDiscordAltData(e621Id, guild); -} - -async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { - const e621UserIds = await Database.getE621Ids(discordId); - - const toIgnore = ignore.concat(e621UserIds); - - let banned = false; - - // It's either this or fetch all the bans and sift through them for every discord alt. - try { - banned = guild ? !!(await guild.bans.fetch(discordId)) : false; - } catch (e) { } - - const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] }; - - for (const e621Id of e621UserIds) { - if (ignore.includes(e621Id)) continue; - - data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore)); - } - - return data; -} - -async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { - const discordIds = await Database.getDiscordIds(e621Id); - - const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] }; - - for (const discordId of discordIds) { - data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore)); - } - - return data; -} - -export function e621IdsFromAltData(altData: AltData, data: number[] = []) { - if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number); - - for (const alt of altData.alts) { - e621IdsFromAltData(alt, data); - } - - return data; +import { Guild } from 'discord.js'; +import { Database } from '../shared/Database'; +import { userIsBanned } from './e621-utils'; +import { config } from '../config'; + +export type AltData = { + type: 'e621' | 'discord' + thisId: number | string + banned: boolean + alts: AltData[] +}; + + +export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise { + const e621UserIds = await Database.getE621Ids(discordId); + + const toIgnore = ignore.concat(e621UserIds); + + let content = ''; + + for (const e621Id of e621UserIds) { + if (ignore.includes(e621Id)) continue; + + const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore); + + const banned = await userIsBanned(e621Id); + + content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`; + } + + return content; +} + +export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise { + const discordIds = await Database.getDiscordIds(e621Id); + + let content = ''; + + for (const discordId of discordIds) { + const alts = await getE621Alts(discordId, guild, depth + 1, ignore); + + let banned = false; + + // It's either this or fetch all the bans and sift through them for every discord alt. + try { + banned = !!(await guild.bans.fetch(discordId)); + } catch (e) { } + + content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`; + } + + return content; +} + +export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise { + return getE621AltData(discordId, guild); +} + +export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise { + return getDiscordAltData(e621Id, guild); +} + +async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { + const e621UserIds = await Database.getE621Ids(discordId); + + const toIgnore = ignore.concat(e621UserIds); + + let banned = false; + + // It's either this or fetch all the bans and sift through them for every discord alt. + try { + banned = guild ? !!(await guild.bans.fetch(discordId)) : false; + } catch (e) { } + + const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] }; + + for (const e621Id of e621UserIds) { + if (ignore.includes(e621Id)) continue; + + data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore)); + } + + return data; +} + +async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise { + const discordIds = await Database.getDiscordIds(e621Id); + + const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] }; + + for (const discordId of discordIds) { + data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore)); + } + + return data; +} + +export function e621IdsFromAltData(altData: AltData, data: number[] = []) { + if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number); + + for (const alt of altData.alts) { + e621IdsFromAltData(alt, data); + } + + return data; } \ No newline at end of file diff --git a/src/utils/array-utils.ts b/src/utils/array-utils.ts index 36bb334..4d0378e 100644 --- a/src/utils/array-utils.ts +++ b/src/utils/array-utils.ts @@ -1,6 +1,6 @@ -export function getArrayDifference(oldArr: any[], newArr: any[]) { - const added = newArr.filter(e => !oldArr.includes(e)); - const removed = oldArr.filter(e => !newArr.includes(e)); - - return { added, removed }; +export function getArrayDifference(oldArr: any[], newArr: any[]) { + const added = newArr.filter(e => !oldArr.includes(e)); + const removed = oldArr.filter(e => !newArr.includes(e)); + + return { added, removed }; } \ No newline at end of file diff --git a/src/utils/audit-log-utils.ts b/src/utils/audit-log-utils.ts index 49462d8..de16587 100644 --- a/src/utils/audit-log-utils.ts +++ b/src/utils/audit-log-utils.ts @@ -1,197 +1,197 @@ -import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js'; -import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types'; -import { getArrayDifference } from './array-utils'; - -export const enum TargetType { - Unknown = 0, - Role = 1, - User = 2, - Channel = 3 -}; - -const TARGETS_ROLES: AuditLogEvent[] = [ - AuditLogEvent.RoleCreate, - AuditLogEvent.RoleDelete, - AuditLogEvent.RoleUpdate -]; - -const TARGETS_USERS: AuditLogEvent[] = [ - AuditLogEvent.MemberUpdate, - AuditLogEvent.MemberKick, - AuditLogEvent.MemberBanAdd, - AuditLogEvent.MemberBanRemove, - AuditLogEvent.MemberRoleUpdate, - AuditLogEvent.MessageDelete, - AuditLogEvent.MessagePin, - AuditLogEvent.MessageUnpin -]; - -const TARGETS_CHANNELS: AuditLogEvent[] = [ - AuditLogEvent.ChannelCreate, - AuditLogEvent.ChannelUpdate, - AuditLogEvent.ChannelDelete, - AuditLogEvent.ThreadCreate, - AuditLogEvent.ThreadUpdate, - AuditLogEvent.ThreadDelete, - AuditLogEvent.ChannelOverwriteCreate, - AuditLogEvent.ChannelOverwriteDelete, - AuditLogEvent.ChannelOverwriteUpdate -]; - -export function getTargetType(actionType: AuditLogEvent): TargetType { - if (TARGETS_ROLES.includes(actionType)) return TargetType.Role; - else if (TARGETS_USERS.includes(actionType)) return TargetType.User; - else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel; - - return TargetType.Unknown; -} - -export function formatSnowflake(snowflake: string, targetType: TargetType): string { - if (targetType == TargetType.Role) return `<@&${snowflake}>`; - else if (targetType == TargetType.User) return `<@${snowflake}>`; - else if (targetType == TargetType.Channel) return `<#${snowflake}>`; - - return snowflake; -} - -export function formatChanges(entry: GuildAuditLogsEntry): string { - return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n'); -} - -export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string { - if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin) - return formatMessagePin(entry.extra as unknown as PinExtras, guild); - - if (entry.action == AuditLogEvent.ChannelOverwriteCreate - || entry.action == AuditLogEvent.ChannelOverwriteDelete - || entry.action == AuditLogEvent.ChannelOverwriteUpdate) { - return `Target: ${entry.extra!.toString()}`; - } - - try { - const reserialized = JSON.parse(JSON.stringify(entry)); - - const results: string[] = []; - - for (const [key, value] of Object.entries(reserialized.extra ?? {})) { - if (!value) continue; - - if (key == 'channel_id' || key == 'channel') { - results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`); - continue; - } - - results.push(`${key}: ${value}`); - } - - return results.join('\n'); - } catch (e) { - console.error(e); - return ''; - } - - return ''; -} - -function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined { - if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) { - return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog); - } - - switch (change.key) { - case '$add': - case '$remove': - return formatMemberRoleChange(change); - - case 'communication_disabled_until': - return formatTimeoutChange(change); - - case 'permissions': - case 'allow': - case 'deny': - return formatPermissionOrOverwrites(change as PermissionsChangeLog); - } - - const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old; - const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new; - - if (change.new !== undefined && change.old === undefined) - return `Set ${change.key} to ${newValue}`; - - if (change.new === undefined && change.old !== undefined) - return `Set ${change.key} with value ${oldValue} to default/null`; - - return `Set ${change.key} from ${oldValue} to ${newValue}`; -} - -function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined { - if (change.new !== undefined && change.old === undefined) - return `${change.new.permission ? 'Allowed' : 'Denied'} access ${change.new.type == 3 ? 'in' : (change.new.permission ? 'to' : 'from')} ${formatSnowflake(change.new.id, change.new.type)} for command: ${change.key}`; - - if (change.new === undefined && change.old !== undefined) - return `Removed permission overrides from ${formatSnowflake(change.old.id, change.old.type)} for command: ${change.key}`; - - return `Updated permission overrides ${change.new!.type == 3 ? 'in' : 'for'} ${formatSnowflake(change.new!.id, change.new!.type)}: ${change.new!.permission ? 'allowed access to' : 'revoked access to'} command: ${change.key}`; -} - -function formatMemberRoleChange(change: RoleChangeLog): string | undefined { - if (!change.new) return; - - const changes: string[] = []; - - for (const roleChange of change.new) { - if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`); - else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`); - } - - if (changes.length == 0) return; - - return changes.join('\n'); -} - -function formatTimeoutChange(change: TimeoutChangeLog): string { - if (!change.new) return 'Timeout removed'; - - const date = new Date(change.new); - - return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`; -} - -function formatPermissionOrOverwrites(change: PermissionsChangeLog): string { - const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray(); - const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray(); - - switch (change.key) { - case 'permissions': - return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)'); - - case 'allow': - return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added'); - - case 'deny': - return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added'); - - default: - return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`); - } -} - -function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) { - const { added, removed } = getArrayDifference(oldPermissions, newPermissions); - - const result: string[] = []; - - if (added.length > 0) { - result.push(`${addedDescription}: ${added.join(', ')}`); - } - - if (removed.length > 0) { - result.push(`${removedDescription}: ${removed.join(', ')}`); - } - - return result.join('\n'); -} - -function formatMessagePin(data: PinExtras, guild: Guild): string { - return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`; +import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js'; +import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types'; +import { getArrayDifference } from './array-utils'; + +export const enum TargetType { + Unknown = 0, + Role = 1, + User = 2, + Channel = 3 +}; + +const TARGETS_ROLES: AuditLogEvent[] = [ + AuditLogEvent.RoleCreate, + AuditLogEvent.RoleDelete, + AuditLogEvent.RoleUpdate +]; + +const TARGETS_USERS: AuditLogEvent[] = [ + AuditLogEvent.MemberUpdate, + AuditLogEvent.MemberKick, + AuditLogEvent.MemberBanAdd, + AuditLogEvent.MemberBanRemove, + AuditLogEvent.MemberRoleUpdate, + AuditLogEvent.MessageDelete, + AuditLogEvent.MessagePin, + AuditLogEvent.MessageUnpin +]; + +const TARGETS_CHANNELS: AuditLogEvent[] = [ + AuditLogEvent.ChannelCreate, + AuditLogEvent.ChannelUpdate, + AuditLogEvent.ChannelDelete, + AuditLogEvent.ThreadCreate, + AuditLogEvent.ThreadUpdate, + AuditLogEvent.ThreadDelete, + AuditLogEvent.ChannelOverwriteCreate, + AuditLogEvent.ChannelOverwriteDelete, + AuditLogEvent.ChannelOverwriteUpdate +]; + +export function getTargetType(actionType: AuditLogEvent): TargetType { + if (TARGETS_ROLES.includes(actionType)) return TargetType.Role; + else if (TARGETS_USERS.includes(actionType)) return TargetType.User; + else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel; + + return TargetType.Unknown; +} + +export function formatSnowflake(snowflake: string, targetType: TargetType): string { + if (targetType == TargetType.Role) return `<@&${snowflake}>`; + else if (targetType == TargetType.User) return `<@${snowflake}>`; + else if (targetType == TargetType.Channel) return `<#${snowflake}>`; + + return snowflake; +} + +export function formatChanges(entry: GuildAuditLogsEntry): string { + return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n'); +} + +export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string { + if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin) + return formatMessagePin(entry.extra as unknown as PinExtras, guild); + + if (entry.action == AuditLogEvent.ChannelOverwriteCreate + || entry.action == AuditLogEvent.ChannelOverwriteDelete + || entry.action == AuditLogEvent.ChannelOverwriteUpdate) { + return `Target: ${entry.extra!.toString()}`; + } + + try { + const reserialized = JSON.parse(JSON.stringify(entry)); + + const results: string[] = []; + + for (const [key, value] of Object.entries(reserialized.extra ?? {})) { + if (!value) continue; + + if (key == 'channel_id' || key == 'channel') { + results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`); + continue; + } + + results.push(`${key}: ${value}`); + } + + return results.join('\n'); + } catch (e) { + console.error(e); + return ''; + } + + return ''; +} + +function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined { + if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) { + return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog); + } + + switch (change.key) { + case '$add': + case '$remove': + return formatMemberRoleChange(change); + + case 'communication_disabled_until': + return formatTimeoutChange(change); + + case 'permissions': + case 'allow': + case 'deny': + return formatPermissionOrOverwrites(change as PermissionsChangeLog); + } + + const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old; + const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new; + + if (change.new !== undefined && change.old === undefined) + return `Set ${change.key} to ${newValue}`; + + if (change.new === undefined && change.old !== undefined) + return `Set ${change.key} with value ${oldValue} to default/null`; + + return `Set ${change.key} from ${oldValue} to ${newValue}`; +} + +function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined { + if (change.new !== undefined && change.old === undefined) + return `${change.new.permission ? 'Allowed' : 'Denied'} access ${change.new.type == 3 ? 'in' : (change.new.permission ? 'to' : 'from')} ${formatSnowflake(change.new.id, change.new.type)} for command: ${change.key}`; + + if (change.new === undefined && change.old !== undefined) + return `Removed permission overrides from ${formatSnowflake(change.old.id, change.old.type)} for command: ${change.key}`; + + return `Updated permission overrides ${change.new!.type == 3 ? 'in' : 'for'} ${formatSnowflake(change.new!.id, change.new!.type)}: ${change.new!.permission ? 'allowed access to' : 'revoked access to'} command: ${change.key}`; +} + +function formatMemberRoleChange(change: RoleChangeLog): string | undefined { + if (!change.new) return; + + const changes: string[] = []; + + for (const roleChange of change.new) { + if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`); + else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`); + } + + if (changes.length == 0) return; + + return changes.join('\n'); +} + +function formatTimeoutChange(change: TimeoutChangeLog): string { + if (!change.new) return 'Timeout removed'; + + const date = new Date(change.new); + + return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`; +} + +function formatPermissionOrOverwrites(change: PermissionsChangeLog): string { + const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray(); + const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray(); + + switch (change.key) { + case 'permissions': + return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)'); + + case 'allow': + return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added'); + + case 'deny': + return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added'); + + default: + return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`); + } +} + +function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) { + const { added, removed } = getArrayDifference(oldPermissions, newPermissions); + + const result: string[] = []; + + if (added.length > 0) { + result.push(`${addedDescription}: ${added.join(', ')}`); + } + + if (removed.length > 0) { + result.push(`${removedDescription}: ${removed.join(', ')}`); + } + + return result.join('\n'); +} + +function formatMessagePin(data: PinExtras, guild: Guild): string { + return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`; } \ No newline at end of file diff --git a/src/utils/ban-events.ts b/src/utils/ban-events.ts index 14a396d..dce297e 100644 --- a/src/utils/ban-events.ts +++ b/src/utils/ban-events.ts @@ -1,49 +1,49 @@ -import { Client } from 'discord.js'; -import { BanUpdate } from '../types'; -import { Database } from '../shared/Database'; -import { config } from '../config'; - -export async function banUpdateHandler(client: Client, update: string) { - const data: BanUpdate = JSON.parse(update); - - if (data.action == 'create') { - kickDiscordAccounts(client, data); - } - // else if (data.action == 'delete') { - // // unbanDiscordAccounts(data); - // } -} - -async function kickDiscordAccounts(client: Client, data: BanUpdate) { - const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!); - - const discordIds = await Database.getDiscordIds(data.ban.user_id); - - for (const id of discordIds) { - const member = await guild.members.fetch(id); - - if (member) await member.kick(`Banned from e621 by: https://e621.net/users/${data.ban.banner_id}. Reason:\n${data.ban.reason}`); - } -} - -// async function banDiscordAccounts(data: BanUpdate) { -// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); - -// const discordIds = await Database.getDiscordIds(data.ban.user_id); - -// for (const id of discordIds) { -// await guild.bans.create(id, { -// reason: data.ban.reason -// }); -// } -// } - -// async function unbanDiscordAccounts(data: BanUpdate) { -// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); - -// const discordIds = await Database.getDiscordIds(data.ban.user_id); - -// for (const id of discordIds) { -// await guild.bans.remove(id); -// } +import { Client } from 'discord.js'; +import { BanUpdate } from '../types'; +import { Database } from '../shared/Database'; +import { config } from '../config'; + +export async function banUpdateHandler(client: Client, update: string) { + const data: BanUpdate = JSON.parse(update); + + if (data.action == 'create') { + kickDiscordAccounts(client, data); + } + // else if (data.action == 'delete') { + // // unbanDiscordAccounts(data); + // } +} + +async function kickDiscordAccounts(client: Client, data: BanUpdate) { + const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!); + + const discordIds = await Database.getDiscordIds(data.ban.user_id); + + for (const id of discordIds) { + const member = await guild.members.fetch(id); + + if (member) await member.kick(`Banned from e621 by: https://e621.net/users/${data.ban.banner_id}. Reason:\n${data.ban.reason}`); + } +} + +// async function banDiscordAccounts(data: BanUpdate) { +// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); + +// const discordIds = await Database.getDiscordIds(data.ban.user_id); + +// for (const id of discordIds) { +// await guild.bans.create(id, { +// reason: data.ban.reason +// }); +// } +// } + +// async function unbanDiscordAccounts(data: BanUpdate) { +// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); + +// const discordIds = await Database.getDiscordIds(data.ban.user_id); + +// for (const id of discordIds) { +// await guild.bans.remove(id); +// } // } \ No newline at end of file diff --git a/src/utils/ban-utils.ts b/src/utils/ban-utils.ts index edd31e0..1ed7464 100644 --- a/src/utils/ban-utils.ts +++ b/src/utils/ban-utils.ts @@ -1,22 +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); +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/channel-utils.ts b/src/utils/channel-utils.ts index d30630c..c7dd62f 100644 --- a/src/utils/channel-utils.ts +++ b/src/utils/channel-utils.ts @@ -1,28 +1,28 @@ -import { GuildBasedChannel } from 'discord.js'; -import { Database } from '../shared/Database'; - -export async function channelIsInStaffCategory(channel: GuildBasedChannel) { - if (!channel.guildId || !channel.parentId) return false; - - const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId); - - const parentChannel = await channel.guild.channels.fetch(channel.parentId); - - return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId); -} - -export async function channelIsSafe(channel: GuildBasedChannel) { - if (!channel.guildId) return false; - - const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId); - - return safeChannels.includes(channel.id); -} - -export async function channelIgnoresLinks(channel: GuildBasedChannel) { - if (!channel.guildId) return false; - - const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId); - - return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false; +import { GuildBasedChannel } from 'discord.js'; +import { Database } from '../shared/Database'; + +export async function channelIsInStaffCategory(channel: GuildBasedChannel) { + if (!channel.guildId || !channel.parentId) return false; + + const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId); + + const parentChannel = await channel.guild.channels.fetch(channel.parentId); + + return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId); +} + +export async function channelIsSafe(channel: GuildBasedChannel) { + if (!channel.guildId) return false; + + const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId); + + return safeChannels.includes(channel.id); +} + +export async function channelIgnoresLinks(channel: GuildBasedChannel) { + if (!channel.guildId) return false; + + const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId); + + return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false; } \ No newline at end of file diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 4a6a8eb..ef0018c 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -1,22 +1,22 @@ -import fs from 'fs'; -import { Handler } from '../types'; -import path from 'path'; -import { Client } from 'discord.js'; - -const ROOT_DIR = path.resolve(__dirname, '..'); - -export function loadHandlersFrom(dir: string, handlerArray: Handler[]) { - if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return; - - const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts')); - for (const file of files) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default); - } -} - -export async function initIfNecessary(client: Client, handlers: Handler[]) { - for (const handler of handlers) { - if (handler.init) await handler.init(client); - } -} +import fs from 'fs'; +import { Handler } from '../types'; +import path from 'path'; +import { Client } from 'discord.js'; + +const ROOT_DIR = path.resolve(__dirname, '..'); + +export function loadHandlersFrom(dir: string, handlerArray: Handler[]) { + if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return; + + const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts')); + for (const file of files) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default); + } +} + +export async function initIfNecessary(client: Client, handlers: Handler[]) { + for (const handler of handlers) { + if (handler.init) await handler.init(client); + } +} diff --git a/src/utils/debug-utils.ts b/src/utils/debug-utils.ts index 5a56de7..d2b5119 100644 --- a/src/utils/debug-utils.ts +++ b/src/utils/debug-utils.ts @@ -1,5 +1,5 @@ -import { config } from '../config'; - -export function logDebug(message: string) { - if (config.DEBUG) console.log(`[DEBUG] ${message}`); +import { config } from '../config'; + +export function logDebug(message: string) { + if (config.DEBUG) console.log(`[DEBUG] ${message}`); } \ No newline at end of file diff --git a/src/utils/discord-user-utils.ts b/src/utils/discord-user-utils.ts index b6a88a4..4c5accc 100644 --- a/src/utils/discord-user-utils.ts +++ b/src/utils/discord-user-utils.ts @@ -1,36 +1,36 @@ -import { Client, Guild, User } from 'discord.js'; -import { Database, PrivateHelpTicketStatus } from '../shared/Database'; - -export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise { - let user: User | null | undefined = null; - - try { - user = await client.users.fetch(value); - } catch { - user = client.users.cache.find(u => u.username == value); - - if (!user && guild) { - user = guild.members.cache.find(m => m.displayName == value)?.user; - - if (!user) { - try { - const users = await guild.members.fetch({ - query: value - }); - - if (users.size > 0) user = users.first()!.user; - } catch { } - } - } - } - - return user; -} - -export async function canOpenPrivateHelpTicket(id: string): Promise { - const latestTicket = await Database.getLatestPrivateHelpTicketBy(id); - - if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false; - - return true; +import { Client, Guild, User } from 'discord.js'; +import { Database, PrivateHelpTicketStatus } from '../shared/Database'; + +export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise { + let user: User | null | undefined = null; + + try { + user = await client.users.fetch(value); + } catch { + user = client.users.cache.find(u => u.username == value); + + if (!user && guild) { + user = guild.members.cache.find(m => m.displayName == value)?.user; + + if (!user) { + try { + const users = await guild.members.fetch({ + query: value + }); + + if (users.size > 0) user = users.first()!.user; + } catch { } + } + } + } + + return user; +} + +export async function canOpenPrivateHelpTicket(id: string): Promise { + const latestTicket = await Database.getLatestPrivateHelpTicketBy(id); + + if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false; + + return true; } \ No newline at end of file diff --git a/src/utils/e621-utils.ts b/src/utils/e621-utils.ts index d72060c..4b6f53d 100644 --- a/src/utils/e621-utils.ts +++ b/src/utils/e621-utils.ts @@ -1,83 +1,83 @@ -import { config } from '../config'; -import { E621Post, E621User, Record } from '../types'; - -const BLACKLISTED_TAGS: string[] = []; -const BLACKLISTED_NONSAFE_TAGS: string[] = ['young']; - -const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports']; -const SPOILERED_NONSAFE_TAGS: string[] = []; - -const USER_AGENT = 'E621DiscordBot'; - -async function request(path: string, query?: { [name: string]: string }): Promise { - const url = new URL(config.E621_BASE_URL!); - url.pathname = path + '.json'; - - if (query) { - for (const [name, value] of Object.entries(query)) { - url.searchParams.set(name, value); - } - } - - const res = await fetch(url, { - headers: { - 'User-Agent': USER_AGENT - } - }); - - if (!res.ok) return null; - - return await res.json(); -} - -export async function getE621User(idOrName: string | number): Promise { - return await request(`/users/${idOrName}`) as E621User; -} - -export async function getE621Post(id: string | number): Promise { - return (await request(`/posts/${id}`))?.post as E621Post ?? null; -} - -export async function getE621PostByMd5(md5: string): Promise { - return (await request('/posts', { md5 }))?.post as E621Post ?? null; -} - -export const enum PostAction { - NoAction = 0, - Spoiler = 1, - Blacklist = 2 -} - -export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } { - const tags = Object.values(post.tags).flat(); - - for (const tag of tags) { - if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag }; - if (post.rating != 's' && BLACKLISTED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag }; - } - - for (const tag of tags) { - if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag }; - if (post.rating != 's' && SPOILERED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag }; - } - - return { action: PostAction.NoAction, tag: '' }; -} - -export function getPostUrl(post: E621Post): string { - if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`; - return `${config.E621_BASE_URL}/posts/${post.id}`; -} - -export async function userIsBanned(idOrName: string | number): Promise { - const user = await getE621User(idOrName); - return user?.is_banned ?? false; -} - -export async function getUserRecords(id: number): Promise { - const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() }); - - if (records.user_feedbacks) return []; - - return records as Record[]; +import { config } from '../config'; +import { E621Post, E621User, Record } from '../types'; + +const BLACKLISTED_TAGS: string[] = []; +const BLACKLISTED_NONSAFE_TAGS: string[] = ['young']; + +const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports']; +const SPOILERED_NONSAFE_TAGS: string[] = []; + +const USER_AGENT = 'E621DiscordBot'; + +async function request(path: string, query?: { [name: string]: string }): Promise { + const url = new URL(config.E621_BASE_URL!); + url.pathname = path + '.json'; + + if (query) { + for (const [name, value] of Object.entries(query)) { + url.searchParams.set(name, value); + } + } + + const res = await fetch(url, { + headers: { + 'User-Agent': USER_AGENT + } + }); + + if (!res.ok) return null; + + return await res.json(); +} + +export async function getE621User(idOrName: string | number): Promise { + return await request(`/users/${idOrName}`) as E621User; +} + +export async function getE621Post(id: string | number): Promise { + return (await request(`/posts/${id}`))?.post as E621Post ?? null; +} + +export async function getE621PostByMd5(md5: string): Promise { + return (await request('/posts', { md5 }))?.post as E621Post ?? null; +} + +export const enum PostAction { + NoAction = 0, + Spoiler = 1, + Blacklist = 2 +} + +export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } { + const tags = Object.values(post.tags).flat(); + + for (const tag of tags) { + if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag }; + if (post.rating != 's' && BLACKLISTED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag }; + } + + for (const tag of tags) { + if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag }; + if (post.rating != 's' && SPOILERED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag }; + } + + return { action: PostAction.NoAction, tag: '' }; +} + +export function getPostUrl(post: E621Post): string { + if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`; + return `${config.E621_BASE_URL}/posts/${post.id}`; +} + +export async function userIsBanned(idOrName: string | number): Promise { + const user = await getE621User(idOrName); + return user?.is_banned ?? false; +} + +export async function getUserRecords(id: number): Promise { + const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() }); + + if (records.user_feedbacks) return []; + + return records as Record[]; } \ No newline at end of file diff --git a/src/utils/event-log-utils.ts b/src/utils/event-log-utils.ts index 8b2067e..b67edfc 100644 --- a/src/utils/event-log-utils.ts +++ b/src/utils/event-log-utils.ts @@ -1,219 +1,219 @@ -import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js'; -import { Message } from '../events'; -import { Database } from '../shared/Database'; -import { LoggedMessage } from '../types'; -import { channelIsInStaffCategory } from './channel-utils'; -import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils'; - -type CustomEventLogData = { - title: string - description: string | null - color: number | null - timestamp: Date | number | null - fields: APIEmbedField[] | null -} - -export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message) { - const channel = await getEventLogChannel(newMessage.guild, newMessage.channel); - - if (!channel) return; - - const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024; - - const fields: APIEmbedField[] = []; - fields.push(...getMainEmbeds(loggedMessage, newMessage)); - fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed)); - - const embed = new EmbedBuilder() - .setTitle('Edited Message') - .setColor(0xFFFF00) - .setTimestamp(newMessage.createdTimestamp) - .addFields(...fields); - - const messagePayload: MessageCreateOptions = { embeds: [embed] }; - - if (!includeContentInEmbed) { - const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' }); - const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' }); - - messagePayload.files = [before, after]; - } - - channel.send(messagePayload); -} - -export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message) { - const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel); - - if (!channel) return; - - const includeContentInEmbed = loggedMessage.content.length <= 1024; - - const fields: APIEmbedField[] = []; - fields.push(...getMainEmbeds(loggedMessage, deletedMessage)); - fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed)); - - const embed = new EmbedBuilder() - .setTitle('Deleted Message') - .setColor(0xFF0000) - .setTimestamp(deletedMessage.createdTimestamp) - .addFields(...fields); - - const messagePayload: MessageCreateOptions = { embeds: [embed] }; - - if (!includeContentInEmbed) { - const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' }); - - messagePayload.files = [before]; - } - - channel.send(messagePayload); -} - -export async function logCustomEvent(guild: Guild, data: CustomEventLogData) { - const channel = await getEventLogChannel(guild); - - if (!channel) return; - - const embed = new EmbedBuilder() - .setTitle(data.title) - .setColor(data.color) - .setTimestamp(data.timestamp); - - if (data.fields) embed.addFields(...data.fields); - - channel.send({ embeds: [embed] }); -} - -async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise { - const settings = await Database.getGuildSettings(guild.id); - - if (!settings) return null; - - if (channel && await channelIsInStaffCategory(channel)) { - if (!settings.event_logs_channel_id) return null; - - const channel = await guild.channels.fetch(settings.event_logs_channel_id); - - if (!channel || !channel.isSendable()) return null; - - return channel; - } else { - if (!settings.discord_logs_channel_id) return null; - - const channel = await guild.channels.fetch(settings.discord_logs_channel_id); - - if (!channel || !channel.isSendable()) return null; - - return channel; - } -} - -function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message): APIEmbedField[] { - const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`; - const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`; - - return [ - { - name: 'Channel', - value: channelString, - inline: true - }, - { - name: 'User', - value: userString, - inline: true - }, - { - name: 'Message', - value: `[${newMessage.id}](${newMessage.url})`, - inline: true - }, - ]; -} - -function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] { - const fields: APIEmbedField[] = []; - - if (includeContentInEmbed && loggedMessage.content != '') { - fields.push({ - name: 'Content', - value: loggedMessage.content, - inline: false - }); - } - - for (const attachment of deserializeMessagePart(loggedMessage.attachments)) { - fields.push({ - name: 'Attachment', - value: attachment, - inline: true - }); - } - - for (const sticker of deserializeMessagePart(loggedMessage.stickers)) { - fields.push({ - name: 'Stickers', - value: sticker, - inline: true - }); - } - - return fields; -} - -function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message, includeContentInEmbed = true): APIEmbedField[] { - const fields: APIEmbedField[] = []; - if (includeContentInEmbed && loggedMessage.content != newMessage.content) { - fields.push( - { - name: 'Before', - value: loggedMessage.content, - inline: false - }, - { - name: 'After', - value: newMessage.content, - inline: false - } - ); - } - - const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); - - for (const removedAttachment of removedAttachments) { - fields.push({ - name: 'Removed Attachment', - value: removedAttachment, - inline: true - }); - } - - for (const addedAttachment of addedAttachments) { - fields.push({ - name: 'Added Attachment', - value: addedAttachment, - inline: true - }); - } - - const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); - - for (const removedSticker of addedStickers) { - fields.push({ - name: 'Removed Sticker', - value: removedSticker, - inline: true - }); - } - - for (const addedSticker of removedStickers) { - fields.push({ - name: 'Added Sticker', - value: addedSticker, - inline: true - }); - } - - return fields; +import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js'; +import { Message } from '../events'; +import { Database } from '../shared/Database'; +import { LoggedMessage } from '../types'; +import { channelIsInStaffCategory } from './channel-utils'; +import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils'; + +type CustomEventLogData = { + title: string + description: string | null + color: number | null + timestamp: Date | number | null + fields: APIEmbedField[] | null +} + +export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message) { + const channel = await getEventLogChannel(newMessage.guild, newMessage.channel); + + if (!channel) return; + + const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024; + + const fields: APIEmbedField[] = []; + fields.push(...getMainEmbeds(loggedMessage, newMessage)); + fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed)); + + const embed = new EmbedBuilder() + .setTitle('Edited Message') + .setColor(0xFFFF00) + .setTimestamp(newMessage.createdTimestamp) + .addFields(...fields); + + const messagePayload: MessageCreateOptions = { embeds: [embed] }; + + if (!includeContentInEmbed) { + const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' }); + const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' }); + + messagePayload.files = [before, after]; + } + + channel.send(messagePayload); +} + +export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message) { + const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel); + + if (!channel) return; + + const includeContentInEmbed = loggedMessage.content.length <= 1024; + + const fields: APIEmbedField[] = []; + fields.push(...getMainEmbeds(loggedMessage, deletedMessage)); + fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed)); + + const embed = new EmbedBuilder() + .setTitle('Deleted Message') + .setColor(0xFF0000) + .setTimestamp(deletedMessage.createdTimestamp) + .addFields(...fields); + + const messagePayload: MessageCreateOptions = { embeds: [embed] }; + + if (!includeContentInEmbed) { + const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' }); + + messagePayload.files = [before]; + } + + channel.send(messagePayload); +} + +export async function logCustomEvent(guild: Guild, data: CustomEventLogData) { + const channel = await getEventLogChannel(guild); + + if (!channel) return; + + const embed = new EmbedBuilder() + .setTitle(data.title) + .setColor(data.color) + .setTimestamp(data.timestamp); + + if (data.fields) embed.addFields(...data.fields); + + channel.send({ embeds: [embed] }); +} + +async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise { + const settings = await Database.getGuildSettings(guild.id); + + if (!settings) return null; + + if (channel && await channelIsInStaffCategory(channel)) { + if (!settings.event_logs_channel_id) return null; + + const channel = await guild.channels.fetch(settings.event_logs_channel_id); + + if (!channel || !channel.isSendable()) return null; + + return channel; + } else { + if (!settings.discord_logs_channel_id) return null; + + const channel = await guild.channels.fetch(settings.discord_logs_channel_id); + + if (!channel || !channel.isSendable()) return null; + + return channel; + } +} + +function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message): APIEmbedField[] { + const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`; + const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`; + + return [ + { + name: 'Channel', + value: channelString, + inline: true + }, + { + name: 'User', + value: userString, + inline: true + }, + { + name: 'Message', + value: `[${newMessage.id}](${newMessage.url})`, + inline: true + }, + ]; +} + +function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] { + const fields: APIEmbedField[] = []; + + if (includeContentInEmbed && loggedMessage.content != '') { + fields.push({ + name: 'Content', + value: loggedMessage.content, + inline: false + }); + } + + for (const attachment of deserializeMessagePart(loggedMessage.attachments)) { + fields.push({ + name: 'Attachment', + value: attachment, + inline: true + }); + } + + for (const sticker of deserializeMessagePart(loggedMessage.stickers)) { + fields.push({ + name: 'Stickers', + value: sticker, + inline: true + }); + } + + return fields; +} + +function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message, includeContentInEmbed = true): APIEmbedField[] { + const fields: APIEmbedField[] = []; + if (includeContentInEmbed && loggedMessage.content != newMessage.content) { + fields.push( + { + name: 'Before', + value: loggedMessage.content, + inline: false + }, + { + name: 'After', + value: newMessage.content, + inline: false + } + ); + } + + const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); + + for (const removedAttachment of removedAttachments) { + fields.push({ + name: 'Removed Attachment', + value: removedAttachment, + inline: true + }); + } + + for (const addedAttachment of addedAttachments) { + fields.push({ + name: 'Added Attachment', + value: addedAttachment, + inline: true + }); + } + + const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); + + for (const removedSticker of addedStickers) { + fields.push({ + name: 'Removed Sticker', + value: removedSticker, + inline: true + }); + } + + for (const addedSticker of removedStickers) { + fields.push({ + name: 'Added Sticker', + value: addedSticker, + inline: true + }); + } + + return fields; } \ No newline at end of file diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts index d52365f..6934ff1 100644 --- a/src/utils/file-utils.ts +++ b/src/utils/file-utils.ts @@ -1,71 +1,71 @@ -import crypto from 'crypto'; - -const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26; -const END_PNG_BYTES = 12; - -const DISCORD_JPG_START_OFFSET = 3; -const DISCORD_JPG_REMOVE_BYTE_LENGTH = 23; -const DISCORD_JPG_REINSERT = Buffer.from([0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]); -const DISCORD_JPG_REINSERT_BYTE_LENGTH = DISCORD_JPG_REINSERT.byteLength; - -export const ALLOWED_MIMETYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'video/mp4', 'video/webm']; - -export function calculateMD5(data: Buffer): string { - return crypto.createHash('md5').update(data).digest('hex'); -} - -// Downloads a file from discord's CDN and reverts the changes they do to the file. -// Returns both the corrected version at index 0, and the original version from discord at index 1. -export async function downloadFile(url: string): Promise { - try { - const res = await fetch(url); - - const mimeType = res.headers.get('Content-Type')!; - - if (!ALLOWED_MIMETYPES.includes(mimeType)) return null; - - const data = await res.arrayBuffer(); - - let finalData: Buffer; - - if (mimeType == 'image/png') { - const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES; - const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); - const buff = Buffer.from(data); - buff.copy(correctedData, 0, 0, startOffset); - buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); - finalData = correctedData; - } else if (mimeType == 'image/jpg' || mimeType == 'image/jpeg') { - const correctedData = Buffer.alloc(data.byteLength - DISCORD_JPG_REMOVE_BYTE_LENGTH + DISCORD_JPG_REINSERT_BYTE_LENGTH); - const buff = Buffer.from(data); - buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET); - DISCORD_JPG_REINSERT.copy(correctedData, DISCORD_JPG_START_OFFSET); - buff.copy(correctedData, DISCORD_JPG_REMOVE_BYTE_LENGTH - DISCORD_JPG_START_OFFSET, DISCORD_JPG_START_OFFSET + DISCORD_JPG_REMOVE_BYTE_LENGTH); - finalData = correctedData; - } else { - finalData = Buffer.from(data); - } - - return [finalData, Buffer.from(data)]; - } catch (e) { - console.error(e); - return null; - } -} - -// This method is used with discord CDN URLs. -// Discord does slight modifications to the data, which will change the MD5, this method reverts those changes. -export async function calculateMD5FromURL(url: string): Promise<{ correctedFileMD5: string, originalFileMD5: string } | null> { - try { - const files = await downloadFile(url); - if (!files) return null; - - return { - correctedFileMD5: calculateMD5(files[0]), - originalFileMD5: calculateMD5(files[1]) - }; - } catch (e) { - console.error(e); - return null; - } +import crypto from 'crypto'; + +const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26; +const END_PNG_BYTES = 12; + +const DISCORD_JPG_START_OFFSET = 3; +const DISCORD_JPG_REMOVE_BYTE_LENGTH = 23; +const DISCORD_JPG_REINSERT = Buffer.from([0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]); +const DISCORD_JPG_REINSERT_BYTE_LENGTH = DISCORD_JPG_REINSERT.byteLength; + +export const ALLOWED_MIMETYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'video/mp4', 'video/webm']; + +export function calculateMD5(data: Buffer): string { + return crypto.createHash('md5').update(data).digest('hex'); +} + +// Downloads a file from discord's CDN and reverts the changes they do to the file. +// Returns both the corrected version at index 0, and the original version from discord at index 1. +export async function downloadFile(url: string): Promise { + try { + const res = await fetch(url); + + const mimeType = res.headers.get('Content-Type')!; + + if (!ALLOWED_MIMETYPES.includes(mimeType)) return null; + + const data = await res.arrayBuffer(); + + let finalData: Buffer; + + if (mimeType == 'image/png') { + const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES; + const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); + const buff = Buffer.from(data); + buff.copy(correctedData, 0, 0, startOffset); + buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); + finalData = correctedData; + } else if (mimeType == 'image/jpg' || mimeType == 'image/jpeg') { + const correctedData = Buffer.alloc(data.byteLength - DISCORD_JPG_REMOVE_BYTE_LENGTH + DISCORD_JPG_REINSERT_BYTE_LENGTH); + const buff = Buffer.from(data); + buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET); + DISCORD_JPG_REINSERT.copy(correctedData, DISCORD_JPG_START_OFFSET); + buff.copy(correctedData, DISCORD_JPG_REMOVE_BYTE_LENGTH - DISCORD_JPG_START_OFFSET, DISCORD_JPG_START_OFFSET + DISCORD_JPG_REMOVE_BYTE_LENGTH); + finalData = correctedData; + } else { + finalData = Buffer.from(data); + } + + return [finalData, Buffer.from(data)]; + } catch (e) { + console.error(e); + return null; + } +} + +// This method is used with discord CDN URLs. +// Discord does slight modifications to the data, which will change the MD5, this method reverts those changes. +export async function calculateMD5FromURL(url: string): Promise<{ correctedFileMD5: string, originalFileMD5: string } | null> { + try { + const files = await downloadFile(url); + if (!files) return null; + + return { + correctedFileMD5: calculateMD5(files[0]), + originalFileMD5: calculateMD5(files[1]) + }; + } catch (e) { + console.error(e); + return null; + } } \ No newline at end of file diff --git a/src/utils/github-user-utils.ts b/src/utils/github-user-utils.ts index dde15a6..94d19bc 100644 --- a/src/utils/github-user-utils.ts +++ b/src/utils/github-user-utils.ts @@ -1,19 +1,19 @@ -import { Database } from '../shared/Database'; - -const mentionRegex = new RegExp('@([\\S]+),|@([\\S]+)', 'gi'); -const issueLinkRegex = new RegExp('\\s?\\(\\[(#\\d+)\\]\\(https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)\\)', 'gi'); - -export async function fixPings(body: string): Promise { - const mappings = await Database.getAllGithubUserMappings(); - - return body.replaceAll(mentionRegex, (match, m1, m2) => { - const name = m1 ?? m2; - const mapping = mappings.find(m => m.github_username == name); - - return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match; - }); -} - -export function removeIssueLinks(body: string): string { - return body.replaceAll(issueLinkRegex, ''); +import { Database } from '../shared/Database'; + +const mentionRegex = new RegExp('@([\\S]+),|@([\\S]+)', 'gi'); +const issueLinkRegex = new RegExp('\\s?\\(\\[(#\\d+)\\]\\(https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)\\)', 'gi'); + +export async function fixPings(body: string): Promise { + const mappings = await Database.getAllGithubUserMappings(); + + return body.replaceAll(mentionRegex, (match, m1, m2) => { + const name = m1 ?? m2; + const mapping = mappings.find(m => m.github_username == name); + + return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match; + }); +} + +export function removeIssueLinks(body: string): string { + return body.replaceAll(issueLinkRegex, ''); } \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 2ced0b1..8d9a9a6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,29 +1,29 @@ -export * from './alt-utils'; -export * from './array-utils'; -export * from './audit-log-utils'; -export * from './ban-events'; -export * from './ban-utils'; -export * from './channel-utils'; -export * from './commands'; -export * from './debug-utils'; -export * from './discord-user-utils'; -export * from './e621-utils'; -export * from './event-log-utils'; -export * from './file-utils'; -export * from './github-user-utils'; -export * from './interaction-utils'; -export * from './message-matcher-regex'; -export * from './message-utils'; -export * from './modal-utils'; -export * from './ms-to-human'; -export * from './name-sync'; -export * from './note-utils'; -export * from './oauth2'; -export * from './private-help-utils'; -export * from './record-utils'; -export * from './refresh-commands'; -export * from './string-utils'; -export * from './ticket-events'; -export * from './ticket-utils'; -export * from './wait'; -export * from './whois'; +export * from './alt-utils'; +export * from './array-utils'; +export * from './audit-log-utils'; +export * from './ban-events'; +export * from './ban-utils'; +export * from './channel-utils'; +export * from './commands'; +export * from './debug-utils'; +export * from './discord-user-utils'; +export * from './e621-utils'; +export * from './event-log-utils'; +export * from './file-utils'; +export * from './github-user-utils'; +export * from './interaction-utils'; +export * from './message-matcher-regex'; +export * from './message-utils'; +export * from './modal-utils'; +export * from './ms-to-human'; +export * from './name-sync'; +export * from './note-utils'; +export * from './oauth2'; +export * from './private-help-utils'; +export * from './record-utils'; +export * from './refresh-commands'; +export * from './string-utils'; +export * from './ticket-events'; +export * from './ticket-utils'; +export * from './wait'; +export * from './whois'; diff --git a/src/utils/interaction-utils.ts b/src/utils/interaction-utils.ts index 594c57a..6ae0183 100644 --- a/src/utils/interaction-utils.ts +++ b/src/utils/interaction-utils.ts @@ -1,9 +1,9 @@ -import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; -import { channelIsInStaffCategory } from './channel-utils'; - -export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) { - const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel); - - if (isStaffChannel) await interaction.deferReply(); - else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); +import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; +import { channelIsInStaffCategory } from './channel-utils'; + +export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) { + const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel); + + if (isStaffChannel) await interaction.deferReply(); + else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); } \ No newline at end of file diff --git a/src/utils/message-matcher-regex.ts b/src/utils/message-matcher-regex.ts index 6773f32..25069b8 100644 --- a/src/utils/message-matcher-regex.ts +++ b/src/utils/message-matcher-regex.ts @@ -1,18 +1,18 @@ -export const postIDRegex = new RegExp('post #([0-9]+)', 'gi'); -export const userIDRegex = new RegExp('user #([0-9]+)', 'gi'); -export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi'); -export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi'); -export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi'); -export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi'); -export const setIDRegex = new RegExp('set #([0-9]+)', 'gi'); -export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi'); -export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi'); -export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi'); -export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi'); - -const tagSearchRegex = '(?:[\\S]| )+?'; -export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi'); -export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi'); - -export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi'); +export const postIDRegex = new RegExp('post #([0-9]+)', 'gi'); +export const userIDRegex = new RegExp('user #([0-9]+)', 'gi'); +export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi'); +export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi'); +export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi'); +export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi'); +export const setIDRegex = new RegExp('set #([0-9]+)', 'gi'); +export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi'); +export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi'); +export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi'); +export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi'); + +const tagSearchRegex = '(?:[\\S]| )+?'; +export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi'); +export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi'); + +export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi'); export const issueRegex = new RegExp('issue #([0-9]+)', 'gi'); \ No newline at end of file diff --git a/src/utils/message-utils.ts b/src/utils/message-utils.ts index e971695..0a5837c 100644 --- a/src/utils/message-utils.ts +++ b/src/utils/message-utils.ts @@ -1,60 +1,60 @@ -import { Message } from '../events'; -import { LoggedMessage } from '../types'; - -export const ARRAY_SEPARATOR = '$'; - -const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi'); - -export function serializeMessage(message: Message): string[] { - const attachments = message.attachments.map(a => `${a.name}:${a.id}`); - const stickers = message.stickers.map(s => `${s.name}:${s.id}`); - - return [message.id, message.author.id, message.author.username, message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), message.content]; -} - -export function deserializeMessagePart(part: string): string[] { - return part.split(ARRAY_SEPARATOR).filter(e => e); -} - -export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } { - const loggedAttachments = loggedMessage.attachments.split(ARRAY_SEPARATOR).filter(e => e); - const addedAttachments = newMessage.attachments.filter(a => !loggedAttachments.includes(`${a.name}:${a.id}`)).map(a => `${a.name}:${a.id}`); - const removedAttachments = loggedAttachments.filter(a => !newMessage.attachments.has(a.split(':').at(-1)!)); - - return { addedAttachments, removedAttachments }; -} - -export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } { - const loggedStickers = loggedMessage.stickers.split(ARRAY_SEPARATOR).filter(e => e); - const addedStickers = newMessage.stickers.filter(s => !loggedStickers.includes(`${s.name}:${s.id}`)).map(s => `${s.name}:${s.id}`); - const removedStickers = loggedStickers.filter(s => !newMessage.stickers.has(s.split(':').at(-1)!)); - - return { addedStickers, removedStickers }; -} - -export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) { - if (newMessage.content != loggedMessage.content) return true; - - const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); - - if (addedAttachments.length > 0 || removedAttachments.length > 0) return true; - - const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); - - return addedStickers.length > 0 || removedStickers.length > 0; -} - -export function isInSpoilerTags(content: string, index: number): boolean { - if (!content.includes('||')) return false; - - let match: RegExpExecArray | null; - while ((match = spoilerRegex.exec(content)) != null) { - if (index >= match.index && index <= spoilerRegex.lastIndex) { - spoilerRegex.lastIndex = 0; - return true; - } - } - - spoilerRegex.lastIndex = 0; - return false; +import { Message } from '../events'; +import { LoggedMessage } from '../types'; + +export const ARRAY_SEPARATOR = '$'; + +const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi'); + +export function serializeMessage(message: Message): string[] { + const attachments = message.attachments.map(a => `${a.name}:${a.id}`); + const stickers = message.stickers.map(s => `${s.name}:${s.id}`); + + return [message.id, message.author.id, message.author.username, message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), message.content]; +} + +export function deserializeMessagePart(part: string): string[] { + return part.split(ARRAY_SEPARATOR).filter(e => e); +} + +export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } { + const loggedAttachments = loggedMessage.attachments.split(ARRAY_SEPARATOR).filter(e => e); + const addedAttachments = newMessage.attachments.filter(a => !loggedAttachments.includes(`${a.name}:${a.id}`)).map(a => `${a.name}:${a.id}`); + const removedAttachments = loggedAttachments.filter(a => !newMessage.attachments.has(a.split(':').at(-1)!)); + + return { addedAttachments, removedAttachments }; +} + +export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } { + const loggedStickers = loggedMessage.stickers.split(ARRAY_SEPARATOR).filter(e => e); + const addedStickers = newMessage.stickers.filter(s => !loggedStickers.includes(`${s.name}:${s.id}`)).map(s => `${s.name}:${s.id}`); + const removedStickers = loggedStickers.filter(s => !newMessage.stickers.has(s.split(':').at(-1)!)); + + return { addedStickers, removedStickers }; +} + +export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) { + if (newMessage.content != loggedMessage.content) return true; + + const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); + + if (addedAttachments.length > 0 || removedAttachments.length > 0) return true; + + const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); + + return addedStickers.length > 0 || removedStickers.length > 0; +} + +export function isInSpoilerTags(content: string, index: number): boolean { + if (!content.includes('||')) return false; + + let match: RegExpExecArray | null; + while ((match = spoilerRegex.exec(content)) != null) { + if (index >= match.index && index <= spoilerRegex.lastIndex) { + spoilerRegex.lastIndex = 0; + return true; + } + } + + spoilerRegex.lastIndex = 0; + return false; } \ No newline at end of file diff --git a/src/utils/modal-utils.ts b/src/utils/modal-utils.ts index 7a65cd8..f0a4113 100644 --- a/src/utils/modal-utils.ts +++ b/src/utils/modal-utils.ts @@ -1,43 +1,43 @@ -import { LabelBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; - -export function createTextInput(customId: string, labelTitle: string, description: string | null, required: boolean, style: TextInputStyle, maxLength: number | null, minLength: number | null): LabelBuilder { - const input = new TextInputBuilder() - .setCustomId(customId) - .setStyle(style) - .setRequired(required); - - if (maxLength !== null) input.setMaxLength(maxLength); - if (minLength !== null) input.setMinLength(minLength); - - const label = new LabelBuilder() - .setLabel(labelTitle); - - if (description !== null) label.setDescription(description); - - label.setTextInputComponent(input); - - return label; -} - -export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder { - const yesNoMenu = new StringSelectMenuBuilder() - .setCustomId(customId) - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Yes') - .setDefault(defaultYes) - .setValue('yes'), - new StringSelectMenuOptionBuilder() - .setLabel('No') - .setDefault(!defaultYes) - .setValue('no') - ); - - const yesNoLabel = new LabelBuilder() - .setLabel(labelTitle) - .setStringSelectMenuComponent(yesNoMenu); - - if (description !== null) yesNoLabel.setDescription(description); - - return yesNoLabel; +import { LabelBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; + +export function createTextInput(customId: string, labelTitle: string, description: string | null, required: boolean, style: TextInputStyle, maxLength: number | null, minLength: number | null): LabelBuilder { + const input = new TextInputBuilder() + .setCustomId(customId) + .setStyle(style) + .setRequired(required); + + if (maxLength !== null) input.setMaxLength(maxLength); + if (minLength !== null) input.setMinLength(minLength); + + const label = new LabelBuilder() + .setLabel(labelTitle); + + if (description !== null) label.setDescription(description); + + label.setTextInputComponent(input); + + return label; +} + +export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder { + const yesNoMenu = new StringSelectMenuBuilder() + .setCustomId(customId) + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Yes') + .setDefault(defaultYes) + .setValue('yes'), + new StringSelectMenuOptionBuilder() + .setLabel('No') + .setDefault(!defaultYes) + .setValue('no') + ); + + const yesNoLabel = new LabelBuilder() + .setLabel(labelTitle) + .setStringSelectMenuComponent(yesNoMenu); + + if (description !== null) yesNoLabel.setDescription(description); + + return yesNoLabel; } \ No newline at end of file diff --git a/src/utils/ms-to-human.ts b/src/utils/ms-to-human.ts index 08e2857..e402fcb 100644 --- a/src/utils/ms-to-human.ts +++ b/src/utils/ms-to-human.ts @@ -1,12 +1,12 @@ -export function msToHuman(ms: number) { - const time = { - day: Math.floor(ms / 86400000), - hour: Math.floor(ms / 3600000) % 24, - minute: Math.floor(ms / 60000) % 60, - second: Math.floor(ms / 1000) % 60, - }; - return Object.entries(time) - .filter(val => val[1] !== 0) - .map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`) - .join(', '); +export function msToHuman(ms: number) { + const time = { + day: Math.floor(ms / 86400000), + hour: Math.floor(ms / 3600000) % 24, + minute: Math.floor(ms / 60000) % 60, + second: Math.floor(ms / 1000) % 60, + }; + return Object.entries(time) + .filter(val => val[1] !== 0) + .map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`) + .join(', '); } \ No newline at end of file diff --git a/src/utils/name-sync.ts b/src/utils/name-sync.ts index ad6894a..842957d 100644 --- a/src/utils/name-sync.ts +++ b/src/utils/name-sync.ts @@ -1,28 +1,28 @@ -import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { E621User } from '../types'; -import { getE621User } from './e621-utils'; - -export async function syncName(interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ModalSubmitInteraction, member: GuildMember, id: number | null) { - if (member.roles.highest.comparePositionTo(member.guild.members.me!.roles.highest) > 0) { - const res = interaction.user.id == member.id ? 'your' : 'their'; - return interaction.editReply(`I am unable to set ${res} nickname as ${res} role is higher than mine.`); - } - - const availableIds = await Database.getE621Ids(interaction.user.id); - - let e621User: E621User | null; - - if (!id || !availableIds.includes(id)) { - e621User = await getE621User(availableIds[0]); - } else { - e621User = await getE621User(id); - } - - if (!e621User) { - return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator."); - } - - await member.setNickname(e621User.name); - interaction.editReply(`Nickname set to: ${e621User.name}`); +import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { E621User } from '../types'; +import { getE621User } from './e621-utils'; + +export async function syncName(interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ModalSubmitInteraction, member: GuildMember, id: number | null) { + if (member.roles.highest.comparePositionTo(member.guild.members.me!.roles.highest) > 0) { + const res = interaction.user.id == member.id ? 'your' : 'their'; + return interaction.editReply(`I am unable to set ${res} nickname as ${res} role is higher than mine.`); + } + + const availableIds = await Database.getE621Ids(interaction.user.id); + + let e621User: E621User | null; + + if (!id || !availableIds.includes(id)) { + e621User = await getE621User(availableIds[0]); + } else { + e621User = await getE621User(id); + } + + if (!e621User) { + return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator."); + } + + await member.setNickname(e621User.name); + interaction.editReply(`Nickname set to: ${e621User.name}`); } \ No newline at end of file diff --git a/src/utils/note-utils.ts b/src/utils/note-utils.ts index f1ead17..bbbe0e3 100644 --- a/src/utils/note-utils.ts +++ b/src/utils/note-utils.ts @@ -1,49 +1,49 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js'; -import { Database } from '../shared/Database'; -import { MessageContent, Note } from '../types'; - -const NOTES_PER_PAGE = 5; - -function getNoteText(note: Note): string { - const timestamp = time(new Date(note.timestamp)); - - return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`; -} - -export async function getNoteMessage(userId: string, page: number): Promise { - const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - - page = page - 1; - - const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1); - - if (notes.length == 0) return null; - - const noteTexts: string[] = []; - - for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) { - if (i >= notes.length) break; - - noteTexts.push(getNoteText(notes[i])); - } - - const prevPage = new ButtonBuilder() - .setLabel('Previous Page') - .setCustomId(`note-previous_${userId}_${page + 1}`) - .setDisabled(page == 0) - .setStyle(ButtonStyle.Primary); - - const nextPage = new ButtonBuilder() - .setLabel('Next Page') - .setCustomId(`note-next_${userId}_${page + 1}`) - .setDisabled(page >= maxPage) - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder() - .addComponents(prevPage, nextPage); - - return { - content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`, - components: [row] - }; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js'; +import { Database } from '../shared/Database'; +import { MessageContent, Note } from '../types'; + +const NOTES_PER_PAGE = 5; + +function getNoteText(note: Note): string { + const timestamp = time(new Date(note.timestamp)); + + return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`; +} + +export async function getNoteMessage(userId: string, page: number): Promise { + const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + page = page - 1; + + const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1); + + if (notes.length == 0) return null; + + const noteTexts: string[] = []; + + for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) { + if (i >= notes.length) break; + + noteTexts.push(getNoteText(notes[i])); + } + + const prevPage = new ButtonBuilder() + .setLabel('Previous Page') + .setCustomId(`note-previous_${userId}_${page + 1}`) + .setDisabled(page == 0) + .setStyle(ButtonStyle.Primary); + + const nextPage = new ButtonBuilder() + .setLabel('Next Page') + .setCustomId(`note-next_${userId}_${page + 1}`) + .setDisabled(page >= maxPage) + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder() + .addComponents(prevPage, nextPage); + + return { + content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`, + components: [row] + }; } \ No newline at end of file diff --git a/src/utils/oauth2.ts b/src/utils/oauth2.ts index db39d6c..11be9d8 100644 --- a/src/utils/oauth2.ts +++ b/src/utils/oauth2.ts @@ -1,137 +1,137 @@ -type ClientOptions = { - clientId: string - clientSecret: string - clientToken: string - redirectUri: string - credentials: string -}; - -type GenerateUrlParameters = { - state: string - scope: string[] - type: 'code' | 'token' -}; - -type TokenResponse = { - access_token: string - token_type: string - expires_in: number - refresh_token: string - scope: string -} - -type DiscordUser = { - id: string - username: string - // bunch of other stuff we don't use -} - -type AddMemberOptions = { - accessToken: string - botToken?: string - guildId: string - userId: string - nickname?: string -} - -const OAUTH_BASE_URL = 'https://discord.com/oauth2'; -const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2'; -const API_BASE_URL = 'https://discord.com/api'; - -export class DiscordOAuth2 { - constructor(private options: ClientOptions) { } - - generateOauth2Url(options: GenerateUrlParameters) { - const url = new URL(`${OAUTH_BASE_URL}/authorize`); - - const params = new URLSearchParams({ - client_id: this.options.clientId, - response_type: options.type, - redirect_uri: this.options.redirectUri, - scope: options.scope.join('+'), - state: options.state - }); - - url.search = params.toString(); - - return url.toString(); - } - - async getAccessToken(code: string, scope: string[]): Promise { - const res = await fetch(`${OAUTH_API_BASE_URL}/token`, { - method: 'POST', - body: new URLSearchParams({ - client_id: this.options.clientId, - client_secret: this.options.clientSecret, - code, - grant_type: 'authorization_code', - redirect_uri: this.options.redirectUri, - scope: scope.join(' ') - }).toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json' - } - }); - - const data = await res.json(); - return data as TokenResponse; - } - - async getUser(accessToken: string): Promise { - const res = await fetch(`${API_BASE_URL}/users/@me`, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json' - } - }); - - return await res.json() as DiscordUser; - } - - async addMember(options: AddMemberOptions) { - const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, { - method: 'PUT', - body: JSON.stringify({ - nick: options.nickname, - access_token: options.accessToken - }), - headers: { - 'Content-Type': 'application/json', - Authorization: `Bot ${this.options.clientToken}`, - Accept: 'application/json' - } - }); - - if (res.status < 200 || res.status >= 300) { - console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`); - const text = await res.text(); - console.error(text); - let data = { code: 0 }; - - try { - data = JSON.parse(text); - } catch { } - - throw data; - } - - return await res.json(); - } - - async revokeToken(token: string) { - const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, { - method: 'POST', - body: new URLSearchParams({ - token - }).toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${this.options.credentials}`, - Accept: 'application/json' - } - }); - - return await res.json(); - } +type ClientOptions = { + clientId: string + clientSecret: string + clientToken: string + redirectUri: string + credentials: string +}; + +type GenerateUrlParameters = { + state: string + scope: string[] + type: 'code' | 'token' +}; + +type TokenResponse = { + access_token: string + token_type: string + expires_in: number + refresh_token: string + scope: string +} + +type DiscordUser = { + id: string + username: string + // bunch of other stuff we don't use +} + +type AddMemberOptions = { + accessToken: string + botToken?: string + guildId: string + userId: string + nickname?: string +} + +const OAUTH_BASE_URL = 'https://discord.com/oauth2'; +const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2'; +const API_BASE_URL = 'https://discord.com/api'; + +export class DiscordOAuth2 { + constructor(private options: ClientOptions) { } + + generateOauth2Url(options: GenerateUrlParameters) { + const url = new URL(`${OAUTH_BASE_URL}/authorize`); + + const params = new URLSearchParams({ + client_id: this.options.clientId, + response_type: options.type, + redirect_uri: this.options.redirectUri, + scope: options.scope.join('+'), + state: options.state + }); + + url.search = params.toString(); + + return url.toString(); + } + + async getAccessToken(code: string, scope: string[]): Promise { + const res = await fetch(`${OAUTH_API_BASE_URL}/token`, { + method: 'POST', + body: new URLSearchParams({ + client_id: this.options.clientId, + client_secret: this.options.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: this.options.redirectUri, + scope: scope.join(' ') + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + }); + + const data = await res.json(); + return data as TokenResponse; + } + + async getUser(accessToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/users/@me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json' + } + }); + + return await res.json() as DiscordUser; + } + + async addMember(options: AddMemberOptions) { + const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, { + method: 'PUT', + body: JSON.stringify({ + nick: options.nickname, + access_token: options.accessToken + }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bot ${this.options.clientToken}`, + Accept: 'application/json' + } + }); + + if (res.status < 200 || res.status >= 300) { + console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`); + const text = await res.text(); + console.error(text); + let data = { code: 0 }; + + try { + data = JSON.parse(text); + } catch { } + + throw data; + } + + return await res.json(); + } + + async revokeToken(token: string) { + const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, { + method: 'POST', + body: new URLSearchParams({ + token + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${this.options.credentials}`, + Accept: 'application/json' + } + }); + + return await res.json(); + } } \ No newline at end of file diff --git a/src/utils/private-help-utils.ts b/src/utils/private-help-utils.ts index c870894..22271bb 100644 --- a/src/utils/private-help-utils.ts +++ b/src/utils/private-help-utils.ts @@ -1,100 +1,100 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, Client, Guild, GuildMember, ModalBuilder, PrivateThreadChannel, TextChannel, TextInputStyle, ThreadAutoArchiveDuration, ThreadChannel, UserContextMenuCommandInteraction } from 'discord.js'; -import { Database } from '../shared/Database'; -import { createTextInput, createYesNoMenu } from './modal-utils'; - -export async function closeOldTickets(client: Client) { - for (const ticket of await Database.getAllOpenPrivateHelpTickets()) { - try { - const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel; - const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0); - if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) { - await Database.closePrivateHelpTicket(thread.id); - - await thread.send('This ticket has been closed due to inactivity.'); - - thread.edit({ - archived: true, - locked: true - }); - } - } catch (e) { - console.error('Error closing ticket due to inactivity:'); - console.error(e); - } - } -} - -export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise { - const guildSettings = await Database.getGuildSettings(guild.id); - - if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null; - - const channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel; - - const thread = await channel.threads.create({ - name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'), - autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, - invitable: false, - type: ChannelType.PrivateThread - }) as PrivateThreadChannel; - - if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id); - - if (creator) { - const closeButton = new ButtonBuilder() - .setCustomId('close-ticket') - .setLabel('Click here if you no longer need help') - .setStyle(ButtonStyle.Danger); - - const claimButton = new ButtonBuilder() - .setCustomId('claim-ticket') - .setLabel('Claim ticket') - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder().addComponents(closeButton, claimButton); - - await thread.send({ - content: `${creator} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`, - components: [row], - allowedMentions: { - users: [creator.id], - roles: [guildSettings.private_help_role_id] - } - }); - } else { - const closeButton = new ButtonBuilder() - .setCustomId('close-mod-ticket') - .setLabel('Close Mod Ticket') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(closeButton); - - await thread.send({ - content: reason, - components: [row], - allowedMentions: { - users: Array.from(new Set(additionalMembersToAdd)) - } - }); - } - - for (const id of additionalMembersToAdd) { - await thread.members.add(id); - } - - return thread; -} - -export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) { - const modal = new ModalBuilder() - .setCustomId(`open-mod-ticket_${member.id}`) - .setTitle('Opening A Mod Ticket'); - - const titleLabel = createTextInput('title', 'Title', `The name of the thread. Defaults to "Mod Ticket For ${member.displayName}" if left empty`, false, TextInputStyle.Short, 100, null); - const initialMessageLabel = createTextInput('initial-message', 'Inital Message', 'The inital message sent in the thread', false, TextInputStyle.Paragraph, 1800, null); - const autoJoinLabel = createYesNoMenu('auto-join-thread', 'Auto Join Thread', 'Whether or not to join you to the thread. Selecting no will not notify you of messages sent!', true); - - modal.addLabelComponents(titleLabel, initialMessageLabel, autoJoinLabel); - - interaction.showModal(modal); +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, Client, Guild, GuildMember, ModalBuilder, PrivateThreadChannel, TextChannel, TextInputStyle, ThreadAutoArchiveDuration, ThreadChannel, UserContextMenuCommandInteraction } from 'discord.js'; +import { Database } from '../shared/Database'; +import { createTextInput, createYesNoMenu } from './modal-utils'; + +export async function closeOldTickets(client: Client) { + for (const ticket of await Database.getAllOpenPrivateHelpTickets()) { + try { + const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel; + const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0); + if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) { + await Database.closePrivateHelpTicket(thread.id); + + await thread.send('This ticket has been closed due to inactivity.'); + + thread.edit({ + archived: true, + locked: true + }); + } + } catch (e) { + console.error('Error closing ticket due to inactivity:'); + console.error(e); + } + } +} + +export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise { + const guildSettings = await Database.getGuildSettings(guild.id); + + if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null; + + const channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel; + + const thread = await channel.threads.create({ + name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'), + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + invitable: false, + type: ChannelType.PrivateThread + }) as PrivateThreadChannel; + + if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id); + + if (creator) { + const closeButton = new ButtonBuilder() + .setCustomId('close-ticket') + .setLabel('Click here if you no longer need help') + .setStyle(ButtonStyle.Danger); + + const claimButton = new ButtonBuilder() + .setCustomId('claim-ticket') + .setLabel('Claim ticket') + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder().addComponents(closeButton, claimButton); + + await thread.send({ + content: `${creator} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`, + components: [row], + allowedMentions: { + users: [creator.id], + roles: [guildSettings.private_help_role_id] + } + }); + } else { + const closeButton = new ButtonBuilder() + .setCustomId('close-mod-ticket') + .setLabel('Close Mod Ticket') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(closeButton); + + await thread.send({ + content: reason, + components: [row], + allowedMentions: { + users: Array.from(new Set(additionalMembersToAdd)) + } + }); + } + + for (const id of additionalMembersToAdd) { + await thread.members.add(id); + } + + return thread; +} + +export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) { + const modal = new ModalBuilder() + .setCustomId(`open-mod-ticket_${member.id}`) + .setTitle('Opening A Mod Ticket'); + + const titleLabel = createTextInput('title', 'Title', `The name of the thread. Defaults to "Mod Ticket For ${member.displayName}" if left empty`, false, TextInputStyle.Short, 100, null); + const initialMessageLabel = createTextInput('initial-message', 'Inital Message', 'The inital message sent in the thread', false, TextInputStyle.Paragraph, 1800, null); + const autoJoinLabel = createYesNoMenu('auto-join-thread', 'Auto Join Thread', 'Whether or not to join you to the thread. Selecting no will not notify you of messages sent!', true); + + modal.addLabelComponents(titleLabel, initialMessageLabel, autoJoinLabel); + + interaction.showModal(modal); } \ No newline at end of file diff --git a/src/utils/record-utils.ts b/src/utils/record-utils.ts index f0f5dff..3f0091f 100644 --- a/src/utils/record-utils.ts +++ b/src/utils/record-utils.ts @@ -1,103 +1,103 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js'; -import { config } from '../config'; -import { E621User, MessageContent, Record, RecordCategory } from '../types'; -import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils'; -import { getE621User, getUserRecords } from './e621-utils'; - -type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User } -type AllRecords = RecordWithUserData[]; - -const RECORDS_PER_PAGE = 5; - -export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise { - const altData = await comprehensiveAltLookupFromDiscord(id, guild); - const userCache: Map = new Map(); - - const allRecords: AllRecords = []; - - const e621UserIds = e621IdsFromAltData(altData); - - for (const id of e621UserIds) { - const user = userCache.get(id) ?? await getE621User(id); - if (!user) continue; - userCache.set(id, user); - - const records = await getUserRecords(id); - for (const record of records) { - const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id); - if (!creator) continue; - userCache.set(record.creator_id, creator); - - const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id); - if (!updater) continue; - userCache.set(record.updater_id, updater); - - allRecords.push({ - user, - creator, - updater, - ...record - }); - } - } - - return allRecords; -} - -export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise { - const records = await getAllRecordsFromDiscordId(id, guild); - - if (records.length == 0) return null; - - page = page - 1; - - const maxPage = Math.floor(records.length / RECORDS_PER_PAGE); - - const embeds: EmbedBuilder[] = []; - - for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) { - if (i >= records.length) break; - - embeds.push(getRecordEmbed(records[i])); - } - - const prevPage = new ButtonBuilder() - .setLabel('Previous Page') - .setCustomId(`records-previous_${id}_${page + 1}`) - .setDisabled(page == 0) - .setStyle(ButtonStyle.Primary); - - const nextPage = new ButtonBuilder() - .setLabel('Next Page') - .setCustomId(`records-next_${id}_${page + 1}`) - .setDisabled(page >= maxPage) - .setStyle(ButtonStyle.Primary); - - const row = new ActionRowBuilder() - .addComponents(prevPage, nextPage); - - return { content: `Records found for <@${id}>`, embeds, components: [row] }; -} - -function getRecordColor(category: RecordCategory) { - switch (category) { - case 'positive': return 0x00ff00; - case 'negative': return 0xff0000; - case 'neutral': return 0xaaaaaa; - } -} - -function getRecordEmbed(record: RecordWithUserData): EmbedBuilder { - const isUpdated = record.updated_at != record.created_at; - const creator = isUpdated ? record.updater : record.creator; - return new EmbedBuilder() - .setColor(getRecordColor(record.category)) - .setTitle(`Record from ${record.creator.name} for ${record.user.name}`) - .setDescription(record.body.trim()) - .setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`) - .setAuthor({ - name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`, - url: `${config.E621_BASE_URL}/users/${creator.id}` - }) - .setTimestamp(new Date(record.updated_at)); +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js'; +import { config } from '../config'; +import { E621User, MessageContent, Record, RecordCategory } from '../types'; +import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils'; +import { getE621User, getUserRecords } from './e621-utils'; + +type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User } +type AllRecords = RecordWithUserData[]; + +const RECORDS_PER_PAGE = 5; + +export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise { + const altData = await comprehensiveAltLookupFromDiscord(id, guild); + const userCache: Map = new Map(); + + const allRecords: AllRecords = []; + + const e621UserIds = e621IdsFromAltData(altData); + + for (const id of e621UserIds) { + const user = userCache.get(id) ?? await getE621User(id); + if (!user) continue; + userCache.set(id, user); + + const records = await getUserRecords(id); + for (const record of records) { + const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id); + if (!creator) continue; + userCache.set(record.creator_id, creator); + + const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id); + if (!updater) continue; + userCache.set(record.updater_id, updater); + + allRecords.push({ + user, + creator, + updater, + ...record + }); + } + } + + return allRecords; +} + +export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise { + const records = await getAllRecordsFromDiscordId(id, guild); + + if (records.length == 0) return null; + + page = page - 1; + + const maxPage = Math.floor(records.length / RECORDS_PER_PAGE); + + const embeds: EmbedBuilder[] = []; + + for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) { + if (i >= records.length) break; + + embeds.push(getRecordEmbed(records[i])); + } + + const prevPage = new ButtonBuilder() + .setLabel('Previous Page') + .setCustomId(`records-previous_${id}_${page + 1}`) + .setDisabled(page == 0) + .setStyle(ButtonStyle.Primary); + + const nextPage = new ButtonBuilder() + .setLabel('Next Page') + .setCustomId(`records-next_${id}_${page + 1}`) + .setDisabled(page >= maxPage) + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder() + .addComponents(prevPage, nextPage); + + return { content: `Records found for <@${id}>`, embeds, components: [row] }; +} + +function getRecordColor(category: RecordCategory) { + switch (category) { + case 'positive': return 0x00ff00; + case 'negative': return 0xff0000; + case 'neutral': return 0xaaaaaa; + } +} + +function getRecordEmbed(record: RecordWithUserData): EmbedBuilder { + const isUpdated = record.updated_at != record.created_at; + const creator = isUpdated ? record.updater : record.creator; + return new EmbedBuilder() + .setColor(getRecordColor(record.category)) + .setTitle(`Record from ${record.creator.name} for ${record.user.name}`) + .setDescription(record.body.trim()) + .setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`) + .setAuthor({ + name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`, + url: `${config.E621_BASE_URL}/users/${creator.id}` + }) + .setTimestamp(new Date(record.updated_at)); } \ No newline at end of file diff --git a/src/utils/refresh-commands.ts b/src/utils/refresh-commands.ts index 967ea68..35de21c 100644 --- a/src/utils/refresh-commands.ts +++ b/src/utils/refresh-commands.ts @@ -1,71 +1,71 @@ -import { Client, REST, Routes } from 'discord.js'; -import { config } from '../config'; -import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js'; -import fs from 'fs'; -import { Command } from '../types'; -import path from 'path'; - -const ROOT_DIR = path.resolve(__dirname, '..'); - -const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!); - -export async function refreshCommands(client: Client) { - try { - const commands: RESTPostAPIApplicationCommandsJSONBody[] = []; - const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {}; - const commandFiles = { - commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), - 'context-menus': fs.readdirSync(`${ROOT_DIR}/context-menus`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), - }; - - for (const [folderName, files] of Object.entries(commandFiles)) { - for (const file of files) { - const p = `${ROOT_DIR}/${folderName}/${file}`; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const command: Command = require(p).default; - - if (!command) { - console.warn(`File at ${p} has no export. Skipping registering.`); - continue; - } - - let data: RESTPostAPIApplicationCommandsJSONBody; - if (typeof (command.data) == 'function') { - data = (await command.data(client)).toJSON(); - } else { - data = command.data.toJSON(); - } - - if (!command.guilds) { - commands.push(data); - } else { - for (const id of command.guilds) { - if (!guildCommands[id]) guildCommands[id] = []; - guildCommands[id].push(data); - } - } - } - } - - console.log('Started refreshing application (/) commands.'); - - console.log('Global commands: ' + commands.length); - await rest.put( - Routes.applicationCommands(config.DISCORD_CLIENT_ID!), - { body: commands } - ); - - for (const guild in guildCommands) { - console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')'); - await rest.put( - Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild), - { body: guildCommands[guild] } - ); - } - - console.log('Successfully reloaded application (/) commands.'); - } catch (error: any) { - console.error(error); - console.error(JSON.stringify(error.requestBody, null, 4)); - } +import { Client, REST, Routes } from 'discord.js'; +import { config } from '../config'; +import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js'; +import fs from 'fs'; +import { Command } from '../types'; +import path from 'path'; + +const ROOT_DIR = path.resolve(__dirname, '..'); + +const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!); + +export async function refreshCommands(client: Client) { + try { + const commands: RESTPostAPIApplicationCommandsJSONBody[] = []; + const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {}; + const commandFiles = { + commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), + 'context-menus': fs.readdirSync(`${ROOT_DIR}/context-menus`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), + }; + + for (const [folderName, files] of Object.entries(commandFiles)) { + for (const file of files) { + const p = `${ROOT_DIR}/${folderName}/${file}`; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const command: Command = require(p).default; + + if (!command) { + console.warn(`File at ${p} has no export. Skipping registering.`); + continue; + } + + let data: RESTPostAPIApplicationCommandsJSONBody; + if (typeof (command.data) == 'function') { + data = (await command.data(client)).toJSON(); + } else { + data = command.data.toJSON(); + } + + if (!command.guilds) { + commands.push(data); + } else { + for (const id of command.guilds) { + if (!guildCommands[id]) guildCommands[id] = []; + guildCommands[id].push(data); + } + } + } + } + + console.log('Started refreshing application (/) commands.'); + + console.log('Global commands: ' + commands.length); + await rest.put( + Routes.applicationCommands(config.DISCORD_CLIENT_ID!), + { body: commands } + ); + + for (const guild in guildCommands) { + console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')'); + await rest.put( + Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild), + { body: guildCommands[guild] } + ); + } + + console.log('Successfully reloaded application (/) commands.'); + } catch (error: any) { + console.error(error); + console.error(JSON.stringify(error.requestBody, null, 4)); + } }; \ No newline at end of file diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts index 590e67e..9132f11 100644 --- a/src/utils/string-utils.ts +++ b/src/utils/string-utils.ts @@ -1,6 +1,6 @@ -export function humanizeCapitalization(str: string): string { - return str.toLowerCase() - .split(' ') - .map(s => s.charAt(0).toUpperCase() + s.substring(1)) - .join(' '); +export function humanizeCapitalization(str: string): string { + return str.toLowerCase() + .split(' ') + .map(s => s.charAt(0).toUpperCase() + s.substring(1)) + .join(' '); } \ No newline at end of file diff --git a/src/utils/ticket-events.ts b/src/utils/ticket-events.ts index 818b0d7..bf28059 100644 --- a/src/utils/ticket-events.ts +++ b/src/utils/ticket-events.ts @@ -1,381 +1,381 @@ -import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js'; -import { config } from '../config'; -import { Database } from '../shared/Database'; -import { Ticket, TicketPhrase, TicketUpdate } from '../types'; -import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils'; -import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex'; -import { humanizeCapitalization } from './string-utils'; -import { shouldAlert } from './ticket-utils'; - -// TODO: Condense this and the message event handler regex array. -const linkReplacers = [ - { - regex: blipIDRegex, - replacement: '/blips/{match}', - encodeURI: false - }, - { - regex: commentIDRegex, - replacement: '/comments/{match}', - encodeURI: false - }, - { - regex: forumTopicIDRegex, - replacement: '/forum_topics/{match}', - encodeURI: false - }, - { - regex: poolIDRegex, - replacement: '/pools/{match}', - encodeURI: false - }, - { - regex: postIDRegex, - tester: async (postId: string, before: string, after: string) => { - const post = await getE621Post(postId); - if (!post) return { allowed: true, before, after }; - const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist; - - return { allowed, before, after }; - }, - replacement: '/posts/{match}', - encodeURI: false - }, - { - regex: recordIDRegex, - replacement: '/user_feedbacks/{match}', - encodeURI: false - }, - { - regex: searchLinkRegex, - replacement: '/posts?tags={match}', - encodeURI: true - }, - { - regex: setIDRegex, - replacement: '/post_sets/{match}', - encodeURI: false - }, - { - regex: takedownIDRegex, - replacement: '/takedowns/{match}', - encodeURI: false - }, - { - regex: ticketIDRegex, - replacement: '/tickets/{match}', - encodeURI: false - }, - { - regex: userIDRegex, - replacement: '/users/{match}', - encodeURI: false - }, - { - regex: wikiLinkRegex, - replacement: '/wiki_pages/{match}', - encodeURI: true - } -]; - -const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi'); - -const MAX_DESCRIPTION_LENGTH = 500; - -export async function ticketUpdateHandler(client: Client, update: string) { - const data: TicketUpdate = JSON.parse(update); - - if (data.action == 'create') { - postTicket(client, data); - } else { - updateTicket(client, data); - } -} - -async function postTicket(client: Client, data: TicketUpdate) { - const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); - - if (!guildSettings || !guildSettings.tickets_channel_id) return; - - const channel = await client.channels.fetch(guildSettings.tickets_channel_id); - - if (!channel || !channel.isSendable()) return; - - const ticket = data.ticket; - - const embed = await createEmbedFromTicket(ticket); - - const row = await getButtons(ticket); - - const message = await channel.send({ embeds: [embed], components: [row] }); - - await Database.putTicket(ticket.id, message.id); - - sendTicketAlerts(ticket, channel); -} - -async function updateTicket(client: Client, data: TicketUpdate) { - const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); - - if (!guildSettings || !guildSettings.tickets_channel_id) return; - - const channel = await client.channels.fetch(guildSettings.tickets_channel_id); - - if (!channel || !channel.isSendable()) return; - - const messageId = await Database.getTicketMessageId(data.ticket.id); - - if (!messageId) return postTicket(client, data); - - const message = await channel.messages.fetch(messageId); - const embed = await createEmbedFromTicket(data.ticket); - - if (!message || message.author.id != config.DISCORD_CLIENT_ID) { - const newMessage = await channel.send({ embeds: [embed] }); - await Database.removeTicket(data.ticket.id); - await Database.putTicket(data.ticket.id, newMessage.id); - } else { - await message.edit({ embeds: [embed] }); - } -} - -function getTitle(ticket: Ticket): string { - if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`; - - switch (ticket.category) { - case 'blip': - return `Blip by ${ticket.target}`; - case 'comment': - return `Comment by ${ticket.target}`; - case 'dmail': - return `DMail sent by ${ticket.target}`; - case 'forum': - return `Forum post by ${ticket.target}`; - case 'pool': - return `Pool ${ticket.target}`; - case 'post': - return `Post uploaded by ${ticket.target}`; - case 'set': - return `Wow, a rare set report! ${ticket.target}`; - case 'user': - return `User ${ticket.target}`; - case 'wiki': - return `Wiki page ${ticket.target}`; - default: - return 'Uknown ticket category'; - } -} - -function getURL(ticket: Ticket): string { - return `${config.E621_BASE_URL}/tickets/${ticket.id}`; -} - -async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise { - const length = input.length; - - const replacedIndexes: { start: number, end: number }[] = []; - const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = []; - - for (const replacer of linkReplacers) { - input = input.replaceAll(replacer.regex, (match, group1) => { - const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`; - if (replacer.tester) checks.push(replacer.tester(group1, match, replaced)); - const start = input.indexOf(match); - replacedIndexes.push({ start, end: start + replaced.length }); - - return replaced; - }); - } - - input = input.replaceAll(urlRegex, (match, group1, group2) => { - const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`; - const start = input.indexOf(match); - replacedIndexes.push({ start, end: start + replaced.length }); - return replaced; - }); - - const values = await Promise.all(checks); - - for (const check of values) { - if (!check.allowed) { - input = input.replace(check.after, check.before); - } - } - - if (length > limit) { - for (const replacedIndex of replacedIndexes) { - if (replacedIndex.start < limit && replacedIndex.end >= limit) { - return input.substring(0, replacedIndex.end) + '...'; - } - } - - return input.substring(0, limit) + '...'; - } - - return input; -} - -async function getDescription(ticket: Ticket): Promise { - return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH); -} - -function getAuthor(ticket: Ticket): EmbedAuthorOptions { - return { - url: `${config.E621_BASE_URL}/users/${ticket.user_id}`, - name: ticket.user - }; -} - -function getColor(ticket: Ticket): number { - if (!ticket.claimant) { - return 0xff0000; - } else { - return 0x00ffff; - } -} - -function getFields(ticket: Ticket): APIEmbedField[] { - return [ - { - name: 'Type', - value: ticket.category, - inline: true - }, - { - name: 'Status', - value: ticket.status, - inline: true - }, - { - name: 'Claimed By', - value: !ticket.claimant ? '' : ticket.claimant, - inline: true - } - ]; -} - -async function createEmbedFromTicket(ticket: Ticket): Promise { - return new EmbedBuilder() - .setTitle(getTitle(ticket)) - .setURL(await getURL(ticket)) - .setDescription(await getDescription(ticket)) - .setAuthor(getAuthor(ticket)) - .setColor(getColor(ticket)) - .setFields(...getFields(ticket)) - .setFooter({ text: `Ticket #${ticket.id}` }); -} - -async function getButtons(ticket: Ticket): Promise> { - const row = new ActionRowBuilder(); - - const primaryButton = new ButtonBuilder() - .setStyle(ButtonStyle.Link); - - let skipPrimary = false; - - if (ticket.category == 'blip') { - primaryButton - .setLabel('Open Blip') - .setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`); - } else if (ticket.category == 'comment') { - primaryButton - .setLabel('Open Comment') - .setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`); - } else if (ticket.category == 'dmail') { - primaryButton - .setLabel('Open DMail') - .setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`); - } else if (ticket.category == 'forum') { - primaryButton - .setLabel('Open Forum Post') - .setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`); - } else if (ticket.category == 'pool') { - primaryButton - .setLabel('Open Pool') - .setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`); - } else if (ticket.category == 'post') { - const post = await getE621Post(ticket.target_id); - - if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true; - else { - primaryButton - .setLabel('Open Post') - .setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`); - } - } else if (ticket.category == 'set') { - primaryButton - .setLabel('Open Set') - .setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`); - } else if (ticket.category == 'user') { - primaryButton - .setLabel('Open User') - .setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`); - } else if (ticket.category == 'wiki') { - primaryButton - .setLabel('Open Wiki') - .setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`); - } else { - console.error('Unknown ticket type:'); - console.error(JSON.stringify(ticket, null, 2)); - skipPrimary = true; - } - - if (!skipPrimary) row.addComponents(primaryButton); - - if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') { - const button = new ButtonBuilder() - .setLabel('Open Target User') - .setStyle(ButtonStyle.Link) - .setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`); - - row.addComponents(button); - } else if (ticket.category == 'post') { - const user = await getE621User(ticket.target!); - - if (user) { - const button = new ButtonBuilder() - .setLabel('Open Target User') - .setStyle(ButtonStyle.Link) - .setURL(`${config.E621_BASE_URL}/users/${user.id}`); - - row.addComponents(button); - } - } - - return row; -} - -async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) { - const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); - - if (!guildSettings || !guildSettings.admin_role_id) return; - - const usersToMention: string[] = []; - const rolesToMention: string[] = []; - let content = ''; - - await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => { - const { alert, match } = shouldAlert(ticketPhrase, ticket); - if (alert) { - const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`; - - if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) { - rolesToMention.push(guildSettings.admin_role_id!); - } else if (!usersToMention.includes(ticketPhrase.user_id)) { - usersToMention.push(ticketPhrase.user_id); - } - - content += `${mention}: ${match}\n`; - } - }); - - if (content.length == 0) return; - - await channel.send({ - content, - allowedMentions: { - users: usersToMention, - roles: rolesToMention - } - }); +import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js'; +import { config } from '../config'; +import { Database } from '../shared/Database'; +import { Ticket, TicketPhrase, TicketUpdate } from '../types'; +import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils'; +import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex'; +import { humanizeCapitalization } from './string-utils'; +import { shouldAlert } from './ticket-utils'; + +// TODO: Condense this and the message event handler regex array. +const linkReplacers = [ + { + regex: blipIDRegex, + replacement: '/blips/{match}', + encodeURI: false + }, + { + regex: commentIDRegex, + replacement: '/comments/{match}', + encodeURI: false + }, + { + regex: forumTopicIDRegex, + replacement: '/forum_topics/{match}', + encodeURI: false + }, + { + regex: poolIDRegex, + replacement: '/pools/{match}', + encodeURI: false + }, + { + regex: postIDRegex, + tester: async (postId: string, before: string, after: string) => { + const post = await getE621Post(postId); + if (!post) return { allowed: true, before, after }; + const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist; + + return { allowed, before, after }; + }, + replacement: '/posts/{match}', + encodeURI: false + }, + { + regex: recordIDRegex, + replacement: '/user_feedbacks/{match}', + encodeURI: false + }, + { + regex: searchLinkRegex, + replacement: '/posts?tags={match}', + encodeURI: true + }, + { + regex: setIDRegex, + replacement: '/post_sets/{match}', + encodeURI: false + }, + { + regex: takedownIDRegex, + replacement: '/takedowns/{match}', + encodeURI: false + }, + { + regex: ticketIDRegex, + replacement: '/tickets/{match}', + encodeURI: false + }, + { + regex: userIDRegex, + replacement: '/users/{match}', + encodeURI: false + }, + { + regex: wikiLinkRegex, + replacement: '/wiki_pages/{match}', + encodeURI: true + } +]; + +const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi'); + +const MAX_DESCRIPTION_LENGTH = 500; + +export async function ticketUpdateHandler(client: Client, update: string) { + const data: TicketUpdate = JSON.parse(update); + + if (data.action == 'create') { + postTicket(client, data); + } else { + updateTicket(client, data); + } +} + +async function postTicket(client: Client, data: TicketUpdate) { + const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!guildSettings || !guildSettings.tickets_channel_id) return; + + const channel = await client.channels.fetch(guildSettings.tickets_channel_id); + + if (!channel || !channel.isSendable()) return; + + const ticket = data.ticket; + + const embed = await createEmbedFromTicket(ticket); + + const row = await getButtons(ticket); + + const message = await channel.send({ embeds: [embed], components: [row] }); + + await Database.putTicket(ticket.id, message.id); + + sendTicketAlerts(ticket, channel); +} + +async function updateTicket(client: Client, data: TicketUpdate) { + const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!guildSettings || !guildSettings.tickets_channel_id) return; + + const channel = await client.channels.fetch(guildSettings.tickets_channel_id); + + if (!channel || !channel.isSendable()) return; + + const messageId = await Database.getTicketMessageId(data.ticket.id); + + if (!messageId) return postTicket(client, data); + + const message = await channel.messages.fetch(messageId); + const embed = await createEmbedFromTicket(data.ticket); + + if (!message || message.author.id != config.DISCORD_CLIENT_ID) { + const newMessage = await channel.send({ embeds: [embed] }); + await Database.removeTicket(data.ticket.id); + await Database.putTicket(data.ticket.id, newMessage.id); + } else { + await message.edit({ embeds: [embed] }); + } +} + +function getTitle(ticket: Ticket): string { + if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`; + + switch (ticket.category) { + case 'blip': + return `Blip by ${ticket.target}`; + case 'comment': + return `Comment by ${ticket.target}`; + case 'dmail': + return `DMail sent by ${ticket.target}`; + case 'forum': + return `Forum post by ${ticket.target}`; + case 'pool': + return `Pool ${ticket.target}`; + case 'post': + return `Post uploaded by ${ticket.target}`; + case 'set': + return `Wow, a rare set report! ${ticket.target}`; + case 'user': + return `User ${ticket.target}`; + case 'wiki': + return `Wiki page ${ticket.target}`; + default: + return 'Uknown ticket category'; + } +} + +function getURL(ticket: Ticket): string { + return `${config.E621_BASE_URL}/tickets/${ticket.id}`; +} + +async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise { + const length = input.length; + + const replacedIndexes: { start: number, end: number }[] = []; + const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = []; + + for (const replacer of linkReplacers) { + input = input.replaceAll(replacer.regex, (match, group1) => { + const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`; + if (replacer.tester) checks.push(replacer.tester(group1, match, replaced)); + const start = input.indexOf(match); + replacedIndexes.push({ start, end: start + replaced.length }); + + return replaced; + }); + } + + input = input.replaceAll(urlRegex, (match, group1, group2) => { + const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`; + const start = input.indexOf(match); + replacedIndexes.push({ start, end: start + replaced.length }); + return replaced; + }); + + const values = await Promise.all(checks); + + for (const check of values) { + if (!check.allowed) { + input = input.replace(check.after, check.before); + } + } + + if (length > limit) { + for (const replacedIndex of replacedIndexes) { + if (replacedIndex.start < limit && replacedIndex.end >= limit) { + return input.substring(0, replacedIndex.end) + '...'; + } + } + + return input.substring(0, limit) + '...'; + } + + return input; +} + +async function getDescription(ticket: Ticket): Promise { + return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH); +} + +function getAuthor(ticket: Ticket): EmbedAuthorOptions { + return { + url: `${config.E621_BASE_URL}/users/${ticket.user_id}`, + name: ticket.user + }; +} + +function getColor(ticket: Ticket): number { + if (!ticket.claimant) { + return 0xff0000; + } else { + return 0x00ffff; + } +} + +function getFields(ticket: Ticket): APIEmbedField[] { + return [ + { + name: 'Type', + value: ticket.category, + inline: true + }, + { + name: 'Status', + value: ticket.status, + inline: true + }, + { + name: 'Claimed By', + value: !ticket.claimant ? '' : ticket.claimant, + inline: true + } + ]; +} + +async function createEmbedFromTicket(ticket: Ticket): Promise { + return new EmbedBuilder() + .setTitle(getTitle(ticket)) + .setURL(await getURL(ticket)) + .setDescription(await getDescription(ticket)) + .setAuthor(getAuthor(ticket)) + .setColor(getColor(ticket)) + .setFields(...getFields(ticket)) + .setFooter({ text: `Ticket #${ticket.id}` }); +} + +async function getButtons(ticket: Ticket): Promise> { + const row = new ActionRowBuilder(); + + const primaryButton = new ButtonBuilder() + .setStyle(ButtonStyle.Link); + + let skipPrimary = false; + + if (ticket.category == 'blip') { + primaryButton + .setLabel('Open Blip') + .setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`); + } else if (ticket.category == 'comment') { + primaryButton + .setLabel('Open Comment') + .setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`); + } else if (ticket.category == 'dmail') { + primaryButton + .setLabel('Open DMail') + .setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`); + } else if (ticket.category == 'forum') { + primaryButton + .setLabel('Open Forum Post') + .setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`); + } else if (ticket.category == 'pool') { + primaryButton + .setLabel('Open Pool') + .setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`); + } else if (ticket.category == 'post') { + const post = await getE621Post(ticket.target_id); + + if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true; + else { + primaryButton + .setLabel('Open Post') + .setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`); + } + } else if (ticket.category == 'set') { + primaryButton + .setLabel('Open Set') + .setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`); + } else if (ticket.category == 'user') { + primaryButton + .setLabel('Open User') + .setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`); + } else if (ticket.category == 'wiki') { + primaryButton + .setLabel('Open Wiki') + .setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`); + } else { + console.error('Unknown ticket type:'); + console.error(JSON.stringify(ticket, null, 2)); + skipPrimary = true; + } + + if (!skipPrimary) row.addComponents(primaryButton); + + if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') { + const button = new ButtonBuilder() + .setLabel('Open Target User') + .setStyle(ButtonStyle.Link) + .setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`); + + row.addComponents(button); + } else if (ticket.category == 'post') { + const user = await getE621User(ticket.target!); + + if (user) { + const button = new ButtonBuilder() + .setLabel('Open Target User') + .setStyle(ButtonStyle.Link) + .setURL(`${config.E621_BASE_URL}/users/${user.id}`); + + row.addComponents(button); + } + } + + return row; +} + +async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) { + const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!guildSettings || !guildSettings.admin_role_id) return; + + const usersToMention: string[] = []; + const rolesToMention: string[] = []; + let content = ''; + + await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => { + const { alert, match } = shouldAlert(ticketPhrase, ticket); + if (alert) { + const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`; + + if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) { + rolesToMention.push(guildSettings.admin_role_id!); + } else if (!usersToMention.includes(ticketPhrase.user_id)) { + usersToMention.push(ticketPhrase.user_id); + } + + content += `${mention}: ${match}\n`; + } + }); + + if (content.length == 0) return; + + await channel.send({ + content, + allowedMentions: { + users: usersToMention, + roles: rolesToMention + } + }); } \ No newline at end of file diff --git a/src/utils/ticket-utils.ts b/src/utils/ticket-utils.ts index 61422a1..a91fc39 100644 --- a/src/utils/ticket-utils.ts +++ b/src/utils/ticket-utils.ts @@ -1,38 +1,38 @@ -import { Ticket, TicketPhrase } from '../types'; - -function friendlyPhrase(phrase: string): string { - switch (phrase) { - case 'underage porn': - case 'child porn': - case 'cp': - return 'Code Red'; - default: - return phrase; - } -} - -export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } { - if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) { - if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) { - return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) }; - } else { - return { alert: false }; - } - } else { - try { - const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i'); - - const regexMatch = regex.exec(ticket.reason); - - if (regexMatch) { - return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` }; - } else { - return { alert: false }; - } - } catch (e) { - console.error(e); - - return { alert: false }; - } - } +import { Ticket, TicketPhrase } from '../types'; + +function friendlyPhrase(phrase: string): string { + switch (phrase) { + case 'underage porn': + case 'child porn': + case 'cp': + return 'Code Red'; + default: + return phrase; + } +} + +export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } { + if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) { + if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) { + return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) }; + } else { + return { alert: false }; + } + } else { + try { + const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i'); + + const regexMatch = regex.exec(ticket.reason); + + if (regexMatch) { + return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` }; + } else { + return { alert: false }; + } + } catch (e) { + console.error(e); + + return { alert: false }; + } + } } \ No newline at end of file diff --git a/src/utils/wait.ts b/src/utils/wait.ts index 9446986..8c45e65 100644 --- a/src/utils/wait.ts +++ b/src/utils/wait.ts @@ -1,3 +1,3 @@ -export function wait(ms) { - return new Promise(r => setTimeout(r, ms)); +export function wait(ms) { + return new Promise(r => setTimeout(r, ms)); } \ No newline at end of file diff --git a/src/utils/whois.ts b/src/utils/whois.ts index bf143fc..1a2635f 100644 --- a/src/utils/whois.ts +++ b/src/utils/whois.ts @@ -1,18 +1,18 @@ -import { CommandInteraction, MessageFlags } from 'discord.js'; -import { getE621Alts } from './alt-utils'; -import { resolveUser } from './discord-user-utils'; - -export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) { - if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); - else await interaction.deferReply(); - - if (!interaction.guild) return interaction.editReply('This command must be used in a server'); - - const user = await resolveUser(interaction.client, valueToUse, interaction.guild); - - if (!user) return interaction.editReply('User not found.'); - - const content = await getE621Alts(user.id, interaction.guild!); - - interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`); +import { CommandInteraction, MessageFlags } from 'discord.js'; +import { getE621Alts } from './alt-utils'; +import { resolveUser } from './discord-user-utils'; + +export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) { + if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + else await interaction.deferReply(); + + if (!interaction.guild) return interaction.editReply('This command must be used in a server'); + + const user = await resolveUser(interaction.client, valueToUse, interaction.guild); + + if (!user) return interaction.editReply('User not found.'); + + const content = await getE621Alts(user.id, interaction.guild!); + + interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`); } \ No newline at end of file diff --git a/src/webserver/index.ts b/src/webserver/index.ts index dcf6240..190ff72 100644 --- a/src/webserver/index.ts +++ b/src/webserver/index.ts @@ -1,303 +1,303 @@ -import express, { Request, Response } from 'express'; -import { config } from '../config'; -import { Database } from '../shared/Database'; -import crypto from 'crypto'; -import session from 'express-session'; -import MemoryStore from 'memorystore'; -import fs from 'fs'; -import path from 'path'; -import { Client } from 'discord.js'; -import bodyParser from 'body-parser'; -import { fixPings, removeIssueLinks } from '../utils/github-user-utils'; -import { logDebug } from '../utils/debug-utils'; -import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils'; - -declare module 'express-session' { - interface SessionData { - username: string; - userId: string; - oauthState: string; - } -} -const GITHUB_REPO_ID = 169334303; - -const DEV_BASE_URL = `http://localhost:${config.PORT}`; -const PROD_BASE_URL = 'https://discord.e621.net'; - -const OAUTH_SCOPES = ['identify', 'guilds.join']; - -const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' }); - -const oauth = new DiscordOAuth2({ - clientId: config.DISCORD_CLIENT_ID!, - clientSecret: config.DISCORD_CLIENT_SECRET!, - redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`, - clientToken: config.DISCORD_TOKEN!, - credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64') -}); - -const enum JoinResponse { - Success = 1, - Error = 2, - Banned = 3, - Underage = 4 -}; - -async function joinGuild(code: string, userId: string, username: string): Promise { - let tokenResponse; - try { - if (Number.isNaN(userId)) return JoinResponse.Error; - if (!username) return JoinResponse.Error; - - const id = Number(userId); - - tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES); - - const user = await oauth.getUser(tokenResponse.access_token); - - if (!user.id || !user.username) { - console.error(`Error joining user (${userId}) to discord. User object missing id or username.`); - console.error(user); - return JoinResponse.Error; - } - - await Database.putUser(id, user); - - const alts = await comprehensiveAltLookupFromE621(id, null); - - if (await checkAltsForFullBans([alts])) return JoinResponse.Banned; - - const response = await oauth.addMember({ - accessToken: tokenResponse.access_token, - guildId: config.DISCORD_GUILD_ID!, - userId: user.id, - nickname: username - }); - - if (config.DEBUG) console.log(response); - - if (!response) return JoinResponse.Error; - } catch (e: any) { - if (e.code == 40007) return JoinResponse.Banned; - else if (e.code == 20024) return JoinResponse.Underage; - - console.error(`Error joining user (${userId}) to discord:`); - console.error(e); - return JoinResponse.Error; - } finally { - if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token); - } - - return JoinResponse.Success; -} - -async function handleInitial(req: Request, res: Response): Promise { - const { username, user_id, time, hash } = req.query; - - if (!username || !user_id || !time || !hash) { - return sendBadRequest(res, 'Missing parameters'); - } - - if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) { - return render(res, 403, 'You took too long to authorize the request. Please try again.'); - } - - const authString = `${username} ${user_id} ${time} ${config.LINK_SECRET}`; - - const digest = crypto.createHash('sha256').update(authString).digest('hex'); - - if (hash !== digest) { - console.error(`Bad auth: ${hash} ${digest}`); - return sendForbidden(res, 'Bad auth'); - } - - const oauthState = crypto.randomBytes(16).toString('hex'); - - const oauthUrl = await oauth.generateOauth2Url({ - state: oauthState, - scope: OAUTH_SCOPES, - type: 'code' - }); - - req.session.username = username as string; - req.session.userId = user_id as string; - req.session.oauthState = oauthState; - - req.session.save((e) => { - if (e) { - console.error('Error saving session:'); - console.error(e); - return sendInteralServerError(res); - } - - res.redirect(oauthUrl); - }); -} - -async function handleCallback(req: Request, res: Response): Promise { - if (!req.session.userId || !req.session.username || !req.session.oauthState) { - return sendForbidden(res, 'Session details missing'); - } - - const state = req.query.state as string; - - if (state != req.session.oauthState) { - console.error('OAuth state mismatch on discord joining'); - return sendForbidden(res, 'OAuth state mismatch'); - } - - const code = req.query.code as string; - - const userId = req.session.userId; - const username = req.session.username; - - req.session.destroy((e) => { - if (e) console.error(e); - }); - - try { - const response = await joinGuild(code, userId, username); - if (response == JoinResponse.Error) { - console.error(`Error joining user: ${username} (${userId})`); - return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.'); - } else if (response == JoinResponse.Banned) { - return sendForbidden(res, 'User is banned.'); - } else if (response == JoinResponse.Underage) { - return sendForbidden(res, 'Discord account flagged as underage by discord.'); - } - } catch (e) { - console.error(e); - return sendInteralServerError(res); - } - - render(res, 200, 'Success', `You have been added to the server. See you there.`); -} - -function sendInteralServerError(res: Response, message: string = '') { - render(res, 500, 'Internal Server Error', message); -} - -function sendForbidden(res: Response, message: string = '') { - render(res, 403, 'Forbidden', message); -} - -function sendBadRequest(res: Response, message: string = '') { - render(res, 400, 'Bad Request', message); -} - -function render(res: Response, code: number, title: string = '', message: string = '') { - res.status(code).setHeader('Content-Type', 'text/html').send(PAGE_TEMPLATE.replaceAll('{{ title }}', title).replaceAll('{{ message }}', message)); -} - -async function handleGithubRelease(client: Client, req: Request, res: Response): Promise { - logDebug('Received github release webhook'); - const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1]; - const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex'); - - if (signature !== computedSignature) { - console.error('Github release webhook signature mismatch'); - return res.sendStatus(401); - } - - res.sendStatus(200); - - const data = JSON.parse(req.body); - logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`); - - if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return; - - const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); - - if (!settings || !settings.github_release_channel) return; - - const channel = await client.channels.fetch(settings.github_release_channel); - - if (!channel || !channel.isSendable()) { - console.error(`Github release channel ${channel ? 'sendable' : 'found'}`); - return; - } - - const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - - const date = new Date(); - - let message = `## [${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}](<${data.release.html_url}>)\n\n${await fixPings(removeIssueLinks(data.release.body))}`; - - logDebug('Sending github release message'); - - const MAX_MESSAGE_LENGTH = 2000; - const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.'; - - if (message.length > MAX_MESSAGE_LENGTH) { - const splitMessage = message.split('\n'); - - message = ''; - - for (const part of splitMessage) { - if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break; - - message += `${part}\n`; - } - - message += ADDITIONAL_PART; - } - - const sentMessage = await channel.send(message); - await sentMessage.startThread({ name: data.release.tag_name }); - - logDebug('Github webhook processed'); -} - -async function checkAltsForFullBans(altData: AltData[]): Promise { - for (const data of altData) { - if (data.type == 'discord') { - try { - const banData = await Database.getBan(data.thisId as string); - if (banData?.full_ban) return true; - } catch (e) { - console.error(e); - } - } - - if (await checkAltsForFullBans(data.alts)) return true; - } - - return false; -} - -export function initializeWebserver(client: Client) { - const app = express(); - - const Store = MemoryStore(session); - - app.set('trust proxy', 1); - - app.use(session({ - secret: config.DISCORD_CLIENT_SECRET!, - cookie: { - secure: !config.DEV_MODE, - httpOnly: !config.DEV_MODE, - sameSite: false, - maxAge: 300000 - }, - store: new Store({ - checkPeriod: 600000, - }), - resave: false, - saveUninitialized: false - })); - - app.get('/', handleInitial); - app.get('/callback', handleCallback); - - app.use(bodyParser.raw({ type: 'application/json' })); - app.post('/release', handleGithubRelease.bind(null, client)); - - app.listen(config.PORT, (error) => { - if (error) { - throw error; - } - - console.log(`Listening on port ${config.PORT}`); - }); +import express, { Request, Response } from 'express'; +import { config } from '../config'; +import { Database } from '../shared/Database'; +import crypto from 'crypto'; +import session from 'express-session'; +import MemoryStore from 'memorystore'; +import fs from 'fs'; +import path from 'path'; +import { Client } from 'discord.js'; +import bodyParser from 'body-parser'; +import { fixPings, removeIssueLinks } from '../utils/github-user-utils'; +import { logDebug } from '../utils/debug-utils'; +import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils'; + +declare module 'express-session' { + interface SessionData { + username: string; + userId: string; + oauthState: string; + } +} +const GITHUB_REPO_ID = 169334303; + +const DEV_BASE_URL = `http://localhost:${config.PORT}`; +const PROD_BASE_URL = 'https://discord.e621.net'; + +const OAUTH_SCOPES = ['identify', 'guilds.join']; + +const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' }); + +const oauth = new DiscordOAuth2({ + clientId: config.DISCORD_CLIENT_ID!, + clientSecret: config.DISCORD_CLIENT_SECRET!, + redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`, + clientToken: config.DISCORD_TOKEN!, + credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64') +}); + +const enum JoinResponse { + Success = 1, + Error = 2, + Banned = 3, + Underage = 4 +}; + +async function joinGuild(code: string, userId: string, username: string): Promise { + let tokenResponse; + try { + if (Number.isNaN(userId)) return JoinResponse.Error; + if (!username) return JoinResponse.Error; + + const id = Number(userId); + + tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES); + + const user = await oauth.getUser(tokenResponse.access_token); + + if (!user.id || !user.username) { + console.error(`Error joining user (${userId}) to discord. User object missing id or username.`); + console.error(user); + return JoinResponse.Error; + } + + await Database.putUser(id, user); + + const alts = await comprehensiveAltLookupFromE621(id, null); + + if (await checkAltsForFullBans([alts])) return JoinResponse.Banned; + + const response = await oauth.addMember({ + accessToken: tokenResponse.access_token, + guildId: config.DISCORD_GUILD_ID!, + userId: user.id, + nickname: username + }); + + if (config.DEBUG) console.log(response); + + if (!response) return JoinResponse.Error; + } catch (e: any) { + if (e.code == 40007) return JoinResponse.Banned; + else if (e.code == 20024) return JoinResponse.Underage; + + console.error(`Error joining user (${userId}) to discord:`); + console.error(e); + return JoinResponse.Error; + } finally { + if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token); + } + + return JoinResponse.Success; +} + +async function handleInitial(req: Request, res: Response): Promise { + const { username, user_id, time, hash } = req.query; + + if (!username || !user_id || !time || !hash) { + return sendBadRequest(res, 'Missing parameters'); + } + + if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) { + return render(res, 403, 'You took too long to authorize the request. Please try again.'); + } + + const authString = `${username} ${user_id} ${time} ${config.LINK_SECRET}`; + + const digest = crypto.createHash('sha256').update(authString).digest('hex'); + + if (hash !== digest) { + console.error(`Bad auth: ${hash} ${digest}`); + return sendForbidden(res, 'Bad auth'); + } + + const oauthState = crypto.randomBytes(16).toString('hex'); + + const oauthUrl = await oauth.generateOauth2Url({ + state: oauthState, + scope: OAUTH_SCOPES, + type: 'code' + }); + + req.session.username = username as string; + req.session.userId = user_id as string; + req.session.oauthState = oauthState; + + req.session.save((e) => { + if (e) { + console.error('Error saving session:'); + console.error(e); + return sendInteralServerError(res); + } + + res.redirect(oauthUrl); + }); +} + +async function handleCallback(req: Request, res: Response): Promise { + if (!req.session.userId || !req.session.username || !req.session.oauthState) { + return sendForbidden(res, 'Session details missing'); + } + + const state = req.query.state as string; + + if (state != req.session.oauthState) { + console.error('OAuth state mismatch on discord joining'); + return sendForbidden(res, 'OAuth state mismatch'); + } + + const code = req.query.code as string; + + const userId = req.session.userId; + const username = req.session.username; + + req.session.destroy((e) => { + if (e) console.error(e); + }); + + try { + const response = await joinGuild(code, userId, username); + if (response == JoinResponse.Error) { + console.error(`Error joining user: ${username} (${userId})`); + return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.'); + } else if (response == JoinResponse.Banned) { + return sendForbidden(res, 'User is banned.'); + } else if (response == JoinResponse.Underage) { + return sendForbidden(res, 'Discord account flagged as underage by discord.'); + } + } catch (e) { + console.error(e); + return sendInteralServerError(res); + } + + render(res, 200, 'Success', `You have been added to the server. See you there.`); +} + +function sendInteralServerError(res: Response, message: string = '') { + render(res, 500, 'Internal Server Error', message); +} + +function sendForbidden(res: Response, message: string = '') { + render(res, 403, 'Forbidden', message); +} + +function sendBadRequest(res: Response, message: string = '') { + render(res, 400, 'Bad Request', message); +} + +function render(res: Response, code: number, title: string = '', message: string = '') { + res.status(code).setHeader('Content-Type', 'text/html').send(PAGE_TEMPLATE.replaceAll('{{ title }}', title).replaceAll('{{ message }}', message)); +} + +async function handleGithubRelease(client: Client, req: Request, res: Response): Promise { + logDebug('Received github release webhook'); + const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1]; + const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex'); + + if (signature !== computedSignature) { + console.error('Github release webhook signature mismatch'); + return res.sendStatus(401); + } + + res.sendStatus(200); + + const data = JSON.parse(req.body); + logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`); + + if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return; + + const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!settings || !settings.github_release_channel) return; + + const channel = await client.channels.fetch(settings.github_release_channel); + + if (!channel || !channel.isSendable()) { + console.error(`Github release channel ${channel ? 'sendable' : 'found'}`); + return; + } + + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + + const date = new Date(); + + let message = `## [${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}](<${data.release.html_url}>)\n\n${await fixPings(removeIssueLinks(data.release.body))}`; + + logDebug('Sending github release message'); + + const MAX_MESSAGE_LENGTH = 2000; + const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.'; + + if (message.length > MAX_MESSAGE_LENGTH) { + const splitMessage = message.split('\n'); + + message = ''; + + for (const part of splitMessage) { + if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break; + + message += `${part}\n`; + } + + message += ADDITIONAL_PART; + } + + const sentMessage = await channel.send(message); + await sentMessage.startThread({ name: data.release.tag_name }); + + logDebug('Github webhook processed'); +} + +async function checkAltsForFullBans(altData: AltData[]): Promise { + for (const data of altData) { + if (data.type == 'discord') { + try { + const banData = await Database.getBan(data.thisId as string); + if (banData?.full_ban) return true; + } catch (e) { + console.error(e); + } + } + + if (await checkAltsForFullBans(data.alts)) return true; + } + + return false; +} + +export function initializeWebserver(client: Client) { + const app = express(); + + const Store = MemoryStore(session); + + app.set('trust proxy', 1); + + app.use(session({ + secret: config.DISCORD_CLIENT_SECRET!, + cookie: { + secure: !config.DEV_MODE, + httpOnly: !config.DEV_MODE, + sameSite: false, + maxAge: 300000 + }, + store: new Store({ + checkPeriod: 600000, + }), + resave: false, + saveUninitialized: false + })); + + app.get('/', handleInitial); + app.get('/callback', handleCallback); + + app.use(bodyParser.raw({ type: 'application/json' })); + app.post('/release', handleGithubRelease.bind(null, client)); + + app.listen(config.PORT, (error) => { + if (error) { + throw error; + } + + console.log(`Listening on port ${config.PORT}`); + }); } \ No newline at end of file diff --git a/src/webserver/templates/page.html b/src/webserver/templates/page.html index 0cb545b..488e2b3 100644 --- a/src/webserver/templates/page.html +++ b/src/webserver/templates/page.html @@ -1,29 +1,29 @@ - - - - - {{ title }} - - - - -

{{ title }}

-

{{ message }}

- - - + + + + + {{ title }} + + + + +

{{ title }}

+

{{ message }}

+ + + diff --git a/tsconfig.json b/tsconfig.json index ab30c4a..3b961de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "CommonJS", - "forceConsistentCasingInFileNames": false, - "inlineSourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "noImplicitAny": false, - "noUnusedLocals": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "strict": true, - "skipLibCheck": true, - "lib": [ - "ES2021.String" - ] - }, - - "include": ["src"], - "exclude": ["node_modules"] +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "forceConsistentCasingInFileNames": false, + "inlineSourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "noImplicitAny": false, + "noUnusedLocals": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "lib": [ + "ES2021.String" + ] + }, + + "include": ["src"], + "exclude": ["node_modules"] } \ No newline at end of file