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
+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));
}