diff --git a/migrations/20180801185500_create_spam_logs_table.js b/migrations/20180801185500_create_spam_logs_table.js new file mode 100644 index 00000000..f40b01fa --- /dev/null +++ b/migrations/20180801185500_create_spam_logs_table.js @@ -0,0 +1,15 @@ +exports.up = async function(knex, Promise) { + if (! await knex.schema.hasTable('spam_logs')) { + await knex.schema.createTable('spam_logs', table => { + table.string('id', 36).notNullable().primary(); + table.string('guild_id', 20).notNullable(); + table.text('body', 'mediumtext').notNullable(); + table.dateTime('created_at').defaultTo(knex.raw('NOW()')).notNullable(); + table.dateTime('expires_at').nullable(); + }); + } +}; + +exports.down = async function(knex, Promise) { + await knex.schema.dropTableIfExists('spam_logs'); +}; diff --git a/package-lock.json b/package-lock.json index bd3ad87c..812550be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2181,9 +2181,9 @@ } }, "knub": { - "version": "9.6.2", - "resolved": "https://registry.npmjs.org/knub/-/knub-9.6.2.tgz", - "integrity": "sha512-4Hz6xTrY8srq+tT5h1uxWIGZrb0iIvRkCP2l5OKINKfFHzm0Xn6IvkWYW6DUTQg4m37KgySGdWDpPhLSZUqVmg==", + "version": "9.6.4", + "resolved": "https://registry.npmjs.org/knub/-/knub-9.6.4.tgz", + "integrity": "sha512-srwdWu/XPciBQQP3phuavJgQd4XDixccSgjl/vcvQu2kjWgH5Cgnprkfl5JuzhMum/7pVnch4YLEfZDrFzjxAw==", "requires": { "escape-string-regexp": "^1.0.5", "js-yaml": "^3.9.1", diff --git a/package.json b/package.json index 2ef22071..393a5f0f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "escape-string-regexp": "^1.0.5", "humanize-duration": "^3.15.0", "knex": "^0.14.6", - "knub": "^9.6.2", + "knub": "^9.6.4", "lodash.at": "^4.6.0", "lodash.difference": "^4.5.0", "lodash.intersection": "^4.4.0", @@ -42,7 +42,8 @@ "moment-timezone": "^0.5.21", "tlds": "^1.203.1", "ts-node": "^3.3.0", - "typescript": "^2.9.2" + "typescript": "^2.9.2", + "uuid": "^3.3.2" }, "devDependencies": { "nodemon": "^1.17.5", diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 301551c9..420e6958 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -35,7 +35,7 @@ "COMMAND": "🤖 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) used command in **#{channel.name}**:\n`{command}`", - "SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) spam deleted in **#{channel.name}**: {description} (more than {limit} in {interval}s)", + "SPAM_DELETE": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) spam deleted in **#{channel.name}**: {description} (more than {limit} in {interval}s) {logUrl}", "CENSOR": "🛑 **{member.user.username}#{member.user.discriminator}** (`{member.id}`) censored message in **#{channel.name}** (`{channel.id}`) {reason}:\n```{messageText}```", "CLEAN": "🚿 **{mod.username}#{mod.discriminator}** (`{mod.id}`) cleaned **{count}** message(s) in **#{channel.name}**", diff --git a/src/data/GuildSpamLogs.ts b/src/data/GuildSpamLogs.ts new file mode 100644 index 00000000..c4ef2f7c --- /dev/null +++ b/src/data/GuildSpamLogs.ts @@ -0,0 +1,72 @@ +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 = 7; +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; + } + + 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/index.ts b/src/index.ts index 043b3271..3348018e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { ReactionRolesPlugin } from "./plugins/ReactionRoles"; import { CensorPlugin } from "./plugins/Censor"; import { PersistPlugin } from "./plugins/Persist"; import { SpamPlugin } from "./plugins/Spam"; +import { LogServerPlugin } from "./plugins/LogServer"; import knex from "./knex"; // Run latest database migrations @@ -44,7 +45,8 @@ knex.migrate.latest().then(() => { spam: SpamPlugin }, globalPlugins: { - bot_control: BotControlPlugin + bot_control: BotControlPlugin, + log_server: LogServerPlugin }, options: { diff --git a/src/models/SpamLog.ts b/src/models/SpamLog.ts new file mode 100644 index 00000000..6b92b0e8 --- /dev/null +++ b/src/models/SpamLog.ts @@ -0,0 +1,9 @@ +import Model from "./Model"; + +export default class SpamLog extends Model { + public id: string; + public guild_id: string; + public body: string; + public created_at: string; + public expires_at: string; +} diff --git a/src/plugins/LogServer.ts b/src/plugins/LogServer.ts new file mode 100644 index 00000000..ee41c958 --- /dev/null +++ b/src/plugins/LogServer.ts @@ -0,0 +1,44 @@ +import http, { ServerResponse } from "http"; +import { GlobalPlugin } from "knub"; +import { GuildSpamLogs } from "../data/GuildSpamLogs"; + +const DEFAULT_PORT = 9920; +const logUrlRegex = /^\/spam-logs\/([a-z0-9\-]+)\/?$/i; + +function notFound(res: ServerResponse) { + res.statusCode = 404; + res.end("Not Found"); +} + +/** + * A global plugin that allows bot owners to control the bot + */ +export class LogServerPlugin extends GlobalPlugin { + protected spamLogs: GuildSpamLogs; + protected server: http.Server; + + onLoad() { + this.spamLogs = new GuildSpamLogs(null); + + this.server = http.createServer(async (req, res) => { + const logId = req.url.match(logUrlRegex); + if (!logId) return notFound(res); + + if (logId) { + const log = await this.spamLogs.find(logId[1]); + if (!log) return notFound(res); + + res.setHeader("Content-Type", "text/plain; charset=UTF-8"); + res.end(log.body); + } + }); + + this.server.listen(this.configValue("port", DEFAULT_PORT)); + } + + async onUnload() { + return new Promise(resolve => { + this.server.close(() => resolve()); + }); + } +} diff --git a/src/plugins/Spam.ts b/src/plugins/Spam.ts index f366b173..ce1838ed 100644 --- a/src/plugins/Spam.ts +++ b/src/plugins/Spam.ts @@ -1,7 +1,6 @@ import { decorators as d, Plugin } from "knub"; import { Message, TextChannel } from "eris"; import { - cleanMessagesInChannel, getEmojiInString, getRoleMentions, getUrlsInString, @@ -12,6 +11,7 @@ import { LogType } from "../data/LogType"; import { GuildLogs } from "../data/GuildLogs"; import { ModActionsPlugin } from "./ModActions"; import { CaseType } from "../data/CaseType"; +import { GuildSpamLogs } from "../data/GuildSpamLogs"; enum RecentActionType { Message = 1, @@ -35,6 +35,7 @@ const MAX_INTERVAL = 300; export class SpamPlugin extends Plugin { protected logs: GuildLogs; + protected spamLogs: GuildSpamLogs; protected recentActions: IRecentAction[]; @@ -56,6 +57,7 @@ export class SpamPlugin extends Plugin { onLoad() { this.logs = new GuildLogs(this.guildId); + this.spamLogs = new GuildSpamLogs(this.guildId); this.expiryInterval = setInterval(() => this.clearOldRecentActions(), 1000 * 60); this.recentActions = []; } @@ -112,6 +114,17 @@ 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); + + const url = this.knub.getGlobalConfig().url; + return url ? `${url}/spam-logs/${logId}` : `Log ID: ${logId}`; + } + async detectSpam( msg: Message, type: RecentActionType, @@ -137,13 +150,15 @@ export class SpamPlugin extends Plugin { const msgIds = recentActions.map(a => a.msg.id); await this.bot.deleteMessages(msg.channel.id, msgIds); + const logUrl = await this.saveSpamLogs(recentActions.map(a => a.msg)); this.logs.log(LogType.SPAM_DELETE, { member: stripObjectToScalars(msg.member, ["user"]), channel: stripObjectToScalars(msg.channel), description, limit: spamConfig.count, - interval: spamConfig.interval + interval: spamConfig.interval, + logUrl }); } diff --git a/src/utils.ts b/src/utils.ts index 43e965bc..cdf177c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -249,7 +249,6 @@ export function getUserMentions(str: string) { // tslint:disable-next-line while ((match = regex.exec(str)) !== null) { - console.log("m", match); userIds.push(match[1]); }