diff --git a/migrations/20180818192600_rename_spam_logs_to_archives.js b/migrations/20180818192600_rename_spam_logs_to_archives.js new file mode 100644 index 00000000..39933ae9 --- /dev/null +++ b/migrations/20180818192600_rename_spam_logs_to_archives.js @@ -0,0 +1,7 @@ +exports.up = async function(knex) { + await knex.schema.renameTable('spam_logs', 'archives'); +}; + +exports.down = async function(knex) { + await knex.schema.renameTable('archives', 'spam_logs'); +}; diff --git a/src/data/GuildArchives.ts b/src/data/GuildArchives.ts new file mode 100644 index 00000000..65a220ee --- /dev/null +++ b/src/data/GuildArchives.ts @@ -0,0 +1,51 @@ +import uuid from "uuid/v4"; // tslint:disable-line +import moment from "moment-timezone"; +import knex from "../knex"; +import SpamLog from "../models/SpamLog"; + +const DEFAULT_EXPIRY_DAYS = 30; + +function deleteExpiredArchives() { + knex("archives") + .where("expires_at", "<=", knex.raw("NOW()")) + .delete(); +} + +deleteExpiredArchives(); +setInterval(deleteExpiredArchives, 1000 * 60 * 60); // Clean expired archives every hour + +export class GuildArchives { + protected guildId: string; + + constructor(guildId) { + this.guildId = guildId; + } + + generateNewLogId() { + return uuid(); + } + + async find(id: string): Promise { + const result = await knex("archives") + .where("id", id) + .first(); + + return result ? new SpamLog(result) : null; + } + + async create(body: string, expiresAt: moment.Moment = null) { + const id = this.generateNewLogId(); + if (!expiresAt) { + expiresAt = moment().add(DEFAULT_EXPIRY_DAYS, "days"); + } + + await knex("archives").insert({ + id, + guild_id: this.guildId, + body, + expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss") + }); + + return id; + } +} diff --git a/src/data/GuildSpamLogs.ts b/src/data/GuildSpamLogs.ts deleted file mode 100644 index b1819102..00000000 --- a/src/data/GuildSpamLogs.ts +++ /dev/null @@ -1,74 +0,0 @@ -import uuid from "uuid/v4"; // tslint:disable-line -import moment from "moment-timezone"; -import knex from "../knex"; -import SpamLog from "../models/SpamLog"; -import { Message } from "eris"; -import { formatTemplateString, stripObjectToScalars, trimLines } from "../utils"; - -const EXPIRY_DAYS = 90; -const MESSAGE_FORMAT = - "[{timestamp}] [{message.id}] {user.username}#{user.discriminator}: {message.content}{attachments}"; - -function cleanExpiredLogs() { - knex("spam_logs") - .where("expires_at", "<=", knex.raw("NOW()")) - .delete(); -} - -cleanExpiredLogs(); -setInterval(cleanExpiredLogs, 1000 * 60 * 60); // Clean expired logs every hour - -export class GuildSpamLogs { - protected guildId: string; - - constructor(guildId) { - this.guildId = guildId; - } - - generateNewLogId() { - return uuid(); - } - - async find(id: string): Promise { - const result = await knex("spam_logs") - .where("id", id) - .first(); - - return result ? new SpamLog(result) : null; - } - - /** - * @return ID of the created spam log entry - */ - async createFromMessages(messages: Message[], header: string = null) { - const lines = messages.map(msg => { - return formatTemplateString(MESSAGE_FORMAT, { - user: stripObjectToScalars(msg.author), - message: stripObjectToScalars(msg), - timestamp: moment(msg.timestamp).format("YYYY-MM-DD HH:mm:ss zz"), - attachments: msg.attachments.length ? ` (message contained ${msg.attachments.length} attachment(s))` : "" - }); - }); - - const id = uuid(); - const now = moment().format("YYYY-MM-DD HH:mm:ss zz"); - const expiresAt = moment().add(EXPIRY_DAYS, "days"); - - const body = trimLines(` - Log file generated on ${now}. Expires at ${expiresAt.format("YYYY-MM-DD HH:mm:ss zz")}.${ - header ? "\n" + header : "" - } - - ${lines.join("\n")} - `); - - await knex("spam_logs").insert({ - id, - guild_id: this.guildId, - body, - expires_at: expiresAt.format("YYYY-MM-DD HH:mm:ss") - }); - - return id; - } -} diff --git a/src/plugins/LogServer.ts b/src/plugins/LogServer.ts index 69c123fd..cc9f3f6a 100644 --- a/src/plugins/LogServer.ts +++ b/src/plugins/LogServer.ts @@ -1,10 +1,10 @@ import http, { ServerResponse } from "http"; import { GlobalPlugin } from "knub"; -import { GuildSpamLogs } from "../data/GuildSpamLogs"; +import { GuildArchives } from "../data/GuildArchives"; import { sleep } from "../utils"; const DEFAULT_PORT = 9920; -const logUrlRegex = /^\/spam-logs\/([a-z0-9\-]+)\/?$/i; +const archivesRegex = /^\/(spam-logs|archives)\/([a-z0-9\-]+)\/?$/i; function notFound(res: ServerResponse) { res.statusCode = 404; @@ -15,18 +15,26 @@ function notFound(res: ServerResponse) { * A global plugin that allows bot owners to control the bot */ export class LogServerPlugin extends GlobalPlugin { - protected spamLogs: GuildSpamLogs; + protected archives: GuildArchives; protected server: http.Server; async onLoad() { - this.spamLogs = new GuildSpamLogs(null); + this.archives = new GuildArchives(null); this.server = http.createServer(async (req, res) => { - const logId = req.url.match(logUrlRegex); - if (!logId) return notFound(res); + const pathMatch = req.url.match(archivesRegex); + if (!pathMatch) return notFound(res); - if (logId) { - const log = await this.spamLogs.find(logId[1]); + const logId = pathMatch[2]; + + if (pathMatch[1] === "spam-logs") { + res.statusCode = 301; + res.setHeader("Location", `/archives/${logId}`); + return; + } + + if (pathMatch) { + const log = await this.archives.find(logId); if (!log) return notFound(res); res.setHeader("Content-Type", "text/plain; charset=UTF-8"); diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index 833c17b4..8c171efe 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -1,6 +1,7 @@ import { decorators as d, Plugin } from "knub"; -import { Message, TextChannel } from "eris"; +import { Channel, Message, TextChannel, User } from "eris"; import { + formatTemplateString, getEmojiInString, getRoleMentions, getUrlsInString, @@ -13,7 +14,8 @@ import { LogType } from "../data/LogType"; import { GuildLogs } from "../data/GuildLogs"; import { ModActionsPlugin } from "./ModActions"; import { CaseType } from "../data/CaseType"; -import { GuildSpamLogs } from "../data/GuildSpamLogs"; +import { GuildArchives } from "../data/GuildArchives"; +import moment from "moment-timezone"; enum RecentActionType { Message = 1, @@ -35,9 +37,21 @@ interface IRecentAction { const MAX_INTERVAL = 300; +const ARCHIVE_EXPIRY_DAYS = 90; +const ARCHIVE_HEADER_FORMAT = trimLines(` + Server: {guild.name} ({guild.id}) + Channel: #{channel.name} ({channel.id}) + User: {user.username}#{user.discriminator} ({user.id}) +`); +const ARCHIVE_MESSAGE_FORMAT = "[MSG ID {message.id}] [{timestamp}] {user.username}: {message.content}{attachments}"; +const ARCHIVE_FOOTER_FORMAT = trimLines(` + Log file generated on {timestamp} + Expires at {expires} +`); + export class SpamPlugin extends Plugin { protected logs: GuildLogs; - protected spamLogs: GuildSpamLogs; + protected archives: GuildArchives; // Handle spam detection with a queue so we don't have overlapping detections on the same user protected spamDetectionQueue: Promise; @@ -82,7 +96,7 @@ export class SpamPlugin extends Plugin { onLoad() { this.logs = new GuildLogs(this.guildId); - this.spamLogs = new GuildSpamLogs(this.guildId); + this.archives = new GuildArchives(this.guildId); this.recentActions = []; this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60); @@ -138,13 +152,31 @@ export class SpamPlugin extends Plugin { this.recentActions = this.recentActions.filter(action => action.timestamp >= expiryTimestamp); } - async saveSpamLogs(messages: Message[]) { - const channel = messages[0].channel as TextChannel; - const header = `Server: ${this.guild.name} (${this.guild.id}), channel: #${channel.name} (${channel.id})`; - const logId = await this.spamLogs.createFromMessages(messages, header); + async saveSpamArchives(messages: Message[], channel: Channel, user: User) { + const expiresAt = moment().add(ARCHIVE_EXPIRY_DAYS, "days"); + + const headerStr = formatTemplateString(ARCHIVE_HEADER_FORMAT, { + guild: this.guild, + channel, + user + }); + const msgLines = messages.map(msg => { + return formatTemplateString(ARCHIVE_MESSAGE_FORMAT, { + message: msg, + timestamp: moment(msg.timestamp, "x").format("HH:mm:ss"), + user + }); + }); + const messagesStr = msgLines.join("\n"); + const footerStr = formatTemplateString(ARCHIVE_FOOTER_FORMAT, { + timestamp: moment().format("YYYY-MM-DD [at] HH:mm:ss (Z)"), + expires: expiresAt.format("YYYY-MM-DD [at] HH:mm:ss (Z)") + }); + + const logId = await this.archives.create([headerStr, messagesStr, footerStr].join("\n\n"), expiresAt); const url = this.knub.getGlobalConfig().url; - return url ? `${url}/spam-logs/${logId}` : `Log ID: ${logId}`; + return url ? `${url}/archives/${logId}` : `Archive ID: ${logId}`; } async logAndDetectSpam( @@ -225,7 +257,7 @@ export class SpamPlugin extends Plugin { this.clearRecentUserActions(type, msg.author.id, msg.channel.id); // Generate a log from the detected messages - const logUrl = await this.saveSpamLogs(uniqueMessages); + const logUrl = await this.saveSpamArchives(uniqueMessages, msg.channel, msg.author); // Create a case and log the actions taken above const caseType = spamConfig.mute ? CaseType.Mute : CaseType.Note;