diff --git a/src/structure/components/observers/GuildLogging.js b/src/structure/components/observers/GuildLogging.js new file mode 100644 index 0000000..4ebb84a --- /dev/null +++ b/src/structure/components/observers/GuildLogging.js @@ -0,0 +1,657 @@ +/* eslint-disable no-labels */ +const { MessageAttachment, WebhookClient } = require('discord.js'); + +const { Observer } = require('../../interfaces/'); +const Util = require('../../../Util'); +const { Constants: { EmbedLimits } } = require('../../../constants'); +const { stripIndents } = require('common-tags'); +const moment = require('moment'); +const { GuildWrapper } = require('../../client/wrappers'); + +const CONSTANTS = { + COLORS: { + RED: 16711680, // message delete + YELLOW: 15120384, // message edit + LIGHT_BLUE: 11337726, // message pin + BLUE: 479397 + }, + IMAGES: { + PREMIUM_LIMIT: 2, + UPLOAD_LIMIT: { + '0': 8, + '1': 8, + '2': 50, + '3': 100 + }, + MB_DIVIDER: 1024 * 1024 + }, + WEEK: 7 * 24 * 60 * 60 +}; + +class GuildLogger extends Observer { + + constructor(client) { + + super(client, { + name: 'guildLogger', + priority: 3, + disabled: false + }); + + this.hooks = [ + ['messageDelete', this.messageDelete.bind(this)], + ['messageDeleteBulk', this.messageDeleteBulk.bind(this)], + ['messageUpdate', this.messageEdit.bind(this)], + ['voiceStateUpdate', this.voiceState.bind(this)], + //['guildBanAdd', this.ban.bind(this)], + //['guildBanRemove', this.unban.bind(this)], + ['guildMemberAdd', this.memberJoin.bind(this)], + ['guildMemberRemove', this.memberLeave.bind(this)], + ['guildMemberUpdate', this.memberUpdate.bind(this)] + ]; + + this.attachmentWebhook = new WebhookClient( + { + id: this.client._options.discord.moderation.attachments.webhook.id, + token: this.client._options.discord.moderation.attachments.webhook.token + } + ); + + } + + //TODO: Figure this thing out, this should be called from messageDelete and rawMessageDelete if any attachments are present + //data should be an object containing the necessary information to query for the attachment from the db, and the relevant information to log it + //Will figure this out once I get to that point + // async logAttachment(data) { + + // } + + async messageDelete(message) { + + if (!this.client._built + || message.webhookID + || message.author.bot + || !message.guildWrapper + || !message.guild.available) return; + + const wrapper = message.guildWrapper; + const settings = await wrapper.settings(); + + if (!message.member) try { + message.member = await message.guild.members.fetch(message.author.id); + } catch (_) { + // Member not found, do nothing + } + + const { messages: messageLog } = settings; + if (!messageLog.channel) return undefined; + + const { bypass, ignore } = messageLog; + const logChannel = await wrapper.resolveChannel(messageLog.channel); + if (!logChannel) return undefined; + + const perms = logChannel.permissionsFor(message.guild.me); + if (!perms.has('SEND_MESSAGES') || !perms.has('VIEW_CHANNEL') || !perms.has('EMBED_LINKS')) return undefined; + + if (bypass.length && message.member.roles.cache.size) { + const roles = message.member.roles.cache.map((r) => r.id); + for (const role of bypass) { + if (roles.includes(role)) return undefined; + } + } + + if (ignore && ignore.includes(message.channel.id)) return undefined; + + const hook = await wrapper.getWebhook('messages'); + if (!hook) { + this.client.logger.debug(`Missing messageLog hook in ${message.guild.name} (${message.guild.id})`); + return; + } + + const { reference, channel, author, content, id } = message; + + const embed = { + title: wrapper.format('MSGLOG_DELETE_TITLE', { channel: channel.name, author: Util.escapeMarkdown(author.tag) }), + description: Util.escapeMarkdown(content)?.replace(/\\n/gu, ' ') || wrapper.format('MSGLOG_NOCONTENT'), + color: CONSTANTS.COLORS.RED, + footer: { + text: wrapper.format('MSGLOG_DELETE_FOOTER', { msgID: id, userID: author.id }) + }, + timestamp: message.createdAt, + fields: [] + }; + + if (reference && reference.channelID === channel.id) { + const referenced = await channel.messages.fetch(reference.messageID); + embed.fields.push({ + name: wrapper.format('MSGLOG_REPLY', { tag: referenced.author.tag, id: referenced.author.id }), + value: wrapper.format('MSGLOG_REPLY_VALUE', { + content: referenced.content.length > 900 ? referenced.content.substring(0, 900) + '...' : referenced.content, + link: referenced.url + }) + }); + } + + if (message.filtered) { + embed.fields.push({ + name: wrapper.format('MSGLOG_FILTERED'), + value: stripIndents` + ${wrapper.format(message.filtered.preset ? 'MSGLOG_FILTERED_PRESET' : 'MSGLOG_FILTERED_VALUE', + { ...message.filtered })} + ${message.filtered.sanctioned ? wrapper.format('MSGLOG_FILTERED_SANCTIONED') : ''} + `// + () + }); + } + + const uploadedFiles = []; + if (message.attachments.size > 0 && messageLog.attachments && logChannel.nsfw) { + const imageExtensions = ['.png', '.webp', '.jpg', '.jpeg', '.gif']; + const data = await this.client.storageManager.mongodb.messages.findOne({ + id: message.id + }); + + if (data) { + const attachments = await this.client.storageManager.mongodb.attachments.find({ + _id: { $in: data.attachments.filter((a) => a.index).map((a) => a.index) } + }); + const sortedAttachments = data.attachments.sort((a, b) => b.size - a.size); + + const files = []; + for (const attachment of sortedAttachments) { + + const attachmentData = attachments.find((a) => a.attachmentId === attachment.id); + + if (attachmentData) { + // Mongo does some weird serialisation of buffer data, so to access the actual buffer, you have to go 1 level deeper + attachmentData.buffer = attachmentData.buffer.buffer; //Buffer.from(attachmentData.buffer, 'base64'); + const messageAttachment = new MessageAttachment(attachmentData.buffer, attachment.name, { size: attachment.size }); + + if (messageAttachment.size < + CONSTANTS.IMAGES.UPLOAD_LIMIT[message.guild.premiumTier] * CONSTANTS.IMAGES.MB_DIVIDER) { + + if (imageExtensions.includes(attachment.extension) && uploadedFiles.length === 0) { + + uploadedFiles.push(messageAttachment); + embed.image = { + url: `attachment://${attachment.name}` + }; + + } else { + + if (messageAttachment.size > 8 * CONSTANTS.IMAGES.MB_DIVIDER) { + + const combined = uploadedFiles.length > 0 ? + uploadedFiles.map((f) => f.size).reduce((p, v) => p + v) : 0; + if ((combined + messageAttachment.size) / CONSTANTS.IMAGES.MB_DIVIDER < + CONSTANTS.IMAGES.UPLOAD_LIMIT[message.guild.premiumTier]) { + uploadedFiles.push(messageAttachment); + } + + } else { + files.push(messageAttachment); + } + } + } + } + } + + let currentFiles = []; + const uploaded = []; + + const upload = async (files) => { + const attachmentMessage = await this.attachmentWebhook.send(null, files) + .catch((error) => this.client.logger.error(error)); + attachmentMessage.attachments.map( + (a) => uploaded.push(`[${a.filename} (${(a.size / CONSTANTS.IMAGES.MB_DIVIDER).toFixed(2)}mb)](${a.url})`) + ); + }; + + for (const file of files) { + const currentMb = currentFiles.length > 0 ? currentFiles.map((f) => f.size).reduce((p, v) => p + v) : 0; + if (currentMb + file.size > 8 * CONSTANTS.IMAGES.MB_DIVIDER) { + await upload(currentFiles); + currentFiles = [file]; + } else { + currentFiles.push(file); + } + } + + if (currentFiles.length > 0) { + await upload(currentFiles); + } + + if (uploaded.length > 0) { + embed.description += `\n\n**${uploadedFiles.length > 0 ? + 'Additional ' : ''}Attachment${uploaded.length > 1 ? 's' : ''}:** ${uploaded.join(', ')}`; + } + } + + } + + await hook.send({ embeds: [embed], files: uploadedFiles }).catch((err) => { + this.client.logger.error('Error in message delete:\n' + err.stack); + }); + + } + + async messageDeleteBulk(messages) { + + //Status: Should be complete, though additional testing might be necessary + + const { guild, channel } = messages.first(); + if (!guild) return; + + const wrapper = new GuildWrapper(this.client, guild); + const settings = await wrapper.settings(); + const chatlogs = settings.messages; + if (!chatlogs.channel) return; + const logChannel = await wrapper.resolveChannel(chatlogs.channel); + if (!logChannel) return undefined; + + const { ignore, bypass } = chatlogs; + if (ignore.includes(channel.id)) return; + + const hook = await wrapper.getWebhook('messages'); + if (!hook) return; + + const cutOff = EmbedLimits.fieldValue;// - 3; + const fields = []; + + messages = messages.filter((msg) => !msg.author.bot); + + //Compile messages into fields that can be compiled into embeds + messageLoop: //Oldest messages first + for (const message of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp).values()) { + + let { member, content } = message; + const { author, id } = message; + content = Util.escapeMarkdown(content); + + if (author.bot) continue; + if (!member || member.partial) member = await guild.members.fetch(message.author.id).catch(() => { + return false; + }); + if (member && member.roles.cache.size) { + const roles = member.roles.cache.map((role) => role.id); + for (const role of roles) { + if (bypass.includes(role)) continue messageLoop; + } + } + + const uploaded = []; + if (message.attachments.size > 0 && chatlogs.attachments && logChannel.nsfw) { + + const data = await this.client.storageManager.mongodb.messages.findOne({ + id: message.id + }); + + if (data) { + + const attachments = await this.client.storageManager.mongodb.attachments.find({ + _id: { $in: data.attachments.filter((a) => a.index).map((a) => a.index) } + }); + const sortedAttachments = data.attachments.sort((a, b) => b.size - a.size); + + const files = []; + for (const attachment of sortedAttachments) { + + const attachmentData = attachments.find((a) => a.attachmentId === attachment.id); + + if (!attachmentData) continue; + // TODO: Implement a webhook on the logging server to upload the images instead + if (attachment.size > 8 * CONSTANTS.IMAGES.MB_DIVIDER) continue; //(attachment.size > CONSTANTS.IMAGES.UPLOAD_LIMIT[message.guild.premiumTier] * CONSTANTS.IMAGES.MB_DIVIDER) + + attachmentData.buffer = attachmentData.buffer.buffer; + const messageAttachment = new MessageAttachment(attachmentData.buffer, attachment.name, { size: attachment.size }); + files.push(messageAttachment); + + } + + let currentFiles = []; + + const upload = async (files) => { + const attachmentMessage = await this.attachmentWebhook.send(null, files) + .catch((error) => this.client.logger.error(error)); + attachmentMessage.attachments.map( + (a) => uploaded.push(`[${a.filename} (${(a.size / CONSTANTS.IMAGES.MB_DIVIDER).toFixed(2)}mb)](${a.url})`) + ); + }; + + for (const file of files) { + const currentMb = currentFiles.length > 0 ? currentFiles.map((f) => f.size).reduce((p, v) => p + v) : 0; + if (currentMb + file.size > 8 * CONSTANTS.IMAGES.MB_DIVIDER) { + await upload(currentFiles); + currentFiles = [file]; + } else { + currentFiles.push(file); + } + } + + if (currentFiles.length > 0) { + await upload(currentFiles); + } + + } + + } + + //const attStr = message.attachments.map((att) => `${att.name} (${att.id})`).join(', '); + const value = stripIndents`${content ? content.substring(0, cutOff) : wrapper.format('BULK_DELETE_NO_CONTENT')}`; + //${message.attachments.size > 0 ? `__(Attachments: ${attStr})__` : '' } + fields.push({ + name: `${Util.escapeMarkdown(author.tag)} @ ${moment.utc(message.createdAt).format('D/M/YYYY H:m:s')} UTC (MSG ID: ${id})`, + value + }); + + if (content && content.length > cutOff) fields.push({ + name: '\u200b', + value: content.substring(cutOff) + }); + + if (uploaded.length) fields.push({ + name: wrapper.format('BULK_DELETE_ATTACHMENTS'), + value: uploaded.join('\n'), + _attachment: true + }); + + } + + //Compile embeds + const embedCutoff = EmbedLimits.embed - 100; + const embeds = []; + const embed = { // title gets set later + fields: [], + color: CONSTANTS.COLORS.RED + }; + let length = 0; // Embed total length + fields.reduce((_embed, field) => { + //Make sure the total character count doesn't exceed the limit + const fieldLength = field.name.length + field.value.length; + if (_embed.fields.length < EmbedLimits.fieldObjects && length + fieldLength < embedCutoff) { + _embed.fields.push(field); + } else { + //"clone" the embed into the array + embeds.push({ ..._embed }); + _embed.fields = [field]; + length = 0; + } + length += fieldLength; + return _embed; + }, embed); + embeds.push(embed); + + //Post messages + let showed = 0; + for (let i = 0; i < embeds.length; i++) { + const embed = embeds[i]; + //Amount of messages showing + const x = embed.fields.filter((field) => field.name !== '\u200b' && !field._attachment).length; + embed.title = wrapper.format('BULK_DELETE_TITLE', { embedNr: i + 1, channel: channel.name }); + embed.footer = { + text: wrapper.format('BULK_DELETE_FOOTER', + { rangeStart: showed + 1, rangeEnd: showed += x, total: messages.size }) + }; + const result = await hook.send({ embeds: [embed] }).catch((err) => { + //Unknown webhook -> webhook was deleted, remove it from db so it doesn't make any more unnecessary calls + if (err.code === 10015) { + wrapper.updateWebhook('messages'); + } else this.client.logger.error(err.stack); + return { error: true }; + }); + if (result.error) break; + } + + } + + async messageEdit(oldMessage, newMessage) { + + //Status: Uses webhook, complete + + // embeds loading in (ex. when a link is posted) would cause a message edit event + if (oldMessage.embeds.length !== newMessage.embeds.length && oldMessage.content === newMessage.content) return; + if (oldMessage.author.bot) return; + + const { guild } = oldMessage; + if (!guild) return; + + const wrapper = new GuildWrapper(this.client, guild); + if (!oldMessage.member) oldMessage.member = await guild.members.fetch(oldMessage.author); + const { member, channel, author, reference } = oldMessage; + + const settings = await wrapper.settings(); + const chatlogs = settings.messages; + if (!chatlogs.channel) return; + + const { ignore, bypass } = chatlogs; + const hook = await wrapper.getWebhook('messages'); + if (!hook) return; + + if (bypass && member.roles.cache.size) { + const roles = member.roles.cache.map((r) => r.id); + for (const role of bypass) { + if (roles.includes(role)) return; + } + } + + if (ignore && ignore.includes(channel.id)) return; + + if (oldMessage.content === newMessage.content && oldMessage.pinned !== newMessage.pinned) { + + const embed = { + title: wrapper.format('MSGLOG_PINNED_TITLE', + { + author: Util.escapeMarkdown(author.tag), + channel: channel.name, + pinned: wrapper.format('PIN_TOGGLE', { toggle: newMessage.pinned }, true) + }), + description: wrapper.format('MSGLOG_EDIT_JUMP', { guild: guild.id, channel: channel.id, message: oldMessage.id }), + color: CONSTANTS.COLORS.LIGHT_BLUE + }; + + if (oldMessage.content.length) embed.description += '\n' + oldMessage.content.substring(0, 1900); + if (oldMessage.attachments.size) { + const img = oldMessage.attachments.first(); + if (img.height && img.width) embed.image = { url: img.url }; + } + + await hook.send({ embeds: [embed] }).catch(this.client.logger.error); + + } else { + + const embed = { + // author: { + // name: oldMessage.format('MSGLOG_EDIT_TITLE', { author: Util.escapeMarkdown(author.tag), channel: channel.name }), + // icon_url: oldMessage.author.displayAvatarURL({ size: 32 }) // eslint-disable-line camelcase + // }, + title: wrapper.format('MSGLOG_EDIT_TITLE', { author: Util.escapeMarkdown(author.tag), channel: channel.name }), + footer: { + text: wrapper.format('MSGLOG_EDIT_FOOTER', { msgID: oldMessage.id, userID: author.id }) + }, + description: wrapper.format('MSGLOG_EDIT_JUMP', { guild: guild.id, channel: channel.id, message: oldMessage.id }), + color: CONSTANTS.COLORS.YELLOW, + timestamp: oldMessage.createdAt, + fields: [] + }; + + const oldCon = oldMessage.content, + newCon = newMessage.content; + //Original content + embed.fields.push({ + name: wrapper.format('MSGLOG_EDIT_OLD'), + // eslint-disable-next-line no-nested-ternary + value: oldCon.length > 1024 ? oldCon.substring(0, 1021) + '...' : oldCon.length ? oldCon : '\u200b' + }); + if (oldCon.length > 1024) embed.fields.push({ + name: '\u200b', + value: '...' + oldCon.substring(1021) + }); + //Edited content + embed.fields.push({ + name: wrapper.format('MSGLOG_EDIT_NEW'), + value: newCon.length > 1024 ? newCon.substring(0, 1021) + '...' : newCon + }); + if (newCon.length > 1024) embed.fields.push({ + name: '\u200b', + value: '...' + newCon.substring(1021) + }); + + if (reference && reference.channelID === channel.id) { + const referenced = await channel.messages.fetch(reference.messageID); + // eslint-disable-next-line no-nested-ternary + const content = referenced.content ? + referenced.content.length > 900 ? + referenced.content.substring(0, 900) + '...' : + referenced.content : + wrapper.format('MSGLOG_REPLY_NOCONTENT'); + + embed.fields.push({ + name: wrapper.format('MSGLOG_REPLY', { tag: referenced.author.tag, id: referenced.author.id }), + value: wrapper.format('MSGLOG_REPLY_VALUE', { + content, + link: referenced.url + }) + }); + } + + //if(oldMessage.content.length > 1024) embed.description += '\n' + oldMessage.format('MSGLOG_EDIT_OLD_CUTOFF'); + //if(newMessage.content.length > 1024) embed.description += '\n' + oldMessage.format('MSGLOG_EDIT_NEW_CUTOFF'); + + await hook.send({ embeds: [embed] }).catch((err) => { + this.client.logger.error('Error in message edit:\n' + err.stack); + }); + + } + + } + + async voiceState(oldState, newState) { + + if (oldState.channel && newState.channel && oldState.channel === newState.channel) return; + const { guild, member } = oldState; + + //TODO: add checks for disconnecting bot from vc when left alone in one (music player) + + const wrapper = new GuildWrapper(this.client, guild); + const settings = await wrapper.settings(); + const setting = settings.voice; + if (!setting || !setting.channel) return; + + const logChannel = await wrapper.resolveChannel(setting.channel); + if (!logChannel) return; + + const perms = logChannel.permissionsFor(guild.me); + if (!perms.has('SEND_MESSAGES') || !perms.has('VIEW_CHANNEL') || !perms.has('EMBED_LINKS')) return; + + let index = null; + const langParams = { + nickname: member.nickname ? `\`(${member.nickname})\`` : '', + tag: Util.escapeMarkdown(member.user.tag), + id: member.id, + oldChannel: oldState.channel?.name, + newChannel: newState.channel?.name + }; + + if (!oldState.channel && newState.channel) index = 'VCLOG_JOIN'; + else if (oldState.channel && newState.channel) index = 'VCLOG_SWITCH'; + else index = 'VCLOG_LEAVE'; + + this.client.rateLimiter.queueSend(logChannel, wrapper.format(index, langParams).trim()); + + } + + // async ban(guild, user) { + + // } + + // async unban(guild, user) { + + // } + + _replaceTags(text, member) { + const { user, guild } = member; + return text + .replace(/\{mention\}/gu, `<@${member.id}>`) + .replace(/\{tag\}/gu, Util.escapeMarkdown(user.tag)) + .replace(/\{user\}/gu, Util.escapeMarkdown(user.username)) + .replace(/\{serversize\}/gu, guild.memberCount) + .replace(/\{servername\}/gu, guild.name) + .replace(/\{accage\}/gu, this.client.resolver.timeAgo(Date.now() / 1000 - user.createdTimestamp / 1000)) //.replace(/a/, '1') + .replace(/\{id\}/gu, user.id) + .trim(); + } + + async memberJoin(member) { + + const { guild } = member; + const wrapper = new GuildWrapper(this.client, guild); + const settings = await wrapper.settings(); + const setting = settings.members; + if (!setting.channel) return; + + const logChannel = await wrapper.resolveChannel(setting.channel); + if (!logChannel) return; + + const perms = logChannel.permissionsFor(guild.me); + if (!perms.has('SEND_MESSAGES') || !perms.has('VIEW_CHANNEL') || !perms.has('EMBED_LINKS')) return; + + let { joinMessage } = setting; + joinMessage = this._replaceTags(joinMessage, member); + this.client.rateLimiter.queueSend(logChannel, joinMessage); + + } + + async memberLeave(member) { + + const { guild } = member; + const wrapper = new GuildWrapper(this.client, guild); + const settings = await wrapper.settings(); + const setting = settings.members; + if (!setting.channel) return; + + const logChannel = await wrapper.resolveChannel(setting.channel); + if (!logChannel) return; + + const perms = logChannel.permissionsFor(guild.me); + if (!perms.has('SEND_MESSAGES') || !perms.has('VIEW_CHANNEL') || !perms.has('EMBED_LINKS')) return; + + let { leaveMessage } = setting; + leaveMessage = this._replaceTags(leaveMessage, member); + this.client.rateLimiter.queueSend(logChannel, leaveMessage); + + } + + async memberUpdate(oldMember, newMember) { + + if (oldMember.nickname === newMember.nickname) return; + + const { guild, user } = oldMember; + const wrapper = new GuildWrapper(this.client, guild); + const settings = await wrapper.settings(); + const setting = settings.nicknames; + if (!setting.channel) return; + + const logChannel = await wrapper.resolveChannel(setting.channel); + if (!logChannel) return; + + const perms = logChannel.permissionsFor(guild.me); + if (!perms.has('SEND_MESSAGES') || !perms.has('VIEW_CHANNEL') || !perms.has('EMBED_LINKS')) return; + + const oldNick = oldMember.nickname || oldMember.user.username; + const newNick = newMember.nickname || newMember.user.username; + + const embed = { + title: wrapper.format('NICKLOG_TITLE', { user: Util.escapeMarkdown(user.tag) }), + description: wrapper.format('NICKLOG_DESCRIPTION', { oldNick, newNick }), + footer: { + text: wrapper.format('NICKLOG_FOOTER', { id: user.id }) + }, + color: CONSTANTS.COLORS.BLUE + }; + + logChannel.send({ embed }); + + } + +} + +module.exports = GuildLogger; \ No newline at end of file