/* eslint-disable no-labels */ const { MessageAttachment, WebhookClient } = require('discord.js'); const { Observer } = require('../../../interfaces/'); const { Util, Constants: { EmbedLimits } } = require('../../../../util'); const { stripIndents } = require('common-tags'); const moment = require('moment'); 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( this.client._options.moderation.attachments.webhook.id, this.client._options.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.guild || !message.guild.available) return; await message.guild.settings(); if (!message.member) try { message.member = await message.guild.members.fetch(message.author.id); } catch (_) { // Member not found, do nothing } const { messageLog } = message.guild._settings; if(!messageLog.channel) return undefined; const { bypass, ignore } = messageLog; const logChannel = await message.guild.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 message.guild.getWebhook('messageLog'); if (!hook) { this.client.logger.debug(`Missing messageLog hook in ${message.guild.name} (${message.guild.id})`); return; } const embed = { // author: { // name: message.format('MSGLOG_DELETE_TITLE', { channel: message.channel.name, author: Util.escapeMarkdown(message.author.tag) }), //`${message.author.tag} (${message.author.id})`, // icon_url: message.author.displayAvatarURL({ size: 32 }) //eslint-disable-line camelcase // }, title: message.format('MSGLOG_DELETE_TITLE', { channel: message.channel.name, author: Util.escapeMarkdown(message.author.tag) }), description: Util.escapeMarkdown(message.content)?.replace(/\\n/gu, ' ') || message.format('MSGLOG_NOCONTENT'), color: CONSTANTS.COLORS.RED, footer: { text: message.format('MSGLOG_DELETE_FOOTER', { msgID: message.id, userID: message.author.id }) }, timestamp: message.createdAt }; if (message.filtered) { embed.fields = [ { name: message.format('MSGLOG_FILTERED'), value: stripIndents` ${message.format(message.filtered.preset ? 'MSGLOG_FILTERED_PRESET' : 'MSGLOG_FILTERED_VALUE', { ...message.filtered })} ${message.filtered.sanctioned ? message.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 = []; } 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(', ')}`; } } } hook.send({ embeds: [embed], files: uploadedFiles }); /* if(message.attachments.size > 0) { return this.logAttachment({ msgID: message.id, guildID: guild.id, channelID: channel.id, logChannel }); } const embed = { title: message.format('MSGLOG_DELETE_TITLE', { author: Util.escapeMarkdown(author.tag), channel: channel.name }), description: message.content, footer: { text: message.format('MSGLOG_DELETE_FOOTER', { msgID: message.id, userID: author.id }) }, color: CONSTANTS.COLORS.RED, timestamp: message.createdAt }; await logChannel.send({ embed }); */ } async messageDeleteBulk(messages) { //Status: Should be complete, though additional testing might be necessary const { guild, channel } = messages.first(); if (!guild) return; const settings = await guild.settings(); const chatlogs = settings.messageLog; if (!chatlogs.channel) return; const { ignore, bypass } = chatlogs; if (ignore.includes(channel.id)) return; const hook = await guild.getWebhook('messageLog'); 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.array().sort((a, b) => b.createdTimestamp - a.createdTimestamp)) { 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 attStr = message.attachments.map((att) => `${att.name} (${att.id})`).join(', '); const value = stripIndents`${content ? content.substring(0, cutOff) : message.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) }); } //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').length; embed.title = guild.format('BULK_DELETE_TITLE', { embedNr: i + 1, channel: channel.name }); embed.footer = { text: guild.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) { guild.updateWebhook('messageLog'); } 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; if (!oldMessage.member) oldMessage.member = await guild.members.fetch(oldMessage.author); const { member, channel, author } = oldMessage; const settings = await guild.settings(); const chatlogs = settings.messageLog; if (!chatlogs.channel) return; const { ignore, bypass } = chatlogs; const hook = await guild.getWebhook('messageLog'); 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: oldMessage.format('MSGLOG_PINNED_TITLE', { author: Util.escapeMarkdown(author.tag), channel: channel.name, pinned: oldMessage.format('PIN_TOGGLE', { toggle: newMessage.pinned }, true) }), description: oldMessage.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: oldMessage.format('MSGLOG_EDIT_TITLE', { author: Util.escapeMarkdown(author.tag), channel: channel.name }), footer: { text: oldMessage.format('MSGLOG_EDIT_FOOTER', { msgID: oldMessage.id, userID: author.id }) }, description: oldMessage.format('MSGLOG_EDIT_JUMP', { guild: guild.id, channel: channel.id, message: oldMessage.id }), color: CONSTANTS.COLORS.YELLOW, timestamp: oldMessage.createdAt, fields: [ // { // name: oldMessage.format('MSGLOG_EDIT_OLD'), // value: oldMessage.content.length > 1024 ? oldMessage.content.substring(0, 1021) + '...' : oldMessage.content // }, // { // name: oldMessage.format('MSGLOG_EDIT_NEW'), // value: newMessage.content.length > 1024 ? newMessage.content.substring(0, 1021) + '...' : newMessage.content // } ] }; const oldCon = oldMessage.content, newCon = newMessage.content; //Original content embed.fields.push({ name: oldMessage.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: oldMessage.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(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 settings = await guild.settings(); const setting = settings.voiceLog; if(!setting || !setting.channel) return; const logChannel = await guild.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, guild.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 settings = await guild.settings(); const setting = settings.memberLog; if(!setting.channel) return; const logChannel = await guild.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 settings = await guild.settings(); const setting = settings.memberLog; if(!setting.channel) return; const logChannel = await guild.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 settings = await guild.settings(); const setting = settings.nicknameLog; if(!setting.channel) return; const logChannel = await guild.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: guild.format('NICKLOG_TITLE', { user: Util.escapeMarkdown(user.tag) }), description: guild.format('NICKLOG_DESCRIPTION', { oldNick, newNick }), footer: { text: guild.format('NICKLOG_FOOTER', { id: user.id }) }, color: CONSTANTS.COLORS.BLUE }; logChannel.send({ embed }); } } module.exports = GuildLogger;