Merge branch 'master' into scheduler

This commit is contained in:
Nix Krystik
2026-05-29 12:47:46 +00:00
committed by GitHub
99 changed files with 6364 additions and 6360 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
/.git /.git
/data /data
/dist /dist
/node_modules /node_modules
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+3 -3
View File
@@ -1,4 +1,4 @@
node_modules node_modules
dist dist
data/* data/*
.env .env
+3
View File
@@ -0,0 +1,3 @@
{
"files.eol": "\n"
}
+21 -21
View File
@@ -1,21 +1,21 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
RUN npm run build RUN npm run build
# ---------- # ----------
FROM node:22-alpine AS runtime FROM node:22-alpine AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
USER node USER node
CMD ["node", "./dist/index.js"] CMD ["node", "./dist/index.js"]
+50 -50
View File
@@ -1,51 +1,51 @@
## Bot Setup ## Bot Setup
### Prerequisites ### Prerequisites
* Latest version of Docker ([download](https://docs.docker.com/get-docker)) * Latest version of Docker ([download](https://docs.docker.com/get-docker))
* Latest version of Docker Compose ([download](https://docs.docker.com/compose/install)) * Latest version of Docker Compose ([download](https://docs.docker.com/compose/install))
* Git ([download](https://git-scm.com/downloads)) * Git ([download](https://git-scm.com/downloads))
* An [e621ng](https://github.com/e621ng/e621ng) instance ready to start * An [e621ng](https://github.com/e621ng/e621ng) instance ready to start
* A [discord bot application](https://discord.com/developers/applications) * 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 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. If you are on Linux/MacOS you can probably use your package manager.
### Discord application setup ### Discord application setup
1. Create a new application 1. Create a new application
2. Under the "Installation" sidebar 2. Under the "Installation" sidebar
- For "Installation Contexts" select "Guild Install" - For "Installation Contexts" select "Guild Install"
- Set the "Install Link" dropdown to "None" - Set the "Install Link" dropdown to "None"
3. Under the "OAuth2" sidebar 3. Under the "OAuth2" sidebar
- Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening - Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening
4. Under the "Bot" sidebar 4. Under the "Bot" sidebar
- It is recommended to disable "Public Bot" - It is recommended to disable "Public Bot"
- Enable "Server Members Intent" - Enable "Server Members Intent"
- Enable "Message Content 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 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 ### Configuration
1. Copy the `.env.sample` file and rename it to `.env` 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) 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) 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) 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 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`) 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 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` 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` 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 ### Installing dependencies
Run `npm i` to install all node dependencies. This is required to start the bot. Run `npm i` to install all node dependencies. This is required to start the bot.
### Starting the bot ### Starting the bot
e621ng must be up for the bot to start properly and open the connection to the redis database. e621ng must be up for the bot to start properly and open the connection to the redis database.
#### In development #### In development
You can use `npm run dev` to run the typescript without compiling. This will watch for changes and restart when they happen. 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` If desired, you can also build the typescript using `npm run build` and then subsequently run it using `npm run start`
#### In docker #### In docker
1. Run `docker compose build` to build the image 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 2. Run `docker compose up` to start the bot. Changes to any files, including `.env` will require a rebuild
+9 -9
View File
@@ -1,9 +1,9 @@
services: services:
hexerade: hexerade:
restart: unless-stopped restart: unless-stopped
build: . build: .
env_file: '.env' env_file: '.env'
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
+67 -67
View File
@@ -1,68 +1,68 @@
const eslint = require('@eslint/js'); const eslint = require('@eslint/js');
const globals = require('globals'); const globals = require('globals');
const tseslint = require('typescript-eslint'); const tseslint = require('typescript-eslint');
const stylistic = require('@stylistic/eslint-plugin'); const stylistic = require('@stylistic/eslint-plugin');
const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js']; const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js'];
module.exports = tseslint.config( module.exports = tseslint.config(
{ {
ignores, ignores,
extends: [ extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommended ...tseslint.configs.recommended
] ]
}, },
{ {
plugins: { plugins: {
'@stylistic': stylistic '@stylistic': stylistic
}, },
ignores, ignores,
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node ...globals.node
} }
}, },
rules: { rules: {
'no-empty': 'off', 'no-empty': 'off',
'prefer-const': ['error'], 'prefer-const': ['error'],
'no-async-promise-executor': 'off', 'no-async-promise-executor': 'off',
'@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-var-requires': 'off',
'quotes': ['error', 'single', { 'avoidEscape': true }], 'quotes': ['error', 'single', { 'avoidEscape': true }],
'semi': ['error'], 'semi': ['error'],
'@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }], '@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }],
'@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }], '@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }],
'@stylistic/array-bracket-spacing': ['error', 'never'], '@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/block-spacing': ['error'], '@stylistic/block-spacing': ['error'],
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }], '@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', { '@stylistic/comma-dangle': ['error', {
'arrays': 'only-multiline', 'arrays': 'only-multiline',
'objects': 'only-multiline' 'objects': 'only-multiline'
}], }],
'@stylistic/comma-spacing': ['error'], '@stylistic/comma-spacing': ['error'],
'@stylistic/dot-location': ['error', 'property'], '@stylistic/dot-location': ['error', 'property'],
'@stylistic/function-call-spacing': ['error', 'never'], '@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/keyword-spacing': ['error'], '@stylistic/keyword-spacing': ['error'],
'@stylistic/key-spacing': ['error'], '@stylistic/key-spacing': ['error'],
'@stylistic/no-trailing-spaces': ['error'], '@stylistic/no-trailing-spaces': ['error'],
'@stylistic/no-whitespace-before-property': ['error'], '@stylistic/no-whitespace-before-property': ['error'],
'@stylistic/object-curly-newline': ['error', { '@stylistic/object-curly-newline': ['error', {
'multiline': true, 'multiline': true,
'consistent': true 'consistent': true
}], }],
'@stylistic/operator-linebreak': ['error', 'before'], '@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/space-infix-ops': ['error'] '@stylistic/space-infix-ops': ['error']
} }
}, },
{ {
ignores, ignores,
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'], files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: { rules: {
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-namespace': 'off',
}, },
} }
); );
+41 -41
View File
@@ -1,42 +1,42 @@
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'claim-ticket', name: 'claim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch(); const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); 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) 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.' }); 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 guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id); const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id) if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' });
const closeButton = new ButtonBuilder() const closeButton = new ButtonBuilder()
.setCustomId('close-ticket') .setCustomId('close-ticket')
.setLabel('Click here if you no longer need help') .setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger); .setStyle(ButtonStyle.Danger);
const unclaimButton = new ButtonBuilder() const unclaimButton = new ButtonBuilder()
.setCustomId('unclaim-ticket') .setCustomId('unclaim-ticket')
.setLabel('Unclaim ticket') .setLabel('Unclaim ticket')
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, unclaimButton); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, unclaimButton);
await interaction.message.edit({ await interaction.message.edit({
content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`, content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`,
components: [row] components: [row]
}); });
await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` }); await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` });
} }
}; };
+30 -30
View File
@@ -1,31 +1,31 @@
import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js'; import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js';
export default { export default {
name: 'close-mod-ticket', name: 'close-mod-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const channel = await interaction.channel?.fetch(); const channel = await interaction.channel?.fetch();
const guild = await interaction.guild?.fetch(); const guild = await interaction.guild?.fetch();
const member = await guild?.members.fetch(interaction.user.id); const member = await guild?.members.fetch(interaction.user.id);
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread || !member) 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.' }); return interaction.editReply({ content: 'Oops. Something went wrong. Please report this to a staff member.' });
if (!member.permissions.has(PermissionFlagsBits.KickMembers)) if (!member.permissions.has(PermissionFlagsBits.KickMembers))
return interaction.editReply({ content: 'Only staff members may close mod tickets.' }); return interaction.editReply({ content: 'Only staff members may close mod tickets.' });
await interaction.message.edit({ await interaction.message.edit({
content: interaction.message.content, content: interaction.message.content,
components: [] components: []
}); });
await channel.send('This ticket has been closed by staff.'); await channel.send('This ticket has been closed by staff.');
await interaction.editReply({ content: 'Ticket closed.' }); await interaction.editReply({ content: 'Ticket closed.' });
channel.edit({ channel.edit({
archived: true, archived: true,
locked: true locked: true
}); });
} }
}; };
+27 -27
View File
@@ -1,28 +1,28 @@
import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js'; import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'close-ticket', name: 'close-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch(); const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) 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.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong. Please report this to a staff member.' });
await interaction.message.edit({ await interaction.message.edit({
content: interaction.message.content, content: interaction.message.content,
components: [] components: []
}); });
await Database.closePrivateHelpTicket(channel.id); await Database.closePrivateHelpTicket(channel.id);
await channel.send(`This ticket has been closed by ${interaction.user}`); await channel.send(`This ticket has been closed by ${interaction.user}`);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' }); await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' });
channel.edit({ channel.edit({
archived: true, archived: true,
locked: true locked: true
}); });
} }
}; };
+22 -22
View File
@@ -1,23 +1,23 @@
import { ButtonInteraction, Client, MessageFlags } from 'discord.js'; import { ButtonInteraction, Client, MessageFlags } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'dev-watch', name: 'dev-watch',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
const member = await interaction.guild!.members.fetch(interaction.user.id); 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.' }); 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); 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 (!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)) { if (member.roles.cache.has(settings.devwatch_role_id)) {
await member.roles.remove(settings.devwatch_role_id); await member.roles.remove(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' }); await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' });
} else { } else {
await member.roles.add(settings.devwatch_role_id); await member.roles.add(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' }); await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' });
} }
} }
}; };
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client } from 'discord.js'; import { ButtonInteraction, Client } from 'discord.js';
import { getNoteMessage } from '../utils'; import { getNoteMessage } from '../utils';
export default { export default {
name: 'note-next', name: 'note-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) + 1); const message = await getNoteMessage(userId, parseInt(page) + 1);
if (!message) return; if (!message) return;
interaction.editReply(message); interaction.editReply(message);
} }
}; };
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client} from 'discord.js'; import { ButtonInteraction, Client} from 'discord.js';
import { getNoteMessage } from '../utils'; import { getNoteMessage } from '../utils';
export default { export default {
name: 'note-previous', name: 'note-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) - 1); const message = await getNoteMessage(userId, parseInt(page) - 1);
if (!message) return; if (!message) return;
interaction.editReply(message); interaction.editReply(message);
} }
}; };
+39 -39
View File
@@ -1,40 +1,40 @@
import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js'; import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js';
import { createPrivateHelpTicketThread } from '../utils'; import { createPrivateHelpTicketThread } from '../utils';
export default { export default {
name: 'open-ticket-for-reported-message', name: 'open-ticket-for-reported-message',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
const message = await interaction.message.fetch(); const message = await interaction.message.fetch();
const guild = await interaction.guild!.fetch(); const guild = await interaction.guild!.fetch();
const reportEmbed = message.embeds[0]!; const reportEmbed = message.embeds[0]!;
const regex = new RegExp(MessageMentions.UsersPattern); const regex = new RegExp(MessageMentions.UsersPattern);
const reportedMessageUrl = reportEmbed.fields[0].value; const reportedMessageUrl = reportEmbed.fields[0].value;
const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id; const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id;
const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : ''; 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 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]); 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) { if (thread) {
await interaction.reply({ await interaction.reply({
flags: [MessageFlags.Ephemeral], flags: [MessageFlags.Ephemeral],
content: `Ticket created: ${thread}` content: `Ticket created: ${thread}`
}); });
const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket'); const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket');
if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1); if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1);
reportEmbed.fields.push({ reportEmbed.fields.push({
name: 'Private Help Ticket', name: 'Private Help Ticket',
value: thread.url, value: thread.url,
inline: false inline: false
}); });
await message.edit({ embeds: [reportEmbed], components: [] }); await message.edit({ embeds: [reportEmbed], components: [] });
} }
} }
}; };
+19 -19
View File
@@ -1,20 +1,20 @@
import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js'; import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js';
import { canOpenPrivateHelpTicket, createTextInput } from '../utils'; import { canOpenPrivateHelpTicket, createTextInput } from '../utils';
export default { export default {
name: 'private-help', name: 'private-help',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
if (!(await canOpenPrivateHelpTicket(interaction.user.id))) 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.' }); 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() const modal = new ModalBuilder()
.setCustomId('open-ticket-modal') .setCustomId('open-ticket-modal')
.setTitle('Get in contact'); .setTitle('Get in contact');
const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10); const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10);
modal.addLabelComponents(label); modal.addLabelComponents(label);
await interaction.showModal(modal); await interaction.showModal(modal);
} }
}; };
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client } from 'discord.js'; import { ButtonInteraction, Client } from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils'; import { getRecordMessageFromDiscordId } from '../utils';
export default { export default {
name: 'records-next', name: 'records-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!); const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!);
if (!message) return; if (!message) return;
interaction.editReply(message); interaction.editReply(message);
} }
}; };
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client} from 'discord.js'; import { ButtonInteraction, Client} from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils'; import { getRecordMessageFromDiscordId } from '../utils';
export default { export default {
name: 'records-previous', name: 'records-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) { handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate(); await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!); const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!);
if (!message) return; if (!message) return;
interaction.editReply(message); interaction.editReply(message);
} }
}; };
+43 -43
View File
@@ -1,44 +1,44 @@
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'unclaim-ticket', name: 'unclaim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) { handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch(); const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread) if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' }); 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) 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.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.' }); 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 guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id); const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id) if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' });
const closeButton = new ButtonBuilder() const closeButton = new ButtonBuilder()
.setCustomId('close-ticket') .setCustomId('close-ticket')
.setLabel('Click here if you no longer need help') .setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger); .setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder() const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket') .setCustomId('claim-ticket')
.setLabel('Claim ticket') .setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await interaction.message.edit({ await interaction.message.edit({
content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(), content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(),
components: [row] components: [row]
}); });
await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] }); await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] });
} }
}; };
+140 -140
View File
@@ -1,141 +1,141 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils'; import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern); const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default { export default {
name: 'ban', name: 'ban',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('ban') .setName('ban')
.setDescription('Bans a user.') .setDescription('Bans a user.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to ban.') .setDescription('The discord user mention, or ID, to ban.')
.setRequired(true) .setRequired(true)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('reason') .setName('reason')
.setDescription('The reason for the ban') .setDescription('The reason for the ban')
.setRequired(false) .setRequired(false)
.setMaxLength(400) .setMaxLength(400)
) )
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('hours') .setName('hours')
.setDescription('The duration of the ban, added with other options (0 for permanent).') .setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false) .setRequired(false)
) )
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('minutes') .setName('minutes')
.setDescription('The duration of the ban, added with other options (0 for permanent).') .setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false) .setRequired(false)
) )
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('seconds') .setName('seconds')
.setDescription('The duration of the ban, added with other options (0 for permanent).') .setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false) .setRequired(false)
) )
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('delete-message-days') .setName('delete-message-days')
.setDescription('How far back to delete messages (in days, default: 0 days).') .setDescription('How far back to delete messages (in days, default: 0 days).')
.setRequired(false) .setRequired(false)
.setMinValue(0) .setMinValue(0)
.setMaxValue(7) .setMaxValue(7)
) )
.addBooleanOption(option => .addBooleanOption(option =>
option option
.setName('full-ban') .setName('full-ban')
.setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).') .setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction); await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server'); 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.'); if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const input = interaction.options.getString('user', true); const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input); const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input; const idToUse = matches ? matches.groups!.id : input;
const reason = interaction.options.getString('reason') ?? ''; const reason = interaction.options.getString('reason') ?? '';
const hours = (interaction.options.getNumber('hours') ?? 0) * 3.6e+6; const hours = (interaction.options.getNumber('hours') ?? 0) * 3.6e+6;
const minutes = (interaction.options.getNumber('minutes') ?? 0) * 60000; const minutes = (interaction.options.getNumber('minutes') ?? 0) * 60000;
const seconds = (interaction.options.getNumber('seconds') ?? 0) * 1000; const seconds = (interaction.options.getNumber('seconds') ?? 0) * 1000;
const duration = hours + minutes + seconds; const duration = hours + minutes + seconds;
const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400; const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400;
const fullBan = interaction.options.getBoolean('full-ban') ?? false; const fullBan = interaction.options.getBoolean('full-ban') ?? false;
let banMember: GuildMember | null = null; let banMember: GuildMember | null = null;
try { try {
banMember = await interaction.guild.members.fetch(idToUse); banMember = await interaction.guild.members.fetch(idToUse);
} catch (e) { } catch (e) {
// Member not in server. // Member not in server.
} }
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) { if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to ban this user.'); return await interaction.editReply('You do not have permission to ban this user.');
} }
if (banMember && !banMember.bannable) { if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to ban this user.'); return await interaction.editReply('I do not have permission to ban this user.');
} }
const expiresAt = new Date(Date.now() + duration); const expiresAt = new Date(Date.now() + duration);
await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan); await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan);
try { try {
await interaction.guild.bans.create(idToUse, { 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(), reason: (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(),
deleteMessageSeconds: deleteMessageDays deleteMessageSeconds: deleteMessageDays
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return await interaction.editReply("Error banning user (couldn't ban)."); return await interaction.editReply("Error banning user (couldn't ban).");
} }
if (fullBan) { if (fullBan) {
const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild); const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild);
await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt); await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt);
} }
await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`); 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) { async function removeAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) {
for (const data of altData) { for (const data of altData) {
if (data.type == 'discord') { if (data.type == 'discord') {
try { try {
if (!data.banned) { 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()); 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) { } catch (e) {
console.error(e); console.error(e);
} }
} }
await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt); await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt);
} }
} }
+47 -47
View File
@@ -1,48 +1,48 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types'; import { KnowledgebaseItem } from '../types';
export default { export default {
name: 'cite', name: 'cite',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('cite') .setName('cite')
.setDescription('Cite content from the knowledgebase.') .setDescription('Cite content from the knowledgebase.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('name') .setName('name')
.setDescription('The name of the entry.') .setDescription('The name of the entry.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await interaction.deferReply(); await interaction.deferReply();
const id = interaction.options.getInteger('name', true); const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id); const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.'); if (!item) return interaction.editReply('Knowledgebase item not found.');
return interaction.editReply(item.content); return interaction.editReply(item.content);
}, },
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]); if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused(); const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.content.includes(value)); const toRespond = items.filter(i => !value ? true : i.content.includes(value));
if (toRespond.length > 25) toRespond.length = 25; if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({ interaction.respond(toRespond.map(p => ({
name: p.name, name: p.name,
value: p.id value: p.id
}))); })));
} }
}; };
+41 -41
View File
@@ -1,42 +1,42 @@
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export default { export default {
name: 'devwatch', name: 'devwatch',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('devwatch') .setName('devwatch')
.setDescription('Sends a dev watch role toggle button.') .setDescription('Sends a dev watch role toggle button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option => .addStringOption(option =>
option option
.setName('content') .setName('content')
.setDescription('The content of the message.') .setDescription('The content of the message.')
.setRequired(false) .setRequired(false)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('button-label') .setName('button-label')
.setDescription('The button label.') .setDescription('The button label.')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable()) if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? ''; const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role'; const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role';
const button = new ButtonBuilder() const button = new ButtonBuilder()
.setCustomId('dev-watch') .setCustomId('dev-watch')
.setStyle(ButtonStyle.Primary) .setStyle(ButtonStyle.Primary)
.setLabel(label); .setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button); .addComponents(button);
await interaction.channel.send({ components: [row], content }); await interaction.channel.send({ components: [row], content });
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' });
} }
}; };
+41 -41
View File
@@ -1,42 +1,42 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { deferInteraction, getDiscordAlts, getE621User } from '../utils'; import { deferInteraction, getDiscordAlts, getE621User } from '../utils';
export default { export default {
name: 'finduser', name: 'finduser',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('finduser') .setName('finduser')
.setDescription("Find a user's discord account based on their e621 usernamename or id.") .setDescription("Find a user's discord account based on their e621 usernamename or id.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The e621 username or e621 id to find the discord user of.') .setDescription('The e621 username or e621 id to find the discord user of.')
.setRequired(true) .setRequired(true)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction); await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server'); if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getString('user', true); const user = interaction.options.getString('user', true);
try { try {
const e621User = await getE621User(user); const e621User = await getE621User(user);
if (!e621User) { if (!e621User) {
return interaction.editReply('I got lost along the way. Who again?'); return interaction.editReply('I got lost along the way. Who again?');
} }
const content = await getDiscordAlts(e621User.id, interaction.guild, 1, [e621User.id]); 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}`); interaction.editReply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s e621 and discord account(s):\n${content}`);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
interaction.editReply('I got lost in the net.'); interaction.editReply('I got lost in the net.');
} }
} }
}; };
+86 -86
View File
@@ -1,87 +1,87 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
const mentionRegex = new RegExp(MessageMentions.UsersPattern); const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default { export default {
name: 'github-mapping', name: 'github-mapping',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('github-mapping') .setName('github-mapping')
.setDescription('Maps github users to discord ids for releases.') .setDescription('Maps github users to discord ids for releases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('add') .setName('add')
.setDescription('Add a user mapping.') .setDescription('Add a user mapping.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('discord-user') .setName('discord-user')
.setDescription('The discord user id, or mention, of the user.') .setDescription('The discord user id, or mention, of the user.')
.setRequired(true) .setRequired(true)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('github-name') .setName('github-name')
.setDescription('The github username of the user (case sensitive).') .setDescription('The github username of the user (case sensitive).')
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Remove a user mapping.') .setDescription('Remove a user mapping.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('discord-user') .setName('discord-user')
.setDescription('The discord user id, or mention, of the user.') .setDescription('The discord user id, or mention, of the user.')
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('list') .setName('list')
.setDescription('List all github-discord mappings.') .setDescription('List all github-discord mappings.')
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true); const subcommand = await interaction.options.getSubcommand(true);
if (subcommand == 'add') { if (subcommand == 'add') {
const discordUserInput = interaction.options.getString('discord-user', true); const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput); const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput; const idToUse = matches ? matches.groups!.id : discordUserInput;
const githubName = interaction.options.getString('github-name', true); const githubName = interaction.options.getString('github-name', true);
const existingMappingId = await Database.getGithubFromDiscordId(idToUse); const existingMappingId = await Database.getGithubFromDiscordId(idToUse);
const existingMappingName = await Database.getDiscordIdFromGithub(githubName); const existingMappingName = await Database.getDiscordIdFromGithub(githubName);
if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`); 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})`); if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`);
Database.putGithubUserMapping(idToUse, githubName); Database.putGithubUserMapping(idToUse, githubName);
return interaction.editReply('Mapping added.'); return interaction.editReply('Mapping added.');
} else if (subcommand == 'remove') { } else if (subcommand == 'remove') {
const discordUserInput = interaction.options.getString('discord-user', true); const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput); const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput; const idToUse = matches ? matches.groups!.id : discordUserInput;
Database.removeGithubUserMapping(idToUse); Database.removeGithubUserMapping(idToUse);
return interaction.editReply('Mapping removed.'); return interaction.editReply('Mapping removed.');
} else if (subcommand == 'list') { } else if (subcommand == 'list') {
const allMappings = await Database.getAllGithubUserMappings(); const allMappings = await Database.getAllGithubUserMappings();
return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n')); return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n'));
} }
} }
}; };
+126 -126
View File
@@ -1,127 +1,127 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js'; import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types'; import { KnowledgebaseItem } from '../types';
import { createTextInput, deferInteraction, logCustomEvent } from '../utils'; import { createTextInput, deferInteraction, logCustomEvent } from '../utils';
export default { export default {
name: 'knowledgebase', name: 'knowledgebase',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('knowledgebase') .setName('knowledgebase')
.setDescription('Access the compendium of knowledge.') .setDescription('Access the compendium of knowledge.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('add') .setName('add')
.setDescription('Add to the knowledgebase.') .setDescription('Add to the knowledgebase.')
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Purge knowledge from the universe.') .setDescription('Purge knowledge from the universe.')
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('name') .setName('name')
.setDescription('The name of the entry to remove.') .setDescription('The name of the entry to remove.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('edit') .setName('edit')
.setDescription('Edit a knowledgebase entry.') .setDescription('Edit a knowledgebase entry.')
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('name') .setName('name')
.setDescription('The name of the entry to edit.') .setDescription('The name of the entry to edit.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
const subcommand = interaction.options.getSubcommand(true); const subcommand = interaction.options.getSubcommand(true);
if (subcommand == 'add') { if (subcommand == 'add') {
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId('add-knowledgebase-item-modal') .setCustomId('add-knowledgebase-item-modal')
.setTitle('Add to knowledgebase'); .setTitle('Add to knowledgebase');
const nameLabel = createTextInput('name', 'Knowledgebase Item Name', null, true, TextInputStyle.Short, 300, 1); 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); const contentLabel = createTextInput('content', 'Item Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(nameLabel, contentLabel); modal.addLabelComponents(nameLabel, contentLabel);
return await interaction.showModal(modal); return await interaction.showModal(modal);
} else if (subcommand == 'remove') { } else if (subcommand == 'remove') {
await deferInteraction(interaction); await deferInteraction(interaction);
const id = interaction.options.getInteger('name', true); const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id); const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.'); if (!item) return interaction.editReply('Knowledgebase item not found.');
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Removed', title: 'Knowledgebase Item Removed',
description: null, description: null,
color: 0xFF0000, color: 0xFF0000,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Name', name: 'Name',
value: item.name, value: item.name,
inline: true inline: true
}, },
{ {
name: 'Content', name: 'Content',
value: item.content, value: item.content,
inline: true inline: true
} }
] ]
}); });
await Database.removeFromKnowledgebase(id); await Database.removeFromKnowledgebase(id);
return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`); return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`);
} else if (subcommand == 'edit') { } else if (subcommand == 'edit') {
const id = interaction.options.getInteger('name', true); const id = interaction.options.getInteger('name', true);
const existingItem = await Database.getFromKnowledgebase(id); const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`edit-knowledgebase-item-modal_${id}`) .setCustomId(`edit-knowledgebase-item-modal_${id}`)
.setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`); .setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`);
const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1); const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(contentLabel); modal.addLabelComponents(contentLabel);
return await interaction.showModal(modal); return await interaction.showModal(modal);
} }
}, },
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]); if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id); const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused(); const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.name.includes(value)); const toRespond = items.filter(i => !value ? true : i.name.includes(value));
if (toRespond.length > 25) toRespond.length = 25; if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({ interaction.respond(toRespond.map(p => ({
name: p.name, name: p.name,
value: p.id value: p.id
}))); })));
} }
}; };
+133 -133
View File
@@ -1,134 +1,134 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { logCustomEvent, resolveUser } from '../utils'; import { logCustomEvent, resolveUser } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern); const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default { export default {
name: 'link', name: 'link',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('link') .setName('link')
.setDescription('Manually link discord users and e621 users.') .setDescription('Manually link discord users and e621 users.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('create') .setName('create')
.setDescription('Create a link.') .setDescription('Create a link.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('discord-user') .setName('discord-user')
.setDescription('The discord user id, or mention, of the user.') .setDescription('The discord user id, or mention, of the user.')
.setRequired(true) .setRequired(true)
) )
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('e621-id') .setName('e621-id')
.setDescription('The id of the e621 user.') .setDescription('The id of the e621 user.')
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Remove a link.') .setDescription('Remove a link.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('discord-user') .setName('discord-user')
.setDescription('The discord user id, or mention, of the user.') .setDescription('The discord user id, or mention, of the user.')
.setRequired(true) .setRequired(true)
) )
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('e621-id') .setName('e621-id')
.setDescription('The id of the e621 user.') .setDescription('The id of the e621 user.')
.setRequired(true) .setRequired(true)
) )
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true); const subcommand = await interaction.options.getSubcommand(true);
const discordUserInput = interaction.options.getString('discord-user', true); const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput); const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput; const idToUse = matches ? matches.groups!.id : discordUserInput;
const user = await resolveUser(client, idToUse, interaction.guild); const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.'); if (!user) return interaction.editReply('User not found.');
const e621Id = interaction.options.getInteger('e621-id', true); const e621Id = interaction.options.getInteger('e621-id', true);
if (subcommand == 'create') { if (subcommand == 'create') {
const existingLinks = await Database.getDiscordIds(e621Id); const existingLinks = await Database.getDiscordIds(e621Id);
if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.'); if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.');
await Database.putUser(e621Id, user); await Database.putUser(e621Id, user);
await logCustomEvent(interaction.guild!, { await logCustomEvent(interaction.guild!, {
title: 'Account Link Created', title: 'Account Link Created',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Admin', name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Discord User', name: 'Discord User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'E621 User', name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`, value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true inline: true
} }
] ]
}); });
interaction.editReply('Accounts linked'); interaction.editReply('Accounts linked');
} else if (subcommand == 'remove') { } else if (subcommand == 'remove') {
const existingLinks = await Database.getDiscordIds(e621Id); const existingLinks = await Database.getDiscordIds(e621Id);
if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.'); if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.');
await Database.removeUser(e621Id, user.id); await Database.removeUser(e621Id, user.id);
await logCustomEvent(interaction.guild!, { await logCustomEvent(interaction.guild!, {
title: 'Account Link Removed', title: 'Account Link Removed',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Admin', name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Discord User', name: 'Discord User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'E621 User', name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`, value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true inline: true
} }
] ]
}); });
interaction.editReply('Accounts unlinked'); interaction.editReply('Accounts unlinked');
} }
} }
}; };
+27 -27
View File
@@ -1,28 +1,28 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { openModTicketModal } from '../utils'; import { openModTicketModal } from '../utils';
export default { export default {
name: 'mod-ticket', name: 'mod-ticket',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('mod-ticket') .setName('mod-ticket')
.setDescription('Opens a mod private ticket and pulls the user into it.') .setDescription('Opens a mod private ticket and pulls the user into it.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The user to pull in to the ticket.') .setDescription('The user to pull in to the ticket.')
.setRequired(true) .setRequired(true)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.editReply('This command must be used in a server.'); if (!interaction.guild) return interaction.editReply('This command must be used in a server.');
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const member = await interaction.guild.members.fetch(user.id); const member = await interaction.guild.members.fetch(user.id);
if (!member) return interaction.editReply('Could not find member.'); if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member); openModTicketModal(interaction, member);
} }
}; };
+35 -35
View File
@@ -1,36 +1,36 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { syncName } from '../utils'; import { syncName } from '../utils';
export default { export default {
name: 'name-sync', name: 'name-sync',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('name-sync') .setName('name-sync')
.setDescription('Sync your discord nickname to your e621 name.') .setDescription('Sync your discord nickname to your e621 name.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.setContexts(InteractionContextType.Guild, InteractionContextType.BotDM) .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM)
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('id') .setName('id')
.setDescription('The id of the e621 user to sync your nickname to.') .setDescription('The id of the e621 user to sync your nickname to.')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const id = interaction.options.getInteger('id'); const id = interaction.options.getInteger('id');
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) { if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
const member = await guild.members.fetch(interaction.user.id); const member = await guild.members.fetch(interaction.user.id);
if (!member || !guild.members.me) { if (!member || !guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
await syncName(interaction, member, id); await syncName(interaction, member, id);
} }
}; };
+238 -238
View File
@@ -1,239 +1,239 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent, resolveUser } from '../utils'; import { deferInteraction, logCustomEvent, resolveUser } from '../utils';
import { getNoteMessage } from '../utils/note-utils'; import { getNoteMessage } from '../utils/note-utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern); const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default { export default {
name: 'notes', name: 'notes',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('notes') .setName('notes')
.setDescription('Add, view, or remove user notes.') .setDescription('Add, view, or remove user notes.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('add') .setName('add')
.setDescription('Add notes to a user.') .setDescription('Add notes to a user.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to add a note to.') .setDescription('The discord user mention, or ID, to add a note to.')
.setRequired(true) .setRequired(true)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('reason') .setName('reason')
.setDescription('The reason for the note.') .setDescription('The reason for the note.')
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('edit') .setName('edit')
.setDescription('Edit notes on a user.') .setDescription('Edit notes on a user.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to edit the notes of.') .setDescription('The discord user mention, or ID, to edit the notes of.')
.setRequired(true) .setRequired(true)
) )
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('note') .setName('note')
.setDescription('The note to edit.') .setDescription('The note to edit.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('new-reason') .setName('new-reason')
.setDescription('The new reason for the note.') .setDescription('The new reason for the note.')
.setRequired(true) .setRequired(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Remove notes from a user.') .setDescription('Remove notes from a user.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to remove a note from.') .setDescription('The discord user mention, or ID, to remove a note from.')
.setRequired(true) .setRequired(true)
) )
.addIntegerOption(option => .addIntegerOption(option =>
option option
.setName('note') .setName('note')
.setDescription('The note to remove.') .setDescription('The note to remove.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('list') .setName('list')
.setDescription("List a user's notes") .setDescription("List a user's notes")
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to list the notes of.') .setDescription('The discord user mention, or ID, to list the notes of.')
.setRequired(true) .setRequired(true)
) )
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommand = interaction.options.getSubcommand(true); const subcommand = interaction.options.getSubcommand(true);
const input = interaction.options.getString('user', true); const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input); const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input; const idToUse = matches ? matches.groups!.id : input;
await deferInteraction(interaction); await deferInteraction(interaction);
const user = await resolveUser(client, idToUse, interaction.guild); const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.'); if (!user) return interaction.editReply('User not found.');
if (subcommand == 'add') { if (subcommand == 'add') {
const reason = interaction.options.getString('reason', true); const reason = interaction.options.getString('reason', true);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Note Added', title: 'Note Added',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Moderator', name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'User', name: 'User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'Note', name: 'Note',
value: reason, value: reason,
inline: true inline: true
} }
] ]
}); });
await Database.putNote(user.id, reason, interaction.user.id); await Database.putNote(user.id, reason, interaction.user.id);
interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`); interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`);
} else if (subcommand == 'remove') { } else if (subcommand == 'remove') {
const noteId = interaction.options.getInteger('note', true); const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id); const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId); const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.'); if (!note) return interaction.editReply('Note not found.');
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Note Removed', title: 'Note Removed',
description: null, description: null,
color: 0xFF0000, color: 0xFF0000,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Moderator', name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'User', name: 'User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'Note', name: 'Note',
value: `${note.reason}\nBy: <@${note.mod_id}>`, value: `${note.reason}\nBy: <@${note.mod_id}>`,
inline: true inline: true
} }
] ]
}); });
await Database.removeNote(noteId); await Database.removeNote(noteId);
interaction.editReply('Removed note.'); interaction.editReply('Removed note.');
} else if (subcommand == 'edit') { } else if (subcommand == 'edit') {
const noteId = interaction.options.getInteger('note', true); const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id); const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId); const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.'); if (!note) return interaction.editReply('Note not found.');
const reason = interaction.options.getString('new-reason', true); const reason = interaction.options.getString('new-reason', true);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Note Edited', title: 'Note Edited',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Moderator', name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'User', name: 'User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'Old reason', name: 'Old reason',
value: note.reason value: note.reason
}, },
{ {
name: 'New reason', name: 'New reason',
value: reason, value: reason,
inline: true inline: true
} }
] ]
}); });
await Database.editNote(noteId, note.reason, reason, interaction.user.id); 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}`); interaction.editReply(`Note on <@${user.id}> (\`${user.username}\` | \`${user.id}\`) edited.\n\nNew reason:\n${reason}`);
} else if (subcommand == 'list') { } else if (subcommand == 'list') {
const noteMessage = await getNoteMessage(user.id, 1); const noteMessage = await getNoteMessage(user.id, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`); if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`);
interaction.editReply(noteMessage); interaction.editReply(noteMessage);
} }
}, },
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const input = interaction.options.getString('user', true); const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input); const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input; const idToUse = matches ? matches.groups!.id : input;
if (!idToUse) return interaction.respond([]); if (!idToUse) return interaction.respond([]);
const value = interaction.options.getFocused().toLowerCase(); const value = interaction.options.getFocused().toLowerCase();
const notes = await Database.getNotes(idToUse); const notes = await Database.getNotes(idToUse);
const toRespond = notes.filter(w => !value ? true : w.reason.toLowerCase().includes(value)); const toRespond = notes.filter(w => !value ? true : w.reason.toLowerCase().includes(value));
if (toRespond.length > 25) toRespond.length = 25; if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map((w) => { interaction.respond(toRespond.map((w) => {
return { return {
name: w.reason.substring(0, 50), name: w.reason.substring(0, 50),
value: w.id value: w.id
}; };
})); }));
} }
}; };
+251 -251
View File
@@ -1,252 +1,252 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js'; import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { TicketPhrase } from '../types'; import { TicketPhrase } from '../types';
import { logCustomEvent } from '../utils'; import { logCustomEvent } from '../utils';
const MIN_PHRASE_LENGTH = 1; const MIN_PHRASE_LENGTH = 1;
const MAX_PHRASE_LENGTH = 512; const MAX_PHRASE_LENGTH = 512;
type SubcommandGroup = 'admin' | 'personal'; type SubcommandGroup = 'admin' | 'personal';
type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge'; type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge';
export default { export default {
name: 'phrases', name: 'phrases',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('phrases') .setName('phrases')
.setDescription('Manage notified phrases.') .setDescription('Manage notified phrases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommandGroup(subcommandGroup => .addSubcommandGroup(subcommandGroup =>
subcommandGroup subcommandGroup
.setName('admin') .setName('admin')
.setDescription('Manage admin notified phrases.') .setDescription('Manage admin notified phrases.')
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('add') .setName('add')
.setDescription('Add an admin notification phrase.') .setDescription('Add an admin notification phrase.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('phrase') .setName('phrase')
.setDescription('The phrase to add.') .setDescription('The phrase to add.')
.setRequired(true) .setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH) .setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH) .setMaxLength(MAX_PHRASE_LENGTH)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Remove an admin notification phrase.') .setDescription('Remove an admin notification phrase.')
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('phrase') .setName('phrase')
.setDescription('The phrase to remove.') .setDescription('The phrase to remove.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('list') .setName('list')
.setDescription('Get a list of the current admin notification phrases.') .setDescription('Get a list of the current admin notification phrases.')
) )
) )
.addSubcommandGroup(subcommandGroup => .addSubcommandGroup(subcommandGroup =>
subcommandGroup subcommandGroup
.setName('personal') .setName('personal')
.setDescription('Manage personal notified phrases.') .setDescription('Manage personal notified phrases.')
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('add') .setName('add')
.setDescription('Add a personal notification phrase.') .setDescription('Add a personal notification phrase.')
.addStringOption(option => .addStringOption(option =>
option option
.setName('phrase') .setName('phrase')
.setDescription('The phrase to add.') .setDescription('The phrase to add.')
.setRequired(true) .setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH) .setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH) .setMaxLength(MAX_PHRASE_LENGTH)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('remove') .setName('remove')
.setDescription('Remove a personal notification phrase.') .setDescription('Remove a personal notification phrase.')
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('phrase') .setName('phrase')
.setDescription('The phrase to remove.') .setDescription('The phrase to remove.')
.setRequired(true) .setRequired(true)
.setAutocomplete(true) .setAutocomplete(true)
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('list') .setName('list')
.setDescription('Get a list of the current personal notification phrases.') .setDescription('Get a list of the current personal notification phrases.')
) )
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('dump') .setName('dump')
.setDescription('List all notification phrases.') .setDescription('List all notification phrases.')
) )
.addSubcommand(subcommand => .addSubcommand(subcommand =>
subcommand subcommand
.setName('purge') .setName('purge')
.setDescription("Purge a user's phrases.") .setDescription("Purge a user's phrases.")
.addUserOption(option => .addUserOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The user to purge the phrases of.') .setDescription('The user to purge the phrases of.')
.setRequired(true) .setRequired(true)
) )
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand; const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand;
switch (subcommand) { switch (subcommand) {
case 'add': case 'add':
return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!); return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!);
case 'remove': case 'remove':
return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!); return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!);
case 'list': case 'list':
return listPhrases(interaction, subcommandGroup!); return listPhrases(interaction, subcommandGroup!);
case 'dump': case 'dump':
return dumpPhrases(interaction); return dumpPhrases(interaction);
case 'purge': case 'purge':
return purgePhrases(interaction, interaction.options.getUser('user', true)); return purgePhrases(interaction, interaction.options.getUser('user', true));
} }
}, },
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) { autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup; const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
if (!subcommandGroup) return interaction.respond([]); if (!subcommandGroup) return interaction.respond([]);
const value = interaction.options.getFocused(); const value = interaction.options.getFocused();
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id); const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id);
const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value)); const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value));
if (toRespond.length > 25) toRespond.length = 25; if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({ interaction.respond(toRespond.map(p => ({
name: p.phrase, name: p.phrase,
value: p.id value: p.id
}))); })));
} }
}; };
async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) { async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) {
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id); const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id);
const count = await Database.removeAllTicketPhrasesFor(user.id); const count = await Database.removeAllTicketPhrasesFor(user.id);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Ticket Phrases Purged', title: 'Ticket Phrases Purged',
description: null, description: null,
color: 0xFF0000, color: 0xFF0000,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Target User', name: 'Target User',
value: `<@${user.id}>\n${user.username}`, value: `<@${user.id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'Count', name: 'Count',
value: count.toString(), value: count.toString(),
inline: true inline: true
} }
] ]
}); });
interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`); interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`);
} }
async function dumpPhrases(interaction: ChatInputCommandInteraction) { async function dumpPhrases(interaction: ChatInputCommandInteraction) {
let content = ''; let content = '';
const guildSettings = await Database.getGuildSettings(interaction.guildId!); const guildSettings = await Database.getGuildSettings(interaction.guildId!);
await Database.getAllTicketPhrases((phrase: TicketPhrase) => { await Database.getAllTicketPhrases((phrase: TicketPhrase) => {
if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return; if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return;
const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`; const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`;
content += `${mention}: \`${phrase.phrase}\`\n`; content += `${mention}: \`${phrase.phrase}\`\n`;
}); });
if (content.length == 0) return interaction.reply('No phrases found.'); if (content.length == 0) return interaction.reply('No phrases found.');
interaction.reply('The following phrases are registered:\n\n' + content); interaction.reply('The following phrases are registered:\n\n' + content);
} }
async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) { async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) {
await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase); await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`, title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`,
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Phrase', name: 'Phrase',
value: phrase, value: phrase,
inline: true inline: true
} }
] ]
}); });
interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`); interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`);
} }
async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) { async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) {
const phrase = await Database.getTicketPhrase(phraseId); const phrase = await Database.getTicketPhrase(phraseId);
if (!phrase) return interaction.reply('Phrase not found'); if (!phrase) return interaction.reply('Phrase not found');
await Database.removeTicketPhrase(phraseId); await Database.removeTicketPhrase(phraseId);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`, title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`,
description: null, description: null,
color: 0xFF0000, color: 0xFF0000,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Phrase', name: 'Phrase',
value: phrase.phrase, value: phrase.phrase,
inline: true inline: true
} }
] ]
}); });
interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`); interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`);
} }
async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) { async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) {
const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id); const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id);
if (phrases.length == 0) return interaction.reply('No phrases registered'); 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')}`); interaction.reply(`The following phrases are registered:\n\n${phrases.map(p => (`- \`${p.phrase}\``)).join('\n')}`);
} }
+44 -44
View File
@@ -1,45 +1,45 @@
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'private-help', name: 'private-help',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('private-help') .setName('private-help')
.setDescription('Setup a private help button.') .setDescription('Setup a private help button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option => .addStringOption(option =>
option option
.setName('content') .setName('content')
.setDescription('The content of the message.') .setDescription('The content of the message.')
.setRequired(false) .setRequired(false)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('button-label') .setName('button-label')
.setDescription('The button label.') .setDescription('The button label.')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable()) if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? ''; const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Get in contact'; const label = interaction.options.getString('button-label') ?? 'Get in contact';
const button = new ButtonBuilder() const button = new ButtonBuilder()
.setCustomId('private-help') .setCustomId('private-help')
.setStyle(ButtonStyle.Primary) .setStyle(ButtonStyle.Primary)
.setLabel(label); .setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button); .addComponents(button);
await interaction.channel.send({ components: [row], content }); await interaction.channel.send({ components: [row], content });
await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId); await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId);
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' }); interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' });
} }
}; };
+44 -44
View File
@@ -1,45 +1,45 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { deferInteraction } from '../utils'; import { deferInteraction } from '../utils';
import { getRecordMessageFromDiscordId } from '../utils/record-utils'; import { getRecordMessageFromDiscordId } from '../utils/record-utils';
export default { export default {
name: 'records', name: 'records',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('records') .setName('records')
.setDescription("Get a user's on-site records.") .setDescription("Get a user's on-site records.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user to find the e621 user of.') .setDescription('The discord user to find the e621 user of.')
.setRequired(false) .setRequired(false)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('id') .setName('id')
.setDescription('The discord user id to find the e621 user of.') .setDescription('The discord user id to find the e621 user of.')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction); await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server'); if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getUser('user'); const user = interaction.options.getUser('user');
const id = interaction.options.getString('id'); const id = interaction.options.getString('id');
if (!user && !id) { if (!user && !id) {
return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] }); return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] });
} }
const idToUse = (user?.id ?? id) as string; const idToUse = (user?.id ?? id) as string;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild); const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage); interaction.editReply(recordMessage);
} }
}; };
+50 -50
View File
@@ -1,51 +1,51 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js';
import { msToHuman } from '../utils'; import { msToHuman } from '../utils';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'rename', name: 'rename',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('rename') .setName('rename')
.setDescription('Rename the general channel.') .setDescription('Rename the general channel.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option => .addStringOption(option =>
option option
.setName('new-name') .setName('new-name')
.setDescription('The new name of the general channel.') .setDescription('The new name of the general channel.')
.setRequired(true) .setRequired(true)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const guildSettings = await Database.getGuildSettings(interaction.guildId!); const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.general_chat_id) { if (!guildSettings || !guildSettings.general_chat_id) {
return interaction.reply('No general chat id found.'); return interaction.reply('No general chat id found.');
} }
const name = interaction.options.getString('new-name', true); const name = interaction.options.getString('new-name', true);
if (name.length > 100) { if (name.length > 100) {
return interaction.reply('Name must be less than 100 characters in length.'); return interaction.reply('Name must be less than 100 characters in length.');
} }
const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!; const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!;
if (!channel) { if (!channel) {
return interaction.reply('No general chat id found.'); return interaction.reply('No general chat id found.');
} }
try { try {
await channel.setName(name); await channel.setName(name);
interaction.reply(`Renamed general to ${channel.name}`); interaction.reply(`Renamed general to ${channel.name}`);
} catch (e: any) { } catch (e: any) {
if (e instanceof RateLimitError) { if (e instanceof RateLimitError) {
return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`); return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`);
} }
console.error(e); console.error(e);
return interaction.reply('An error has occurred.'); return interaction.reply('An error has occurred.');
} }
} }
}; };
+302 -302
View File
@@ -1,303 +1,303 @@
import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export default { export default {
name: 'settings', name: 'settings',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('settings') .setName('settings')
.setDescription('Change server settings.') .setDescription('Change server settings.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('general-channel') .setName('general-channel')
.setDescription('Set the general channel.') .setDescription('Set the general channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('tickets-channel') .setName('tickets-channel')
.setDescription('Set the ticket logs channel.') .setDescription('Set the ticket logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('event-logs-channel') .setName('event-logs-channel')
.setDescription('Set the event logs channel.') .setDescription('Set the event logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('discord-logs-channel') .setName('discord-logs-channel')
.setDescription('Set the discord logs channel.') .setDescription('Set the discord logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('audit-logs-channel') .setName('audit-logs-channel')
.setDescription('Set the audit logs channel.') .setDescription('Set the audit logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('voice-logs-channel') .setName('voice-logs-channel')
.setDescription('Set the voice logs channel.') .setDescription('Set the voice logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('new-member-channel') .setName('new-member-channel')
.setDescription('Set the new member logs channel.') .setDescription('Set the new member logs channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('moderator-channel') .setName('moderator-channel')
.setDescription('Set the site moderator channel.') .setDescription('Set the site moderator channel.')
.setRequired(false) .setRequired(false)
) )
.addRoleOption(option => .addRoleOption(option =>
option option
.setName('admin-role') .setName('admin-role')
.setDescription('Set the admin role.') .setDescription('Set the admin role.')
.setRequired(false) .setRequired(false)
) )
.addRoleOption(option => .addRoleOption(option =>
option option
.setName('private-helper-role') .setName('private-helper-role')
.setDescription('Set the private helper role.') .setDescription('Set the private helper role.')
.setRequired(false) .setRequired(false)
) )
.addRoleOption(option => .addRoleOption(option =>
option option
.setName('devwatch-role') .setName('devwatch-role')
.setDescription('Set the DevWatch role.') .setDescription('Set the DevWatch role.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('add-staff-category') .setName('add-staff-category')
.setDescription('Add a category to staff categories.') .setDescription('Add a category to staff categories.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('remove-staff-category') .setName('remove-staff-category')
.setDescription('Remove a category from staff categories.') .setDescription('Remove a category from staff categories.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('add-safe-channel') .setName('add-safe-channel')
.setDescription('Add a SFW channel.') .setDescription('Add a SFW channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('remove-safe-channel') .setName('remove-safe-channel')
.setDescription('Remove a SFW channel.') .setDescription('Remove a SFW channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('add-link-skip-channel') .setName('add-link-skip-channel')
.setDescription('Add a link skip channel.') .setDescription('Add a link skip channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('remove-link-skip-channel') .setName('remove-link-skip-channel')
.setDescription('Remove a link skip channel.') .setDescription('Remove a link skip channel.')
.setRequired(false) .setRequired(false)
) )
.addChannelOption(option => .addChannelOption(option =>
option option
.setName('github-release-channel') .setName('github-release-channel')
.setDescription('Set the github release channel.') .setDescription('Set the github release channel.')
.setRequired(false) .setRequired(false)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
let response = ''; let response = '';
const settings = await Database.getGuildSettings(interaction.guildId!); const settings = await Database.getGuildSettings(interaction.guildId!);
if (!settings) { if (!settings) {
await Database.putGuild(interaction.guildId!); await Database.putGuild(interaction.guildId!);
} }
const generalChannel = interaction.options.getChannel('general-channel'); const generalChannel = interaction.options.getChannel('general-channel');
if (generalChannel) { if (generalChannel) {
await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id); await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id);
response += `General channel set to ${generalChannel}.\n`; response += `General channel set to ${generalChannel}.\n`;
} }
const ticketsChannel = interaction.options.getChannel('tickets-channel'); const ticketsChannel = interaction.options.getChannel('tickets-channel');
if (ticketsChannel) { if (ticketsChannel) {
await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id); await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id);
response += `Tickets logs channel set to ${ticketsChannel}.\n`; response += `Tickets logs channel set to ${ticketsChannel}.\n`;
} }
const eventLogsChannel = interaction.options.getChannel('event-logs-channel'); const eventLogsChannel = interaction.options.getChannel('event-logs-channel');
if (eventLogsChannel) { if (eventLogsChannel) {
await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id); await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id);
response += `Event logs channel set to ${eventLogsChannel}.\n`; response += `Event logs channel set to ${eventLogsChannel}.\n`;
} }
const discordLogsChannel = interaction.options.getChannel('discord-logs-channel'); const discordLogsChannel = interaction.options.getChannel('discord-logs-channel');
if (discordLogsChannel) { if (discordLogsChannel) {
await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id); await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id);
response += `Discord logs channel set to ${discordLogsChannel}.\n`; response += `Discord logs channel set to ${discordLogsChannel}.\n`;
} }
const auditLogsChannel = interaction.options.getChannel('audit-logs-channel'); const auditLogsChannel = interaction.options.getChannel('audit-logs-channel');
if (auditLogsChannel) { if (auditLogsChannel) {
await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id); await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id);
response += `Audit logs channel set to ${auditLogsChannel}.\n`; response += `Audit logs channel set to ${auditLogsChannel}.\n`;
} }
const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel'); const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel');
if (voiceLogsChannel) { if (voiceLogsChannel) {
await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id); await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id);
response += `Voice logs channel set to ${voiceLogsChannel}.\n`; response += `Voice logs channel set to ${voiceLogsChannel}.\n`;
} }
const newMemberLogsChannel = interaction.options.getChannel('new-member-channel'); const newMemberLogsChannel = interaction.options.getChannel('new-member-channel');
if (newMemberLogsChannel) { if (newMemberLogsChannel) {
await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id); await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id);
response += `New member logs channel set to ${newMemberLogsChannel}.\n`; response += `New member logs channel set to ${newMemberLogsChannel}.\n`;
} }
const moderatorChannel = interaction.options.getChannel('moderator-channel'); const moderatorChannel = interaction.options.getChannel('moderator-channel');
if (moderatorChannel) { if (moderatorChannel) {
await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id); await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id);
response += `Moderator channel set to ${moderatorChannel}.\n`; response += `Moderator channel set to ${moderatorChannel}.\n`;
} }
const adminRole = interaction.options.getRole('admin-role'); const adminRole = interaction.options.getRole('admin-role');
if (adminRole) { if (adminRole) {
await Database.setGuildAdminRole(interaction.guildId!, adminRole.id); await Database.setGuildAdminRole(interaction.guildId!, adminRole.id);
response += `Admin role set to ${adminRole}.\n`; response += `Admin role set to ${adminRole}.\n`;
} }
const privateHelperRole = interaction.options.getRole('private-helper-role'); const privateHelperRole = interaction.options.getRole('private-helper-role');
if (privateHelperRole) { if (privateHelperRole) {
await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id); await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id);
response += `Private helper role set to ${privateHelperRole}.\n`; response += `Private helper role set to ${privateHelperRole}.\n`;
} }
const devWatchRole = interaction.options.getRole('devwatch-role'); const devWatchRole = interaction.options.getRole('devwatch-role');
if (devWatchRole) { if (devWatchRole) {
await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id); await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id);
response += `DevWatch role set to ${devWatchRole}.\n`; response += `DevWatch role set to ${devWatchRole}.\n`;
} }
const addCategory = interaction.options.getChannel('add-staff-category'); const addCategory = interaction.options.getChannel('add-staff-category');
if (addCategory) { if (addCategory) {
if (addCategory.type == ChannelType.GuildCategory) { if (addCategory.type == ChannelType.GuildCategory) {
await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id); await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id);
response += `Added ${addCategory.toString()} as a staff category.\n`; response += `Added ${addCategory.toString()} as a staff category.\n`;
} else { } else {
response += `Error adding staff category: ${addCategory.toString()} isn't a category.`; response += `Error adding staff category: ${addCategory.toString()} isn't a category.`;
} }
} }
const removeCategory = interaction.options.getChannel('remove-staff-category'); const removeCategory = interaction.options.getChannel('remove-staff-category');
if (removeCategory) { if (removeCategory) {
if (removeCategory.type == ChannelType.GuildCategory) { if (removeCategory.type == ChannelType.GuildCategory) {
if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) { if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) {
response += `Removed ${removeCategory.toString()} as a staff category\n`; response += `Removed ${removeCategory.toString()} as a staff category\n`;
} else { } else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`; response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`;
} }
} else { } else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`; response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`;
} }
} }
const addSafeChannel = interaction.options.getChannel('add-safe-channel'); const addSafeChannel = interaction.options.getChannel('add-safe-channel');
if (addSafeChannel) { if (addSafeChannel) {
if (addSafeChannel.type == ChannelType.GuildText) { if (addSafeChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id); await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id);
response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`; response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`;
} else { } else {
response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`; response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`;
} }
} }
const removeSafeChannel = interaction.options.getChannel('remove-safe-channel'); const removeSafeChannel = interaction.options.getChannel('remove-safe-channel');
if (removeSafeChannel) { if (removeSafeChannel) {
if (removeSafeChannel.type == ChannelType.GuildText) { if (removeSafeChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) { if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) {
response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`; response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`;
} else { } else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`; response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`;
} }
} else { } else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`; response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`;
} }
} }
const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel'); const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel');
if (addLinkSkipChannel) { if (addLinkSkipChannel) {
if (addLinkSkipChannel.type == ChannelType.GuildText) { if (addLinkSkipChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id); await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id);
response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`; response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`;
} else { } else {
response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`; response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`;
} }
} }
const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel'); const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel');
if (removeLinkSkipChannel) { if (removeLinkSkipChannel) {
if (removeLinkSkipChannel.type == ChannelType.GuildText) { if (removeLinkSkipChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) { if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) {
response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`; response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`;
} else { } else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`; response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`;
} }
} else { } else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`; response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`;
} }
} }
const githubReleaseChannel = interaction.options.getChannel('github-release-channel'); const githubReleaseChannel = interaction.options.getChannel('github-release-channel');
if (githubReleaseChannel) { if (githubReleaseChannel) {
await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id); await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id);
response += `Github releases channel set to ${githubReleaseChannel}.\n`; response += `Github releases channel set to ${githubReleaseChannel}.\n`;
} }
if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' }); if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' });
interaction.editReply({ content: response }); interaction.editReply({ content: response });
} }
}; };
+80 -80
View File
@@ -1,81 +1,81 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { deferInteraction } from '../utils'; import { deferInteraction } from '../utils';
export default { export default {
name: 'softban', name: 'softban',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('softban') .setName('softban')
.setDescription('Bans and immediately unbans a user to purge messages.') .setDescription('Bans and immediately unbans a user to purge messages.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option => .addUserOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user to softban.') .setDescription('The discord user to softban.')
.setRequired(true) .setRequired(true)
) )
.addStringOption(option => .addStringOption(option =>
option option
.setName('reason') .setName('reason')
.setDescription('The reason for the softban') .setDescription('The reason for the softban')
.setRequired(false) .setRequired(false)
.setMaxLength(400) .setMaxLength(400)
) )
.addNumberOption(option => .addNumberOption(option =>
option option
.setName('days') .setName('days')
.setDescription('How far back to delete messages (in days, default: 7 days).') .setDescription('How far back to delete messages (in days, default: 7 days).')
.setRequired(false) .setRequired(false)
.setMinValue(0) .setMinValue(0)
.setMaxValue(7) .setMaxValue(7)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction); await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server'); 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.'); if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const user = interaction.options.getUser('user', true); const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason') ?? ''; const reason = interaction.options.getString('reason') ?? '';
const seconds = (interaction.options.getNumber('days') ?? 7) * 86400; const seconds = (interaction.options.getNumber('days') ?? 7) * 86400;
let banMember: GuildMember | null = null; let banMember: GuildMember | null = null;
try { try {
banMember = await interaction.guild.members.fetch(user.id); banMember = await interaction.guild.members.fetch(user.id);
} catch (e) { } catch (e) {
// Member not in server. // Member not in server.
} }
const member = await interaction.guild.members.fetch(interaction.user.id); const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) { if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to softban this user.'); return await interaction.editReply('You do not have permission to softban this user.');
} }
if (banMember && !banMember.bannable) { if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to softban this user.'); return await interaction.editReply('I do not have permission to softban this user.');
} }
try { try {
await interaction.guild.bans.create(user, { await interaction.guild.bans.create(user, {
reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(), reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(),
deleteMessageSeconds: seconds deleteMessageSeconds: seconds
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return await interaction.editReply("Error softbanning user (couldn't ban)."); return await interaction.editReply("Error softbanning user (couldn't ban).");
} }
try { try {
await interaction.guild.bans.remove(user); await interaction.guild.bans.remove(user);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return await interaction.editReply("Error softbanning user (couldn't remove ban)."); return await interaction.editReply("Error softbanning user (couldn't remove ban).");
} }
await interaction.editReply('Softban successful'); await interaction.editReply('Softban successful');
} }
}; };
+29 -29
View File
@@ -1,30 +1,30 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern); const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default { export default {
name: 'whois', name: 'whois',
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName('whois') .setName('whois')
.setDescription("Find a user's e621 account from their discord account, or vice versa.") .setDescription("Find a user's e621 account from their discord account, or vice versa.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option => .addStringOption(option =>
option option
.setName('user') .setName('user')
.setDescription('The discord user mention, or ID, to find the e621 user of.') .setDescription('The discord user mention, or ID, to find the e621 user of.')
.setRequired(true) .setRequired(true)
), ),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) { handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const input = interaction.options.getString('user', true); const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input); const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0; mentionRegex.lastIndex = 0;
const valueToUse = matches ? matches.groups!.id : input; const valueToUse = matches ? matches.groups!.id : input;
handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
} }
}; };
+26 -26
View File
@@ -1,27 +1,27 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); 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; 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 = { export const config = {
DISCORD_TOKEN, DISCORD_TOKEN,
DISCORD_CLIENT_ID, DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_CLIENT_SECRET,
DISCORD_GUILD_ID, DISCORD_GUILD_ID,
RELEASE_SECRET, RELEASE_SECRET,
LINK_SECRET, LINK_SECRET,
E621_BASE_URL, E621_BASE_URL,
E926_BASE_URL, E926_BASE_URL,
GIT_REPO_BASE_URL, GIT_REPO_BASE_URL,
PORT: parseInt(PORT as string), PORT: parseInt(PORT as string),
REDIS_URL, REDIS_URL,
DEV_MODE: process.env.npm_lifecycle_event == 'dev', DEV_MODE: process.env.npm_lifecycle_event == 'dev',
DEBUG: DEBUG == 'true' DEBUG: DEBUG == 'true'
}; };
for (const [key, val] of Object.entries(config)) { for (const [key, val] of Object.entries(config)) {
if (val === undefined) { if (val === undefined) {
throw new Error(`${key} is undefined in config`); throw new Error(`${key} is undefined in config`);
} }
} }
+26 -26
View File
@@ -1,27 +1,27 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { createTextInput } from '../utils'; import { createTextInput } from '../utils';
export default { export default {
name: 'Add Note', name: 'Add Note',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('Add Note') .setName('Add Note')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id; const idToUse = interaction.targetUser.id;
const member = await interaction.guild?.members.fetch(idToUse); const member = await interaction.guild?.members.fetch(idToUse);
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`add-note-modal_${idToUse}`) .setCustomId(`add-note-modal_${idToUse}`)
.setTitle(`Adding note to ${member ? member.displayName : idToUse}`); .setTitle(`Adding note to ${member ? member.displayName : idToUse}`);
const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2); const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2);
modal.addLabelComponents(inputLabel); modal.addLabelComponents(inputLabel);
await interaction.showModal(modal); await interaction.showModal(modal);
} }
}; };
+22 -22
View File
@@ -1,23 +1,23 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getNoteMessage } from '../utils'; import { deferInteraction, getNoteMessage } from '../utils';
export default { export default {
name: 'List Notes', name: 'List Notes',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('List Notes') .setName('List Notes')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id; const idToUse = interaction.targetUser.id;
await deferInteraction(interaction); await deferInteraction(interaction);
const noteMessage = await getNoteMessage(idToUse, 1); const noteMessage = await getNoteMessage(idToUse, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`); if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`);
interaction.editReply(noteMessage); interaction.editReply(noteMessage);
} }
}; };
+18 -18
View File
@@ -1,19 +1,19 @@
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js'; import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js';
import { openModTicketModal } from '../utils'; import { openModTicketModal } from '../utils';
export default { export default {
name: 'Open Mod Ticket', name: 'Open Mod Ticket',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('Open Mod Ticket') .setName('Open Mod Ticket')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const member = await interaction.guild?.members.fetch(interaction.targetUser.id); const member = await interaction.guild?.members.fetch(interaction.targetUser.id);
if (!member) return interaction.editReply('Could not find member.'); if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member); openModTicketModal(interaction, member);
} }
}; };
+22 -22
View File
@@ -1,23 +1,23 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js'; import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getRecordMessageFromDiscordId } from '../utils'; import { deferInteraction, getRecordMessageFromDiscordId } from '../utils';
export default { export default {
name: 'Get Records', name: 'Get Records',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('Get Records') .setName('Get Records')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
await deferInteraction(interaction); await deferInteraction(interaction);
const idToUse = interaction.targetUser.id; const idToUse = interaction.targetUser.id;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!); const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage); interaction.editReply(recordMessage);
} }
}; };
+56 -56
View File
@@ -1,57 +1,57 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js'; import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { createTextInput, deferInteraction, syncName } from '../utils'; import { createTextInput, deferInteraction, syncName } from '../utils';
export default { export default {
name: 'Sync Name', name: 'Sync Name',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('Sync Name') .setName('Sync Name')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames) .setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const availableIds = await Database.getE621Ids(interaction.user.id); const availableIds = await Database.getE621Ids(interaction.user.id);
const idToUse = interaction.targetUser.id; const idToUse = interaction.targetUser.id;
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) { if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
const member = await guild.members.fetch(idToUse); const member = await guild.members.fetch(idToUse);
const interactionMember = await guild.members.fetch(interaction.user.id); const interactionMember = await guild.members.fetch(interaction.user.id);
if (!member || !interactionMember) { if (!member || !interactionMember) {
return interaction.reply('An error has occurred. Please try again later.'); return interaction.reply('An error has occurred. Please try again later.');
} }
if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) { if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) {
return await interaction.editReply("You do not have permission to sync this user's name."); return await interaction.editReply("You do not have permission to sync this user's name.");
} }
if (availableIds.length > 1) { if (availableIds.length > 1) {
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`sync-name-modal_${idToUse}`) .setCustomId(`sync-name-modal_${idToUse}`)
.setTitle(`Syncing ${member ? member.displayName : idToUse}'s name`); .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); const inputLabel = createTextInput('id', 'User has multiple linked accounts. Provide ID', null, false, TextInputStyle.Short, null, null);
modal.addLabelComponents(inputLabel); modal.addLabelComponents(inputLabel);
return await interaction.showModal(modal); return await interaction.showModal(modal);
} }
await deferInteraction(interaction); await deferInteraction(interaction);
if (!guild.members.me) { if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
await syncName(interaction, member, null); await syncName(interaction, member, null);
} }
}; };
+16 -16
View File
@@ -1,17 +1,17 @@
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js'; import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
export default { export default {
name: 'Whois', name: 'Whois',
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
.setName('Whois') .setName('Whois')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild) .setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User), .setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) { handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id; const idToUse = interaction.targetUser.id;
handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel))); handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
} }
}; };
+93 -93
View File
@@ -1,94 +1,94 @@
import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js'; import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils'; import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils';
const IGNORED_ACTIONS = [ const IGNORED_ACTIONS = [
AuditLogEvent.MemberMove, AuditLogEvent.MemberMove,
// Handled by automod. // Handled by automod.
AuditLogEvent.AutoModerationFlagToChannel AuditLogEvent.AutoModerationFlagToChannel
]; ];
export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) { export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) {
if (!await shouldLog(entry, guild)) return; if (!await shouldLog(entry, guild)) return;
const settings = await Database.getGuildSettings(guild.id); const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.audit_logs_channel_id) return; if (!settings || !settings.audit_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.audit_logs_channel_id); const channel = await guild.channels.fetch(settings.audit_logs_channel_id);
if (!channel || !channel.isSendable()) return; if (!channel || !channel.isSendable()) return;
const fields: APIEmbedField[] = [ const fields: APIEmbedField[] = [
{ {
name: 'Actor', name: 'Actor',
value: `<@${entry.executorId}>`, value: `<@${entry.executorId}>`,
inline: true inline: true
} }
]; ];
if (entry.targetId) { if (entry.targetId) {
const targetType = getTargetType(entry.action); const targetType = getTargetType(entry.action);
fields.push({ fields.push({
name: 'Target', name: 'Target',
value: formatSnowflake(entry.targetId, targetType), value: formatSnowflake(entry.targetId, targetType),
inline: true inline: true
}); });
} }
if (entry.reason) { if (entry.reason) {
fields.push({ fields.push({
name: 'Reason', name: 'Reason',
value: entry.reason, value: entry.reason,
inline: true inline: true
}); });
} }
if (entry.changes && entry.changes.length > 0) { if (entry.changes && entry.changes.length > 0) {
fields.push({ fields.push({
name: 'Changes', name: 'Changes',
value: formatChanges(entry), value: formatChanges(entry),
inline: false inline: false
}); });
} }
if (entry.extra) { if (entry.extra) {
fields.push({ fields.push({
name: 'Options', name: 'Options',
value: formatExtras(entry, guild), value: formatExtras(entry, guild),
inline: false inline: false
}); });
} }
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)]) .setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)])
.setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp)) .setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp))
.addFields(...fields); .addFields(...fields);
channel.send({ embeds: [embed] }); channel.send({ embeds: [embed] });
} }
async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise<boolean> { async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise<boolean> {
if (!entry.executorId) return true; if (!entry.executorId) return true;
if (IGNORED_ACTIONS.some(a => entry.action == a)) return false; if (IGNORED_ACTIONS.some(a => entry.action == a)) return false;
if (entry.action == AuditLogEvent.MemberRoleUpdate) { if (entry.action == AuditLogEvent.MemberRoleUpdate) {
return await shouldLogRoleChanges(entry as GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild); return await shouldLogRoleChanges(entry as GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild);
} }
return true; return true;
} }
async function shouldLogRoleChanges(entry: GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild: Guild): Promise<boolean> { async function shouldLogRoleChanges(entry: GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild: Guild): Promise<boolean> {
for (const change of entry.changes) { for (const change of entry.changes) {
// Get role changes from the log. // Get role changes from the log.
const roles = (await Promise.all((change.new! as Pick<APIRole, 'id' | 'name'>[]).map(c => guild.roles.fetch(c.id)))); const roles = (await Promise.all((change.new! as Pick<APIRole, 'id' | 'name'>[]).map(c => guild.roles.fetch(c.id))));
// Check if role is part of onboarding. // Check if role is part of onboarding.
for (const role of roles) { for (const role of roles) {
if (role && !role.flags.has(RoleFlags.InPrompt)) return true; if (role && !role.flags.has(RoleFlags.InPrompt)) return true;
} }
} }
return false; return false;
} }
+5 -5
View File
@@ -1,6 +1,6 @@
import { GuildBan } from 'discord.js'; import { GuildBan } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export async function handleBanRemove(ban: GuildBan) { export async function handleBanRemove(ban: GuildBan) {
await Database.removeBan(ban.user.id); await Database.removeBan(ban.user.id);
} }
+9 -9
View File
@@ -1,10 +1,10 @@
import { Guild } from 'discord.js'; import { Guild } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export async function handleGuildCreate(guild: Guild) { export async function handleGuildCreate(guild: Guild) {
try { try {
if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id); if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
+22 -22
View File
@@ -1,23 +1,23 @@
import { GuildMember, GuildTextBasedChannel } from 'discord.js'; import { GuildMember, GuildTextBasedChannel } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { getE621Alts } from '../utils'; import { getE621Alts } from '../utils';
export async function handleMemberJoin(member: GuildMember) { export async function handleMemberJoin(member: GuildMember) {
const guildSettings = await Database.getGuildSettings(member.guild.id); const guildSettings = await Database.getGuildSettings(member.guild.id);
if (guildSettings?.new_member_channel_id) { if (guildSettings?.new_member_channel_id) {
const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel; const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel;
if (channel) { if (channel) {
const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`; 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); channel.send(content).catch(console.error);
if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) { if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) {
const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel; 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); if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error);
} }
} }
} }
} }
+384 -384
View File
@@ -1,385 +1,385 @@
import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js'; import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { E621Post } from '../types'; 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'; 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<InGuild extends boolean = boolean> = OmitPartialGroupDMChannel<DiscordMessage<InGuild>>; export type Message<InGuild extends boolean = boolean> = OmitPartialGroupDMChannel<DiscordMessage<InGuild>>;
export type Partial = OmitPartialGroupDMChannel<PartialMessage>; export type Partial = OmitPartialGroupDMChannel<PartialMessage>;
// 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. // 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 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 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 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 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 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 md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi');
const regexTesters = [ const regexTesters = [
{ runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) }, { runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) },
{ {
runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => { runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => {
return parseInt(idString, 32); return parseInt(idString, 32);
}) })
}, },
{ runInDev: false, regex: imageRegex, handler: imageHandler }, { runInDev: false, regex: imageRegex, handler: imageHandler },
{ runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) }, { runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) },
{ runInDev: true, regex: imageRegex_DEV, handler: imageHandler }, { runInDev: true, regex: imageRegex_DEV, handler: imageHandler },
{ runInDev: true, regex: postIDRegex, handler: postIdHandler }, { runInDev: true, regex: postIDRegex, handler: postIdHandler },
{ runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') }, { runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') },
{ runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') }, { runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') },
{ runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') }, { runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') },
{ runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') }, { runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') },
{ runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') }, { runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') },
{ runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') }, { runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') },
{ runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') }, { runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') },
{ runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') }, { runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') },
{ runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') }, { runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') },
{ runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') }, { runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') },
{ runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler }, { runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler },
{ runInDev: true, regex: searchLinkRegex, handler: searchHandler }, { runInDev: true, regex: searchLinkRegex, handler: searchHandler },
{ runInDev: true, regex: prRegex, handler: githubPullRequestHandler }, { runInDev: true, regex: prRegex, handler: githubPullRequestHandler },
{ runInDev: true, regex: issueRegex, handler: githubIssueHandler }, { runInDev: true, regex: issueRegex, handler: githubIssueHandler },
]; ];
const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i; const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i;
export async function handleMessageCreate(message: Message) { export async function handleMessageCreate(message: Message) {
if (message.author.bot) return; if (message.author.bot) return;
if (message.inGuild()) await Database.putMessage(message); if (message.inGuild()) await Database.putMessage(message);
const responses: string[] = []; const responses: string[] = [];
for (const test of regexTesters) { for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue; if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(message.content); const hasMatches = test.regex.test(message.content);
test.regex.lastIndex = 0; test.regex.lastIndex = 0;
if (hasMatches) { if (hasMatches) {
const matches: RegExpExecArray[] = []; const matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = test.regex.exec(message.content)) != null) { while ((match = test.regex.exec(message.content)) != null) {
matches.push(match); matches.push(match);
} }
test.regex.lastIndex = 0; test.regex.lastIndex = 0;
const response = await test.handler(message, matches.filter(uniqueRegexMatches)); const response = await test.handler(message, matches.filter(uniqueRegexMatches));
if (response === false) return; if (response === false) return;
if (response !== true) responses.push(response as string); if (response !== true) responses.push(response as string);
} }
} }
for (const attachment of message.attachments.values()) { for (const attachment of message.attachments.values()) {
const match = md5Regex.exec(attachment.name); const match = md5Regex.exec(attachment.name);
md5Regex.lastIndex = 0; md5Regex.lastIndex = 0;
const md5s: string[] = []; const md5s: string[] = [];
if (match) md5s.push(match[1]); if (match) md5s.push(match[1]);
else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) { else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) {
const md5Data = await calculateMD5FromURL(attachment.url); const md5Data = await calculateMD5FromURL(attachment.url);
if (!md5Data) continue; if (!md5Data) continue;
md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5); md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5);
} }
if (md5s.length == 0) continue; if (md5s.length == 0) continue;
for (const md5 of md5s) { for (const md5 of md5s) {
const post = await getE621PostByMd5(md5); const post = await getE621PostByMd5(md5);
if (post) { if (post) {
if (await blacklistIfNecessary(message, [post])) return; if (await blacklistIfNecessary(message, [post])) return;
responses.push(`<${getPostUrl(post)}>`); responses.push(`<${getPostUrl(post)}>`);
continue; continue;
} }
} }
} }
if (responses.length > 0) { if (responses.length > 0) {
await message.reply(responses.join('\n')); await message.reply(responses.join('\n'));
} }
} }
export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) { export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) {
if (newMessage.author.bot) return; if (newMessage.author.bot) return;
const loggedMessage = await Database.getMessageWithRetry(newMessage.id); const loggedMessage = await Database.getMessageWithRetry(newMessage.id);
if (!loggedMessage) { if (!loggedMessage) {
if (newMessage.inGuild()) await Database.putMessage(newMessage); if (newMessage.inGuild()) await Database.putMessage(newMessage);
return; return;
} }
if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) { if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) {
await Database.putMessage(newMessage); await Database.putMessage(newMessage);
await logEdit(loggedMessage, newMessage); await logEdit(loggedMessage, newMessage);
} }
if (loggedMessage.content == newMessage.content) return; if (loggedMessage.content == newMessage.content) return;
const responses: string[] = []; const responses: string[] = [];
for (const test of regexTesters) { for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue; if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(newMessage.content); const hasMatches = test.regex.test(newMessage.content);
test.regex.lastIndex = 0; test.regex.lastIndex = 0;
if (hasMatches) { if (hasMatches) {
const oldMatches: RegExpExecArray[] = []; const oldMatches: RegExpExecArray[] = [];
const newMatches: RegExpExecArray[] = []; const newMatches: RegExpExecArray[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = test.regex.exec(newMessage.content)) != null) { while ((match = test.regex.exec(newMessage.content)) != null) {
newMatches.push(match); newMatches.push(match);
} }
test.regex.lastIndex = 0; test.regex.lastIndex = 0;
while ((match = test.regex.exec(loggedMessage.content)) != null) { while ((match = test.regex.exec(loggedMessage.content)) != null) {
oldMatches.push(match); oldMatches.push(match);
} }
test.regex.lastIndex = 0; test.regex.lastIndex = 0;
const properMatches: RegExpExecArray[] = []; const properMatches: RegExpExecArray[] = [];
for (const newMatch of newMatches) { for (const newMatch of newMatches) {
if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch); if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch);
} }
if (properMatches.length == 0) continue; if (properMatches.length == 0) continue;
const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches)); const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches));
if (response === false) return; if (response === false) return;
if (response !== true) responses.push(response as string); if (response !== true) responses.push(response as string);
} }
} }
if (responses.length > 0) { if (responses.length > 0) {
await newMessage.reply(responses.join('\n')); await newMessage.reply(responses.join('\n'));
} }
} }
export async function handleMessageDelete(message: Message | PartialMessage) { export async function handleMessageDelete(message: Message | PartialMessage) {
const loggedMessage = await Database.getMessageWithRetry(message.id); const loggedMessage = await Database.getMessageWithRetry(message.id);
if (!loggedMessage) return; if (!loggedMessage) return;
if (message.inGuild()) await logDeletion(loggedMessage, message); if (message.inGuild()) await logDeletion(loggedMessage, message);
} }
export async function handleBulkMessageDelete(messages: ReadonlyCollection<string, Message | Partial>, channel: GuildTextBasedChannel) { export async function handleBulkMessageDelete(messages: ReadonlyCollection<string, Message | Partial>, channel: GuildTextBasedChannel) {
for (const message of messages.values()) { for (const message of messages.values()) {
await handleMessageDelete(message); await handleMessageDelete(message);
} }
} }
async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
let content = ''; let content = '';
for (const group of matchedGroups) { for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`; content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`;
} }
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
let content = ''; let content = '';
for (const group of matchedGroups) { for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`; content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`;
} }
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> { async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> {
const blacklistedIds: number[] = []; const blacklistedIds: number[] = [];
const channel = await message.channel.fetch() as GuildTextBasedChannel; const channel = await message.channel.fetch() as GuildTextBasedChannel;
const isStaffChannel = await channelIsInStaffCategory(channel); const isStaffChannel = await channelIsInStaffCategory(channel);
for (const post of posts) { for (const post of posts) {
if (spoilerOrBlacklist(post).action == PostAction.Blacklist) { if (spoilerOrBlacklist(post).action == PostAction.Blacklist) {
blacklistedIds.push(post.id); blacklistedIds.push(post.id);
} }
} }
if (blacklistedIds.length == 0) return false; if (blacklistedIds.length == 0) return false;
await message.delete(); await message.delete();
if (channel.parentId && isStaffChannel) { if (channel.parentId && isStaffChannel) {
await message.channel.send({ 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.`, 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: { allowedMentions: {
users: [message.author.id] users: [message.author.id]
} }
}); });
} else { } else {
await message.channel.send({ 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.`, 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: { allowedMentions: {
users: [message.author.id] users: [message.author.id]
} }
}); });
} }
return true; return true;
} }
async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true; if (!message.guildId) return true;
const posts: { post: E621Post, spoilered: boolean }[] = []; const posts: { post: E621Post, spoilered: boolean }[] = [];
for (const match of matchedGroups) { for (const match of matchedGroups) {
try { try {
const post = await getE621Post(match[1]); const post = await getE621Post(match[1]);
if (post) posts.push({ if (post) posts.push({
spoilered: isInSpoilerTags(message.content, match.index), spoilered: isInSpoilerTags(message.content, match.index),
post post
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false; if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
const sfw = await channelIsSafe(message.channel as GuildBasedChannel); const sfw = await channelIsSafe(message.channel as GuildBasedChannel);
const content = posts.map((postData) => { const content = posts.map((postData) => {
if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`; if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`;
const shouldSpoiler = spoilerOrBlacklist(postData.post); const shouldSpoiler = spoilerOrBlacklist(postData.post);
if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`; if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`;
return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post); return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post);
}).join('\n'); }).join('\n');
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true; if (!message.guildId) return true;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n'); const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n');
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true; if (!message.guildId) return true;
const posts: E621Post[] = []; const posts: E621Post[] = [];
for (const match of matchedGroups) { for (const match of matchedGroups) {
try { try {
const post = await getE621Post(transform ? transform(match[1]) : match[1]); const post = await getE621Post(transform ? transform(match[1]) : match[1]);
if (post) posts.push(post); if (post) posts.push(post);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
if (await blacklistIfNecessary(message, posts)) return false; if (await blacklistIfNecessary(message, posts)) return false;
return true; return true;
} }
async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true; if (!message.guildId) return true;
const posts: E621Post[] = []; const posts: E621Post[] = [];
for (const match of matchedGroups) { for (const match of matchedGroups) {
try { try {
const post = await getE621PostByMd5(match[1]); const post = await getE621PostByMd5(match[1]);
if (post) posts.push(post); if (post) posts.push(post);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
if (await blacklistIfNecessary(message, posts)) return false; if (await blacklistIfNecessary(message, posts)) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n'); const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n');
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
let content = ''; let content = '';
for (const group of matchedGroups) { for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`; content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`;
} }
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> { async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel); const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true; if (skip) return true;
let content = ''; let content = '';
for (const group of matchedGroups) { for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`; content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`;
} }
if (content.trim().length > 0) return content.trim(); if (content.trim().length > 0) return content.trim();
return true; return true;
} }
+10 -10
View File
@@ -1,11 +1,11 @@
import { AnyThreadChannel } from 'discord.js'; import { AnyThreadChannel } from 'discord.js';
export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) { export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) {
try { try {
await thread.join(); await thread.join();
} catch (e) { } catch (e) {
console.error('Failed to join thread:'); console.error('Failed to join thread:');
console.error(e); console.error(e);
} }
} }
+45 -45
View File
@@ -1,46 +1,46 @@
import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js'; import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) { 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. // 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) { if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) {
const logChannel = await getVoiceLogsChannel(newState.guild); const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return; if (!logChannel) return;
await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!); await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!);
} else if (oldState.channelId == null && newState.channelId != null) { } else if (oldState.channelId == null && newState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild); const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return; if (!logChannel) return;
await sendJoinMessage(logChannel, newState.member!, newState.channel!); await sendJoinMessage(logChannel, newState.member!, newState.channel!);
} else if (newState.channelId == null && oldState.channelId != null) { } else if (newState.channelId == null && oldState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild); const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return; if (!logChannel) return;
await sendLeftMessage(logChannel, newState.member!, oldState.channel!); await sendLeftMessage(logChannel, newState.member!, oldState.channel!);
} }
} }
async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} joined ${voiceChannel} at ${time()}`); await channel.send(`${member} joined ${voiceChannel} at ${time()}`);
} }
async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) { async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} left ${voiceChannel} at ${time()}`); await channel.send(`${member} left ${voiceChannel} at ${time()}`);
} }
async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) { async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) {
await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`); await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`);
} }
async function getVoiceLogsChannel(guild: Guild): Promise<GuildTextBasedChannel | undefined> { async function getVoiceLogsChannel(guild: Guild): Promise<GuildTextBasedChannel | undefined> {
const settings = await Database.getGuildSettings(guild.id); const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.voice_logs_channel_id) return; if (!settings || !settings.voice_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.voice_logs_channel_id); const channel = await guild.channels.fetch(settings.voice_logs_channel_id);
if (!channel || !channel.isSendable()) return; if (!channel || !channel.isSendable()) return;
return channel as GuildTextBasedChannel; return channel as GuildTextBasedChannel;
} }
+7 -7
View File
@@ -1,7 +1,7 @@
export * from './handle-audit-log-create'; export * from './handle-audit-log-create';
export * from './handle-ban-remove'; export * from './handle-ban-remove';
export * from './handle-guild-create'; export * from './handle-guild-create';
export * from './handle-member-join'; export * from './handle-member-join';
export * from './handle-message'; export * from './handle-message';
export * from './handle-thread-create'; export * from './handle-thread-create';
export * from './handle-voice-state-update'; export * from './handle-voice-state-update';
+48 -48
View File
@@ -1,49 +1,49 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils'; import { deferInteraction, logCustomEvent } from '../utils';
export default { export default {
name: 'add-knowledgebase-item-modal', name: 'add-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) { handler: async function (client: Client, interaction: ModalSubmitInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction); await deferInteraction(interaction);
const name = interaction.fields.getTextInputValue('name'); const name = interaction.fields.getTextInputValue('name');
const content = interaction.fields.getTextInputValue('content'); const content = interaction.fields.getTextInputValue('content');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name); const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name);
if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`); if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Added', title: 'Knowledgebase Item Added',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Name', name: 'Name',
value: name, value: name,
inline: true inline: true
}, },
{ {
name: 'Content', name: 'Content',
value: content, value: content,
inline: true inline: true
} }
] ]
}); });
await Database.addToKnowledgebase(interaction.guild.id, name, content); await Database.addToKnowledgebase(interaction.guild.id, name, content);
return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`); return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`);
} }
}; };
+39 -39
View File
@@ -1,40 +1,40 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { logCustomEvent } from '../utils'; import { logCustomEvent } from '../utils';
export default { export default {
name: 'add-note-modal', name: 'add-note-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
const message = interaction.fields.getTextInputValue('note-message'); const message = interaction.fields.getTextInputValue('note-message');
const user = await client.users.fetch(id); const user = await client.users.fetch(id);
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Note Added', title: 'Note Added',
description: null, description: null,
color: 0x00FF00, color: 0x00FF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'Moderator', name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'User', name: 'User',
value: `<@${id}>\n${user.username}`, value: `<@${id}>\n${user.username}`,
inline: true inline: true
}, },
{ {
name: 'Note', name: 'Note',
value: message, value: message,
inline: true inline: true
} }
] ]
}); });
await Database.putNote(id, message, interaction.user.id); await Database.putNote(id, message, interaction.user.id);
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' }); interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' });
} }
}; };
+53 -53
View File
@@ -1,54 +1,54 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils'; import { deferInteraction, logCustomEvent } from '../utils';
export default { export default {
name: 'edit-knowledgebase-item-modal', name: 'edit-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) { handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' }); if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction); await deferInteraction(interaction);
const id = parseInt(idString); const id = parseInt(idString);
const content = interaction.fields.getTextInputValue('content'); const content = interaction.fields.getTextInputValue('content');
const existingItem = await Database.getFromKnowledgebase(id); const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.'); if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.'); if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
logCustomEvent(interaction.guild!, { logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Edited', title: 'Knowledgebase Item Edited',
description: null, description: null,
color: 0xFFFF00, color: 0xFFFF00,
timestamp: new Date(), timestamp: new Date(),
fields: [ fields: [
{ {
name: 'User', name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`, value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true inline: true
}, },
{ {
name: 'Name', name: 'Name',
value: existingItem.name, value: existingItem.name,
inline: true inline: true
}, },
{ {
name: 'Old Content', name: 'Old Content',
value: existingItem.content, value: existingItem.content,
inline: true inline: true
}, },
{ {
name: 'New Content', name: 'New Content',
value: content, value: content,
inline: true inline: true
} }
] ]
}); });
await Database.editKnowledgebaseItem(id, content); await Database.editKnowledgebaseItem(id, content);
return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`); return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`);
} }
}; };
+30 -30
View File
@@ -1,31 +1,31 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils'; 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.'; 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 { export default {
name: 'open-mod-ticket', name: 'open-mod-ticket',
handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) { handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) {
const guild = await client.guilds.fetch(interaction.guildId!); const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(userId); const member = await guild.members.fetch(userId);
const guildSettings = await Database.getGuildSettings(guild.id); const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_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.' }); 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 title = interaction.fields.getTextInputValue('title') ? interaction.fields.getTextInputValue('title') : `Mod Ticket For ${member.displayName}`;
const reason = interaction.fields.getTextInputValue('initial-message') + warning; const reason = interaction.fields.getTextInputValue('initial-message') + warning;
const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes'; const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes';
const membersToAdd = [userId]; const membersToAdd = [userId];
if (autoJoin) membersToAdd.push(interaction.user.id); if (autoJoin) membersToAdd.push(interaction.user.id);
const thread = await createPrivateHelpTicketThread(client, guild, null, reason, title, membersToAdd); 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." : ''}` }); 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.' }); else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' });
} }
}; };
+22 -22
View File
@@ -1,23 +1,23 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils'; import { createPrivateHelpTicketThread } from '../utils';
export default { export default {
name: 'open-ticket-modal', name: 'open-ticket-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) { handler: async function (client: Client, interaction: ModalSubmitInteraction) {
const guild = await client.guilds.fetch(interaction.guildId!); const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id); const member = await guild.members.fetch(interaction.user.id);
const guildSettings = await Database.getGuildSettings(guild.id); const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_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.' }); 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 reason = interaction.fields.getTextInputValue('ticket-message');
const thread = await createPrivateHelpTicketThread(client, guild, member, reason); const thread = await createPrivateHelpTicketThread(client, guild, member, reason);
if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` }); 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.' }); else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' });
} }
}; };
+117 -117
View File
@@ -1,118 +1,118 @@
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils'; import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils';
export default { export default {
name: 'report-message', name: 'report-message',
handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) { handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const guild = await client.guilds.fetch(interaction.guildId!); const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id); const member = await guild.members.fetch(interaction.user.id);
const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel; const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel;
const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId); const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId);
const additionalInfo = interaction.fields.getTextInputValue('additional-info'); const additionalInfo = interaction.fields.getTextInputValue('additional-info');
const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes'; const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes';
if (!reportedMessageChannel || !reportedMessage) if (!reportedMessageChannel || !reportedMessage)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' }); 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!); const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.moderator_channel_id) if (!guildSettings || !guildSettings.moderator_channel_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); 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); const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id);
if (!reportsChannel || !reportsChannel.isSendable()) if (!reportsChannel || !reportsChannel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' }); return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' });
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('New Message Report!') .setTitle('New Message Report!')
.setColor(0xFF0000) .setColor(0xFF0000)
.addFields( .addFields(
{ {
name: 'Message', name: 'Message',
value: reportedMessage.url, value: reportedMessage.url,
inline: false inline: false
}, },
{ {
name: 'Message Author', name: 'Message Author',
value: reportedMessage.author.toString(), value: reportedMessage.author.toString(),
inline: false inline: false
}, },
{ {
name: 'Reporter', name: 'Reporter',
value: member.toString(), value: member.toString(),
inline: false inline: false
}); });
if (additionalInfo) { if (additionalInfo) {
embed.addFields( embed.addFields(
{ {
name: 'Additional Information', name: 'Additional Information',
value: additionalInfo, value: additionalInfo,
inline: false inline: false
} }
); );
} }
let replyContent = "Thanks for making a report! I've notified the moderators who can take further action."; let replyContent = "Thanks for making a report! I've notified the moderators who can take further action.";
const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id); const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id);
if (wantsTicketButCantOpen) { if (wantsTicketButCantOpen) {
embed.addFields( embed.addFields(
{ {
name: 'User Requested Private Ticket', 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.', 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 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."; 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() const openTicketButton = new ButtonBuilder()
.setCustomId('open-ticket-for-reported-message') .setCustomId('open-ticket-for-reported-message')
.setLabel('Open Private Ticket') .setLabel('Open Private Ticket')
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(openTicketButton); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(openTicketButton);
const reportMessage = await reportsChannel.send({ const reportMessage = await reportsChannel.send({
embeds: [embed], embeds: [embed],
components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row] components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row]
}); });
await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] }); await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] });
if (createPrivateHelpTicket && !wantsTicketButCantOpen) { if (createPrivateHelpTicket && !wantsTicketButCantOpen) {
if (!guildSettings.private_help_channel_id) { 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.'; replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.';
} else { } 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')}` : ''}`); 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) { if (thread) {
replyContent += ` Private help ticket created: ${thread}.`; replyContent += ` Private help ticket created: ${thread}.`;
embed.addFields({ embed.addFields({
name: 'Private Help Ticket', name: 'Private Help Ticket',
value: thread.url, value: thread.url,
inline: false inline: false
}); });
await reportMessage.edit({ embeds: [embed] }); await reportMessage.edit({ embeds: [embed] });
} else { } else {
replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.'; replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.';
await reportMessage.edit({ await reportMessage.edit({
embeds: [embed], embeds: [embed],
components: [row] components: [row]
}); });
} }
} }
} }
await interaction.editReply(replyContent); await interaction.editReply(replyContent);
} }
}; };
+33 -33
View File
@@ -1,34 +1,34 @@
import { Client, ModalSubmitInteraction } from 'discord.js'; import { Client, ModalSubmitInteraction } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { deferInteraction, syncName } from '../utils'; import { deferInteraction, syncName } from '../utils';
export default { export default {
name: 'sync-name-modal', name: 'sync-name-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) { handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
await deferInteraction(interaction); await deferInteraction(interaction);
const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0); const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0);
console.log(e621Id); console.log(e621Id);
if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number'); if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number');
const member = await interaction.guild?.members.fetch(id); const member = await interaction.guild?.members.fetch(id);
if (!member) { if (!member) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!); const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) { if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
if (!guild.members.me) { if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.'); return interaction.editReply('An error has occurred. Please try again later.');
} }
await syncName(interaction, member, e621Id); await syncName(interaction, member, e621Id);
} }
}; };
+483 -483
View File
@@ -1,484 +1,484 @@
import sqlite3 from 'sqlite3'; import sqlite3 from 'sqlite3';
import { open, Database as SqliteDatabase } from 'sqlite'; import { open, Database as SqliteDatabase } from 'sqlite';
import { serializeMessage, wait } from '../utils'; import { serializeMessage, wait } from '../utils';
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types'; import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types';
import { Message } from '../events'; import { Message } from '../events';
const DB_SCHEMA = ` const DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS discord_names ( CREATE TABLE IF NOT EXISTS discord_names (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
discord_id TEXT NOT NULL, discord_id TEXT NOT NULL,
discord_username TEXT NOT NULL, discord_username TEXT NOT NULL,
added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime')) added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
); );
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
guild_id TEXT PRIMARY KEY, guild_id TEXT PRIMARY KEY,
general_chat_id TEXT, general_chat_id TEXT,
new_member_channel_id TEXT, new_member_channel_id TEXT,
tickets_channel_id TEXT, tickets_channel_id TEXT,
event_logs_channel_id TEXT, event_logs_channel_id TEXT,
discord_logs_channel_id TEXT, discord_logs_channel_id TEXT,
audit_logs_channel_id TEXT, audit_logs_channel_id TEXT,
voice_logs_channel_id TEXT, voice_logs_channel_id TEXT,
admin_role_id TEXT, admin_role_id TEXT,
private_help_role_id TEXT, private_help_role_id TEXT,
devwatch_role_id TEXT, devwatch_role_id TEXT,
staff_categories TEXT, staff_categories TEXT,
safe_channels TEXT, safe_channels TEXT,
link_skip_channels TEXT, link_skip_channels TEXT,
github_release_channel TEXT, github_release_channel TEXT,
moderator_channel_id TEXT, moderator_channel_id TEXT,
private_help_channel_id TEXT private_help_channel_id TEXT
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY ON CONFLICT REPLACE, id TEXT PRIMARY KEY ON CONFLICT REPLACE,
author_id TEXT NOT NULL, author_id TEXT NOT NULL,
author_name TEXT NOT NULL, author_name TEXT NOT NULL,
channel_id TEXT NOT NULL, channel_id TEXT NOT NULL,
attachments TEXT NOT NULL, attachments TEXT NOT NULL,
stickers TEXT NOT NULL, stickers TEXT NOT NULL,
content TEXT NOT NULL content TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id); CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id);
CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id); CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id);
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
message_id TEXT NOT NULL message_id TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS ticket_phrases ( CREATE TABLE IF NOT EXISTS ticket_phrases (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
phrase TEXT NOT NULL phrase TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS notes ( CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id TEXT, user_id TEXT,
reason TEXT, reason TEXT,
mod_id TEXT, mod_id TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
); );
CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id); CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id);
CREATE TABLE IF NOT EXISTS note_edits ( CREATE TABLE IF NOT EXISTS note_edits (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
note_id INTEGER, note_id INTEGER,
mod_id TEXT, mod_id TEXT,
previous_reason TEXT, previous_reason TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')), timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY(note_id) REFERENCES notes(id) FOREIGN KEY(note_id) REFERENCES notes(id)
); );
CREATE TABLE IF NOT EXISTS bans ( CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id TEXT, user_id TEXT,
expires INTEGER, expires INTEGER,
expires_at datetime, expires_at datetime,
full_ban INTEGER full_ban INTEGER
); );
CREATE TABLE IF NOT EXISTS github_user_mapping ( CREATE TABLE IF NOT EXISTS github_user_mapping (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
discord_id TEXT, discord_id TEXT,
github_username TEXT github_username TEXT
); );
CREATE TABLE IF NOT EXISTS knowledgebase ( CREATE TABLE IF NOT EXISTS knowledgebase (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
guild_id TEXT NOT NULL, guild_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
content TEXT NOT NULL content TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS private_help_tickets ( CREATE TABLE IF NOT EXISTS private_help_tickets (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
thread_id TEXT NOT NULL, thread_id TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
status INTEGER NOT NULL, status INTEGER NOT NULL,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')) timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
); );
CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp); CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp);
`; `;
export const enum PrivateHelpTicketStatus { export const enum PrivateHelpTicketStatus {
OPEN = 0, OPEN = 0,
CLOSED = 1 CLOSED = 1
} }
export class Database { export class Database {
private static db: SqliteDatabase; private static db: SqliteDatabase;
static async open(file: string): Promise<void> { static async open(file: string): Promise<void> {
if (Database.db) return; if (Database.db) return;
Database.db = await open({ Database.db = await open({
filename: file, filename: file,
driver: sqlite3.Database driver: sqlite3.Database
}); });
console.log('SQLite database opened'); console.log('SQLite database opened');
await Database.ensure(); await Database.ensure();
} }
private static async ensure() { private static async ensure() {
await Database.db.exec(DB_SCHEMA); await Database.db.exec(DB_SCHEMA);
console.log('SQLite database ensured'); console.log('SQLite database ensured');
} }
// -- START WHOIS -- // -- START WHOIS --
static async getE621Ids(discordId: string): Promise<number[]> { static async getE621Ids(discordId: string): Promise<number[]> {
const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId); 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); return ids.map(r => r.user_id);
} }
static async getDiscordIds(e621Id: string | number): Promise<string[]> { static async getDiscordIds(e621Id: string | number): Promise<string[]> {
// 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; // 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); 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); return ids.map(r => r.discord_id);
} }
static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> { static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> {
const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(` const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(`
WITH RECURSIVE rec AS ( 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 = ? SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ?
UNION UNION
SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec 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 d2 ON rec.discord_id = d2.discord_id
LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id
WHERE depth <= 5 AND rec.depth = depth WHERE depth <= 5 AND rec.depth = depth
) SELECT DISTINCT user_id, discord_id FROM rec`, id, id); ) SELECT DISTINCT user_id, discord_id FROM rec`, id, id);
return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id })); return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id }));
} }
static async putUser(id: number, user: { id: string, username: string }) { 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); 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) { static async removeUser(id: number, discordId: string) {
await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId); await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId);
} }
// -- END WHOIS -- // -- END WHOIS --
// -- START SETTINGS -- // -- START SETTINGS --
static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> { static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> {
return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId); return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId);
} }
static async putGuild(guildId: string) { static async putGuild(guildId: string) {
await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId); await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId);
} }
static async setGuildGeneralChatId(guildId: string, id: string) { static async setGuildGeneralChatId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildTicketsLogsChannelId(guildId: string, id: string) { static async setGuildTicketsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildEventsLogsChannelId(guildId: string, id: string) { static async setGuildEventsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildDiscordLogsChannelId(guildId: string, id: string) { static async setGuildDiscordLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildAuditLogsChannelId(guildId: string, id: string) { static async setGuildAuditLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildVoiceLogsChannelId(guildId: string, id: string) { static async setGuildVoiceLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildNewMemberLogsChannel(guildId: string, id: string) { static async setGuildNewMemberLogsChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildModeratorChannel(guildId: string, id: string) { static async setGuildModeratorChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildAdminRole(guildId: string, id: string) { static async setGuildAdminRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildPrivateHelperRole(guildId: string, id: string) { static async setGuildPrivateHelperRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildDevWatchRole(guildId: string, id: string) { static async setGuildDevWatchRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId);
} }
static async setGuildGithubReleaseChannel(guildId: string, id: string) { static async setGuildGithubReleaseChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId); await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId);
} }
static async setPrivateHelpChannel(guildId: string, id: string) { static async setPrivateHelpChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId); 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. // 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. // But it does allow me to skip rewriting this a bunch.
static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> { static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> {
const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId); const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId);
if (!settings || !settings[setting]) return []; if (!settings || !settings[setting]) return [];
return settings[setting].split(','); return settings[setting].split(',');
} }
static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) { static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) {
const values = await Database.getGuildArraySetting(setting, guildId); const values = await Database.getGuildArraySetting(setting, guildId);
if (values.indexOf(value) == -1) values.push(value); if (values.indexOf(value) == -1) values.push(value);
const newString = values.join(','); const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
} }
static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise<boolean> { static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise<boolean> {
const values = await Database.getGuildArraySetting(setting, guildId); const values = await Database.getGuildArraySetting(setting, guildId);
const index = values.indexOf(value); const index = values.indexOf(value);
if (index == -1) return false; if (index == -1) return false;
values.splice(index, 1); values.splice(index, 1);
const newString = values.join(','); const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId); await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
return true; return true;
} }
// -- END SETTINGS -- // -- END SETTINGS --
// START MESSAGE LOGS -- // START MESSAGE LOGS --
static async putMessage(message: Message): Promise<boolean> { static async putMessage(message: Message): Promise<boolean> {
try { try {
const serializedMessage = serializeMessage(message); const serializedMessage = serializeMessage(message);
await Database.db.run(` await Database.db.run(`
INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES
(:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content) (:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content)
`, ...serializedMessage); `, ...serializedMessage);
return true; return true;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return false; return false;
} }
} }
static async getMessage(id: string): Promise<LoggedMessage | undefined> { static async getMessage(id: string): Promise<LoggedMessage | undefined> {
return await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id); return await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
} }
static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise<LoggedMessage | undefined> { static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise<LoggedMessage | undefined> {
let tried = 0; let tried = 0;
while (tried < retries) { while (tried < retries) {
tried++; tried++;
const message = await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id); const message = await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
if (message) return message; if (message) return message;
await wait(delay); await wait(delay);
} }
} }
// -- END MESSAGE LOGS -- // -- END MESSAGE LOGS --
// -- START TICKETS -- // -- START TICKETS --
static async putTicket(ticketId: number, messageId: string) { static async putTicket(ticketId: number, messageId: string) {
await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId); await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId);
} }
static async removeTicket(ticketId: number) { static async removeTicket(ticketId: number) {
await Database.db.run('DELETE from tickets WHERE id = ?', ticketId); await Database.db.run('DELETE from tickets WHERE id = ?', ticketId);
} }
static async getTicketMessageId(ticketId: number): Promise<string | undefined> { static async getTicketMessageId(ticketId: number): Promise<string | undefined> {
const ticket = await Database.db.get<Pick<TicketMessage, 'message_id'>>('SELECT message_id FROM tickets WHERE id = ?', ticketId); const ticket = await Database.db.get<Pick<TicketMessage, 'message_id'>>('SELECT message_id FROM tickets WHERE id = ?', ticketId);
return ticket?.message_id; return ticket?.message_id;
} }
static async putTicketPhrase(userId: string, phrase: string) { static async putTicketPhrase(userId: string, phrase: string) {
await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase); await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase);
} }
static async getTicketPhrase(id: number): Promise<TicketPhrase | undefined> { static async getTicketPhrase(id: number): Promise<TicketPhrase | undefined> {
return await Database.db.get<TicketPhrase>('SELECT * FROM ticket_phrases WHERE id = ?', id); return await Database.db.get<TicketPhrase>('SELECT * FROM ticket_phrases WHERE id = ?', id);
} }
static async removeTicketPhrase(id: number) { static async removeTicketPhrase(id: number) {
await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id); await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id);
} }
static async removeAllTicketPhrasesFor(id: string): Promise<number> { static async removeAllTicketPhrasesFor(id: string): Promise<number> {
return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!; return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!;
} }
static async getTicketPhrasesFor(userId: string): Promise<TicketPhrase[]> { static async getTicketPhrasesFor(userId: string): Promise<TicketPhrase[]> {
return await Database.db.all<TicketPhrase[]>('SELECT * from ticket_phrases WHERE user_id = ?', userId); return await Database.db.all<TicketPhrase[]>('SELECT * from ticket_phrases WHERE user_id = ?', userId);
} }
static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) { static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) {
await Database.db.each<TicketPhrase>('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => { await Database.db.each<TicketPhrase>('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => {
if (err) return console.error(err); if (err) return console.error(err);
cb(ticketPhrase); cb(ticketPhrase);
}); });
} }
// -- END TICKETS -- // -- END TICKETS --
// -- START NOTES -- // -- START NOTES --
static async putNote(userId: string, reason: string, modId: string) { 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); 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) { 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('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); await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason);
} }
static async removeNote(id: number): Promise<boolean> { static async removeNote(id: number): Promise<boolean> {
const res = await Database.db.run('DELETE from notes WHERE id = ?', id); const res = await Database.db.run('DELETE from notes WHERE id = ?', id);
return (res.changes ?? 0) > 0; return (res.changes ?? 0) > 0;
} }
static async getNotes(userId: string): Promise<Note[]> { static async getNotes(userId: string): Promise<Note[]> {
return await Database.db.all<Note[]>('SELECT * from notes WHERE user_id = ?', userId); return await Database.db.all<Note[]>('SELECT * from notes WHERE user_id = ?', userId);
} }
// -- END NOTES -- // -- END NOTES --
// -- START BANS -- // -- START BANS --
static async putBan(userId: string, expiresAt: Date | null, fullBan = false) { 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); 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<Ban | undefined> { static async getBan(userId: string): Promise<Ban | undefined> {
return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId); return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId);
} }
static async getExpiredBans(date: Date): Promise<Ban[]> { static async getExpiredBans(date: Date): Promise<Ban[]> {
return await Database.db.all<Ban[]>('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date); return await Database.db.all<Ban[]>('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date);
} }
static async pruneExpiredBans(date: Date) { static async pruneExpiredBans(date: Date) {
await Database.db.all<Ban[]>('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date); await Database.db.all<Ban[]>('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date);
} }
static async removeBan(userId: string) { static async removeBan(userId: string) {
await Database.db.run('DELETE from bans WHERE user_id = ?', userId); await Database.db.run('DELETE from bans WHERE user_id = ?', userId);
} }
// -- END BANS -- // -- END BANS --
// -- START GITHUB USER MAPPING -- // -- START GITHUB USER MAPPING --
// github_user_mapping // github_user_mapping
static async putGithubUserMapping(discordId: string, githubUsername: string) { static async putGithubUserMapping(discordId: string, githubUsername: string) {
await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername); await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername);
} }
static async getDiscordIdFromGithub(githubUsername: string): Promise<string | null> { static async getDiscordIdFromGithub(githubUsername: string): Promise<string | null> {
const mapping = await Database.db.get<Pick<GithubUserMapping, 'discord_id'>>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername); const mapping = await Database.db.get<Pick<GithubUserMapping, 'discord_id'>>('SELECT discord_id FROM github_user_mapping WHERE github_username = ?', githubUsername);
return mapping?.discord_id ?? null; return mapping?.discord_id ?? null;
} }
static async getGithubFromDiscordId(discordId: string): Promise<string | null> { static async getGithubFromDiscordId(discordId: string): Promise<string | null> {
const mapping = await Database.db.get<Pick<GithubUserMapping, 'github_username'>>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId); const mapping = await Database.db.get<Pick<GithubUserMapping, 'github_username'>>('SELECT github_username FROM github_user_mapping WHERE discord_id = ?', discordId);
return mapping?.github_username ?? null; return mapping?.github_username ?? null;
} }
static async getAllGithubUserMappings(): Promise<GithubUserMapping[]> { static async getAllGithubUserMappings(): Promise<GithubUserMapping[]> {
return await Database.db.all<GithubUserMapping[]>('SELECT * from github_user_mapping'); return await Database.db.all<GithubUserMapping[]>('SELECT * from github_user_mapping');
} }
static async removeGithubUserMapping(discordId: string) { static async removeGithubUserMapping(discordId: string) {
await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId); await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId);
} }
// -- END GITHUB USER MAPPING -- // -- END GITHUB USER MAPPING --
// -- START KNOWLEDGEBASE -- // -- START KNOWLEDGEBASE --
static async addToKnowledgebase(guildId: string, name: string, content: string) { static async addToKnowledgebase(guildId: string, name: string, content: string) {
if (content.length > 2000) return; if (content.length > 2000) return;
await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content); await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content);
} }
static async removeFromKnowledgebase(id: number) { static async removeFromKnowledgebase(id: number) {
await Database.db.run('DELETE from knowledgebase WHERE id = ?', id); await Database.db.run('DELETE from knowledgebase WHERE id = ?', id);
} }
static async editKnowledgebaseItem(id: number, content: string) { static async editKnowledgebaseItem(id: number, content: string) {
if (content.length > 2000) return; if (content.length > 2000) return;
await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id); await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id);
} }
static async getFromKnowledgebaseByName(guildId: string, name: string): Promise<KnowledgebaseItem | undefined> { static async getFromKnowledgebaseByName(guildId: string, name: string): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name); return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name);
} }
static async getFromKnowledgebase(id: number): Promise<KnowledgebaseItem | undefined> { static async getFromKnowledgebase(id: number): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE id = ?', id); return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE id = ?', id);
} }
static async getAllKnowledgebaseItems(guildId: string): Promise<KnowledgebaseItem[]> { static async getAllKnowledgebaseItems(guildId: string): Promise<KnowledgebaseItem[]> {
return await Database.db.all<KnowledgebaseItem[]>('SELECT * from knowledgebase WHERE guild_id = ?', guildId); return await Database.db.all<KnowledgebaseItem[]>('SELECT * from knowledgebase WHERE guild_id = ?', guildId);
} }
// -- END KNOWLEDGEBASE -- // -- END KNOWLEDGEBASE --
// -- START PRIVATE HELP TICKETS -- // -- START PRIVATE HELP TICKETS --
static async createPrivateHelpTicket(userId: string, threadId: string) { 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); await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN);
} }
static async closePrivateHelpTicket(threadId: string) { static async closePrivateHelpTicket(threadId: string) {
await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId); await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId);
} }
static async getLatestPrivateHelpTicketBy(userId: string): Promise<PrivateHelpTicket | undefined> { static async getLatestPrivateHelpTicketBy(userId: string): Promise<PrivateHelpTicket | undefined> {
return await Database.db.get<PrivateHelpTicket>('SELECT * from private_help_tickets WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1', userId); return await Database.db.get<PrivateHelpTicket>('SELECT * from private_help_tickets WHERE user_id = ? ORDER BY timestamp DESC LIMIT 1', userId);
} }
static async getAllOpenPrivateHelpTickets(): Promise<PrivateHelpTicket[]> { static async getAllOpenPrivateHelpTickets(): Promise<PrivateHelpTicket[]> {
return await Database.db.all<PrivateHelpTicket[]>('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN); return await Database.db.all<PrivateHelpTicket[]>('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN);
} }
// -- END PRIVATE HELP TICKETS // -- END PRIVATE HELP TICKETS
} }
+49 -49
View File
@@ -1,50 +1,50 @@
import { createClient, SocketClosedUnexpectedlyError } from '@redis/client'; import { createClient, SocketClosedUnexpectedlyError } from '@redis/client';
import { Client } from 'discord.js'; import { Client } from 'discord.js';
import { banUpdateHandler, ticketUpdateHandler } from '../utils'; import { banUpdateHandler, ticketUpdateHandler } from '../utils';
let discordClient: Client; let discordClient: Client;
export async function openRedisClient(url: string, discClient: Client) { export async function openRedisClient(url: string, discClient: Client) {
const client = await createClient({ const client = await createClient({
url: `redis://${url}`, url: `redis://${url}`,
socket: { socket: {
reconnectStrategy: 60000 reconnectStrategy: 60000
} }
}); });
client.on('error', (error) => { client.on('error', (error) => {
if (error.code == 'ECONNREFUSED') { if (error.code == 'ECONNREFUSED') {
console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)"); console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)");
} else if (error instanceof SocketClosedUnexpectedlyError) { } else if (error instanceof SocketClosedUnexpectedlyError) {
console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.'); console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.');
} else { } else {
console.error('Redis error:'); console.error('Redis error:');
console.error(error); console.error(error);
} }
}); });
client.on('connect', () => { client.on('connect', () => {
console.log('Connected to redis database'); console.log('Connected to redis database');
}); });
client.on('reconnecting', () => { client.on('reconnecting', () => {
console.log('Attempting to reconnect to redis database'); console.log('Attempting to reconnect to redis database');
}); });
client.once('connect', () => { client.once('connect', () => {
client.subscribe(['ticket_updates', 'ban_updates'], updateHandler); client.subscribe(['ticket_updates', 'ban_updates'], updateHandler);
}); });
client.connect(); client.connect();
discordClient = discClient; discordClient = discClient;
} }
function updateHandler(data: string, channel: string) { function updateHandler(data: string, channel: string) {
switch (channel) { switch (channel) {
case 'ticket_updates': case 'ticket_updates':
return ticketUpdateHandler(discordClient, data); return ticketUpdateHandler(discordClient, data);
case 'ban_updates': case 'ban_updates':
return banUpdateHandler(discordClient, data); return banUpdateHandler(discordClient, data);
} }
} }
+8 -8
View File
@@ -1,9 +1,9 @@
import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js'; import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js';
import { Handler } from './handler'; import { Handler } from './handler';
export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder; export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder;
export interface Command extends Handler { export interface Command extends Handler {
data: CommandBuilder | ((client: Client) => Promise<CommandBuilder>); data: CommandBuilder | ((client: Client) => Promise<CommandBuilder>);
guilds?: string[]; guilds?: string[];
} }
+78 -78
View File
@@ -1,79 +1,79 @@
export type LoggedMessage = { export type LoggedMessage = {
id: string id: string
author_id: string author_id: string
author_name: string author_name: string
channel_id: string channel_id: string
attachments: string attachments: string
stickers: string stickers: string
content: string content: string
} }
export type GuildSettings = { export type GuildSettings = {
guild_id: string guild_id: string
general_chat_id?: string general_chat_id?: string
new_member_channel_id?: string new_member_channel_id?: string
tickets_channel_id?: string tickets_channel_id?: string
event_logs_channel_id?: string event_logs_channel_id?: string
discord_logs_channel_id?: string discord_logs_channel_id?: string
audit_logs_channel_id?: string audit_logs_channel_id?: string
voice_logs_channel_id?: string voice_logs_channel_id?: string
admin_role_id?: string admin_role_id?: string
private_help_role_id?: string private_help_role_id?: string
devwatch_role_id?: string devwatch_role_id?: string
staff_categories?: string staff_categories?: string
safe_channels?: string safe_channels?: string
link_skip_channels?: string link_skip_channels?: string
github_release_channel?: string github_release_channel?: string
moderator_channel_id?: string moderator_channel_id?: string
private_help_channel_id?: string private_help_channel_id?: string
} }
export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels';
export type TicketMessage = { export type TicketMessage = {
id: number id: number
message_id: string message_id: string
} }
export type TicketPhrase = { export type TicketPhrase = {
id: number id: number
user_id: string user_id: string
phrase: string phrase: string
} }
export type Note = { export type Note = {
id: number id: number
user_id: string user_id: string
reason: string reason: string
mod_id: string mod_id: string
timestamp: string timestamp: string
} }
export type Ban = { export type Ban = {
id: number id: number
user_id: string user_id: string
expires: 0 | 1 expires: 0 | 1
expires_at: string expires_at: string
full_ban: 0 | 1 full_ban: 0 | 1
} }
export type GithubUserMapping = { export type GithubUserMapping = {
id: number id: number
discord_id: string discord_id: string
github_username: string github_username: string
} }
export type KnowledgebaseItem = { export type KnowledgebaseItem = {
id: number id: number
guild_id: string guild_id: string
name: string name: string
content: string content: string
} }
export type PrivateHelpTicket = { export type PrivateHelpTicket = {
id: number id: number
user_id: string user_id: string
thread_id: string thread_id: string
status: PrivateHelpTicketStatus status: PrivateHelpTicketStatus
timestamp: string timestamp: string
} }
+156 -156
View File
@@ -1,157 +1,157 @@
export type E621User = { export type E621User = {
wiki_page_version_count: number wiki_page_version_count: number
artist_version_count: number artist_version_count: number
pool_version_count: number pool_version_count: number
forum_post_count: number forum_post_count: number
comment_count: number comment_count: number
flag_count: number flag_count: number
favorite_count: number favorite_count: number
positive_feedback_count: number positive_feedback_count: number
neutral_feedback_count: number neutral_feedback_count: number
negative_feedback_count: number negative_feedback_count: number
upload_limit: number upload_limit: number
profile_about: string profile_about: string
profile_artinfo: string profile_artinfo: string
id: number id: number
created_at: string created_at: string
name: string name: string
level: number level: number
base_upload_limit: number base_upload_limit: number
post_upload_count: number post_upload_count: number
post_update_count: number post_update_count: number
note_update_count: number note_update_count: number
is_banned: boolean is_banned: boolean
can_approve_posts: boolean can_approve_posts: boolean
can_upload_free: boolean can_upload_free: boolean
level_string: string level_string: string
avatar_id: number avatar_id: number
} }
export type E621Post = { export type E621Post = {
id: number id: number
created_at: string created_at: string
updated_at: string updated_at: string
file: E621File file: E621File
preview: E621PreviewFile preview: E621PreviewFile
sample: E621SampleFile sample: E621SampleFile
score: E621ScoreData score: E621ScoreData
tags: E621Tags tags: E621Tags
locked_tags: string[] locked_tags: string[]
change_seq: number change_seq: number
flags: E621FlagData flags: E621FlagData
rating: 's' | 'q' | 'e' rating: 's' | 'q' | 'e'
fav_count: number fav_count: number
sources: string[] sources: string[]
pools: number[] pools: number[]
relationships: E621PostRelationships relationships: E621PostRelationships
approver_id: number approver_id: number
uploader_id: number uploader_id: number
description: string description: string
comment_count: number comment_count: number
is_favorited: boolean is_favorited: boolean
has_notes: boolean has_notes: boolean
duration: number | null duration: number | null
} }
export type E621File = { export type E621File = {
width: number width: number
height: number height: number
ext: 'png' | 'jpg' | 'mp4' | 'webm' ext: 'png' | 'jpg' | 'mp4' | 'webm'
size: number size: number
md5: string md5: string
url: string | null url: string | null
} }
export type E621PreviewFile = { export type E621PreviewFile = {
width: number width: number
height: number height: number
url: string | null url: string | null
} }
export type E621SampleFile = { export type E621SampleFile = {
has: boolean has: boolean
height: number height: number
width: number width: number
url: string | null url: string | null
// Typing this will be a pain in the ass, so I skipped it for now. // Typing this will be a pain in the ass, so I skipped it for now.
alternates: any alternates: any
} }
export type E621ScoreData = { export type E621ScoreData = {
up: number up: number
down: number down: number
total: number total: number
} }
export type E621Tags = { export type E621Tags = {
general: string[] general: string[]
artist: string[] artist: string[]
contributor: string[] contributor: string[]
copyright: string[] copyright: string[]
character: string[] character: string[]
species: string[] species: string[]
invalid: string[] invalid: string[]
meta: string[] meta: string[]
lore: string[] lore: string[]
} }
export type E621FlagData = { export type E621FlagData = {
pending: boolean pending: boolean
flagged: boolean flagged: boolean
note_locked: boolean note_locked: boolean
status_locked: boolean status_locked: boolean
rating_locked: boolean rating_locked: boolean
deleted: boolean deleted: boolean
} }
export type E621PostRelationships = { export type E621PostRelationships = {
parent_id: number | null parent_id: number | null
has_children: boolean has_children: boolean
has_active_children: boolean has_active_children: boolean
children: number[] children: number[]
} }
export type Ticket = { export type Ticket = {
id: number id: number
user_id: number user_id: number
user: string user: string
claimant: string | null claimant: string | null
target?: string target?: string
accused_id?: number accused_id?: number
target_id: number target_id: number
status: 'pending' | 'partial' | 'approved' status: 'pending' | 'partial' | 'approved'
category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki' category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki'
reason: string reason: string
}; };
export type TicketUpdate = { export type TicketUpdate = {
action: 'claim' | 'create' | 'unclaim' | 'update' action: 'claim' | 'create' | 'unclaim' | 'update'
ticket: Ticket ticket: Ticket
}; };
export type Ban = { export type Ban = {
id: number id: number
user_id: number user_id: number
banner_id: number banner_id: number
expires_at: string expires_at: string
reason: string reason: string
}; };
export type BanUpdate = { export type BanUpdate = {
action: 'create' | 'update' | 'delete' action: 'create' | 'update' | 'delete'
ban: Ban ban: Ban
}; };
export type RecordCategory = 'positive' | 'negative' | 'neutral' export type RecordCategory = 'positive' | 'negative' | 'neutral'
export type Record = { export type Record = {
id: number id: number
user_id: number user_id: number
creator_id: number creator_id: number
created_at: string created_at: string
body: string body: string
category: RecordCategory category: RecordCategory
updated_at: string updated_at: string
updater_id: number updater_id: number
is_deleted: boolean is_deleted: boolean
} }
+9 -9
View File
@@ -1,10 +1,10 @@
import { Client, Interaction } from 'discord.js'; import { Client, Interaction } from 'discord.js';
type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise<void> type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise<void>
export interface Handler { export interface Handler {
name: string; name: string;
handler: HandlerFunction; handler: HandlerFunction;
init?: (client: Client) => Promise<void>; init?: (client: Client) => Promise<void>;
autoComplete?: HandlerFunction; autoComplete?: HandlerFunction;
} }
+37 -37
View File
@@ -1,38 +1,38 @@
import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js'; import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js';
export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] }; export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] };
export type RoleChangeLog = { export type RoleChangeLog = {
key: '$add' | '$remove', key: '$add' | '$remove',
old?: Pick<APIRole, 'id' | 'name'>[], old?: Pick<APIRole, 'id' | 'name'>[],
new?: Pick<APIRole, 'id' | 'name'>[] new?: Pick<APIRole, 'id' | 'name'>[]
} }
type ApplicationCommandPermission = { type ApplicationCommandPermission = {
type: 1 | 2 | 3, type: 1 | 2 | 3,
permission: boolean, permission: boolean,
id: string id: string
} }
export type ApplicationCommandPermissionChangeLog = { export type ApplicationCommandPermissionChangeLog = {
key: sting, key: sting,
old?: ApplicationCommandPermission, old?: ApplicationCommandPermission,
new?: ApplicationCommandPermission new?: ApplicationCommandPermission
} }
export type TimeoutChangeLog = { export type TimeoutChangeLog = {
key: 'communication_disabled_until', key: 'communication_disabled_until',
old?: string, old?: string,
new?: string new?: string
} }
export type PermissionsChangeLog = { export type PermissionsChangeLog = {
key: 'permissions' | 'allow' | 'deny', key: 'permissions' | 'allow' | 'deny',
old?: number, old?: number,
new?: number new?: number
} }
export type PinExtras = { export type PinExtras = {
channel: TextChannel, channel: TextChannel,
messageId: string messageId: string
} }
+1 -1
View File
@@ -3,4 +3,4 @@ export * from './database-types.d';
export * from './e621-types.d'; export * from './e621-types.d';
export * from './handler.d'; export * from './handler.d';
export * from './helper-types.d'; export * from './helper-types.d';
export * from './scheduler.d'; export * from './scheduler.d';
+105 -105
View File
@@ -1,106 +1,106 @@
import { Guild } from 'discord.js'; import { Guild } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { userIsBanned } from './e621-utils'; import { userIsBanned } from './e621-utils';
import { config } from '../config'; import { config } from '../config';
export type AltData = { export type AltData = {
type: 'e621' | 'discord' type: 'e621' | 'discord'
thisId: number | string thisId: number | string
banned: boolean banned: boolean
alts: AltData[] alts: AltData[]
}; };
export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> { export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const e621UserIds = await Database.getE621Ids(discordId); const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds); const toIgnore = ignore.concat(e621UserIds);
let content = ''; let content = '';
for (const e621Id of e621UserIds) { for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue; if (ignore.includes(e621Id)) continue;
const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore); const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore);
const banned = await userIsBanned(e621Id); const banned = await userIsBanned(e621Id);
content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`; content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`;
} }
return content; return content;
} }
export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> { export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const discordIds = await Database.getDiscordIds(e621Id); const discordIds = await Database.getDiscordIds(e621Id);
let content = ''; let content = '';
for (const discordId of discordIds) { for (const discordId of discordIds) {
const alts = await getE621Alts(discordId, guild, depth + 1, ignore); const alts = await getE621Alts(discordId, guild, depth + 1, ignore);
let banned = false; let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt. // It's either this or fetch all the bans and sift through them for every discord alt.
try { try {
banned = !!(await guild.bans.fetch(discordId)); banned = !!(await guild.bans.fetch(discordId));
} catch (e) { } } catch (e) { }
content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`; content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`;
} }
return content; return content;
} }
export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise<AltData> { export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise<AltData> {
return getE621AltData(discordId, guild); return getE621AltData(discordId, guild);
} }
export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise<AltData> { export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise<AltData> {
return getDiscordAltData(e621Id, guild); return getDiscordAltData(e621Id, guild);
} }
async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> { async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const e621UserIds = await Database.getE621Ids(discordId); const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds); const toIgnore = ignore.concat(e621UserIds);
let banned = false; let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt. // It's either this or fetch all the bans and sift through them for every discord alt.
try { try {
banned = guild ? !!(await guild.bans.fetch(discordId)) : false; banned = guild ? !!(await guild.bans.fetch(discordId)) : false;
} catch (e) { } } catch (e) { }
const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] }; const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] };
for (const e621Id of e621UserIds) { for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue; if (ignore.includes(e621Id)) continue;
data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore)); data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore));
} }
return data; return data;
} }
async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> { async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const discordIds = await Database.getDiscordIds(e621Id); const discordIds = await Database.getDiscordIds(e621Id);
const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] }; const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] };
for (const discordId of discordIds) { for (const discordId of discordIds) {
data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore)); data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore));
} }
return data; return data;
} }
export function e621IdsFromAltData(altData: AltData, data: number[] = []) { export function e621IdsFromAltData(altData: AltData, data: number[] = []) {
if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number); if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number);
for (const alt of altData.alts) { for (const alt of altData.alts) {
e621IdsFromAltData(alt, data); e621IdsFromAltData(alt, data);
} }
return data; return data;
} }
+5 -5
View File
@@ -1,6 +1,6 @@
export function getArrayDifference(oldArr: any[], newArr: any[]) { export function getArrayDifference(oldArr: any[], newArr: any[]) {
const added = newArr.filter(e => !oldArr.includes(e)); const added = newArr.filter(e => !oldArr.includes(e));
const removed = oldArr.filter(e => !newArr.includes(e)); const removed = oldArr.filter(e => !newArr.includes(e));
return { added, removed }; return { added, removed };
} }
+196 -196
View File
@@ -1,197 +1,197 @@
import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js'; import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js';
import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types'; import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types';
import { getArrayDifference } from './array-utils'; import { getArrayDifference } from './array-utils';
export const enum TargetType { export const enum TargetType {
Unknown = 0, Unknown = 0,
Role = 1, Role = 1,
User = 2, User = 2,
Channel = 3 Channel = 3
}; };
const TARGETS_ROLES: AuditLogEvent[] = [ const TARGETS_ROLES: AuditLogEvent[] = [
AuditLogEvent.RoleCreate, AuditLogEvent.RoleCreate,
AuditLogEvent.RoleDelete, AuditLogEvent.RoleDelete,
AuditLogEvent.RoleUpdate AuditLogEvent.RoleUpdate
]; ];
const TARGETS_USERS: AuditLogEvent[] = [ const TARGETS_USERS: AuditLogEvent[] = [
AuditLogEvent.MemberUpdate, AuditLogEvent.MemberUpdate,
AuditLogEvent.MemberKick, AuditLogEvent.MemberKick,
AuditLogEvent.MemberBanAdd, AuditLogEvent.MemberBanAdd,
AuditLogEvent.MemberBanRemove, AuditLogEvent.MemberBanRemove,
AuditLogEvent.MemberRoleUpdate, AuditLogEvent.MemberRoleUpdate,
AuditLogEvent.MessageDelete, AuditLogEvent.MessageDelete,
AuditLogEvent.MessagePin, AuditLogEvent.MessagePin,
AuditLogEvent.MessageUnpin AuditLogEvent.MessageUnpin
]; ];
const TARGETS_CHANNELS: AuditLogEvent[] = [ const TARGETS_CHANNELS: AuditLogEvent[] = [
AuditLogEvent.ChannelCreate, AuditLogEvent.ChannelCreate,
AuditLogEvent.ChannelUpdate, AuditLogEvent.ChannelUpdate,
AuditLogEvent.ChannelDelete, AuditLogEvent.ChannelDelete,
AuditLogEvent.ThreadCreate, AuditLogEvent.ThreadCreate,
AuditLogEvent.ThreadUpdate, AuditLogEvent.ThreadUpdate,
AuditLogEvent.ThreadDelete, AuditLogEvent.ThreadDelete,
AuditLogEvent.ChannelOverwriteCreate, AuditLogEvent.ChannelOverwriteCreate,
AuditLogEvent.ChannelOverwriteDelete, AuditLogEvent.ChannelOverwriteDelete,
AuditLogEvent.ChannelOverwriteUpdate AuditLogEvent.ChannelOverwriteUpdate
]; ];
export function getTargetType(actionType: AuditLogEvent): TargetType { export function getTargetType(actionType: AuditLogEvent): TargetType {
if (TARGETS_ROLES.includes(actionType)) return TargetType.Role; if (TARGETS_ROLES.includes(actionType)) return TargetType.Role;
else if (TARGETS_USERS.includes(actionType)) return TargetType.User; else if (TARGETS_USERS.includes(actionType)) return TargetType.User;
else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel; else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel;
return TargetType.Unknown; return TargetType.Unknown;
} }
export function formatSnowflake(snowflake: string, targetType: TargetType): string { export function formatSnowflake(snowflake: string, targetType: TargetType): string {
if (targetType == TargetType.Role) return `<@&${snowflake}>`; if (targetType == TargetType.Role) return `<@&${snowflake}>`;
else if (targetType == TargetType.User) return `<@${snowflake}>`; else if (targetType == TargetType.User) return `<@${snowflake}>`;
else if (targetType == TargetType.Channel) return `<#${snowflake}>`; else if (targetType == TargetType.Channel) return `<#${snowflake}>`;
return snowflake; return snowflake;
} }
export function formatChanges(entry: GuildAuditLogsEntry): string { export function formatChanges(entry: GuildAuditLogsEntry): string {
return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n'); return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n');
} }
export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string { export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string {
if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin) if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin)
return formatMessagePin(entry.extra as unknown as PinExtras, guild); return formatMessagePin(entry.extra as unknown as PinExtras, guild);
if (entry.action == AuditLogEvent.ChannelOverwriteCreate if (entry.action == AuditLogEvent.ChannelOverwriteCreate
|| entry.action == AuditLogEvent.ChannelOverwriteDelete || entry.action == AuditLogEvent.ChannelOverwriteDelete
|| entry.action == AuditLogEvent.ChannelOverwriteUpdate) { || entry.action == AuditLogEvent.ChannelOverwriteUpdate) {
return `Target: ${entry.extra!.toString()}`; return `Target: ${entry.extra!.toString()}`;
} }
try { try {
const reserialized = JSON.parse(JSON.stringify(entry)); const reserialized = JSON.parse(JSON.stringify(entry));
const results: string[] = []; const results: string[] = [];
for (const [key, value] of Object.entries(reserialized.extra ?? {})) { for (const [key, value] of Object.entries(reserialized.extra ?? {})) {
if (!value) continue; if (!value) continue;
if (key == 'channel_id' || key == 'channel') { if (key == 'channel_id' || key == 'channel') {
results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`); results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`);
continue; continue;
} }
results.push(`${key}: ${value}`); results.push(`${key}: ${value}`);
} }
return results.join('\n'); return results.join('\n');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return ''; return '';
} }
return ''; return '';
} }
function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined { function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined {
if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) { if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) {
return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog); return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog);
} }
switch (change.key) { switch (change.key) {
case '$add': case '$add':
case '$remove': case '$remove':
return formatMemberRoleChange(change); return formatMemberRoleChange(change);
case 'communication_disabled_until': case 'communication_disabled_until':
return formatTimeoutChange(change); return formatTimeoutChange(change);
case 'permissions': case 'permissions':
case 'allow': case 'allow':
case 'deny': case 'deny':
return formatPermissionOrOverwrites(change as PermissionsChangeLog); return formatPermissionOrOverwrites(change as PermissionsChangeLog);
} }
const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old; const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old;
const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new; const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new;
if (change.new !== undefined && change.old === undefined) if (change.new !== undefined && change.old === undefined)
return `Set ${change.key} to ${newValue}`; return `Set ${change.key} to ${newValue}`;
if (change.new === undefined && change.old !== undefined) if (change.new === undefined && change.old !== undefined)
return `Set ${change.key} with value ${oldValue} to default/null`; return `Set ${change.key} with value ${oldValue} to default/null`;
return `Set ${change.key} from ${oldValue} to ${newValue}`; return `Set ${change.key} from ${oldValue} to ${newValue}`;
} }
function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined { function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined {
if (change.new !== undefined && change.old === 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}`; 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) if (change.new === undefined && change.old !== undefined)
return `Removed permission overrides from ${formatSnowflake(change.old.id, change.old.type)} for command: ${change.key}`; 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}`; 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 { function formatMemberRoleChange(change: RoleChangeLog): string | undefined {
if (!change.new) return; if (!change.new) return;
const changes: string[] = []; const changes: string[] = [];
for (const roleChange of change.new) { for (const roleChange of change.new) {
if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`); if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`); else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
} }
if (changes.length == 0) return; if (changes.length == 0) return;
return changes.join('\n'); return changes.join('\n');
} }
function formatTimeoutChange(change: TimeoutChangeLog): string { function formatTimeoutChange(change: TimeoutChangeLog): string {
if (!change.new) return 'Timeout removed'; if (!change.new) return 'Timeout removed';
const date = new Date(change.new); const date = new Date(change.new);
return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`; return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`;
} }
function formatPermissionOrOverwrites(change: PermissionsChangeLog): string { function formatPermissionOrOverwrites(change: PermissionsChangeLog): string {
const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray(); const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray();
const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray(); const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray();
switch (change.key) { switch (change.key) {
case 'permissions': case 'permissions':
return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)'); return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)');
case 'allow': case 'allow':
return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added'); return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added');
case 'deny': case 'deny':
return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added'); return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added');
default: default:
return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`); return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`);
} }
} }
function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) { function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) {
const { added, removed } = getArrayDifference(oldPermissions, newPermissions); const { added, removed } = getArrayDifference(oldPermissions, newPermissions);
const result: string[] = []; const result: string[] = [];
if (added.length > 0) { if (added.length > 0) {
result.push(`${addedDescription}: ${added.join(', ')}`); result.push(`${addedDescription}: ${added.join(', ')}`);
} }
if (removed.length > 0) { if (removed.length > 0) {
result.push(`${removedDescription}: ${removed.join(', ')}`); result.push(`${removedDescription}: ${removed.join(', ')}`);
} }
return result.join('\n'); return result.join('\n');
} }
function formatMessagePin(data: PinExtras, guild: Guild): string { function formatMessagePin(data: PinExtras, guild: Guild): string {
return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`; return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`;
} }
+48 -48
View File
@@ -1,49 +1,49 @@
import { Client } from 'discord.js'; import { Client } from 'discord.js';
import { BanUpdate } from '../types'; import { BanUpdate } from '../types';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { config } from '../config'; import { config } from '../config';
export async function banUpdateHandler(client: Client, update: string) { export async function banUpdateHandler(client: Client, update: string) {
const data: BanUpdate = JSON.parse(update); const data: BanUpdate = JSON.parse(update);
if (data.action == 'create') { if (data.action == 'create') {
kickDiscordAccounts(client, data); kickDiscordAccounts(client, data);
} }
// else if (data.action == 'delete') { // else if (data.action == 'delete') {
// // unbanDiscordAccounts(data); // // unbanDiscordAccounts(data);
// } // }
} }
async function kickDiscordAccounts(client: Client, data: BanUpdate) { async function kickDiscordAccounts(client: Client, data: BanUpdate) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!); const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
const discordIds = await Database.getDiscordIds(data.ban.user_id); const discordIds = await Database.getDiscordIds(data.ban.user_id);
for (const id of discordIds) { for (const id of discordIds) {
const member = await guild.members.fetch(id); 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}`); 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) { // async function banDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); // const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id); // const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) { // for (const id of discordIds) {
// await guild.bans.create(id, { // await guild.bans.create(id, {
// reason: data.ban.reason // reason: data.ban.reason
// }); // });
// } // }
// } // }
// async function unbanDiscordAccounts(data: BanUpdate) { // async function unbanDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!); // const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id); // const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) { // for (const id of discordIds) {
// await guild.bans.remove(id); // await guild.bans.remove(id);
// } // }
// } // }
+21 -21
View File
@@ -1,22 +1,22 @@
import { Client } from 'discord.js'; import { Client } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { config } from '../config'; import { config } from '../config';
export async function checkExpiredBans(client: Client) { export async function checkExpiredBans(client: Client) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!); const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) return; if (!guild) return;
const date = new Date(); const date = new Date();
for (const ban of await Database.getExpiredBans(date)) { for (const ban of await Database.getExpiredBans(date)) {
try { try {
await guild.bans.remove(ban.user_id); await guild.bans.remove(ban.user_id);
} catch (e) { } catch (e) {
console.error(`Error unbanning user: ${ban.user_id}`); console.error(`Error unbanning user: ${ban.user_id}`);
console.error(e); console.error(e);
} }
} }
await Database.pruneExpiredBans(date); await Database.pruneExpiredBans(date);
} }
+27 -27
View File
@@ -1,28 +1,28 @@
import { GuildBasedChannel } from 'discord.js'; import { GuildBasedChannel } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
export async function channelIsInStaffCategory(channel: GuildBasedChannel) { export async function channelIsInStaffCategory(channel: GuildBasedChannel) {
if (!channel.guildId || !channel.parentId) return false; if (!channel.guildId || !channel.parentId) return false;
const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId); const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId);
const parentChannel = await channel.guild.channels.fetch(channel.parentId); const parentChannel = await channel.guild.channels.fetch(channel.parentId);
return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId); return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId);
} }
export async function channelIsSafe(channel: GuildBasedChannel) { export async function channelIsSafe(channel: GuildBasedChannel) {
if (!channel.guildId) return false; if (!channel.guildId) return false;
const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId); const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId);
return safeChannels.includes(channel.id); return safeChannels.includes(channel.id);
} }
export async function channelIgnoresLinks(channel: GuildBasedChannel) { export async function channelIgnoresLinks(channel: GuildBasedChannel) {
if (!channel.guildId) return false; if (!channel.guildId) return false;
const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId); const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId);
return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false; return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false;
} }
+22 -22
View File
@@ -1,22 +1,22 @@
import fs from 'fs'; import fs from 'fs';
import { Handler } from '../types'; import { Handler } from '../types';
import path from 'path'; import path from 'path';
import { Client } from 'discord.js'; import { Client } from 'discord.js';
const ROOT_DIR = path.resolve(__dirname, '..'); const ROOT_DIR = path.resolve(__dirname, '..');
export function loadHandlersFrom(dir: string, handlerArray: Handler[]) { export function loadHandlersFrom(dir: string, handlerArray: Handler[]) {
if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return; if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return;
const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts')); const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts'));
for (const file of files) { for (const file of files) {
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default); handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default);
} }
} }
export async function initIfNecessary(client: Client, handlers: Handler[]) { export async function initIfNecessary(client: Client, handlers: Handler[]) {
for (const handler of handlers) { for (const handler of handlers) {
if (handler.init) await handler.init(client); if (handler.init) await handler.init(client);
} }
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import { config } from '../config'; import { config } from '../config';
export function logDebug(message: string) { export function logDebug(message: string) {
if (config.DEBUG) console.log(`[DEBUG] ${message}`); if (config.DEBUG) console.log(`[DEBUG] ${message}`);
} }
+35 -35
View File
@@ -1,36 +1,36 @@
import { Client, Guild, User } from 'discord.js'; import { Client, Guild, User } from 'discord.js';
import { Database, PrivateHelpTicketStatus } from '../shared/Database'; import { Database, PrivateHelpTicketStatus } from '../shared/Database';
export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise<User | null | undefined> { export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise<User | null | undefined> {
let user: User | null | undefined = null; let user: User | null | undefined = null;
try { try {
user = await client.users.fetch(value); user = await client.users.fetch(value);
} catch { } catch {
user = client.users.cache.find(u => u.username == value); user = client.users.cache.find(u => u.username == value);
if (!user && guild) { if (!user && guild) {
user = guild.members.cache.find(m => m.displayName == value)?.user; user = guild.members.cache.find(m => m.displayName == value)?.user;
if (!user) { if (!user) {
try { try {
const users = await guild.members.fetch({ const users = await guild.members.fetch({
query: value query: value
}); });
if (users.size > 0) user = users.first()!.user; if (users.size > 0) user = users.first()!.user;
} catch { } } catch { }
} }
} }
} }
return user; return user;
} }
export async function canOpenPrivateHelpTicket(id: string): Promise<boolean> { export async function canOpenPrivateHelpTicket(id: string): Promise<boolean> {
const latestTicket = await Database.getLatestPrivateHelpTicketBy(id); const latestTicket = await Database.getLatestPrivateHelpTicketBy(id);
if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false; if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false;
return true; return true;
} }
+82 -82
View File
@@ -1,83 +1,83 @@
import { config } from '../config'; import { config } from '../config';
import { E621Post, E621User, Record } from '../types'; import { E621Post, E621User, Record } from '../types';
const BLACKLISTED_TAGS: string[] = []; const BLACKLISTED_TAGS: string[] = [];
const BLACKLISTED_NONSAFE_TAGS: string[] = ['young']; const BLACKLISTED_NONSAFE_TAGS: string[] = ['young'];
const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports']; const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports'];
const SPOILERED_NONSAFE_TAGS: string[] = []; const SPOILERED_NONSAFE_TAGS: string[] = [];
const USER_AGENT = 'E621DiscordBot'; const USER_AGENT = 'E621DiscordBot';
async function request(path: string, query?: { [name: string]: string }): Promise<any> { async function request(path: string, query?: { [name: string]: string }): Promise<any> {
const url = new URL(config.E621_BASE_URL!); const url = new URL(config.E621_BASE_URL!);
url.pathname = path + '.json'; url.pathname = path + '.json';
if (query) { if (query) {
for (const [name, value] of Object.entries(query)) { for (const [name, value] of Object.entries(query)) {
url.searchParams.set(name, value); url.searchParams.set(name, value);
} }
} }
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
'User-Agent': USER_AGENT 'User-Agent': USER_AGENT
} }
}); });
if (!res.ok) return null; if (!res.ok) return null;
return await res.json(); return await res.json();
} }
export async function getE621User(idOrName: string | number): Promise<E621User | null> { export async function getE621User(idOrName: string | number): Promise<E621User | null> {
return await request(`/users/${idOrName}`) as E621User; return await request(`/users/${idOrName}`) as E621User;
} }
export async function getE621Post(id: string | number): Promise<E621Post | null> { export async function getE621Post(id: string | number): Promise<E621Post | null> {
return (await request(`/posts/${id}`))?.post as E621Post ?? null; return (await request(`/posts/${id}`))?.post as E621Post ?? null;
} }
export async function getE621PostByMd5(md5: string): Promise<E621Post | null> { export async function getE621PostByMd5(md5: string): Promise<E621Post | null> {
return (await request('/posts', { md5 }))?.post as E621Post ?? null; return (await request('/posts', { md5 }))?.post as E621Post ?? null;
} }
export const enum PostAction { export const enum PostAction {
NoAction = 0, NoAction = 0,
Spoiler = 1, Spoiler = 1,
Blacklist = 2 Blacklist = 2
} }
export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } { export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } {
const tags = Object.values(post.tags).flat(); const tags = Object.values(post.tags).flat();
for (const tag of tags) { for (const tag of tags) {
if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag }; if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag };
if (post.rating != 's' && BLACKLISTED_NONSAFE_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) { for (const tag of tags) {
if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag }; if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag };
if (post.rating != 's' && SPOILERED_NONSAFE_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: '' }; return { action: PostAction.NoAction, tag: '' };
} }
export function getPostUrl(post: E621Post): string { export function getPostUrl(post: E621Post): string {
if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`; if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`;
return `${config.E621_BASE_URL}/posts/${post.id}`; return `${config.E621_BASE_URL}/posts/${post.id}`;
} }
export async function userIsBanned(idOrName: string | number): Promise<boolean> { export async function userIsBanned(idOrName: string | number): Promise<boolean> {
const user = await getE621User(idOrName); const user = await getE621User(idOrName);
return user?.is_banned ?? false; return user?.is_banned ?? false;
} }
export async function getUserRecords(id: number): Promise<Record[]> { export async function getUserRecords(id: number): Promise<Record[]> {
const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() }); const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() });
if (records.user_feedbacks) return []; if (records.user_feedbacks) return [];
return records as Record[]; return records as Record[];
} }
+218 -218
View File
@@ -1,219 +1,219 @@
import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js'; import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js';
import { Message } from '../events'; import { Message } from '../events';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { LoggedMessage } from '../types'; import { LoggedMessage } from '../types';
import { channelIsInStaffCategory } from './channel-utils'; import { channelIsInStaffCategory } from './channel-utils';
import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils'; import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils';
type CustomEventLogData = { type CustomEventLogData = {
title: string title: string
description: string | null description: string | null
color: number | null color: number | null
timestamp: Date | number | null timestamp: Date | number | null
fields: APIEmbedField[] | null fields: APIEmbedField[] | null
} }
export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message<true>) { export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message<true>) {
const channel = await getEventLogChannel(newMessage.guild, newMessage.channel); const channel = await getEventLogChannel(newMessage.guild, newMessage.channel);
if (!channel) return; if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024; const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024;
const fields: APIEmbedField[] = []; const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, newMessage)); fields.push(...getMainEmbeds(loggedMessage, newMessage));
fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed)); fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed));
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Edited Message') .setTitle('Edited Message')
.setColor(0xFFFF00) .setColor(0xFFFF00)
.setTimestamp(newMessage.createdTimestamp) .setTimestamp(newMessage.createdTimestamp)
.addFields(...fields); .addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] }; const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) { if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' }); const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' });
const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' }); const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' });
messagePayload.files = [before, after]; messagePayload.files = [before, after];
} }
channel.send(messagePayload); channel.send(messagePayload);
} }
export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message<true>) { export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message<true>) {
const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel); const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel);
if (!channel) return; if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024; const includeContentInEmbed = loggedMessage.content.length <= 1024;
const fields: APIEmbedField[] = []; const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, deletedMessage)); fields.push(...getMainEmbeds(loggedMessage, deletedMessage));
fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed)); fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed));
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Deleted Message') .setTitle('Deleted Message')
.setColor(0xFF0000) .setColor(0xFF0000)
.setTimestamp(deletedMessage.createdTimestamp) .setTimestamp(deletedMessage.createdTimestamp)
.addFields(...fields); .addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] }; const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) { if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' }); const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' });
messagePayload.files = [before]; messagePayload.files = [before];
} }
channel.send(messagePayload); channel.send(messagePayload);
} }
export async function logCustomEvent(guild: Guild, data: CustomEventLogData) { export async function logCustomEvent(guild: Guild, data: CustomEventLogData) {
const channel = await getEventLogChannel(guild); const channel = await getEventLogChannel(guild);
if (!channel) return; if (!channel) return;
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(data.title) .setTitle(data.title)
.setColor(data.color) .setColor(data.color)
.setTimestamp(data.timestamp); .setTimestamp(data.timestamp);
if (data.fields) embed.addFields(...data.fields); if (data.fields) embed.addFields(...data.fields);
channel.send({ embeds: [embed] }); channel.send({ embeds: [embed] });
} }
async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise<GuildTextBasedChannel | null> { async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise<GuildTextBasedChannel | null> {
const settings = await Database.getGuildSettings(guild.id); const settings = await Database.getGuildSettings(guild.id);
if (!settings) return null; if (!settings) return null;
if (channel && await channelIsInStaffCategory(channel)) { if (channel && await channelIsInStaffCategory(channel)) {
if (!settings.event_logs_channel_id) return null; if (!settings.event_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.event_logs_channel_id); const channel = await guild.channels.fetch(settings.event_logs_channel_id);
if (!channel || !channel.isSendable()) return null; if (!channel || !channel.isSendable()) return null;
return channel; return channel;
} else { } else {
if (!settings.discord_logs_channel_id) return null; if (!settings.discord_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.discord_logs_channel_id); const channel = await guild.channels.fetch(settings.discord_logs_channel_id);
if (!channel || !channel.isSendable()) return null; if (!channel || !channel.isSendable()) return null;
return channel; return channel;
} }
} }
function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>): APIEmbedField[] { function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>): APIEmbedField[] {
const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`; const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`;
const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`; const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`;
return [ return [
{ {
name: 'Channel', name: 'Channel',
value: channelString, value: channelString,
inline: true inline: true
}, },
{ {
name: 'User', name: 'User',
value: userString, value: userString,
inline: true inline: true
}, },
{ {
name: 'Message', name: 'Message',
value: `[${newMessage.id}](${newMessage.url})`, value: `[${newMessage.id}](${newMessage.url})`,
inline: true inline: true
}, },
]; ];
} }
function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] { function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = []; const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != '') { if (includeContentInEmbed && loggedMessage.content != '') {
fields.push({ fields.push({
name: 'Content', name: 'Content',
value: loggedMessage.content, value: loggedMessage.content,
inline: false inline: false
}); });
} }
for (const attachment of deserializeMessagePart(loggedMessage.attachments)) { for (const attachment of deserializeMessagePart(loggedMessage.attachments)) {
fields.push({ fields.push({
name: 'Attachment', name: 'Attachment',
value: attachment, value: attachment,
inline: true inline: true
}); });
} }
for (const sticker of deserializeMessagePart(loggedMessage.stickers)) { for (const sticker of deserializeMessagePart(loggedMessage.stickers)) {
fields.push({ fields.push({
name: 'Stickers', name: 'Stickers',
value: sticker, value: sticker,
inline: true inline: true
}); });
} }
return fields; return fields;
} }
function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>, includeContentInEmbed = true): APIEmbedField[] { function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = []; const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != newMessage.content) { if (includeContentInEmbed && loggedMessage.content != newMessage.content) {
fields.push( fields.push(
{ {
name: 'Before', name: 'Before',
value: loggedMessage.content, value: loggedMessage.content,
inline: false inline: false
}, },
{ {
name: 'After', name: 'After',
value: newMessage.content, value: newMessage.content,
inline: false inline: false
} }
); );
} }
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
for (const removedAttachment of removedAttachments) { for (const removedAttachment of removedAttachments) {
fields.push({ fields.push({
name: 'Removed Attachment', name: 'Removed Attachment',
value: removedAttachment, value: removedAttachment,
inline: true inline: true
}); });
} }
for (const addedAttachment of addedAttachments) { for (const addedAttachment of addedAttachments) {
fields.push({ fields.push({
name: 'Added Attachment', name: 'Added Attachment',
value: addedAttachment, value: addedAttachment,
inline: true inline: true
}); });
} }
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
for (const removedSticker of addedStickers) { for (const removedSticker of addedStickers) {
fields.push({ fields.push({
name: 'Removed Sticker', name: 'Removed Sticker',
value: removedSticker, value: removedSticker,
inline: true inline: true
}); });
} }
for (const addedSticker of removedStickers) { for (const addedSticker of removedStickers) {
fields.push({ fields.push({
name: 'Added Sticker', name: 'Added Sticker',
value: addedSticker, value: addedSticker,
inline: true inline: true
}); });
} }
return fields; return fields;
} }
+70 -70
View File
@@ -1,71 +1,71 @@
import crypto from 'crypto'; import crypto from 'crypto';
const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26; const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26;
const END_PNG_BYTES = 12; const END_PNG_BYTES = 12;
const DISCORD_JPG_START_OFFSET = 3; const DISCORD_JPG_START_OFFSET = 3;
const DISCORD_JPG_REMOVE_BYTE_LENGTH = 23; 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 = 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; 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 const ALLOWED_MIMETYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'video/mp4', 'video/webm'];
export function calculateMD5(data: Buffer): string { export function calculateMD5(data: Buffer): string {
return crypto.createHash('md5').update(data).digest('hex'); return crypto.createHash('md5').update(data).digest('hex');
} }
// Downloads a file from discord's CDN and reverts the changes they do to the file. // 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. // Returns both the corrected version at index 0, and the original version from discord at index 1.
export async function downloadFile(url: string): Promise<Buffer[] | null> { export async function downloadFile(url: string): Promise<Buffer[] | null> {
try { try {
const res = await fetch(url); const res = await fetch(url);
const mimeType = res.headers.get('Content-Type')!; const mimeType = res.headers.get('Content-Type')!;
if (!ALLOWED_MIMETYPES.includes(mimeType)) return null; if (!ALLOWED_MIMETYPES.includes(mimeType)) return null;
const data = await res.arrayBuffer(); const data = await res.arrayBuffer();
let finalData: Buffer; let finalData: Buffer;
if (mimeType == 'image/png') { if (mimeType == 'image/png') {
const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES; const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES;
const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
const buff = Buffer.from(data); const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, startOffset); buff.copy(correctedData, 0, 0, startOffset);
buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH); buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
finalData = correctedData; finalData = correctedData;
} else if (mimeType == 'image/jpg' || mimeType == 'image/jpeg') { } 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 correctedData = Buffer.alloc(data.byteLength - DISCORD_JPG_REMOVE_BYTE_LENGTH + DISCORD_JPG_REINSERT_BYTE_LENGTH);
const buff = Buffer.from(data); const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET); buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET);
DISCORD_JPG_REINSERT.copy(correctedData, 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); buff.copy(correctedData, DISCORD_JPG_REMOVE_BYTE_LENGTH - DISCORD_JPG_START_OFFSET, DISCORD_JPG_START_OFFSET + DISCORD_JPG_REMOVE_BYTE_LENGTH);
finalData = correctedData; finalData = correctedData;
} else { } else {
finalData = Buffer.from(data); finalData = Buffer.from(data);
} }
return [finalData, Buffer.from(data)]; return [finalData, Buffer.from(data)];
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
} }
// This method is used with discord CDN URLs. // 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. // 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> { export async function calculateMD5FromURL(url: string): Promise<{ correctedFileMD5: string, originalFileMD5: string } | null> {
try { try {
const files = await downloadFile(url); const files = await downloadFile(url);
if (!files) return null; if (!files) return null;
return { return {
correctedFileMD5: calculateMD5(files[0]), correctedFileMD5: calculateMD5(files[0]),
originalFileMD5: calculateMD5(files[1]) originalFileMD5: calculateMD5(files[1])
}; };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
} }
} }
+18 -18
View File
@@ -1,19 +1,19 @@
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
const mentionRegex = new RegExp('@([\\S]+),|@([\\S]+)', 'gi'); 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'); 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<string> { export async function fixPings(body: string): Promise<string> {
const mappings = await Database.getAllGithubUserMappings(); const mappings = await Database.getAllGithubUserMappings();
return body.replaceAll(mentionRegex, (match, m1, m2) => { return body.replaceAll(mentionRegex, (match, m1, m2) => {
const name = m1 ?? m2; const name = m1 ?? m2;
const mapping = mappings.find(m => m.github_username == name); const mapping = mappings.find(m => m.github_username == name);
return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match; return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match;
}); });
} }
export function removeIssueLinks(body: string): string { export function removeIssueLinks(body: string): string {
return body.replaceAll(issueLinkRegex, ''); return body.replaceAll(issueLinkRegex, '');
} }
+29 -29
View File
@@ -1,29 +1,29 @@
export * from './alt-utils'; export * from './alt-utils';
export * from './array-utils'; export * from './array-utils';
export * from './audit-log-utils'; export * from './audit-log-utils';
export * from './ban-events'; export * from './ban-events';
export * from './ban-utils'; export * from './ban-utils';
export * from './channel-utils'; export * from './channel-utils';
export * from './commands'; export * from './commands';
export * from './debug-utils'; export * from './debug-utils';
export * from './discord-user-utils'; export * from './discord-user-utils';
export * from './e621-utils'; export * from './e621-utils';
export * from './event-log-utils'; export * from './event-log-utils';
export * from './file-utils'; export * from './file-utils';
export * from './github-user-utils'; export * from './github-user-utils';
export * from './interaction-utils'; export * from './interaction-utils';
export * from './message-matcher-regex'; export * from './message-matcher-regex';
export * from './message-utils'; export * from './message-utils';
export * from './modal-utils'; export * from './modal-utils';
export * from './ms-to-human'; export * from './ms-to-human';
export * from './name-sync'; export * from './name-sync';
export * from './note-utils'; export * from './note-utils';
export * from './oauth2'; export * from './oauth2';
export * from './private-help-utils'; export * from './private-help-utils';
export * from './record-utils'; export * from './record-utils';
export * from './refresh-commands'; export * from './refresh-commands';
export * from './string-utils'; export * from './string-utils';
export * from './ticket-events'; export * from './ticket-events';
export * from './ticket-utils'; export * from './ticket-utils';
export * from './wait'; export * from './wait';
export * from './whois'; export * from './whois';
+8 -8
View File
@@ -1,9 +1,9 @@
import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js'; import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { channelIsInStaffCategory } from './channel-utils'; import { channelIsInStaffCategory } from './channel-utils';
export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) { export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) {
const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel); const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel);
if (isStaffChannel) await interaction.deferReply(); if (isStaffChannel) await interaction.deferReply();
else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
} }
+17 -17
View File
@@ -1,18 +1,18 @@
export const postIDRegex = new RegExp('post #([0-9]+)', 'gi'); export const postIDRegex = new RegExp('post #([0-9]+)', 'gi');
export const userIDRegex = new RegExp('user #([0-9]+)', 'gi'); export const userIDRegex = new RegExp('user #([0-9]+)', 'gi');
export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi'); export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi');
export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi'); export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi');
export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi'); export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi');
export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi'); export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi');
export const setIDRegex = new RegExp('set #([0-9]+)', 'gi'); export const setIDRegex = new RegExp('set #([0-9]+)', 'gi');
export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi'); export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi');
export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi'); export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi');
export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi'); export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi');
export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi'); export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi');
const tagSearchRegex = '(?:[\\S]| )+?'; const tagSearchRegex = '(?:[\\S]| )+?';
export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi'); export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi');
export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi'); export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi');
export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi'); export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi');
export const issueRegex = new RegExp('issue #([0-9]+)', 'gi'); export const issueRegex = new RegExp('issue #([0-9]+)', 'gi');
+59 -59
View File
@@ -1,60 +1,60 @@
import { Message } from '../events'; import { Message } from '../events';
import { LoggedMessage } from '../types'; import { LoggedMessage } from '../types';
export const ARRAY_SEPARATOR = '$'; export const ARRAY_SEPARATOR = '$';
const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi'); const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi');
export function serializeMessage(message: Message): string[] { export function serializeMessage(message: Message): string[] {
const attachments = message.attachments.map(a => `${a.name}:${a.id}`); const attachments = message.attachments.map(a => `${a.name}:${a.id}`);
const stickers = message.stickers.map(s => `${s.name}:${s.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]; 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[] { export function deserializeMessagePart(part: string): string[] {
return part.split(ARRAY_SEPARATOR).filter(e => e); return part.split(ARRAY_SEPARATOR).filter(e => e);
} }
export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } { export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } {
const loggedAttachments = loggedMessage.attachments.split(ARRAY_SEPARATOR).filter(e => e); 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 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)!)); const removedAttachments = loggedAttachments.filter(a => !newMessage.attachments.has(a.split(':').at(-1)!));
return { addedAttachments, removedAttachments }; return { addedAttachments, removedAttachments };
} }
export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } { export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } {
const loggedStickers = loggedMessage.stickers.split(ARRAY_SEPARATOR).filter(e => e); 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 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)!)); const removedStickers = loggedStickers.filter(s => !newMessage.stickers.has(s.split(':').at(-1)!));
return { addedStickers, removedStickers }; return { addedStickers, removedStickers };
} }
export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) { export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) {
if (newMessage.content != loggedMessage.content) return true; if (newMessage.content != loggedMessage.content) return true;
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage); const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
if (addedAttachments.length > 0 || removedAttachments.length > 0) return true; if (addedAttachments.length > 0 || removedAttachments.length > 0) return true;
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage); const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
return addedStickers.length > 0 || removedStickers.length > 0; return addedStickers.length > 0 || removedStickers.length > 0;
} }
export function isInSpoilerTags(content: string, index: number): boolean { export function isInSpoilerTags(content: string, index: number): boolean {
if (!content.includes('||')) return false; if (!content.includes('||')) return false;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = spoilerRegex.exec(content)) != null) { while ((match = spoilerRegex.exec(content)) != null) {
if (index >= match.index && index <= spoilerRegex.lastIndex) { if (index >= match.index && index <= spoilerRegex.lastIndex) {
spoilerRegex.lastIndex = 0; spoilerRegex.lastIndex = 0;
return true; return true;
} }
} }
spoilerRegex.lastIndex = 0; spoilerRegex.lastIndex = 0;
return false; return false;
} }
+42 -42
View File
@@ -1,43 +1,43 @@
import { LabelBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; 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 { 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() const input = new TextInputBuilder()
.setCustomId(customId) .setCustomId(customId)
.setStyle(style) .setStyle(style)
.setRequired(required); .setRequired(required);
if (maxLength !== null) input.setMaxLength(maxLength); if (maxLength !== null) input.setMaxLength(maxLength);
if (minLength !== null) input.setMinLength(minLength); if (minLength !== null) input.setMinLength(minLength);
const label = new LabelBuilder() const label = new LabelBuilder()
.setLabel(labelTitle); .setLabel(labelTitle);
if (description !== null) label.setDescription(description); if (description !== null) label.setDescription(description);
label.setTextInputComponent(input); label.setTextInputComponent(input);
return label; return label;
} }
export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder { export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder {
const yesNoMenu = new StringSelectMenuBuilder() const yesNoMenu = new StringSelectMenuBuilder()
.setCustomId(customId) .setCustomId(customId)
.addOptions( .addOptions(
new StringSelectMenuOptionBuilder() new StringSelectMenuOptionBuilder()
.setLabel('Yes') .setLabel('Yes')
.setDefault(defaultYes) .setDefault(defaultYes)
.setValue('yes'), .setValue('yes'),
new StringSelectMenuOptionBuilder() new StringSelectMenuOptionBuilder()
.setLabel('No') .setLabel('No')
.setDefault(!defaultYes) .setDefault(!defaultYes)
.setValue('no') .setValue('no')
); );
const yesNoLabel = new LabelBuilder() const yesNoLabel = new LabelBuilder()
.setLabel(labelTitle) .setLabel(labelTitle)
.setStringSelectMenuComponent(yesNoMenu); .setStringSelectMenuComponent(yesNoMenu);
if (description !== null) yesNoLabel.setDescription(description); if (description !== null) yesNoLabel.setDescription(description);
return yesNoLabel; return yesNoLabel;
} }
+11 -11
View File
@@ -1,12 +1,12 @@
export function msToHuman(ms: number) { export function msToHuman(ms: number) {
const time = { const time = {
day: Math.floor(ms / 86400000), day: Math.floor(ms / 86400000),
hour: Math.floor(ms / 3600000) % 24, hour: Math.floor(ms / 3600000) % 24,
minute: Math.floor(ms / 60000) % 60, minute: Math.floor(ms / 60000) % 60,
second: Math.floor(ms / 1000) % 60, second: Math.floor(ms / 1000) % 60,
}; };
return Object.entries(time) return Object.entries(time)
.filter(val => val[1] !== 0) .filter(val => val[1] !== 0)
.map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`) .map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`)
.join(', '); .join(', ');
} }
+27 -27
View File
@@ -1,28 +1,28 @@
import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js'; import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { E621User } from '../types'; import { E621User } from '../types';
import { getE621User } from './e621-utils'; import { getE621User } from './e621-utils';
export async function syncName(interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ModalSubmitInteraction, member: GuildMember, id: number | null) { 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) { if (member.roles.highest.comparePositionTo(member.guild.members.me!.roles.highest) > 0) {
const res = interaction.user.id == member.id ? 'your' : 'their'; 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.`); 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); const availableIds = await Database.getE621Ids(interaction.user.id);
let e621User: E621User | null; let e621User: E621User | null;
if (!id || !availableIds.includes(id)) { if (!id || !availableIds.includes(id)) {
e621User = await getE621User(availableIds[0]); e621User = await getE621User(availableIds[0]);
} else { } else {
e621User = await getE621User(id); e621User = await getE621User(id);
} }
if (!e621User) { if (!e621User) {
return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator."); return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator.");
} }
await member.setNickname(e621User.name); await member.setNickname(e621User.name);
interaction.editReply(`Nickname set to: ${e621User.name}`); interaction.editReply(`Nickname set to: ${e621User.name}`);
} }
+48 -48
View File
@@ -1,49 +1,49 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { MessageContent, Note } from '../types'; import { MessageContent, Note } from '../types';
const NOTES_PER_PAGE = 5; const NOTES_PER_PAGE = 5;
function getNoteText(note: Note): string { function getNoteText(note: Note): string {
const timestamp = time(new Date(note.timestamp)); const timestamp = time(new Date(note.timestamp));
return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`; return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`;
} }
export async function getNoteMessage(userId: string, page: number): Promise<MessageContent | null> { export async function getNoteMessage(userId: string, page: number): Promise<MessageContent | null> {
const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
page = page - 1; page = page - 1;
const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1); const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1);
if (notes.length == 0) return null; if (notes.length == 0) return null;
const noteTexts: string[] = []; const noteTexts: string[] = [];
for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) { for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) {
if (i >= notes.length) break; if (i >= notes.length) break;
noteTexts.push(getNoteText(notes[i])); noteTexts.push(getNoteText(notes[i]));
} }
const prevPage = new ButtonBuilder() const prevPage = new ButtonBuilder()
.setLabel('Previous Page') .setLabel('Previous Page')
.setCustomId(`note-previous_${userId}_${page + 1}`) .setCustomId(`note-previous_${userId}_${page + 1}`)
.setDisabled(page == 0) .setDisabled(page == 0)
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder() const nextPage = new ButtonBuilder()
.setLabel('Next Page') .setLabel('Next Page')
.setCustomId(`note-next_${userId}_${page + 1}`) .setCustomId(`note-next_${userId}_${page + 1}`)
.setDisabled(page >= maxPage) .setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage); .addComponents(prevPage, nextPage);
return { return {
content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`, content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`,
components: [row] components: [row]
}; };
} }
+136 -136
View File
@@ -1,137 +1,137 @@
type ClientOptions = { type ClientOptions = {
clientId: string clientId: string
clientSecret: string clientSecret: string
clientToken: string clientToken: string
redirectUri: string redirectUri: string
credentials: string credentials: string
}; };
type GenerateUrlParameters = { type GenerateUrlParameters = {
state: string state: string
scope: string[] scope: string[]
type: 'code' | 'token' type: 'code' | 'token'
}; };
type TokenResponse = { type TokenResponse = {
access_token: string access_token: string
token_type: string token_type: string
expires_in: number expires_in: number
refresh_token: string refresh_token: string
scope: string scope: string
} }
type DiscordUser = { type DiscordUser = {
id: string id: string
username: string username: string
// bunch of other stuff we don't use // bunch of other stuff we don't use
} }
type AddMemberOptions = { type AddMemberOptions = {
accessToken: string accessToken: string
botToken?: string botToken?: string
guildId: string guildId: string
userId: string userId: string
nickname?: string nickname?: string
} }
const OAUTH_BASE_URL = 'https://discord.com/oauth2'; const OAUTH_BASE_URL = 'https://discord.com/oauth2';
const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2'; const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2';
const API_BASE_URL = 'https://discord.com/api'; const API_BASE_URL = 'https://discord.com/api';
export class DiscordOAuth2 { export class DiscordOAuth2 {
constructor(private options: ClientOptions) { } constructor(private options: ClientOptions) { }
generateOauth2Url(options: GenerateUrlParameters) { generateOauth2Url(options: GenerateUrlParameters) {
const url = new URL(`${OAUTH_BASE_URL}/authorize`); const url = new URL(`${OAUTH_BASE_URL}/authorize`);
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.options.clientId, client_id: this.options.clientId,
response_type: options.type, response_type: options.type,
redirect_uri: this.options.redirectUri, redirect_uri: this.options.redirectUri,
scope: options.scope.join('+'), scope: options.scope.join('+'),
state: options.state state: options.state
}); });
url.search = params.toString(); url.search = params.toString();
return url.toString(); return url.toString();
} }
async getAccessToken(code: string, scope: string[]): Promise<TokenResponse> { async getAccessToken(code: string, scope: string[]): Promise<TokenResponse> {
const res = await fetch(`${OAUTH_API_BASE_URL}/token`, { const res = await fetch(`${OAUTH_API_BASE_URL}/token`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({
client_id: this.options.clientId, client_id: this.options.clientId,
client_secret: this.options.clientSecret, client_secret: this.options.clientSecret,
code, code,
grant_type: 'authorization_code', grant_type: 'authorization_code',
redirect_uri: this.options.redirectUri, redirect_uri: this.options.redirectUri,
scope: scope.join(' ') scope: scope.join(' ')
}).toString(), }).toString(),
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json' Accept: 'application/json'
} }
}); });
const data = await res.json(); const data = await res.json();
return data as TokenResponse; return data as TokenResponse;
} }
async getUser(accessToken: string): Promise<DiscordUser> { async getUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${API_BASE_URL}/users/@me`, { const res = await fetch(`${API_BASE_URL}/users/@me`, {
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
Accept: 'application/json' Accept: 'application/json'
} }
}); });
return await res.json() as DiscordUser; return await res.json() as DiscordUser;
} }
async addMember(options: AddMemberOptions) { async addMember(options: AddMemberOptions) {
const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, { const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
nick: options.nickname, nick: options.nickname,
access_token: options.accessToken access_token: options.accessToken
}), }),
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bot ${this.options.clientToken}`, Authorization: `Bot ${this.options.clientToken}`,
Accept: 'application/json' Accept: 'application/json'
} }
}); });
if (res.status < 200 || res.status >= 300) { if (res.status < 200 || res.status >= 300) {
console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`); console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`);
const text = await res.text(); const text = await res.text();
console.error(text); console.error(text);
let data = { code: 0 }; let data = { code: 0 };
try { try {
data = JSON.parse(text); data = JSON.parse(text);
} catch { } } catch { }
throw data; throw data;
} }
return await res.json(); return await res.json();
} }
async revokeToken(token: string) { async revokeToken(token: string) {
const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, { const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, {
method: 'POST', method: 'POST',
body: new URLSearchParams({ body: new URLSearchParams({
token token
}).toString(), }).toString(),
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${this.options.credentials}`, Authorization: `Basic ${this.options.credentials}`,
Accept: 'application/json' Accept: 'application/json'
} }
}); });
return await res.json(); return await res.json();
} }
} }
+99 -99
View File
@@ -1,100 +1,100 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, Client, Guild, GuildMember, ModalBuilder, PrivateThreadChannel, TextChannel, TextInputStyle, ThreadAutoArchiveDuration, ThreadChannel, UserContextMenuCommandInteraction } from 'discord.js'; 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 { Database } from '../shared/Database';
import { createTextInput, createYesNoMenu } from './modal-utils'; import { createTextInput, createYesNoMenu } from './modal-utils';
export async function closeOldTickets(client: Client) { export async function closeOldTickets(client: Client) {
for (const ticket of await Database.getAllOpenPrivateHelpTickets()) { for (const ticket of await Database.getAllOpenPrivateHelpTickets()) {
try { try {
const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel; const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel;
const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0); const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0);
if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) { if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) {
await Database.closePrivateHelpTicket(thread.id); await Database.closePrivateHelpTicket(thread.id);
await thread.send('This ticket has been closed due to inactivity.'); await thread.send('This ticket has been closed due to inactivity.');
thread.edit({ thread.edit({
archived: true, archived: true,
locked: true locked: true
}); });
} }
} catch (e) { } catch (e) {
console.error('Error closing ticket due to inactivity:'); console.error('Error closing ticket due to inactivity:');
console.error(e); console.error(e);
} }
} }
} }
export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise<PrivateThreadChannel | null> { export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise<PrivateThreadChannel | null> {
const guildSettings = await Database.getGuildSettings(guild.id); const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null; 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 channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel;
const thread = await channel.threads.create({ const thread = await channel.threads.create({
name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'), name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'),
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
invitable: false, invitable: false,
type: ChannelType.PrivateThread type: ChannelType.PrivateThread
}) as PrivateThreadChannel; }) as PrivateThreadChannel;
if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id); if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id);
if (creator) { if (creator) {
const closeButton = new ButtonBuilder() const closeButton = new ButtonBuilder()
.setCustomId('close-ticket') .setCustomId('close-ticket')
.setLabel('Click here if you no longer need help') .setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger); .setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder() const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket') .setCustomId('claim-ticket')
.setLabel('Claim ticket') .setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await thread.send({ 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.`, 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], components: [row],
allowedMentions: { allowedMentions: {
users: [creator.id], users: [creator.id],
roles: [guildSettings.private_help_role_id] roles: [guildSettings.private_help_role_id]
} }
}); });
} else { } else {
const closeButton = new ButtonBuilder() const closeButton = new ButtonBuilder()
.setCustomId('close-mod-ticket') .setCustomId('close-mod-ticket')
.setLabel('Close Mod Ticket') .setLabel('Close Mod Ticket')
.setStyle(ButtonStyle.Danger); .setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton); const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton);
await thread.send({ await thread.send({
content: reason, content: reason,
components: [row], components: [row],
allowedMentions: { allowedMentions: {
users: Array.from(new Set(additionalMembersToAdd)) users: Array.from(new Set(additionalMembersToAdd))
} }
}); });
} }
for (const id of additionalMembersToAdd) { for (const id of additionalMembersToAdd) {
await thread.members.add(id); await thread.members.add(id);
} }
return thread; return thread;
} }
export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) { export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) {
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId(`open-mod-ticket_${member.id}`) .setCustomId(`open-mod-ticket_${member.id}`)
.setTitle('Opening A Mod Ticket'); .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 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 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); 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); modal.addLabelComponents(titleLabel, initialMessageLabel, autoJoinLabel);
interaction.showModal(modal); interaction.showModal(modal);
} }
+102 -102
View File
@@ -1,103 +1,103 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { E621User, MessageContent, Record, RecordCategory } from '../types'; import { E621User, MessageContent, Record, RecordCategory } from '../types';
import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils'; import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils';
import { getE621User, getUserRecords } from './e621-utils'; import { getE621User, getUserRecords } from './e621-utils';
type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User } type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User }
type AllRecords = RecordWithUserData[]; type AllRecords = RecordWithUserData[];
const RECORDS_PER_PAGE = 5; const RECORDS_PER_PAGE = 5;
export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise<AllRecords> { export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise<AllRecords> {
const altData = await comprehensiveAltLookupFromDiscord(id, guild); const altData = await comprehensiveAltLookupFromDiscord(id, guild);
const userCache: Map<number, E621User> = new Map(); const userCache: Map<number, E621User> = new Map();
const allRecords: AllRecords = []; const allRecords: AllRecords = [];
const e621UserIds = e621IdsFromAltData(altData); const e621UserIds = e621IdsFromAltData(altData);
for (const id of e621UserIds) { for (const id of e621UserIds) {
const user = userCache.get(id) ?? await getE621User(id); const user = userCache.get(id) ?? await getE621User(id);
if (!user) continue; if (!user) continue;
userCache.set(id, user); userCache.set(id, user);
const records = await getUserRecords(id); const records = await getUserRecords(id);
for (const record of records) { for (const record of records) {
const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id); const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id);
if (!creator) continue; if (!creator) continue;
userCache.set(record.creator_id, creator); userCache.set(record.creator_id, creator);
const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id); const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id);
if (!updater) continue; if (!updater) continue;
userCache.set(record.updater_id, updater); userCache.set(record.updater_id, updater);
allRecords.push({ allRecords.push({
user, user,
creator, creator,
updater, updater,
...record ...record
}); });
} }
} }
return allRecords; return allRecords;
} }
export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise<MessageContent | null> { export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise<MessageContent | null> {
const records = await getAllRecordsFromDiscordId(id, guild); const records = await getAllRecordsFromDiscordId(id, guild);
if (records.length == 0) return null; if (records.length == 0) return null;
page = page - 1; page = page - 1;
const maxPage = Math.floor(records.length / RECORDS_PER_PAGE); const maxPage = Math.floor(records.length / RECORDS_PER_PAGE);
const embeds: EmbedBuilder[] = []; const embeds: EmbedBuilder[] = [];
for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) { for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) {
if (i >= records.length) break; if (i >= records.length) break;
embeds.push(getRecordEmbed(records[i])); embeds.push(getRecordEmbed(records[i]));
} }
const prevPage = new ButtonBuilder() const prevPage = new ButtonBuilder()
.setLabel('Previous Page') .setLabel('Previous Page')
.setCustomId(`records-previous_${id}_${page + 1}`) .setCustomId(`records-previous_${id}_${page + 1}`)
.setDisabled(page == 0) .setDisabled(page == 0)
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder() const nextPage = new ButtonBuilder()
.setLabel('Next Page') .setLabel('Next Page')
.setCustomId(`records-next_${id}_${page + 1}`) .setCustomId(`records-next_${id}_${page + 1}`)
.setDisabled(page >= maxPage) .setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary); .setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>() const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage); .addComponents(prevPage, nextPage);
return { content: `Records found for <@${id}>`, embeds, components: [row] }; return { content: `Records found for <@${id}>`, embeds, components: [row] };
} }
function getRecordColor(category: RecordCategory) { function getRecordColor(category: RecordCategory) {
switch (category) { switch (category) {
case 'positive': return 0x00ff00; case 'positive': return 0x00ff00;
case 'negative': return 0xff0000; case 'negative': return 0xff0000;
case 'neutral': return 0xaaaaaa; case 'neutral': return 0xaaaaaa;
} }
} }
function getRecordEmbed(record: RecordWithUserData): EmbedBuilder { function getRecordEmbed(record: RecordWithUserData): EmbedBuilder {
const isUpdated = record.updated_at != record.created_at; const isUpdated = record.updated_at != record.created_at;
const creator = isUpdated ? record.updater : record.creator; const creator = isUpdated ? record.updater : record.creator;
return new EmbedBuilder() return new EmbedBuilder()
.setColor(getRecordColor(record.category)) .setColor(getRecordColor(record.category))
.setTitle(`Record from ${record.creator.name} for ${record.user.name}`) .setTitle(`Record from ${record.creator.name} for ${record.user.name}`)
.setDescription(record.body.trim()) .setDescription(record.body.trim())
.setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`) .setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`)
.setAuthor({ .setAuthor({
name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`, name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`,
url: `${config.E621_BASE_URL}/users/${creator.id}` url: `${config.E621_BASE_URL}/users/${creator.id}`
}) })
.setTimestamp(new Date(record.updated_at)); .setTimestamp(new Date(record.updated_at));
} }
+70 -70
View File
@@ -1,71 +1,71 @@
import { Client, REST, Routes } from 'discord.js'; import { Client, REST, Routes } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js'; import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js';
import fs from 'fs'; import fs from 'fs';
import { Command } from '../types'; import { Command } from '../types';
import path from 'path'; import path from 'path';
const ROOT_DIR = path.resolve(__dirname, '..'); const ROOT_DIR = path.resolve(__dirname, '..');
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!); const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!);
export async function refreshCommands(client: Client) { export async function refreshCommands(client: Client) {
try { try {
const commands: RESTPostAPIApplicationCommandsJSONBody[] = []; const commands: RESTPostAPIApplicationCommandsJSONBody[] = [];
const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {}; const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {};
const commandFiles = { const commandFiles = {
commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), 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')), '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 [folderName, files] of Object.entries(commandFiles)) {
for (const file of files) { for (const file of files) {
const p = `${ROOT_DIR}/${folderName}/${file}`; const p = `${ROOT_DIR}/${folderName}/${file}`;
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const command: Command = require(p).default; const command: Command = require(p).default;
if (!command) { if (!command) {
console.warn(`File at ${p} has no export. Skipping registering.`); console.warn(`File at ${p} has no export. Skipping registering.`);
continue; continue;
} }
let data: RESTPostAPIApplicationCommandsJSONBody; let data: RESTPostAPIApplicationCommandsJSONBody;
if (typeof (command.data) == 'function') { if (typeof (command.data) == 'function') {
data = (await command.data(client)).toJSON(); data = (await command.data(client)).toJSON();
} else { } else {
data = command.data.toJSON(); data = command.data.toJSON();
} }
if (!command.guilds) { if (!command.guilds) {
commands.push(data); commands.push(data);
} else { } else {
for (const id of command.guilds) { for (const id of command.guilds) {
if (!guildCommands[id]) guildCommands[id] = []; if (!guildCommands[id]) guildCommands[id] = [];
guildCommands[id].push(data); guildCommands[id].push(data);
} }
} }
} }
} }
console.log('Started refreshing application (/) commands.'); console.log('Started refreshing application (/) commands.');
console.log('Global commands: ' + commands.length); console.log('Global commands: ' + commands.length);
await rest.put( await rest.put(
Routes.applicationCommands(config.DISCORD_CLIENT_ID!), Routes.applicationCommands(config.DISCORD_CLIENT_ID!),
{ body: commands } { body: commands }
); );
for (const guild in guildCommands) { for (const guild in guildCommands) {
console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')'); console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')');
await rest.put( await rest.put(
Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild), Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild),
{ body: guildCommands[guild] } { body: guildCommands[guild] }
); );
} }
console.log('Successfully reloaded application (/) commands.'); console.log('Successfully reloaded application (/) commands.');
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
console.error(JSON.stringify(error.requestBody, null, 4)); console.error(JSON.stringify(error.requestBody, null, 4));
} }
}; };
+5 -5
View File
@@ -1,6 +1,6 @@
export function humanizeCapitalization(str: string): string { export function humanizeCapitalization(str: string): string {
return str.toLowerCase() return str.toLowerCase()
.split(' ') .split(' ')
.map(s => s.charAt(0).toUpperCase() + s.substring(1)) .map(s => s.charAt(0).toUpperCase() + s.substring(1))
.join(' '); .join(' ');
} }
+380 -380
View File
@@ -1,381 +1,381 @@
import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js'; import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js';
import { config } from '../config'; import { config } from '../config';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import { Ticket, TicketPhrase, TicketUpdate } from '../types'; import { Ticket, TicketPhrase, TicketUpdate } from '../types';
import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils'; 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 { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex';
import { humanizeCapitalization } from './string-utils'; import { humanizeCapitalization } from './string-utils';
import { shouldAlert } from './ticket-utils'; import { shouldAlert } from './ticket-utils';
// TODO: Condense this and the message event handler regex array. // TODO: Condense this and the message event handler regex array.
const linkReplacers = [ const linkReplacers = [
{ {
regex: blipIDRegex, regex: blipIDRegex,
replacement: '/blips/{match}', replacement: '/blips/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: commentIDRegex, regex: commentIDRegex,
replacement: '/comments/{match}', replacement: '/comments/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: forumTopicIDRegex, regex: forumTopicIDRegex,
replacement: '/forum_topics/{match}', replacement: '/forum_topics/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: poolIDRegex, regex: poolIDRegex,
replacement: '/pools/{match}', replacement: '/pools/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: postIDRegex, regex: postIDRegex,
tester: async (postId: string, before: string, after: string) => { tester: async (postId: string, before: string, after: string) => {
const post = await getE621Post(postId); const post = await getE621Post(postId);
if (!post) return { allowed: true, before, after }; if (!post) return { allowed: true, before, after };
const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist; const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist;
return { allowed, before, after }; return { allowed, before, after };
}, },
replacement: '/posts/{match}', replacement: '/posts/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: recordIDRegex, regex: recordIDRegex,
replacement: '/user_feedbacks/{match}', replacement: '/user_feedbacks/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: searchLinkRegex, regex: searchLinkRegex,
replacement: '/posts?tags={match}', replacement: '/posts?tags={match}',
encodeURI: true encodeURI: true
}, },
{ {
regex: setIDRegex, regex: setIDRegex,
replacement: '/post_sets/{match}', replacement: '/post_sets/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: takedownIDRegex, regex: takedownIDRegex,
replacement: '/takedowns/{match}', replacement: '/takedowns/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: ticketIDRegex, regex: ticketIDRegex,
replacement: '/tickets/{match}', replacement: '/tickets/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: userIDRegex, regex: userIDRegex,
replacement: '/users/{match}', replacement: '/users/{match}',
encodeURI: false encodeURI: false
}, },
{ {
regex: wikiLinkRegex, regex: wikiLinkRegex,
replacement: '/wiki_pages/{match}', replacement: '/wiki_pages/{match}',
encodeURI: true encodeURI: true
} }
]; ];
const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi'); const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi');
const MAX_DESCRIPTION_LENGTH = 500; const MAX_DESCRIPTION_LENGTH = 500;
export async function ticketUpdateHandler(client: Client, update: string) { export async function ticketUpdateHandler(client: Client, update: string) {
const data: TicketUpdate = JSON.parse(update); const data: TicketUpdate = JSON.parse(update);
if (data.action == 'create') { if (data.action == 'create') {
postTicket(client, data); postTicket(client, data);
} else { } else {
updateTicket(client, data); updateTicket(client, data);
} }
} }
async function postTicket(client: Client, data: TicketUpdate) { async function postTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return; if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id); const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return; if (!channel || !channel.isSendable()) return;
const ticket = data.ticket; const ticket = data.ticket;
const embed = await createEmbedFromTicket(ticket); const embed = await createEmbedFromTicket(ticket);
const row = await getButtons(ticket); const row = await getButtons(ticket);
const message = await channel.send({ embeds: [embed], components: [row] }); const message = await channel.send({ embeds: [embed], components: [row] });
await Database.putTicket(ticket.id, message.id); await Database.putTicket(ticket.id, message.id);
sendTicketAlerts(ticket, channel); sendTicketAlerts(ticket, channel);
} }
async function updateTicket(client: Client, data: TicketUpdate) { async function updateTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return; if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id); const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return; if (!channel || !channel.isSendable()) return;
const messageId = await Database.getTicketMessageId(data.ticket.id); const messageId = await Database.getTicketMessageId(data.ticket.id);
if (!messageId) return postTicket(client, data); if (!messageId) return postTicket(client, data);
const message = await channel.messages.fetch(messageId); const message = await channel.messages.fetch(messageId);
const embed = await createEmbedFromTicket(data.ticket); const embed = await createEmbedFromTicket(data.ticket);
if (!message || message.author.id != config.DISCORD_CLIENT_ID) { if (!message || message.author.id != config.DISCORD_CLIENT_ID) {
const newMessage = await channel.send({ embeds: [embed] }); const newMessage = await channel.send({ embeds: [embed] });
await Database.removeTicket(data.ticket.id); await Database.removeTicket(data.ticket.id);
await Database.putTicket(data.ticket.id, newMessage.id); await Database.putTicket(data.ticket.id, newMessage.id);
} else { } else {
await message.edit({ embeds: [embed] }); await message.edit({ embeds: [embed] });
} }
} }
function getTitle(ticket: Ticket): string { function getTitle(ticket: Ticket): string {
if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`; if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`;
switch (ticket.category) { switch (ticket.category) {
case 'blip': case 'blip':
return `Blip by ${ticket.target}`; return `Blip by ${ticket.target}`;
case 'comment': case 'comment':
return `Comment by ${ticket.target}`; return `Comment by ${ticket.target}`;
case 'dmail': case 'dmail':
return `DMail sent by ${ticket.target}`; return `DMail sent by ${ticket.target}`;
case 'forum': case 'forum':
return `Forum post by ${ticket.target}`; return `Forum post by ${ticket.target}`;
case 'pool': case 'pool':
return `Pool ${ticket.target}`; return `Pool ${ticket.target}`;
case 'post': case 'post':
return `Post uploaded by ${ticket.target}`; return `Post uploaded by ${ticket.target}`;
case 'set': case 'set':
return `Wow, a rare set report! ${ticket.target}`; return `Wow, a rare set report! ${ticket.target}`;
case 'user': case 'user':
return `User ${ticket.target}`; return `User ${ticket.target}`;
case 'wiki': case 'wiki':
return `Wiki page ${ticket.target}`; return `Wiki page ${ticket.target}`;
default: default:
return 'Uknown ticket category'; return 'Uknown ticket category';
} }
} }
function getURL(ticket: Ticket): string { function getURL(ticket: Ticket): string {
return `${config.E621_BASE_URL}/tickets/${ticket.id}`; return `${config.E621_BASE_URL}/tickets/${ticket.id}`;
} }
async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise<string> { async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise<string> {
const length = input.length; const length = input.length;
const replacedIndexes: { start: number, end: number }[] = []; const replacedIndexes: { start: number, end: number }[] = [];
const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = []; const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = [];
for (const replacer of linkReplacers) { for (const replacer of linkReplacers) {
input = input.replaceAll(replacer.regex, (match, group1) => { input = input.replaceAll(replacer.regex, (match, group1) => {
const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : 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)); if (replacer.tester) checks.push(replacer.tester(group1, match, replaced));
const start = input.indexOf(match); const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length }); replacedIndexes.push({ start, end: start + replaced.length });
return replaced; return replaced;
}); });
} }
input = input.replaceAll(urlRegex, (match, group1, group2) => { input = input.replaceAll(urlRegex, (match, group1, group2) => {
const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`; const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`;
const start = input.indexOf(match); const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length }); replacedIndexes.push({ start, end: start + replaced.length });
return replaced; return replaced;
}); });
const values = await Promise.all(checks); const values = await Promise.all(checks);
for (const check of values) { for (const check of values) {
if (!check.allowed) { if (!check.allowed) {
input = input.replace(check.after, check.before); input = input.replace(check.after, check.before);
} }
} }
if (length > limit) { if (length > limit) {
for (const replacedIndex of replacedIndexes) { for (const replacedIndex of replacedIndexes) {
if (replacedIndex.start < limit && replacedIndex.end >= limit) { if (replacedIndex.start < limit && replacedIndex.end >= limit) {
return input.substring(0, replacedIndex.end) + '...'; return input.substring(0, replacedIndex.end) + '...';
} }
} }
return input.substring(0, limit) + '...'; return input.substring(0, limit) + '...';
} }
return input; return input;
} }
async function getDescription(ticket: Ticket): Promise<string> { async function getDescription(ticket: Ticket): Promise<string> {
return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH); return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH);
} }
function getAuthor(ticket: Ticket): EmbedAuthorOptions { function getAuthor(ticket: Ticket): EmbedAuthorOptions {
return { return {
url: `${config.E621_BASE_URL}/users/${ticket.user_id}`, url: `${config.E621_BASE_URL}/users/${ticket.user_id}`,
name: ticket.user name: ticket.user
}; };
} }
function getColor(ticket: Ticket): number { function getColor(ticket: Ticket): number {
if (!ticket.claimant) { if (!ticket.claimant) {
return 0xff0000; return 0xff0000;
} else { } else {
return 0x00ffff; return 0x00ffff;
} }
} }
function getFields(ticket: Ticket): APIEmbedField[] { function getFields(ticket: Ticket): APIEmbedField[] {
return [ return [
{ {
name: 'Type', name: 'Type',
value: ticket.category, value: ticket.category,
inline: true inline: true
}, },
{ {
name: 'Status', name: 'Status',
value: ticket.status, value: ticket.status,
inline: true inline: true
}, },
{ {
name: 'Claimed By', name: 'Claimed By',
value: !ticket.claimant ? '<Unclaimed>' : ticket.claimant, value: !ticket.claimant ? '<Unclaimed>' : ticket.claimant,
inline: true inline: true
} }
]; ];
} }
async function createEmbedFromTicket(ticket: Ticket): Promise<EmbedBuilder> { async function createEmbedFromTicket(ticket: Ticket): Promise<EmbedBuilder> {
return new EmbedBuilder() return new EmbedBuilder()
.setTitle(getTitle(ticket)) .setTitle(getTitle(ticket))
.setURL(await getURL(ticket)) .setURL(await getURL(ticket))
.setDescription(await getDescription(ticket)) .setDescription(await getDescription(ticket))
.setAuthor(getAuthor(ticket)) .setAuthor(getAuthor(ticket))
.setColor(getColor(ticket)) .setColor(getColor(ticket))
.setFields(...getFields(ticket)) .setFields(...getFields(ticket))
.setFooter({ text: `Ticket #${ticket.id}` }); .setFooter({ text: `Ticket #${ticket.id}` });
} }
async function getButtons(ticket: Ticket): Promise<ActionRowBuilder<ButtonBuilder>> { async function getButtons(ticket: Ticket): Promise<ActionRowBuilder<ButtonBuilder>> {
const row = new ActionRowBuilder<ButtonBuilder>(); const row = new ActionRowBuilder<ButtonBuilder>();
const primaryButton = new ButtonBuilder() const primaryButton = new ButtonBuilder()
.setStyle(ButtonStyle.Link); .setStyle(ButtonStyle.Link);
let skipPrimary = false; let skipPrimary = false;
if (ticket.category == 'blip') { if (ticket.category == 'blip') {
primaryButton primaryButton
.setLabel('Open Blip') .setLabel('Open Blip')
.setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`);
} else if (ticket.category == 'comment') { } else if (ticket.category == 'comment') {
primaryButton primaryButton
.setLabel('Open Comment') .setLabel('Open Comment')
.setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`);
} else if (ticket.category == 'dmail') { } else if (ticket.category == 'dmail') {
primaryButton primaryButton
.setLabel('Open DMail') .setLabel('Open DMail')
.setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`);
} else if (ticket.category == 'forum') { } else if (ticket.category == 'forum') {
primaryButton primaryButton
.setLabel('Open Forum Post') .setLabel('Open Forum Post')
.setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`);
} else if (ticket.category == 'pool') { } else if (ticket.category == 'pool') {
primaryButton primaryButton
.setLabel('Open Pool') .setLabel('Open Pool')
.setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`);
} else if (ticket.category == 'post') { } else if (ticket.category == 'post') {
const post = await getE621Post(ticket.target_id); const post = await getE621Post(ticket.target_id);
if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true; if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true;
else { else {
primaryButton primaryButton
.setLabel('Open Post') .setLabel('Open Post')
.setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`);
} }
} else if (ticket.category == 'set') { } else if (ticket.category == 'set') {
primaryButton primaryButton
.setLabel('Open Set') .setLabel('Open Set')
.setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`);
} else if (ticket.category == 'user') { } else if (ticket.category == 'user') {
primaryButton primaryButton
.setLabel('Open User') .setLabel('Open User')
.setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`);
} else if (ticket.category == 'wiki') { } else if (ticket.category == 'wiki') {
primaryButton primaryButton
.setLabel('Open Wiki') .setLabel('Open Wiki')
.setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`); .setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`);
} else { } else {
console.error('Unknown ticket type:'); console.error('Unknown ticket type:');
console.error(JSON.stringify(ticket, null, 2)); console.error(JSON.stringify(ticket, null, 2));
skipPrimary = true; skipPrimary = true;
} }
if (!skipPrimary) row.addComponents(primaryButton); if (!skipPrimary) row.addComponents(primaryButton);
if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') { if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') {
const button = new ButtonBuilder() const button = new ButtonBuilder()
.setLabel('Open Target User') .setLabel('Open Target User')
.setStyle(ButtonStyle.Link) .setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`); .setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`);
row.addComponents(button); row.addComponents(button);
} else if (ticket.category == 'post') { } else if (ticket.category == 'post') {
const user = await getE621User(ticket.target!); const user = await getE621User(ticket.target!);
if (user) { if (user) {
const button = new ButtonBuilder() const button = new ButtonBuilder()
.setLabel('Open Target User') .setLabel('Open Target User')
.setStyle(ButtonStyle.Link) .setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${user.id}`); .setURL(`${config.E621_BASE_URL}/users/${user.id}`);
row.addComponents(button); row.addComponents(button);
} }
} }
return row; return row;
} }
async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) { async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.admin_role_id) return; if (!guildSettings || !guildSettings.admin_role_id) return;
const usersToMention: string[] = []; const usersToMention: string[] = [];
const rolesToMention: string[] = []; const rolesToMention: string[] = [];
let content = ''; let content = '';
await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => { await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => {
const { alert, match } = shouldAlert(ticketPhrase, ticket); const { alert, match } = shouldAlert(ticketPhrase, ticket);
if (alert) { if (alert) {
const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`; const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`;
if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) { if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) {
rolesToMention.push(guildSettings.admin_role_id!); rolesToMention.push(guildSettings.admin_role_id!);
} else if (!usersToMention.includes(ticketPhrase.user_id)) { } else if (!usersToMention.includes(ticketPhrase.user_id)) {
usersToMention.push(ticketPhrase.user_id); usersToMention.push(ticketPhrase.user_id);
} }
content += `${mention}: ${match}\n`; content += `${mention}: ${match}\n`;
} }
}); });
if (content.length == 0) return; if (content.length == 0) return;
await channel.send({ await channel.send({
content, content,
allowedMentions: { allowedMentions: {
users: usersToMention, users: usersToMention,
roles: rolesToMention roles: rolesToMention
} }
}); });
} }
+37 -37
View File
@@ -1,38 +1,38 @@
import { Ticket, TicketPhrase } from '../types'; import { Ticket, TicketPhrase } from '../types';
function friendlyPhrase(phrase: string): string { function friendlyPhrase(phrase: string): string {
switch (phrase) { switch (phrase) {
case 'underage porn': case 'underage porn':
case 'child porn': case 'child porn':
case 'cp': case 'cp':
return 'Code Red'; return 'Code Red';
default: default:
return phrase; return phrase;
} }
} }
export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } { export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } {
if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) { if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) {
if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) { if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) {
return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) }; return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) };
} else { } else {
return { alert: false }; return { alert: false };
} }
} else { } else {
try { try {
const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i'); const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i');
const regexMatch = regex.exec(ticket.reason); const regexMatch = regex.exec(ticket.reason);
if (regexMatch) { if (regexMatch) {
return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` }; return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` };
} else { } else {
return { alert: false }; return { alert: false };
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return { alert: false }; return { alert: false };
} }
} }
} }
+2 -2
View File
@@ -1,3 +1,3 @@
export function wait(ms) { export function wait(ms) {
return new Promise(r => setTimeout(r, ms)); return new Promise(r => setTimeout(r, ms));
} }
+17 -17
View File
@@ -1,18 +1,18 @@
import { CommandInteraction, MessageFlags } from 'discord.js'; import { CommandInteraction, MessageFlags } from 'discord.js';
import { getE621Alts } from './alt-utils'; import { getE621Alts } from './alt-utils';
import { resolveUser } from './discord-user-utils'; import { resolveUser } from './discord-user-utils';
export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) { export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) {
if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
else await interaction.deferReply(); else await interaction.deferReply();
if (!interaction.guild) return interaction.editReply('This command must be used in a server'); if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = await resolveUser(interaction.client, valueToUse, interaction.guild); const user = await resolveUser(interaction.client, valueToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.'); if (!user) return interaction.editReply('User not found.');
const content = await getE621Alts(user.id, interaction.guild!); const content = await getE621Alts(user.id, interaction.guild!);
interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`); interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`);
} }
+302 -302
View File
@@ -1,303 +1,303 @@
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import { config } from '../config'; import { config } from '../config';
import { Database } from '../shared/Database'; import { Database } from '../shared/Database';
import crypto from 'crypto'; import crypto from 'crypto';
import session from 'express-session'; import session from 'express-session';
import MemoryStore from 'memorystore'; import MemoryStore from 'memorystore';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { Client } from 'discord.js'; import { Client } from 'discord.js';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { fixPings, removeIssueLinks } from '../utils/github-user-utils'; import { fixPings, removeIssueLinks } from '../utils/github-user-utils';
import { logDebug } from '../utils/debug-utils'; import { logDebug } from '../utils/debug-utils';
import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils'; import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils';
declare module 'express-session' { declare module 'express-session' {
interface SessionData { interface SessionData {
username: string; username: string;
userId: string; userId: string;
oauthState: string; oauthState: string;
} }
} }
const GITHUB_REPO_ID = 169334303; const GITHUB_REPO_ID = 169334303;
const DEV_BASE_URL = `http://localhost:${config.PORT}`; const DEV_BASE_URL = `http://localhost:${config.PORT}`;
const PROD_BASE_URL = 'https://discord.e621.net'; const PROD_BASE_URL = 'https://discord.e621.net';
const OAUTH_SCOPES = ['identify', 'guilds.join']; const OAUTH_SCOPES = ['identify', 'guilds.join'];
const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' }); const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' });
const oauth = new DiscordOAuth2({ const oauth = new DiscordOAuth2({
clientId: config.DISCORD_CLIENT_ID!, clientId: config.DISCORD_CLIENT_ID!,
clientSecret: config.DISCORD_CLIENT_SECRET!, clientSecret: config.DISCORD_CLIENT_SECRET!,
redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`, redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`,
clientToken: config.DISCORD_TOKEN!, clientToken: config.DISCORD_TOKEN!,
credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64') credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64')
}); });
const enum JoinResponse { const enum JoinResponse {
Success = 1, Success = 1,
Error = 2, Error = 2,
Banned = 3, Banned = 3,
Underage = 4 Underage = 4
}; };
async function joinGuild(code: string, userId: string, username: string): Promise<JoinResponse> { async function joinGuild(code: string, userId: string, username: string): Promise<JoinResponse> {
let tokenResponse; let tokenResponse;
try { try {
if (Number.isNaN(userId)) return JoinResponse.Error; if (Number.isNaN(userId)) return JoinResponse.Error;
if (!username) return JoinResponse.Error; if (!username) return JoinResponse.Error;
const id = Number(userId); const id = Number(userId);
tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES); tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES);
const user = await oauth.getUser(tokenResponse.access_token); const user = await oauth.getUser(tokenResponse.access_token);
if (!user.id || !user.username) { if (!user.id || !user.username) {
console.error(`Error joining user (${userId}) to discord. User object missing id or username.`); console.error(`Error joining user (${userId}) to discord. User object missing id or username.`);
console.error(user); console.error(user);
return JoinResponse.Error; return JoinResponse.Error;
} }
await Database.putUser(id, user); await Database.putUser(id, user);
const alts = await comprehensiveAltLookupFromE621(id, null); const alts = await comprehensiveAltLookupFromE621(id, null);
if (await checkAltsForFullBans([alts])) return JoinResponse.Banned; if (await checkAltsForFullBans([alts])) return JoinResponse.Banned;
const response = await oauth.addMember({ const response = await oauth.addMember({
accessToken: tokenResponse.access_token, accessToken: tokenResponse.access_token,
guildId: config.DISCORD_GUILD_ID!, guildId: config.DISCORD_GUILD_ID!,
userId: user.id, userId: user.id,
nickname: username nickname: username
}); });
if (config.DEBUG) console.log(response); if (config.DEBUG) console.log(response);
if (!response) return JoinResponse.Error; if (!response) return JoinResponse.Error;
} catch (e: any) { } catch (e: any) {
if (e.code == 40007) return JoinResponse.Banned; if (e.code == 40007) return JoinResponse.Banned;
else if (e.code == 20024) return JoinResponse.Underage; else if (e.code == 20024) return JoinResponse.Underage;
console.error(`Error joining user (${userId}) to discord:`); console.error(`Error joining user (${userId}) to discord:`);
console.error(e); console.error(e);
return JoinResponse.Error; return JoinResponse.Error;
} finally { } finally {
if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token); if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token);
} }
return JoinResponse.Success; return JoinResponse.Success;
} }
async function handleInitial(req: Request, res: Response): Promise<any> { async function handleInitial(req: Request, res: Response): Promise<any> {
const { username, user_id, time, hash } = req.query; const { username, user_id, time, hash } = req.query;
if (!username || !user_id || !time || !hash) { if (!username || !user_id || !time || !hash) {
return sendBadRequest(res, 'Missing parameters'); return sendBadRequest(res, 'Missing parameters');
} }
if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) { if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) {
return render(res, 403, 'You took too long to authorize the request. Please try again.'); 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 authString = `${username} ${user_id} ${time} ${config.LINK_SECRET}`;
const digest = crypto.createHash('sha256').update(authString).digest('hex'); const digest = crypto.createHash('sha256').update(authString).digest('hex');
if (hash !== digest) { if (hash !== digest) {
console.error(`Bad auth: ${hash} ${digest}`); console.error(`Bad auth: ${hash} ${digest}`);
return sendForbidden(res, 'Bad auth'); return sendForbidden(res, 'Bad auth');
} }
const oauthState = crypto.randomBytes(16).toString('hex'); const oauthState = crypto.randomBytes(16).toString('hex');
const oauthUrl = await oauth.generateOauth2Url({ const oauthUrl = await oauth.generateOauth2Url({
state: oauthState, state: oauthState,
scope: OAUTH_SCOPES, scope: OAUTH_SCOPES,
type: 'code' type: 'code'
}); });
req.session.username = username as string; req.session.username = username as string;
req.session.userId = user_id as string; req.session.userId = user_id as string;
req.session.oauthState = oauthState; req.session.oauthState = oauthState;
req.session.save((e) => { req.session.save((e) => {
if (e) { if (e) {
console.error('Error saving session:'); console.error('Error saving session:');
console.error(e); console.error(e);
return sendInteralServerError(res); return sendInteralServerError(res);
} }
res.redirect(oauthUrl); res.redirect(oauthUrl);
}); });
} }
async function handleCallback(req: Request, res: Response): Promise<any> { async function handleCallback(req: Request, res: Response): Promise<any> {
if (!req.session.userId || !req.session.username || !req.session.oauthState) { if (!req.session.userId || !req.session.username || !req.session.oauthState) {
return sendForbidden(res, 'Session details missing'); return sendForbidden(res, 'Session details missing');
} }
const state = req.query.state as string; const state = req.query.state as string;
if (state != req.session.oauthState) { if (state != req.session.oauthState) {
console.error('OAuth state mismatch on discord joining'); console.error('OAuth state mismatch on discord joining');
return sendForbidden(res, 'OAuth state mismatch'); return sendForbidden(res, 'OAuth state mismatch');
} }
const code = req.query.code as string; const code = req.query.code as string;
const userId = req.session.userId; const userId = req.session.userId;
const username = req.session.username; const username = req.session.username;
req.session.destroy((e) => { req.session.destroy((e) => {
if (e) console.error(e); if (e) console.error(e);
}); });
try { try {
const response = await joinGuild(code, userId, username); const response = await joinGuild(code, userId, username);
if (response == JoinResponse.Error) { if (response == JoinResponse.Error) {
console.error(`Error joining user: ${username} (${userId})`); console.error(`Error joining user: ${username} (${userId})`);
return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.'); return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.');
} else if (response == JoinResponse.Banned) { } else if (response == JoinResponse.Banned) {
return sendForbidden(res, 'User is banned.'); return sendForbidden(res, 'User is banned.');
} else if (response == JoinResponse.Underage) { } else if (response == JoinResponse.Underage) {
return sendForbidden(res, 'Discord account flagged as underage by discord.'); return sendForbidden(res, 'Discord account flagged as underage by discord.');
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return sendInteralServerError(res); return sendInteralServerError(res);
} }
render(res, 200, 'Success', `You have been added to the server. <a href="https://discord.com/channels/${config.DISCORD_GUILD_ID}">See you there.</a>`); render(res, 200, 'Success', `You have been added to the server. <a href="https://discord.com/channels/${config.DISCORD_GUILD_ID}">See you there.</a>`);
} }
function sendInteralServerError(res: Response, message: string = '') { function sendInteralServerError(res: Response, message: string = '') {
render(res, 500, 'Internal Server Error', message); render(res, 500, 'Internal Server Error', message);
} }
function sendForbidden(res: Response, message: string = '') { function sendForbidden(res: Response, message: string = '') {
render(res, 403, 'Forbidden', message); render(res, 403, 'Forbidden', message);
} }
function sendBadRequest(res: Response, message: string = '') { function sendBadRequest(res: Response, message: string = '') {
render(res, 400, 'Bad Request', message); render(res, 400, 'Bad Request', message);
} }
function render(res: Response, code: number, title: string = '', message: string = '') { 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)); 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<any> { async function handleGithubRelease(client: Client, req: Request, res: Response): Promise<any> {
logDebug('Received github release webhook'); logDebug('Received github release webhook');
const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1]; 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'); const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex');
if (signature !== computedSignature) { if (signature !== computedSignature) {
console.error('Github release webhook signature mismatch'); console.error('Github release webhook signature mismatch');
return res.sendStatus(401); return res.sendStatus(401);
} }
res.sendStatus(200); res.sendStatus(200);
const data = JSON.parse(req.body); const data = JSON.parse(req.body);
logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`); logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`);
if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return; if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return;
const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!settings || !settings.github_release_channel) return; if (!settings || !settings.github_release_channel) return;
const channel = await client.channels.fetch(settings.github_release_channel); const channel = await client.channels.fetch(settings.github_release_channel);
if (!channel || !channel.isSendable()) { if (!channel || !channel.isSendable()) {
console.error(`Github release channel ${channel ? 'sendable' : 'found'}`); console.error(`Github release channel ${channel ? 'sendable' : 'found'}`);
return; return;
} }
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const date = new Date(); 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))}`; 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'); logDebug('Sending github release message');
const MAX_MESSAGE_LENGTH = 2000; const MAX_MESSAGE_LENGTH = 2000;
const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.'; const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.';
if (message.length > MAX_MESSAGE_LENGTH) { if (message.length > MAX_MESSAGE_LENGTH) {
const splitMessage = message.split('\n'); const splitMessage = message.split('\n');
message = ''; message = '';
for (const part of splitMessage) { for (const part of splitMessage) {
if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break; if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break;
message += `${part}\n`; message += `${part}\n`;
} }
message += ADDITIONAL_PART; message += ADDITIONAL_PART;
} }
const sentMessage = await channel.send(message); const sentMessage = await channel.send(message);
await sentMessage.startThread({ name: data.release.tag_name }); await sentMessage.startThread({ name: data.release.tag_name });
logDebug('Github webhook processed'); logDebug('Github webhook processed');
} }
async function checkAltsForFullBans(altData: AltData[]): Promise<boolean> { async function checkAltsForFullBans(altData: AltData[]): Promise<boolean> {
for (const data of altData) { for (const data of altData) {
if (data.type == 'discord') { if (data.type == 'discord') {
try { try {
const banData = await Database.getBan(data.thisId as string); const banData = await Database.getBan(data.thisId as string);
if (banData?.full_ban) return true; if (banData?.full_ban) return true;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
} }
if (await checkAltsForFullBans(data.alts)) return true; if (await checkAltsForFullBans(data.alts)) return true;
} }
return false; return false;
} }
export function initializeWebserver(client: Client) { export function initializeWebserver(client: Client) {
const app = express(); const app = express();
const Store = MemoryStore(session); const Store = MemoryStore(session);
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(session({ app.use(session({
secret: config.DISCORD_CLIENT_SECRET!, secret: config.DISCORD_CLIENT_SECRET!,
cookie: { cookie: {
secure: !config.DEV_MODE, secure: !config.DEV_MODE,
httpOnly: !config.DEV_MODE, httpOnly: !config.DEV_MODE,
sameSite: false, sameSite: false,
maxAge: 300000 maxAge: 300000
}, },
store: new Store({ store: new Store({
checkPeriod: 600000, checkPeriod: 600000,
}), }),
resave: false, resave: false,
saveUninitialized: false saveUninitialized: false
})); }));
app.get('/', handleInitial); app.get('/', handleInitial);
app.get('/callback', handleCallback); app.get('/callback', handleCallback);
app.use(bodyParser.raw({ type: 'application/json' })); app.use(bodyParser.raw({ type: 'application/json' }));
app.post('/release', handleGithubRelease.bind(null, client)); app.post('/release', handleGithubRelease.bind(null, client));
app.listen(config.PORT, (error) => { app.listen(config.PORT, (error) => {
if (error) { if (error) {
throw error; throw error;
} }
console.log(`Listening on port ${config.PORT}`); console.log(`Listening on port ${config.PORT}`);
}); });
} }
+29 -29
View File
@@ -1,29 +1,29 @@
<!DOCTYPE HTML> <!DOCTYPE HTML>
<html lang="en"> <html lang="en">
<head> <head>
<title>{{ title }}</title> <title>{{ title }}</title>
<style> <style>
body { body {
background-color: #012e56; background-color: #012e56;
color: #fff; color: #fff;
font-family: Verdana, sans-serif; font-family: Verdana, sans-serif;
} }
a { a {
color: #b4c7d9; color: #b4c7d9;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
color: #e9f2fa; color: #e9f2fa;
} }
</style> </style>
</head> </head>
<body> <body>
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
<p>{{ message }}</p> <p>{{ message }}</p>
</body> </body>
</html> </html>
+21 -21
View File
@@ -1,22 +1,22 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"module": "CommonJS", "module": "CommonJS",
"forceConsistentCasingInFileNames": false, "forceConsistentCasingInFileNames": false,
"inlineSourceMap": true, "inlineSourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"noImplicitAny": false, "noImplicitAny": false,
"noUnusedLocals": true, "noUnusedLocals": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"lib": [ "lib": [
"ES2021.String" "ES2021.String"
] ]
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }