diff --git a/src/data/GuildNameHistory.ts b/src/data/GuildNameHistory.ts new file mode 100644 index 00000000..be0af1c2 --- /dev/null +++ b/src/data/GuildNameHistory.ts @@ -0,0 +1,64 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { NameHistoryEntry } from "./entities/NameHistoryEntry"; + +const MAX_ENTRIES_PER_USER = 10; + +export class GuildNameHistory extends BaseRepository { + private nameHistory: Repository; + + constructor(guildId) { + super(guildId); + this.nameHistory = getRepository(NameHistoryEntry); + } + + async getByUserId(userId): Promise { + return this.nameHistory.find({ + where: { + guild_id: this.guildId, + user_id: userId + }, + order: { + id: "DESC" + }, + take: MAX_ENTRIES_PER_USER + }); + } + + getLastEntryByType(userId, type): Promise { + return this.nameHistory.findOne({ + where: { + guild_id: this.guildId, + user_id: userId, + type + }, + order: { + id: "DESC" + } + }); + } + + async addEntry(userId, type, value) { + await this.nameHistory.insert({ + guild_id: this.guildId, + user_id: userId, + type, + value + }); + + // Cleanup (leave only the last MAX_ENTRIES_PER_USER entries) + const lastEntries = await this.getByUserId(userId); + if (lastEntries.length > MAX_ENTRIES_PER_USER) { + const earliestEntry = lastEntries[lastEntries.length - 1]; + if (!earliestEntry) return; + + this.nameHistory + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("user_id = :userId", { userId }) + .andWhere("id < :id", { id: earliestEntry.id }) + .delete() + .execute(); + } + } +} diff --git a/src/data/NameHistoryEntryTypes.ts b/src/data/NameHistoryEntryTypes.ts new file mode 100644 index 00000000..a64287f8 --- /dev/null +++ b/src/data/NameHistoryEntryTypes.ts @@ -0,0 +1,4 @@ +export enum NameHistoryEntryTypes { + Username = 1, + Nickname +} diff --git a/src/data/entities/NameHistoryEntry.ts b/src/data/entities/NameHistoryEntry.ts new file mode 100644 index 00000000..3be1c5a9 --- /dev/null +++ b/src/data/entities/NameHistoryEntry.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("name_history") +export class NameHistoryEntry { + @Column() + @PrimaryColumn() + id: string; + + @Column() guild_id: string; + + @Column() user_id: string; + + @Column() type: number; + + @Column() value: string; + + @Column() timestamp: string; +} diff --git a/src/index.ts b/src/index.ts index bc736326..658c7cbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,7 @@ import { CasesPlugin } from "./plugins/Cases"; import { MutesPlugin } from "./plugins/Mutes"; import { SlowmodePlugin } from "./plugins/Slowmode"; import { StarboardPlugin } from "./plugins/Starboard"; +import { NameHistoryPlugin } from "./plugins/NameHistory"; // Run latest database migrations logger.info("Running database migrations"); @@ -72,12 +73,13 @@ connect().then(async conn => { }); client.setMaxListeners(100); - const basePlugins = ["message_saver", "cases", "mutes"]; + const basePlugins = ["message_saver", "name_history", "cases", "mutes"]; const bot = new Knub(client, { plugins: [ // Base plugins (always enabled) MessageSaverPlugin, + NameHistoryPlugin, CasesPlugin, MutesPlugin, @@ -95,10 +97,7 @@ connect().then(async conn => { StarboardPlugin ], - globalPlugins: [ - BotControlPlugin, - LogServerPlugin - ], + globalPlugins: [BotControlPlugin, LogServerPlugin], options: { getEnabledPlugins(guildId, guildConfig): string[] { diff --git a/src/migrations/1546778415930-CreateNameHistoryTable.ts b/src/migrations/1546778415930-CreateNameHistoryTable.ts new file mode 100644 index 00000000..65b36fc6 --- /dev/null +++ b/src/migrations/1546778415930-CreateNameHistoryTable.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateNameHistoryTable1546778415930 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "name_history", + columns: [ + { + name: "id", + type: "int", + unsigned: true, + isGenerated: true, + generationStrategy: "increment", + isPrimary: true + }, + { + name: "guild_id", + type: "bigint", + unsigned: true + }, + { + name: "user_id", + type: "bigint", + unsigned: true + }, + { + name: "type", + type: "tinyint", + unsigned: true + }, + { + name: "value", + type: "varchar", + length: "128", + isNullable: true + }, + { + name: "timestamp", + type: "datetime", + default: "CURRENT_TIMESTAMP" + } + ], + indices: [ + { + columnNames: ["guild_id", "user_id"] + }, + { + columnNames: ["type"] + }, + { + columnNames: ["timestamp"] + } + ] + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("name_history"); + } +} diff --git a/src/plugins/NameHistory.ts b/src/plugins/NameHistory.ts new file mode 100644 index 00000000..da064bb7 --- /dev/null +++ b/src/plugins/NameHistory.ts @@ -0,0 +1,85 @@ +import { Plugin, decorators as d } from "knub"; +import { GuildNameHistory } from "../data/GuildNameHistory"; +import { Member, Message, Relationship, User } from "eris"; +import { NameHistoryEntryTypes } from "../data/NameHistoryEntryTypes"; +import { createChunkedMessage, errorMessage, trimLines } from "../utils"; + +export class NameHistoryPlugin extends Plugin { + public static pluginName = "name_history"; + + protected nameHistory: GuildNameHistory; + + getDefaultOptions() { + return { + permissions: { + view: false + }, + + overrides: [ + { + level: ">=50", + permissions: { + view: true + } + } + ] + }; + } + + onLoad() { + this.nameHistory = GuildNameHistory.getInstance(this.guildId); + } + + @d.command("names", "") + @d.permission("view") + async namesCmd(msg: Message, args: { userId: string }) { + const names = await this.nameHistory.getByUserId(args.userId); + if (!names) { + msg.channel.createMessage(errorMessage("No name history found for that user!")); + return; + } + + const rows = names.map(entry => { + const type = entry.type === NameHistoryEntryTypes.Username ? "Username" : "Nickname"; + const value = entry.value || ""; + return `\`[${entry.timestamp}]\` ${type} **${value}**`; + }); + + const user = this.bot.users.get(args.userId); + const currentUsername = user ? `${user.username}#${user.discriminator}` : args.userId; + + const message = trimLines(` + Name history for **${currentUsername}**: + + ${rows.join("\n")} + `); + createChunkedMessage(msg.channel, message); + } + + @d.event("userUpdate") + async onUserUpdate(user: User, oldUser: { username: string; discriminator: string; avatar: string }) { + console.log("onUserUpdate", user.username, oldUser.username); + if (user.username !== oldUser.username || user.discriminator !== oldUser.discriminator) { + const newUsername = `${user.username}#${user.discriminator}`; + await this.nameHistory.addEntry(user.id, NameHistoryEntryTypes.Username, newUsername); + } + } + + @d.event("presenceUpdate") + async onPresenceUpdate(other: Member | Relationship) { + const user = other.user; + const username = `${user.username}#${user.discriminator}`; + + const lastEntry = await this.nameHistory.getLastEntryByType(user.id, NameHistoryEntryTypes.Username); + if (!lastEntry || lastEntry.value !== username) { + await this.nameHistory.addEntry(user.id, NameHistoryEntryTypes.Username, username); + } + } + + @d.event("guildMemberUpdate") + async onGuildMemberUpdate(_, member: Member, oldMember: { nick: string; roles: string[] }) { + if (member.nick !== oldMember.nick) { + await this.nameHistory.addEntry(member.id, NameHistoryEntryTypes.Nickname, member.nick); + } + } +} diff --git a/src/utils.ts b/src/utils.ts index a0f67a11..f47f0a5f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import at = require("lodash.at"); -import { Guild, GuildAuditLogEntry } from "eris"; +import { Guild, GuildAuditLogEntry, TextableChannel } from "eris"; import url from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; @@ -276,6 +276,13 @@ export function chunkMessageLines(str: string): string[] { }); } +export async function createChunkedMessage(channel: TextableChannel, messageText: string) { + const chunks = chunkMessageLines(messageText); + for (const chunk of chunks) { + await channel.createMessage(chunk); + } +} + export function noop() { // IT'S LITERALLY NOTHING }