From ad48cd516eea1f96020ef390e5c33f4dd2401683 Mon Sep 17 00:00:00 2001 From: "Jill \"oatmealine\" Monoids" Date: Mon, 18 Mar 2024 22:19:14 +0300 Subject: [PATCH] more wip work yippeee --- package.json | 2 + pnpm-lock.yaml | 15 +++ src/commands/auditlog.ts | 123 +++++++++++++++++++++ src/lib/events.ts | 223 +++++++++++++++++++++++++++------------ src/lib/game.ts | 7 +- src/lib/util.ts | 89 ++++++++++++++++ 6 files changed, 386 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index 5471039..fab506e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@discordjs/rest": "^2.2.0", "chalk": "^4.1.2", "d3-array": "^2.12.1", + "diff": "^5.2.0", "discord.js": "^14.14.1", "express": "^4.18.3", "express-handlebars": "^7.1.2", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/d3-array": "^3.2.1", + "@types/diff": "^5.0.9", "@types/express": "^4.17.21", "@types/parse-color": "^1.0.3", "@types/tough-cookie": "^4.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f39032b..a573ba0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: d3-array: specifier: ^2.12.1 version: 2.12.1 + diff: + specifier: ^5.2.0 + version: 5.2.0 discord.js: specifier: ^14.14.1 version: 14.14.1 @@ -58,6 +61,9 @@ devDependencies: '@types/d3-array': specifier: ^3.2.1 version: 3.2.1 + '@types/diff': + specifier: ^5.0.9 + version: 5.0.9 '@types/express': specifier: ^4.17.21 version: 4.17.21 @@ -372,6 +378,10 @@ packages: resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} dev: true + /@types/diff@5.0.9: + resolution: {integrity: sha512-RWVEhh/zGXpAVF/ZChwNnv7r4rvqzJ7lYNSmZSVTxjV0PBLf6Qu7RNg+SUtkpzxmiNkjCx0Xn2tPp7FIkshJwQ==} + dev: true + /@types/express-serve-static-core@4.17.43: resolution: {integrity: sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==} dependencies: @@ -1043,6 +1053,11 @@ packages: engines: {node: '>=8'} dev: false + /diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + dev: false + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} diff --git a/src/commands/auditlog.ts b/src/commands/auditlog.ts index e69de29..38c73de 100644 --- a/src/commands/auditlog.ts +++ b/src/commands/auditlog.ts @@ -0,0 +1,123 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, CommandInteraction, ComponentType, SlashCommandBuilder, StringSelectMenuBuilder } from 'discord.js'; +import { Command } from '../types/index'; +import { AuditLog, db } from '../lib/db'; +import { EventType } from '../lib/events'; +import { chunks } from '../lib/util'; + +export default { + data: new SlashCommandBuilder() + .setName('auditlog') + .setDescription('[ADMIN] Set up an audit logger, or edit an existing one') + .setDefaultMemberPermissions('0'), + + execute: async (interaction: CommandInteraction) => { + if (!interaction.isChatInputCommand()) return; + + const channel = interaction.channelId; + + if (!channel) return; + + const log = await db('auditLogs') + .select('eventTypes') + .where('channel', channel) + .first(); + + let types = log ? log.eventTypes.split(',') : []; + + const options = Object.values(EventType) + .map(event => ({ + label: event, + value: event, + })); + + const chunked = [...chunks(options, 25)]; + + const selectRows = + chunked.map( + (opt, i) => new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .addOptions( + ...opt + ) + .setMinValues(0) + .setMaxValues(opt.length) + .setCustomId(`auditlog-select-events-${i}`) + ) + ); + + const components = [ + ...selectRows, + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('auditlog-select-events-done') + .setLabel('Done') + .setStyle(ButtonStyle.Primary) + .setDisabled(true) + ), + ]; + + const formatContent = () => + `${log ? `Editing audit log in <#${channel}>` : `Creating audit log in <#${channel}>`}\n` + + 'Select the types of events to be reported on' + ((types.length > 0) ? `\n**Current events**: ${types.map(s => '`' + s + '`').join(', ')}` : ''); + + const msg = await interaction.reply({ + ephemeral: true, + content: formatContent(), + components, + }); + + const selectCollector = msg.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60_000 * 5, + }); + + selectCollector.on('collect', async selectInteraction => { + const possibleTypes = selectInteraction.component.options.map(opt => opt.value); + const selectedTypes = selectInteraction.values; + types = types.filter(t => !possibleTypes.includes(t)); + types = [...types, ...selectedTypes]; + + components[components.length - 1].components[0].setDisabled(types.length === 0); + + await selectInteraction.reply({ + content: 'Hit "Done" when finished', + ephemeral: true, + }); + + await msg.edit({ + content: formatContent(), + components, + }); + }); + selectCollector.on('end', async () => { + await msg.edit({ + content: formatContent(), + components: [], + }); + }); + + const buttonInteraction = await msg.awaitMessageComponent({ componentType: ComponentType.Button, time: 60_000 * 5 }); + selectCollector.stop(); + + if (log) { + await db('auditLogs') + .where('channel', channel) + .update({ + eventTypes: types.join(','), + }); + } else { + await db('auditLogs') + .insert({ + guild: interaction.guildId!, + channel: channel, + eventTypes: types.join(','), + }); + } + + await buttonInteraction.reply({ + content: 'Audit log successfully created.', + components: [], + ephemeral: true, + }); + } +} satisfies Command; \ No newline at end of file diff --git a/src/lib/events.ts b/src/lib/events.ts index 596abff..eefb708 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -1,12 +1,10 @@ -import { Sticker, ThreadChannel, Message, User, Invite, GuildMember, GuildScheduledEvent, GuildEmoji, Channel, Client, Events, GuildAuditLogsEntry, AuditLogEvent } from 'discord.js'; +import { Sticker, ThreadChannel, Message, User, Invite, GuildMember, GuildScheduledEvent, GuildEmoji, Client, Events, AuditLogEvent, ChannelType, PartialMessage, EmbedBuilder, MessageType } from 'discord.js'; import { AuditLog, db } from './db'; import * as log from './log'; +import { formatMessageAsEmbed, getUploadLimitForGuild, shortenStr } from './util'; +import { Change, diffWords } from 'diff'; export enum EventType { - ChannelCreate = 'CHANNEL_CREATE', - ChannelRename = 'CHANNEL_RENAME', - ChannelDelete = 'CHANNEL_DELETE', - EmojiCreate = 'EMOJI_CREATE', EmojiRename = 'EMOJI_RENAME', EmojiDelete = 'EMOJI_DELETE', @@ -16,7 +14,6 @@ export enum EventType { EventDelete = 'EVENT_DELETE', InviteCreate = 'INVITE_CREATE', - InviteUpdate = 'INVITE_UPDATE', InviteDelete = 'INVITE_DELETE', MemberBan = 'MEMBER_BAN', @@ -24,7 +21,8 @@ export enum EventType { MemberKick = 'MEMBER_KICK', MemberDisconnect = 'MEMBER_DISCONNECT', MemberNickname = 'MEMBER_NICKNAME', - MemberChangeRoles = 'MEMBER_CHANGE_ROLES', + MemberJoin = 'MEMBER_JOIN', + MemberLeave = 'MEMBER_LEAVE', MessageDelete = 'MESSAGE_DELETE', MessageEdit = 'MESSAGE_EDIT', @@ -39,129 +37,107 @@ export enum EventType { } export type Event = { - type: EventType.ChannelCreate, - causer: User, - channel: Channel, -} | { - type: EventType.ChannelRename, - causer: User, - channel: Channel, - oldName: string, newName: string, -} | { - type: EventType.ChannelDelete, - causer: User, - channel: Channel, -} | { type: EventType.EmojiCreate, - causer: User, + causer: User | null, emoji: GuildEmoji, } | { type: EventType.EmojiRename, - causer: User, + causer: User | null, emoji: GuildEmoji, oldName: string, newName: string, } | { type: EventType.EmojiDelete, - causer: User, + causer: User | null, emoji: GuildEmoji, } | { type: EventType.EventCreate, - causer: User, + causer: User | null, event: GuildScheduledEvent, } | { type: EventType.EventEdit, - causer: User, + causer: User | null, oldEvent: GuildScheduledEvent, event: GuildScheduledEvent, } | { type: EventType.EventDelete, - causer: User, + causer: User | null, event: GuildScheduledEvent, } | { type: EventType.InviteCreate, - causer: User, - invite: Invite, -} | { - type: EventType.InviteUpdate, - causer: User, - oldInvite: Invite, + causer: User | null, invite: Invite, } | { type: EventType.InviteDelete, - causer: User, + causer: User | null, invite: Invite, } | { type: EventType.MemberBan, - causer: User, + causer: User | null, member: GuildMember, reason: string, } | { type: EventType.MemberUnban, - causer: User, + causer: User | null, member: GuildMember, reason: string, } | { type: EventType.MemberKick, - causer: User, + causer: User | null, member: GuildMember, reason: string, } | { type: EventType.MemberDisconnect, - causer: User, + causer: User | null, member: GuildMember, } | { type: EventType.MemberNickname, - causer: User, + causer: User | null, member: GuildMember, oldNickname: string, newNickname: string, -} | { - type: EventType.MemberChangeRoles, - causer: User, - member: GuildMember, - // TODO: huh } | { type: EventType.MessageDelete, - causer: User, - message: Message, + causer: User | null, + message: Message | PartialMessage, } | { type: EventType.MessageEdit, - causer: User, + causer: User | null, oldMessage: Message, message: Message, } | { type: EventType.StickerCreate, - causer: User, + causer: User | null, sticker: Sticker, } | { type: EventType.StickerRename, - causer: User, + causer: User | null, oldName: string, newName: string, } | { type: EventType.StickerDelete, - causer: User, + causer: User | null, sticker: Sticker, } | { type: EventType.ThreadCreate, - causer: User, + causer: User | null, thread: ThreadChannel, } | { type: EventType.ThreadEdit, - causer: User, + causer: User | null, oldThread: ThreadChannel, thread: ThreadChannel, } | { type: EventType.ThreadDelete, - causer: User, + causer: User | null, thread: ThreadChannel, }; -export async function triggerEvent(bot: Client, event: Event) { +export async function triggerEvent(bot: Client, guildId: string, event: Event) { const type = event.type; - log.info(`Got event ${event.type}: ${JSON.stringify(event)}`); + log.info(`got event ${event.type}`); const logs = await db('auditLogs') .select('guild', 'channel') + .where('guild', guildId) // @ts-expect-error this LITERALLY works .whereLike(db.raw('UPPER(eventTypes)'), `%${type.toUpperCase()}%`); @@ -173,31 +149,144 @@ export async function triggerEvent(bot: Client, event: Event) { log.warn(err); } - if (channel && channel.isText()) { - channel.send(`${JSON.stringify(event)}`); + if (channel && (channel.type === ChannelType.GuildText)) { + switch(event.type) { + case EventType.MessageDelete: { + const limit = getUploadLimitForGuild(channel.guild); + const { embed, attach } = formatMessageAsEmbed(event.message, limit); + + const logEmbed = new EmbedBuilder() + .setDescription(`Message sent by ${event.message.author} in <#${event.message.channelId}> deleted by **${event.causer}**:`) + .setTimestamp(); + + channel.send({ + embeds: [ logEmbed, embed ], + files: attach ? [{ + attachment: attach.url, + name: attach.name, + description: attach.url, + }] : [], + }); + + break; + } + case EventType.MessageEdit: { + const logEmbed = new EmbedBuilder() + .setDescription(`Message sent by ${event.message.author} in <#${event.message.channelId}> edited:`) + .setTimestamp(); + + const diff = diffWords(event.oldMessage.content.replace(/`/g, ''), event.message.content.replace(/`/g, '')); + let output = ''; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + diff.forEach((part: Change) => { + if (part.added) output += `\x1B[2;32m\x1B[1;32m${part.value}\x1B[0m`; + if (part.removed) output += `\x1B[2;41m\x1B[1;2m${part.value}\x1B[0m`; + if (!part.added && !part.removed) output += part.value; + }); + + const diffMsg = `\`\`\`ansi\n${shortenStr(output, 4000)}\`\`\``; + + const editEmbed = new EmbedBuilder() + .setAuthor({ name: event.message.author.tag, iconURL: event.message.author.displayAvatarURL() }) + .setDescription(diffMsg); + + channel.send({ + embeds: [ logEmbed, editEmbed ], + }); + + break; + } + case EventType.InviteCreate: { + const logEmbed = new EmbedBuilder() + .setDescription(`Invite ${event.invite.code} created by ${event.causer} in <#${event.invite.channelId}>`) + .setTimestamp(); + + channel.send({ + embeds: [ logEmbed ], + }); + + break; + } + case EventType.InviteDelete: { + const logEmbed = new EmbedBuilder() + .setDescription(`Invite ${event.invite.code} made by ${event.invite.inviter} in <#${event.invite.channelId}> deleted by ${event.causer}`) + .setTimestamp(); + + channel.send({ + embeds: [ logEmbed ], + }); + + break; + } + } } else { log.warn(`Channel ${auditLog.channel} from guild ${auditLog.guild} not found! Deleting audit log`); await db('auditLogs') + .where('guild', auditLog.guild) .where('channel', auditLog.channel) .delete(); } } } +const DELETE_FETCH_TIMEOUT = 2_000; + export function setupListeners(bot: Client) { - /*bot.on(Events.GuildAuditLogEntryCreate, async (auditLog: GuildAuditLogsEntry) => { - const { action, extra: channel, executorId, targetId } = auditLog; + bot.on(Events.MessageDelete, async message => { + if (!message.guild) return; + if (!(message.type === MessageType.Default || message.type === MessageType.Reply || message.type === MessageType.ThreadStarterMessage)) return; - // Check only for deleted messages. - if (action !== AuditLogEvent.MessageDelete) return; + let causer: User | null = message.author; - // Ensure the executor is cached. - const executor = await bot.users.fetch(executorId); + const audit = await message.guild.fetchAuditLogs({ + type: AuditLogEvent.MessageDelete, + limit: 1, + }); - // Ensure the author whose message was deleted is cached. - const target = await bot.users.fetch(targetId); + const entry = audit.entries.first(); - // Log the output. - console.log(`A message by ${target.tag} was deleted by ${executor.tag} in ${channel}.`); - });*/ + if (entry && ((Date.now() - entry.createdTimestamp) < DELETE_FETCH_TIMEOUT || (entry.extra.count > 1)) && entry.targetId === message.author?.id) { + causer = entry.executor; + } + + triggerEvent(bot, message.guild.id, { + type: EventType.MessageDelete, + causer, message, + }); + }); + bot.on(Events.MessageUpdate, async (old, message) => { + if (!message.guild) return; + if (message.content === old.content) return; + if (message.partial || old.partial) return; + + triggerEvent(bot, message.guild.id, { + type: EventType.MessageEdit, + causer: message.author, + oldMessage: old, message: message, + }); + }); + bot.on(Events.InviteCreate, async invite => { + if (!invite.guild || !invite.inviter) return; + + triggerEvent(bot, invite.guild.id, { + type: EventType.InviteCreate, + causer: invite.inviter, invite, + }); + }); + + bot.on(Events.GuildAuditLogEntryCreate, async (entry, guild) => { + if (entry.action === AuditLogEvent.InviteDelete) { + if (!entry.target) return; + + triggerEvent(bot, guild.id, { + type: EventType.InviteDelete, + causer: entry.executor, + // @ts-expect-error shut up + invite: entry.target + }); + } else if (entry.action === AuditLogEvent.EmojiCreate) { + + } + }); } \ No newline at end of file diff --git a/src/lib/game.ts b/src/lib/game.ts index b1051a4..25c3dd2 100644 --- a/src/lib/game.ts +++ b/src/lib/game.ts @@ -1,5 +1,6 @@ import { Interaction, Message, TextBasedChannel, User } from 'discord.js'; import * as log from '../lib/log'; +import { chunks } from './util'; export const RANDOM_WORDS = [ 'tarsorado', 'aboba', 'radiation', 'extreme', 'glogging', 'glogged', 'penis', 'easy', 'glue', 'contaminated water', @@ -140,12 +141,6 @@ export async function getTextResponsePrettyPlease(user: User, prompt: string, fi return randomWord(); } -function* chunks(arr: unknown[], n: number) { - for (let i = 0; i < arr.length; i += n) { - yield arr.slice(i, i + n); - } -} - export async function sendSegments(segments: string[], channel: TextBasedChannel) { const content = []; let contentBuffer = ''; diff --git a/src/lib/util.ts b/src/lib/util.ts index d822174..a6378ce 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -2,6 +2,7 @@ import * as fsp from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { randomBytes } from 'crypto'; +import { Attachment, EmbedBuilder, Guild, GuildPremiumTier, Message, PartialMessage } from 'discord.js'; export async function exists(file: string) { try { @@ -41,3 +42,91 @@ export class Right { constructor(private readonly value: R) {} public getValue() { return this.value; } } + +export function* chunks(arr: T[], n: number) { + for (let i = 0; i < arr.length; i += n) { + yield arr.slice(i, i + n); + } +} + +export function shortenStr(str: string, chars: number) { + if (str.length > chars) + return str.slice(0, chars - 1) + '…'; + + return str; +} + +export const MAX_MESSAGE_SIZE = 2000; + +export const defaultUploadLimit = 25 * 1024 * 1024; + +export const getUploadLimitForGuild = (guild: Guild) => { + switch (guild.premiumTier) { + case GuildPremiumTier.Tier3: return 100 * 1024 * 1024; + case GuildPremiumTier.Tier2: return 50 * 1024 * 1024; + default: return defaultUploadLimit; + } +}; + +export function formatMessageAsEmbed(message: Message | PartialMessage, uploadLimit: number): { embed: EmbedBuilder, attach: Attachment | null } { + const embed = new EmbedBuilder() + .setTimestamp(message.createdTimestamp); + + if (message.author) { + embed.setAuthor({ + name: message.author.tag, + iconURL: message.author.displayAvatarURL(), + }); + } + + let attach = null; + + message.embeds.forEach(em => { + if (em.image) { + embed.setImage(em.image.url); + } else if (em.thumbnail) { + embed.setImage(em.thumbnail.url); + } + if (em.provider) { + embed.addFields({ + name: shortenStr(`${em.provider.name}:`, 256), + value: shortenStr(`${em.author}: **${em.title}**\n${em.description || ''}`, 2048), + }); + } else if (!em.image) { + embed.addFields({ + name: 'Embed:', + value: shortenStr(em.description || em.fields[0]?.value || em.title || 'Unknown content', 2048), + }); + } + }); + + let msgEnd = null; + + const first = message.attachments.first(); + + if ( + message.attachments.size === 1 && first && first.contentType + && !first.spoiler + && (first.contentType?.startsWith('image/') + || first.contentType?.startsWith('video/')) + && (first.size <= uploadLimit) + ) { + attach = first; + } else { + for (const attach of message.attachments.values()) { + msgEnd = (msgEnd || '\n') + `\n[${attach.name}](${attach.url})`; + } + } + + if (message.content) { + if (!msgEnd) { + embed.setDescription(shortenStr(message.content, 4096)); + } else { + embed.setDescription(shortenStr(message.content, 4096 - msgEnd.length) + msgEnd); + } + } else if (msgEnd) { + embed.setDescription('_Content unknown_' + msgEnd); + } + + return { embed, attach }; +} \ No newline at end of file