diff --git a/.eslintrc.json b/.eslintrc.json index ae049ee..ebf38ae 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -109,7 +109,7 @@ "id-blacklist": "off", "id-length": "off", "id-match": "off", - "indent": ["error", 2], + "indent": ["error", 2, { "SwitchCase": 1 }], "init-declarations": "off", "line-comment-position": "off", "lines-between-class-members": [ diff --git a/package.json b/package.json index 0e29e84..fcda7ba 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,10 @@ "axios": "^0.26.0", "bull": "^4.6.2", "date-fns": "^2.28.0", + "diff": "^5.0.0", "discord.js": "^13.6.0", "fs-extra": "^10.0.1", + "mime-types": "^2.1.35", "mongodb": "^4.4.0", "node-cron": "^3.0.0", "puppeteer-core": "^13.4.0", @@ -48,7 +50,9 @@ "@sapphire/ts-config": "^3.3.1", "@types/chai": "^4.3.0", "@types/chai-as-promised": "^7.1.5", + "@types/diff": "^5.0.2", "@types/fs-extra": "^9.0.13", + "@types/mime-types": "^2.1.1", "@types/mocha": "^9.1.0", "@types/node": "^16.11.26", "@types/node-cron": "^3.0.1", diff --git a/src/commands/config/Logs.ts b/src/commands/config/Logs.ts new file mode 100644 index 0000000..5b2f561 --- /dev/null +++ b/src/commands/config/Logs.ts @@ -0,0 +1,74 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, type CommandOptions } from '@sapphire/framework'; +import { type CommandInteraction } from 'discord.js'; +import { env } from '../../lib'; + +@ApplyOptions({ + chatInputApplicationOptions: { + defaultPermission: false, + options: [ + { + description: 'Especifica el canal donde se enviarán las notificaciones.', + name: 'canal', + options: [ + { + channelTypes: [ + 'GUILD_NEWS', 'GUILD_TEXT' + ], + description: 'Mención del canal', + name: 'canal', + required: true, + type: 'CHANNEL' + } + ], + type: 'SUB_COMMAND' + }, + ], + permissions: [ + { + id: env.STAFF_ROLE, + permission: true, + type: 'ROLE' + } + ] + }, + description: 'Configura el sistema de logging del servidor.', + name: 'logs' +}) +export class LogsCommand extends Command { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public messageRun(): void { + + } + + public async chatInputApplicationRun(interaction: CommandInteraction<'present'>): Promise { + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'canal': { + await this.setLogsChannel(interaction); + break; + } + + default: { + await interaction.editReply('No reconozco el comando que has utilizado.'); + } + } + } + + private async setLogsChannel(interaction: CommandInteraction<'present'>): Promise { + const channel = interaction.options.getChannel('canal', true); + const guild = this.container.stores.get('models').get('guild'); + + await guild.setChannel(interaction.guildId, 'logs', channel.id); + + await interaction.editReply({ + embeds: [{ + color: 0x1b5e20, + description: `Canal de logs cambiado a: <#${ channel.id }>` + }] + }); + } +} diff --git a/src/lib/util/discord/index.ts b/src/lib/util/discord/index.ts index e88a9a4..e6ba641 100644 --- a/src/lib/util/discord/index.ts +++ b/src/lib/util/discord/index.ts @@ -1,4 +1,5 @@ -export * from './get-interaction-channel'; -export * from './get-interaction-guild'; -export * from './get-interaction-member'; -export * from './get-interaction-member-roles'; +export * from './get-interaction-channel'; +export * from './get-interaction-guild'; +export * from './get-interaction-member'; +export * from './get-interaction-member-roles'; +export * from './message-utils'; diff --git a/src/lib/util/discord/message-utils.ts b/src/lib/util/discord/message-utils.ts new file mode 100644 index 0000000..35e9169 --- /dev/null +++ b/src/lib/util/discord/message-utils.ts @@ -0,0 +1,36 @@ +import { diffChars } from 'diff'; +import { MessageAttachment } from 'discord.js'; +import mime from 'mime-types'; + +/** + * Escapes markdown formatting from a string. + * @param text The text to escape. + */ +export const escapeMarkdown = (text: string): string => { + return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&'); +}; + +/** + * Get the difference between two messages, as a markdown formatted string. + * @param oldMessage The old message content. + * @param newMessage The new message content. + */ +export const messageDiff = (oldMessage: string, newMessage: string): string => { + const changes = diffChars(oldMessage, newMessage); + + const diffText = changes.map((change) => { + if (change.added) { + return `**${escapeMarkdown(change.value)}**`; + } else if (change.removed) { + return `~~${escapeMarkdown(change.value)}~~`; + } + return escapeMarkdown(change.value); + }).join(''); + + return diffText; +}; + +export const reuploadAttachment = (attachment: MessageAttachment): MessageAttachment => { + const newAttachment = new MessageAttachment(attachment.url, attachment.name ?? `${attachment.id}.${mime.extension(attachment.contentType ?? 'application/octet-stream')}`); + return newAttachment; +}; diff --git a/src/listeners/MessageDelete.ts b/src/listeners/MessageDelete.ts new file mode 100644 index 0000000..3c679e8 --- /dev/null +++ b/src/listeners/MessageDelete.ts @@ -0,0 +1,76 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; +import { MessageEmbed, type BaseGuildTextChannel, type Message } from 'discord.js'; +import { stringSimilarity } from 'string-similarity-js'; +import { sleep } from '../lib'; + +@ApplyOptions({ + event: Events.MessageDelete, +}) +export class MessageDeleteListener extends Listener { + public async run(message: Message): Promise { + const { client } = this.container; + + if (!message.guild) return; + if (message.author.bot) return; + + const guild = this.container.stores.get('models').get('guild'), + logsChannelId = await guild.getChannel(message.guild.id, 'logs'), + logsChannel = message.guild.channels.cache.get(logsChannelId!) as BaseGuildTextChannel; + + // TODO: log to database + + if (!logsChannel) { + client.logger.info(`No logs channel configured for ${message.guild.name}`); + return; + } + + if (message.content.length) { + /** + * Try to find a similar message sent after the deleted one, and from a webhook with the same name as the deleted message's author + * If found, compare the content + * If the content is similar, we can be 99% sure that the message was deleted by NQN, so we don't log it + */ + + await sleep(2000); + + const webhookMessages = message.channel.messages.cache.filter((m) => m.webhookId !== null && m.createdTimestamp > message.createdTimestamp), + webhookMessagesSameUser = webhookMessages.filter((m) => m.author.username === message.member?.displayName); + + const contentMatch = webhookMessagesSameUser.find((m) => { + const messageContentWithoutEmojiIDs = m.content.replace(/<(a)?(:[^:]+:)([0-9]+)>/g, '$2'), + similarityScore = stringSimilarity(messageContentWithoutEmojiIDs, message.content); + + return similarityScore > 0.99; + }); + + if (contentMatch) return; + } + + const embed = new MessageEmbed() + .setColor('#ff0000') + .setAuthor({ + name: message.author.tag, + iconURL: message.author.displayAvatarURL() + }) + .setDescription(`Mensaje de <@${message.author.id}> eliminado en <#${message.channel.id}>`); + + if (message.content.length) { + embed.addField('Mensaje', message.content.length > 1024 ? `${message.content.slice(0, 1021)}...` : message.content, false); + } + + if (message.attachments.size) { + embed.addField('Archivos', message.attachments.map(file => file.url).join('\n'), false); + } + + const previousMessages = await message.channel.messages.fetch({ limit: 10, before: message.id }), + previousMessagesNotAuthor = previousMessages.filter(msg => msg.author.id !== message.author.id), + previousMessage = previousMessagesNotAuthor.size ? previousMessagesNotAuthor.first() : previousMessages.first(); + + embed.addField('Mensaje anterior', `[Ver mensaje](${previousMessage!.url})`); + + await logsChannel.send({ + embeds: [embed] + }); + } +} diff --git a/src/listeners/MessageUpdate.ts b/src/listeners/MessageUpdate.ts new file mode 100644 index 0000000..0aaf4d1 --- /dev/null +++ b/src/listeners/MessageUpdate.ts @@ -0,0 +1,55 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; +import { type BaseGuildTextChannel, type Message, MessageEmbed } from 'discord.js'; +import { messageDiff } from '../lib/util'; + +@ApplyOptions({ + event: Events.MessageUpdate, +}) +export class MessageUpdateListener extends Listener { + public async run(oldMessage: Message, newMessage: Message): Promise { + const { client } = this.container; + + if (!oldMessage.guild || !newMessage.guild) return; + if (newMessage.author.bot) return; + + const guild = this.container.stores.get('models').get('guild'), + logsChannelId = await guild.getChannel(newMessage.guild.id, 'logs'), + logsChannel = newMessage.guild.channels.cache.get(logsChannelId!) as BaseGuildTextChannel; + + // TODO: log to database + + if (!logsChannel) { + client.logger.info(`No logs channel configured for ${newMessage.guild.name}`); + return; + } + + const diff = messageDiff(oldMessage.content, newMessage.content); + + const embed = new MessageEmbed() + .setTitle(`Mensaje editado en **#${(newMessage.channel as BaseGuildTextChannel).name}**`) + .setColor('#00bcd4') + .setAuthor({ + name: newMessage.author.tag, + iconURL: newMessage.author.displayAvatarURL() + }) + .setDescription(`[Ver mensaje](${newMessage.url})`) + .setTimestamp(); + + if (oldMessage.content.length) { + embed.addField('Antes', oldMessage.content.length > 1024 ? `${oldMessage.content.slice(0, 1021)}...` : oldMessage.content, false); + } + + if (newMessage.content.length) { + embed.addField('Después', newMessage.content.length > 1024 ? `${newMessage.content.slice(0, 1021)}...` : newMessage.content, false); + } + + if (oldMessage.content.length && newMessage.content.length) { + embed.addField('Cambios', diff); + } + + await logsChannel.send({ + embeds: [embed] + }); + } +} diff --git a/src/models/Guild.ts b/src/models/Guild.ts index 5839e20..a30ae9b 100644 --- a/src/models/Guild.ts +++ b/src/models/Guild.ts @@ -2,15 +2,16 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Model } from '../lib'; import type { PieceOptions } from '@sapphire/pieces'; -type SettingsChannels = 'starboard' | 'roles'; +type SettingsChannels = 'logs' | 'roles' | 'starboard'; export interface IGuild { id: string; settings?: { prefix?: string; channels?: { - starboard?: string; + logs?: string; roles?: string; + starboard?: string; }; }; stats?: { diff --git a/yarn.lock b/yarn.lock index 8b83fe8..122fbd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -453,6 +453,13 @@ __metadata: languageName: node linkType: hard +"@types/diff@npm:^5.0.2": + version: 5.0.2 + resolution: "@types/diff@npm:5.0.2" + checksum: 8fbc419b5aca33f494026bf5f70e026f76367689677ef114f9c078ac738d7dbe96e6dda3fd8290e4a7c35281e2b60b034e3d7e3c968b850cf06a21279e7ddcbe + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^4.17.18": version: 4.17.28 resolution: "@types/express-serve-static-core@npm:4.17.28" @@ -499,6 +506,13 @@ __metadata: languageName: node linkType: hard +"@types/mime-types@npm:^2.1.1": + version: 2.1.1 + resolution: "@types/mime-types@npm:2.1.1" + checksum: 106b5d556add46446a579ad25ff15d6b421851790d887edcad558c90c1e64b1defc72bfbaf4b08f208916e21d9cc45cdb951d77be51268b18221544cfe054a3c + languageName: node + linkType: hard + "@types/mime@npm:^1": version: 1.3.2 resolution: "@types/mime@npm:1.3.2" @@ -1304,7 +1318,9 @@ __metadata: "@sapphire/ts-config": ^3.3.1 "@types/chai": ^4.3.0 "@types/chai-as-promised": ^7.1.5 + "@types/diff": ^5.0.2 "@types/fs-extra": ^9.0.13 + "@types/mime-types": ^2.1.1 "@types/mocha": ^9.1.0 "@types/node": ^16.11.26 "@types/node-cron": ^3.0.1 @@ -1316,12 +1332,14 @@ __metadata: chai: ^4.3.6 chai-as-promised: ^7.1.1 date-fns: ^2.28.0 + diff: ^5.0.0 discord.js: ^13.6.0 erlpack: "github:discord/erlpack" eslint: ~8.10.0 fs-extra: ^10.0.1 husky: ^7.0.4 lint-staged: ^12.3.4 + mime-types: ^2.1.35 mocha: ^9.2.1 mongodb: ^4.4.0 node-cron: ^3.0.0 @@ -1482,7 +1500,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:5.0.0": +"diff@npm:5.0.0, diff@npm:^5.0.0": version: 5.0.0 resolution: "diff@npm:5.0.0" checksum: f19fe29284b633afdb2725c2a8bb7d25761ea54d321d8e67987ac851c5294be4afeab532bd84531e02583a3fe7f4014aa314a3eda84f5590e7a9e6b371ef3b46 @@ -2750,6 +2768,13 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + languageName: node + linkType: hard + "mime-types@npm:^2.1.12": version: 2.1.34 resolution: "mime-types@npm:2.1.34" @@ -2759,6 +2784,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^2.1.35": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: 1.52.0 + checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0"