Fix line endings

This commit is contained in:
Tarrgon
2026-05-29 08:43:48 -04:00
parent 66d4bd4926
commit 6ad3ee5f23
98 changed files with 6528 additions and 6528 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
/.git
/data
/dist
/.git
/data
/dist
/node_modules
+3 -3
View File
@@ -1,4 +1,4 @@
node_modules
dist
data/*
node_modules
dist
data/*
.env
+21 -21
View File
@@ -1,21 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ----------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "./dist/index.js"]
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ----------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "./dist/index.js"]
+50 -50
View File
@@ -1,51 +1,51 @@
## Bot Setup
### Prerequisites
* Latest version of Docker ([download](https://docs.docker.com/get-docker))
* Latest version of Docker Compose ([download](https://docs.docker.com/compose/install))
* Git ([download](https://git-scm.com/downloads))
* An [e621ng](https://github.com/e621ng/e621ng) instance ready to start
* A [discord bot application](https://discord.com/developers/applications)
If you are on Windows Docker Compose is already included, you do not need to install it yourself.
If you are on Linux/MacOS you can probably use your package manager.
### Discord application setup
1. Create a new application
2. Under the "Installation" sidebar
- For "Installation Contexts" select "Guild Install"
- Set the "Install Link" dropdown to "None"
3. Under the "OAuth2" sidebar
- Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening
4. Under the "Bot" sidebar
- It is recommended to disable "Public Bot"
- Enable "Server Members Intent"
- Enable "Message Content Intent"
5. Join the bot to the desired server using the following invite link, but replace the client id with your application's id: https://discord.com/oauth2/authorize?client_id=YOUR_APPLICATION_ID_HERE&scope=bot&permissions=395271335063
### Configuration
1. Copy the `.env.sample` file and rename it to `.env`
2. Enter your bot's token (found on the `Bot` page of the application setup)
3. Enter your application's client secret (found on the `OAuth2` page of the application setup)
4. Enter your bot's client id (application id, found on the `General Information` page of the application setup)
5. Enter your discord guild (server) id
6. Enter your discord joiner link secret (found in e621ng's `docker-compose.yml` file, or other environment file, it's passed as `DANBOORU_DISCORD_SECRET`. It defaults to `super_secret_for_url_discord`)
7. e621/e926 base url can be the same in development environments, but should be your e621ng's domain. Do not add trailing slashes
8. Enter the redis url of your e621ng instance. You may need to expose the port from docker manually in development enviornments. This can be done by adding a `ports` mapping to the `redis` service in e621ng's `docker-compose.yml` file. You should map `6379:6379`
9. Enter your desired port number. It is recommended to leave this at `8000`, if you select anything different you will need to map the correct port in `docker-compose.yml`
### Installing dependencies
Run `npm i` to install all node dependencies. This is required to start the bot.
### Starting the bot
e621ng must be up for the bot to start properly and open the connection to the redis database.
#### In development
You can use `npm run dev` to run the typescript without compiling. This will watch for changes and restart when they happen.
If desired, you can also build the typescript using `npm run build` and then subsequently run it using `npm run start`
#### In docker
1. Run `docker compose build` to build the image
## Bot Setup
### Prerequisites
* Latest version of Docker ([download](https://docs.docker.com/get-docker))
* Latest version of Docker Compose ([download](https://docs.docker.com/compose/install))
* Git ([download](https://git-scm.com/downloads))
* An [e621ng](https://github.com/e621ng/e621ng) instance ready to start
* A [discord bot application](https://discord.com/developers/applications)
If you are on Windows Docker Compose is already included, you do not need to install it yourself.
If you are on Linux/MacOS you can probably use your package manager.
### Discord application setup
1. Create a new application
2. Under the "Installation" sidebar
- For "Installation Contexts" select "Guild Install"
- Set the "Install Link" dropdown to "None"
3. Under the "OAuth2" sidebar
- Add a redirect to `http://localhost:8000/callback`, or where ever your discord bot joiner will be listening
4. Under the "Bot" sidebar
- It is recommended to disable "Public Bot"
- Enable "Server Members Intent"
- Enable "Message Content Intent"
5. Join the bot to the desired server using the following invite link, but replace the client id with your application's id: https://discord.com/oauth2/authorize?client_id=YOUR_APPLICATION_ID_HERE&scope=bot&permissions=395271335063
### Configuration
1. Copy the `.env.sample` file and rename it to `.env`
2. Enter your bot's token (found on the `Bot` page of the application setup)
3. Enter your application's client secret (found on the `OAuth2` page of the application setup)
4. Enter your bot's client id (application id, found on the `General Information` page of the application setup)
5. Enter your discord guild (server) id
6. Enter your discord joiner link secret (found in e621ng's `docker-compose.yml` file, or other environment file, it's passed as `DANBOORU_DISCORD_SECRET`. It defaults to `super_secret_for_url_discord`)
7. e621/e926 base url can be the same in development environments, but should be your e621ng's domain. Do not add trailing slashes
8. Enter the redis url of your e621ng instance. You may need to expose the port from docker manually in development enviornments. This can be done by adding a `ports` mapping to the `redis` service in e621ng's `docker-compose.yml` file. You should map `6379:6379`
9. Enter your desired port number. It is recommended to leave this at `8000`, if you select anything different you will need to map the correct port in `docker-compose.yml`
### Installing dependencies
Run `npm i` to install all node dependencies. This is required to start the bot.
### Starting the bot
e621ng must be up for the bot to start properly and open the connection to the redis database.
#### In development
You can use `npm run dev` to run the typescript without compiling. This will watch for changes and restart when they happen.
If desired, you can also build the typescript using `npm run build` and then subsequently run it using `npm run start`
#### In docker
1. Run `docker compose build` to build the image
2. Run `docker compose up` to start the bot. Changes to any files, including `.env` will require a rebuild
+9 -9
View File
@@ -1,9 +1,9 @@
services:
hexerade:
restart: unless-stopped
build: .
env_file: '.env'
ports:
- "8000:8000"
volumes:
- ./data:/app/data
services:
hexerade:
restart: unless-stopped
build: .
env_file: '.env'
ports:
- "8000:8000"
volumes:
- ./data:/app/data
+67 -67
View File
@@ -1,68 +1,68 @@
const eslint = require('@eslint/js');
const globals = require('globals');
const tseslint = require('typescript-eslint');
const stylistic = require('@stylistic/eslint-plugin');
const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js'];
module.exports = tseslint.config(
{
ignores,
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended
]
},
{
plugins: {
'@stylistic': stylistic
},
ignores,
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
rules: {
'no-empty': 'off',
'prefer-const': ['error'],
'no-async-promise-executor': 'off',
'@typescript-eslint/no-var-requires': 'off',
'quotes': ['error', 'single', { 'avoidEscape': true }],
'semi': ['error'],
'@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }],
'@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }],
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/block-spacing': ['error'],
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', {
'arrays': 'only-multiline',
'objects': 'only-multiline'
}],
'@stylistic/comma-spacing': ['error'],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/keyword-spacing': ['error'],
'@stylistic/key-spacing': ['error'],
'@stylistic/no-trailing-spaces': ['error'],
'@stylistic/no-whitespace-before-property': ['error'],
'@stylistic/object-curly-newline': ['error', {
'multiline': true,
'consistent': true
}],
'@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/space-infix-ops': ['error']
}
},
{
ignores,
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-namespace': 'off',
},
}
const eslint = require('@eslint/js');
const globals = require('globals');
const tseslint = require('typescript-eslint');
const stylistic = require('@stylistic/eslint-plugin');
const ignores = ['dist/**/*', 'node_modules/**/*', 'eslint.config.js'];
module.exports = tseslint.config(
{
ignores,
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended
]
},
{
plugins: {
'@stylistic': stylistic
},
ignores,
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
rules: {
'no-empty': 'off',
'prefer-const': ['error'],
'no-async-promise-executor': 'off',
'@typescript-eslint/no-var-requires': 'off',
'quotes': ['error', 'single', { 'avoidEscape': true }],
'semi': ['error'],
'@stylistic/indent': ['error', 2, { 'SwitchCase': 1 }],
'@stylistic/arrow-parens': ['error', 'as-needed', { 'requireForBlockBody': true }],
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/block-spacing': ['error'],
'@stylistic/brace-style': ['error', '1tbs', { 'allowSingleLine': true }],
'@stylistic/comma-dangle': ['error', {
'arrays': 'only-multiline',
'objects': 'only-multiline'
}],
'@stylistic/comma-spacing': ['error'],
'@stylistic/dot-location': ['error', 'property'],
'@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/keyword-spacing': ['error'],
'@stylistic/key-spacing': ['error'],
'@stylistic/no-trailing-spaces': ['error'],
'@stylistic/no-whitespace-before-property': ['error'],
'@stylistic/object-curly-newline': ['error', {
'multiline': true,
'consistent': true
}],
'@stylistic/operator-linebreak': ['error', 'before'],
'@stylistic/space-infix-ops': ['error']
}
},
{
ignores,
files: ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-namespace': 'off',
},
}
);
+41 -41
View File
@@ -1,42 +1,42 @@
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'claim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' });
if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' });
if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to claim tickets.' });
const guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' });
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const unclaimButton = new ButtonBuilder()
.setCustomId('unclaim-ticket')
.setLabel('Unclaim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, unclaimButton);
await interaction.message.edit({
content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`,
components: [row]
});
await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` });
}
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'claim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' });
if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' });
if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to claim tickets.' });
const guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to claim ticket.' });
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const unclaimButton = new ButtonBuilder()
.setCustomId('unclaim-ticket')
.setLabel('Unclaim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, unclaimButton);
await interaction.message.edit({
content: `${interaction.message.content}\n\nClaimed by: ${interaction.user}`,
components: [row]
});
await interaction.reply({ content: `Ticket claimed by ${interaction.user}.` });
}
};
+30 -30
View File
@@ -1,31 +1,31 @@
import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js';
export default {
name: 'close-mod-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const channel = await interaction.channel?.fetch();
const guild = await interaction.guild?.fetch();
const member = await guild?.members.fetch(interaction.user.id);
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread || !member)
return interaction.editReply({ content: 'Oops. Something went wrong. Please report this to a staff member.' });
if (!member.permissions.has(PermissionFlagsBits.KickMembers))
return interaction.editReply({ content: 'Only staff members may close mod tickets.' });
await interaction.message.edit({
content: interaction.message.content,
components: []
});
await channel.send('This ticket has been closed by staff.');
await interaction.editReply({ content: 'Ticket closed.' });
channel.edit({
archived: true,
locked: true
});
}
import { ButtonInteraction, Client, MessageFlags, ChannelType, PermissionFlagsBits } from 'discord.js';
export default {
name: 'close-mod-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const channel = await interaction.channel?.fetch();
const guild = await interaction.guild?.fetch();
const member = await guild?.members.fetch(interaction.user.id);
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread || !member)
return interaction.editReply({ content: 'Oops. Something went wrong. Please report this to a staff member.' });
if (!member.permissions.has(PermissionFlagsBits.KickMembers))
return interaction.editReply({ content: 'Only staff members may close mod tickets.' });
await interaction.message.edit({
content: interaction.message.content,
components: []
});
await channel.send('This ticket has been closed by staff.');
await interaction.editReply({ content: 'Ticket closed.' });
channel.edit({
archived: true,
locked: true
});
}
};
+27 -27
View File
@@ -1,28 +1,28 @@
import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'close-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong. Please report this to a staff member.' });
await interaction.message.edit({
content: interaction.message.content,
components: []
});
await Database.closePrivateHelpTicket(channel.id);
await channel.send(`This ticket has been closed by ${interaction.user}`);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' });
channel.edit({
archived: true,
locked: true
});
}
import { ButtonInteraction, Client, MessageFlags, ChannelType } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'close-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong. Please report this to a staff member.' });
await interaction.message.edit({
content: interaction.message.content,
components: []
});
await Database.closePrivateHelpTicket(channel.id);
await channel.send(`This ticket has been closed by ${interaction.user}`);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Ticket closed.' });
channel.edit({
archived: true,
locked: true
});
}
};
+22 -22
View File
@@ -1,23 +1,23 @@
import { ButtonInteraction, Client, MessageFlags } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'dev-watch',
handler: async function (client: Client, interaction: ButtonInteraction) {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!member) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' });
const settings = await Database.getGuildSettings(interaction.guild!.id);
if (!settings || !settings.devwatch_role_id) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' });
if (member.roles.cache.has(settings.devwatch_role_id)) {
await member.roles.remove(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' });
} else {
await member.roles.add(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' });
}
}
import { ButtonInteraction, Client, MessageFlags } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'dev-watch',
handler: async function (client: Client, interaction: ButtonInteraction) {
const member = await interaction.guild!.members.fetch(interaction.user.id);
if (!member) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' });
const settings = await Database.getGuildSettings(interaction.guild!.id);
if (!settings || !settings.devwatch_role_id) return await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'There was an error. Please try again later.' });
if (member.roles.cache.has(settings.devwatch_role_id)) {
await member.roles.remove(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Removed role.' });
} else {
await member.roles.add(settings.devwatch_role_id);
await interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Added role.' });
}
}
};
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client } from 'discord.js';
import { getNoteMessage } from '../utils';
export default {
name: 'note-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) + 1);
if (!message) return;
interaction.editReply(message);
}
import { ButtonInteraction, Client } from 'discord.js';
import { getNoteMessage } from '../utils';
export default {
name: 'note-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) + 1);
if (!message) return;
interaction.editReply(message);
}
};
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client} from 'discord.js';
import { getNoteMessage } from '../utils';
export default {
name: 'note-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) - 1);
if (!message) return;
interaction.editReply(message);
}
import { ButtonInteraction, Client} from 'discord.js';
import { getNoteMessage } from '../utils';
export default {
name: 'note-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getNoteMessage(userId, parseInt(page) - 1);
if (!message) return;
interaction.editReply(message);
}
};
+39 -39
View File
@@ -1,40 +1,40 @@
import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js';
import { createPrivateHelpTicketThread } from '../utils';
export default {
name: 'open-ticket-for-reported-message',
handler: async function (client: Client, interaction: ButtonInteraction) {
const message = await interaction.message.fetch();
const guild = await interaction.guild!.fetch();
const reportEmbed = message.embeds[0]!;
const regex = new RegExp(MessageMentions.UsersPattern);
const reportedMessageUrl = reportEmbed.fields[0].value;
const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id;
const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : '';
const reporter = await guild.members.fetch(reporterId) ?? await client.users.fetch(reporterId);
const thread = await createPrivateHelpTicketThread(client, guild, null, `Ticket creted by staff in response to message report: ${message.url}. Reported message: ${reportedMessageUrl}. ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`, `Ticket: Message Report From ${reporter.displayName}`, [reporterId, interaction.user.id]);
if (thread) {
await interaction.reply({
flags: [MessageFlags.Ephemeral],
content: `Ticket created: ${thread}`
});
const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket');
if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1);
reportEmbed.fields.push({
name: 'Private Help Ticket',
value: thread.url,
inline: false
});
await message.edit({ embeds: [reportEmbed], components: [] });
}
}
import { ButtonInteraction, Client, MessageFlags, MessageMentions } from 'discord.js';
import { createPrivateHelpTicketThread } from '../utils';
export default {
name: 'open-ticket-for-reported-message',
handler: async function (client: Client, interaction: ButtonInteraction) {
const message = await interaction.message.fetch();
const guild = await interaction.guild!.fetch();
const reportEmbed = message.embeds[0]!;
const regex = new RegExp(MessageMentions.UsersPattern);
const reportedMessageUrl = reportEmbed.fields[0].value;
const reporterId = regex.exec(reportEmbed.fields[2].value)!.groups!.id;
const additionalInfo = reportEmbed.fields[3]?.name == 'Additional Information' ? reportEmbed.fields[3].value : '';
const reporter = await guild.members.fetch(reporterId) ?? await client.users.fetch(reporterId);
const thread = await createPrivateHelpTicketThread(client, guild, null, `Ticket creted by staff in response to message report: ${message.url}. Reported message: ${reportedMessageUrl}. ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`, `Ticket: Message Report From ${reporter.displayName}`, [reporterId, interaction.user.id]);
if (thread) {
await interaction.reply({
flags: [MessageFlags.Ephemeral],
content: `Ticket created: ${thread}`
});
const requestIndex = reportEmbed.fields.findIndex(f => f.name == 'User Requested Private Ticket');
if (requestIndex != -1) reportEmbed.fields.splice(requestIndex, 1);
reportEmbed.fields.push({
name: 'Private Help Ticket',
value: thread.url,
inline: false
});
await message.edit({ embeds: [reportEmbed], components: [] });
}
}
};
+19 -19
View File
@@ -1,20 +1,20 @@
import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js';
import { canOpenPrivateHelpTicket, createTextInput } from '../utils';
export default {
name: 'private-help',
handler: async function (client: Client, interaction: ButtonInteraction) {
if (!(await canOpenPrivateHelpTicket(interaction.user.id)))
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You can only have one open ticket at a time that is less than a day old.' });
const modal = new ModalBuilder()
.setCustomId('open-ticket-modal')
.setTitle('Get in contact');
const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10);
modal.addLabelComponents(label);
await interaction.showModal(modal);
}
import { ButtonInteraction, Client, ModalBuilder, TextInputStyle, MessageFlags } from 'discord.js';
import { canOpenPrivateHelpTicket, createTextInput } from '../utils';
export default {
name: 'private-help',
handler: async function (client: Client, interaction: ButtonInteraction) {
if (!(await canOpenPrivateHelpTicket(interaction.user.id)))
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You can only have one open ticket at a time that is less than a day old.' });
const modal = new ModalBuilder()
.setCustomId('open-ticket-modal')
.setTitle('Get in contact');
const label = createTextInput('ticket-message', 'What is the reason for your ticket?', null, true, TextInputStyle.Paragraph, 1500, 10);
modal.addLabelComponents(label);
await interaction.showModal(modal);
}
};
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client } from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'records-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!);
if (!message) return;
interaction.editReply(message);
}
import { ButtonInteraction, Client } from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'records-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) + 1, interaction.guild!);
if (!message) return;
interaction.editReply(message);
}
};
+14 -14
View File
@@ -1,15 +1,15 @@
import { ButtonInteraction, Client} from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'records-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!);
if (!message) return;
interaction.editReply(message);
}
import { ButtonInteraction, Client} from 'discord.js';
import { getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'records-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getRecordMessageFromDiscordId(userId, parseInt(page) - 1, interaction.guild!);
if (!message) return;
interaction.editReply(message);
}
};
+43 -43
View File
@@ -1,44 +1,44 @@
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'unclaim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' });
if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' });
if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to unclaim this ticket.' });
if (!interaction.message.content.split('\n').at(-1)!.includes(`<@${interaction.user.id}>`)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You did not claim this ticket.' });
const guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' });
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket')
.setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await interaction.message.edit({
content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(),
components: [row]
});
await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] });
}
import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChannelType, Client, MessageFlags, PermissionFlagsBits } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'unclaim-ticket',
handler: async function (client: Client, interaction: ButtonInteraction) {
const channel = await interaction.channel?.fetch();
if (!channel || !channel.isThread() || !channel.isSendable() || channel.type != ChannelType.PrivateThread)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Oops. Something went wrong.' });
if (!interaction.memberPermissions) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'An error has occurred.' });
if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageMessages)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You do not have permission to unclaim this ticket.' });
if (!interaction.message.content.split('\n').at(-1)!.includes(`<@${interaction.user.id}>`)) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You did not claim this ticket.' });
const guild = await client.guilds.fetch(interaction.guildId!);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to unclaim ticket.' });
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket')
.setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await interaction.message.edit({
content: interaction.message.content.split('\n').slice(0, -1).join('\n').trim(),
components: [row]
});
await interaction.reply({ content: 'Ticket unclaimed.', flags: [MessageFlags.Ephemeral] });
}
};
+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 { Database } from '../shared/Database';
import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'ban',
data: new SlashCommandBuilder()
.setName('ban')
.setDescription('Bans a user.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to ban.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the ban')
.setRequired(false)
.setMaxLength(400)
)
.addNumberOption(option =>
option
.setName('hours')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('minutes')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('seconds')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('delete-message-days')
.setDescription('How far back to delete messages (in days, default: 0 days).')
.setRequired(false)
.setMinValue(0)
.setMaxValue(7)
)
.addBooleanOption(option =>
option
.setName('full-ban')
.setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
const reason = interaction.options.getString('reason') ?? '';
const hours = (interaction.options.getNumber('hours') ?? 0) * 3.6e+6;
const minutes = (interaction.options.getNumber('minutes') ?? 0) * 60000;
const seconds = (interaction.options.getNumber('seconds') ?? 0) * 1000;
const duration = hours + minutes + seconds;
const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400;
const fullBan = interaction.options.getBoolean('full-ban') ?? false;
let banMember: GuildMember | null = null;
try {
banMember = await interaction.guild.members.fetch(idToUse);
} catch (e) {
// Member not in server.
}
const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to ban this user.');
}
if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to ban this user.');
}
const expiresAt = new Date(Date.now() + duration);
await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan);
try {
await interaction.guild.bans.create(idToUse, {
reason: (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(),
deleteMessageSeconds: deleteMessageDays
});
} catch (e) {
console.error(e);
return await interaction.editReply("Error banning user (couldn't ban).");
}
if (fullBan) {
const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild);
await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt);
}
await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`);
}
};
async function removeAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) {
for (const data of altData) {
if (data.type == 'discord') {
try {
if (!data.banned) {
await guild.members.kick(data.thisId as string, (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${moderator.username} (${moderator.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim());
}
} catch (e) {
console.error(e);
}
}
await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt);
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, Guild, GuildMember, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder, time, TimestampStyles, User } from 'discord.js';
import { Database } from '../shared/Database';
import { AltData, comprehensiveAltLookupFromDiscord, deferInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'ban',
data: new SlashCommandBuilder()
.setName('ban')
.setDescription('Bans a user.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to ban.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the ban')
.setRequired(false)
.setMaxLength(400)
)
.addNumberOption(option =>
option
.setName('hours')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('minutes')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('seconds')
.setDescription('The duration of the ban, added with other options (0 for permanent).')
.setRequired(false)
)
.addNumberOption(option =>
option
.setName('delete-message-days')
.setDescription('How far back to delete messages (in days, default: 0 days).')
.setRequired(false)
.setMinValue(0)
.setMaxValue(7)
)
.addBooleanOption(option =>
option
.setName('full-ban')
.setDescription('Whether or not to prevent the user from joining on known alts (and ban all existing alts).')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
const reason = interaction.options.getString('reason') ?? '';
const hours = (interaction.options.getNumber('hours') ?? 0) * 3.6e+6;
const minutes = (interaction.options.getNumber('minutes') ?? 0) * 60000;
const seconds = (interaction.options.getNumber('seconds') ?? 0) * 1000;
const duration = hours + minutes + seconds;
const deleteMessageDays = (interaction.options.getNumber('delete-message-days') ?? 0) * 86400;
const fullBan = interaction.options.getBoolean('full-ban') ?? false;
let banMember: GuildMember | null = null;
try {
banMember = await interaction.guild.members.fetch(idToUse);
} catch (e) {
// Member not in server.
}
const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to ban this user.');
}
if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to ban this user.');
}
const expiresAt = new Date(Date.now() + duration);
await Database.putBan(idToUse, duration > 0 ? expiresAt : null, fullBan);
try {
await interaction.guild.bans.create(idToUse, {
reason: (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${interaction.user.username} (${interaction.user.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim(),
deleteMessageSeconds: deleteMessageDays
});
} catch (e) {
console.error(e);
return await interaction.editReply("Error banning user (couldn't ban).");
}
if (fullBan) {
const alts = await comprehensiveAltLookupFromDiscord(idToUse, interaction.guild);
await removeAllAlts([alts], interaction.guild, interaction.user, fullBan, reason, deleteMessageDays, duration, expiresAt);
}
await interaction.editReply(`<@${idToUse}> (${idToUse}) has been ${fullBan ? 'full banned' : 'banned'}.`);
}
};
async function removeAllAlts(altData: AltData[], guild: Guild, moderator: User, fullBan: boolean, reason: string, deleteMessageDays: number, duration: number, expiresAt: Date) {
for (const data of altData) {
if (data.type == 'discord') {
try {
if (!data.banned) {
await guild.members.kick(data.thisId as string, (reason + ` ${fullBan ? 'Full banned' : 'Banned'} by ${moderator.username} (${moderator.id})${duration > 0 ? `. Expires at: ${time(expiresAt, TimestampStyles.ShortDateTime)}` : ''}`).trim());
}
} catch (e) {
console.error(e);
}
}
await removeAllAlts(data.alts, guild, moderator, fullBan, reason, deleteMessageDays, duration, expiresAt);
}
}
+47 -47
View File
@@ -1,48 +1,48 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types';
export default {
name: 'cite',
data: new SlashCommandBuilder()
.setName('cite')
.setDescription('Cite content from the knowledgebase.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry.')
.setRequired(true)
.setAutocomplete(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await interaction.deferReply();
const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.');
return interaction.editReply(item.content);
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.content.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.name,
value: p.id
})));
}
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types';
export default {
name: 'cite',
data: new SlashCommandBuilder()
.setName('cite')
.setDescription('Cite content from the knowledgebase.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry.')
.setRequired(true)
.setAutocomplete(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await interaction.deferReply();
const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.');
return interaction.editReply(item.content);
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.content.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.name,
value: p.id
})));
}
};
+41 -41
View File
@@ -1,42 +1,42 @@
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export default {
name: 'devwatch',
data: new SlashCommandBuilder()
.setName('devwatch')
.setDescription('Sends a dev watch role toggle button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option =>
option
.setName('content')
.setDescription('The content of the message.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('button-label')
.setDescription('The button label.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role';
const button = new ButtonBuilder()
.setCustomId('dev-watch')
.setStyle(ButtonStyle.Primary)
.setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button);
await interaction.channel.send({ components: [row], content });
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' });
}
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
export default {
name: 'devwatch',
data: new SlashCommandBuilder()
.setName('devwatch')
.setDescription('Sends a dev watch role toggle button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option =>
option
.setName('content')
.setDescription('The content of the message.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('button-label')
.setDescription('The button label.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Toggle DevWatch Role';
const button = new ButtonBuilder()
.setCustomId('dev-watch')
.setStyle(ButtonStyle.Primary)
.setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button);
await interaction.channel.send({ components: [row], content });
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 { config } from '../config';
import { deferInteraction, getDiscordAlts, getE621User } from '../utils';
export default {
name: 'finduser',
data: new SlashCommandBuilder()
.setName('finduser')
.setDescription("Find a user's discord account based on their e621 usernamename or id.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The e621 username or e621 id to find the discord user of.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getString('user', true);
try {
const e621User = await getE621User(user);
if (!e621User) {
return interaction.editReply('I got lost along the way. Who again?');
}
const content = await getDiscordAlts(e621User.id, interaction.guild, 1, [e621User.id]);
interaction.editReply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s e621 and discord account(s):\n${content}`);
} catch (e) {
console.error(e);
interaction.editReply('I got lost in the net.');
}
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { config } from '../config';
import { deferInteraction, getDiscordAlts, getE621User } from '../utils';
export default {
name: 'finduser',
data: new SlashCommandBuilder()
.setName('finduser')
.setDescription("Find a user's discord account based on their e621 usernamename or id.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The e621 username or e621 id to find the discord user of.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getString('user', true);
try {
const e621User = await getE621User(user);
if (!e621User) {
return interaction.editReply('I got lost along the way. Who again?');
}
const content = await getDiscordAlts(e621User.id, interaction.guild, 1, [e621User.id]);
interaction.editReply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s e621 and discord account(s):\n${content}`);
} catch (e) {
console.error(e);
interaction.editReply('I got lost in the net.');
}
}
};
+86 -86
View File
@@ -1,87 +1,87 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'github-mapping',
data: new SlashCommandBuilder()
.setName('github-mapping')
.setDescription('Maps github users to discord ids for releases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add a user mapping.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('github-name')
.setDescription('The github username of the user (case sensitive).')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a user mapping.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('List all github-discord mappings.')
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true);
if (subcommand == 'add') {
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
const githubName = interaction.options.getString('github-name', true);
const existingMappingId = await Database.getGithubFromDiscordId(idToUse);
const existingMappingName = await Database.getDiscordIdFromGithub(githubName);
if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`);
if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`);
Database.putGithubUserMapping(idToUse, githubName);
return interaction.editReply('Mapping added.');
} else if (subcommand == 'remove') {
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
Database.removeGithubUserMapping(idToUse);
return interaction.editReply('Mapping removed.');
} else if (subcommand == 'list') {
const allMappings = await Database.getAllGithubUserMappings();
return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n'));
}
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'github-mapping',
data: new SlashCommandBuilder()
.setName('github-mapping')
.setDescription('Maps github users to discord ids for releases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add a user mapping.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('github-name')
.setDescription('The github username of the user (case sensitive).')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a user mapping.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('List all github-discord mappings.')
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true);
if (subcommand == 'add') {
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
const githubName = interaction.options.getString('github-name', true);
const existingMappingId = await Database.getGithubFromDiscordId(idToUse);
const existingMappingName = await Database.getDiscordIdFromGithub(githubName);
if (existingMappingId) return interaction.editReply(`Discord user id is already mapped to ${existingMappingId}`);
if (existingMappingName) return interaction.editReply(`Github username is already mapped to <@${existingMappingName}> (${existingMappingName})`);
Database.putGithubUserMapping(idToUse, githubName);
return interaction.editReply('Mapping added.');
} else if (subcommand == 'remove') {
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
Database.removeGithubUserMapping(idToUse);
return interaction.editReply('Mapping removed.');
} else if (subcommand == 'list') {
const allMappings = await Database.getAllGithubUserMappings();
return interaction.editReply(allMappings.map(m => `- <@${m.discord_id}> (${m.discord_id}) - ${m.github_username}`).join('\n'));
}
}
};
+126 -126
View File
@@ -1,127 +1,127 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js';
import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types';
import { createTextInput, deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'knowledgebase',
data: new SlashCommandBuilder()
.setName('knowledgebase')
.setDescription('Access the compendium of knowledge.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add to the knowledgebase.')
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Purge knowledge from the universe.')
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('edit')
.setDescription('Edit a knowledgebase entry.')
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry to edit.')
.setRequired(true)
.setAutocomplete(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
const subcommand = interaction.options.getSubcommand(true);
if (subcommand == 'add') {
const modal = new ModalBuilder()
.setCustomId('add-knowledgebase-item-modal')
.setTitle('Add to knowledgebase');
const nameLabel = createTextInput('name', 'Knowledgebase Item Name', null, true, TextInputStyle.Short, 300, 1);
const contentLabel = createTextInput('content', 'Item Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(nameLabel, contentLabel);
return await interaction.showModal(modal);
} else if (subcommand == 'remove') {
await deferInteraction(interaction);
const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.');
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Removed',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: item.name,
inline: true
},
{
name: 'Content',
value: item.content,
inline: true
}
]
});
await Database.removeFromKnowledgebase(id);
return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`);
} else if (subcommand == 'edit') {
const id = interaction.options.getInteger('name', true);
const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
const modal = new ModalBuilder()
.setCustomId(`edit-knowledgebase-item-modal_${id}`)
.setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`);
const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(contentLabel);
return await interaction.showModal(modal);
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.name.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.name,
value: p.id
})));
}
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, ModalBuilder, PermissionFlagsBits, SlashCommandBuilder, TextInputStyle } from 'discord.js';
import { Database } from '../shared/Database';
import { KnowledgebaseItem } from '../types';
import { createTextInput, deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'knowledgebase',
data: new SlashCommandBuilder()
.setName('knowledgebase')
.setDescription('Access the compendium of knowledge.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add to the knowledgebase.')
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Purge knowledge from the universe.')
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('edit')
.setDescription('Edit a knowledgebase entry.')
.addIntegerOption(option =>
option
.setName('name')
.setDescription('The name of the entry to edit.')
.setRequired(true)
.setAutocomplete(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
const subcommand = interaction.options.getSubcommand(true);
if (subcommand == 'add') {
const modal = new ModalBuilder()
.setCustomId('add-knowledgebase-item-modal')
.setTitle('Add to knowledgebase');
const nameLabel = createTextInput('name', 'Knowledgebase Item Name', null, true, TextInputStyle.Short, 300, 1);
const contentLabel = createTextInput('content', 'Item Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(nameLabel, contentLabel);
return await interaction.showModal(modal);
} else if (subcommand == 'remove') {
await deferInteraction(interaction);
const id = interaction.options.getInteger('name', true);
const item = await Database.getFromKnowledgebase(id);
if (!item) return interaction.editReply('Knowledgebase item not found.');
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Removed',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: item.name,
inline: true
},
{
name: 'Content',
value: item.content,
inline: true
}
]
});
await Database.removeFromKnowledgebase(id);
return interaction.editReply(`Removed knowledgebase entry \`${item.name}\`.`);
} else if (subcommand == 'edit') {
const id = interaction.options.getInteger('name', true);
const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
const modal = new ModalBuilder()
.setCustomId(`edit-knowledgebase-item-modal_${id}`)
.setTitle(`Editing knowledgebase item ${existingItem.name.slice(0, 18)}`);
const contentLabel = createTextInput('content', 'New Content', null, true, TextInputStyle.Paragraph, 2000, 1);
modal.addLabelComponents(contentLabel);
return await interaction.showModal(modal);
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
if (!interaction.guild) return interaction.respond([]);
const items: KnowledgebaseItem[] = await Database.getAllKnowledgebaseItems(interaction.guild.id);
const value = interaction.options.getFocused();
const toRespond = items.filter(i => !value ? true : i.name.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.name,
value: p.id
})));
}
};
+133 -133
View File
@@ -1,134 +1,134 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { logCustomEvent, resolveUser } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'link',
data: new SlashCommandBuilder()
.setName('link')
.setDescription('Manually link discord users and e621 users.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand =>
subcommand
.setName('create')
.setDescription('Create a link.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('e621-id')
.setDescription('The id of the e621 user.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a link.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('e621-id')
.setDescription('The id of the e621 user.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true);
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
const e621Id = interaction.options.getInteger('e621-id', true);
if (subcommand == 'create') {
const existingLinks = await Database.getDiscordIds(e621Id);
if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.');
await Database.putUser(e621Id, user);
await logCustomEvent(interaction.guild!, {
title: 'Account Link Created',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Discord User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true
}
]
});
interaction.editReply('Accounts linked');
} else if (subcommand == 'remove') {
const existingLinks = await Database.getDiscordIds(e621Id);
if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.');
await Database.removeUser(e621Id, user.id);
await logCustomEvent(interaction.guild!, {
title: 'Account Link Removed',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Discord User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true
}
]
});
interaction.editReply('Accounts unlinked');
}
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { logCustomEvent, resolveUser } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'link',
data: new SlashCommandBuilder()
.setName('link')
.setDescription('Manually link discord users and e621 users.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(subcommand =>
subcommand
.setName('create')
.setDescription('Create a link.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('e621-id')
.setDescription('The id of the e621 user.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a link.')
.addStringOption(option =>
option
.setName('discord-user')
.setDescription('The discord user id, or mention, of the user.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('e621-id')
.setDescription('The id of the e621 user.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const subcommand = await interaction.options.getSubcommand(true);
const discordUserInput = interaction.options.getString('discord-user', true);
const matches = mentionRegex.exec(discordUserInput);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : discordUserInput;
const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
const e621Id = interaction.options.getInteger('e621-id', true);
if (subcommand == 'create') {
const existingLinks = await Database.getDiscordIds(e621Id);
if (existingLinks.includes(user.id)) return interaction.editReply('Accounts already linked.');
await Database.putUser(e621Id, user);
await logCustomEvent(interaction.guild!, {
title: 'Account Link Created',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Discord User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true
}
]
});
interaction.editReply('Accounts linked');
} else if (subcommand == 'remove') {
const existingLinks = await Database.getDiscordIds(e621Id);
if (!existingLinks.includes(user.id)) return interaction.editReply('Accounts not linked.');
await Database.removeUser(e621Id, user.id);
await logCustomEvent(interaction.guild!, {
title: 'Account Link Removed',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Admin',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Discord User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'E621 User',
value: `${config.E621_BASE_URL}/users/${e621Id}`,
inline: true
}
]
});
interaction.editReply('Accounts unlinked');
}
}
};
+27 -27
View File
@@ -1,28 +1,28 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { openModTicketModal } from '../utils';
export default {
name: 'mod-ticket',
data: new SlashCommandBuilder()
.setName('mod-ticket')
.setDescription('Opens a mod private ticket and pulls the user into it.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to pull in to the ticket.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.editReply('This command must be used in a server.');
const user = interaction.options.getUser('user', true);
const member = await interaction.guild.members.fetch(user.id);
if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member);
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { openModTicketModal } from '../utils';
export default {
name: 'mod-ticket',
data: new SlashCommandBuilder()
.setName('mod-ticket')
.setDescription('Opens a mod private ticket and pulls the user into it.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to pull in to the ticket.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.guild) return interaction.editReply('This command must be used in a server.');
const user = interaction.options.getUser('user', true);
const member = await interaction.guild.members.fetch(user.id);
if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member);
}
};
+35 -35
View File
@@ -1,36 +1,36 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js';
import { config } from '../config';
import { syncName } from '../utils';
export default {
name: 'name-sync',
data: new SlashCommandBuilder()
.setName('name-sync')
.setDescription('Sync your discord nickname to your e621 name.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.setContexts(InteractionContextType.Guild, InteractionContextType.BotDM)
.addIntegerOption(option =>
option
.setName('id')
.setDescription('The id of the e621 user to sync your nickname to.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const id = interaction.options.getInteger('id');
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const member = await guild.members.fetch(interaction.user.id);
if (!member || !guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, id);
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, SlashCommandBuilder } from 'discord.js';
import { config } from '../config';
import { syncName } from '../utils';
export default {
name: 'name-sync',
data: new SlashCommandBuilder()
.setName('name-sync')
.setDescription('Sync your discord nickname to your e621 name.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.setContexts(InteractionContextType.Guild, InteractionContextType.BotDM)
.addIntegerOption(option =>
option
.setName('id')
.setDescription('The id of the e621 user to sync your nickname to.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const id = interaction.options.getInteger('id');
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const member = await guild.members.fetch(interaction.user.id);
if (!member || !guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, id);
}
};
+238 -238
View File
@@ -1,239 +1,239 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent, resolveUser } from '../utils';
import { getNoteMessage } from '../utils/note-utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'notes',
data: new SlashCommandBuilder()
.setName('notes')
.setDescription('Add, view, or remove user notes.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add notes to a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to add a note to.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the note.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('edit')
.setDescription('Edit notes on a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to edit the notes of.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('note')
.setDescription('The note to edit.')
.setRequired(true)
.setAutocomplete(true)
)
.addStringOption(option =>
option
.setName('new-reason')
.setDescription('The new reason for the note.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove notes from a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to remove a note from.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('note')
.setDescription('The note to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription("List a user's notes")
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to list the notes of.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommand = interaction.options.getSubcommand(true);
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
await deferInteraction(interaction);
const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
if (subcommand == 'add') {
const reason = interaction.options.getString('reason', true);
logCustomEvent(interaction.guild!, {
title: 'Note Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: reason,
inline: true
}
]
});
await Database.putNote(user.id, reason, interaction.user.id);
interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`);
} else if (subcommand == 'remove') {
const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.');
logCustomEvent(interaction.guild!, {
title: 'Note Removed',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: `${note.reason}\nBy: <@${note.mod_id}>`,
inline: true
}
]
});
await Database.removeNote(noteId);
interaction.editReply('Removed note.');
} else if (subcommand == 'edit') {
const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.');
const reason = interaction.options.getString('new-reason', true);
logCustomEvent(interaction.guild!, {
title: 'Note Edited',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Old reason',
value: note.reason
},
{
name: 'New reason',
value: reason,
inline: true
}
]
});
await Database.editNote(noteId, note.reason, reason, interaction.user.id);
interaction.editReply(`Note on <@${user.id}> (\`${user.username}\` | \`${user.id}\`) edited.\n\nNew reason:\n${reason}`);
} else if (subcommand == 'list') {
const noteMessage = await getNoteMessage(user.id, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`);
interaction.editReply(noteMessage);
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
if (!idToUse) return interaction.respond([]);
const value = interaction.options.getFocused().toLowerCase();
const notes = await Database.getNotes(idToUse);
const toRespond = notes.filter(w => !value ? true : w.reason.toLowerCase().includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map((w) => {
return {
name: w.reason.substring(0, 50),
value: w.id
};
}));
}
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent, resolveUser } from '../utils';
import { getNoteMessage } from '../utils/note-utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'notes',
data: new SlashCommandBuilder()
.setName('notes')
.setDescription('Add, view, or remove user notes.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add notes to a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to add a note to.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the note.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('edit')
.setDescription('Edit notes on a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to edit the notes of.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('note')
.setDescription('The note to edit.')
.setRequired(true)
.setAutocomplete(true)
)
.addStringOption(option =>
option
.setName('new-reason')
.setDescription('The new reason for the note.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove notes from a user.')
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to remove a note from.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('note')
.setDescription('The note to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription("List a user's notes")
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to list the notes of.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommand = interaction.options.getSubcommand(true);
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
await deferInteraction(interaction);
const user = await resolveUser(client, idToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
if (subcommand == 'add') {
const reason = interaction.options.getString('reason', true);
logCustomEvent(interaction.guild!, {
title: 'Note Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: reason,
inline: true
}
]
});
await Database.putNote(user.id, reason, interaction.user.id);
interaction.editReply(`Note added to <@${user.id}> (\`${user.username}\` | \`${user.id}\`).\n\nReason:\n${reason}`);
} else if (subcommand == 'remove') {
const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.');
logCustomEvent(interaction.guild!, {
title: 'Note Removed',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: `${note.reason}\nBy: <@${note.mod_id}>`,
inline: true
}
]
});
await Database.removeNote(noteId);
interaction.editReply('Removed note.');
} else if (subcommand == 'edit') {
const noteId = interaction.options.getInteger('note', true);
const notes = await Database.getNotes(user.id);
const note = notes.find(n => n.id == noteId);
if (!note) return interaction.editReply('Note not found.');
const reason = interaction.options.getString('new-reason', true);
logCustomEvent(interaction.guild!, {
title: 'Note Edited',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Old reason',
value: note.reason
},
{
name: 'New reason',
value: reason,
inline: true
}
]
});
await Database.editNote(noteId, note.reason, reason, interaction.user.id);
interaction.editReply(`Note on <@${user.id}> (\`${user.username}\` | \`${user.id}\`) edited.\n\nNew reason:\n${reason}`);
} else if (subcommand == 'list') {
const noteMessage = await getNoteMessage(user.id, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${user.id}> (\`${user.username}\` | \`${user.id}\`)`);
interaction.editReply(noteMessage);
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const idToUse = matches ? matches.groups!.id : input;
if (!idToUse) return interaction.respond([]);
const value = interaction.options.getFocused().toLowerCase();
const notes = await Database.getNotes(idToUse);
const toRespond = notes.filter(w => !value ? true : w.reason.toLowerCase().includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map((w) => {
return {
name: w.reason.substring(0, 50),
value: w.id
};
}));
}
};
+251 -251
View File
@@ -1,252 +1,252 @@
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js';
import { Database } from '../shared/Database';
import { TicketPhrase } from '../types';
import { logCustomEvent } from '../utils';
const MIN_PHRASE_LENGTH = 1;
const MAX_PHRASE_LENGTH = 512;
type SubcommandGroup = 'admin' | 'personal';
type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge';
export default {
name: 'phrases',
data: new SlashCommandBuilder()
.setName('phrases')
.setDescription('Manage notified phrases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommandGroup(subcommandGroup =>
subcommandGroup
.setName('admin')
.setDescription('Manage admin notified phrases.')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add an admin notification phrase.')
.addStringOption(option =>
option
.setName('phrase')
.setDescription('The phrase to add.')
.setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove an admin notification phrase.')
.addNumberOption(option =>
option
.setName('phrase')
.setDescription('The phrase to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Get a list of the current admin notification phrases.')
)
)
.addSubcommandGroup(subcommandGroup =>
subcommandGroup
.setName('personal')
.setDescription('Manage personal notified phrases.')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add a personal notification phrase.')
.addStringOption(option =>
option
.setName('phrase')
.setDescription('The phrase to add.')
.setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a personal notification phrase.')
.addNumberOption(option =>
option
.setName('phrase')
.setDescription('The phrase to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Get a list of the current personal notification phrases.')
)
)
.addSubcommand(subcommand =>
subcommand
.setName('dump')
.setDescription('List all notification phrases.')
)
.addSubcommand(subcommand =>
subcommand
.setName('purge')
.setDescription("Purge a user's phrases.")
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to purge the phrases of.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand;
switch (subcommand) {
case 'add':
return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!);
case 'remove':
return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!);
case 'list':
return listPhrases(interaction, subcommandGroup!);
case 'dump':
return dumpPhrases(interaction);
case 'purge':
return purgePhrases(interaction, interaction.options.getUser('user', true));
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
if (!subcommandGroup) return interaction.respond([]);
const value = interaction.options.getFocused();
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id);
const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.phrase,
value: p.id
})));
}
};
async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) {
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id);
const count = await Database.removeAllTicketPhrasesFor(user.id);
logCustomEvent(interaction.guild!, {
title: 'Ticket Phrases Purged',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Target User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Count',
value: count.toString(),
inline: true
}
]
});
interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`);
}
async function dumpPhrases(interaction: ChatInputCommandInteraction) {
let content = '';
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
await Database.getAllTicketPhrases((phrase: TicketPhrase) => {
if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return;
const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`;
content += `${mention}: \`${phrase.phrase}\`\n`;
});
if (content.length == 0) return interaction.reply('No phrases found.');
interaction.reply('The following phrases are registered:\n\n' + content);
}
async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) {
await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`,
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Phrase',
value: phrase,
inline: true
}
]
});
interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`);
}
async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) {
const phrase = await Database.getTicketPhrase(phraseId);
if (!phrase) return interaction.reply('Phrase not found');
await Database.removeTicketPhrase(phraseId);
logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`,
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Phrase',
value: phrase.phrase,
inline: true
}
]
});
interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`);
}
async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) {
const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id);
if (phrases.length == 0) return interaction.reply('No phrases registered');
interaction.reply(`The following phrases are registered:\n\n${phrases.map(p => (`- \`${p.phrase}\``)).join('\n')}`);
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder, User } from 'discord.js';
import { Database } from '../shared/Database';
import { TicketPhrase } from '../types';
import { logCustomEvent } from '../utils';
const MIN_PHRASE_LENGTH = 1;
const MAX_PHRASE_LENGTH = 512;
type SubcommandGroup = 'admin' | 'personal';
type Subcommand = 'add' | 'remove' | 'list' | 'dump' | 'purge';
export default {
name: 'phrases',
data: new SlashCommandBuilder()
.setName('phrases')
.setDescription('Manage notified phrases.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommandGroup(subcommandGroup =>
subcommandGroup
.setName('admin')
.setDescription('Manage admin notified phrases.')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add an admin notification phrase.')
.addStringOption(option =>
option
.setName('phrase')
.setDescription('The phrase to add.')
.setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove an admin notification phrase.')
.addNumberOption(option =>
option
.setName('phrase')
.setDescription('The phrase to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Get a list of the current admin notification phrases.')
)
)
.addSubcommandGroup(subcommandGroup =>
subcommandGroup
.setName('personal')
.setDescription('Manage personal notified phrases.')
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add a personal notification phrase.')
.addStringOption(option =>
option
.setName('phrase')
.setDescription('The phrase to add.')
.setRequired(true)
.setMinLength(MIN_PHRASE_LENGTH)
.setMaxLength(MAX_PHRASE_LENGTH)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove a personal notification phrase.')
.addNumberOption(option =>
option
.setName('phrase')
.setDescription('The phrase to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription('Get a list of the current personal notification phrases.')
)
)
.addSubcommand(subcommand =>
subcommand
.setName('dump')
.setDescription('List all notification phrases.')
)
.addSubcommand(subcommand =>
subcommand
.setName('purge')
.setDescription("Purge a user's phrases.")
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to purge the phrases of.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
const subcommand: Subcommand | null = interaction.options.getSubcommand() as Subcommand;
switch (subcommand) {
case 'add':
return addPhrase(interaction, interaction.options.getString('phrase', true), subcommandGroup!);
case 'remove':
return removePhrase(interaction, interaction.options.getNumber('phrase', true), subcommandGroup!);
case 'list':
return listPhrases(interaction, subcommandGroup!);
case 'dump':
return dumpPhrases(interaction);
case 'purge':
return purgePhrases(interaction, interaction.options.getUser('user', true));
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const subcommandGroup: SubcommandGroup | null = interaction.options.getSubcommandGroup() as SubcommandGroup;
if (!subcommandGroup) return interaction.respond([]);
const value = interaction.options.getFocused();
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(subcommandGroup == 'admin' ? 'admin' : interaction.user.id);
const toRespond = phrases.filter(p => !value ? true : p.phrase.includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map(p => ({
name: p.phrase,
value: p.id
})));
}
};
async function purgePhrases(interaction: ChatInputCommandInteraction, user: User) {
const phrases: TicketPhrase[] = await Database.getTicketPhrasesFor(user.id);
const count = await Database.removeAllTicketPhrasesFor(user.id);
logCustomEvent(interaction.guild!, {
title: 'Ticket Phrases Purged',
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Target User',
value: `<@${user.id}>\n${user.username}`,
inline: true
},
{
name: 'Count',
value: count.toString(),
inline: true
}
]
});
interaction.reply(`Purged the following phrases (${count}):\n${phrases.map(p => `- \`${p.phrase}\``).join('\n')}`);
}
async function dumpPhrases(interaction: ChatInputCommandInteraction) {
let content = '';
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
await Database.getAllTicketPhrases((phrase: TicketPhrase) => {
if (phrase.user_id == 'admin' && (!guildSettings || !guildSettings.admin_role_id)) return;
const mention = phrase.user_id == 'admin' ? `<@&${guildSettings?.admin_role_id}>` : `<@${phrase.user_id}>`;
content += `${mention}: \`${phrase.phrase}\`\n`;
});
if (content.length == 0) return interaction.reply('No phrases found.');
interaction.reply('The following phrases are registered:\n\n' + content);
}
async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) {
await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Added`,
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Phrase',
value: phrase,
inline: true
}
]
});
interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`);
}
async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) {
const phrase = await Database.getTicketPhrase(phraseId);
if (!phrase) return interaction.reply('Phrase not found');
await Database.removeTicketPhrase(phraseId);
logCustomEvent(interaction.guild!, {
title: `${group == 'admin' ? 'Admin ' : ''}Ticket Phrase Removed`,
description: null,
color: 0xFF0000,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Phrase',
value: phrase.phrase,
inline: true
}
]
});
interaction.reply(`Phrase will no longer alert ${group == 'admin' ? 'admins' : 'you'}.`);
}
async function listPhrases(interaction: ChatInputCommandInteraction, group: SubcommandGroup) {
const phrases = await Database.getTicketPhrasesFor(group == 'admin' ? 'admin' : interaction.user.id);
if (phrases.length == 0) return interaction.reply('No phrases registered');
interaction.reply(`The following phrases are registered:\n\n${phrases.map(p => (`- \`${p.phrase}\``)).join('\n')}`);
}
+44 -44
View File
@@ -1,45 +1,45 @@
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'private-help',
data: new SlashCommandBuilder()
.setName('private-help')
.setDescription('Setup a private help button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option =>
option
.setName('content')
.setDescription('The content of the message.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('button-label')
.setDescription('The button label.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Get in contact';
const button = new ButtonBuilder()
.setCustomId('private-help')
.setStyle(ButtonStyle.Primary)
.setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button);
await interaction.channel.send({ components: [row], content });
await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId);
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' });
}
import { ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'private-help',
data: new SlashCommandBuilder()
.setName('private-help')
.setDescription('Setup a private help button.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addStringOption(option =>
option
.setName('content')
.setDescription('The content of the message.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('button-label')
.setDescription('The button label.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
if (!interaction.channel || !interaction.channel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Missing permissions to send to channel.' });
const content = interaction.options.getString('content') ?? '';
const label = interaction.options.getString('button-label') ?? 'Get in contact';
const button = new ButtonBuilder()
.setCustomId('private-help')
.setStyle(ButtonStyle.Primary)
.setLabel(label);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(button);
await interaction.channel.send({ components: [row], content });
await Database.setPrivateHelpChannel(interaction.guildId!, interaction.channelId);
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 { deferInteraction } from '../utils';
import { getRecordMessageFromDiscordId } from '../utils/record-utils';
export default {
name: 'records',
data: new SlashCommandBuilder()
.setName('records')
.setDescription("Get a user's on-site records.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The discord user to find the e621 user of.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('id')
.setDescription('The discord user id to find the e621 user of.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getUser('user');
const id = interaction.options.getString('id');
if (!user && !id) {
return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] });
}
const idToUse = (user?.id ?? id) as string;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage);
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { deferInteraction } from '../utils';
import { getRecordMessageFromDiscordId } from '../utils/record-utils';
export default {
name: 'records',
data: new SlashCommandBuilder()
.setName('records')
.setDescription("Get a user's on-site records.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The discord user to find the e621 user of.')
.setRequired(false)
)
.addStringOption(option =>
option
.setName('id')
.setDescription('The discord user id to find the e621 user of.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = interaction.options.getUser('user');
const id = interaction.options.getString('id');
if (!user && !id) {
return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] });
}
const idToUse = (user?.id ?? id) as string;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage);
}
};
+50 -50
View File
@@ -1,51 +1,51 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js';
import { msToHuman } from '../utils';
import { Database } from '../shared/Database';
export default {
name: 'rename',
data: new SlashCommandBuilder()
.setName('rename')
.setDescription('Rename the general channel.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('new-name')
.setDescription('The new name of the general channel.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.general_chat_id) {
return interaction.reply('No general chat id found.');
}
const name = interaction.options.getString('new-name', true);
if (name.length > 100) {
return interaction.reply('Name must be less than 100 characters in length.');
}
const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!;
if (!channel) {
return interaction.reply('No general chat id found.');
}
try {
await channel.setName(name);
interaction.reply(`Renamed general to ${channel.name}`);
} catch (e: any) {
if (e instanceof RateLimitError) {
return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`);
}
console.error(e);
return interaction.reply('An error has occurred.');
}
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js';
import { msToHuman } from '../utils';
import { Database } from '../shared/Database';
export default {
name: 'rename',
data: new SlashCommandBuilder()
.setName('rename')
.setDescription('Rename the general channel.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('new-name')
.setDescription('The new name of the general channel.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.general_chat_id) {
return interaction.reply('No general chat id found.');
}
const name = interaction.options.getString('new-name', true);
if (name.length > 100) {
return interaction.reply('Name must be less than 100 characters in length.');
}
const channel = await interaction.guild!.channels.fetch(guildSettings.general_chat_id)!;
if (!channel) {
return interaction.reply('No general chat id found.');
}
try {
await channel.setName(name);
interaction.reply(`Renamed general to ${channel.name}`);
} catch (e: any) {
if (e instanceof RateLimitError) {
return interaction.reply(`Name change limited. Try again in ${msToHuman(e.retryAfter)}`);
}
console.error(e);
return interaction.reply('An error has occurred.');
}
}
};
+302 -302
View File
@@ -1,303 +1,303 @@
import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'settings',
data: new SlashCommandBuilder()
.setName('settings')
.setDescription('Change server settings.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addChannelOption(option =>
option
.setName('general-channel')
.setDescription('Set the general channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('tickets-channel')
.setDescription('Set the ticket logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('event-logs-channel')
.setDescription('Set the event logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('discord-logs-channel')
.setDescription('Set the discord logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('audit-logs-channel')
.setDescription('Set the audit logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('voice-logs-channel')
.setDescription('Set the voice logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('new-member-channel')
.setDescription('Set the new member logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('moderator-channel')
.setDescription('Set the site moderator channel.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('admin-role')
.setDescription('Set the admin role.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('private-helper-role')
.setDescription('Set the private helper role.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('devwatch-role')
.setDescription('Set the DevWatch role.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-staff-category')
.setDescription('Add a category to staff categories.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-staff-category')
.setDescription('Remove a category from staff categories.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-safe-channel')
.setDescription('Add a SFW channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-safe-channel')
.setDescription('Remove a SFW channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-link-skip-channel')
.setDescription('Add a link skip channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-link-skip-channel')
.setDescription('Remove a link skip channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('github-release-channel')
.setDescription('Set the github release channel.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
let response = '';
const settings = await Database.getGuildSettings(interaction.guildId!);
if (!settings) {
await Database.putGuild(interaction.guildId!);
}
const generalChannel = interaction.options.getChannel('general-channel');
if (generalChannel) {
await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id);
response += `General channel set to ${generalChannel}.\n`;
}
const ticketsChannel = interaction.options.getChannel('tickets-channel');
if (ticketsChannel) {
await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id);
response += `Tickets logs channel set to ${ticketsChannel}.\n`;
}
const eventLogsChannel = interaction.options.getChannel('event-logs-channel');
if (eventLogsChannel) {
await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id);
response += `Event logs channel set to ${eventLogsChannel}.\n`;
}
const discordLogsChannel = interaction.options.getChannel('discord-logs-channel');
if (discordLogsChannel) {
await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id);
response += `Discord logs channel set to ${discordLogsChannel}.\n`;
}
const auditLogsChannel = interaction.options.getChannel('audit-logs-channel');
if (auditLogsChannel) {
await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id);
response += `Audit logs channel set to ${auditLogsChannel}.\n`;
}
const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel');
if (voiceLogsChannel) {
await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id);
response += `Voice logs channel set to ${voiceLogsChannel}.\n`;
}
const newMemberLogsChannel = interaction.options.getChannel('new-member-channel');
if (newMemberLogsChannel) {
await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id);
response += `New member logs channel set to ${newMemberLogsChannel}.\n`;
}
const moderatorChannel = interaction.options.getChannel('moderator-channel');
if (moderatorChannel) {
await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id);
response += `Moderator channel set to ${moderatorChannel}.\n`;
}
const adminRole = interaction.options.getRole('admin-role');
if (adminRole) {
await Database.setGuildAdminRole(interaction.guildId!, adminRole.id);
response += `Admin role set to ${adminRole}.\n`;
}
const privateHelperRole = interaction.options.getRole('private-helper-role');
if (privateHelperRole) {
await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id);
response += `Private helper role set to ${privateHelperRole}.\n`;
}
const devWatchRole = interaction.options.getRole('devwatch-role');
if (devWatchRole) {
await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id);
response += `DevWatch role set to ${devWatchRole}.\n`;
}
const addCategory = interaction.options.getChannel('add-staff-category');
if (addCategory) {
if (addCategory.type == ChannelType.GuildCategory) {
await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id);
response += `Added ${addCategory.toString()} as a staff category.\n`;
} else {
response += `Error adding staff category: ${addCategory.toString()} isn't a category.`;
}
}
const removeCategory = interaction.options.getChannel('remove-staff-category');
if (removeCategory) {
if (removeCategory.type == ChannelType.GuildCategory) {
if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) {
response += `Removed ${removeCategory.toString()} as a staff category\n`;
} else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`;
}
} else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`;
}
}
const addSafeChannel = interaction.options.getChannel('add-safe-channel');
if (addSafeChannel) {
if (addSafeChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id);
response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`;
} else {
response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`;
}
}
const removeSafeChannel = interaction.options.getChannel('remove-safe-channel');
if (removeSafeChannel) {
if (removeSafeChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) {
response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`;
} else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`;
}
} else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`;
}
}
const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel');
if (addLinkSkipChannel) {
if (addLinkSkipChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id);
response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`;
} else {
response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`;
}
}
const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel');
if (removeLinkSkipChannel) {
if (removeLinkSkipChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) {
response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`;
} else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`;
}
} else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`;
}
}
const githubReleaseChannel = interaction.options.getChannel('github-release-channel');
if (githubReleaseChannel) {
await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id);
response += `Github releases channel set to ${githubReleaseChannel}.\n`;
}
if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' });
interaction.editReply({ content: response });
}
import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
export default {
name: 'settings',
data: new SlashCommandBuilder()
.setName('settings')
.setDescription('Change server settings.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addChannelOption(option =>
option
.setName('general-channel')
.setDescription('Set the general channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('tickets-channel')
.setDescription('Set the ticket logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('event-logs-channel')
.setDescription('Set the event logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('discord-logs-channel')
.setDescription('Set the discord logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('audit-logs-channel')
.setDescription('Set the audit logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('voice-logs-channel')
.setDescription('Set the voice logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('new-member-channel')
.setDescription('Set the new member logs channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('moderator-channel')
.setDescription('Set the site moderator channel.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('admin-role')
.setDescription('Set the admin role.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('private-helper-role')
.setDescription('Set the private helper role.')
.setRequired(false)
)
.addRoleOption(option =>
option
.setName('devwatch-role')
.setDescription('Set the DevWatch role.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-staff-category')
.setDescription('Add a category to staff categories.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-staff-category')
.setDescription('Remove a category from staff categories.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-safe-channel')
.setDescription('Add a SFW channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-safe-channel')
.setDescription('Remove a SFW channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('add-link-skip-channel')
.setDescription('Add a link skip channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('remove-link-skip-channel')
.setDescription('Remove a link skip channel.')
.setRequired(false)
)
.addChannelOption(option =>
option
.setName('github-release-channel')
.setDescription('Set the github release channel.')
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
let response = '';
const settings = await Database.getGuildSettings(interaction.guildId!);
if (!settings) {
await Database.putGuild(interaction.guildId!);
}
const generalChannel = interaction.options.getChannel('general-channel');
if (generalChannel) {
await Database.setGuildGeneralChatId(interaction.guildId!, generalChannel.id);
response += `General channel set to ${generalChannel}.\n`;
}
const ticketsChannel = interaction.options.getChannel('tickets-channel');
if (ticketsChannel) {
await Database.setGuildTicketsLogsChannelId(interaction.guildId!, ticketsChannel.id);
response += `Tickets logs channel set to ${ticketsChannel}.\n`;
}
const eventLogsChannel = interaction.options.getChannel('event-logs-channel');
if (eventLogsChannel) {
await Database.setGuildEventsLogsChannelId(interaction.guildId!, eventLogsChannel.id);
response += `Event logs channel set to ${eventLogsChannel}.\n`;
}
const discordLogsChannel = interaction.options.getChannel('discord-logs-channel');
if (discordLogsChannel) {
await Database.setGuildDiscordLogsChannelId(interaction.guildId!, discordLogsChannel.id);
response += `Discord logs channel set to ${discordLogsChannel}.\n`;
}
const auditLogsChannel = interaction.options.getChannel('audit-logs-channel');
if (auditLogsChannel) {
await Database.setGuildAuditLogsChannelId(interaction.guildId!, auditLogsChannel.id);
response += `Audit logs channel set to ${auditLogsChannel}.\n`;
}
const voiceLogsChannel = interaction.options.getChannel('voice-logs-channel');
if (voiceLogsChannel) {
await Database.setGuildVoiceLogsChannelId(interaction.guildId!, voiceLogsChannel.id);
response += `Voice logs channel set to ${voiceLogsChannel}.\n`;
}
const newMemberLogsChannel = interaction.options.getChannel('new-member-channel');
if (newMemberLogsChannel) {
await Database.setGuildNewMemberLogsChannel(interaction.guildId!, newMemberLogsChannel.id);
response += `New member logs channel set to ${newMemberLogsChannel}.\n`;
}
const moderatorChannel = interaction.options.getChannel('moderator-channel');
if (moderatorChannel) {
await Database.setGuildModeratorChannel(interaction.guildId!, moderatorChannel.id);
response += `Moderator channel set to ${moderatorChannel}.\n`;
}
const adminRole = interaction.options.getRole('admin-role');
if (adminRole) {
await Database.setGuildAdminRole(interaction.guildId!, adminRole.id);
response += `Admin role set to ${adminRole}.\n`;
}
const privateHelperRole = interaction.options.getRole('private-helper-role');
if (privateHelperRole) {
await Database.setGuildPrivateHelperRole(interaction.guildId!, privateHelperRole.id);
response += `Private helper role set to ${privateHelperRole}.\n`;
}
const devWatchRole = interaction.options.getRole('devwatch-role');
if (devWatchRole) {
await Database.setGuildDevWatchRole(interaction.guildId!, devWatchRole.id);
response += `DevWatch role set to ${devWatchRole}.\n`;
}
const addCategory = interaction.options.getChannel('add-staff-category');
if (addCategory) {
if (addCategory.type == ChannelType.GuildCategory) {
await Database.putGuildArraySetting('staff_categories', interaction.guildId!, addCategory.id);
response += `Added ${addCategory.toString()} as a staff category.\n`;
} else {
response += `Error adding staff category: ${addCategory.toString()} isn't a category.`;
}
}
const removeCategory = interaction.options.getChannel('remove-staff-category');
if (removeCategory) {
if (removeCategory.type == ChannelType.GuildCategory) {
if (await Database.removeGuildArraySetting('staff_categories', interaction.guildId!, removeCategory.id)) {
response += `Removed ${removeCategory.toString()} as a staff category\n`;
} else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a staff category.`;
}
} else {
response += `Error removing staff category: ${removeCategory.toString()} isn't a category.`;
}
}
const addSafeChannel = interaction.options.getChannel('add-safe-channel');
if (addSafeChannel) {
if (addSafeChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('safe_channels', interaction.guildId!, addSafeChannel.id);
response += `Added ${addSafeChannel.toString()} as a SFW cannel.\n`;
} else {
response += `Error adding SFW channel: ${addSafeChannel.toString()} isn't a text channel.`;
}
}
const removeSafeChannel = interaction.options.getChannel('remove-safe-channel');
if (removeSafeChannel) {
if (removeSafeChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('safe_channels', interaction.guildId!, removeSafeChannel.id)) {
response += `Removed ${removeSafeChannel.toString()} as a safe channel\n`;
} else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a safe channel.`;
}
} else {
response += `Error removing safe channel: ${removeSafeChannel.toString()} isn't a text channel.`;
}
}
const addLinkSkipChannel = interaction.options.getChannel('add-link-skip-channel');
if (addLinkSkipChannel) {
if (addLinkSkipChannel.type == ChannelType.GuildText) {
await Database.putGuildArraySetting('link_skip_channels', interaction.guildId!, addLinkSkipChannel.id);
response += `Added ${addLinkSkipChannel.toString()} as a link skip channel.\n`;
} else {
response += `Error adding link skip channel: ${addLinkSkipChannel.toString()} isn't a text channel.`;
}
}
const removeLinkSkipChannel = interaction.options.getChannel('remove-link-skip-channel');
if (removeLinkSkipChannel) {
if (removeLinkSkipChannel.type == ChannelType.GuildText) {
if (await Database.removeGuildArraySetting('link_skip_channels', interaction.guildId!, removeLinkSkipChannel.id)) {
response += `Removed ${removeLinkSkipChannel.toString()} as a staff category\n`;
} else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a link skip channel.`;
}
} else {
response += `Error removing link skip channel: ${removeLinkSkipChannel.toString()} isn't a text channel.`;
}
}
const githubReleaseChannel = interaction.options.getChannel('github-release-channel');
if (githubReleaseChannel) {
await Database.setGuildGithubReleaseChannel(interaction.guildId!, githubReleaseChannel.id);
response += `Github releases channel set to ${githubReleaseChannel}.\n`;
}
if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' });
interaction.editReply({ content: response });
}
};
+80 -80
View File
@@ -1,81 +1,81 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { deferInteraction } from '../utils';
export default {
name: 'softban',
data: new SlashCommandBuilder()
.setName('softban')
.setDescription('Bans and immediately unbans a user to purge messages.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The discord user to softban.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the softban')
.setRequired(false)
.setMaxLength(400)
)
.addNumberOption(option =>
option
.setName('days')
.setDescription('How far back to delete messages (in days, default: 7 days).')
.setRequired(false)
.setMinValue(0)
.setMaxValue(7)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason') ?? '';
const seconds = (interaction.options.getNumber('days') ?? 7) * 86400;
let banMember: GuildMember | null = null;
try {
banMember = await interaction.guild.members.fetch(user.id);
} catch (e) {
// Member not in server.
}
const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to softban this user.');
}
if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to softban this user.');
}
try {
await interaction.guild.bans.create(user, {
reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(),
deleteMessageSeconds: seconds
});
} catch (e) {
console.error(e);
return await interaction.editReply("Error softbanning user (couldn't ban).");
}
try {
await interaction.guild.bans.remove(user);
} catch (e) {
console.error(e);
return await interaction.editReply("Error softbanning user (couldn't remove ban).");
}
await interaction.editReply('Softban successful');
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildMember, InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { deferInteraction } from '../utils';
export default {
name: 'softban',
data: new SlashCommandBuilder()
.setName('softban')
.setDescription('Bans and immediately unbans a user to purge messages.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.addUserOption(option =>
option
.setName('user')
.setDescription('The discord user to softban.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the softban')
.setRequired(false)
.setMaxLength(400)
)
.addNumberOption(option =>
option
.setName('days')
.setDescription('How far back to delete messages (in days, default: 7 days).')
.setRequired(false)
.setMinValue(0)
.setMaxValue(7)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await deferInteraction(interaction);
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
if (!interaction.guild.members.me) return interaction.editReply('An error has occurred. Please try again later.');
const user = interaction.options.getUser('user', true);
const reason = interaction.options.getString('reason') ?? '';
const seconds = (interaction.options.getNumber('days') ?? 7) * 86400;
let banMember: GuildMember | null = null;
try {
banMember = await interaction.guild.members.fetch(user.id);
} catch (e) {
// Member not in server.
}
const member = await interaction.guild.members.fetch(interaction.user.id);
if (banMember && member.roles.highest.comparePositionTo(banMember.roles.highest) <= 0) {
return await interaction.editReply('You do not have permission to softban this user.');
}
if (banMember && !banMember.bannable) {
return await interaction.editReply('I do not have permission to softban this user.');
}
try {
await interaction.guild.bans.create(user, {
reason: (reason + ` Softban by ${interaction.user.username} (${interaction.user.id})`).trim(),
deleteMessageSeconds: seconds
});
} catch (e) {
console.error(e);
return await interaction.editReply("Error softbanning user (couldn't ban).");
}
try {
await interaction.guild.bans.remove(user);
} catch (e) {
console.error(e);
return await interaction.editReply("Error softbanning user (couldn't remove ban).");
}
await interaction.editReply('Softban successful');
}
};
+29 -29
View File
@@ -1,30 +1,30 @@
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'whois',
data: new SlashCommandBuilder()
.setName('whois')
.setDescription("Find a user's e621 account from their discord account, or vice versa.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to find the e621 user of.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const valueToUse = matches ? matches.groups!.id : input;
handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
}
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageMentions, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
const mentionRegex = new RegExp(MessageMentions.UsersPattern);
export default {
name: 'whois',
data: new SlashCommandBuilder()
.setName('whois')
.setDescription("Find a user's e621 account from their discord account, or vice versa.")
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addStringOption(option =>
option
.setName('user')
.setDescription('The discord user mention, or ID, to find the e621 user of.')
.setRequired(true)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const input = interaction.options.getString('user', true);
const matches = mentionRegex.exec(input);
mentionRegex.lastIndex = 0;
const valueToUse = matches ? matches.groups!.id : input;
handleWhoIsInteraction(interaction, valueToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
}
};
+26 -26
View File
@@ -1,27 +1,27 @@
import dotenv from 'dotenv';
dotenv.config();
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env;
export const config = {
DISCORD_TOKEN,
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
DISCORD_GUILD_ID,
RELEASE_SECRET,
LINK_SECRET,
E621_BASE_URL,
E926_BASE_URL,
GIT_REPO_BASE_URL,
PORT: parseInt(PORT as string),
REDIS_URL,
DEV_MODE: process.env.npm_lifecycle_event == 'dev',
DEBUG: DEBUG == 'true'
};
for (const [key, val] of Object.entries(config)) {
if (val === undefined) {
throw new Error(`${key} is undefined in config`);
}
import dotenv from 'dotenv';
dotenv.config();
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, RELEASE_SECRET, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, GIT_REPO_BASE_URL, REDIS_URL, PORT, DEBUG } = process.env;
export const config = {
DISCORD_TOKEN,
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
DISCORD_GUILD_ID,
RELEASE_SECRET,
LINK_SECRET,
E621_BASE_URL,
E926_BASE_URL,
GIT_REPO_BASE_URL,
PORT: parseInt(PORT as string),
REDIS_URL,
DEV_MODE: process.env.npm_lifecycle_event == 'dev',
DEBUG: DEBUG == 'true'
};
for (const [key, val] of Object.entries(config)) {
if (val === undefined) {
throw new Error(`${key} is undefined in config`);
}
}
+26 -26
View File
@@ -1,27 +1,27 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { createTextInput } from '../utils';
export default {
name: 'Add Note',
data: new ContextMenuCommandBuilder()
.setName('Add Note')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
const member = await interaction.guild?.members.fetch(idToUse);
const modal = new ModalBuilder()
.setCustomId(`add-note-modal_${idToUse}`)
.setTitle(`Adding note to ${member ? member.displayName : idToUse}`);
const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2);
modal.addLabelComponents(inputLabel);
await interaction.showModal(modal);
}
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { createTextInput } from '../utils';
export default {
name: 'Add Note',
data: new ContextMenuCommandBuilder()
.setName('Add Note')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
const member = await interaction.guild?.members.fetch(idToUse);
const modal = new ModalBuilder()
.setCustomId(`add-note-modal_${idToUse}`)
.setTitle(`Adding note to ${member ? member.displayName : idToUse}`);
const inputLabel = createTextInput('note-message', 'Note Message', null, true, TextInputStyle.Paragraph, 1500, 2);
modal.addLabelComponents(inputLabel);
await interaction.showModal(modal);
}
};
+22 -22
View File
@@ -1,23 +1,23 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getNoteMessage } from '../utils';
export default {
name: 'List Notes',
data: new ContextMenuCommandBuilder()
.setName('List Notes')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
await deferInteraction(interaction);
const noteMessage = await getNoteMessage(idToUse, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`);
interaction.editReply(noteMessage);
}
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getNoteMessage } from '../utils';
export default {
name: 'List Notes',
data: new ContextMenuCommandBuilder()
.setName('List Notes')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
await deferInteraction(interaction);
const noteMessage = await getNoteMessage(idToUse, 1);
if (!noteMessage) return interaction.editReply(`No notes found for <@${idToUse}>`);
interaction.editReply(noteMessage);
}
};
+18 -18
View File
@@ -1,19 +1,19 @@
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js';
import { openModTicketModal } from '../utils';
export default {
name: 'Open Mod Ticket',
data: new ContextMenuCommandBuilder()
.setName('Open Mod Ticket')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const member = await interaction.guild?.members.fetch(interaction.targetUser.id);
if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member);
}
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction } from 'discord.js';
import { openModTicketModal } from '../utils';
export default {
name: 'Open Mod Ticket',
data: new ContextMenuCommandBuilder()
.setName('Open Mod Ticket')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const member = await interaction.guild?.members.fetch(interaction.targetUser.id);
if (!member) return interaction.editReply('Could not find member.');
openModTicketModal(interaction, member);
}
};
+22 -22
View File
@@ -1,23 +1,23 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'Get Records',
data: new ContextMenuCommandBuilder()
.setName('Get Records')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
await deferInteraction(interaction);
const idToUse = interaction.targetUser.id;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage);
}
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, PermissionFlagsBits, UserContextMenuCommandInteraction } from 'discord.js';
import { deferInteraction, getRecordMessageFromDiscordId } from '../utils';
export default {
name: 'Get Records',
data: new ContextMenuCommandBuilder()
.setName('Get Records')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
await deferInteraction(interaction);
const idToUse = interaction.targetUser.id;
const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild!);
if (!recordMessage) return interaction.editReply('No records found on any linked accounts.');
interaction.editReply(recordMessage);
}
};
+56 -56
View File
@@ -1,57 +1,57 @@
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { createTextInput, deferInteraction, syncName } from '../utils';
export default {
name: 'Sync Name',
data: new ContextMenuCommandBuilder()
.setName('Sync Name')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const availableIds = await Database.getE621Ids(interaction.user.id);
const idToUse = interaction.targetUser.id;
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const member = await guild.members.fetch(idToUse);
const interactionMember = await guild.members.fetch(interaction.user.id);
if (!member || !interactionMember) {
return interaction.reply('An error has occurred. Please try again later.');
}
if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) {
return await interaction.editReply("You do not have permission to sync this user's name.");
}
if (availableIds.length > 1) {
const modal = new ModalBuilder()
.setCustomId(`sync-name-modal_${idToUse}`)
.setTitle(`Syncing ${member ? member.displayName : idToUse}'s name`);
const inputLabel = createTextInput('id', 'User has multiple linked accounts. Provide ID', null, false, TextInputStyle.Short, null, null);
modal.addLabelComponents(inputLabel);
return await interaction.showModal(modal);
}
await deferInteraction(interaction);
if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, null);
}
import { ApplicationCommandType, ApplicationIntegrationType, Client, ContextMenuCommandBuilder, InteractionContextType, ModalBuilder, PermissionFlagsBits, TextInputStyle, UserContextMenuCommandInteraction } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { createTextInput, deferInteraction, syncName } from '../utils';
export default {
name: 'Sync Name',
data: new ContextMenuCommandBuilder()
.setName('Sync Name')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageNicknames)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const availableIds = await Database.getE621Ids(interaction.user.id);
const idToUse = interaction.targetUser.id;
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const member = await guild.members.fetch(idToUse);
const interactionMember = await guild.members.fetch(interaction.user.id);
if (!member || !interactionMember) {
return interaction.reply('An error has occurred. Please try again later.');
}
if (interactionMember.roles.highest.comparePositionTo(member.roles.highest) <= 0) {
return await interaction.editReply("You do not have permission to sync this user's name.");
}
if (availableIds.length > 1) {
const modal = new ModalBuilder()
.setCustomId(`sync-name-modal_${idToUse}`)
.setTitle(`Syncing ${member ? member.displayName : idToUse}'s name`);
const inputLabel = createTextInput('id', 'User has multiple linked accounts. Provide ID', null, false, TextInputStyle.Short, null, null);
modal.addLabelComponents(inputLabel);
return await interaction.showModal(modal);
}
await deferInteraction(interaction);
if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, null);
}
};
+16 -16
View File
@@ -1,17 +1,17 @@
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
export default {
name: 'Whois',
data: new ContextMenuCommandBuilder()
.setName('Whois')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
}
import { ApplicationIntegrationType, Client, InteractionContextType, PermissionFlagsBits, ContextMenuCommandBuilder, ApplicationCommandType, UserContextMenuCommandInteraction, GuildBasedChannel } from 'discord.js';
import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils';
export default {
name: 'Whois',
data: new ContextMenuCommandBuilder()
.setName('Whois')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.setType(ApplicationCommandType.User),
handler: async function (client: Client, interaction: UserContextMenuCommandInteraction) {
const idToUse = interaction.targetUser.id;
handleWhoIsInteraction(interaction, idToUse, !(await channelIsInStaffCategory(interaction.channel as GuildBasedChannel)));
}
};
+93 -93
View File
@@ -1,94 +1,94 @@
import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js';
import { Database } from '../shared/Database';
import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils';
const IGNORED_ACTIONS = [
AuditLogEvent.MemberMove,
// Handled by automod.
AuditLogEvent.AutoModerationFlagToChannel
];
export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) {
if (!await shouldLog(entry, guild)) return;
const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.audit_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.audit_logs_channel_id);
if (!channel || !channel.isSendable()) return;
const fields: APIEmbedField[] = [
{
name: 'Actor',
value: `<@${entry.executorId}>`,
inline: true
}
];
if (entry.targetId) {
const targetType = getTargetType(entry.action);
fields.push({
name: 'Target',
value: formatSnowflake(entry.targetId, targetType),
inline: true
});
}
if (entry.reason) {
fields.push({
name: 'Reason',
value: entry.reason,
inline: true
});
}
if (entry.changes && entry.changes.length > 0) {
fields.push({
name: 'Changes',
value: formatChanges(entry),
inline: false
});
}
if (entry.extra) {
fields.push({
name: 'Options',
value: formatExtras(entry, guild),
inline: false
});
}
const embed = new EmbedBuilder()
.setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)])
.setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp))
.addFields(...fields);
channel.send({ embeds: [embed] });
}
async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise<boolean> {
if (!entry.executorId) return true;
if (IGNORED_ACTIONS.some(a => entry.action == a)) return false;
if (entry.action == AuditLogEvent.MemberRoleUpdate) {
return await shouldLogRoleChanges(entry as GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild);
}
return true;
}
async function shouldLogRoleChanges(entry: GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild: Guild): Promise<boolean> {
for (const change of entry.changes) {
// 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))));
// Check if role is part of onboarding.
for (const role of roles) {
if (role && !role.flags.has(RoleFlags.InPrompt)) return true;
}
}
return false;
import { APIEmbedField, APIRole, AuditLogEvent, EmbedBuilder, Guild, GuildAuditLogsEntry, RoleFlags, SnowflakeUtil } from 'discord.js';
import { Database } from '../shared/Database';
import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils';
const IGNORED_ACTIONS = [
AuditLogEvent.MemberMove,
// Handled by automod.
AuditLogEvent.AutoModerationFlagToChannel
];
export async function handleAuditLogCreate(entry: GuildAuditLogsEntry, guild: Guild) {
if (!await shouldLog(entry, guild)) return;
const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.audit_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.audit_logs_channel_id);
if (!channel || !channel.isSendable()) return;
const fields: APIEmbedField[] = [
{
name: 'Actor',
value: `<@${entry.executorId}>`,
inline: true
}
];
if (entry.targetId) {
const targetType = getTargetType(entry.action);
fields.push({
name: 'Target',
value: formatSnowflake(entry.targetId, targetType),
inline: true
});
}
if (entry.reason) {
fields.push({
name: 'Reason',
value: entry.reason,
inline: true
});
}
if (entry.changes && entry.changes.length > 0) {
fields.push({
name: 'Changes',
value: formatChanges(entry),
inline: false
});
}
if (entry.extra) {
fields.push({
name: 'Options',
value: formatExtras(entry, guild),
inline: false
});
}
const embed = new EmbedBuilder()
.setTitle(Object.keys(AuditLogEvent)[Object.values(AuditLogEvent).indexOf(entry.action)])
.setTimestamp(Number(SnowflakeUtil.decode(entry.id).timestamp))
.addFields(...fields);
channel.send({ embeds: [embed] });
}
async function shouldLog(entry: GuildAuditLogsEntry, guild: Guild): Promise<boolean> {
if (!entry.executorId) return true;
if (IGNORED_ACTIONS.some(a => entry.action == a)) return false;
if (entry.action == AuditLogEvent.MemberRoleUpdate) {
return await shouldLogRoleChanges(entry as GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild);
}
return true;
}
async function shouldLogRoleChanges(entry: GuildAuditLogsEntry<AuditLogEvent.MemberRoleUpdate>, guild: Guild): Promise<boolean> {
for (const change of entry.changes) {
// 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))));
// Check if role is part of onboarding.
for (const role of roles) {
if (role && !role.flags.has(RoleFlags.InPrompt)) return true;
}
}
return false;
}
+5 -5
View File
@@ -1,6 +1,6 @@
import { GuildBan } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleBanRemove(ban: GuildBan) {
await Database.removeBan(ban.user.id);
import { GuildBan } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleBanRemove(ban: GuildBan) {
await Database.removeBan(ban.user.id);
}
+9 -9
View File
@@ -1,10 +1,10 @@
import { Guild } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleGuildCreate(guild: Guild) {
try {
if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id);
} catch (e) {
console.error(e);
}
import { Guild } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleGuildCreate(guild: Guild) {
try {
if (!await Database.getGuildSettings(guild.id)) await Database.putGuild(guild.id);
} catch (e) {
console.error(e);
}
}
+22 -22
View File
@@ -1,23 +1,23 @@
import { GuildMember, GuildTextBasedChannel } from 'discord.js';
import { Database } from '../shared/Database';
import { getE621Alts } from '../utils';
export async function handleMemberJoin(member: GuildMember) {
const guildSettings = await Database.getGuildSettings(member.guild.id);
if (guildSettings?.new_member_channel_id) {
const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel;
if (channel) {
const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`;
channel.send(content).catch(console.error);
if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) {
const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel;
if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error);
}
}
}
import { GuildMember, GuildTextBasedChannel } from 'discord.js';
import { Database } from '../shared/Database';
import { getE621Alts } from '../utils';
export async function handleMemberJoin(member: GuildMember) {
const guildSettings = await Database.getGuildSettings(member.guild.id);
if (guildSettings?.new_member_channel_id) {
const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel;
if (channel) {
const content = `${member.toString()}'s (${member.id}) e621 and discord account(s):\n${await getE621Alts(member.id, member.guild)}`;
channel.send(content).catch(console.error);
if (guildSettings.moderator_channel_id && content.includes('[BANNED]')) {
const modChannel = await member.guild.channels.fetch(guildSettings.moderator_channel_id) as GuildTextBasedChannel;
if (modChannel) modChannel.send(`Member joined with banned alts:\n${content}`).catch(console.error);
}
}
}
}
+384 -384
View File
@@ -1,385 +1,385 @@
import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { E621Post } from '../types';
import { ALLOWED_MIMETYPES, artistIDRegex, blipIDRegex, calculateMD5FromURL, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, forumTopicIDRegex, getE621Post, getE621PostByMd5, getPostUrl, isEdited, isInSpoilerTags, issueRegex, logDeletion, logEdit, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '../utils';
export type Message<InGuild extends boolean = boolean> = OmitPartialGroupDMChannel<DiscordMessage<InGuild>>;
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.
const postRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+posts/+([0-9]+)', 'gi');
const postShareRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+p/+([a-z0-9]+)', 'gi');
const imageRegex = new RegExp('!?https?://(?:.*@)?static[0-9]*\\.(?:e621|e926)\\.net/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi');
const postRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+posts/+([0-9]+)', 'gi');
const imageRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi');
const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi');
const regexTesters = [
{ runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) },
{
runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => {
return parseInt(idString, 32);
})
},
{ runInDev: false, regex: imageRegex, handler: imageHandler },
{ runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) },
{ runInDev: true, regex: imageRegex_DEV, handler: imageHandler },
{ runInDev: true, regex: postIDRegex, handler: postIdHandler },
{ runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') },
{ runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') },
{ runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') },
{ runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') },
{ runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') },
{ runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') },
{ runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') },
{ runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') },
{ runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') },
{ runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') },
{ runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler },
{ runInDev: true, regex: searchLinkRegex, handler: searchHandler },
{ runInDev: true, regex: prRegex, handler: githubPullRequestHandler },
{ runInDev: true, regex: issueRegex, handler: githubIssueHandler },
];
const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i;
export async function handleMessageCreate(message: Message) {
if (message.author.bot) return;
if (message.inGuild()) await Database.putMessage(message);
const responses: string[] = [];
for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(message.content);
test.regex.lastIndex = 0;
if (hasMatches) {
const matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = test.regex.exec(message.content)) != null) {
matches.push(match);
}
test.regex.lastIndex = 0;
const response = await test.handler(message, matches.filter(uniqueRegexMatches));
if (response === false) return;
if (response !== true) responses.push(response as string);
}
}
for (const attachment of message.attachments.values()) {
const match = md5Regex.exec(attachment.name);
md5Regex.lastIndex = 0;
const md5s: string[] = [];
if (match) md5s.push(match[1]);
else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) {
const md5Data = await calculateMD5FromURL(attachment.url);
if (!md5Data) continue;
md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5);
}
if (md5s.length == 0) continue;
for (const md5 of md5s) {
const post = await getE621PostByMd5(md5);
if (post) {
if (await blacklistIfNecessary(message, [post])) return;
responses.push(`<${getPostUrl(post)}>`);
continue;
}
}
}
if (responses.length > 0) {
await message.reply(responses.join('\n'));
}
}
export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) {
if (newMessage.author.bot) return;
const loggedMessage = await Database.getMessageWithRetry(newMessage.id);
if (!loggedMessage) {
if (newMessage.inGuild()) await Database.putMessage(newMessage);
return;
}
if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) {
await Database.putMessage(newMessage);
await logEdit(loggedMessage, newMessage);
}
if (loggedMessage.content == newMessage.content) return;
const responses: string[] = [];
for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(newMessage.content);
test.regex.lastIndex = 0;
if (hasMatches) {
const oldMatches: RegExpExecArray[] = [];
const newMatches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = test.regex.exec(newMessage.content)) != null) {
newMatches.push(match);
}
test.regex.lastIndex = 0;
while ((match = test.regex.exec(loggedMessage.content)) != null) {
oldMatches.push(match);
}
test.regex.lastIndex = 0;
const properMatches: RegExpExecArray[] = [];
for (const newMatch of newMatches) {
if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch);
}
if (properMatches.length == 0) continue;
const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches));
if (response === false) return;
if (response !== true) responses.push(response as string);
}
}
if (responses.length > 0) {
await newMessage.reply(responses.join('\n'));
}
}
export async function handleMessageDelete(message: Message | PartialMessage) {
const loggedMessage = await Database.getMessageWithRetry(message.id);
if (!loggedMessage) return;
if (message.inGuild()) await logDeletion(loggedMessage, message);
}
export async function handleBulkMessageDelete(messages: ReadonlyCollection<string, Message | Partial>, channel: GuildTextBasedChannel) {
for (const message of messages.values()) {
await handleMessageDelete(message);
}
}
async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> {
const blacklistedIds: number[] = [];
const channel = await message.channel.fetch() as GuildTextBasedChannel;
const isStaffChannel = await channelIsInStaffCategory(channel);
for (const post of posts) {
if (spoilerOrBlacklist(post).action == PostAction.Blacklist) {
blacklistedIds.push(post.id);
}
}
if (blacklistedIds.length == 0) return false;
await message.delete();
if (channel.parentId && isStaffChannel) {
await message.channel.send({
content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to ${blacklistedIds.length == 1 ? `post ${blacklistedIds[0]}` : `posts \`${blacklistedIds.join('`, `')}\``}. See rule #5.b for more details.`,
allowedMentions: {
users: [message.author.id]
}
});
} else {
await message.channel.send({
content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to young/cub content. See rule #5.b for more details.`,
allowedMentions: {
users: [message.author.id]
}
});
}
return true;
}
async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: { post: E621Post, spoilered: boolean }[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621Post(match[1]);
if (post) posts.push({
spoilered: isInSpoilerTags(message.content, match.index),
post
});
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const sfw = await channelIsSafe(message.channel as GuildBasedChannel);
const content = posts.map((postData) => {
if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`;
const shouldSpoiler = spoilerOrBlacklist(postData.post);
if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`;
return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post);
}).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: E621Post[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621Post(transform ? transform(match[1]) : match[1]);
if (post) posts.push(post);
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts)) return false;
return true;
}
async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: E621Post[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621PostByMd5(match[1]);
if (post) posts.push(post);
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts)) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
import { Message as DiscordMessage, GuildBasedChannel, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection, spoiler } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { E621Post } from '../types';
import { ALLOWED_MIMETYPES, artistIDRegex, blipIDRegex, calculateMD5FromURL, channelIgnoresLinks, channelIsInStaffCategory, channelIsSafe, commentIDRegex, forumTopicIDRegex, getE621Post, getE621PostByMd5, getPostUrl, isEdited, isInSpoilerTags, issueRegex, logDeletion, logEdit, poolIDRegex, PostAction, postIDRegex, prRegex, recordIDRegex, searchLinkRegex, setIDRegex, spoilerOrBlacklist, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from '../utils';
export type Message<InGuild extends boolean = boolean> = OmitPartialGroupDMChannel<DiscordMessage<InGuild>>;
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.
const postRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+posts/+([0-9]+)', 'gi');
const postShareRegex = new RegExp('!?https?://(?:.*@)?(?:e621|e926)\\.net/+p/+([a-z0-9]+)', 'gi');
const imageRegex = new RegExp('!?https?://(?:.*@)?static[0-9]*\\.(?:e621|e926)\\.net/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi');
const postRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+posts/+([0-9]+)', 'gi');
const imageRegex_DEV = new RegExp('!?https?://(?:.*@)?localhost:3000/+data/+(?:sample/+|preview/+|)[\\da-f]{2}/+[\\da-f]{2}/+([\\da-f]{32})\\.[\\da-z]+', 'gi');
const md5Regex = new RegExp('^([a-f0-9]{32}).(?:png|apng|jpg|jpeg|gif|webm|mp4)$', 'gi');
const regexTesters = [
{ runInDev: false, regex: postRegex, handler: postHandler.bind(null, null) },
{
runInDev: false, regex: postShareRegex, handler: postHandler.bind(null, (idString: string) => {
return parseInt(idString, 32);
})
},
{ runInDev: false, regex: imageRegex, handler: imageHandler },
{ runInDev: true, regex: postRegex_DEV, handler: postHandler.bind(null, null) },
{ runInDev: true, regex: imageRegex_DEV, handler: imageHandler },
{ runInDev: true, regex: postIDRegex, handler: postIdHandler },
{ runInDev: true, regex: userIDRegex, handler: idHandler.bind(null, 'users') },
{ runInDev: true, regex: forumTopicIDRegex, handler: idHandler.bind(null, 'forum_topics') },
{ runInDev: true, regex: commentIDRegex, handler: idHandler.bind(null, 'comments') },
{ runInDev: true, regex: blipIDRegex, handler: idHandler.bind(null, 'blips') },
{ runInDev: true, regex: poolIDRegex, handler: idHandler.bind(null, 'pools') },
{ runInDev: true, regex: setIDRegex, handler: idHandler.bind(null, 'post_sets') },
{ runInDev: true, regex: takedownIDRegex, handler: idHandler.bind(null, 'takedowns') },
{ runInDev: true, regex: recordIDRegex, handler: idHandler.bind(null, 'user_feedbacks') },
{ runInDev: true, regex: ticketIDRegex, handler: idHandler.bind(null, 'tickets') },
{ runInDev: true, regex: artistIDRegex, handler: idHandler.bind(null, 'artists') },
{ runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler },
{ runInDev: true, regex: searchLinkRegex, handler: searchHandler },
{ runInDev: true, regex: prRegex, handler: githubPullRequestHandler },
{ runInDev: true, regex: issueRegex, handler: githubIssueHandler },
];
const uniqueRegexMatches = (g, i, a) => a.findIndex(v => v[1] == g[1]) == i;
export async function handleMessageCreate(message: Message) {
if (message.author.bot) return;
if (message.inGuild()) await Database.putMessage(message);
const responses: string[] = [];
for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(message.content);
test.regex.lastIndex = 0;
if (hasMatches) {
const matches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = test.regex.exec(message.content)) != null) {
matches.push(match);
}
test.regex.lastIndex = 0;
const response = await test.handler(message, matches.filter(uniqueRegexMatches));
if (response === false) return;
if (response !== true) responses.push(response as string);
}
}
for (const attachment of message.attachments.values()) {
const match = md5Regex.exec(attachment.name);
md5Regex.lastIndex = 0;
const md5s: string[] = [];
if (match) md5s.push(match[1]);
else if (ALLOWED_MIMETYPES.includes(attachment.contentType!)) {
const md5Data = await calculateMD5FromURL(attachment.url);
if (!md5Data) continue;
md5s.push(md5Data.correctedFileMD5, md5Data.originalFileMD5);
}
if (md5s.length == 0) continue;
for (const md5 of md5s) {
const post = await getE621PostByMd5(md5);
if (post) {
if (await blacklistIfNecessary(message, [post])) return;
responses.push(`<${getPostUrl(post)}>`);
continue;
}
}
}
if (responses.length > 0) {
await message.reply(responses.join('\n'));
}
}
export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) {
if (newMessage.author.bot) return;
const loggedMessage = await Database.getMessageWithRetry(newMessage.id);
if (!loggedMessage) {
if (newMessage.inGuild()) await Database.putMessage(newMessage);
return;
}
if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) {
await Database.putMessage(newMessage);
await logEdit(loggedMessage, newMessage);
}
if (loggedMessage.content == newMessage.content) return;
const responses: string[] = [];
for (const test of regexTesters) {
if (config.DEV_MODE && !test.runInDev) continue;
const hasMatches = test.regex.test(newMessage.content);
test.regex.lastIndex = 0;
if (hasMatches) {
const oldMatches: RegExpExecArray[] = [];
const newMatches: RegExpExecArray[] = [];
let match: RegExpExecArray | null;
while ((match = test.regex.exec(newMessage.content)) != null) {
newMatches.push(match);
}
test.regex.lastIndex = 0;
while ((match = test.regex.exec(loggedMessage.content)) != null) {
oldMatches.push(match);
}
test.regex.lastIndex = 0;
const properMatches: RegExpExecArray[] = [];
for (const newMatch of newMatches) {
if (!oldMatches.find(m => m[1] == newMatch[1])) properMatches.push(newMatch);
}
if (properMatches.length == 0) continue;
const response = await test.handler(newMessage, properMatches.filter(uniqueRegexMatches));
if (response === false) return;
if (response !== true) responses.push(response as string);
}
}
if (responses.length > 0) {
await newMessage.reply(responses.join('\n'));
}
}
export async function handleMessageDelete(message: Message | PartialMessage) {
const loggedMessage = await Database.getMessageWithRetry(message.id);
if (!loggedMessage) return;
if (message.inGuild()) await logDeletion(loggedMessage, message);
}
export async function handleBulkMessageDelete(messages: ReadonlyCollection<string, Message | Partial>, channel: GuildTextBasedChannel) {
for (const message of messages.values()) {
await handleMessageDelete(message);
}
}
async function searchHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `<${config.E621_BASE_URL}/wiki_pages/${group[1].split('#').map(t => encodeURIComponent(t)).join('#')}>\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> {
const blacklistedIds: number[] = [];
const channel = await message.channel.fetch() as GuildTextBasedChannel;
const isStaffChannel = await channelIsInStaffCategory(channel);
for (const post of posts) {
if (spoilerOrBlacklist(post).action == PostAction.Blacklist) {
blacklistedIds.push(post.id);
}
}
if (blacklistedIds.length == 0) return false;
await message.delete();
if (channel.parentId && isStaffChannel) {
await message.channel.send({
content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to ${blacklistedIds.length == 1 ? `post ${blacklistedIds[0]}` : `posts \`${blacklistedIds.join('`, `')}\``}. See rule #5.b for more details.`,
allowedMentions: {
users: [message.author.id]
}
});
} else {
await message.channel.send({
content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to young/cub content. See rule #5.b for more details.`,
allowedMentions: {
users: [message.author.id]
}
});
}
return true;
}
async function postIdHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: { post: E621Post, spoilered: boolean }[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621Post(match[1]);
if (post) posts.push({
spoilered: isInSpoilerTags(message.content, match.index),
post
});
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts.map(p => p.post))) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const sfw = await channelIsSafe(message.channel as GuildBasedChannel);
const content = posts.map((postData) => {
if (sfw && postData.post.rating != 's') return ` [NSFW] <${getPostUrl(postData.post)}>`;
const shouldSpoiler = spoilerOrBlacklist(postData.post);
if (shouldSpoiler.action == PostAction.Spoiler) return `${spoiler(getPostUrl(postData.post))} (${shouldSpoiler.tag})`;
return postData.spoilered ? spoiler(getPostUrl(postData.post)) : getPostUrl(postData.post);
}).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function idHandler(path: string, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const content = matchedGroups.map(m => `${config.E621_BASE_URL}/${path}/${m[1]}`).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function postHandler(transform: ((idString: string) => number) | null, message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: E621Post[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621Post(transform ? transform(match[1]) : match[1]);
if (post) posts.push(post);
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts)) return false;
return true;
}
async function imageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
if (!message.guildId) return true;
const posts: E621Post[] = [];
for (const match of matchedGroups) {
try {
const post = await getE621PostByMd5(match[1]);
if (post) posts.push(post);
} catch (e) {
console.error(e);
}
}
if (await blacklistIfNecessary(message, posts)) return false;
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
const content = posts.map(post => `<${getPostUrl(post)}>`).join('\n');
if (content.trim().length > 0) return content.trim();
return true;
}
async function githubPullRequestHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/pull/${group[1]}\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
async function githubIssueHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<string | boolean> {
const skip = await channelIgnoresLinks(message.channel as GuildBasedChannel);
if (skip) return true;
let content = '';
for (const group of matchedGroups) {
content += `${config.GIT_REPO_BASE_URL}/issues/${group[1]}\n`;
}
if (content.trim().length > 0) return content.trim();
return true;
}
+10 -10
View File
@@ -1,11 +1,11 @@
import { AnyThreadChannel } from 'discord.js';
export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) {
try {
await thread.join();
} catch (e) {
console.error('Failed to join thread:');
console.error(e);
}
import { AnyThreadChannel } from 'discord.js';
export async function handleThreadCreate(thread: AnyThreadChannel, newlyCreated: boolean) {
try {
await thread.join();
} catch (e) {
console.error('Failed to join thread:');
console.error(e);
}
}
+45 -45
View File
@@ -1,46 +1,46 @@
import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
// The logChannel declaration being inside is purposeful, as this event is fired a lot for users talking.
if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!);
} else if (oldState.channelId == null && newState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendJoinMessage(logChannel, newState.member!, newState.channel!);
} else if (newState.channelId == null && oldState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendLeftMessage(logChannel, newState.member!, oldState.channel!);
}
}
async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} joined ${voiceChannel} at ${time()}`);
}
async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} left ${voiceChannel} at ${time()}`);
}
async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) {
await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`);
}
async function getVoiceLogsChannel(guild: Guild): Promise<GuildTextBasedChannel | undefined> {
const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.voice_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.voice_logs_channel_id);
if (!channel || !channel.isSendable()) return;
return channel as GuildTextBasedChannel;
import { Guild, GuildMember, GuildTextBasedChannel, time, VoiceBasedChannel, VoiceState } from 'discord.js';
import { Database } from '../shared/Database';
export async function handleVoiceStateUpdate(oldState: VoiceState, newState: VoiceState) {
// The logChannel declaration being inside is purposeful, as this event is fired a lot for users talking.
if (newState.channelId != null && oldState.channelId != null && newState.channelId != oldState.channelId) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendMovedMessage(logChannel, newState.member!, oldState.channel!, newState.channel!);
} else if (oldState.channelId == null && newState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendJoinMessage(logChannel, newState.member!, newState.channel!);
} else if (newState.channelId == null && oldState.channelId != null) {
const logChannel = await getVoiceLogsChannel(newState.guild);
if (!logChannel) return;
await sendLeftMessage(logChannel, newState.member!, oldState.channel!);
}
}
async function sendJoinMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} joined ${voiceChannel} at ${time()}`);
}
async function sendLeftMessage(channel: GuildTextBasedChannel, member: GuildMember, voiceChannel: VoiceBasedChannel) {
await channel.send(`${member} left ${voiceChannel} at ${time()}`);
}
async function sendMovedMessage(channel: GuildTextBasedChannel, member: GuildMember, oldVoiceChannel: VoiceBasedChannel, newVoiceChannel: VoiceBasedChannel) {
await channel.send(`${member} moved from ${oldVoiceChannel} to ${newVoiceChannel} at ${time()}`);
}
async function getVoiceLogsChannel(guild: Guild): Promise<GuildTextBasedChannel | undefined> {
const settings = await Database.getGuildSettings(guild.id);
if (!settings || !settings.voice_logs_channel_id) return;
const channel = await guild.channels.fetch(settings.voice_logs_channel_id);
if (!channel || !channel.isSendable()) return;
return channel as GuildTextBasedChannel;
}
+7 -7
View File
@@ -1,7 +1,7 @@
export * from './handle-audit-log-create';
export * from './handle-ban-remove';
export * from './handle-guild-create';
export * from './handle-member-join';
export * from './handle-message';
export * from './handle-thread-create';
export * from './handle-voice-state-update';
export * from './handle-audit-log-create';
export * from './handle-ban-remove';
export * from './handle-guild-create';
export * from './handle-member-join';
export * from './handle-message';
export * from './handle-thread-create';
export * from './handle-voice-state-update';
+164 -164
View File
@@ -1,165 +1,165 @@
import { Client as DiscordClient, GatewayIntentBits, MessageFlags, Partials } from 'discord.js';
import { config } from './config';
import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events';
import { Database } from './shared/Database';
import { openRedisClient } from './shared/RedisClient';
import { Handler } from './types';
import { checkExpiredBans, closeOldTickets, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils';
import { initializeWebserver } from './webserver';
let ready = false;
console.log('Starting...');
const client = new DiscordClient({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent
],
partials: [Partials.Message, Partials.GuildMember, Partials.User, Partials.Channel],
rest: { timeout: 30000 },
allowedMentions: {
parse: [],
repliedUser: false
}
});
const commands: Handler[] = [];
const contextMenus: Handler[] = [];
const buttons: Handler[] = [];
const modals: Handler[] = [];
const menus: Handler[] = [];
loadHandlersFrom('commands', commands);
loadHandlersFrom('context-menus', contextMenus);
loadHandlersFrom('buttons', buttons);
loadHandlersFrom('modals', modals);
loadHandlersFrom('menus', menus);
// Due to their reliance on each other, these two events (interactionCreate, and ready) have to stay here.
// Alternatively, they can move to another single file. Or use static classes.
client.on('interactionCreate', async (interaction) => {
if (!ready) {
if (
interaction.isChatInputCommand()
|| interaction.isContextMenuCommand()
|| interaction.isButton()
|| interaction.isModalSubmit()
|| interaction.isAnySelectMenu()
)
interaction.reply({
content: 'Bot is still starting up. Please wait a few seconds.',
flags: [MessageFlags.Ephemeral],
});
return;
}
if (interaction.isChatInputCommand()) {
// Handle chat
for (const command of commands) {
if (interaction.commandName == command.name) {
command.handler(client, interaction);
return;
}
}
} else if (interaction.isContextMenuCommand()) {
// Handle context menu commands.
for (const command of contextMenus) {
if (interaction.commandName == command.name) {
command.handler(client, interaction);
return;
}
}
} else if (interaction.isAutocomplete()) {
// Handle autocomplete requests.
for (const command of commands) {
if (interaction.commandName == command.name) {
if (command.autoComplete) {
command
.autoComplete(client, interaction)
.catch(e => console.error(e));
}
return;
}
}
} else if (interaction.isButton()) {
// Handle button presses.
const id = interaction.customId.split('_')[0];
for (const button of buttons) {
if (id == button.name) {
button.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
} else if (interaction.isModalSubmit()) {
// Handle modal submissions.
const id = interaction.customId.split('_')[0];
for (const modal of modals) {
if (id == modal.name) {
modal.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
} else if (interaction.isAnySelectMenu()) {
// Handle menu selections.
const id = interaction.customId.split('_')[0];
for (const menu of menus) {
if (id == menu.name) {
menu.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
}
});
client.on('clientReady', async () => {
console.log(`Logged in as ${client.user!.tag}!`);
await refreshCommands(client);
await initIfNecessary(client, commands);
await initIfNecessary(client, buttons);
await initIfNecessary(client, modals);
await initIfNecessary(client, menus);
await Database.open('./data/discord-main.db');
await openRedisClient(config.REDIS_URL!, client);
await initializeWebserver(client);
// Check for expired bans every 5 minutes
checkExpiredBans(client);
setInterval(checkExpiredBans.bind(null, client), 300000);
// Close old tickets every hour
closeOldTickets(client);
setInterval(closeOldTickets.bind(null, client), 3.6e+6);
ready = true;
console.log('Ready');
});
client.on('guildAuditLogEntryCreate', handleAuditLogCreate);
client.on('guildBanRemove', handleBanRemove);
client.on('guildCreate', handleGuildCreate);
client.on('guildMemberAdd', handleMemberJoin);
client.on('messageCreate', handleMessageCreate);
client.on('messageDelete', handleMessageDelete);
client.on('messageDeleteBulk', handleBulkMessageDelete);
client.on('messageUpdate', handleMessageUpdate);
client.on('threadCreate', handleThreadCreate);
client.on('voiceStateUpdate', handleVoiceStateUpdate);
client.on('error', console.error);
process.on('uncaughtException', console.error);
import { Client as DiscordClient, GatewayIntentBits, MessageFlags, Partials } from 'discord.js';
import { config } from './config';
import { handleAuditLogCreate, handleBanRemove, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events';
import { Database } from './shared/Database';
import { openRedisClient } from './shared/RedisClient';
import { Handler } from './types';
import { checkExpiredBans, closeOldTickets, initIfNecessary, loadHandlersFrom, refreshCommands } from './utils';
import { initializeWebserver } from './webserver';
let ready = false;
console.log('Starting...');
const client = new DiscordClient({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildModeration,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent
],
partials: [Partials.Message, Partials.GuildMember, Partials.User, Partials.Channel],
rest: { timeout: 30000 },
allowedMentions: {
parse: [],
repliedUser: false
}
});
const commands: Handler[] = [];
const contextMenus: Handler[] = [];
const buttons: Handler[] = [];
const modals: Handler[] = [];
const menus: Handler[] = [];
loadHandlersFrom('commands', commands);
loadHandlersFrom('context-menus', contextMenus);
loadHandlersFrom('buttons', buttons);
loadHandlersFrom('modals', modals);
loadHandlersFrom('menus', menus);
// Due to their reliance on each other, these two events (interactionCreate, and ready) have to stay here.
// Alternatively, they can move to another single file. Or use static classes.
client.on('interactionCreate', async (interaction) => {
if (!ready) {
if (
interaction.isChatInputCommand()
|| interaction.isContextMenuCommand()
|| interaction.isButton()
|| interaction.isModalSubmit()
|| interaction.isAnySelectMenu()
)
interaction.reply({
content: 'Bot is still starting up. Please wait a few seconds.',
flags: [MessageFlags.Ephemeral],
});
return;
}
if (interaction.isChatInputCommand()) {
// Handle chat
for (const command of commands) {
if (interaction.commandName == command.name) {
command.handler(client, interaction);
return;
}
}
} else if (interaction.isContextMenuCommand()) {
// Handle context menu commands.
for (const command of contextMenus) {
if (interaction.commandName == command.name) {
command.handler(client, interaction);
return;
}
}
} else if (interaction.isAutocomplete()) {
// Handle autocomplete requests.
for (const command of commands) {
if (interaction.commandName == command.name) {
if (command.autoComplete) {
command
.autoComplete(client, interaction)
.catch(e => console.error(e));
}
return;
}
}
} else if (interaction.isButton()) {
// Handle button presses.
const id = interaction.customId.split('_')[0];
for (const button of buttons) {
if (id == button.name) {
button.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
} else if (interaction.isModalSubmit()) {
// Handle modal submissions.
const id = interaction.customId.split('_')[0];
for (const modal of modals) {
if (id == modal.name) {
modal.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
} else if (interaction.isAnySelectMenu()) {
// Handle menu selections.
const id = interaction.customId.split('_')[0];
for (const menu of menus) {
if (id == menu.name) {
menu.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
}
});
client.on('clientReady', async () => {
console.log(`Logged in as ${client.user!.tag}!`);
await refreshCommands(client);
await initIfNecessary(client, commands);
await initIfNecessary(client, buttons);
await initIfNecessary(client, modals);
await initIfNecessary(client, menus);
await Database.open('./data/discord-main.db');
await openRedisClient(config.REDIS_URL!, client);
await initializeWebserver(client);
// Check for expired bans every 5 minutes
checkExpiredBans(client);
setInterval(checkExpiredBans.bind(null, client), 300000);
// Close old tickets every hour
closeOldTickets(client);
setInterval(closeOldTickets.bind(null, client), 3.6e+6);
ready = true;
console.log('Ready');
});
client.on('guildAuditLogEntryCreate', handleAuditLogCreate);
client.on('guildBanRemove', handleBanRemove);
client.on('guildCreate', handleGuildCreate);
client.on('guildMemberAdd', handleMemberJoin);
client.on('messageCreate', handleMessageCreate);
client.on('messageDelete', handleMessageDelete);
client.on('messageDeleteBulk', handleBulkMessageDelete);
client.on('messageUpdate', handleMessageUpdate);
client.on('threadCreate', handleThreadCreate);
client.on('voiceStateUpdate', handleVoiceStateUpdate);
client.on('error', console.error);
process.on('uncaughtException', console.error);
client.login(config.DISCORD_TOKEN);
+48 -48
View File
@@ -1,49 +1,49 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'add-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction);
const name = interaction.fields.getTextInputValue('name');
const content = interaction.fields.getTextInputValue('content');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name);
if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`);
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: name,
inline: true
},
{
name: 'Content',
value: content,
inline: true
}
]
});
await Database.addToKnowledgebase(interaction.guild.id, name, content);
return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`);
}
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'add-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction);
const name = interaction.fields.getTextInputValue('name');
const content = interaction.fields.getTextInputValue('content');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
const existingItem = await Database.getFromKnowledgebaseByName(interaction.guild.id, name);
if (existingItem) return interaction.editReply(`Knowledgebase item ${name} already exists!`);
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: name,
inline: true
},
{
name: 'Content',
value: content,
inline: true
}
]
});
await Database.addToKnowledgebase(interaction.guild.id, name, content);
return interaction.editReply(`Entry \`${name}\` added to knowledgebase.`);
}
};
+39 -39
View File
@@ -1,40 +1,40 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { logCustomEvent } from '../utils';
export default {
name: 'add-note-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
const message = interaction.fields.getTextInputValue('note-message');
const user = await client.users.fetch(id);
logCustomEvent(interaction.guild!, {
title: 'Note Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: message,
inline: true
}
]
});
await Database.putNote(id, message, interaction.user.id);
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' });
}
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { logCustomEvent } from '../utils';
export default {
name: 'add-note-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
const message = interaction.fields.getTextInputValue('note-message');
const user = await client.users.fetch(id);
logCustomEvent(interaction.guild!, {
title: 'Note Added',
description: null,
color: 0x00FF00,
timestamp: new Date(),
fields: [
{
name: 'Moderator',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'User',
value: `<@${id}>\n${user.username}`,
inline: true
},
{
name: 'Note',
value: message,
inline: true
}
]
});
await Database.putNote(id, message, interaction.user.id);
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Note added' });
}
};
+53 -53
View File
@@ -1,54 +1,54 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'edit-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction);
const id = parseInt(idString);
const content = interaction.fields.getTextInputValue('content');
const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Edited',
description: null,
color: 0xFFFF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: existingItem.name,
inline: true
},
{
name: 'Old Content',
value: existingItem.content,
inline: true
},
{
name: 'New Content',
value: content,
inline: true
}
]
});
await Database.editKnowledgebaseItem(id, content);
return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`);
}
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { deferInteraction, logCustomEvent } from '../utils';
export default {
name: 'edit-knowledgebase-item-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, idString: string) {
if (!interaction.guild) return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Must be ran in guild.' });
await deferInteraction(interaction);
const id = parseInt(idString);
const content = interaction.fields.getTextInputValue('content');
const existingItem = await Database.getFromKnowledgebase(id);
if (!existingItem) return interaction.editReply('Knowledgebase item not found.');
if (content.length > 2000) return interaction.editReply('Content cannot be over 2000 characters long.');
logCustomEvent(interaction.guild!, {
title: 'Knowledgebase Item Edited',
description: null,
color: 0xFFFF00,
timestamp: new Date(),
fields: [
{
name: 'User',
value: `<@${interaction.user.id}>\n${interaction.user.username}`,
inline: true
},
{
name: 'Name',
value: existingItem.name,
inline: true
},
{
name: 'Old Content',
value: existingItem.content,
inline: true
},
{
name: 'New Content',
value: content,
inline: true
}
]
});
await Database.editKnowledgebaseItem(id, content);
return interaction.editReply(`Edited knowledgebase entry \`${existingItem.name}\`.`);
}
};
+30 -30
View File
@@ -1,31 +1,31 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils';
const warning = '\n\n\nLeaving this thread without acknowledgement may result in punishment. Staff will close the thread when they deem your response acceptable.';
export default {
name: 'open-mod-ticket',
handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) {
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(userId);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' });
const title = interaction.fields.getTextInputValue('title') ? interaction.fields.getTextInputValue('title') : `Mod Ticket For ${member.displayName}`;
const reason = interaction.fields.getTextInputValue('initial-message') + warning;
const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes';
const membersToAdd = [userId];
if (autoJoin) membersToAdd.push(interaction.user.id);
const thread = await createPrivateHelpTicketThread(client, guild, null, reason, title, membersToAdd);
if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Mod ticket created: ${thread}.${!autoJoin ? "You've selected not to auto join the thread. You will not be notified of messages sent there. You will have to check the thread periodically for user response." : ''}` });
else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' });
}
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils';
const warning = '\n\n\nLeaving this thread without acknowledgement may result in punishment. Staff will close the thread when they deem your response acceptable.';
export default {
name: 'open-mod-ticket',
handler: async function (client: Client, interaction: ModalSubmitInteraction, userId: string) {
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(userId);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' });
const title = interaction.fields.getTextInputValue('title') ? interaction.fields.getTextInputValue('title') : `Mod Ticket For ${member.displayName}`;
const reason = interaction.fields.getTextInputValue('initial-message') + warning;
const autoJoin = interaction.fields.getStringSelectValues('auto-join-thread')[0] == 'yes';
const membersToAdd = [userId];
if (autoJoin) membersToAdd.push(interaction.user.id);
const thread = await createPrivateHelpTicketThread(client, guild, null, reason, title, membersToAdd);
if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Mod ticket created: ${thread}.${!autoJoin ? "You've selected not to auto join the thread. You will not be notified of messages sent there. You will have to check the thread periodically for user response." : ''}` });
else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a developer.' });
}
};
+22 -22
View File
@@ -1,23 +1,23 @@
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils';
export default {
name: 'open-ticket-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) {
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' });
const reason = interaction.fields.getTextInputValue('ticket-message');
const thread = await createPrivateHelpTicketThread(client, guild, member, reason);
if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` });
else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' });
}
import { Client, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { createPrivateHelpTicketThread } from '../utils';
export default {
name: 'open-ticket-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction) {
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id);
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_role_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' });
const reason = interaction.fields.getTextInputValue('ticket-message');
const thread = await createPrivateHelpTicketThread(client, guild, member, reason);
if (thread) interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` });
else interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to create ticket. Please report this to a staff member.' });
}
};
+117 -117
View File
@@ -1,118 +1,118 @@
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils';
export default {
name: 'report-message',
handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id);
const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel;
const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId);
const additionalInfo = interaction.fields.getTextInputValue('additional-info');
const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes';
if (!reportedMessageChannel || !reportedMessage)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' });
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.moderator_channel_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' });
const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id);
if (!reportsChannel || !reportsChannel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' });
const embed = new EmbedBuilder()
.setTitle('New Message Report!')
.setColor(0xFF0000)
.addFields(
{
name: 'Message',
value: reportedMessage.url,
inline: false
},
{
name: 'Message Author',
value: reportedMessage.author.toString(),
inline: false
},
{
name: 'Reporter',
value: member.toString(),
inline: false
});
if (additionalInfo) {
embed.addFields(
{
name: 'Additional Information',
value: additionalInfo,
inline: false
}
);
}
let replyContent = "Thanks for making a report! I've notified the moderators who can take further action.";
const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id);
if (wantsTicketButCantOpen) {
embed.addFields(
{
name: 'User Requested Private Ticket',
value: 'The user requested a private ticket be opened, but already has an open ticket. If additional information is needed, press the button below to open a ticket with the user.',
inline: false
}
);
replyContent += " Could not open private help ticket since you already have one open. Staff have been notified that you'd like to have a ticket opened, and can make one for you if they deem it necessary.";
}
const openTicketButton = new ButtonBuilder()
.setCustomId('open-ticket-for-reported-message')
.setLabel('Open Private Ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(openTicketButton);
const reportMessage = await reportsChannel.send({
embeds: [embed],
components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row]
});
await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] });
if (createPrivateHelpTicket && !wantsTicketButCantOpen) {
if (!guildSettings.private_help_channel_id) {
replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.';
} else {
const thread = await createPrivateHelpTicketThread(client, guild, member, `Ticket created with message report (${reportMessage.url}). ${member}, use this channel to talk with staff privately about the reported message (${reportedMessage.url}). ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`);
if (thread) {
replyContent += ` Private help ticket created: ${thread}.`;
embed.addFields({
name: 'Private Help Ticket',
value: thread.url,
inline: false
});
await reportMessage.edit({ embeds: [embed] });
} else {
replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.';
await reportMessage.edit({
embeds: [embed],
components: [row]
});
}
}
}
await interaction.editReply(replyContent);
}
import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { canOpenPrivateHelpTicket, createPrivateHelpTicketThread } from '../utils';
export default {
name: 'report-message',
handler: async function (client: Client, interaction: ModalSubmitInteraction, channelId: string, messageId: string) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
const guild = await client.guilds.fetch(interaction.guildId!);
const member = await guild.members.fetch(interaction.user.id);
const reportedMessageChannel = await guild.channels.fetch(channelId) as GuildTextBasedChannel;
const reportedMessage = await reportedMessageChannel?.messages.fetch(messageId);
const additionalInfo = interaction.fields.getTextInputValue('additional-info');
const createPrivateHelpTicket = interaction.fields.getStringSelectValues('create-private-help-ticket')[0] == 'yes';
if (!reportedMessageChannel || !reportedMessage)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Failed to submit report. Please report this to a staff member.' });
const guildSettings = await Database.getGuildSettings(interaction.guildId!);
if (!guildSettings || !guildSettings.moderator_channel_id)
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' });
const reportsChannel = await interaction.guild!.channels.fetch(guildSettings.moderator_channel_id);
if (!reportsChannel || !reportsChannel.isSendable())
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Report channel missing. Unable to submit report.' });
const embed = new EmbedBuilder()
.setTitle('New Message Report!')
.setColor(0xFF0000)
.addFields(
{
name: 'Message',
value: reportedMessage.url,
inline: false
},
{
name: 'Message Author',
value: reportedMessage.author.toString(),
inline: false
},
{
name: 'Reporter',
value: member.toString(),
inline: false
});
if (additionalInfo) {
embed.addFields(
{
name: 'Additional Information',
value: additionalInfo,
inline: false
}
);
}
let replyContent = "Thanks for making a report! I've notified the moderators who can take further action.";
const wantsTicketButCantOpen = createPrivateHelpTicket && !await canOpenPrivateHelpTicket(member.id);
if (wantsTicketButCantOpen) {
embed.addFields(
{
name: 'User Requested Private Ticket',
value: 'The user requested a private ticket be opened, but already has an open ticket. If additional information is needed, press the button below to open a ticket with the user.',
inline: false
}
);
replyContent += " Could not open private help ticket since you already have one open. Staff have been notified that you'd like to have a ticket opened, and can make one for you if they deem it necessary.";
}
const openTicketButton = new ButtonBuilder()
.setCustomId('open-ticket-for-reported-message')
.setLabel('Open Private Ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(openTicketButton);
const reportMessage = await reportsChannel.send({
embeds: [embed],
components: createPrivateHelpTicket && !wantsTicketButCantOpen ? [] : [row]
});
await reportsChannel.send({ files: [new AttachmentBuilder(Buffer.from(reportedMessage.content), { name: 'message-content.txt' })] });
if (createPrivateHelpTicket && !wantsTicketButCantOpen) {
if (!guildSettings.private_help_channel_id) {
replyContent += ' Could not open private help ticket. Private help channel not set. Please report this to a staff member.';
} else {
const thread = await createPrivateHelpTicketThread(client, guild, member, `Ticket created with message report (${reportMessage.url}). ${member}, use this channel to talk with staff privately about the reported message (${reportedMessage.url}). ${additionalInfo ? `\n\nAdditional information provided in report:\n${additionalInfo.split('\n').map(c => `> ${c}`).join('\n')}` : ''}`);
if (thread) {
replyContent += ` Private help ticket created: ${thread}.`;
embed.addFields({
name: 'Private Help Ticket',
value: thread.url,
inline: false
});
await reportMessage.edit({ embeds: [embed] });
} else {
replyContent += ' There was an issue opening a private help ticket, please report this to a staff member.';
await reportMessage.edit({
embeds: [embed],
components: [row]
});
}
}
}
await interaction.editReply(replyContent);
}
};
+33 -33
View File
@@ -1,34 +1,34 @@
import { Client, ModalSubmitInteraction } from 'discord.js';
import { config } from '../config';
import { deferInteraction, syncName } from '../utils';
export default {
name: 'sync-name-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
await deferInteraction(interaction);
const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0);
console.log(e621Id);
if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number');
const member = await interaction.guild?.members.fetch(id);
if (!member) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, e621Id);
}
import { Client, ModalSubmitInteraction } from 'discord.js';
import { config } from '../config';
import { deferInteraction, syncName } from '../utils';
export default {
name: 'sync-name-modal',
handler: async function (client: Client, interaction: ModalSubmitInteraction, id: string) {
await deferInteraction(interaction);
const e621Id = Number(interaction.fields.getTextInputValue('id') ?? 0);
console.log(e621Id);
if (isNaN(e621Id)) return await interaction.editReply('Provided id is not a number');
const member = await interaction.guild?.members.fetch(id);
if (!member) {
return interaction.editReply('An error has occurred. Please try again later.');
}
const guild = await interaction.client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) {
return interaction.editReply('An error has occurred. Please try again later.');
}
if (!guild.members.me) {
return interaction.editReply('An error has occurred. Please try again later.');
}
await syncName(interaction, member, e621Id);
}
};
+483 -483
View File
@@ -1,484 +1,484 @@
import sqlite3 from 'sqlite3';
import { open, Database as SqliteDatabase } from 'sqlite';
import { serializeMessage, wait } from '../utils';
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types';
import { Message } from '../events';
const DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS discord_names (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
discord_id TEXT NOT NULL,
discord_username TEXT NOT NULL,
added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS settings (
guild_id TEXT PRIMARY KEY,
general_chat_id TEXT,
new_member_channel_id TEXT,
tickets_channel_id TEXT,
event_logs_channel_id TEXT,
discord_logs_channel_id TEXT,
audit_logs_channel_id TEXT,
voice_logs_channel_id TEXT,
admin_role_id TEXT,
private_help_role_id TEXT,
devwatch_role_id TEXT,
staff_categories TEXT,
safe_channels TEXT,
link_skip_channels TEXT,
github_release_channel TEXT,
moderator_channel_id TEXT,
private_help_channel_id TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY ON CONFLICT REPLACE,
author_id TEXT NOT NULL,
author_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
attachments TEXT NOT NULL,
stickers TEXT NOT NULL,
content TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id);
CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
message_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ticket_phrases (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
phrase TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
user_id TEXT,
reason TEXT,
mod_id TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id);
CREATE TABLE IF NOT EXISTS note_edits (
id INTEGER PRIMARY KEY,
note_id INTEGER,
mod_id TEXT,
previous_reason TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY(note_id) REFERENCES notes(id)
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY,
user_id TEXT,
expires INTEGER,
expires_at datetime,
full_ban INTEGER
);
CREATE TABLE IF NOT EXISTS github_user_mapping (
id INTEGER PRIMARY KEY,
discord_id TEXT,
github_username TEXT
);
CREATE TABLE IF NOT EXISTS knowledgebase (
id INTEGER PRIMARY KEY,
guild_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS private_help_tickets (
id INTEGER PRIMARY KEY,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
status INTEGER NOT NULL,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp);
`;
export const enum PrivateHelpTicketStatus {
OPEN = 0,
CLOSED = 1
}
export class Database {
private static db: SqliteDatabase;
static async open(file: string): Promise<void> {
if (Database.db) return;
Database.db = await open({
filename: file,
driver: sqlite3.Database
});
console.log('SQLite database opened');
await Database.ensure();
}
private static async ensure() {
await Database.db.exec(DB_SCHEMA);
console.log('SQLite database ensured');
}
// -- START WHOIS --
static async getE621Ids(discordId: string): Promise<number[]> {
const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId);
return ids.map(r => r.user_id);
}
static async getDiscordIds(e621Id: string | number): Promise<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;
const ids = await Database.db.all<{ discord_id: string }[]>('SELECT DISTINCT discord_id FROM discord_names WHERE user_id = ?', e621Id);
return ids.map(r => r.discord_id);
}
static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> {
const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(`
WITH RECURSIVE rec AS (
SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ?
UNION
SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec
LEFT OUTER JOIN discord_names d2 ON rec.discord_id = d2.discord_id
LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id
WHERE depth <= 5 AND rec.depth = depth
) SELECT DISTINCT user_id, discord_id FROM rec`, id, id);
return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id }));
}
static async putUser(id: number, user: { id: string, username: string }) {
await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username);
}
static async removeUser(id: number, discordId: string) {
await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId);
}
// -- END WHOIS --
// -- START SETTINGS --
static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> {
return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId);
}
static async putGuild(guildId: string) {
await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId);
}
static async setGuildGeneralChatId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildTicketsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildEventsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildDiscordLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildAuditLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildVoiceLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildNewMemberLogsChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildModeratorChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildAdminRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildPrivateHelperRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildDevWatchRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildGithubReleaseChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId);
}
static async setPrivateHelpChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId);
}
// Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues.
// But it does allow me to skip rewriting this a bunch.
static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> {
const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId);
if (!settings || !settings[setting]) return [];
return settings[setting].split(',');
}
static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) {
const values = await Database.getGuildArraySetting(setting, guildId);
if (values.indexOf(value) == -1) values.push(value);
const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
}
static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise<boolean> {
const values = await Database.getGuildArraySetting(setting, guildId);
const index = values.indexOf(value);
if (index == -1) return false;
values.splice(index, 1);
const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
return true;
}
// -- END SETTINGS --
// START MESSAGE LOGS --
static async putMessage(message: Message): Promise<boolean> {
try {
const serializedMessage = serializeMessage(message);
await Database.db.run(`
INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES
(:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content)
`, ...serializedMessage);
return true;
} catch (e) {
console.error(e);
return false;
}
}
static async getMessage(id: string): Promise<LoggedMessage | undefined> {
return await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
}
static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise<LoggedMessage | undefined> {
let tried = 0;
while (tried < retries) {
tried++;
const message = await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
if (message) return message;
await wait(delay);
}
}
// -- END MESSAGE LOGS --
// -- START TICKETS --
static async putTicket(ticketId: number, messageId: string) {
await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId);
}
static async removeTicket(ticketId: number) {
await Database.db.run('DELETE from tickets WHERE id = ?', ticketId);
}
static async getTicketMessageId(ticketId: number): Promise<string | undefined> {
const ticket = await Database.db.get<Pick<TicketMessage, 'message_id'>>('SELECT message_id FROM tickets WHERE id = ?', ticketId);
return ticket?.message_id;
}
static async putTicketPhrase(userId: string, phrase: string) {
await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase);
}
static async getTicketPhrase(id: number): Promise<TicketPhrase | undefined> {
return await Database.db.get<TicketPhrase>('SELECT * FROM ticket_phrases WHERE id = ?', id);
}
static async removeTicketPhrase(id: number) {
await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id);
}
static async removeAllTicketPhrasesFor(id: string): Promise<number> {
return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!;
}
static async getTicketPhrasesFor(userId: string): Promise<TicketPhrase[]> {
return await Database.db.all<TicketPhrase[]>('SELECT * from ticket_phrases WHERE user_id = ?', userId);
}
static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) {
await Database.db.each<TicketPhrase>('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => {
if (err) return console.error(err);
cb(ticketPhrase);
});
}
// -- END TICKETS --
// -- START NOTES --
static async putNote(userId: string, reason: string, modId: string) {
await Database.db.run('INSERT INTO notes(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId);
}
static async editNote(id: number, oldReason: string, newReason: string, modId: string) {
await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, modId, id);
await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason);
}
static async removeNote(id: number): Promise<boolean> {
const res = await Database.db.run('DELETE from notes WHERE id = ?', id);
return (res.changes ?? 0) > 0;
}
static async getNotes(userId: string): Promise<Note[]> {
return await Database.db.all<Note[]>('SELECT * from notes WHERE user_id = ?', userId);
}
// -- END NOTES --
// -- START BANS --
static async putBan(userId: string, expiresAt: Date | null, fullBan = false) {
await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan);
}
static async getBan(userId: string): Promise<Ban | undefined> {
return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId);
}
static async getExpiredBans(date: Date): Promise<Ban[]> {
return await Database.db.all<Ban[]>('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date);
}
static async pruneExpiredBans(date: Date) {
await Database.db.all<Ban[]>('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date);
}
static async removeBan(userId: string) {
await Database.db.run('DELETE from bans WHERE user_id = ?', userId);
}
// -- END BANS --
// -- START GITHUB USER MAPPING --
// github_user_mapping
static async putGithubUserMapping(discordId: string, githubUsername: string) {
await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername);
}
static async getDiscordIdFromGithub(githubUsername: string): Promise<string | null> {
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;
}
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);
return mapping?.github_username ?? null;
}
static async getAllGithubUserMappings(): Promise<GithubUserMapping[]> {
return await Database.db.all<GithubUserMapping[]>('SELECT * from github_user_mapping');
}
static async removeGithubUserMapping(discordId: string) {
await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId);
}
// -- END GITHUB USER MAPPING --
// -- START KNOWLEDGEBASE --
static async addToKnowledgebase(guildId: string, name: string, content: string) {
if (content.length > 2000) return;
await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content);
}
static async removeFromKnowledgebase(id: number) {
await Database.db.run('DELETE from knowledgebase WHERE id = ?', id);
}
static async editKnowledgebaseItem(id: number, content: string) {
if (content.length > 2000) return;
await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id);
}
static async getFromKnowledgebaseByName(guildId: string, name: string): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name);
}
static async getFromKnowledgebase(id: number): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE id = ?', id);
}
static async getAllKnowledgebaseItems(guildId: string): Promise<KnowledgebaseItem[]> {
return await Database.db.all<KnowledgebaseItem[]>('SELECT * from knowledgebase WHERE guild_id = ?', guildId);
}
// -- END KNOWLEDGEBASE --
// -- START PRIVATE HELP TICKETS --
static async createPrivateHelpTicket(userId: string, threadId: string) {
await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN);
}
static async closePrivateHelpTicket(threadId: string) {
await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId);
}
static async getLatestPrivateHelpTicketBy(userId: string): Promise<PrivateHelpTicket | undefined> {
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[]> {
return await Database.db.all<PrivateHelpTicket[]>('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN);
}
// -- END PRIVATE HELP TICKETS
import sqlite3 from 'sqlite3';
import { open, Database as SqliteDatabase } from 'sqlite';
import { serializeMessage, wait } from '../utils';
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types';
import { Message } from '../events';
const DB_SCHEMA = `
CREATE TABLE IF NOT EXISTS discord_names (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
discord_id TEXT NOT NULL,
discord_username TEXT NOT NULL,
added_on datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS settings (
guild_id TEXT PRIMARY KEY,
general_chat_id TEXT,
new_member_channel_id TEXT,
tickets_channel_id TEXT,
event_logs_channel_id TEXT,
discord_logs_channel_id TEXT,
audit_logs_channel_id TEXT,
voice_logs_channel_id TEXT,
admin_role_id TEXT,
private_help_role_id TEXT,
devwatch_role_id TEXT,
staff_categories TEXT,
safe_channels TEXT,
link_skip_channels TEXT,
github_release_channel TEXT,
moderator_channel_id TEXT,
private_help_channel_id TEXT
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY ON CONFLICT REPLACE,
author_id TEXT NOT NULL,
author_name TEXT NOT NULL,
channel_id TEXT NOT NULL,
attachments TEXT NOT NULL,
stickers TEXT NOT NULL,
content TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS index_authors ON messages (author_id);
CREATE INDEX IF NOT EXISTS index_channels ON messages (channel_id);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY,
message_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ticket_phrases (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
phrase TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
user_id TEXT,
reason TEXT,
mod_id TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS index_user_ids ON notes (user_id);
CREATE TABLE IF NOT EXISTS note_edits (
id INTEGER PRIMARY KEY,
note_id INTEGER,
mod_id TEXT,
previous_reason TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY(note_id) REFERENCES notes(id)
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY,
user_id TEXT,
expires INTEGER,
expires_at datetime,
full_ban INTEGER
);
CREATE TABLE IF NOT EXISTS github_user_mapping (
id INTEGER PRIMARY KEY,
discord_id TEXT,
github_username TEXT
);
CREATE TABLE IF NOT EXISTS knowledgebase (
id INTEGER PRIMARY KEY,
guild_id TEXT NOT NULL,
name TEXT NOT NULL,
content TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS private_help_tickets (
id INTEGER PRIMARY KEY,
thread_id TEXT NOT NULL,
user_id TEXT NOT NULL,
status INTEGER NOT NULL,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS index_timestamp ON private_help_tickets (timestamp);
`;
export const enum PrivateHelpTicketStatus {
OPEN = 0,
CLOSED = 1
}
export class Database {
private static db: SqliteDatabase;
static async open(file: string): Promise<void> {
if (Database.db) return;
Database.db = await open({
filename: file,
driver: sqlite3.Database
});
console.log('SQLite database opened');
await Database.ensure();
}
private static async ensure() {
await Database.db.exec(DB_SCHEMA);
console.log('SQLite database ensured');
}
// -- START WHOIS --
static async getE621Ids(discordId: string): Promise<number[]> {
const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId);
return ids.map(r => r.user_id);
}
static async getDiscordIds(e621Id: string | number): Promise<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;
const ids = await Database.db.all<{ discord_id: string }[]>('SELECT DISTINCT discord_id FROM discord_names WHERE user_id = ?', e621Id);
return ids.map(r => r.discord_id);
}
static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> {
const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(`
WITH RECURSIVE rec AS (
SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ?
UNION
SELECT d3.user_id, d3.discord_id, depth + 1 AS depth FROM rec
LEFT OUTER JOIN discord_names d2 ON rec.discord_id = d2.discord_id
LEFT OUTER JOIN discord_names d3 ON d2.user_id = d3.user_id
WHERE depth <= 5 AND rec.depth = depth
) SELECT DISTINCT user_id, discord_id FROM rec`, id, id);
return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id }));
}
static async putUser(id: number, user: { id: string, username: string }) {
await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username);
}
static async removeUser(id: number, discordId: string) {
await Database.db.run('DELETE from discord_names WHERE user_id = ? AND discord_id = ?', id, discordId);
}
// -- END WHOIS --
// -- START SETTINGS --
static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> {
return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId);
}
static async putGuild(guildId: string) {
await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId);
}
static async setGuildGeneralChatId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET general_chat_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildTicketsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET tickets_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildEventsLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET event_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildDiscordLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET discord_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildAuditLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET audit_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildVoiceLogsChannelId(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET voice_logs_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildNewMemberLogsChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET new_member_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildModeratorChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET moderator_channel_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildAdminRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET admin_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildPrivateHelperRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildDevWatchRole(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET devwatch_role_id = ? WHERE guild_id = ?', id, guildId);
}
static async setGuildGithubReleaseChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET github_release_channel = ? WHERE guild_id = ?', id, guildId);
}
static async setPrivateHelpChannel(guildId: string, id: string) {
await Database.db.run('UPDATE settings SET private_help_channel_id = ? WHERE guild_id = ?', id, guildId);
}
// Since "setting" has guaranteed values and is never set by the user, this shouldn't cause any security issues.
// But it does allow me to skip rewriting this a bunch.
static async getGuildArraySetting(setting: GuildArraySetting, guildId: string): Promise<string[]> {
const settings = await Database.db.get<{ [setting]: string }>(`SELECT ${setting} FROM settings WHERE guild_id = ?`, guildId);
if (!settings || !settings[setting]) return [];
return settings[setting].split(',');
}
static async putGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string) {
const values = await Database.getGuildArraySetting(setting, guildId);
if (values.indexOf(value) == -1) values.push(value);
const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
}
static async removeGuildArraySetting(setting: GuildArraySetting, guildId: string, value: string): Promise<boolean> {
const values = await Database.getGuildArraySetting(setting, guildId);
const index = values.indexOf(value);
if (index == -1) return false;
values.splice(index, 1);
const newString = values.join(',');
await Database.db.run(`UPDATE settings SET ${setting} = ? WHERE guild_id = ?`, newString, guildId);
return true;
}
// -- END SETTINGS --
// START MESSAGE LOGS --
static async putMessage(message: Message): Promise<boolean> {
try {
const serializedMessage = serializeMessage(message);
await Database.db.run(`
INSERT INTO messages (id, author_id, author_name, channel_id, attachments, stickers, content) VALUES
(:id, :author_id, :author_name, :channel_id, :attachments, :stickers, :content)
`, ...serializedMessage);
return true;
} catch (e) {
console.error(e);
return false;
}
}
static async getMessage(id: string): Promise<LoggedMessage | undefined> {
return await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
}
static async getMessageWithRetry(id: string, retries = 5, delay = 500): Promise<LoggedMessage | undefined> {
let tried = 0;
while (tried < retries) {
tried++;
const message = await Database.db.get<LoggedMessage>('SELECT * FROM messages WHERE id = ?', id);
if (message) return message;
await wait(delay);
}
}
// -- END MESSAGE LOGS --
// -- START TICKETS --
static async putTicket(ticketId: number, messageId: string) {
await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId);
}
static async removeTicket(ticketId: number) {
await Database.db.run('DELETE from tickets WHERE id = ?', ticketId);
}
static async getTicketMessageId(ticketId: number): Promise<string | undefined> {
const ticket = await Database.db.get<Pick<TicketMessage, 'message_id'>>('SELECT message_id FROM tickets WHERE id = ?', ticketId);
return ticket?.message_id;
}
static async putTicketPhrase(userId: string, phrase: string) {
await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase);
}
static async getTicketPhrase(id: number): Promise<TicketPhrase | undefined> {
return await Database.db.get<TicketPhrase>('SELECT * FROM ticket_phrases WHERE id = ?', id);
}
static async removeTicketPhrase(id: number) {
await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id);
}
static async removeAllTicketPhrasesFor(id: string): Promise<number> {
return (await Database.db.run('DELETE from ticket_phrases WHERE user_id = ?', id)).changes!;
}
static async getTicketPhrasesFor(userId: string): Promise<TicketPhrase[]> {
return await Database.db.all<TicketPhrase[]>('SELECT * from ticket_phrases WHERE user_id = ?', userId);
}
static async getAllTicketPhrases(cb: (ticketPhrase: TicketPhrase) => void) {
await Database.db.each<TicketPhrase>('SELECT * from ticket_phrases', (err: any, ticketPhrase: TicketPhrase) => {
if (err) return console.error(err);
cb(ticketPhrase);
});
}
// -- END TICKETS --
// -- START NOTES --
static async putNote(userId: string, reason: string, modId: string) {
await Database.db.run('INSERT INTO notes(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId);
}
static async editNote(id: number, oldReason: string, newReason: string, modId: string) {
await Database.db.run('UPDATE notes SET reason = ?, mod_id = ? WHERE id = ?', newReason, modId, id);
await Database.db.run('INSERT INTO note_edits(note_id, mod_id, previous_reason) VALUES (?, ?, ?)', id, modId, oldReason);
}
static async removeNote(id: number): Promise<boolean> {
const res = await Database.db.run('DELETE from notes WHERE id = ?', id);
return (res.changes ?? 0) > 0;
}
static async getNotes(userId: string): Promise<Note[]> {
return await Database.db.all<Note[]>('SELECT * from notes WHERE user_id = ?', userId);
}
// -- END NOTES --
// -- START BANS --
static async putBan(userId: string, expiresAt: Date | null, fullBan = false) {
await Database.db.run('INSERT INTO bans(user_id, expires, expires_at, full_ban) VALUES (?, ?, ?, ?)', userId, expiresAt != null ? 1 : 0, expiresAt, fullBan);
}
static async getBan(userId: string): Promise<Ban | undefined> {
return await Database.db.get('SELECT * from bans WHERE user_id = ? ORDER BY id DESC', userId);
}
static async getExpiredBans(date: Date): Promise<Ban[]> {
return await Database.db.all<Ban[]>('SELECT * from bans WHERE expires = 1 AND expires_at <= ?', date);
}
static async pruneExpiredBans(date: Date) {
await Database.db.all<Ban[]>('DELETE from bans WHERE expires = 1 AND expires_at <= ?', date);
}
static async removeBan(userId: string) {
await Database.db.run('DELETE from bans WHERE user_id = ?', userId);
}
// -- END BANS --
// -- START GITHUB USER MAPPING --
// github_user_mapping
static async putGithubUserMapping(discordId: string, githubUsername: string) {
await Database.db.run('INSERT INTO github_user_mapping(discord_id, github_username) VALUES (?, ?)', discordId, githubUsername);
}
static async getDiscordIdFromGithub(githubUsername: string): Promise<string | null> {
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;
}
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);
return mapping?.github_username ?? null;
}
static async getAllGithubUserMappings(): Promise<GithubUserMapping[]> {
return await Database.db.all<GithubUserMapping[]>('SELECT * from github_user_mapping');
}
static async removeGithubUserMapping(discordId: string) {
await Database.db.run('DELETE from github_user_mapping WHERE discord_id = ?', discordId);
}
// -- END GITHUB USER MAPPING --
// -- START KNOWLEDGEBASE --
static async addToKnowledgebase(guildId: string, name: string, content: string) {
if (content.length > 2000) return;
await Database.db.run('INSERT INTO knowledgebase(guild_id, name, content) VALUES (?, ?, ?)', guildId, name, content);
}
static async removeFromKnowledgebase(id: number) {
await Database.db.run('DELETE from knowledgebase WHERE id = ?', id);
}
static async editKnowledgebaseItem(id: number, content: string) {
if (content.length > 2000) return;
await Database.db.run('UPDATE knowledgebase SET content = ? WHERE id = ?', content, id);
}
static async getFromKnowledgebaseByName(guildId: string, name: string): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE guild_id = ? AND name = ?', guildId, name);
}
static async getFromKnowledgebase(id: number): Promise<KnowledgebaseItem | undefined> {
return await Database.db.get<KnowledgebaseItem>('SELECT * from knowledgebase WHERE id = ?', id);
}
static async getAllKnowledgebaseItems(guildId: string): Promise<KnowledgebaseItem[]> {
return await Database.db.all<KnowledgebaseItem[]>('SELECT * from knowledgebase WHERE guild_id = ?', guildId);
}
// -- END KNOWLEDGEBASE --
// -- START PRIVATE HELP TICKETS --
static async createPrivateHelpTicket(userId: string, threadId: string) {
await Database.db.run('INSERT INTO private_help_tickets(user_id, thread_id, status) VALUES (?, ?, ?)', userId, threadId, PrivateHelpTicketStatus.OPEN);
}
static async closePrivateHelpTicket(threadId: string) {
await Database.db.run('UPDATE private_help_tickets SET status = ? WHERE thread_id = ?', PrivateHelpTicketStatus.CLOSED, threadId);
}
static async getLatestPrivateHelpTicketBy(userId: string): Promise<PrivateHelpTicket | undefined> {
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[]> {
return await Database.db.all<PrivateHelpTicket[]>('SELECT * from private_help_tickets WHERE status = ?', PrivateHelpTicketStatus.OPEN);
}
// -- END PRIVATE HELP TICKETS
}
+49 -49
View File
@@ -1,50 +1,50 @@
import { createClient, SocketClosedUnexpectedlyError } from '@redis/client';
import { Client } from 'discord.js';
import { banUpdateHandler, ticketUpdateHandler } from '../utils';
let discordClient: Client;
export async function openRedisClient(url: string, discClient: Client) {
const client = await createClient({
url: `redis://${url}`,
socket: {
reconnectStrategy: 60000
}
});
client.on('error', (error) => {
if (error.code == 'ECONNREFUSED') {
console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)");
} else if (error instanceof SocketClosedUnexpectedlyError) {
console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.');
} else {
console.error('Redis error:');
console.error(error);
}
});
client.on('connect', () => {
console.log('Connected to redis database');
});
client.on('reconnecting', () => {
console.log('Attempting to reconnect to redis database');
});
client.once('connect', () => {
client.subscribe(['ticket_updates', 'ban_updates'], updateHandler);
});
client.connect();
discordClient = discClient;
}
function updateHandler(data: string, channel: string) {
switch (channel) {
case 'ticket_updates':
return ticketUpdateHandler(discordClient, data);
case 'ban_updates':
return banUpdateHandler(discordClient, data);
}
import { createClient, SocketClosedUnexpectedlyError } from '@redis/client';
import { Client } from 'discord.js';
import { banUpdateHandler, ticketUpdateHandler } from '../utils';
let discordClient: Client;
export async function openRedisClient(url: string, discClient: Client) {
const client = await createClient({
url: `redis://${url}`,
socket: {
reconnectStrategy: 60000
}
});
client.on('error', (error) => {
if (error.code == 'ECONNREFUSED') {
console.error("Couldn't connect to redis database: Connection refused (is redis on? is the port reachable?)");
} else if (error instanceof SocketClosedUnexpectedlyError) {
console.error('Redis server closed unexpectedly. Attempting reconnect every 60 seconds.');
} else {
console.error('Redis error:');
console.error(error);
}
});
client.on('connect', () => {
console.log('Connected to redis database');
});
client.on('reconnecting', () => {
console.log('Attempting to reconnect to redis database');
});
client.once('connect', () => {
client.subscribe(['ticket_updates', 'ban_updates'], updateHandler);
});
client.connect();
discordClient = discClient;
}
function updateHandler(data: string, channel: string) {
switch (channel) {
case 'ticket_updates':
return ticketUpdateHandler(discordClient, data);
case 'ban_updates':
return banUpdateHandler(discordClient, data);
}
}
+8 -8
View File
@@ -1,9 +1,9 @@
import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js';
import { Handler } from './handler';
export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder;
export interface Command extends Handler {
data: CommandBuilder | ((client: Client) => Promise<CommandBuilder>);
guilds?: string[];
import { Client, ContextMenuCommandBuilder, SlashCommandBuilder } from 'discord.js';
import { Handler } from './handler';
export type CommandBuilder = ContextMenuCommandBuilder | SlashCommandBuilder;
export interface Command extends Handler {
data: CommandBuilder | ((client: Client) => Promise<CommandBuilder>);
guilds?: string[];
}
+78 -78
View File
@@ -1,79 +1,79 @@
export type LoggedMessage = {
id: string
author_id: string
author_name: string
channel_id: string
attachments: string
stickers: string
content: string
}
export type GuildSettings = {
guild_id: string
general_chat_id?: string
new_member_channel_id?: string
tickets_channel_id?: string
event_logs_channel_id?: string
discord_logs_channel_id?: string
audit_logs_channel_id?: string
voice_logs_channel_id?: string
admin_role_id?: string
private_help_role_id?: string
devwatch_role_id?: string
staff_categories?: string
safe_channels?: string
link_skip_channels?: string
github_release_channel?: string
moderator_channel_id?: string
private_help_channel_id?: string
}
export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels';
export type TicketMessage = {
id: number
message_id: string
}
export type TicketPhrase = {
id: number
user_id: string
phrase: string
}
export type Note = {
id: number
user_id: string
reason: string
mod_id: string
timestamp: string
}
export type Ban = {
id: number
user_id: string
expires: 0 | 1
expires_at: string
full_ban: 0 | 1
}
export type GithubUserMapping = {
id: number
discord_id: string
github_username: string
}
export type KnowledgebaseItem = {
id: number
guild_id: string
name: string
content: string
}
export type PrivateHelpTicket = {
id: number
user_id: string
thread_id: string
status: PrivateHelpTicketStatus
timestamp: string
export type LoggedMessage = {
id: string
author_id: string
author_name: string
channel_id: string
attachments: string
stickers: string
content: string
}
export type GuildSettings = {
guild_id: string
general_chat_id?: string
new_member_channel_id?: string
tickets_channel_id?: string
event_logs_channel_id?: string
discord_logs_channel_id?: string
audit_logs_channel_id?: string
voice_logs_channel_id?: string
admin_role_id?: string
private_help_role_id?: string
devwatch_role_id?: string
staff_categories?: string
safe_channels?: string
link_skip_channels?: string
github_release_channel?: string
moderator_channel_id?: string
private_help_channel_id?: string
}
export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels';
export type TicketMessage = {
id: number
message_id: string
}
export type TicketPhrase = {
id: number
user_id: string
phrase: string
}
export type Note = {
id: number
user_id: string
reason: string
mod_id: string
timestamp: string
}
export type Ban = {
id: number
user_id: string
expires: 0 | 1
expires_at: string
full_ban: 0 | 1
}
export type GithubUserMapping = {
id: number
discord_id: string
github_username: string
}
export type KnowledgebaseItem = {
id: number
guild_id: string
name: string
content: string
}
export type PrivateHelpTicket = {
id: number
user_id: string
thread_id: string
status: PrivateHelpTicketStatus
timestamp: string
}
+156 -156
View File
@@ -1,157 +1,157 @@
export type E621User = {
wiki_page_version_count: number
artist_version_count: number
pool_version_count: number
forum_post_count: number
comment_count: number
flag_count: number
favorite_count: number
positive_feedback_count: number
neutral_feedback_count: number
negative_feedback_count: number
upload_limit: number
profile_about: string
profile_artinfo: string
id: number
created_at: string
name: string
level: number
base_upload_limit: number
post_upload_count: number
post_update_count: number
note_update_count: number
is_banned: boolean
can_approve_posts: boolean
can_upload_free: boolean
level_string: string
avatar_id: number
}
export type E621Post = {
id: number
created_at: string
updated_at: string
file: E621File
preview: E621PreviewFile
sample: E621SampleFile
score: E621ScoreData
tags: E621Tags
locked_tags: string[]
change_seq: number
flags: E621FlagData
rating: 's' | 'q' | 'e'
fav_count: number
sources: string[]
pools: number[]
relationships: E621PostRelationships
approver_id: number
uploader_id: number
description: string
comment_count: number
is_favorited: boolean
has_notes: boolean
duration: number | null
}
export type E621File = {
width: number
height: number
ext: 'png' | 'jpg' | 'mp4' | 'webm'
size: number
md5: string
url: string | null
}
export type E621PreviewFile = {
width: number
height: number
url: string | null
}
export type E621SampleFile = {
has: boolean
height: number
width: number
url: string | null
// Typing this will be a pain in the ass, so I skipped it for now.
alternates: any
}
export type E621ScoreData = {
up: number
down: number
total: number
}
export type E621Tags = {
general: string[]
artist: string[]
contributor: string[]
copyright: string[]
character: string[]
species: string[]
invalid: string[]
meta: string[]
lore: string[]
}
export type E621FlagData = {
pending: boolean
flagged: boolean
note_locked: boolean
status_locked: boolean
rating_locked: boolean
deleted: boolean
}
export type E621PostRelationships = {
parent_id: number | null
has_children: boolean
has_active_children: boolean
children: number[]
}
export type Ticket = {
id: number
user_id: number
user: string
claimant: string | null
target?: string
accused_id?: number
target_id: number
status: 'pending' | 'partial' | 'approved'
category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki'
reason: string
};
export type TicketUpdate = {
action: 'claim' | 'create' | 'unclaim' | 'update'
ticket: Ticket
};
export type Ban = {
id: number
user_id: number
banner_id: number
expires_at: string
reason: string
};
export type BanUpdate = {
action: 'create' | 'update' | 'delete'
ban: Ban
};
export type RecordCategory = 'positive' | 'negative' | 'neutral'
export type Record = {
id: number
user_id: number
creator_id: number
created_at: string
body: string
category: RecordCategory
updated_at: string
updater_id: number
is_deleted: boolean
export type E621User = {
wiki_page_version_count: number
artist_version_count: number
pool_version_count: number
forum_post_count: number
comment_count: number
flag_count: number
favorite_count: number
positive_feedback_count: number
neutral_feedback_count: number
negative_feedback_count: number
upload_limit: number
profile_about: string
profile_artinfo: string
id: number
created_at: string
name: string
level: number
base_upload_limit: number
post_upload_count: number
post_update_count: number
note_update_count: number
is_banned: boolean
can_approve_posts: boolean
can_upload_free: boolean
level_string: string
avatar_id: number
}
export type E621Post = {
id: number
created_at: string
updated_at: string
file: E621File
preview: E621PreviewFile
sample: E621SampleFile
score: E621ScoreData
tags: E621Tags
locked_tags: string[]
change_seq: number
flags: E621FlagData
rating: 's' | 'q' | 'e'
fav_count: number
sources: string[]
pools: number[]
relationships: E621PostRelationships
approver_id: number
uploader_id: number
description: string
comment_count: number
is_favorited: boolean
has_notes: boolean
duration: number | null
}
export type E621File = {
width: number
height: number
ext: 'png' | 'jpg' | 'mp4' | 'webm'
size: number
md5: string
url: string | null
}
export type E621PreviewFile = {
width: number
height: number
url: string | null
}
export type E621SampleFile = {
has: boolean
height: number
width: number
url: string | null
// Typing this will be a pain in the ass, so I skipped it for now.
alternates: any
}
export type E621ScoreData = {
up: number
down: number
total: number
}
export type E621Tags = {
general: string[]
artist: string[]
contributor: string[]
copyright: string[]
character: string[]
species: string[]
invalid: string[]
meta: string[]
lore: string[]
}
export type E621FlagData = {
pending: boolean
flagged: boolean
note_locked: boolean
status_locked: boolean
rating_locked: boolean
deleted: boolean
}
export type E621PostRelationships = {
parent_id: number | null
has_children: boolean
has_active_children: boolean
children: number[]
}
export type Ticket = {
id: number
user_id: number
user: string
claimant: string | null
target?: string
accused_id?: number
target_id: number
status: 'pending' | 'partial' | 'approved'
category: 'blip' | 'comment' | 'dmail' | 'forum' | 'pool' | 'post' | 'set' | 'user' | 'wiki'
reason: string
};
export type TicketUpdate = {
action: 'claim' | 'create' | 'unclaim' | 'update'
ticket: Ticket
};
export type Ban = {
id: number
user_id: number
banner_id: number
expires_at: string
reason: string
};
export type BanUpdate = {
action: 'create' | 'update' | 'delete'
ban: Ban
};
export type RecordCategory = 'positive' | 'negative' | 'neutral'
export type Record = {
id: number
user_id: number
creator_id: number
created_at: string
body: string
category: RecordCategory
updated_at: string
updater_id: number
is_deleted: boolean
}
+9 -9
View File
@@ -1,10 +1,10 @@
import { Client, Interaction } from 'discord.js';
type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise<void>
export interface Handler {
name: string;
handler: HandlerFunction;
init?: (client: Client) => Promise<void>;
autoComplete?: HandlerFunction;
import { Client, Interaction } from 'discord.js';
type HandlerFunction = (client: Client, interaction: Interaction, ...args: any) => Promise<void>
export interface Handler {
name: string;
handler: HandlerFunction;
init?: (client: Client) => Promise<void>;
autoComplete?: HandlerFunction;
}
+37 -37
View File
@@ -1,38 +1,38 @@
import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js';
export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] };
export type RoleChangeLog = {
key: '$add' | '$remove',
old?: Pick<APIRole, 'id' | 'name'>[],
new?: Pick<APIRole, 'id' | 'name'>[]
}
type ApplicationCommandPermission = {
type: 1 | 2 | 3,
permission: boolean,
id: string
}
export type ApplicationCommandPermissionChangeLog = {
key: sting,
old?: ApplicationCommandPermission,
new?: ApplicationCommandPermission
}
export type TimeoutChangeLog = {
key: 'communication_disabled_until',
old?: string,
new?: string
}
export type PermissionsChangeLog = {
key: 'permissions' | 'allow' | 'deny',
old?: number,
new?: number
}
export type PinExtras = {
channel: TextChannel,
messageId: string
import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js';
export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder<ButtonBuilder>[] };
export type RoleChangeLog = {
key: '$add' | '$remove',
old?: Pick<APIRole, 'id' | 'name'>[],
new?: Pick<APIRole, 'id' | 'name'>[]
}
type ApplicationCommandPermission = {
type: 1 | 2 | 3,
permission: boolean,
id: string
}
export type ApplicationCommandPermissionChangeLog = {
key: sting,
old?: ApplicationCommandPermission,
new?: ApplicationCommandPermission
}
export type TimeoutChangeLog = {
key: 'communication_disabled_until',
old?: string,
new?: string
}
export type PermissionsChangeLog = {
key: 'permissions' | 'allow' | 'deny',
old?: number,
new?: number
}
export type PinExtras = {
channel: TextChannel,
messageId: string
}
+5 -5
View File
@@ -1,5 +1,5 @@
export * from './command.d';
export * from './database-types.d';
export * from './e621-types.d';
export * from './handler.d';
export * from './helper-types.d';
export * from './command.d';
export * from './database-types.d';
export * from './e621-types.d';
export * from './handler.d';
export * from './helper-types.d';
+105 -105
View File
@@ -1,106 +1,106 @@
import { Guild } from 'discord.js';
import { Database } from '../shared/Database';
import { userIsBanned } from './e621-utils';
import { config } from '../config';
export type AltData = {
type: 'e621' | 'discord'
thisId: number | string
banned: boolean
alts: AltData[]
};
export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds);
let content = '';
for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue;
const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore);
const banned = await userIsBanned(e621Id);
content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`;
}
return content;
}
export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const discordIds = await Database.getDiscordIds(e621Id);
let content = '';
for (const discordId of discordIds) {
const alts = await getE621Alts(discordId, guild, depth + 1, ignore);
let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt.
try {
banned = !!(await guild.bans.fetch(discordId));
} catch (e) { }
content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`;
}
return content;
}
export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise<AltData> {
return getE621AltData(discordId, guild);
}
export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise<AltData> {
return getDiscordAltData(e621Id, guild);
}
async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds);
let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt.
try {
banned = guild ? !!(await guild.bans.fetch(discordId)) : false;
} catch (e) { }
const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] };
for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue;
data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore));
}
return data;
}
async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const discordIds = await Database.getDiscordIds(e621Id);
const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] };
for (const discordId of discordIds) {
data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore));
}
return data;
}
export function e621IdsFromAltData(altData: AltData, data: number[] = []) {
if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number);
for (const alt of altData.alts) {
e621IdsFromAltData(alt, data);
}
return data;
import { Guild } from 'discord.js';
import { Database } from '../shared/Database';
import { userIsBanned } from './e621-utils';
import { config } from '../config';
export type AltData = {
type: 'e621' | 'discord'
thisId: number | string
banned: boolean
alts: AltData[]
};
export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds);
let content = '';
for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue;
const alts = await getDiscordAlts(e621Id, guild, depth + 1, toIgnore);
const banned = await userIsBanned(e621Id);
content += `${' '.repeat((depth - 1) * 2)}- ${config.E621_BASE_URL}/users/${e621Id}${banned ? ' [BANNED]' : ''}\n${alts}`;
}
return content;
}
export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise<string> {
const discordIds = await Database.getDiscordIds(e621Id);
let content = '';
for (const discordId of discordIds) {
const alts = await getE621Alts(discordId, guild, depth + 1, ignore);
let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt.
try {
banned = !!(await guild.bans.fetch(discordId));
} catch (e) { }
content += `${' '.repeat((depth - 1) * 2)}- <@${discordId}> (${discordId})${banned ? ' [BANNED]' : ''}\n${alts}`;
}
return content;
}
export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild | null): Promise<AltData> {
return getE621AltData(discordId, guild);
}
export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild | null): Promise<AltData> {
return getDiscordAltData(e621Id, guild);
}
async function getE621AltData(discordId: string, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const e621UserIds = await Database.getE621Ids(discordId);
const toIgnore = ignore.concat(e621UserIds);
let banned = false;
// It's either this or fetch all the bans and sift through them for every discord alt.
try {
banned = guild ? !!(await guild.bans.fetch(discordId)) : false;
} catch (e) { }
const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] };
for (const e621Id of e621UserIds) {
if (ignore.includes(e621Id)) continue;
data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore));
}
return data;
}
async function getDiscordAltData(e621Id: number, guild: Guild | null, depth = 1, ignore: number[] = []): Promise<AltData> {
const discordIds = await Database.getDiscordIds(e621Id);
const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] };
for (const discordId of discordIds) {
data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore));
}
return data;
}
export function e621IdsFromAltData(altData: AltData, data: number[] = []) {
if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number);
for (const alt of altData.alts) {
e621IdsFromAltData(alt, data);
}
return data;
}
+5 -5
View File
@@ -1,6 +1,6 @@
export function getArrayDifference(oldArr: any[], newArr: any[]) {
const added = newArr.filter(e => !oldArr.includes(e));
const removed = oldArr.filter(e => !newArr.includes(e));
return { added, removed };
export function getArrayDifference(oldArr: any[], newArr: any[]) {
const added = newArr.filter(e => !oldArr.includes(e));
const removed = oldArr.filter(e => !newArr.includes(e));
return { added, removed };
}
+196 -196
View File
@@ -1,197 +1,197 @@
import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js';
import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types';
import { getArrayDifference } from './array-utils';
export const enum TargetType {
Unknown = 0,
Role = 1,
User = 2,
Channel = 3
};
const TARGETS_ROLES: AuditLogEvent[] = [
AuditLogEvent.RoleCreate,
AuditLogEvent.RoleDelete,
AuditLogEvent.RoleUpdate
];
const TARGETS_USERS: AuditLogEvent[] = [
AuditLogEvent.MemberUpdate,
AuditLogEvent.MemberKick,
AuditLogEvent.MemberBanAdd,
AuditLogEvent.MemberBanRemove,
AuditLogEvent.MemberRoleUpdate,
AuditLogEvent.MessageDelete,
AuditLogEvent.MessagePin,
AuditLogEvent.MessageUnpin
];
const TARGETS_CHANNELS: AuditLogEvent[] = [
AuditLogEvent.ChannelCreate,
AuditLogEvent.ChannelUpdate,
AuditLogEvent.ChannelDelete,
AuditLogEvent.ThreadCreate,
AuditLogEvent.ThreadUpdate,
AuditLogEvent.ThreadDelete,
AuditLogEvent.ChannelOverwriteCreate,
AuditLogEvent.ChannelOverwriteDelete,
AuditLogEvent.ChannelOverwriteUpdate
];
export function getTargetType(actionType: AuditLogEvent): TargetType {
if (TARGETS_ROLES.includes(actionType)) return TargetType.Role;
else if (TARGETS_USERS.includes(actionType)) return TargetType.User;
else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel;
return TargetType.Unknown;
}
export function formatSnowflake(snowflake: string, targetType: TargetType): string {
if (targetType == TargetType.Role) return `<@&${snowflake}>`;
else if (targetType == TargetType.User) return `<@${snowflake}>`;
else if (targetType == TargetType.Channel) return `<#${snowflake}>`;
return snowflake;
}
export function formatChanges(entry: GuildAuditLogsEntry): string {
return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n');
}
export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string {
if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin)
return formatMessagePin(entry.extra as unknown as PinExtras, guild);
if (entry.action == AuditLogEvent.ChannelOverwriteCreate
|| entry.action == AuditLogEvent.ChannelOverwriteDelete
|| entry.action == AuditLogEvent.ChannelOverwriteUpdate) {
return `Target: ${entry.extra!.toString()}`;
}
try {
const reserialized = JSON.parse(JSON.stringify(entry));
const results: string[] = [];
for (const [key, value] of Object.entries(reserialized.extra ?? {})) {
if (!value) continue;
if (key == 'channel_id' || key == 'channel') {
results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`);
continue;
}
results.push(`${key}: ${value}`);
}
return results.join('\n');
} catch (e) {
console.error(e);
return '';
}
return '';
}
function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined {
if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) {
return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog);
}
switch (change.key) {
case '$add':
case '$remove':
return formatMemberRoleChange(change);
case 'communication_disabled_until':
return formatTimeoutChange(change);
case 'permissions':
case 'allow':
case 'deny':
return formatPermissionOrOverwrites(change as PermissionsChangeLog);
}
const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old;
const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new;
if (change.new !== undefined && change.old === undefined)
return `Set ${change.key} to ${newValue}`;
if (change.new === undefined && change.old !== undefined)
return `Set ${change.key} with value ${oldValue} to default/null`;
return `Set ${change.key} from ${oldValue} to ${newValue}`;
}
function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined {
if (change.new !== undefined && change.old === undefined)
return `${change.new.permission ? 'Allowed' : 'Denied'} access ${change.new.type == 3 ? 'in' : (change.new.permission ? 'to' : 'from')} ${formatSnowflake(change.new.id, change.new.type)} for command: ${change.key}`;
if (change.new === undefined && change.old !== undefined)
return `Removed permission overrides from ${formatSnowflake(change.old.id, change.old.type)} for command: ${change.key}`;
return `Updated permission overrides ${change.new!.type == 3 ? 'in' : 'for'} ${formatSnowflake(change.new!.id, change.new!.type)}: ${change.new!.permission ? 'allowed access to' : 'revoked access to'} command: ${change.key}`;
}
function formatMemberRoleChange(change: RoleChangeLog): string | undefined {
if (!change.new) return;
const changes: string[] = [];
for (const roleChange of change.new) {
if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
}
if (changes.length == 0) return;
return changes.join('\n');
}
function formatTimeoutChange(change: TimeoutChangeLog): string {
if (!change.new) return 'Timeout removed';
const date = new Date(change.new);
return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`;
}
function formatPermissionOrOverwrites(change: PermissionsChangeLog): string {
const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray();
const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray();
switch (change.key) {
case 'permissions':
return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)');
case 'allow':
return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added');
case 'deny':
return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added');
default:
return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`);
}
}
function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) {
const { added, removed } = getArrayDifference(oldPermissions, newPermissions);
const result: string[] = [];
if (added.length > 0) {
result.push(`${addedDescription}: ${added.join(', ')}`);
}
if (removed.length > 0) {
result.push(`${removedDescription}: ${removed.join(', ')}`);
}
return result.join('\n');
}
function formatMessagePin(data: PinExtras, guild: Guild): string {
return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`;
import { AuditLogChange, AuditLogEvent, Guild, GuildAuditLogsEntry, PermissionsBitField, PermissionsString, time, TimestampStyles } from 'discord.js';
import { ApplicationCommandPermissionChangeLog, PermissionsChangeLog, PinExtras, RoleChangeLog, TimeoutChangeLog } from '../types';
import { getArrayDifference } from './array-utils';
export const enum TargetType {
Unknown = 0,
Role = 1,
User = 2,
Channel = 3
};
const TARGETS_ROLES: AuditLogEvent[] = [
AuditLogEvent.RoleCreate,
AuditLogEvent.RoleDelete,
AuditLogEvent.RoleUpdate
];
const TARGETS_USERS: AuditLogEvent[] = [
AuditLogEvent.MemberUpdate,
AuditLogEvent.MemberKick,
AuditLogEvent.MemberBanAdd,
AuditLogEvent.MemberBanRemove,
AuditLogEvent.MemberRoleUpdate,
AuditLogEvent.MessageDelete,
AuditLogEvent.MessagePin,
AuditLogEvent.MessageUnpin
];
const TARGETS_CHANNELS: AuditLogEvent[] = [
AuditLogEvent.ChannelCreate,
AuditLogEvent.ChannelUpdate,
AuditLogEvent.ChannelDelete,
AuditLogEvent.ThreadCreate,
AuditLogEvent.ThreadUpdate,
AuditLogEvent.ThreadDelete,
AuditLogEvent.ChannelOverwriteCreate,
AuditLogEvent.ChannelOverwriteDelete,
AuditLogEvent.ChannelOverwriteUpdate
];
export function getTargetType(actionType: AuditLogEvent): TargetType {
if (TARGETS_ROLES.includes(actionType)) return TargetType.Role;
else if (TARGETS_USERS.includes(actionType)) return TargetType.User;
else if (TARGETS_CHANNELS.includes(actionType)) return TargetType.Channel;
return TargetType.Unknown;
}
export function formatSnowflake(snowflake: string, targetType: TargetType): string {
if (targetType == TargetType.Role) return `<@&${snowflake}>`;
else if (targetType == TargetType.User) return `<@${snowflake}>`;
else if (targetType == TargetType.Channel) return `<#${snowflake}>`;
return snowflake;
}
export function formatChanges(entry: GuildAuditLogsEntry): string {
return entry.changes.map(c => formatChange(c, entry)).filter(e => e).join('\n');
}
export function formatExtras(entry: GuildAuditLogsEntry, guild: Guild): string {
if (entry.action == AuditLogEvent.MessagePin || entry.action == AuditLogEvent.MessageUnpin)
return formatMessagePin(entry.extra as unknown as PinExtras, guild);
if (entry.action == AuditLogEvent.ChannelOverwriteCreate
|| entry.action == AuditLogEvent.ChannelOverwriteDelete
|| entry.action == AuditLogEvent.ChannelOverwriteUpdate) {
return `Target: ${entry.extra!.toString()}`;
}
try {
const reserialized = JSON.parse(JSON.stringify(entry));
const results: string[] = [];
for (const [key, value] of Object.entries(reserialized.extra ?? {})) {
if (!value) continue;
if (key == 'channel_id' || key == 'channel') {
results.push(`channel: ${formatSnowflake(value as string, TargetType.Channel)}`);
continue;
}
results.push(`${key}: ${value}`);
}
return results.join('\n');
} catch (e) {
console.error(e);
return '';
}
return '';
}
function formatChange(change: AuditLogChange, entry: GuildAuditLogsEntry): string | undefined {
if (entry.action == AuditLogEvent.ApplicationCommandPermissionUpdate) {
return formatApplicationPermissionsUpdate(change as ApplicationCommandPermissionChangeLog);
}
switch (change.key) {
case '$add':
case '$remove':
return formatMemberRoleChange(change);
case 'communication_disabled_until':
return formatTimeoutChange(change);
case 'permissions':
case 'allow':
case 'deny':
return formatPermissionOrOverwrites(change as PermissionsChangeLog);
}
const oldValue = change.key == 'nick' ? `\`${change.old}\`` : change.old;
const newValue = change.key == 'nick' ? `\`${change.new}\`` : change.new;
if (change.new !== undefined && change.old === undefined)
return `Set ${change.key} to ${newValue}`;
if (change.new === undefined && change.old !== undefined)
return `Set ${change.key} with value ${oldValue} to default/null`;
return `Set ${change.key} from ${oldValue} to ${newValue}`;
}
function formatApplicationPermissionsUpdate(change: ApplicationCommandPermissionChangeLog): string | undefined {
if (change.new !== undefined && change.old === undefined)
return `${change.new.permission ? 'Allowed' : 'Denied'} access ${change.new.type == 3 ? 'in' : (change.new.permission ? 'to' : 'from')} ${formatSnowflake(change.new.id, change.new.type)} for command: ${change.key}`;
if (change.new === undefined && change.old !== undefined)
return `Removed permission overrides from ${formatSnowflake(change.old.id, change.old.type)} for command: ${change.key}`;
return `Updated permission overrides ${change.new!.type == 3 ? 'in' : 'for'} ${formatSnowflake(change.new!.id, change.new!.type)}: ${change.new!.permission ? 'allowed access to' : 'revoked access to'} command: ${change.key}`;
}
function formatMemberRoleChange(change: RoleChangeLog): string | undefined {
if (!change.new) return;
const changes: string[] = [];
for (const roleChange of change.new) {
if (change.key == '$add') changes.push(`Added role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
else changes.push(`Removed role ${formatSnowflake(roleChange.id, TargetType.Role)}`);
}
if (changes.length == 0) return;
return changes.join('\n');
}
function formatTimeoutChange(change: TimeoutChangeLog): string {
if (!change.new) return 'Timeout removed';
const date = new Date(change.new);
return `Timeout until ${time(date, TimestampStyles.RelativeTime)}`;
}
function formatPermissionOrOverwrites(change: PermissionsChangeLog): string {
const oldPerms = new PermissionsBitField(BigInt(change.old ?? 0)).toArray();
const newPerms = new PermissionsBitField(BigInt(change.new ?? 0)).toArray();
switch (change.key) {
case 'permissions':
return formatPermissionChange(oldPerms, newPerms, 'Removed permission(s)', 'Added permission(s)');
case 'allow':
return formatPermissionChange(oldPerms, newPerms, 'Allow removed', 'Allow added');
case 'deny':
return formatPermissionChange(oldPerms, newPerms, 'Deny removed', 'Deny added');
default:
return formatPermissionChange(oldPerms, newPerms, `${change.key} removed`, `${change.key} added`);
}
}
function formatPermissionChange(oldPermissions: PermissionsString[], newPermissions: PermissionsString[], removedDescription: string, addedDescription: string) {
const { added, removed } = getArrayDifference(oldPermissions, newPermissions);
const result: string[] = [];
if (added.length > 0) {
result.push(`${addedDescription}: ${added.join(', ')}`);
}
if (removed.length > 0) {
result.push(`${removedDescription}: ${removed.join(', ')}`);
}
return result.join('\n');
}
function formatMessagePin(data: PinExtras, guild: Guild): string {
return `Message: [${data.messageId}](https://discord.com/channels/${guild.id}/${data.channel.id}/${data.messageId})`;
}
+48 -48
View File
@@ -1,49 +1,49 @@
import { Client } from 'discord.js';
import { BanUpdate } from '../types';
import { Database } from '../shared/Database';
import { config } from '../config';
export async function banUpdateHandler(client: Client, update: string) {
const data: BanUpdate = JSON.parse(update);
if (data.action == 'create') {
kickDiscordAccounts(client, data);
}
// else if (data.action == 'delete') {
// // unbanDiscordAccounts(data);
// }
}
async function kickDiscordAccounts(client: Client, data: BanUpdate) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
const discordIds = await Database.getDiscordIds(data.ban.user_id);
for (const id of discordIds) {
const member = await guild.members.fetch(id);
if (member) await member.kick(`Banned from e621 by: https://e621.net/users/${data.ban.banner_id}. Reason:\n${data.ban.reason}`);
}
}
// async function banDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) {
// await guild.bans.create(id, {
// reason: data.ban.reason
// });
// }
// }
// async function unbanDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) {
// await guild.bans.remove(id);
// }
import { Client } from 'discord.js';
import { BanUpdate } from '../types';
import { Database } from '../shared/Database';
import { config } from '../config';
export async function banUpdateHandler(client: Client, update: string) {
const data: BanUpdate = JSON.parse(update);
if (data.action == 'create') {
kickDiscordAccounts(client, data);
}
// else if (data.action == 'delete') {
// // unbanDiscordAccounts(data);
// }
}
async function kickDiscordAccounts(client: Client, data: BanUpdate) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
const discordIds = await Database.getDiscordIds(data.ban.user_id);
for (const id of discordIds) {
const member = await guild.members.fetch(id);
if (member) await member.kick(`Banned from e621 by: https://e621.net/users/${data.ban.banner_id}. Reason:\n${data.ban.reason}`);
}
}
// async function banDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) {
// await guild.bans.create(id, {
// reason: data.ban.reason
// });
// }
// }
// async function unbanDiscordAccounts(data: BanUpdate) {
// const guild = await discordClient.guilds.fetch(config.DISCORD_GUILD_ID!);
// const discordIds = await Database.getDiscordIds(data.ban.user_id);
// for (const id of discordIds) {
// await guild.bans.remove(id);
// }
// }
+21 -21
View File
@@ -1,22 +1,22 @@
import { Client } from 'discord.js';
import { Database } from '../shared/Database';
import { config } from '../config';
export async function checkExpiredBans(client: Client) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) return;
const date = new Date();
for (const ban of await Database.getExpiredBans(date)) {
try {
await guild.bans.remove(ban.user_id);
} catch (e) {
console.error(`Error unbanning user: ${ban.user_id}`);
console.error(e);
}
}
await Database.pruneExpiredBans(date);
import { Client } from 'discord.js';
import { Database } from '../shared/Database';
import { config } from '../config';
export async function checkExpiredBans(client: Client) {
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
if (!guild) return;
const date = new Date();
for (const ban of await Database.getExpiredBans(date)) {
try {
await guild.bans.remove(ban.user_id);
} catch (e) {
console.error(`Error unbanning user: ${ban.user_id}`);
console.error(e);
}
}
await Database.pruneExpiredBans(date);
}
+27 -27
View File
@@ -1,28 +1,28 @@
import { GuildBasedChannel } from 'discord.js';
import { Database } from '../shared/Database';
export async function channelIsInStaffCategory(channel: GuildBasedChannel) {
if (!channel.guildId || !channel.parentId) return false;
const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId);
const parentChannel = await channel.guild.channels.fetch(channel.parentId);
return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId);
}
export async function channelIsSafe(channel: GuildBasedChannel) {
if (!channel.guildId) return false;
const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId);
return safeChannels.includes(channel.id);
}
export async function channelIgnoresLinks(channel: GuildBasedChannel) {
if (!channel.guildId) return false;
const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId);
return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false;
import { GuildBasedChannel } from 'discord.js';
import { Database } from '../shared/Database';
export async function channelIsInStaffCategory(channel: GuildBasedChannel) {
if (!channel.guildId || !channel.parentId) return false;
const staffCategories = await Database.getGuildArraySetting('staff_categories', channel.guildId);
const parentChannel = await channel.guild.channels.fetch(channel.parentId);
return parentChannel?.parentId ? staffCategories.includes(parentChannel.parentId) : staffCategories.includes(channel.parentId);
}
export async function channelIsSafe(channel: GuildBasedChannel) {
if (!channel.guildId) return false;
const safeChannels = await Database.getGuildArraySetting('safe_channels', channel.guildId);
return safeChannels.includes(channel.id);
}
export async function channelIgnoresLinks(channel: GuildBasedChannel) {
if (!channel.guildId) return false;
const linkSkipChannels = await Database.getGuildArraySetting('link_skip_channels', channel.guildId);
return linkSkipChannels.includes(channel.id) || channel.parentId ? linkSkipChannels.includes(channel.parentId!) : false;
}
+22 -22
View File
@@ -1,22 +1,22 @@
import fs from 'fs';
import { Handler } from '../types';
import path from 'path';
import { Client } from 'discord.js';
const ROOT_DIR = path.resolve(__dirname, '..');
export function loadHandlersFrom(dir: string, handlerArray: Handler[]) {
if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return;
const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts'));
for (const file of files) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default);
}
}
export async function initIfNecessary(client: Client, handlers: Handler[]) {
for (const handler of handlers) {
if (handler.init) await handler.init(client);
}
}
import fs from 'fs';
import { Handler } from '../types';
import path from 'path';
import { Client } from 'discord.js';
const ROOT_DIR = path.resolve(__dirname, '..');
export function loadHandlersFrom(dir: string, handlerArray: Handler[]) {
if (!fs.existsSync(`${ROOT_DIR}/${dir}`)) return;
const files = fs.readdirSync(`${ROOT_DIR}/${dir}`).filter(file => file.endsWith('.js') || file.endsWith('.ts'));
for (const file of files) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
handlerArray.push(require(`${ROOT_DIR}/${dir}/${file}`).default);
}
}
export async function initIfNecessary(client: Client, handlers: Handler[]) {
for (const handler of handlers) {
if (handler.init) await handler.init(client);
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { config } from '../config';
export function logDebug(message: string) {
if (config.DEBUG) console.log(`[DEBUG] ${message}`);
import { config } from '../config';
export function logDebug(message: string) {
if (config.DEBUG) console.log(`[DEBUG] ${message}`);
}
+35 -35
View File
@@ -1,36 +1,36 @@
import { Client, Guild, User } from 'discord.js';
import { Database, PrivateHelpTicketStatus } from '../shared/Database';
export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise<User | null | undefined> {
let user: User | null | undefined = null;
try {
user = await client.users.fetch(value);
} catch {
user = client.users.cache.find(u => u.username == value);
if (!user && guild) {
user = guild.members.cache.find(m => m.displayName == value)?.user;
if (!user) {
try {
const users = await guild.members.fetch({
query: value
});
if (users.size > 0) user = users.first()!.user;
} catch { }
}
}
}
return user;
}
export async function canOpenPrivateHelpTicket(id: string): Promise<boolean> {
const latestTicket = await Database.getLatestPrivateHelpTicketBy(id);
if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false;
return true;
import { Client, Guild, User } from 'discord.js';
import { Database, PrivateHelpTicketStatus } from '../shared/Database';
export async function resolveUser(client: Client, value: string, guild: Guild | null = null): Promise<User | null | undefined> {
let user: User | null | undefined = null;
try {
user = await client.users.fetch(value);
} catch {
user = client.users.cache.find(u => u.username == value);
if (!user && guild) {
user = guild.members.cache.find(m => m.displayName == value)?.user;
if (!user) {
try {
const users = await guild.members.fetch({
query: value
});
if (users.size > 0) user = users.first()!.user;
} catch { }
}
}
}
return user;
}
export async function canOpenPrivateHelpTicket(id: string): Promise<boolean> {
const latestTicket = await Database.getLatestPrivateHelpTicketBy(id);
if (latestTicket && latestTicket.status == PrivateHelpTicketStatus.OPEN && Date.now() - new Date(latestTicket.timestamp).getTime() < 8.64e+7) return false;
return true;
}
+82 -82
View File
@@ -1,83 +1,83 @@
import { config } from '../config';
import { E621Post, E621User, Record } from '../types';
const BLACKLISTED_TAGS: string[] = [];
const BLACKLISTED_NONSAFE_TAGS: string[] = ['young'];
const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports'];
const SPOILERED_NONSAFE_TAGS: string[] = [];
const USER_AGENT = 'E621DiscordBot';
async function request(path: string, query?: { [name: string]: string }): Promise<any> {
const url = new URL(config.E621_BASE_URL!);
url.pathname = path + '.json';
if (query) {
for (const [name, value] of Object.entries(query)) {
url.searchParams.set(name, value);
}
}
const res = await fetch(url, {
headers: {
'User-Agent': USER_AGENT
}
});
if (!res.ok) return null;
return await res.json();
}
export async function getE621User(idOrName: string | number): Promise<E621User | null> {
return await request(`/users/${idOrName}`) as E621User;
}
export async function getE621Post(id: string | number): Promise<E621Post | null> {
return (await request(`/posts/${id}`))?.post as E621Post ?? null;
}
export async function getE621PostByMd5(md5: string): Promise<E621Post | null> {
return (await request('/posts', { md5 }))?.post as E621Post ?? null;
}
export const enum PostAction {
NoAction = 0,
Spoiler = 1,
Blacklist = 2
}
export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } {
const tags = Object.values(post.tags).flat();
for (const tag of tags) {
if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag };
if (post.rating != 's' && BLACKLISTED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag };
}
for (const tag of tags) {
if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag };
if (post.rating != 's' && SPOILERED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag };
}
return { action: PostAction.NoAction, tag: '' };
}
export function getPostUrl(post: E621Post): string {
if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`;
return `${config.E621_BASE_URL}/posts/${post.id}`;
}
export async function userIsBanned(idOrName: string | number): Promise<boolean> {
const user = await getE621User(idOrName);
return user?.is_banned ?? false;
}
export async function getUserRecords(id: number): Promise<Record[]> {
const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() });
if (records.user_feedbacks) return [];
return records as Record[];
import { config } from '../config';
import { E621Post, E621User, Record } from '../types';
const BLACKLISTED_TAGS: string[] = [];
const BLACKLISTED_NONSAFE_TAGS: string[] = ['young'];
const SPOILERED_TAGS: string[] = ['gore', 'feces', 'watersports'];
const SPOILERED_NONSAFE_TAGS: string[] = [];
const USER_AGENT = 'E621DiscordBot';
async function request(path: string, query?: { [name: string]: string }): Promise<any> {
const url = new URL(config.E621_BASE_URL!);
url.pathname = path + '.json';
if (query) {
for (const [name, value] of Object.entries(query)) {
url.searchParams.set(name, value);
}
}
const res = await fetch(url, {
headers: {
'User-Agent': USER_AGENT
}
});
if (!res.ok) return null;
return await res.json();
}
export async function getE621User(idOrName: string | number): Promise<E621User | null> {
return await request(`/users/${idOrName}`) as E621User;
}
export async function getE621Post(id: string | number): Promise<E621Post | null> {
return (await request(`/posts/${id}`))?.post as E621Post ?? null;
}
export async function getE621PostByMd5(md5: string): Promise<E621Post | null> {
return (await request('/posts', { md5 }))?.post as E621Post ?? null;
}
export const enum PostAction {
NoAction = 0,
Spoiler = 1,
Blacklist = 2
}
export function spoilerOrBlacklist(post: E621Post): { action: PostAction, tag: string } {
const tags = Object.values(post.tags).flat();
for (const tag of tags) {
if (BLACKLISTED_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag };
if (post.rating != 's' && BLACKLISTED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Blacklist, tag };
}
for (const tag of tags) {
if (SPOILERED_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag };
if (post.rating != 's' && SPOILERED_NONSAFE_TAGS.includes(tag)) return { action: PostAction.Spoiler, tag };
}
return { action: PostAction.NoAction, tag: '' };
}
export function getPostUrl(post: E621Post): string {
if (post.rating == 's') return `${config.E926_BASE_URL}/posts/${post.id}`;
return `${config.E621_BASE_URL}/posts/${post.id}`;
}
export async function userIsBanned(idOrName: string | number): Promise<boolean> {
const user = await getE621User(idOrName);
return user?.is_banned ?? false;
}
export async function getUserRecords(id: number): Promise<Record[]> {
const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() });
if (records.user_feedbacks) return [];
return records as Record[];
}
+218 -218
View File
@@ -1,219 +1,219 @@
import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js';
import { Message } from '../events';
import { Database } from '../shared/Database';
import { LoggedMessage } from '../types';
import { channelIsInStaffCategory } from './channel-utils';
import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils';
type CustomEventLogData = {
title: string
description: string | null
color: number | null
timestamp: Date | number | null
fields: APIEmbedField[] | null
}
export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message<true>) {
const channel = await getEventLogChannel(newMessage.guild, newMessage.channel);
if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024;
const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, newMessage));
fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed));
const embed = new EmbedBuilder()
.setTitle('Edited Message')
.setColor(0xFFFF00)
.setTimestamp(newMessage.createdTimestamp)
.addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' });
const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' });
messagePayload.files = [before, after];
}
channel.send(messagePayload);
}
export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message<true>) {
const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel);
if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024;
const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, deletedMessage));
fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed));
const embed = new EmbedBuilder()
.setTitle('Deleted Message')
.setColor(0xFF0000)
.setTimestamp(deletedMessage.createdTimestamp)
.addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' });
messagePayload.files = [before];
}
channel.send(messagePayload);
}
export async function logCustomEvent(guild: Guild, data: CustomEventLogData) {
const channel = await getEventLogChannel(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle(data.title)
.setColor(data.color)
.setTimestamp(data.timestamp);
if (data.fields) embed.addFields(...data.fields);
channel.send({ embeds: [embed] });
}
async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise<GuildTextBasedChannel | null> {
const settings = await Database.getGuildSettings(guild.id);
if (!settings) return null;
if (channel && await channelIsInStaffCategory(channel)) {
if (!settings.event_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.event_logs_channel_id);
if (!channel || !channel.isSendable()) return null;
return channel;
} else {
if (!settings.discord_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.discord_logs_channel_id);
if (!channel || !channel.isSendable()) return null;
return channel;
}
}
function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>): APIEmbedField[] {
const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`;
const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`;
return [
{
name: 'Channel',
value: channelString,
inline: true
},
{
name: 'User',
value: userString,
inline: true
},
{
name: 'Message',
value: `[${newMessage.id}](${newMessage.url})`,
inline: true
},
];
}
function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != '') {
fields.push({
name: 'Content',
value: loggedMessage.content,
inline: false
});
}
for (const attachment of deserializeMessagePart(loggedMessage.attachments)) {
fields.push({
name: 'Attachment',
value: attachment,
inline: true
});
}
for (const sticker of deserializeMessagePart(loggedMessage.stickers)) {
fields.push({
name: 'Stickers',
value: sticker,
inline: true
});
}
return fields;
}
function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != newMessage.content) {
fields.push(
{
name: 'Before',
value: loggedMessage.content,
inline: false
},
{
name: 'After',
value: newMessage.content,
inline: false
}
);
}
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
for (const removedAttachment of removedAttachments) {
fields.push({
name: 'Removed Attachment',
value: removedAttachment,
inline: true
});
}
for (const addedAttachment of addedAttachments) {
fields.push({
name: 'Added Attachment',
value: addedAttachment,
inline: true
});
}
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
for (const removedSticker of addedStickers) {
fields.push({
name: 'Removed Sticker',
value: removedSticker,
inline: true
});
}
for (const addedSticker of removedStickers) {
fields.push({
name: 'Added Sticker',
value: addedSticker,
inline: true
});
}
return fields;
import { APIEmbedField, AttachmentBuilder, EmbedBuilder, Guild, GuildBasedChannel, GuildTextBasedChannel, MessageCreateOptions } from 'discord.js';
import { Message } from '../events';
import { Database } from '../shared/Database';
import { LoggedMessage } from '../types';
import { channelIsInStaffCategory } from './channel-utils';
import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils';
type CustomEventLogData = {
title: string
description: string | null
color: number | null
timestamp: Date | number | null
fields: APIEmbedField[] | null
}
export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message<true>) {
const channel = await getEventLogChannel(newMessage.guild, newMessage.channel);
if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024 && newMessage.content.length <= 1024;
const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, newMessage));
fields.push(...getEditEmbeds(loggedMessage, newMessage, includeContentInEmbed));
const embed = new EmbedBuilder()
.setTitle('Edited Message')
.setColor(0xFFFF00)
.setTimestamp(newMessage.createdTimestamp)
.addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'before.txt' });
const after = new AttachmentBuilder(Buffer.from(newMessage.content), { name: 'after.txt' });
messagePayload.files = [before, after];
}
channel.send(messagePayload);
}
export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message<true>) {
const channel = await getEventLogChannel(deletedMessage.guild, deletedMessage.channel);
if (!channel) return;
const includeContentInEmbed = loggedMessage.content.length <= 1024;
const fields: APIEmbedField[] = [];
fields.push(...getMainEmbeds(loggedMessage, deletedMessage));
fields.push(...getDeletedEmbeds(loggedMessage, includeContentInEmbed));
const embed = new EmbedBuilder()
.setTitle('Deleted Message')
.setColor(0xFF0000)
.setTimestamp(deletedMessage.createdTimestamp)
.addFields(...fields);
const messagePayload: MessageCreateOptions = { embeds: [embed] };
if (!includeContentInEmbed) {
const before = new AttachmentBuilder(Buffer.from(loggedMessage.content), { name: 'content.txt' });
messagePayload.files = [before];
}
channel.send(messagePayload);
}
export async function logCustomEvent(guild: Guild, data: CustomEventLogData) {
const channel = await getEventLogChannel(guild);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle(data.title)
.setColor(data.color)
.setTimestamp(data.timestamp);
if (data.fields) embed.addFields(...data.fields);
channel.send({ embeds: [embed] });
}
async function getEventLogChannel(guild: Guild, channel: GuildBasedChannel | null = null): Promise<GuildTextBasedChannel | null> {
const settings = await Database.getGuildSettings(guild.id);
if (!settings) return null;
if (channel && await channelIsInStaffCategory(channel)) {
if (!settings.event_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.event_logs_channel_id);
if (!channel || !channel.isSendable()) return null;
return channel;
} else {
if (!settings.discord_logs_channel_id) return null;
const channel = await guild.channels.fetch(settings.discord_logs_channel_id);
if (!channel || !channel.isSendable()) return null;
return channel;
}
}
function getMainEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>): APIEmbedField[] {
const channelString = `${newMessage.channel.toString()}\n${newMessage.channel.name}`;
const userString = `<@${loggedMessage.author_id}>\n${loggedMessage.author_name}`;
return [
{
name: 'Channel',
value: channelString,
inline: true
},
{
name: 'User',
value: userString,
inline: true
},
{
name: 'Message',
value: `[${newMessage.id}](${newMessage.url})`,
inline: true
},
];
}
function getDeletedEmbeds(loggedMessage: LoggedMessage, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != '') {
fields.push({
name: 'Content',
value: loggedMessage.content,
inline: false
});
}
for (const attachment of deserializeMessagePart(loggedMessage.attachments)) {
fields.push({
name: 'Attachment',
value: attachment,
inline: true
});
}
for (const sticker of deserializeMessagePart(loggedMessage.stickers)) {
fields.push({
name: 'Stickers',
value: sticker,
inline: true
});
}
return fields;
}
function getEditEmbeds(loggedMessage: LoggedMessage, newMessage: Message<true>, includeContentInEmbed = true): APIEmbedField[] {
const fields: APIEmbedField[] = [];
if (includeContentInEmbed && loggedMessage.content != newMessage.content) {
fields.push(
{
name: 'Before',
value: loggedMessage.content,
inline: false
},
{
name: 'After',
value: newMessage.content,
inline: false
}
);
}
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
for (const removedAttachment of removedAttachments) {
fields.push({
name: 'Removed Attachment',
value: removedAttachment,
inline: true
});
}
for (const addedAttachment of addedAttachments) {
fields.push({
name: 'Added Attachment',
value: addedAttachment,
inline: true
});
}
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
for (const removedSticker of addedStickers) {
fields.push({
name: 'Removed Sticker',
value: removedSticker,
inline: true
});
}
for (const addedSticker of removedStickers) {
fields.push({
name: 'Added Sticker',
value: addedSticker,
inline: true
});
}
return fields;
}
+70 -70
View File
@@ -1,71 +1,71 @@
import crypto from 'crypto';
const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26;
const END_PNG_BYTES = 12;
const DISCORD_JPG_START_OFFSET = 3;
const DISCORD_JPG_REMOVE_BYTE_LENGTH = 23;
const DISCORD_JPG_REINSERT = Buffer.from([0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
const DISCORD_JPG_REINSERT_BYTE_LENGTH = DISCORD_JPG_REINSERT.byteLength;
export const ALLOWED_MIMETYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'video/mp4', 'video/webm'];
export function calculateMD5(data: Buffer): string {
return crypto.createHash('md5').update(data).digest('hex');
}
// Downloads a file from discord's CDN and reverts the changes they do to the file.
// Returns both the corrected version at index 0, and the original version from discord at index 1.
export async function downloadFile(url: string): Promise<Buffer[] | null> {
try {
const res = await fetch(url);
const mimeType = res.headers.get('Content-Type')!;
if (!ALLOWED_MIMETYPES.includes(mimeType)) return null;
const data = await res.arrayBuffer();
let finalData: Buffer;
if (mimeType == 'image/png') {
const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES;
const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, startOffset);
buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
finalData = correctedData;
} else if (mimeType == 'image/jpg' || mimeType == 'image/jpeg') {
const correctedData = Buffer.alloc(data.byteLength - DISCORD_JPG_REMOVE_BYTE_LENGTH + DISCORD_JPG_REINSERT_BYTE_LENGTH);
const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET);
DISCORD_JPG_REINSERT.copy(correctedData, DISCORD_JPG_START_OFFSET);
buff.copy(correctedData, DISCORD_JPG_REMOVE_BYTE_LENGTH - DISCORD_JPG_START_OFFSET, DISCORD_JPG_START_OFFSET + DISCORD_JPG_REMOVE_BYTE_LENGTH);
finalData = correctedData;
} else {
finalData = Buffer.from(data);
}
return [finalData, Buffer.from(data)];
} catch (e) {
console.error(e);
return null;
}
}
// This method is used with discord CDN URLs.
// Discord does slight modifications to the data, which will change the MD5, this method reverts those changes.
export async function calculateMD5FromURL(url: string): Promise<{ correctedFileMD5: string, originalFileMD5: string } | null> {
try {
const files = await downloadFile(url);
if (!files) return null;
return {
correctedFileMD5: calculateMD5(files[0]),
originalFileMD5: calculateMD5(files[1])
};
} catch (e) {
console.error(e);
return null;
}
import crypto from 'crypto';
const DISCORD_PNG_ADDITIONAL_BYTE_LENGTH = 26;
const END_PNG_BYTES = 12;
const DISCORD_JPG_START_OFFSET = 3;
const DISCORD_JPG_REMOVE_BYTE_LENGTH = 23;
const DISCORD_JPG_REINSERT = Buffer.from([0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00]);
const DISCORD_JPG_REINSERT_BYTE_LENGTH = DISCORD_JPG_REINSERT.byteLength;
export const ALLOWED_MIMETYPES = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'video/mp4', 'video/webm'];
export function calculateMD5(data: Buffer): string {
return crypto.createHash('md5').update(data).digest('hex');
}
// Downloads a file from discord's CDN and reverts the changes they do to the file.
// Returns both the corrected version at index 0, and the original version from discord at index 1.
export async function downloadFile(url: string): Promise<Buffer[] | null> {
try {
const res = await fetch(url);
const mimeType = res.headers.get('Content-Type')!;
if (!ALLOWED_MIMETYPES.includes(mimeType)) return null;
const data = await res.arrayBuffer();
let finalData: Buffer;
if (mimeType == 'image/png') {
const startOffset = data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH - END_PNG_BYTES;
const correctedData = Buffer.alloc(data.byteLength - DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, startOffset);
buff.copy(correctedData, startOffset, startOffset + DISCORD_PNG_ADDITIONAL_BYTE_LENGTH);
finalData = correctedData;
} else if (mimeType == 'image/jpg' || mimeType == 'image/jpeg') {
const correctedData = Buffer.alloc(data.byteLength - DISCORD_JPG_REMOVE_BYTE_LENGTH + DISCORD_JPG_REINSERT_BYTE_LENGTH);
const buff = Buffer.from(data);
buff.copy(correctedData, 0, 0, DISCORD_JPG_START_OFFSET);
DISCORD_JPG_REINSERT.copy(correctedData, DISCORD_JPG_START_OFFSET);
buff.copy(correctedData, DISCORD_JPG_REMOVE_BYTE_LENGTH - DISCORD_JPG_START_OFFSET, DISCORD_JPG_START_OFFSET + DISCORD_JPG_REMOVE_BYTE_LENGTH);
finalData = correctedData;
} else {
finalData = Buffer.from(data);
}
return [finalData, Buffer.from(data)];
} catch (e) {
console.error(e);
return null;
}
}
// This method is used with discord CDN URLs.
// Discord does slight modifications to the data, which will change the MD5, this method reverts those changes.
export async function calculateMD5FromURL(url: string): Promise<{ correctedFileMD5: string, originalFileMD5: string } | null> {
try {
const files = await downloadFile(url);
if (!files) return null;
return {
correctedFileMD5: calculateMD5(files[0]),
originalFileMD5: calculateMD5(files[1])
};
} catch (e) {
console.error(e);
return null;
}
}
+18 -18
View File
@@ -1,19 +1,19 @@
import { Database } from '../shared/Database';
const mentionRegex = new RegExp('@([\\S]+),|@([\\S]+)', 'gi');
const issueLinkRegex = new RegExp('\\s?\\(\\[(#\\d+)\\]\\(https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)\\)', 'gi');
export async function fixPings(body: string): Promise<string> {
const mappings = await Database.getAllGithubUserMappings();
return body.replaceAll(mentionRegex, (match, m1, m2) => {
const name = m1 ?? m2;
const mapping = mappings.find(m => m.github_username == name);
return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match;
});
}
export function removeIssueLinks(body: string): string {
return body.replaceAll(issueLinkRegex, '');
import { Database } from '../shared/Database';
const mentionRegex = new RegExp('@([\\S]+),|@([\\S]+)', 'gi');
const issueLinkRegex = new RegExp('\\s?\\(\\[(#\\d+)\\]\\(https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_+.~#?&//=]*)\\)', 'gi');
export async function fixPings(body: string): Promise<string> {
const mappings = await Database.getAllGithubUserMappings();
return body.replaceAll(mentionRegex, (match, m1, m2) => {
const name = m1 ?? m2;
const mapping = mappings.find(m => m.github_username == name);
return mapping ? `<@${mapping.discord_id}>${match.endsWith(',') ? ',' : ''}` : match;
});
}
export function removeIssueLinks(body: string): string {
return body.replaceAll(issueLinkRegex, '');
}
+29 -29
View File
@@ -1,29 +1,29 @@
export * from './alt-utils';
export * from './array-utils';
export * from './audit-log-utils';
export * from './ban-events';
export * from './ban-utils';
export * from './channel-utils';
export * from './commands';
export * from './debug-utils';
export * from './discord-user-utils';
export * from './e621-utils';
export * from './event-log-utils';
export * from './file-utils';
export * from './github-user-utils';
export * from './interaction-utils';
export * from './message-matcher-regex';
export * from './message-utils';
export * from './modal-utils';
export * from './ms-to-human';
export * from './name-sync';
export * from './note-utils';
export * from './oauth2';
export * from './private-help-utils';
export * from './record-utils';
export * from './refresh-commands';
export * from './string-utils';
export * from './ticket-events';
export * from './ticket-utils';
export * from './wait';
export * from './whois';
export * from './alt-utils';
export * from './array-utils';
export * from './audit-log-utils';
export * from './ban-events';
export * from './ban-utils';
export * from './channel-utils';
export * from './commands';
export * from './debug-utils';
export * from './discord-user-utils';
export * from './e621-utils';
export * from './event-log-utils';
export * from './file-utils';
export * from './github-user-utils';
export * from './interaction-utils';
export * from './message-matcher-regex';
export * from './message-utils';
export * from './modal-utils';
export * from './ms-to-human';
export * from './name-sync';
export * from './note-utils';
export * from './oauth2';
export * from './private-help-utils';
export * from './record-utils';
export * from './refresh-commands';
export * from './string-utils';
export * from './ticket-events';
export * from './ticket-utils';
export * from './wait';
export * from './whois';
+8 -8
View File
@@ -1,9 +1,9 @@
import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { channelIsInStaffCategory } from './channel-utils';
export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) {
const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel);
if (isStaffChannel) await interaction.deferReply();
else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
import { ChatInputCommandInteraction, ContextMenuCommandInteraction, GuildBasedChannel, MessageFlags, ModalSubmitInteraction } from 'discord.js';
import { channelIsInStaffCategory } from './channel-utils';
export async function deferInteraction(interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction | ModalSubmitInteraction) {
const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel);
if (isStaffChannel) await interaction.deferReply();
else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
}
+17 -17
View File
@@ -1,18 +1,18 @@
export const postIDRegex = new RegExp('post #([0-9]+)', 'gi');
export const userIDRegex = new RegExp('user #([0-9]+)', 'gi');
export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi');
export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi');
export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi');
export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi');
export const setIDRegex = new RegExp('set #([0-9]+)', 'gi');
export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi');
export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi');
export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi');
export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi');
const tagSearchRegex = '(?:[\\S]| )+?';
export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi');
export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi');
export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi');
export const postIDRegex = new RegExp('post #([0-9]+)', 'gi');
export const userIDRegex = new RegExp('user #([0-9]+)', 'gi');
export const forumTopicIDRegex = new RegExp('topic #([0-9]+)', 'gi');
export const commentIDRegex = new RegExp('comment #([0-9]+)', 'gi');
export const blipIDRegex = new RegExp('blip #([0-9]+)', 'gi');
export const poolIDRegex = new RegExp('pool #([0-9]+)', 'gi');
export const setIDRegex = new RegExp('set #([0-9]+)', 'gi');
export const takedownIDRegex = new RegExp('takedown #([0-9]+)', 'gi');
export const recordIDRegex = new RegExp('record #([0-9]+)', 'gi');
export const ticketIDRegex = new RegExp('ticket #([0-9]+)', 'gi');
export const artistIDRegex = new RegExp('artist #([0-9]+)', 'gi');
const tagSearchRegex = '(?:[\\S]| )+?';
export const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi');
export const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi');
export const prRegex = new RegExp('(?:pr|pull) #([0-9]+)', 'gi');
export const issueRegex = new RegExp('issue #([0-9]+)', 'gi');
+59 -59
View File
@@ -1,60 +1,60 @@
import { Message } from '../events';
import { LoggedMessage } from '../types';
export const ARRAY_SEPARATOR = '$';
const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi');
export function serializeMessage(message: Message): string[] {
const attachments = message.attachments.map(a => `${a.name}:${a.id}`);
const stickers = message.stickers.map(s => `${s.name}:${s.id}`);
return [message.id, message.author.id, message.author.username, message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), message.content];
}
export function deserializeMessagePart(part: string): string[] {
return part.split(ARRAY_SEPARATOR).filter(e => e);
}
export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } {
const loggedAttachments = loggedMessage.attachments.split(ARRAY_SEPARATOR).filter(e => e);
const addedAttachments = newMessage.attachments.filter(a => !loggedAttachments.includes(`${a.name}:${a.id}`)).map(a => `${a.name}:${a.id}`);
const removedAttachments = loggedAttachments.filter(a => !newMessage.attachments.has(a.split(':').at(-1)!));
return { addedAttachments, removedAttachments };
}
export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } {
const loggedStickers = loggedMessage.stickers.split(ARRAY_SEPARATOR).filter(e => e);
const addedStickers = newMessage.stickers.filter(s => !loggedStickers.includes(`${s.name}:${s.id}`)).map(s => `${s.name}:${s.id}`);
const removedStickers = loggedStickers.filter(s => !newMessage.stickers.has(s.split(':').at(-1)!));
return { addedStickers, removedStickers };
}
export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) {
if (newMessage.content != loggedMessage.content) return true;
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
if (addedAttachments.length > 0 || removedAttachments.length > 0) return true;
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
return addedStickers.length > 0 || removedStickers.length > 0;
}
export function isInSpoilerTags(content: string, index: number): boolean {
if (!content.includes('||')) return false;
let match: RegExpExecArray | null;
while ((match = spoilerRegex.exec(content)) != null) {
if (index >= match.index && index <= spoilerRegex.lastIndex) {
spoilerRegex.lastIndex = 0;
return true;
}
}
spoilerRegex.lastIndex = 0;
return false;
import { Message } from '../events';
import { LoggedMessage } from '../types';
export const ARRAY_SEPARATOR = '$';
const spoilerRegex = new RegExp('\\|\\|((?:[\\S]| )+?)\\|\\|', 'gi');
export function serializeMessage(message: Message): string[] {
const attachments = message.attachments.map(a => `${a.name}:${a.id}`);
const stickers = message.stickers.map(s => `${s.name}:${s.id}`);
return [message.id, message.author.id, message.author.username, message.channelId, attachments.join(ARRAY_SEPARATOR), stickers.join(ARRAY_SEPARATOR), message.content];
}
export function deserializeMessagePart(part: string): string[] {
return part.split(ARRAY_SEPARATOR).filter(e => e);
}
export function getModifiedAttachments(loggedMessage: LoggedMessage, newMessage: Message): { addedAttachments: string[], removedAttachments: string[] } {
const loggedAttachments = loggedMessage.attachments.split(ARRAY_SEPARATOR).filter(e => e);
const addedAttachments = newMessage.attachments.filter(a => !loggedAttachments.includes(`${a.name}:${a.id}`)).map(a => `${a.name}:${a.id}`);
const removedAttachments = loggedAttachments.filter(a => !newMessage.attachments.has(a.split(':').at(-1)!));
return { addedAttachments, removedAttachments };
}
export function getModifiedStickers(loggedMessage: LoggedMessage, newMessage: Message): { addedStickers: string[], removedStickers: string[] } {
const loggedStickers = loggedMessage.stickers.split(ARRAY_SEPARATOR).filter(e => e);
const addedStickers = newMessage.stickers.filter(s => !loggedStickers.includes(`${s.name}:${s.id}`)).map(s => `${s.name}:${s.id}`);
const removedStickers = loggedStickers.filter(s => !newMessage.stickers.has(s.split(':').at(-1)!));
return { addedStickers, removedStickers };
}
export function isEdited(loggedMessage: LoggedMessage, newMessage: Message) {
if (newMessage.content != loggedMessage.content) return true;
const { addedAttachments, removedAttachments } = getModifiedAttachments(loggedMessage, newMessage);
if (addedAttachments.length > 0 || removedAttachments.length > 0) return true;
const { addedStickers, removedStickers } = getModifiedStickers(loggedMessage, newMessage);
return addedStickers.length > 0 || removedStickers.length > 0;
}
export function isInSpoilerTags(content: string, index: number): boolean {
if (!content.includes('||')) return false;
let match: RegExpExecArray | null;
while ((match = spoilerRegex.exec(content)) != null) {
if (index >= match.index && index <= spoilerRegex.lastIndex) {
spoilerRegex.lastIndex = 0;
return true;
}
}
spoilerRegex.lastIndex = 0;
return false;
}
+42 -42
View File
@@ -1,43 +1,43 @@
import { LabelBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
export function createTextInput(customId: string, labelTitle: string, description: string | null, required: boolean, style: TextInputStyle, maxLength: number | null, minLength: number | null): LabelBuilder {
const input = new TextInputBuilder()
.setCustomId(customId)
.setStyle(style)
.setRequired(required);
if (maxLength !== null) input.setMaxLength(maxLength);
if (minLength !== null) input.setMinLength(minLength);
const label = new LabelBuilder()
.setLabel(labelTitle);
if (description !== null) label.setDescription(description);
label.setTextInputComponent(input);
return label;
}
export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder {
const yesNoMenu = new StringSelectMenuBuilder()
.setCustomId(customId)
.addOptions(
new StringSelectMenuOptionBuilder()
.setLabel('Yes')
.setDefault(defaultYes)
.setValue('yes'),
new StringSelectMenuOptionBuilder()
.setLabel('No')
.setDefault(!defaultYes)
.setValue('no')
);
const yesNoLabel = new LabelBuilder()
.setLabel(labelTitle)
.setStringSelectMenuComponent(yesNoMenu);
if (description !== null) yesNoLabel.setDescription(description);
return yesNoLabel;
import { LabelBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from 'discord.js';
export function createTextInput(customId: string, labelTitle: string, description: string | null, required: boolean, style: TextInputStyle, maxLength: number | null, minLength: number | null): LabelBuilder {
const input = new TextInputBuilder()
.setCustomId(customId)
.setStyle(style)
.setRequired(required);
if (maxLength !== null) input.setMaxLength(maxLength);
if (minLength !== null) input.setMinLength(minLength);
const label = new LabelBuilder()
.setLabel(labelTitle);
if (description !== null) label.setDescription(description);
label.setTextInputComponent(input);
return label;
}
export function createYesNoMenu(customId: string, labelTitle: string, description: string | null, defaultYes: boolean): LabelBuilder {
const yesNoMenu = new StringSelectMenuBuilder()
.setCustomId(customId)
.addOptions(
new StringSelectMenuOptionBuilder()
.setLabel('Yes')
.setDefault(defaultYes)
.setValue('yes'),
new StringSelectMenuOptionBuilder()
.setLabel('No')
.setDefault(!defaultYes)
.setValue('no')
);
const yesNoLabel = new LabelBuilder()
.setLabel(labelTitle)
.setStringSelectMenuComponent(yesNoMenu);
if (description !== null) yesNoLabel.setDescription(description);
return yesNoLabel;
}
+11 -11
View File
@@ -1,12 +1,12 @@
export function msToHuman(ms: number) {
const time = {
day: Math.floor(ms / 86400000),
hour: Math.floor(ms / 3600000) % 24,
minute: Math.floor(ms / 60000) % 60,
second: Math.floor(ms / 1000) % 60,
};
return Object.entries(time)
.filter(val => val[1] !== 0)
.map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`)
.join(', ');
export function msToHuman(ms: number) {
const time = {
day: Math.floor(ms / 86400000),
hour: Math.floor(ms / 3600000) % 24,
minute: Math.floor(ms / 60000) % 60,
second: Math.floor(ms / 1000) % 60,
};
return Object.entries(time)
.filter(val => val[1] !== 0)
.map(([key, val]) => `${val} ${key}${val !== 1 ? 's' : ''}`)
.join(', ');
}
+27 -27
View File
@@ -1,28 +1,28 @@
import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { E621User } from '../types';
import { getE621User } from './e621-utils';
export async function syncName(interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ModalSubmitInteraction, member: GuildMember, id: number | null) {
if (member.roles.highest.comparePositionTo(member.guild.members.me!.roles.highest) > 0) {
const res = interaction.user.id == member.id ? 'your' : 'their';
return interaction.editReply(`I am unable to set ${res} nickname as ${res} role is higher than mine.`);
}
const availableIds = await Database.getE621Ids(interaction.user.id);
let e621User: E621User | null;
if (!id || !availableIds.includes(id)) {
e621User = await getE621User(availableIds[0]);
} else {
e621User = await getE621User(id);
}
if (!e621User) {
return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator.");
}
await member.setNickname(e621User.name);
interaction.editReply(`Nickname set to: ${e621User.name}`);
import { ChatInputCommandInteraction, GuildMember, ModalSubmitInteraction, UserContextMenuCommandInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { E621User } from '../types';
import { getE621User } from './e621-utils';
export async function syncName(interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ModalSubmitInteraction, member: GuildMember, id: number | null) {
if (member.roles.highest.comparePositionTo(member.guild.members.me!.roles.highest) > 0) {
const res = interaction.user.id == member.id ? 'your' : 'their';
return interaction.editReply(`I am unable to set ${res} nickname as ${res} role is higher than mine.`);
}
const availableIds = await Database.getE621Ids(interaction.user.id);
let e621User: E621User | null;
if (!id || !availableIds.includes(id)) {
e621User = await getE621User(availableIds[0]);
} else {
e621User = await getE621User(id);
}
if (!e621User) {
return interaction.editReply("Couldn't figure out what your name was. Please contact an administrator.");
}
await member.setNickname(e621User.name);
interaction.editReply(`Nickname set to: ${e621User.name}`);
}
+48 -48
View File
@@ -1,49 +1,49 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js';
import { Database } from '../shared/Database';
import { MessageContent, Note } from '../types';
const NOTES_PER_PAGE = 5;
function getNoteText(note: Note): string {
const timestamp = time(new Date(note.timestamp));
return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`;
}
export async function getNoteMessage(userId: string, page: number): Promise<MessageContent | null> {
const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
page = page - 1;
const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1);
if (notes.length == 0) return null;
const noteTexts: string[] = [];
for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) {
if (i >= notes.length) break;
noteTexts.push(getNoteText(notes[i]));
}
const prevPage = new ButtonBuilder()
.setLabel('Previous Page')
.setCustomId(`note-previous_${userId}_${page + 1}`)
.setDisabled(page == 0)
.setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder()
.setLabel('Next Page')
.setCustomId(`note-next_${userId}_${page + 1}`)
.setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage);
return {
content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`,
components: [row]
};
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, time } from 'discord.js';
import { Database } from '../shared/Database';
import { MessageContent, Note } from '../types';
const NOTES_PER_PAGE = 5;
function getNoteText(note: Note): string {
const timestamp = time(new Date(note.timestamp));
return `### Note by <@${note.mod_id}> (${timestamp}):\n${note.reason}`;
}
export async function getNoteMessage(userId: string, page: number): Promise<MessageContent | null> {
const notes = (await Database.getNotes(userId)).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
page = page - 1;
const maxPage = Math.max(0, Math.ceil(notes.length / NOTES_PER_PAGE) - 1);
if (notes.length == 0) return null;
const noteTexts: string[] = [];
for (let i = page * NOTES_PER_PAGE; i < page * NOTES_PER_PAGE + NOTES_PER_PAGE; i++) {
if (i >= notes.length) break;
noteTexts.push(getNoteText(notes[i]));
}
const prevPage = new ButtonBuilder()
.setLabel('Previous Page')
.setCustomId(`note-previous_${userId}_${page + 1}`)
.setDisabled(page == 0)
.setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder()
.setLabel('Next Page')
.setCustomId(`note-next_${userId}_${page + 1}`)
.setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage);
return {
content: `<@${userId}>'s Notes\n` + noteTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`,
components: [row]
};
}
+136 -136
View File
@@ -1,137 +1,137 @@
type ClientOptions = {
clientId: string
clientSecret: string
clientToken: string
redirectUri: string
credentials: string
};
type GenerateUrlParameters = {
state: string
scope: string[]
type: 'code' | 'token'
};
type TokenResponse = {
access_token: string
token_type: string
expires_in: number
refresh_token: string
scope: string
}
type DiscordUser = {
id: string
username: string
// bunch of other stuff we don't use
}
type AddMemberOptions = {
accessToken: string
botToken?: string
guildId: string
userId: string
nickname?: string
}
const OAUTH_BASE_URL = 'https://discord.com/oauth2';
const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2';
const API_BASE_URL = 'https://discord.com/api';
export class DiscordOAuth2 {
constructor(private options: ClientOptions) { }
generateOauth2Url(options: GenerateUrlParameters) {
const url = new URL(`${OAUTH_BASE_URL}/authorize`);
const params = new URLSearchParams({
client_id: this.options.clientId,
response_type: options.type,
redirect_uri: this.options.redirectUri,
scope: options.scope.join('+'),
state: options.state
});
url.search = params.toString();
return url.toString();
}
async getAccessToken(code: string, scope: string[]): Promise<TokenResponse> {
const res = await fetch(`${OAUTH_API_BASE_URL}/token`, {
method: 'POST',
body: new URLSearchParams({
client_id: this.options.clientId,
client_secret: this.options.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: this.options.redirectUri,
scope: scope.join(' ')
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
});
const data = await res.json();
return data as TokenResponse;
}
async getUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${API_BASE_URL}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
return await res.json() as DiscordUser;
}
async addMember(options: AddMemberOptions) {
const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, {
method: 'PUT',
body: JSON.stringify({
nick: options.nickname,
access_token: options.accessToken
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bot ${this.options.clientToken}`,
Accept: 'application/json'
}
});
if (res.status < 200 || res.status >= 300) {
console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`);
const text = await res.text();
console.error(text);
let data = { code: 0 };
try {
data = JSON.parse(text);
} catch { }
throw data;
}
return await res.json();
}
async revokeToken(token: string) {
const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, {
method: 'POST',
body: new URLSearchParams({
token
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${this.options.credentials}`,
Accept: 'application/json'
}
});
return await res.json();
}
type ClientOptions = {
clientId: string
clientSecret: string
clientToken: string
redirectUri: string
credentials: string
};
type GenerateUrlParameters = {
state: string
scope: string[]
type: 'code' | 'token'
};
type TokenResponse = {
access_token: string
token_type: string
expires_in: number
refresh_token: string
scope: string
}
type DiscordUser = {
id: string
username: string
// bunch of other stuff we don't use
}
type AddMemberOptions = {
accessToken: string
botToken?: string
guildId: string
userId: string
nickname?: string
}
const OAUTH_BASE_URL = 'https://discord.com/oauth2';
const OAUTH_API_BASE_URL = 'https://discord.com/api/oauth2';
const API_BASE_URL = 'https://discord.com/api';
export class DiscordOAuth2 {
constructor(private options: ClientOptions) { }
generateOauth2Url(options: GenerateUrlParameters) {
const url = new URL(`${OAUTH_BASE_URL}/authorize`);
const params = new URLSearchParams({
client_id: this.options.clientId,
response_type: options.type,
redirect_uri: this.options.redirectUri,
scope: options.scope.join('+'),
state: options.state
});
url.search = params.toString();
return url.toString();
}
async getAccessToken(code: string, scope: string[]): Promise<TokenResponse> {
const res = await fetch(`${OAUTH_API_BASE_URL}/token`, {
method: 'POST',
body: new URLSearchParams({
client_id: this.options.clientId,
client_secret: this.options.clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: this.options.redirectUri,
scope: scope.join(' ')
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
});
const data = await res.json();
return data as TokenResponse;
}
async getUser(accessToken: string): Promise<DiscordUser> {
const res = await fetch(`${API_BASE_URL}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
return await res.json() as DiscordUser;
}
async addMember(options: AddMemberOptions) {
const res = await fetch(`${API_BASE_URL}/guilds/${options.guildId}/members/${options.userId}`, {
method: 'PUT',
body: JSON.stringify({
nick: options.nickname,
access_token: options.accessToken
}),
headers: {
'Content-Type': 'application/json',
Authorization: `Bot ${this.options.clientToken}`,
Accept: 'application/json'
}
});
if (res.status < 200 || res.status >= 300) {
console.error(`Non 200 code while joining user (${options.userId}) to discord (${res.status}):`);
const text = await res.text();
console.error(text);
let data = { code: 0 };
try {
data = JSON.parse(text);
} catch { }
throw data;
}
return await res.json();
}
async revokeToken(token: string) {
const res = await fetch(`${OAUTH_API_BASE_URL}/token/revoke`, {
method: 'POST',
body: new URLSearchParams({
token
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${this.options.credentials}`,
Accept: 'application/json'
}
});
return await res.json();
}
}
+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 { Database } from '../shared/Database';
import { createTextInput, createYesNoMenu } from './modal-utils';
export async function closeOldTickets(client: Client) {
for (const ticket of await Database.getAllOpenPrivateHelpTickets()) {
try {
const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel;
const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0);
if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) {
await Database.closePrivateHelpTicket(thread.id);
await thread.send('This ticket has been closed due to inactivity.');
thread.edit({
archived: true,
locked: true
});
}
} catch (e) {
console.error('Error closing ticket due to inactivity:');
console.error(e);
}
}
}
export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise<PrivateThreadChannel | null> {
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null;
const channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel;
const thread = await channel.threads.create({
name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'),
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
invitable: false,
type: ChannelType.PrivateThread
}) as PrivateThreadChannel;
if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id);
if (creator) {
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket')
.setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await thread.send({
content: `${creator} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`,
components: [row],
allowedMentions: {
users: [creator.id],
roles: [guildSettings.private_help_role_id]
}
});
} else {
const closeButton = new ButtonBuilder()
.setCustomId('close-mod-ticket')
.setLabel('Close Mod Ticket')
.setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton);
await thread.send({
content: reason,
components: [row],
allowedMentions: {
users: Array.from(new Set(additionalMembersToAdd))
}
});
}
for (const id of additionalMembersToAdd) {
await thread.members.add(id);
}
return thread;
}
export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) {
const modal = new ModalBuilder()
.setCustomId(`open-mod-ticket_${member.id}`)
.setTitle('Opening A Mod Ticket');
const titleLabel = createTextInput('title', 'Title', `The name of the thread. Defaults to "Mod Ticket For ${member.displayName}" if left empty`, false, TextInputStyle.Short, 100, null);
const initialMessageLabel = createTextInput('initial-message', 'Inital Message', 'The inital message sent in the thread', false, TextInputStyle.Paragraph, 1800, null);
const autoJoinLabel = createYesNoMenu('auto-join-thread', 'Auto Join Thread', 'Whether or not to join you to the thread. Selecting no will not notify you of messages sent!', true);
modal.addLabelComponents(titleLabel, initialMessageLabel, autoJoinLabel);
interaction.showModal(modal);
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, Client, Guild, GuildMember, ModalBuilder, PrivateThreadChannel, TextChannel, TextInputStyle, ThreadAutoArchiveDuration, ThreadChannel, UserContextMenuCommandInteraction } from 'discord.js';
import { Database } from '../shared/Database';
import { createTextInput, createYesNoMenu } from './modal-utils';
export async function closeOldTickets(client: Client) {
for (const ticket of await Database.getAllOpenPrivateHelpTickets()) {
try {
const thread = await client.channels.fetch(ticket.thread_id) as ThreadChannel;
const latestMessage = (await thread.messages.fetch({ limit: 1 })).at(0);
if (latestMessage && latestMessage.createdTimestamp <= Date.now() - 432e6) {
await Database.closePrivateHelpTicket(thread.id);
await thread.send('This ticket has been closed due to inactivity.');
thread.edit({
archived: true,
locked: true
});
}
} catch (e) {
console.error('Error closing ticket due to inactivity:');
console.error(e);
}
}
}
export async function createPrivateHelpTicketThread(client: Client, guild: Guild, creator: GuildMember | null, reason: string, customTitle: string = '', additionalMembersToAdd: string[] = []): Promise<PrivateThreadChannel | null> {
const guildSettings = await Database.getGuildSettings(guild.id);
if (!guildSettings || !guildSettings.private_help_channel_id || !guildSettings.private_help_role_id) return null;
const channel = await client.channels.fetch(guildSettings.private_help_channel_id) as TextChannel;
const thread = await channel.threads.create({
name: customTitle ? customTitle : (creator ? `${creator.displayName}'s Ticket` : 'Mod Ticket'),
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
invitable: false,
type: ChannelType.PrivateThread
}) as PrivateThreadChannel;
if (creator) await Database.createPrivateHelpTicket(creator.id, thread.id);
if (creator) {
const closeButton = new ButtonBuilder()
.setCustomId('close-ticket')
.setLabel('Click here if you no longer need help')
.setStyle(ButtonStyle.Danger);
const claimButton = new ButtonBuilder()
.setCustomId('claim-ticket')
.setLabel('Claim ticket')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton, claimButton);
await thread.send({
content: `${creator} feel free to direct your questions at any <@&${guildSettings.private_help_role_id}>. Only you and staff members can see this channel.\n\n**Reason for contact:**\n${reason}\n\n-# Tickets will automatically close after 5 days of inactivity.`,
components: [row],
allowedMentions: {
users: [creator.id],
roles: [guildSettings.private_help_role_id]
}
});
} else {
const closeButton = new ButtonBuilder()
.setCustomId('close-mod-ticket')
.setLabel('Close Mod Ticket')
.setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(closeButton);
await thread.send({
content: reason,
components: [row],
allowedMentions: {
users: Array.from(new Set(additionalMembersToAdd))
}
});
}
for (const id of additionalMembersToAdd) {
await thread.members.add(id);
}
return thread;
}
export async function openModTicketModal(interaction: UserContextMenuCommandInteraction | ChatInputCommandInteraction, member: GuildMember) {
const modal = new ModalBuilder()
.setCustomId(`open-mod-ticket_${member.id}`)
.setTitle('Opening A Mod Ticket');
const titleLabel = createTextInput('title', 'Title', `The name of the thread. Defaults to "Mod Ticket For ${member.displayName}" if left empty`, false, TextInputStyle.Short, 100, null);
const initialMessageLabel = createTextInput('initial-message', 'Inital Message', 'The inital message sent in the thread', false, TextInputStyle.Paragraph, 1800, null);
const autoJoinLabel = createYesNoMenu('auto-join-thread', 'Auto Join Thread', 'Whether or not to join you to the thread. Selecting no will not notify you of messages sent!', true);
modal.addLabelComponents(titleLabel, initialMessageLabel, autoJoinLabel);
interaction.showModal(modal);
}
+102 -102
View File
@@ -1,103 +1,103 @@
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js';
import { config } from '../config';
import { E621User, MessageContent, Record, RecordCategory } from '../types';
import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils';
import { getE621User, getUserRecords } from './e621-utils';
type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User }
type AllRecords = RecordWithUserData[];
const RECORDS_PER_PAGE = 5;
export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise<AllRecords> {
const altData = await comprehensiveAltLookupFromDiscord(id, guild);
const userCache: Map<number, E621User> = new Map();
const allRecords: AllRecords = [];
const e621UserIds = e621IdsFromAltData(altData);
for (const id of e621UserIds) {
const user = userCache.get(id) ?? await getE621User(id);
if (!user) continue;
userCache.set(id, user);
const records = await getUserRecords(id);
for (const record of records) {
const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id);
if (!creator) continue;
userCache.set(record.creator_id, creator);
const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id);
if (!updater) continue;
userCache.set(record.updater_id, updater);
allRecords.push({
user,
creator,
updater,
...record
});
}
}
return allRecords;
}
export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise<MessageContent | null> {
const records = await getAllRecordsFromDiscordId(id, guild);
if (records.length == 0) return null;
page = page - 1;
const maxPage = Math.floor(records.length / RECORDS_PER_PAGE);
const embeds: EmbedBuilder[] = [];
for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) {
if (i >= records.length) break;
embeds.push(getRecordEmbed(records[i]));
}
const prevPage = new ButtonBuilder()
.setLabel('Previous Page')
.setCustomId(`records-previous_${id}_${page + 1}`)
.setDisabled(page == 0)
.setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder()
.setLabel('Next Page')
.setCustomId(`records-next_${id}_${page + 1}`)
.setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage);
return { content: `Records found for <@${id}>`, embeds, components: [row] };
}
function getRecordColor(category: RecordCategory) {
switch (category) {
case 'positive': return 0x00ff00;
case 'negative': return 0xff0000;
case 'neutral': return 0xaaaaaa;
}
}
function getRecordEmbed(record: RecordWithUserData): EmbedBuilder {
const isUpdated = record.updated_at != record.created_at;
const creator = isUpdated ? record.updater : record.creator;
return new EmbedBuilder()
.setColor(getRecordColor(record.category))
.setTitle(`Record from ${record.creator.name} for ${record.user.name}`)
.setDescription(record.body.trim())
.setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`)
.setAuthor({
name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`,
url: `${config.E621_BASE_URL}/users/${creator.id}`
})
.setTimestamp(new Date(record.updated_at));
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, Guild } from 'discord.js';
import { config } from '../config';
import { E621User, MessageContent, Record, RecordCategory } from '../types';
import { comprehensiveAltLookupFromDiscord, e621IdsFromAltData } from './alt-utils';
import { getE621User, getUserRecords } from './e621-utils';
type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User }
type AllRecords = RecordWithUserData[];
const RECORDS_PER_PAGE = 5;
export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise<AllRecords> {
const altData = await comprehensiveAltLookupFromDiscord(id, guild);
const userCache: Map<number, E621User> = new Map();
const allRecords: AllRecords = [];
const e621UserIds = e621IdsFromAltData(altData);
for (const id of e621UserIds) {
const user = userCache.get(id) ?? await getE621User(id);
if (!user) continue;
userCache.set(id, user);
const records = await getUserRecords(id);
for (const record of records) {
const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id);
if (!creator) continue;
userCache.set(record.creator_id, creator);
const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id);
if (!updater) continue;
userCache.set(record.updater_id, updater);
allRecords.push({
user,
creator,
updater,
...record
});
}
}
return allRecords;
}
export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise<MessageContent | null> {
const records = await getAllRecordsFromDiscordId(id, guild);
if (records.length == 0) return null;
page = page - 1;
const maxPage = Math.floor(records.length / RECORDS_PER_PAGE);
const embeds: EmbedBuilder[] = [];
for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) {
if (i >= records.length) break;
embeds.push(getRecordEmbed(records[i]));
}
const prevPage = new ButtonBuilder()
.setLabel('Previous Page')
.setCustomId(`records-previous_${id}_${page + 1}`)
.setDisabled(page == 0)
.setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder()
.setLabel('Next Page')
.setCustomId(`records-next_${id}_${page + 1}`)
.setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage);
return { content: `Records found for <@${id}>`, embeds, components: [row] };
}
function getRecordColor(category: RecordCategory) {
switch (category) {
case 'positive': return 0x00ff00;
case 'negative': return 0xff0000;
case 'neutral': return 0xaaaaaa;
}
}
function getRecordEmbed(record: RecordWithUserData): EmbedBuilder {
const isUpdated = record.updated_at != record.created_at;
const creator = isUpdated ? record.updater : record.creator;
return new EmbedBuilder()
.setColor(getRecordColor(record.category))
.setTitle(`Record from ${record.creator.name} for ${record.user.name}`)
.setDescription(record.body.trim())
.setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`)
.setAuthor({
name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`,
url: `${config.E621_BASE_URL}/users/${creator.id}`
})
.setTimestamp(new Date(record.updated_at));
}
+70 -70
View File
@@ -1,71 +1,71 @@
import { Client, REST, Routes } from 'discord.js';
import { config } from '../config';
import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js';
import fs from 'fs';
import { Command } from '../types';
import path from 'path';
const ROOT_DIR = path.resolve(__dirname, '..');
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!);
export async function refreshCommands(client: Client) {
try {
const commands: RESTPostAPIApplicationCommandsJSONBody[] = [];
const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {};
const commandFiles = {
commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')),
'context-menus': fs.readdirSync(`${ROOT_DIR}/context-menus`).filter(file => file.endsWith('.js') || file.endsWith('.ts')),
};
for (const [folderName, files] of Object.entries(commandFiles)) {
for (const file of files) {
const p = `${ROOT_DIR}/${folderName}/${file}`;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const command: Command = require(p).default;
if (!command) {
console.warn(`File at ${p} has no export. Skipping registering.`);
continue;
}
let data: RESTPostAPIApplicationCommandsJSONBody;
if (typeof (command.data) == 'function') {
data = (await command.data(client)).toJSON();
} else {
data = command.data.toJSON();
}
if (!command.guilds) {
commands.push(data);
} else {
for (const id of command.guilds) {
if (!guildCommands[id]) guildCommands[id] = [];
guildCommands[id].push(data);
}
}
}
}
console.log('Started refreshing application (/) commands.');
console.log('Global commands: ' + commands.length);
await rest.put(
Routes.applicationCommands(config.DISCORD_CLIENT_ID!),
{ body: commands }
);
for (const guild in guildCommands) {
console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')');
await rest.put(
Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild),
{ body: guildCommands[guild] }
);
}
console.log('Successfully reloaded application (/) commands.');
} catch (error: any) {
console.error(error);
console.error(JSON.stringify(error.requestBody, null, 4));
}
import { Client, REST, Routes } from 'discord.js';
import { config } from '../config';
import { RESTPostAPIApplicationCommandsJSONBody } from 'discord.js';
import fs from 'fs';
import { Command } from '../types';
import path from 'path';
const ROOT_DIR = path.resolve(__dirname, '..');
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN!);
export async function refreshCommands(client: Client) {
try {
const commands: RESTPostAPIApplicationCommandsJSONBody[] = [];
const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {};
const commandFiles = {
commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')),
'context-menus': fs.readdirSync(`${ROOT_DIR}/context-menus`).filter(file => file.endsWith('.js') || file.endsWith('.ts')),
};
for (const [folderName, files] of Object.entries(commandFiles)) {
for (const file of files) {
const p = `${ROOT_DIR}/${folderName}/${file}`;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const command: Command = require(p).default;
if (!command) {
console.warn(`File at ${p} has no export. Skipping registering.`);
continue;
}
let data: RESTPostAPIApplicationCommandsJSONBody;
if (typeof (command.data) == 'function') {
data = (await command.data(client)).toJSON();
} else {
data = command.data.toJSON();
}
if (!command.guilds) {
commands.push(data);
} else {
for (const id of command.guilds) {
if (!guildCommands[id]) guildCommands[id] = [];
guildCommands[id].push(data);
}
}
}
}
console.log('Started refreshing application (/) commands.');
console.log('Global commands: ' + commands.length);
await rest.put(
Routes.applicationCommands(config.DISCORD_CLIENT_ID!),
{ body: commands }
);
for (const guild in guildCommands) {
console.log('Guild commands: ' + guildCommands[guild].length + ' (' + guild + ')');
await rest.put(
Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID!, guild),
{ body: guildCommands[guild] }
);
}
console.log('Successfully reloaded application (/) commands.');
} catch (error: any) {
console.error(error);
console.error(JSON.stringify(error.requestBody, null, 4));
}
};
+5 -5
View File
@@ -1,6 +1,6 @@
export function humanizeCapitalization(str: string): string {
return str.toLowerCase()
.split(' ')
.map(s => s.charAt(0).toUpperCase() + s.substring(1))
.join(' ');
export function humanizeCapitalization(str: string): string {
return str.toLowerCase()
.split(' ')
.map(s => s.charAt(0).toUpperCase() + s.substring(1))
.join(' ');
}
+380 -380
View File
@@ -1,381 +1,381 @@
import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { Ticket, TicketPhrase, TicketUpdate } from '../types';
import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils';
import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex';
import { humanizeCapitalization } from './string-utils';
import { shouldAlert } from './ticket-utils';
// TODO: Condense this and the message event handler regex array.
const linkReplacers = [
{
regex: blipIDRegex,
replacement: '/blips/{match}',
encodeURI: false
},
{
regex: commentIDRegex,
replacement: '/comments/{match}',
encodeURI: false
},
{
regex: forumTopicIDRegex,
replacement: '/forum_topics/{match}',
encodeURI: false
},
{
regex: poolIDRegex,
replacement: '/pools/{match}',
encodeURI: false
},
{
regex: postIDRegex,
tester: async (postId: string, before: string, after: string) => {
const post = await getE621Post(postId);
if (!post) return { allowed: true, before, after };
const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist;
return { allowed, before, after };
},
replacement: '/posts/{match}',
encodeURI: false
},
{
regex: recordIDRegex,
replacement: '/user_feedbacks/{match}',
encodeURI: false
},
{
regex: searchLinkRegex,
replacement: '/posts?tags={match}',
encodeURI: true
},
{
regex: setIDRegex,
replacement: '/post_sets/{match}',
encodeURI: false
},
{
regex: takedownIDRegex,
replacement: '/takedowns/{match}',
encodeURI: false
},
{
regex: ticketIDRegex,
replacement: '/tickets/{match}',
encodeURI: false
},
{
regex: userIDRegex,
replacement: '/users/{match}',
encodeURI: false
},
{
regex: wikiLinkRegex,
replacement: '/wiki_pages/{match}',
encodeURI: true
}
];
const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi');
const MAX_DESCRIPTION_LENGTH = 500;
export async function ticketUpdateHandler(client: Client, update: string) {
const data: TicketUpdate = JSON.parse(update);
if (data.action == 'create') {
postTicket(client, data);
} else {
updateTicket(client, data);
}
}
async function postTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return;
const ticket = data.ticket;
const embed = await createEmbedFromTicket(ticket);
const row = await getButtons(ticket);
const message = await channel.send({ embeds: [embed], components: [row] });
await Database.putTicket(ticket.id, message.id);
sendTicketAlerts(ticket, channel);
}
async function updateTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return;
const messageId = await Database.getTicketMessageId(data.ticket.id);
if (!messageId) return postTicket(client, data);
const message = await channel.messages.fetch(messageId);
const embed = await createEmbedFromTicket(data.ticket);
if (!message || message.author.id != config.DISCORD_CLIENT_ID) {
const newMessage = await channel.send({ embeds: [embed] });
await Database.removeTicket(data.ticket.id);
await Database.putTicket(data.ticket.id, newMessage.id);
} else {
await message.edit({ embeds: [embed] });
}
}
function getTitle(ticket: Ticket): string {
if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`;
switch (ticket.category) {
case 'blip':
return `Blip by ${ticket.target}`;
case 'comment':
return `Comment by ${ticket.target}`;
case 'dmail':
return `DMail sent by ${ticket.target}`;
case 'forum':
return `Forum post by ${ticket.target}`;
case 'pool':
return `Pool ${ticket.target}`;
case 'post':
return `Post uploaded by ${ticket.target}`;
case 'set':
return `Wow, a rare set report! ${ticket.target}`;
case 'user':
return `User ${ticket.target}`;
case 'wiki':
return `Wiki page ${ticket.target}`;
default:
return 'Uknown ticket category';
}
}
function getURL(ticket: Ticket): string {
return `${config.E621_BASE_URL}/tickets/${ticket.id}`;
}
async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise<string> {
const length = input.length;
const replacedIndexes: { start: number, end: number }[] = [];
const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = [];
for (const replacer of linkReplacers) {
input = input.replaceAll(replacer.regex, (match, group1) => {
const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`;
if (replacer.tester) checks.push(replacer.tester(group1, match, replaced));
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
}
input = input.replaceAll(urlRegex, (match, group1, group2) => {
const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`;
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
const values = await Promise.all(checks);
for (const check of values) {
if (!check.allowed) {
input = input.replace(check.after, check.before);
}
}
if (length > limit) {
for (const replacedIndex of replacedIndexes) {
if (replacedIndex.start < limit && replacedIndex.end >= limit) {
return input.substring(0, replacedIndex.end) + '...';
}
}
return input.substring(0, limit) + '...';
}
return input;
}
async function getDescription(ticket: Ticket): Promise<string> {
return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH);
}
function getAuthor(ticket: Ticket): EmbedAuthorOptions {
return {
url: `${config.E621_BASE_URL}/users/${ticket.user_id}`,
name: ticket.user
};
}
function getColor(ticket: Ticket): number {
if (!ticket.claimant) {
return 0xff0000;
} else {
return 0x00ffff;
}
}
function getFields(ticket: Ticket): APIEmbedField[] {
return [
{
name: 'Type',
value: ticket.category,
inline: true
},
{
name: 'Status',
value: ticket.status,
inline: true
},
{
name: 'Claimed By',
value: !ticket.claimant ? '<Unclaimed>' : ticket.claimant,
inline: true
}
];
}
async function createEmbedFromTicket(ticket: Ticket): Promise<EmbedBuilder> {
return new EmbedBuilder()
.setTitle(getTitle(ticket))
.setURL(await getURL(ticket))
.setDescription(await getDescription(ticket))
.setAuthor(getAuthor(ticket))
.setColor(getColor(ticket))
.setFields(...getFields(ticket))
.setFooter({ text: `Ticket #${ticket.id}` });
}
async function getButtons(ticket: Ticket): Promise<ActionRowBuilder<ButtonBuilder>> {
const row = new ActionRowBuilder<ButtonBuilder>();
const primaryButton = new ButtonBuilder()
.setStyle(ButtonStyle.Link);
let skipPrimary = false;
if (ticket.category == 'blip') {
primaryButton
.setLabel('Open Blip')
.setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`);
} else if (ticket.category == 'comment') {
primaryButton
.setLabel('Open Comment')
.setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`);
} else if (ticket.category == 'dmail') {
primaryButton
.setLabel('Open DMail')
.setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`);
} else if (ticket.category == 'forum') {
primaryButton
.setLabel('Open Forum Post')
.setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`);
} else if (ticket.category == 'pool') {
primaryButton
.setLabel('Open Pool')
.setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`);
} else if (ticket.category == 'post') {
const post = await getE621Post(ticket.target_id);
if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true;
else {
primaryButton
.setLabel('Open Post')
.setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`);
}
} else if (ticket.category == 'set') {
primaryButton
.setLabel('Open Set')
.setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`);
} else if (ticket.category == 'user') {
primaryButton
.setLabel('Open User')
.setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`);
} else if (ticket.category == 'wiki') {
primaryButton
.setLabel('Open Wiki')
.setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`);
} else {
console.error('Unknown ticket type:');
console.error(JSON.stringify(ticket, null, 2));
skipPrimary = true;
}
if (!skipPrimary) row.addComponents(primaryButton);
if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') {
const button = new ButtonBuilder()
.setLabel('Open Target User')
.setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`);
row.addComponents(button);
} else if (ticket.category == 'post') {
const user = await getE621User(ticket.target!);
if (user) {
const button = new ButtonBuilder()
.setLabel('Open Target User')
.setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${user.id}`);
row.addComponents(button);
}
}
return row;
}
async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.admin_role_id) return;
const usersToMention: string[] = [];
const rolesToMention: string[] = [];
let content = '';
await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => {
const { alert, match } = shouldAlert(ticketPhrase, ticket);
if (alert) {
const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`;
if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) {
rolesToMention.push(guildSettings.admin_role_id!);
} else if (!usersToMention.includes(ticketPhrase.user_id)) {
usersToMention.push(ticketPhrase.user_id);
}
content += `${mention}: ${match}\n`;
}
});
if (content.length == 0) return;
await channel.send({
content,
allowedMentions: {
users: usersToMention,
roles: rolesToMention
}
});
import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js';
import { config } from '../config';
import { Database } from '../shared/Database';
import { Ticket, TicketPhrase, TicketUpdate } from '../types';
import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils';
import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex';
import { humanizeCapitalization } from './string-utils';
import { shouldAlert } from './ticket-utils';
// TODO: Condense this and the message event handler regex array.
const linkReplacers = [
{
regex: blipIDRegex,
replacement: '/blips/{match}',
encodeURI: false
},
{
regex: commentIDRegex,
replacement: '/comments/{match}',
encodeURI: false
},
{
regex: forumTopicIDRegex,
replacement: '/forum_topics/{match}',
encodeURI: false
},
{
regex: poolIDRegex,
replacement: '/pools/{match}',
encodeURI: false
},
{
regex: postIDRegex,
tester: async (postId: string, before: string, after: string) => {
const post = await getE621Post(postId);
if (!post) return { allowed: true, before, after };
const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist;
return { allowed, before, after };
},
replacement: '/posts/{match}',
encodeURI: false
},
{
regex: recordIDRegex,
replacement: '/user_feedbacks/{match}',
encodeURI: false
},
{
regex: searchLinkRegex,
replacement: '/posts?tags={match}',
encodeURI: true
},
{
regex: setIDRegex,
replacement: '/post_sets/{match}',
encodeURI: false
},
{
regex: takedownIDRegex,
replacement: '/takedowns/{match}',
encodeURI: false
},
{
regex: ticketIDRegex,
replacement: '/tickets/{match}',
encodeURI: false
},
{
regex: userIDRegex,
replacement: '/users/{match}',
encodeURI: false
},
{
regex: wikiLinkRegex,
replacement: '/wiki_pages/{match}',
encodeURI: true
}
];
const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi');
const MAX_DESCRIPTION_LENGTH = 500;
export async function ticketUpdateHandler(client: Client, update: string) {
const data: TicketUpdate = JSON.parse(update);
if (data.action == 'create') {
postTicket(client, data);
} else {
updateTicket(client, data);
}
}
async function postTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return;
const ticket = data.ticket;
const embed = await createEmbedFromTicket(ticket);
const row = await getButtons(ticket);
const message = await channel.send({ embeds: [embed], components: [row] });
await Database.putTicket(ticket.id, message.id);
sendTicketAlerts(ticket, channel);
}
async function updateTicket(client: Client, data: TicketUpdate) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.tickets_channel_id) return;
const channel = await client.channels.fetch(guildSettings.tickets_channel_id);
if (!channel || !channel.isSendable()) return;
const messageId = await Database.getTicketMessageId(data.ticket.id);
if (!messageId) return postTicket(client, data);
const message = await channel.messages.fetch(messageId);
const embed = await createEmbedFromTicket(data.ticket);
if (!message || message.author.id != config.DISCORD_CLIENT_ID) {
const newMessage = await channel.send({ embeds: [embed] });
await Database.removeTicket(data.ticket.id);
await Database.putTicket(data.ticket.id, newMessage.id);
} else {
await message.edit({ embeds: [embed] });
}
}
function getTitle(ticket: Ticket): string {
if (!ticket.target) return `${humanizeCapitalization(ticket.category)} report by ${ticket.user}`;
switch (ticket.category) {
case 'blip':
return `Blip by ${ticket.target}`;
case 'comment':
return `Comment by ${ticket.target}`;
case 'dmail':
return `DMail sent by ${ticket.target}`;
case 'forum':
return `Forum post by ${ticket.target}`;
case 'pool':
return `Pool ${ticket.target}`;
case 'post':
return `Post uploaded by ${ticket.target}`;
case 'set':
return `Wow, a rare set report! ${ticket.target}`;
case 'user':
return `User ${ticket.target}`;
case 'wiki':
return `Wiki page ${ticket.target}`;
default:
return 'Uknown ticket category';
}
}
function getURL(ticket: Ticket): string {
return `${config.E621_BASE_URL}/tickets/${ticket.id}`;
}
async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise<string> {
const length = input.length;
const replacedIndexes: { start: number, end: number }[] = [];
const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = [];
for (const replacer of linkReplacers) {
input = input.replaceAll(replacer.regex, (match, group1) => {
const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`;
if (replacer.tester) checks.push(replacer.tester(group1, match, replaced));
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
}
input = input.replaceAll(urlRegex, (match, group1, group2) => {
const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`;
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
const values = await Promise.all(checks);
for (const check of values) {
if (!check.allowed) {
input = input.replace(check.after, check.before);
}
}
if (length > limit) {
for (const replacedIndex of replacedIndexes) {
if (replacedIndex.start < limit && replacedIndex.end >= limit) {
return input.substring(0, replacedIndex.end) + '...';
}
}
return input.substring(0, limit) + '...';
}
return input;
}
async function getDescription(ticket: Ticket): Promise<string> {
return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH);
}
function getAuthor(ticket: Ticket): EmbedAuthorOptions {
return {
url: `${config.E621_BASE_URL}/users/${ticket.user_id}`,
name: ticket.user
};
}
function getColor(ticket: Ticket): number {
if (!ticket.claimant) {
return 0xff0000;
} else {
return 0x00ffff;
}
}
function getFields(ticket: Ticket): APIEmbedField[] {
return [
{
name: 'Type',
value: ticket.category,
inline: true
},
{
name: 'Status',
value: ticket.status,
inline: true
},
{
name: 'Claimed By',
value: !ticket.claimant ? '<Unclaimed>' : ticket.claimant,
inline: true
}
];
}
async function createEmbedFromTicket(ticket: Ticket): Promise<EmbedBuilder> {
return new EmbedBuilder()
.setTitle(getTitle(ticket))
.setURL(await getURL(ticket))
.setDescription(await getDescription(ticket))
.setAuthor(getAuthor(ticket))
.setColor(getColor(ticket))
.setFields(...getFields(ticket))
.setFooter({ text: `Ticket #${ticket.id}` });
}
async function getButtons(ticket: Ticket): Promise<ActionRowBuilder<ButtonBuilder>> {
const row = new ActionRowBuilder<ButtonBuilder>();
const primaryButton = new ButtonBuilder()
.setStyle(ButtonStyle.Link);
let skipPrimary = false;
if (ticket.category == 'blip') {
primaryButton
.setLabel('Open Blip')
.setURL(`${config.E621_BASE_URL}/blips/${ticket.target_id}`);
} else if (ticket.category == 'comment') {
primaryButton
.setLabel('Open Comment')
.setURL(`${config.E621_BASE_URL}/comments/${ticket.target_id}`);
} else if (ticket.category == 'dmail') {
primaryButton
.setLabel('Open DMail')
.setURL(`${config.E621_BASE_URL}/dmails/${ticket.target_id}`);
} else if (ticket.category == 'forum') {
primaryButton
.setLabel('Open Forum Post')
.setURL(`${config.E621_BASE_URL}/forum_posts/${ticket.target_id}`);
} else if (ticket.category == 'pool') {
primaryButton
.setLabel('Open Pool')
.setURL(`${config.E621_BASE_URL}/pools/${ticket.target_id}`);
} else if (ticket.category == 'post') {
const post = await getE621Post(ticket.target_id);
if (post && spoilerOrBlacklist(post).action == PostAction.Blacklist) skipPrimary = true;
else {
primaryButton
.setLabel('Open Post')
.setURL(`${config.E621_BASE_URL}/posts/${ticket.target_id}`);
}
} else if (ticket.category == 'set') {
primaryButton
.setLabel('Open Set')
.setURL(`${config.E621_BASE_URL}/post_sets/${ticket.target_id}`);
} else if (ticket.category == 'user') {
primaryButton
.setLabel('Open User')
.setURL(`${config.E621_BASE_URL}/users/${ticket.target_id}`);
} else if (ticket.category == 'wiki') {
primaryButton
.setLabel('Open Wiki')
.setURL(`${config.E621_BASE_URL}/wikis/${ticket.target_id}`);
} else {
console.error('Unknown ticket type:');
console.error(JSON.stringify(ticket, null, 2));
skipPrimary = true;
}
if (!skipPrimary) row.addComponents(primaryButton);
if (ticket.category == 'blip' || ticket.category == 'comment' || ticket.category == 'dmail' || ticket.category == 'forum') {
const button = new ButtonBuilder()
.setLabel('Open Target User')
.setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${ticket.accused_id}`);
row.addComponents(button);
} else if (ticket.category == 'post') {
const user = await getE621User(ticket.target!);
if (user) {
const button = new ButtonBuilder()
.setLabel('Open Target User')
.setStyle(ButtonStyle.Link)
.setURL(`${config.E621_BASE_URL}/users/${user.id}`);
row.addComponents(button);
}
}
return row;
}
async function sendTicketAlerts(ticket: Ticket, channel: SendableChannels) {
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!guildSettings || !guildSettings.admin_role_id) return;
const usersToMention: string[] = [];
const rolesToMention: string[] = [];
let content = '';
await Database.getAllTicketPhrases((ticketPhrase: TicketPhrase) => {
const { alert, match } = shouldAlert(ticketPhrase, ticket);
if (alert) {
const mention = ticketPhrase.user_id == 'admin' ? `<@&${guildSettings.admin_role_id!}>` : `<@${ticketPhrase.user_id}>`;
if (ticketPhrase.user_id == 'admin' && !rolesToMention.includes(guildSettings.admin_role_id!)) {
rolesToMention.push(guildSettings.admin_role_id!);
} else if (!usersToMention.includes(ticketPhrase.user_id)) {
usersToMention.push(ticketPhrase.user_id);
}
content += `${mention}: ${match}\n`;
}
});
if (content.length == 0) return;
await channel.send({
content,
allowedMentions: {
users: usersToMention,
roles: rolesToMention
}
});
}
+37 -37
View File
@@ -1,38 +1,38 @@
import { Ticket, TicketPhrase } from '../types';
function friendlyPhrase(phrase: string): string {
switch (phrase) {
case 'underage porn':
case 'child porn':
case 'cp':
return 'Code Red';
default:
return phrase;
}
}
export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } {
if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) {
if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) {
return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) };
} else {
return { alert: false };
}
} else {
try {
const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i');
const regexMatch = regex.exec(ticket.reason);
if (regexMatch) {
return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` };
} else {
return { alert: false };
}
} catch (e) {
console.error(e);
return { alert: false };
}
}
import { Ticket, TicketPhrase } from '../types';
function friendlyPhrase(phrase: string): string {
switch (phrase) {
case 'underage porn':
case 'child porn':
case 'cp':
return 'Code Red';
default:
return phrase;
}
}
export function shouldAlert(ticketPhrase: TicketPhrase, ticket: Ticket): { alert: boolean, match?: string } {
if (!(ticketPhrase.phrase.startsWith('/') && ticketPhrase.phrase.endsWith('/'))) {
if (ticket.reason.toLowerCase().includes(ticketPhrase.phrase.toLowerCase())) {
return { alert: true, match: friendlyPhrase(ticketPhrase.phrase.trim()) };
} else {
return { alert: false };
}
} else {
try {
const regex = new RegExp(ticketPhrase.phrase.slice(1, -1), 'i');
const regexMatch = regex.exec(ticket.reason);
if (regexMatch) {
return { alert: true, match: `${friendlyPhrase(regexMatch[0].trim())} (RegEx match: \`${ticketPhrase.phrase}\`)` };
} else {
return { alert: false };
}
} catch (e) {
console.error(e);
return { alert: false };
}
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
export function wait(ms) {
return new Promise(r => setTimeout(r, ms));
export function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
+17 -17
View File
@@ -1,18 +1,18 @@
import { CommandInteraction, MessageFlags } from 'discord.js';
import { getE621Alts } from './alt-utils';
import { resolveUser } from './discord-user-utils';
export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) {
if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
else await interaction.deferReply();
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = await resolveUser(interaction.client, valueToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
const content = await getE621Alts(user.id, interaction.guild!);
interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`);
import { CommandInteraction, MessageFlags } from 'discord.js';
import { getE621Alts } from './alt-utils';
import { resolveUser } from './discord-user-utils';
export async function handleWhoIsInteraction(interaction: CommandInteraction, valueToUse: string, ephemeral = false) {
if (ephemeral) await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
else await interaction.deferReply();
if (!interaction.guild) return interaction.editReply('This command must be used in a server');
const user = await resolveUser(interaction.client, valueToUse, interaction.guild);
if (!user) return interaction.editReply('User not found.');
const content = await getE621Alts(user.id, interaction.guild!);
interaction.editReply(`<@${user.id}>'s (${user.id}) e621 and discord account(s):\n${content}`);
}
+302 -302
View File
@@ -1,303 +1,303 @@
import express, { Request, Response } from 'express';
import { config } from '../config';
import { Database } from '../shared/Database';
import crypto from 'crypto';
import session from 'express-session';
import MemoryStore from 'memorystore';
import fs from 'fs';
import path from 'path';
import { Client } from 'discord.js';
import bodyParser from 'body-parser';
import { fixPings, removeIssueLinks } from '../utils/github-user-utils';
import { logDebug } from '../utils/debug-utils';
import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils';
declare module 'express-session' {
interface SessionData {
username: string;
userId: string;
oauthState: string;
}
}
const GITHUB_REPO_ID = 169334303;
const DEV_BASE_URL = `http://localhost:${config.PORT}`;
const PROD_BASE_URL = 'https://discord.e621.net';
const OAUTH_SCOPES = ['identify', 'guilds.join'];
const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' });
const oauth = new DiscordOAuth2({
clientId: config.DISCORD_CLIENT_ID!,
clientSecret: config.DISCORD_CLIENT_SECRET!,
redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`,
clientToken: config.DISCORD_TOKEN!,
credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64')
});
const enum JoinResponse {
Success = 1,
Error = 2,
Banned = 3,
Underage = 4
};
async function joinGuild(code: string, userId: string, username: string): Promise<JoinResponse> {
let tokenResponse;
try {
if (Number.isNaN(userId)) return JoinResponse.Error;
if (!username) return JoinResponse.Error;
const id = Number(userId);
tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES);
const user = await oauth.getUser(tokenResponse.access_token);
if (!user.id || !user.username) {
console.error(`Error joining user (${userId}) to discord. User object missing id or username.`);
console.error(user);
return JoinResponse.Error;
}
await Database.putUser(id, user);
const alts = await comprehensiveAltLookupFromE621(id, null);
if (await checkAltsForFullBans([alts])) return JoinResponse.Banned;
const response = await oauth.addMember({
accessToken: tokenResponse.access_token,
guildId: config.DISCORD_GUILD_ID!,
userId: user.id,
nickname: username
});
if (config.DEBUG) console.log(response);
if (!response) return JoinResponse.Error;
} catch (e: any) {
if (e.code == 40007) return JoinResponse.Banned;
else if (e.code == 20024) return JoinResponse.Underage;
console.error(`Error joining user (${userId}) to discord:`);
console.error(e);
return JoinResponse.Error;
} finally {
if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token);
}
return JoinResponse.Success;
}
async function handleInitial(req: Request, res: Response): Promise<any> {
const { username, user_id, time, hash } = req.query;
if (!username || !user_id || !time || !hash) {
return sendBadRequest(res, 'Missing parameters');
}
if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) {
return render(res, 403, 'You took too long to authorize the request. Please try again.');
}
const authString = `${username} ${user_id} ${time} ${config.LINK_SECRET}`;
const digest = crypto.createHash('sha256').update(authString).digest('hex');
if (hash !== digest) {
console.error(`Bad auth: ${hash} ${digest}`);
return sendForbidden(res, 'Bad auth');
}
const oauthState = crypto.randomBytes(16).toString('hex');
const oauthUrl = await oauth.generateOauth2Url({
state: oauthState,
scope: OAUTH_SCOPES,
type: 'code'
});
req.session.username = username as string;
req.session.userId = user_id as string;
req.session.oauthState = oauthState;
req.session.save((e) => {
if (e) {
console.error('Error saving session:');
console.error(e);
return sendInteralServerError(res);
}
res.redirect(oauthUrl);
});
}
async function handleCallback(req: Request, res: Response): Promise<any> {
if (!req.session.userId || !req.session.username || !req.session.oauthState) {
return sendForbidden(res, 'Session details missing');
}
const state = req.query.state as string;
if (state != req.session.oauthState) {
console.error('OAuth state mismatch on discord joining');
return sendForbidden(res, 'OAuth state mismatch');
}
const code = req.query.code as string;
const userId = req.session.userId;
const username = req.session.username;
req.session.destroy((e) => {
if (e) console.error(e);
});
try {
const response = await joinGuild(code, userId, username);
if (response == JoinResponse.Error) {
console.error(`Error joining user: ${username} (${userId})`);
return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.');
} else if (response == JoinResponse.Banned) {
return sendForbidden(res, 'User is banned.');
} else if (response == JoinResponse.Underage) {
return sendForbidden(res, 'Discord account flagged as underage by discord.');
}
} catch (e) {
console.error(e);
return sendInteralServerError(res);
}
render(res, 200, 'Success', `You have been added to the server. <a href="https://discord.com/channels/${config.DISCORD_GUILD_ID}">See you there.</a>`);
}
function sendInteralServerError(res: Response, message: string = '') {
render(res, 500, 'Internal Server Error', message);
}
function sendForbidden(res: Response, message: string = '') {
render(res, 403, 'Forbidden', message);
}
function sendBadRequest(res: Response, message: string = '') {
render(res, 400, 'Bad Request', message);
}
function render(res: Response, code: number, title: string = '', message: string = '') {
res.status(code).setHeader('Content-Type', 'text/html').send(PAGE_TEMPLATE.replaceAll('{{ title }}', title).replaceAll('{{ message }}', message));
}
async function handleGithubRelease(client: Client, req: Request, res: Response): Promise<any> {
logDebug('Received github release webhook');
const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1];
const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex');
if (signature !== computedSignature) {
console.error('Github release webhook signature mismatch');
return res.sendStatus(401);
}
res.sendStatus(200);
const data = JSON.parse(req.body);
logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`);
if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return;
const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!settings || !settings.github_release_channel) return;
const channel = await client.channels.fetch(settings.github_release_channel);
if (!channel || !channel.isSendable()) {
console.error(`Github release channel ${channel ? 'sendable' : 'found'}`);
return;
}
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const date = new Date();
let message = `## [${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}](<${data.release.html_url}>)\n\n${await fixPings(removeIssueLinks(data.release.body))}`;
logDebug('Sending github release message');
const MAX_MESSAGE_LENGTH = 2000;
const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.';
if (message.length > MAX_MESSAGE_LENGTH) {
const splitMessage = message.split('\n');
message = '';
for (const part of splitMessage) {
if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break;
message += `${part}\n`;
}
message += ADDITIONAL_PART;
}
const sentMessage = await channel.send(message);
await sentMessage.startThread({ name: data.release.tag_name });
logDebug('Github webhook processed');
}
async function checkAltsForFullBans(altData: AltData[]): Promise<boolean> {
for (const data of altData) {
if (data.type == 'discord') {
try {
const banData = await Database.getBan(data.thisId as string);
if (banData?.full_ban) return true;
} catch (e) {
console.error(e);
}
}
if (await checkAltsForFullBans(data.alts)) return true;
}
return false;
}
export function initializeWebserver(client: Client) {
const app = express();
const Store = MemoryStore(session);
app.set('trust proxy', 1);
app.use(session({
secret: config.DISCORD_CLIENT_SECRET!,
cookie: {
secure: !config.DEV_MODE,
httpOnly: !config.DEV_MODE,
sameSite: false,
maxAge: 300000
},
store: new Store({
checkPeriod: 600000,
}),
resave: false,
saveUninitialized: false
}));
app.get('/', handleInitial);
app.get('/callback', handleCallback);
app.use(bodyParser.raw({ type: 'application/json' }));
app.post('/release', handleGithubRelease.bind(null, client));
app.listen(config.PORT, (error) => {
if (error) {
throw error;
}
console.log(`Listening on port ${config.PORT}`);
});
import express, { Request, Response } from 'express';
import { config } from '../config';
import { Database } from '../shared/Database';
import crypto from 'crypto';
import session from 'express-session';
import MemoryStore from 'memorystore';
import fs from 'fs';
import path from 'path';
import { Client } from 'discord.js';
import bodyParser from 'body-parser';
import { fixPings, removeIssueLinks } from '../utils/github-user-utils';
import { logDebug } from '../utils/debug-utils';
import { AltData, comprehensiveAltLookupFromE621, DiscordOAuth2 } from '../utils';
declare module 'express-session' {
interface SessionData {
username: string;
userId: string;
oauthState: string;
}
}
const GITHUB_REPO_ID = 169334303;
const DEV_BASE_URL = `http://localhost:${config.PORT}`;
const PROD_BASE_URL = 'https://discord.e621.net';
const OAUTH_SCOPES = ['identify', 'guilds.join'];
const PAGE_TEMPLATE = fs.readFileSync(path.join(__dirname, 'templates', 'page.html'), { encoding: 'utf-8' });
const oauth = new DiscordOAuth2({
clientId: config.DISCORD_CLIENT_ID!,
clientSecret: config.DISCORD_CLIENT_SECRET!,
redirectUri: `${config.DEV_MODE ? DEV_BASE_URL : PROD_BASE_URL}/callback`,
clientToken: config.DISCORD_TOKEN!,
credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64')
});
const enum JoinResponse {
Success = 1,
Error = 2,
Banned = 3,
Underage = 4
};
async function joinGuild(code: string, userId: string, username: string): Promise<JoinResponse> {
let tokenResponse;
try {
if (Number.isNaN(userId)) return JoinResponse.Error;
if (!username) return JoinResponse.Error;
const id = Number(userId);
tokenResponse = await oauth.getAccessToken(code, OAUTH_SCOPES);
const user = await oauth.getUser(tokenResponse.access_token);
if (!user.id || !user.username) {
console.error(`Error joining user (${userId}) to discord. User object missing id or username.`);
console.error(user);
return JoinResponse.Error;
}
await Database.putUser(id, user);
const alts = await comprehensiveAltLookupFromE621(id, null);
if (await checkAltsForFullBans([alts])) return JoinResponse.Banned;
const response = await oauth.addMember({
accessToken: tokenResponse.access_token,
guildId: config.DISCORD_GUILD_ID!,
userId: user.id,
nickname: username
});
if (config.DEBUG) console.log(response);
if (!response) return JoinResponse.Error;
} catch (e: any) {
if (e.code == 40007) return JoinResponse.Banned;
else if (e.code == 20024) return JoinResponse.Underage;
console.error(`Error joining user (${userId}) to discord:`);
console.error(e);
return JoinResponse.Error;
} finally {
if (tokenResponse) await oauth.revokeToken(tokenResponse.access_token);
}
return JoinResponse.Success;
}
async function handleInitial(req: Request, res: Response): Promise<any> {
const { username, user_id, time, hash } = req.query;
if (!username || !user_id || !time || !hash) {
return sendBadRequest(res, 'Missing parameters');
}
if (Number.isNaN(time) || Date.now() / 1000 > Number(time)) {
return render(res, 403, 'You took too long to authorize the request. Please try again.');
}
const authString = `${username} ${user_id} ${time} ${config.LINK_SECRET}`;
const digest = crypto.createHash('sha256').update(authString).digest('hex');
if (hash !== digest) {
console.error(`Bad auth: ${hash} ${digest}`);
return sendForbidden(res, 'Bad auth');
}
const oauthState = crypto.randomBytes(16).toString('hex');
const oauthUrl = await oauth.generateOauth2Url({
state: oauthState,
scope: OAUTH_SCOPES,
type: 'code'
});
req.session.username = username as string;
req.session.userId = user_id as string;
req.session.oauthState = oauthState;
req.session.save((e) => {
if (e) {
console.error('Error saving session:');
console.error(e);
return sendInteralServerError(res);
}
res.redirect(oauthUrl);
});
}
async function handleCallback(req: Request, res: Response): Promise<any> {
if (!req.session.userId || !req.session.username || !req.session.oauthState) {
return sendForbidden(res, 'Session details missing');
}
const state = req.query.state as string;
if (state != req.session.oauthState) {
console.error('OAuth state mismatch on discord joining');
return sendForbidden(res, 'OAuth state mismatch');
}
const code = req.query.code as string;
const userId = req.session.userId;
const username = req.session.username;
req.session.destroy((e) => {
if (e) console.error(e);
});
try {
const response = await joinGuild(code, userId, username);
if (response == JoinResponse.Error) {
console.error(`Error joining user: ${username} (${userId})`);
return sendInteralServerError(res, 'Unable to join user to guild. Retry later. If issue persists, please contact staff.');
} else if (response == JoinResponse.Banned) {
return sendForbidden(res, 'User is banned.');
} else if (response == JoinResponse.Underage) {
return sendForbidden(res, 'Discord account flagged as underage by discord.');
}
} catch (e) {
console.error(e);
return sendInteralServerError(res);
}
render(res, 200, 'Success', `You have been added to the server. <a href="https://discord.com/channels/${config.DISCORD_GUILD_ID}">See you there.</a>`);
}
function sendInteralServerError(res: Response, message: string = '') {
render(res, 500, 'Internal Server Error', message);
}
function sendForbidden(res: Response, message: string = '') {
render(res, 403, 'Forbidden', message);
}
function sendBadRequest(res: Response, message: string = '') {
render(res, 400, 'Bad Request', message);
}
function render(res: Response, code: number, title: string = '', message: string = '') {
res.status(code).setHeader('Content-Type', 'text/html').send(PAGE_TEMPLATE.replaceAll('{{ title }}', title).replaceAll('{{ message }}', message));
}
async function handleGithubRelease(client: Client, req: Request, res: Response): Promise<any> {
logDebug('Received github release webhook');
const signature = (req.headers['x-hub-signature-256'] as string).split('=')[1];
const computedSignature = crypto.createHmac('sha256', config.RELEASE_SECRET!).update(req.body).digest('hex');
if (signature !== computedSignature) {
console.error('Github release webhook signature mismatch');
return res.sendStatus(401);
}
res.sendStatus(200);
const data = JSON.parse(req.body);
logDebug(`Release webhook data:\n${JSON.stringify(data, null, 4)}`);
if (data.action != 'published' || data.repository.id != GITHUB_REPO_ID) return;
const settings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
if (!settings || !settings.github_release_channel) return;
const channel = await client.channels.fetch(settings.github_release_channel);
if (!channel || !channel.isSendable()) {
console.error(`Github release channel ${channel ? 'sendable' : 'found'}`);
return;
}
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const date = new Date();
let message = `## [${months[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}](<${data.release.html_url}>)\n\n${await fixPings(removeIssueLinks(data.release.body))}`;
logDebug('Sending github release message');
const MAX_MESSAGE_LENGTH = 2000;
const ADDITIONAL_PART = '...\n\nYou may view the full changelog on github.';
if (message.length > MAX_MESSAGE_LENGTH) {
const splitMessage = message.split('\n');
message = '';
for (const part of splitMessage) {
if (message.length + part.length + 1 >= MAX_MESSAGE_LENGTH - ADDITIONAL_PART.length) break;
message += `${part}\n`;
}
message += ADDITIONAL_PART;
}
const sentMessage = await channel.send(message);
await sentMessage.startThread({ name: data.release.tag_name });
logDebug('Github webhook processed');
}
async function checkAltsForFullBans(altData: AltData[]): Promise<boolean> {
for (const data of altData) {
if (data.type == 'discord') {
try {
const banData = await Database.getBan(data.thisId as string);
if (banData?.full_ban) return true;
} catch (e) {
console.error(e);
}
}
if (await checkAltsForFullBans(data.alts)) return true;
}
return false;
}
export function initializeWebserver(client: Client) {
const app = express();
const Store = MemoryStore(session);
app.set('trust proxy', 1);
app.use(session({
secret: config.DISCORD_CLIENT_SECRET!,
cookie: {
secure: !config.DEV_MODE,
httpOnly: !config.DEV_MODE,
sameSite: false,
maxAge: 300000
},
store: new Store({
checkPeriod: 600000,
}),
resave: false,
saveUninitialized: false
}));
app.get('/', handleInitial);
app.get('/callback', handleCallback);
app.use(bodyParser.raw({ type: 'application/json' }));
app.post('/release', handleGithubRelease.bind(null, client));
app.listen(config.PORT, (error) => {
if (error) {
throw error;
}
console.log(`Listening on port ${config.PORT}`);
});
}
+29 -29
View File
@@ -1,29 +1,29 @@
<!DOCTYPE HTML>
<html lang="en">
<head>
<title>{{ title }}</title>
<style>
body {
background-color: #012e56;
color: #fff;
font-family: Verdana, sans-serif;
}
a {
color: #b4c7d9;
text-decoration: none;
}
a:hover {
color: #e9f2fa;
}
</style>
</head>
<body>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</body>
</html>
<!DOCTYPE HTML>
<html lang="en">
<head>
<title>{{ title }}</title>
<style>
body {
background-color: #012e56;
color: #fff;
font-family: Verdana, sans-serif;
}
a {
color: #b4c7d9;
text-decoration: none;
}
a:hover {
color: #e9f2fa;
}
</style>
</head>
<body>
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</body>
</html>
+21 -21
View File
@@ -1,22 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"forceConsistentCasingInFileNames": false,
"inlineSourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"noImplicitAny": false,
"noUnusedLocals": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"lib": [
"ES2021.String"
]
},
"include": ["src"],
"exclude": ["node_modules"]
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"forceConsistentCasingInFileNames": false,
"inlineSourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"noImplicitAny": false,
"noUnusedLocals": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"lib": [
"ES2021.String"
]
},
"include": ["src"],
"exclude": ["node_modules"]
}