Initial commit.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
/.git
|
||||
/data
|
||||
/dist
|
||||
/node_modules
|
||||
@@ -0,0 +1,9 @@
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
LINK_SECRET=super_secret_for_url_discord
|
||||
E621_BASE_URL=https://e621.net
|
||||
E926_BASE_URL=https://e926.net
|
||||
REDIS_URL=localhost:6379
|
||||
PORT=8000
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
data/*
|
||||
.env
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm i
|
||||
|
||||
RUN npm run build
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
discordbotrewrite:
|
||||
restart: always
|
||||
build: .
|
||||
network_mode: host
|
||||
env_file: '.env'
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
@@ -0,0 +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',
|
||||
},
|
||||
}
|
||||
);
|
||||
Generated
+5751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@stylistic/eslint-plugin": "^4.2.0",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"eslint": "^9.27.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@redis/client": "^5.1.0",
|
||||
"discord-oauth2": "^2.12.1",
|
||||
"discord.js": "^14.19.3",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "npx tsc"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ActionRowBuilder, ButtonInteraction, Client, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags, ChannelType } from 'discord.js';
|
||||
import { ticketCooldownMap } from '../shared/ticket-cooldown';
|
||||
|
||||
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 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
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ActionRowBuilder, ButtonInteraction, Client, ModalBuilder, TextInputBuilder, TextInputStyle, MessageFlags } from 'discord.js';
|
||||
import { ticketCooldownMap } from '../shared/ticket-cooldown';
|
||||
|
||||
export default {
|
||||
name: 'private-help',
|
||||
handler: async function (client: Client, interaction: ButtonInteraction) {
|
||||
const allowedAt = ticketCooldownMap.get(interaction.user.id);
|
||||
|
||||
if (allowedAt && Date.now() < allowedAt)
|
||||
return interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'You can only create one ticket per day.' });
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId('open-ticket-modal')
|
||||
.setTitle('Get in contact');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('ticket-message')
|
||||
.setLabel('What is the reason for your ticket?')
|
||||
.setStyle(TextInputStyle.Paragraph)
|
||||
.setPlaceholder('Please be as thorough as possible.')
|
||||
.setRequired(true)
|
||||
.setMinLength(10)
|
||||
.setMaxLength(1500);
|
||||
|
||||
modal.addComponents(new ActionRowBuilder<TextInputBuilder>().addComponents(input));
|
||||
|
||||
await interaction.showModal(modal);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { getE621User } from '../utils';
|
||||
import { config } from '../config';
|
||||
|
||||
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('username')
|
||||
.setDescription('The e621 username to find the discord user of.')
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(option =>
|
||||
option
|
||||
.setName('id')
|
||||
.setDescription('The e621 user id to find the discord user of.')
|
||||
.setRequired(false)
|
||||
),
|
||||
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
|
||||
const username = interaction.options.getString('username');
|
||||
const id = interaction.options.getString('id');
|
||||
|
||||
if (!username && !id) {
|
||||
return interaction.reply({ content: 'No username or id given.', flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
|
||||
try {
|
||||
const e621User = await getE621User((id ?? username) as string);
|
||||
|
||||
if (!e621User) {
|
||||
return interaction.reply('I got lost along the way. Who again?');
|
||||
}
|
||||
|
||||
const results = await Database.getDiscordIds(e621User.id);
|
||||
|
||||
const mappedResults = results.map(id => `- <@${id}>\n`);
|
||||
interaction.reply(`[${e621User.name}](${config.E621_BASE_URL}/users/${e621User.id})<${e621User.id}>'s discord account(s):\n${mappedResults}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
interaction.reply('I got lost in the net.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { getE621User } from '../utils';
|
||||
import { config } from '../config';
|
||||
import { E621User } from '../types';
|
||||
|
||||
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) {
|
||||
const id = interaction.options.getInteger('id');
|
||||
|
||||
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.reply({ content: "Couldn't figure out what your name was. Please contact an administrator.", flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
|
||||
const guild = await client.guilds.fetch(config.DISCORD_GUILD_ID!);
|
||||
|
||||
if (!guild) {
|
||||
return interaction.reply({ content: 'An error has occurred. Please try again later', flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(interaction.user.id);
|
||||
|
||||
if (!member) {
|
||||
return interaction.reply({ content: 'An error has occurred. Please try again later', flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
|
||||
await member.setNickname(e621User.name);
|
||||
interaction.reply({ content: `Nickname set to: ${e621User.name}`, flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { getE621User } from '../utils';
|
||||
import { config } from '../config';
|
||||
import { TicketPhrase } from '../types';
|
||||
|
||||
const MIN_PHRASE_LENGTH = 1;
|
||||
const MAX_PHRASE_LENGTH = 512;
|
||||
|
||||
type SubcommandGroup = 'admin' | 'personal';
|
||||
type Subcommand = 'add' | 'remove' | 'list' | 'dump';
|
||||
|
||||
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.')
|
||||
),
|
||||
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;
|
||||
if (subcommand == 'dump') {
|
||||
return dumpPhrases(interaction);
|
||||
}
|
||||
|
||||
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!);
|
||||
}
|
||||
},
|
||||
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 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.addTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
|
||||
interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`);
|
||||
}
|
||||
|
||||
async function removePhrase(interaction: ChatInputCommandInteraction, phraseId: number, group: SubcommandGroup) {
|
||||
await Database.removeTicketPhrase(phraseId);
|
||||
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')}`);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ActionRow, ActionRowBuilder, ApplicationIntegrationType, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js';
|
||||
|
||||
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.Administrator)
|
||||
.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 });
|
||||
|
||||
interaction.reply({ flags: [MessageFlags.Ephemeral], content: 'Sent.' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
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);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ApplicationIntegrationType, ChannelType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, RateLimitError, SlashCommandBuilder } from 'discord.js';
|
||||
import { msToHuman } from '../utils';
|
||||
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.Administrator)
|
||||
.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('message-logs-channel')
|
||||
.setDescription('Set the message 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)
|
||||
)
|
||||
.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)
|
||||
)
|
||||
.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)
|
||||
),
|
||||
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
|
||||
let response = '';
|
||||
|
||||
const settings = await Database.getGuildSettings(interaction.guildId!);
|
||||
|
||||
if (!settings) {
|
||||
await Database.addGuild(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 messageLogsChannel = interaction.options.getChannel('message-logs-channel');
|
||||
|
||||
if (messageLogsChannel) {
|
||||
await Database.setGuildEventsLogsChannelId(interaction.guildId!, messageLogsChannel.id);
|
||||
|
||||
response += `Message logs channel set to ${messageLogsChannel}.\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 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 addCategory = interaction.options.getChannel('add-staff-category');
|
||||
if (addCategory) {
|
||||
if (addCategory.type == ChannelType.GuildCategory) {
|
||||
await Database.addGuildStaffCategory(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.removeGuildStaffCategory(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.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.length == 0) return interaction.reply({ content: 'No settings provided.', flags: [MessageFlags.Ephemeral] });
|
||||
|
||||
interaction.reply({ content: response, flags: [MessageFlags.Ephemeral] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ApplicationIntegrationType, ChatInputCommandInteraction, Client, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { getE621User } from '../utils';
|
||||
import { config } from '../config';
|
||||
|
||||
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)
|
||||
.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) {
|
||||
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 results = await Database.getE621Ids(idToUse);
|
||||
|
||||
if (results.length > 0) {
|
||||
const mappedResults = results.map(e => `- ${config.E621_BASE_URL}/users/${e}\n`);
|
||||
|
||||
const alts: string[] = [];
|
||||
|
||||
for (const e621Id of results) {
|
||||
const discordIds = await Database.getDiscordIds(e621Id);
|
||||
|
||||
for (const discordId of discordIds) {
|
||||
if (discordId != idToUse && !alts.includes(discordId)) alts.push(discordId);
|
||||
}
|
||||
}
|
||||
|
||||
let content = `<@${idToUse}>'s e621 account(s):\n${mappedResults}`;
|
||||
|
||||
if (alts.length > 0) {
|
||||
const mappedAlts = alts.map(id => `- <@${id}>\n`);
|
||||
content += `\n\nDiscord alts found:\n${mappedAlts}`;
|
||||
}
|
||||
|
||||
interaction.reply(content);
|
||||
} else {
|
||||
interaction.reply('No e621 accounts found for this user');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DISCORD_TOKEN, DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_GUILD_ID, LINK_SECRET, E621_BASE_URL, E926_BASE_URL, REDIS_URL, PORT } = process.env;
|
||||
|
||||
export const config = {
|
||||
DISCORD_TOKEN,
|
||||
DISCORD_CLIENT_ID,
|
||||
DISCORD_CLIENT_SECRET,
|
||||
DISCORD_GUILD_ID,
|
||||
LINK_SECRET,
|
||||
E621_BASE_URL,
|
||||
E926_BASE_URL,
|
||||
PORT: parseInt(PORT as string),
|
||||
REDIS_URL,
|
||||
DEV_MODE: process.env.npm_lifecycle_event == 'dev'
|
||||
};
|
||||
|
||||
for (const [key, val] of Object.entries(config)) {
|
||||
if (val === undefined) {
|
||||
throw new Error(`${key} is undefined in config`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import DiscordOAuth2 from 'discord-oauth2';
|
||||
import { config } from './config';
|
||||
import { Database } from './shared/Database';
|
||||
import crypto from 'crypto';
|
||||
import session from 'express-session';
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
username: string;
|
||||
userId: string;
|
||||
oauthState: string;
|
||||
}
|
||||
}
|
||||
|
||||
const DEV_BASE_URL = `http://localhost:${config.PORT}`;
|
||||
const PROD_BASE_URL = 'https://discord.e621.net';
|
||||
|
||||
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`,
|
||||
credentials: Buffer.from(`${config.DISCORD_CLIENT_ID!}:${config.DISCORD_CLIENT_SECRET!}`).toString('base64')
|
||||
});
|
||||
|
||||
async function joinGuild(code: string, userId: string, username: string) {
|
||||
if (Number.isNaN(userId)) return false;
|
||||
if (!username) return false;
|
||||
|
||||
const id = Number(userId);
|
||||
|
||||
const tokenResponse = await oauth.tokenRequest({
|
||||
code,
|
||||
scope: 'identify guilds.join',
|
||||
grantType: 'authorization_code'
|
||||
});
|
||||
|
||||
const user = await oauth.getUser(tokenResponse.access_token);
|
||||
|
||||
await Database.putUser(id, user);
|
||||
|
||||
await oauth.addMember({
|
||||
accessToken: tokenResponse.access_token,
|
||||
botToken: config.DISCORD_TOKEN!,
|
||||
guildId: config.DISCORD_GUILD_ID!,
|
||||
userId: user.id,
|
||||
nickname: username
|
||||
});
|
||||
|
||||
await oauth.revokeToken(tokenResponse.access_token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleInitial(req: Request, res: Response): Promise<any> {
|
||||
const { username, user_id, time, hash } = req.query;
|
||||
|
||||
if (!username || !user_id || !time || !hash) {
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
if (Date.now() / 1000 > Number(time)) {
|
||||
return res.status(403).send('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 res.sendStatus(403);
|
||||
}
|
||||
|
||||
const oauthState = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
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 res.sendStatus(500);
|
||||
}
|
||||
|
||||
res.redirect(oauth.generateAuthUrl({
|
||||
state: oauthState,
|
||||
scope: ['identify', 'guilds.join']
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCallback(req: Request, res: Response): Promise<any> {
|
||||
if (!req.session.userId || !req.session.username || !req.session.oauthState) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
const state = req.query.state as string;
|
||||
|
||||
if (state != req.session.oauthState) {
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!await joinGuild(code, userId, username)) {
|
||||
console.error(`Error joining user: ${username} (${userId})`);
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
export function initializeDiscordJoiner() {
|
||||
const app = express();
|
||||
|
||||
app.use(session({
|
||||
secret: config.DISCORD_CLIENT_SECRET!,
|
||||
cookie: {
|
||||
secure: !config.DEV_MODE,
|
||||
httpOnly: !config.DEV_MODE,
|
||||
sameSite: false
|
||||
},
|
||||
resave: false,
|
||||
saveUninitialized: false
|
||||
}));
|
||||
|
||||
app.get('/', handleInitial);
|
||||
app.get('/callback', handleCallback);
|
||||
|
||||
app.listen(config.PORT, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Listening on port ${config.PORT}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { APIEmbedField, APIRole, AuditLogChange, AuditLogEvent, EmbedBuilder, EmbedField, Guild, GuildAuditLogsEntry, PermissionsBitField, Role, RoleFlags, SlashCommandSubcommandGroupBuilder, SnowflakeUtil } from 'discord.js';
|
||||
import { formatChanges, formatExtras, formatSnowflake, getTargetType } from '../utils';
|
||||
import { Database } from '../shared/Database';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Database } from '../shared/Database';
|
||||
|
||||
export async function handleGuildCreate(guild) {
|
||||
try {
|
||||
if (!await Database.getGuildSettings(guild.id)) await Database.addGuild(guild.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GuildMember, GuildTextBasedChannel } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { config } from '../config';
|
||||
|
||||
export async function handleMemberJoin(member: GuildMember) {
|
||||
const guildSettings = await Database.getGuildSettings(member.guild.id);
|
||||
|
||||
if (guildSettings?.new_member_channel_id) {
|
||||
const e621UserIds = await Database.getE621Ids(member.id);
|
||||
const channel = await member.guild.channels.fetch(guildSettings.new_member_channel_id) as GuildTextBasedChannel;
|
||||
|
||||
if (channel) {
|
||||
let content = `${member.toString()}'s e621 and discord account(s):\n`;
|
||||
|
||||
for (const e621Id of e621UserIds) {
|
||||
content += `- ${config.E621_BASE_URL}/users/${e621Id}\n`;
|
||||
|
||||
const discordIds = await Database.getDiscordIds(e621Id);
|
||||
|
||||
for (const discordId of discordIds) {
|
||||
content += `- - <@${discordId}>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
channel.send(content).catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { AllowedMentionsTypes, Message as DiscordMessage, GuildTextBasedChannel, OmitPartialGroupDMChannel, PartialMessage, ReadonlyCollection } from 'discord.js';
|
||||
import { config } from '../config';
|
||||
import { E621Post } from '../types';
|
||||
import { getE621Post, getE621PostByMd5, getPostUrl, hasBlacklistedTags } from '../utils/e621-utils';
|
||||
import { Database } from '../shared/Database';
|
||||
import { logDeletion, logEdit } from '../utils/message-logger';
|
||||
import { isEdited } from '../utils/message-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 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 postIDRegex = new RegExp('post #([0-9]+)', 'gi');
|
||||
const tagSearchRegex = '(?:[\\S]| )+?';
|
||||
const wikiLinkRegex = new RegExp(`\\[\\[(${tagSearchRegex})]]`, 'gi');
|
||||
const searchLinkRegex = new RegExp(`{{(${tagSearchRegex})}}`, 'gi');
|
||||
|
||||
const regexTesters = [
|
||||
{ runInDev: false, regex: postRegex, handler: postHandler },
|
||||
{ runInDev: false, regex: imageRegex, handler: imageHandler },
|
||||
{ runInDev: true, regex: postRegex_DEV, handler: postHandler },
|
||||
{ runInDev: true, regex: imageRegex_DEV, handler: imageHandler },
|
||||
{ runInDev: true, regex: postIDRegex, handler: postIdHandler },
|
||||
{ runInDev: true, regex: wikiLinkRegex, handler: wikiPageHandler },
|
||||
{ runInDev: true, regex: searchLinkRegex, handler: searchHandler }
|
||||
];
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
if (!await test.handler(message, matches.filter(uniqueRegexMatches))) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleMessageUpdate(oldMessage: Message | PartialMessage, newMessage: Message) {
|
||||
if (newMessage.author.bot) return;
|
||||
|
||||
const loggedMessage = await Database.getMessageWithRetry(newMessage.id);
|
||||
|
||||
if (!loggedMessage) return;
|
||||
|
||||
if (newMessage.inGuild() && isEdited(loggedMessage, newMessage)) {
|
||||
await Database.putMessage(newMessage);
|
||||
await logEdit(loggedMessage, newMessage);
|
||||
}
|
||||
|
||||
if (loggedMessage.content == newMessage.content) return;
|
||||
|
||||
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 && !await test.handler(newMessage, properMatches.filter(uniqueRegexMatches))) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
let content = '';
|
||||
|
||||
for (const group of matchedGroups) {
|
||||
content += `<${config.E621_BASE_URL}/posts?tags=${encodeURIComponent(group[1])}>\n`;
|
||||
}
|
||||
|
||||
await message.reply(content.trim());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<boolean> {
|
||||
let content = '';
|
||||
|
||||
for (const group of matchedGroups) {
|
||||
content += `<${config.E621_BASE_URL}/wiki_pages/${encodeURIComponent(group[1])}>\n`;
|
||||
}
|
||||
|
||||
await message.reply(content.trim());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> {
|
||||
const staffCategories = await Database.getGuildStaffCategories(message.guildId!);
|
||||
|
||||
const blacklistedIds: number[] = [];
|
||||
|
||||
const channel = await message.channel.fetch() as GuildTextBasedChannel;
|
||||
|
||||
|
||||
for (const post of posts) {
|
||||
if (hasBlacklistedTags(post)) {
|
||||
blacklistedIds.push(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (blacklistedIds.length == 0) return false;
|
||||
|
||||
await message.delete();
|
||||
|
||||
if (channel.parentId && staffCategories.includes(channel.parentId)) {
|
||||
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<boolean> {
|
||||
if (!message.guildId) return true;
|
||||
|
||||
const posts: E621Post[] = [];
|
||||
|
||||
for (const match of matchedGroups) {
|
||||
try {
|
||||
const post = await getE621Post(match[1]);
|
||||
if (post) posts.push(post);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (await blacklistIfNecessary(message, posts)) return false;
|
||||
|
||||
const content = posts.map(post => getPostUrl(post)).join('\n');
|
||||
|
||||
if (content.length > 0) await message.reply(content);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function postHandler(message: Message, matchedGroups: RegExpExecArray[]): Promise<boolean> {
|
||||
if (!message.guildId) return true;
|
||||
|
||||
const posts: E621Post[] = [];
|
||||
|
||||
for (const match of matchedGroups) {
|
||||
try {
|
||||
const post = await getE621Post(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<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 content = posts.map(post => getPostUrl(post)).join('\n');
|
||||
|
||||
if (content.length > 0) await message.reply(content);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Guild, GuildMember, GuildTextBasedChannel, time, TimestampStyles, 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.event_logs_channel_id) return;
|
||||
|
||||
const channel = await guild.channels.fetch(settings.event_logs_channel_id);
|
||||
|
||||
if (!channel || !channel.isSendable()) return;
|
||||
|
||||
return channel as GuildTextBasedChannel;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './handle-audit-log-create';
|
||||
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';
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import 'source-map-support/register';
|
||||
import { Client as DiscordClient, GatewayIntentBits, Guild, Partials } from 'discord.js';
|
||||
import { config } from './config';
|
||||
import { Handler } from './types';
|
||||
import { initIfNecessary, loadHandlersFrom, openRedisClient, refreshCommands } from './utils';
|
||||
import { initializeDiscordJoiner } from './discord-joiner';
|
||||
import { Database } from './shared/Database';
|
||||
import { handleAuditLogCreate, handleBulkMessageDelete, handleGuildCreate, handleMemberJoin, handleMessageCreate, handleMessageDelete, handleMessageUpdate, handleThreadCreate, handleVoiceStateUpdate } from './events';
|
||||
import { ticketCooldownMap } from './shared/ticket-cooldown';
|
||||
|
||||
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 buttons: Handler[] = [];
|
||||
const modals: Handler[] = [];
|
||||
const menus: Handler[] = [];
|
||||
|
||||
loadHandlersFrom('commands', commands);
|
||||
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.',
|
||||
ephemeral: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) {
|
||||
// Handle chat and context menu commands.
|
||||
for (const command of commands) {
|
||||
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('_')[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('_')[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('_')[1]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('ready', 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 initializeDiscordJoiner();
|
||||
|
||||
// Prune ticket cooldowns that are expired every day
|
||||
setInterval(() => {
|
||||
const keys = Array.from(ticketCooldownMap.keys());
|
||||
for (const key of keys) {
|
||||
if (Date.now() >= ticketCooldownMap.get(key)!)
|
||||
ticketCooldownMap.delete(key);
|
||||
}
|
||||
}, 8.64e+7);
|
||||
|
||||
ready = true;
|
||||
|
||||
console.log('Ready');
|
||||
});
|
||||
|
||||
client.on('guildAuditLogEntryCreate', handleAuditLogCreate);
|
||||
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);
|
||||
|
||||
client.login(config.DISCORD_TOKEN);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, GuildTextBasedChannel, MessageFlags, ModalSubmitInteraction, TextChannel, ThreadAutoArchiveDuration } from 'discord.js';
|
||||
import { ticketCooldownMap } from '../shared/ticket-cooldown';
|
||||
import { Database } from '../shared/Database';
|
||||
|
||||
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.' });
|
||||
|
||||
ticketCooldownMap.set(interaction.user.id, Date.now() + 8.64e+7);
|
||||
|
||||
const reason = interaction.fields.getTextInputValue('ticket-message');
|
||||
|
||||
const channel = (await interaction.channel?.fetch()) as TextChannel;
|
||||
|
||||
const thread = await channel.threads.create({
|
||||
name: `${member.displayName}'s Ticket`,
|
||||
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
|
||||
invitable: false,
|
||||
type: ChannelType.PrivateThread
|
||||
});
|
||||
|
||||
const button = new ButtonBuilder()
|
||||
.setCustomId('close-ticket')
|
||||
.setLabel('Click here if you no longer need help')
|
||||
.setStyle(ButtonStyle.Danger);
|
||||
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(button);
|
||||
|
||||
await thread.send({
|
||||
content: `${interaction.user} 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}`,
|
||||
components: [row],
|
||||
allowedMentions: {
|
||||
users: [interaction.user.id],
|
||||
roles: [guildSettings.private_help_role_id]
|
||||
}
|
||||
});
|
||||
|
||||
interaction.reply({ flags: [MessageFlags.Ephemeral], content: `Your ticket has been created: ${thread}` });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open, Database as SqliteDatabase } from 'sqlite';
|
||||
import { config } from '../config';
|
||||
import DiscordOAuth2 from 'discord-oauth2';
|
||||
import { serializeMessage, wait } from '../utils';
|
||||
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase } from '../types';
|
||||
import { 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,
|
||||
audit_logs_channel_id TEXT,
|
||||
voice_logs_channel_id TEXT,
|
||||
admin_role_id TEXT,
|
||||
private_help_role_id TEXT,
|
||||
staff_categories 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
|
||||
);
|
||||
`;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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) {
|
||||
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 => ({ user_id: r.user_id.toString(), discord_id: r.discord_id }));
|
||||
}
|
||||
|
||||
static async putUser(id: number, user: DiscordOAuth2.User) {
|
||||
await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username);
|
||||
}
|
||||
|
||||
static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> {
|
||||
return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId);
|
||||
}
|
||||
|
||||
static async addGuild(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 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 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 getGuildStaffCategories(guildId: string): Promise<string[]> {
|
||||
const settings = await Database.db.get<{ staff_categories: string }>('SELECT staff_categories FROM settings WHERE guild_id = ?', guildId);
|
||||
|
||||
if (!settings || !settings.staff_categories) return [];
|
||||
|
||||
return settings.staff_categories.split(',');
|
||||
}
|
||||
|
||||
static async addGuildStaffCategory(guildId: string, categoryId: string) {
|
||||
const categories = await Database.getGuildStaffCategories(guildId);
|
||||
|
||||
categories.push(categoryId);
|
||||
|
||||
const newString = categories.join(',');
|
||||
|
||||
await Database.db.run('UPDATE settings SET staff_categories = ? WHERE guild_id = ?', newString, guildId);
|
||||
}
|
||||
|
||||
static async removeGuildStaffCategory(guildId: string, categoryId: string): Promise<boolean> {
|
||||
const categories = await Database.getGuildStaffCategories(guildId);
|
||||
|
||||
const index = categories.indexOf(categoryId);
|
||||
if (index == -1) return false;
|
||||
|
||||
categories.splice(index, 1);
|
||||
|
||||
const newString = categories.join(',');
|
||||
|
||||
await Database.db.run('UPDATE settings SET staff_categories = ? WHERE guild_id = ?', newString, guildId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
static async putTicket(ticketId: number, messageId: string) {
|
||||
await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId);
|
||||
}
|
||||
|
||||
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 addTicketPhrase(userId: string, phrase: string) {
|
||||
await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase);
|
||||
}
|
||||
|
||||
static async removeTicketPhrase(id: number) {
|
||||
await Database.db.run('DELETE from ticket_phrases WHERE id = ?', id);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const ticketCooldownMap = new Map<string, number>();
|
||||
@@ -0,0 +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[];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
audit_logs_channel_id?: string
|
||||
voice_logs_channel_id?: string
|
||||
admin_role_id?: string
|
||||
private_help_role_id?: string
|
||||
staff_categories?: string
|
||||
}
|
||||
|
||||
export type TicketMessage = {
|
||||
id: number
|
||||
message_id: string
|
||||
}
|
||||
|
||||
export type TicketPhrase = {
|
||||
id: number
|
||||
user_id: string
|
||||
phrase: string
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
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
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { APIRole, PermissionsBitField, TextChannel } from 'discord.js';
|
||||
|
||||
export type RoleChangeLog = {
|
||||
key: '$add' | '$remove',
|
||||
old?: Pick<APIRole, 'id' | 'name'>[],
|
||||
new?: Pick<APIRole, 'id' | 'name'>[]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './command';
|
||||
export * from './e621-types';
|
||||
export * from './handler';
|
||||
export * from './helper-types';
|
||||
export * from './database-types';
|
||||
@@ -0,0 +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 };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { AuditLogChange, AuditLogEvent, AuditLogOptionsType, GuildAuditLogsEntry, APIRole, time, TimestampStyles, PermissionsBitField, PermissionsString, Guild } from 'discord.js';
|
||||
import { 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)).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') results.push(formatSnowflake(value as string, TargetType.Channel));
|
||||
|
||||
results.push(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
return results.join('\n');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatChange(change: AuditLogChange): string | undefined {
|
||||
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);
|
||||
}
|
||||
|
||||
if (change.new !== undefined && change.old === undefined)
|
||||
return `Set ${change.key} to ${change.new}`;
|
||||
|
||||
if (change.new === undefined && change.old !== undefined)
|
||||
return `Set ${change.key} with value ${change.old} to default/null`;
|
||||
|
||||
return `Set ${change.key} from ${change.old} to ${change.new}`;
|
||||
}
|
||||
|
||||
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: https://discord.com/${guild.id}/${data.channel.id}/${data.messageId}`;
|
||||
}
|
||||
@@ -0,0 +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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createClient } from '@redis/client';
|
||||
import { Database } from '../shared/Database';
|
||||
import { config } from '../config';
|
||||
import { APIEmbedField, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels, TextBasedChannel } from 'discord.js';
|
||||
import { humanizeCapitalization } from './string-utils';
|
||||
import { Ticket, TicketPhrase, TicketUpdate } from '../types';
|
||||
import { shouldAlert } from './ticket-utils';
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
|
||||
let discordClient: Client;
|
||||
|
||||
export async function openRedisClient(url: string, discClient: Client) {
|
||||
const client = await createClient({
|
||||
url: `redis://${url}`
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
discordClient = discClient;
|
||||
|
||||
console.log('Connected to redis database');
|
||||
|
||||
await client.subscribe('ticket_updates', updateHandler);
|
||||
}
|
||||
|
||||
async function updateHandler(update: string, channel: string) {
|
||||
const data: TicketUpdate = JSON.parse(update);
|
||||
|
||||
if (data.action == 'create') {
|
||||
postTicket(data);
|
||||
} else {
|
||||
updateTicket(data);
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function getDescription(ticket: Ticket): string {
|
||||
return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? ticket.reason : ticket.reason.substring(0, 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
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function createEmbedFromTicket(ticket: Ticket): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle(getTitle(ticket))
|
||||
.setURL(getURL(ticket))
|
||||
.setDescription(getDescription(ticket))
|
||||
.setAuthor(getAuthor(ticket))
|
||||
.setColor(getColor(ticket))
|
||||
.setFields(...getFields(ticket));
|
||||
}
|
||||
|
||||
async function postTicket(data: TicketUpdate) {
|
||||
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
|
||||
|
||||
if (!guildSettings || !guildSettings.tickets_channel_id) return;
|
||||
|
||||
const channel = await discordClient.channels.fetch(guildSettings.tickets_channel_id);
|
||||
|
||||
if (!channel || !channel.isSendable()) return;
|
||||
|
||||
const ticket = data.ticket;
|
||||
|
||||
const embed = createEmbedFromTicket(ticket);
|
||||
|
||||
const message = await channel.send({ embeds: [embed] });
|
||||
|
||||
await Database.putTicket(ticket.id, message.id);
|
||||
|
||||
sendTicketAlerts(ticket, channel);
|
||||
}
|
||||
|
||||
async function updateTicket(data: TicketUpdate) {
|
||||
const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!);
|
||||
|
||||
if (!guildSettings || !guildSettings.tickets_channel_id) return;
|
||||
|
||||
const channel = await discordClient.channels.fetch(guildSettings.tickets_channel_id);
|
||||
|
||||
if (!channel || !channel.isSendable()) return;
|
||||
|
||||
const messageId = await Database.getTicketMessageId(data.ticket.id);
|
||||
|
||||
if (!messageId) return;
|
||||
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
const embed = createEmbedFromTicket(data.ticket);
|
||||
|
||||
if (!message) {
|
||||
const newMessage = await channel.send({ embeds: [embed] });
|
||||
await Database.putTicket(data.ticket.id, newMessage.id);
|
||||
} else {
|
||||
await message.edit({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { config } from '../config';
|
||||
import { E621Post, E621User } from '../types';
|
||||
|
||||
const BLACKLISTED_TAGS: string[] = [];
|
||||
const BLACKLISTED_NONSAFE_TAGS: string[] = ['young'];
|
||||
|
||||
const USER_AGENT = 'E621DiscordBot';
|
||||
const E621_NAME_URL = `${config.E621_BASE_URL}/users/{idOrName}.json`;
|
||||
const E621_POST_URL = `${config.E621_BASE_URL}/posts/{id}.json`;
|
||||
const E621_MD5_POST_URL = `${config.E621_BASE_URL}/posts.json?md5={md5}`;
|
||||
|
||||
export async function getE621User(idOrName: string | number): Promise<E621User | null> {
|
||||
const res = await fetch(E621_NAME_URL.replace('{idOrName}', idOrName.toString()), {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
return await res.json() as E621User;
|
||||
}
|
||||
|
||||
export async function getE621Post(id: string | number): Promise<E621Post | null> {
|
||||
const res = await fetch(E621_POST_URL.replace('{id}', id.toString()), {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
return (await res.json() as { post: E621Post }).post as E621Post;
|
||||
}
|
||||
|
||||
export async function getE621PostByMd5(md5: string): Promise<E621Post | null> {
|
||||
const res = await fetch(E621_MD5_POST_URL.replace('{md5}', md5), {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
return (await res.json() as { post: E621Post }).post as E621Post;
|
||||
}
|
||||
|
||||
export function hasBlacklistedTags(post: E621Post): boolean {
|
||||
for (const tags of Object.values(post.tags)) {
|
||||
if (tags.some(t => BLACKLISTED_TAGS.includes(t))) return true;
|
||||
if (post.rating != 's' && tags.some(t => BLACKLISTED_NONSAFE_TAGS.includes(t))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getPostUrl(post: E621Post): string {
|
||||
if (post.rating == 's') return `${config.E926_BASE_URL}/post/${post.id}`;
|
||||
return `${config.E621_BASE_URL}/post/${post.id}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './array-utils';
|
||||
export * from './audit-log-utils';
|
||||
export * from './commands';
|
||||
export * from './e621-ticket-listener';
|
||||
export * from './e621-utils';
|
||||
export * from './message-logger';
|
||||
export * from './message-utils';
|
||||
export * from './ms-to-human';
|
||||
export * from './refresh-commands';
|
||||
export * from './string-utils';
|
||||
export * from './wait';
|
||||
@@ -0,0 +1,158 @@
|
||||
import { APIEmbedField, Channel, EmbedBuilder, GuildTextBasedChannel, messageLink } from 'discord.js';
|
||||
import { Database } from '../shared/Database';
|
||||
import { Message } from '../events';
|
||||
import { deserializeMessagePart, getModifiedAttachments, getModifiedStickers } from './message-utils';
|
||||
import { LoggedMessage } from '../types';
|
||||
|
||||
export async function logEdit(loggedMessage: LoggedMessage, newMessage: Message<true>) {
|
||||
const settings = await Database.getGuildSettings(newMessage.guildId);
|
||||
|
||||
if (!settings || !settings.event_logs_channel_id) return;
|
||||
|
||||
const channel = await newMessage.guild.channels.fetch(settings.event_logs_channel_id);
|
||||
|
||||
if (!channel || !channel.isSendable()) return;
|
||||
|
||||
const fields: APIEmbedField[] = [];
|
||||
fields.push(...getMainEmbeds(loggedMessage, newMessage));
|
||||
fields.push(...getEditEmbeds(loggedMessage, newMessage));
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Edited Message')
|
||||
.setColor(0xFFFF00)
|
||||
.setTimestamp(newMessage.createdTimestamp)
|
||||
.addFields(...fields);
|
||||
|
||||
channel.send({ embeds: [embed] });
|
||||
}
|
||||
|
||||
export async function logDeletion(loggedMessage: LoggedMessage, deletedMessage: Message<true>) {
|
||||
const settings = await Database.getGuildSettings(deletedMessage.guildId);
|
||||
|
||||
if (!settings || !settings.event_logs_channel_id) return;
|
||||
|
||||
const channel = await deletedMessage.guild.channels.fetch(settings.event_logs_channel_id);
|
||||
|
||||
if (!channel || !channel.isSendable()) return;
|
||||
|
||||
const fields: APIEmbedField[] = [];
|
||||
fields.push(...getMainEmbeds(loggedMessage, deletedMessage));
|
||||
fields.push(...getDeletedEmbeds(loggedMessage));
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Deleted Message')
|
||||
.setColor(0xFF0000)
|
||||
.setTimestamp(deletedMessage.createdTimestamp)
|
||||
.addFields(...fields);
|
||||
|
||||
channel.send({ embeds: [embed] });
|
||||
}
|
||||
|
||||
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.toString(),
|
||||
inline: true
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getDeletedEmbeds(loggedMessage: LoggedMessage): APIEmbedField[] {
|
||||
const fields: APIEmbedField[] = [];
|
||||
|
||||
if (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>): APIEmbedField[] {
|
||||
const fields: APIEmbedField[] = [];
|
||||
if (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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Message } from '../events';
|
||||
import { LoggedMessage } from '../types';
|
||||
|
||||
export const ARRAY_SEPARATOR = '$';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +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(', ');
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 = fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts'));
|
||||
|
||||
for (const file of commandFiles) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const command: Command = require(`${ROOT_DIR}/commands/${file}`).default;
|
||||
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));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export function humanizeCapitalization(str: string): string {
|
||||
return str.toLowerCase()
|
||||
.split(' ')
|
||||
.map(s => s.charAt(0).toUpperCase() + s.substring(1))
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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) };
|
||||
} else {
|
||||
return { alert: false };
|
||||
}
|
||||
} else {
|
||||
const regex = new RegExp(ticketPhrase.phrase.substring(1, ticketPhrase.phrase.length - 2), 'i');
|
||||
|
||||
const regexMatch = regex.exec(ticket.reason);
|
||||
|
||||
if (regexMatch) {
|
||||
return { alert: true, match: `${friendlyPhrase(regexMatch[0])} (RegEx match: \`${ticketPhrase.phrase}\`)` };
|
||||
} else {
|
||||
return { alert: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function wait(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"inlineSourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ES2021.String"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules/**"]
|
||||
}
|
||||
Reference in New Issue
Block a user