diff --git a/src/shared/Database.ts b/src/shared/Database.ts index 71bc95a..25eabee 100644 --- a/src/shared/Database.ts +++ b/src/shared/Database.ts @@ -1,7 +1,7 @@ import sqlite3 from 'sqlite3'; import { open, Database as SqliteDatabase } from 'sqlite'; import { serializeMessage, wait } from '../utils'; -import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket } from '../types'; +import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Note, Ban, GuildArraySetting, GithubUserMapping, KnowledgebaseItem, PrivateHelpTicket, AppealMessage } from '../types'; import { Message } from '../events'; const DB_SCHEMA = ` @@ -30,7 +30,8 @@ const DB_SCHEMA = ` link_skip_channels TEXT, github_release_channel TEXT, moderator_channel_id TEXT, - private_help_channel_id TEXT + private_help_channel_id TEXT, + appeals_channel_id TEXT ); CREATE TABLE IF NOT EXISTS messages ( @@ -58,6 +59,11 @@ const DB_SCHEMA = ` phrase TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS appeals ( + id INTEGER PRIMARY KEY, + message_id TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY, user_id TEXT, @@ -354,6 +360,23 @@ export class Database { // -- END TICKETS -- + // -- START APPEALS -- + + static async putAppeal(appealId: number, messageId: string) { + await Database.db.run('INSERT INTO appeals(id, message_id) VALUES (?, ?)', appealId, messageId); + } + + static async removeAppeal(appealId: number) { + await Database.db.run('DELETE from appeals WHERE id = ?', appealId); + } + + static async getAppealMessageId(appealId: number): Promise { + const appeal = await Database.db.get>('SELECT message_id FROM appeals WHERE id = ?', appealId); + return appeal?.message_id; + } + + // -- END APPEALS -- + // -- START NOTES -- static async putNote(userId: string, reason: string, modId: string) { diff --git a/src/shared/RedisClient.ts b/src/shared/RedisClient.ts index dcd317b..32ebb2a 100644 --- a/src/shared/RedisClient.ts +++ b/src/shared/RedisClient.ts @@ -1,6 +1,6 @@ import { createClient, SocketClosedUnexpectedlyError } from '@redis/client'; import { Client } from 'discord.js'; -import { banUpdateHandler, ticketUpdateHandler } from '../utils'; +import { banUpdateHandler, ticketUpdateHandler, appealUpdateHandler } from '../utils'; let discordClient: Client; @@ -32,7 +32,7 @@ export async function openRedisClient(url: string, discClient: Client) { }); client.once('connect', () => { - client.subscribe(['ticket_updates', 'ban_updates'], updateHandler); + client.subscribe(['ticket_updates', 'ban_updates', 'appeal_updates'], updateHandler); }); client.connect(); @@ -46,5 +46,7 @@ function updateHandler(data: string, channel: string) { return ticketUpdateHandler(discordClient, data); case 'ban_updates': return banUpdateHandler(discordClient, data); + case 'appeal_updates': + return appealUpdateHandler(discordClient, data); } } \ No newline at end of file diff --git a/src/types/database-types.d.ts b/src/types/database-types.d.ts index 2d078da..27013eb 100644 --- a/src/types/database-types.d.ts +++ b/src/types/database-types.d.ts @@ -25,7 +25,8 @@ export type GuildSettings = { link_skip_channels?: string github_release_channel?: string moderator_channel_id?: string - private_help_channel_id?: string + private_help_channel_id?: string, + appeals_channel_id?: string } export type GuildArraySetting = 'staff_categories' | 'safe_channels' | 'link_skip_channels'; @@ -35,6 +36,11 @@ export type TicketMessage = { message_id: string } +export type AppealMessage = { + id: number + message_id: string +} + export type TicketPhrase = { id: number user_id: string diff --git a/src/types/e621-types.d.ts b/src/types/e621-types.d.ts index c4bc61b..e891723 100644 --- a/src/types/e621-types.d.ts +++ b/src/types/e621-types.d.ts @@ -142,6 +142,24 @@ export type BanUpdate = { ban: Ban }; +export type Appeal = { + id: number + user_id: number + user: string + claimant: string | null + target?: string + accused_id: number + target_id: number + status: 'pending' | 'partial' | 'approved' | 'rejected' + category: 'flag' + reason: string +} + +export type AppealUpdate = { + action: 'claim' | 'create' | 'unclaim' | 'update' + appeal: Appeal +}; + export type RecordCategory = 'positive' | 'negative' | 'neutral' export type Record = { diff --git a/src/utils/appeal-events.ts b/src/utils/appeal-events.ts new file mode 100644 index 0000000..29e0589 --- /dev/null +++ b/src/utils/appeal-events.ts @@ -0,0 +1,102 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder } from 'discord.js'; +import { config } from '../config'; +import { Appeal, AppealUpdate } from '../types'; +import { getAuthor, getColor, getDescription, getFields } from './event-utils'; +import { humanizeCapitalization } from './string-utils'; +import { Database } from '../shared/Database'; + +export async function appealUpdateHandler(client: Client, update: string) { + const data: AppealUpdate = JSON.parse(update); + + if (data.action == 'create') { + postAppeal(client, data); + } else { + updateAppeal(client, data); + } +} + +async function postAppeal(client: Client, data: AppealUpdate) { + const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!guildSettings || !guildSettings.appeals_channel_id) return; + + const channel = await client.channels.fetch(guildSettings.appeals_channel_id); + + if (!channel || !channel.isSendable()) return; + + const appeal = data.appeal; + + const embed = await createEmbedFromAppeal(appeal); + + const row = await getButtons(appeal); + + const message = await channel.send({ embeds: [embed], components: [row] }); + + await Database.putAppeal(appeal.id, message.id); +} + +async function updateAppeal(client: Client, data: AppealUpdate) { + const guildSettings = await Database.getGuildSettings(config.DISCORD_GUILD_ID!); + + if (!guildSettings || !guildSettings.tickets_channel_id) return; + + const channel = await client.channels.fetch(guildSettings.tickets_channel_id); + + if (!channel || !channel.isSendable()) return; + + const messageId = await Database.getAppealMessageId(data.appeal.id); + + if (!messageId) return postAppeal(client, data); + + const message = await channel.messages.fetch(messageId); + const embed = await createEmbedFromAppeal(data.appeal); + + if (!message || message.author.id != config.DISCORD_CLIENT_ID) { + const newMessage = await channel.send({ embeds: [embed] }); + await Database.removeAppeal(data.appeal.id); + await Database.putAppeal(data.appeal.id, newMessage.id); + } else { + await message.edit({ embeds: [embed] }); + } +} + +function getTitle(appeal: Appeal): string { + if (!appeal.target) return `${humanizeCapitalization(appeal.category)} appeal from ${appeal.user}`; + + switch (appeal.category) { + case 'flag': + return `Flag by ${appeal.target}`; + default: + return 'Uknown ticket category'; + } +} + +function getURL(appeal: Appeal): string { + return `${config.E621_BASE_URL}/appeals/${appeal.id}`; +} + +async function createEmbedFromAppeal(appeal: Appeal): Promise { + return new EmbedBuilder() + .setTitle(getTitle(appeal)) + .setURL(await getURL(appeal)) + .setDescription(await getDescription(appeal)) + .setAuthor(getAuthor(appeal)) + .setColor(getColor(appeal)) + .setFields(...getFields(appeal)) + .setFooter({ text: `Appeal #${appeal.id}` }); +} + +async function getButtons(appeal: Appeal): Promise> { + const row = new ActionRowBuilder(); + + const primaryButton = new ButtonBuilder() + .setStyle(ButtonStyle.Link); + + if (appeal.category == 'flag') { + primaryButton + .setLabel('Open Flag') + .setURL(`${config.E621_BASE_URL}/post_flags/${appeal.target_id}`); + } + + return row; +} \ No newline at end of file diff --git a/src/utils/event-utils.ts b/src/utils/event-utils.ts new file mode 100644 index 0000000..0815ed8 --- /dev/null +++ b/src/utils/event-utils.ts @@ -0,0 +1,163 @@ +import { EmbedAuthorOptions, APIEmbedField } from 'discord.js'; +import { config } from '../config'; +import { getE621Post, spoilerOrBlacklist, PostAction } from './e621-utils'; +import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex'; + +// TODO: Condense this and the message event handler regex array. +const linkReplacers = [ + { + regex: blipIDRegex, + replacement: '/blips/{match}', + encodeURI: false + }, + { + regex: commentIDRegex, + replacement: '/comments/{match}', + encodeURI: false + }, + { + regex: forumTopicIDRegex, + replacement: '/forum_topics/{match}', + encodeURI: false + }, + { + regex: poolIDRegex, + replacement: '/pools/{match}', + encodeURI: false + }, + { + regex: postIDRegex, + tester: async (postId: string, before: string, after: string) => { + const post = await getE621Post(postId); + if (!post) return { allowed: true, before, after }; + const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist; + + return { allowed, before, after }; + }, + replacement: '/posts/{match}', + encodeURI: false + }, + { + regex: recordIDRegex, + replacement: '/user_feedbacks/{match}', + encodeURI: false + }, + { + regex: searchLinkRegex, + replacement: '/posts?tags={match}', + encodeURI: true + }, + { + regex: setIDRegex, + replacement: '/post_sets/{match}', + encodeURI: false + }, + { + regex: takedownIDRegex, + replacement: '/takedowns/{match}', + encodeURI: false + }, + { + regex: ticketIDRegex, + replacement: '/tickets/{match}', + encodeURI: false + }, + { + regex: userIDRegex, + replacement: '/users/{match}', + encodeURI: false + }, + { + regex: wikiLinkRegex, + replacement: '/wiki_pages/{match}', + encodeURI: true + } +]; + +const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi'); + +const MAX_DESCRIPTION_LENGTH = 500; + +export async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise { + const length = input.length; + + const replacedIndexes: { start: number, end: number }[] = []; + const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = []; + + for (const replacer of linkReplacers) { + input = input.replaceAll(replacer.regex, (match, group1) => { + const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`; + if (replacer.tester) checks.push(replacer.tester(group1, match, replaced)); + const start = input.indexOf(match); + replacedIndexes.push({ start, end: start + replaced.length }); + + return replaced; + }); + } + + input = input.replaceAll(urlRegex, (match, group1, group2) => { + const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`; + const start = input.indexOf(match); + replacedIndexes.push({ start, end: start + replaced.length }); + return replaced; + }); + + const values = await Promise.all(checks); + + for (const check of values) { + if (!check.allowed) { + input = input.replace(check.after, check.before); + } + } + + if (length > limit) { + for (const replacedIndex of replacedIndexes) { + if (replacedIndex.start < limit && replacedIndex.end >= limit) { + return input.substring(0, replacedIndex.end) + '...'; + } + } + + return input.substring(0, limit) + '...'; + } + + return input; +} + +export async function getDescription(data: { reason: string }): Promise { + return data.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(data.reason) : await getLinks(data.reason, MAX_DESCRIPTION_LENGTH); +} + +export function getAuthor(data: { user_id: number, user: string }): EmbedAuthorOptions { + return { + url: `${config.E621_BASE_URL}/users/${data.user_id}`, + name: data.user + }; +} + +export function getColor(data: { claimant: string | null }): number { + if (!data.claimant) { + return 0xff0000; + } else { + return 0x00ffff; + } +} + +export function getFields(data: { category: string, status: string, claimant: string | null }): APIEmbedField[] { + return [ + { + name: 'Type', + value: data.category, + inline: true + }, + { + name: 'Status', + value: data.status, + inline: true + }, + { + name: 'Claimed By', + value: !data.claimant ? '' : data.claimant, + inline: true + } + ]; +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 8d9a9a6..275420d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './alt-utils'; +export * from './appeal-events'; export * from './array-utils'; export * from './audit-log-utils'; export * from './ban-events'; diff --git a/src/utils/ticket-events.ts b/src/utils/ticket-events.ts index bf28059..fd33e75 100644 --- a/src/utils/ticket-events.ts +++ b/src/utils/ticket-events.ts @@ -1,87 +1,12 @@ -import { APIEmbedField, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedAuthorOptions, EmbedBuilder, SendableChannels } from 'discord.js'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, SendableChannels } from 'discord.js'; import { config } from '../config'; import { Database } from '../shared/Database'; import { Ticket, TicketPhrase, TicketUpdate } from '../types'; import { PostAction, getE621Post, getE621User, spoilerOrBlacklist } from './e621-utils'; -import { blipIDRegex, commentIDRegex, forumTopicIDRegex, poolIDRegex, postIDRegex, recordIDRegex, searchLinkRegex, setIDRegex, takedownIDRegex, ticketIDRegex, userIDRegex, wikiLinkRegex } from './message-matcher-regex'; +import { getAuthor, getColor, getDescription, getFields } from './event-utils'; import { humanizeCapitalization } from './string-utils'; import { shouldAlert } from './ticket-utils'; -// TODO: Condense this and the message event handler regex array. -const linkReplacers = [ - { - regex: blipIDRegex, - replacement: '/blips/{match}', - encodeURI: false - }, - { - regex: commentIDRegex, - replacement: '/comments/{match}', - encodeURI: false - }, - { - regex: forumTopicIDRegex, - replacement: '/forum_topics/{match}', - encodeURI: false - }, - { - regex: poolIDRegex, - replacement: '/pools/{match}', - encodeURI: false - }, - { - regex: postIDRegex, - tester: async (postId: string, before: string, after: string) => { - const post = await getE621Post(postId); - if (!post) return { allowed: true, before, after }; - const allowed = spoilerOrBlacklist(post).action != PostAction.Blacklist; - - return { allowed, before, after }; - }, - replacement: '/posts/{match}', - encodeURI: false - }, - { - regex: recordIDRegex, - replacement: '/user_feedbacks/{match}', - encodeURI: false - }, - { - regex: searchLinkRegex, - replacement: '/posts?tags={match}', - encodeURI: true - }, - { - regex: setIDRegex, - replacement: '/post_sets/{match}', - encodeURI: false - }, - { - regex: takedownIDRegex, - replacement: '/takedowns/{match}', - encodeURI: false - }, - { - regex: ticketIDRegex, - replacement: '/tickets/{match}', - encodeURI: false - }, - { - regex: userIDRegex, - replacement: '/users/{match}', - encodeURI: false - }, - { - regex: wikiLinkRegex, - replacement: '/wiki_pages/{match}', - encodeURI: true - } -]; - -const urlRegex = new RegExp('"((?:[\\S]| )+?)":\\[?((?:https?:\\/\\/[\\w\\d.\\/?=#&%]+)|\\/[\\w\\d.\\/?=#\\[\\]]+)\\]?', 'gi'); - -const MAX_DESCRIPTION_LENGTH = 500; - export async function ticketUpdateHandler(client: Client, update: string) { const data: TicketUpdate = JSON.parse(update); @@ -170,90 +95,6 @@ function getURL(ticket: Ticket): string { return `${config.E621_BASE_URL}/tickets/${ticket.id}`; } -async function getLinks(input: string, limit: number = Number.MAX_SAFE_INTEGER): Promise { - const length = input.length; - - const replacedIndexes: { start: number, end: number }[] = []; - const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = []; - - for (const replacer of linkReplacers) { - input = input.replaceAll(replacer.regex, (match, group1) => { - const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`; - if (replacer.tester) checks.push(replacer.tester(group1, match, replaced)); - const start = input.indexOf(match); - replacedIndexes.push({ start, end: start + replaced.length }); - - return replaced; - }); - } - - input = input.replaceAll(urlRegex, (match, group1, group2) => { - const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`; - const start = input.indexOf(match); - replacedIndexes.push({ start, end: start + replaced.length }); - return replaced; - }); - - const values = await Promise.all(checks); - - for (const check of values) { - if (!check.allowed) { - input = input.replace(check.after, check.before); - } - } - - if (length > limit) { - for (const replacedIndex of replacedIndexes) { - if (replacedIndex.start < limit && replacedIndex.end >= limit) { - return input.substring(0, replacedIndex.end) + '...'; - } - } - - return input.substring(0, limit) + '...'; - } - - return input; -} - -async function getDescription(ticket: Ticket): Promise { - return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH); -} - -function getAuthor(ticket: Ticket): EmbedAuthorOptions { - return { - url: `${config.E621_BASE_URL}/users/${ticket.user_id}`, - name: ticket.user - }; -} - -function getColor(ticket: Ticket): number { - if (!ticket.claimant) { - return 0xff0000; - } else { - return 0x00ffff; - } -} - -function getFields(ticket: Ticket): APIEmbedField[] { - return [ - { - name: 'Type', - value: ticket.category, - inline: true - }, - { - name: 'Status', - value: ticket.status, - inline: true - }, - { - name: 'Claimed By', - value: !ticket.claimant ? '' : ticket.claimant, - inline: true - } - ]; -} - async function createEmbedFromTicket(ticket: Ticket): Promise { return new EmbedBuilder() .setTitle(getTitle(ticket))