Merge branch 'master' into pr/5

This commit is contained in:
Tarrgon
2026-06-02 12:45:47 -04:00
10 changed files with 362 additions and 165 deletions
+104
View File
@@ -0,0 +1,104 @@
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.appeals_channel_id) return;
const channel = await client.channels.fetch(guildSettings.appeals_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 appeal category';
}
}
function getURL(appeal: Appeal): string {
return `${config.E621_BASE_URL}/appeals/${appeal.id}`;
}
async function createEmbedFromAppeal(appeal: Appeal): Promise<EmbedBuilder> {
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<ActionRowBuilder<ButtonBuilder>> {
const row = new ActionRowBuilder<ButtonBuilder>();
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}`);
}
row.addComponents(primaryButton);
return row;
}
+163
View File
@@ -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<string> {
const length = input.length;
const replacedIndexes: { start: number, end: number }[] = [];
const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = [];
for (const replacer of linkReplacers) {
input = input.replaceAll(replacer.regex, (match, group1) => {
const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`;
if (replacer.tester) checks.push(replacer.tester(group1, match, replaced));
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
}
input = input.replaceAll(urlRegex, (match, group1, group2) => {
const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`;
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
const values = await Promise.all(checks);
for (const check of values) {
if (!check.allowed) {
input = input.replace(check.after, check.before);
}
}
if (length > limit) {
for (const replacedIndex of replacedIndexes) {
if (replacedIndex.start < limit && replacedIndex.end >= limit) {
return input.substring(0, replacedIndex.end) + '...';
}
}
return input.substring(0, limit) + '...';
}
return input;
}
export async function getDescription(data: { reason: string }): Promise<string> {
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 ? '<Unclaimed>' : data.claimant,
inline: true
}
];
}
+1
View File
@@ -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';
+2 -161
View File
@@ -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<string> {
const length = input.length;
const replacedIndexes: { start: number, end: number }[] = [];
const checks: Promise<{ allowed: boolean, before: string, after: string }>[] = [];
for (const replacer of linkReplacers) {
input = input.replaceAll(replacer.regex, (match, group1) => {
const replaced = `[${match}](${config.E621_BASE_URL}${(replacer.replacement).replace('{match}', replacer.encodeURI ? encodeURIComponent(group1) : group1)})`;
if (replacer.tester) checks.push(replacer.tester(group1, match, replaced));
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
}
input = input.replaceAll(urlRegex, (match, group1, group2) => {
const replaced = group2.startsWith('/') ? `[${group1}](${config.E621_BASE_URL}${group2})` : `[${group1}](${group2})`;
const start = input.indexOf(match);
replacedIndexes.push({ start, end: start + replaced.length });
return replaced;
});
const values = await Promise.all(checks);
for (const check of values) {
if (!check.allowed) {
input = input.replace(check.after, check.before);
}
}
if (length > limit) {
for (const replacedIndex of replacedIndexes) {
if (replacedIndex.start < limit && replacedIndex.end >= limit) {
return input.substring(0, replacedIndex.end) + '...';
}
}
return input.substring(0, limit) + '...';
}
return input;
}
async function getDescription(ticket: Ticket): Promise<string> {
return ticket.reason.length <= MAX_DESCRIPTION_LENGTH ? await getLinks(ticket.reason) : await getLinks(ticket.reason, MAX_DESCRIPTION_LENGTH);
}
function getAuthor(ticket: Ticket): EmbedAuthorOptions {
return {
url: `${config.E621_BASE_URL}/users/${ticket.user_id}`,
name: ticket.user
};
}
function getColor(ticket: Ticket): number {
if (!ticket.claimant) {
return 0xff0000;
} else {
return 0x00ffff;
}
}
function getFields(ticket: Ticket): APIEmbedField[] {
return [
{
name: 'Type',
value: ticket.category,
inline: true
},
{
name: 'Status',
value: ticket.status,
inline: true
},
{
name: 'Claimed By',
value: !ticket.claimant ? '<Unclaimed>' : ticket.claimant,
inline: true
}
];
}
async function createEmbedFromTicket(ticket: Ticket): Promise<EmbedBuilder> {
return new EmbedBuilder()
.setTitle(getTitle(ticket))