Initial commit.

This commit is contained in:
Tarrgon
2025-05-28 10:44:41 -04:00
commit e0efb86dce
50 changed files with 8579 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/.git
/data
/dist
/node_modules
+9
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
data/*
.env
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm i
RUN npm run build
CMD ["npm", "run", "start"]
+10
View File
@@ -0,0 +1,10 @@
services:
discordbotrewrite:
restart: always
build: .
network_mode: host
env_file: '.env'
ports:
- "8000:8000"
volumes:
- ./data:/app/data
+68
View File
@@ -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',
},
}
);
+5751
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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"
}
}
+21
View File
@@ -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
});
}
};
+29
View File
@@ -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);
}
};
+51
View File
@@ -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.');
}
}
};
+52
View File
@@ -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] });
}
};
+165
View File
@@ -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')}`);
}
+42
View File
@@ -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.' });
}
};
+46
View File
@@ -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.');
}
}
};
+160
View File
@@ -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] });
}
};
+63
View File
@@ -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');
}
}
};
+24
View File
@@ -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`);
}
}
+152
View File
@@ -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}`);
});
}
+94
View File
@@ -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;
}
+9
View File
@@ -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);
}
}
+28
View File
@@ -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);
}
}
}
+244
View File
@@ -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;
}
+11
View File
@@ -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);
}
}
+46
View File
@@ -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;
}
+6
View File
@@ -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
View File
@@ -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);
+47
View File
@@ -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}` });
}
};
+237
View File
@@ -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);
});
}
}
+1
View File
@@ -0,0 +1 @@
export const ticketCooldownMap = new Map<string, number>();
+9
View File
@@ -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[];
}
+33
View File
@@ -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
}
+128
View File
@@ -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
};
+10
View File
@@ -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;
}
+24
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
export * from './command';
export * from './e621-types';
export * from './handler';
export * from './helper-types';
export * from './database-types';
+6
View File
@@ -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 };
}
+177
View File
@@ -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}`;
}
+22
View File
@@ -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);
}
}
+194
View File
@@ -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
}
});
}
+60
View File
@@ -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}`;
}
+11
View File
@@ -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';
+158
View File
@@ -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;
}
+43
View File
@@ -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;
}
+12
View File
@@ -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(', ');
}
+58
View File
@@ -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));
}
};
+6
View File
@@ -0,0 +1,6 @@
export function humanizeCapitalization(str: string): string {
return str.toLowerCase()
.split(' ')
.map(s => s.charAt(0).toUpperCase() + s.substring(1))
.join(' ');
}
+32
View File
@@ -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 };
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
+20
View File
@@ -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/**"]
}