From 54052d9237264b6bffb5ee24400cac6fa97709ef Mon Sep 17 00:00:00 2001 From: roflmaoqwerty Date: Sun, 26 Jan 2020 09:14:06 +1100 Subject: [PATCH 01/49] implemented performBanSearch --- backend/src/plugins/Utility.ts | 163 +++++++++++++++++++++++++++++---- 1 file changed, 145 insertions(+), 18 deletions(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index 5f41d2fe..758d4808 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -123,6 +123,13 @@ type MemberSearchParams = { "status-search"?: boolean; }; +type BanSearchParams = { + query?: string; + sort?: string; + "case-sensitive"?: boolean; + regex?: boolean; +}; + class SearchError extends Error {} export class UtilityPlugin extends ZeppelinPlugin { @@ -342,6 +349,63 @@ export class UtilityPlugin extends ZeppelinPlugin { msg.channel.createMessage(`The permission level of ${member.username}#${member.discriminator} is **${level}**`); } + protected async performBanSearch( + args: BanSearchParams, + page = 1, + perPage = SEARCH_RESULTS_PER_PAGE, + ): Promise<{ results: User[]; totalResults: number; page: number; lastPage: number; from: number; to: number }> { + let matchingBans = (await this.guild.getBans()).map(x => x.user); + + if (args.query) { + let queryRegex: RegExp; + if (args.regex) { + queryRegex = new RegExp(args.query.trimStart(), args["case-sensitive"] ? "" : "i"); + } else { + queryRegex = new RegExp(escapeStringRegexp(args.query.trimStart()), args["case-sensitive"] ? "" : "i"); + } + + if (!safeRegex(queryRegex)) { + throw new SearchError("Unsafe/too complex regex (star depth is limited to 1)"); + } + + matchingBans = matchingBans.filter(user => { + const fullUsername = `${user.username}#${user.discriminator}`; + if (fullUsername.match(queryRegex)) return true; + }); + } + + const [, sortDir, sortBy] = args.sort ? args.sort.match(/^(-?)(.*)$/) : [null, "ASC", "name"]; + const realSortDir = sortDir === "-" ? "DESC" : "ASC"; + + if (sortBy === "id") { + matchingBans.sort(sorter(m => BigInt(m.id), realSortDir)); + } else { + matchingBans.sort( + multiSorter([ + [m => m.username.toLowerCase(), realSortDir], + [m => m.discriminator, realSortDir], + ]), + ); + } + + const lastPage = Math.max(1, Math.ceil(matchingBans.length / perPage)); + page = Math.min(lastPage, Math.max(1, page)); + + const from = (page - 1) * perPage; + const to = Math.min(from + perPage, matchingBans.length); + + const pageMembers = matchingBans.slice(from, to); + + return { + results: pageMembers, + totalResults: matchingBans.length, + page, + lastPage, + from: from + 1, + to, + }; + } + protected async performMemberSearch( args: MemberSearchParams, page = 1, @@ -457,6 +521,21 @@ export class UtilityPlugin extends ZeppelinPlugin { }; } + protected formatSearchResultList(members: Member[]): string { + const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); + const lines = members.map(member => { + const paddedId = member.id.padEnd(longestId, " "); + let line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; + if (member.nick) line += ` (${member.nick})`; + return line; + }); + return lines.join("\n"); + } + + protected formatSearchResultIdList(members: Member[]): string { + return members.map(m => m.id).join(" "); + } + @d.command("search", "[query:string$]", { aliases: ["s"], options: [ @@ -542,21 +621,6 @@ export class UtilityPlugin extends ZeppelinPlugin { "status-search"?: boolean; }, ) { - const formatSearchResultList = (members: Member[]): string => { - const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); - const lines = members.map(member => { - const paddedId = member.id.padEnd(longestId, " "); - let line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; - if (member.nick) line += ` (${member.nick})`; - return line; - }); - return lines.join("\n"); - }; - - const formatSearchResultIdList = (members: Member[]): string => { - return members.map(m => m.id).join(" "); - }; - // If we're exporting the results, we don't need all the fancy schmancy pagination stuff. // Just get the results and dump them in an archive. if (args.export) { @@ -575,7 +639,9 @@ export class UtilityPlugin extends ZeppelinPlugin { return this.sendErrorMessage(msg.channel, "No results found"); } - const resultList = args.ids ? formatSearchResultIdList(results.results) : formatSearchResultList(results.results); + const resultList = args.ids + ? this.formatSearchResultIdList(results.results) + : this.formatSearchResultList(results.results); const archiveId = await this.archives.create( trimLines(` @@ -640,8 +706,8 @@ export class UtilityPlugin extends ZeppelinPlugin { : `Found ${searchResult.totalResults} ${resultWord}`; const resultList = args.ids - ? formatSearchResultIdList(searchResult.results) - : formatSearchResultList(searchResult.results); + ? this.formatSearchResultIdList(searchResult.results) + : this.formatSearchResultList(searchResult.results); const result = trimLines(` ${headerText} @@ -694,6 +760,67 @@ export class UtilityPlugin extends ZeppelinPlugin { loadSearchPage(currentPage); } + @d.command("bansearch", "[query:string$]", { + aliases: ["bs"], + options: [ + { + name: "page", + shortcut: "p", + type: "number", + }, + { + name: "sort", + type: "string", + }, + { + name: "case-sensitive", + shortcut: "cs", + isSwitch: true, + }, + { + name: "export", + shortcut: "e", + isSwitch: true, + }, + { + name: "ids", + isSwitch: true, + }, + { + name: "regex", + shortcut: "re", + isSwitch: true, + }, + ], + extra: { + info: { + description: "Search banned users", + basicUsage: "!bansearch dragory", + optionDescriptions: { + sort: + "Change how the results are sorted. Possible values are 'id' and 'name'. Prefix with a dash, e.g. '-id', to reverse sorting.", + "case-sensitive": "By default, the search is case-insensitive. Use this to make it case-sensitive instead.", + export: "If set, the full search results are exported as an archive", + }, + }, + }, + }) + @d.permission("can_search") + async banSearchCmd( + msg: Message, + args: { + query?: string; + page?: number; + sort?: string; + "case-sensitive"?: boolean; + export?: boolean; + ids?: boolean; + regex?: boolean; + }, + ) { + // TODO: implement command + } + async cleanMessages(channel: Channel, savedMessages: SavedMessage[], mod: User) { this.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); From d7e2cf8721e8383a1cfc7787775ad1cea477c944 Mon Sep 17 00:00:00 2001 From: roflmaoqwerty <36663568+roflmaoqwerty@users.noreply.github.com> Date: Wed, 29 Jan 2020 07:42:27 +1100 Subject: [PATCH 02/49] refactored search result format methods --- backend/src/plugins/Utility.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index 758d4808..3e0a6625 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -521,18 +521,23 @@ export class UtilityPlugin extends ZeppelinPlugin { }; } - protected formatSearchResultList(members: Member[]): string { + protected formatSearchResultList(members: Array): string { const longestId = members.reduce((longest, member) => Math.max(longest, member.id.length), 0); const lines = members.map(member => { const paddedId = member.id.padEnd(longestId, " "); - let line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; - if (member.nick) line += ` (${member.nick})`; + let line; + if (member instanceof Member) { + line = `${paddedId} ${member.user.username}#${member.user.discriminator}`; + if (member.nick) line += ` (${member.nick})`; + } else { + line = `${paddedId} ${member.username}#${member.discriminator}`; + } return line; }); return lines.join("\n"); } - protected formatSearchResultIdList(members: Member[]): string { + protected formatSearchResultIdList(members: Array): string { return members.map(m => m.id).join(" "); } From f376b173ccff1e10702140217ee9848182bc13bc Mon Sep 17 00:00:00 2001 From: roflmaoqwerty <36663568+roflmaoqwerty@users.noreply.github.com> Date: Mon, 18 May 2020 23:59:45 +1000 Subject: [PATCH 03/49] implemented ban search command. moved search rendering code to new method --- backend/src/plugins/Utility.ts | 199 ++++++++++++++++++--------------- 1 file changed, 107 insertions(+), 92 deletions(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index 3e0a6625..ff186d68 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -130,6 +130,10 @@ type BanSearchParams = { regex?: boolean; }; +enum SearchType { + MemberSearch, + BanSearch, +} class SearchError extends Error {} export class UtilityPlugin extends ZeppelinPlugin { @@ -663,6 +667,101 @@ export class UtilityPlugin extends ZeppelinPlugin { return; } + await this.displaySearchResults(args, SearchType.MemberSearch, msg); + } + + @d.command("bansearch", "[query:string$]", { + aliases: ["bs"], + options: [ + { + name: "page", + shortcut: "p", + type: "number", + }, + { + name: "sort", + type: "string", + }, + { + name: "case-sensitive", + shortcut: "cs", + isSwitch: true, + }, + { + name: "export", + shortcut: "e", + isSwitch: true, + }, + { + name: "ids", + isSwitch: true, + }, + { + name: "regex", + shortcut: "re", + isSwitch: true, + }, + ], + extra: { + info: { + description: "Search banned users", + basicUsage: "!bansearch dragory", + optionDescriptions: { + sort: + "Change how the results are sorted. Possible values are 'id' and 'name'. Prefix with a dash, e.g. '-id', to reverse sorting.", + "case-sensitive": "By default, the search is case-insensitive. Use this to make it case-sensitive instead.", + export: "If set, the full search results are exported as an archive", + }, + }, + }, + }) + @d.permission("can_search") + async banSearchCmd( + msg: Message, + args: { + query?: string; + page?: number; + sort?: string; + "case-sensitive"?: boolean; + export?: boolean; + ids?: boolean; + regex?: boolean; + }, + ) { + await this.displaySearchResults(args, SearchType.BanSearch, msg); + } + + async cleanMessages(channel: Channel, savedMessages: SavedMessage[], mod: User) { + this.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); + this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); + + // Delete & archive in ID order + savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1)); + const idsToDelete = savedMessages.map(m => m.id); + + // Make sure the deletions aren't double logged + idsToDelete.forEach(id => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); + this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]); + + // Actually delete the messages + await this.bot.deleteMessages(channel.id, idsToDelete); + await this.savedMessages.markBulkAsDeleted(idsToDelete); + + // Create an archive + const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); + const archiveUrl = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); + + this.logs.log(LogType.CLEAN, { + mod: stripObjectToScalars(mod), + channel: stripObjectToScalars(channel), + count: savedMessages.length, + archiveUrl, + }); + + return { archiveUrl }; + } + + async displaySearchResults(args: any, searchType: SearchType, msg: Message) { // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions let originalSearchMsg: Message = null; let searching = false; @@ -689,7 +788,14 @@ export class UtilityPlugin extends ZeppelinPlugin { let searchResult; try { - searchResult = await this.performMemberSearch(args, page, perPage); + switch (searchType) { + case SearchType.MemberSearch: + searchResult = await this.performMemberSearch(args, page, perPage); + break; + case SearchType.BanSearch: + searchResult = await this.performBanSearch(args, page, perPage); + break; + } } catch (e) { if (e instanceof SearchError) { return this.sendErrorMessage(msg.channel, e.message); @@ -765,97 +871,6 @@ export class UtilityPlugin extends ZeppelinPlugin { loadSearchPage(currentPage); } - @d.command("bansearch", "[query:string$]", { - aliases: ["bs"], - options: [ - { - name: "page", - shortcut: "p", - type: "number", - }, - { - name: "sort", - type: "string", - }, - { - name: "case-sensitive", - shortcut: "cs", - isSwitch: true, - }, - { - name: "export", - shortcut: "e", - isSwitch: true, - }, - { - name: "ids", - isSwitch: true, - }, - { - name: "regex", - shortcut: "re", - isSwitch: true, - }, - ], - extra: { - info: { - description: "Search banned users", - basicUsage: "!bansearch dragory", - optionDescriptions: { - sort: - "Change how the results are sorted. Possible values are 'id' and 'name'. Prefix with a dash, e.g. '-id', to reverse sorting.", - "case-sensitive": "By default, the search is case-insensitive. Use this to make it case-sensitive instead.", - export: "If set, the full search results are exported as an archive", - }, - }, - }, - }) - @d.permission("can_search") - async banSearchCmd( - msg: Message, - args: { - query?: string; - page?: number; - sort?: string; - "case-sensitive"?: boolean; - export?: boolean; - ids?: boolean; - regex?: boolean; - }, - ) { - // TODO: implement command - } - - async cleanMessages(channel: Channel, savedMessages: SavedMessage[], mod: User) { - this.logs.ignoreLog(LogType.MESSAGE_DELETE, savedMessages[0].id); - this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, savedMessages[0].id); - - // Delete & archive in ID order - savedMessages = Array.from(savedMessages).sort((a, b) => (a.id > b.id ? 1 : -1)); - const idsToDelete = savedMessages.map(m => m.id); - - // Make sure the deletions aren't double logged - idsToDelete.forEach(id => this.logs.ignoreLog(LogType.MESSAGE_DELETE, id)); - this.logs.ignoreLog(LogType.MESSAGE_DELETE_BULK, idsToDelete[0]); - - // Actually delete the messages - await this.bot.deleteMessages(channel.id, idsToDelete); - await this.savedMessages.markBulkAsDeleted(idsToDelete); - - // Create an archive - const archiveId = await this.archives.createFromSavedMessages(savedMessages, this.guild); - const archiveUrl = this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - this.logs.log(LogType.CLEAN, { - mod: stripObjectToScalars(mod), - channel: stripObjectToScalars(channel), - count: savedMessages.length, - archiveUrl, - }); - - return { archiveUrl }; - } - @d.command("clean", "", { options: [ { From dddbf5c096b04a962357e66919bba16f7a44d34c Mon Sep 17 00:00:00 2001 From: roflmaoqwerty <36663568+roflmaoqwerty@users.noreply.github.com> Date: Sat, 23 May 2020 11:53:31 +1000 Subject: [PATCH 04/49] refactored archive searcg into its own method. Updated ban and regular search to use these methods --- backend/src/plugins/Utility.ts | 87 ++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index ff186d68..8ff49e8c 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -633,41 +633,10 @@ export class UtilityPlugin extends ZeppelinPlugin { // If we're exporting the results, we don't need all the fancy schmancy pagination stuff. // Just get the results and dump them in an archive. if (args.export) { - let results; - try { - results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT); - } catch (e) { - if (e instanceof SearchError) { - return this.sendErrorMessage(msg.channel, e.message); - } - - throw e; - } - - if (results.totalResults === 0) { - return this.sendErrorMessage(msg.channel, "No results found"); - } - - const resultList = args.ids - ? this.formatSearchResultIdList(results.results) - : this.formatSearchResultList(results.results); - - const archiveId = await this.archives.create( - trimLines(` - Search results (total ${results.totalResults}): - - ${resultList} - `), - moment().add(1, "hour"), - ); - const url = await this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); - - msg.channel.createMessage(`Exported search results: ${url}`); - - return; + return this.archiveSearch(args, SearchType.MemberSearch, msg); + } else { + return this.displaySearch(args, SearchType.MemberSearch, msg); } - - await this.displaySearchResults(args, SearchType.MemberSearch, msg); } @d.command("bansearch", "[query:string$]", { @@ -728,7 +697,11 @@ export class UtilityPlugin extends ZeppelinPlugin { regex?: boolean; }, ) { - await this.displaySearchResults(args, SearchType.BanSearch, msg); + if (args.export) { + return this.archiveSearch(args, SearchType.BanSearch, msg); + } else { + return this.displaySearch(args, SearchType.BanSearch, msg); + } } async cleanMessages(channel: Channel, savedMessages: SavedMessage[], mod: User) { @@ -761,7 +734,49 @@ export class UtilityPlugin extends ZeppelinPlugin { return { archiveUrl }; } - async displaySearchResults(args: any, searchType: SearchType, msg: Message) { + async archiveSearch(args: any, searchType: SearchType, msg: Message) { + let results; + try { + switch (searchType) { + case SearchType.MemberSearch: + results = await this.performMemberSearch(args, 1, SEARCH_EXPORT_LIMIT); + break; + case SearchType.BanSearch: + results = await this.performBanSearch(args, 1, SEARCH_EXPORT_LIMIT); + break; + } + } catch (e) { + if (e instanceof SearchError) { + return this.sendErrorMessage(msg.channel, e.message); + } + + throw e; + } + + if (results.totalResults === 0) { + return this.sendErrorMessage(msg.channel, "No results found"); + } + + const resultList = args.ids + ? this.formatSearchResultIdList(results.results) + : this.formatSearchResultList(results.results); + + const archiveId = await this.archives.create( + trimLines(` + Search results (total ${results.totalResults}): + + ${resultList} + `), + moment().add(1, "hour"), + ); + const url = await this.archives.getUrl(this.knub.getGlobalConfig().url, archiveId); + + msg.channel.createMessage(`Exported search results: ${url}`); + + return; + } + + async displaySearch(args: any, searchType: SearchType, msg: Message) { // If we're not exporting, load 1 page of search results at a time and allow the user to switch pages with reactions let originalSearchMsg: Message = null; let searching = false; From cb4beacf8a4063d5009fd69c4c89aceedc0b0387 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 00:55:09 +0300 Subject: [PATCH 05/49] automod: add affects_bots --- backend/src/plugins/Automod/Automod.ts | 11 ++++++++--- backend/src/plugins/Automod/types.ts | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index 74cf4ece..cb68664d 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -260,6 +260,10 @@ export class AutomodPlugin extends ZeppelinPlugin { @@ -1524,6 +1527,8 @@ export class AutomodPlugin extends ZeppelinPlugin { if (this.unloaded) return; @@ -1568,6 +1571,8 @@ export class AutomodPlugin extends ZeppelinPlugin Date: Thu, 28 May 2020 01:29:51 +0300 Subject: [PATCH 06/49] Store supporters in the database --- backend/src/data/Supporters.ts | 16 ++++++++ backend/src/data/entities/Supporter.ts | 14 +++++++ .../1590616691907-CreateSupportersTable.ts | 36 +++++++++++++++++ backend/src/plugins/Utility.ts | 40 +++++++++++-------- 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 backend/src/data/Supporters.ts create mode 100644 backend/src/data/entities/Supporter.ts create mode 100644 backend/src/migrations/1590616691907-CreateSupportersTable.ts diff --git a/backend/src/data/Supporters.ts b/backend/src/data/Supporters.ts new file mode 100644 index 00000000..26c294c7 --- /dev/null +++ b/backend/src/data/Supporters.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { Supporter } from "./entities/Supporter"; + +export class Supporters extends BaseRepository { + private supporters: Repository; + + constructor() { + super(); + this.supporters = getRepository(Supporter); + } + + getAll() { + return this.supporters.find(); + } +} diff --git a/backend/src/data/entities/Supporter.ts b/backend/src/data/entities/Supporter.ts new file mode 100644 index 00000000..4189b087 --- /dev/null +++ b/backend/src/data/entities/Supporter.ts @@ -0,0 +1,14 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("supporters") +export class Supporter { + @Column() + @PrimaryColumn() + user_id: string; + + @Column() + name: string; + + @Column() + amount: string | null; +} diff --git a/backend/src/migrations/1590616691907-CreateSupportersTable.ts b/backend/src/migrations/1590616691907-CreateSupportersTable.ts new file mode 100644 index 00000000..16273fbf --- /dev/null +++ b/backend/src/migrations/1590616691907-CreateSupportersTable.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateSupportersTable1590616691907 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "supporters", + columns: [ + { + name: "user_id", + type: "bigint", + unsigned: true, + isPrimary: true, + }, + { + name: "name", + type: "varchar", + length: "255", + }, + { + name: "amount", + type: "decimal", + precision: 6, + scale: 2, + isNullable: true, + default: null, + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("supporters"); + } +} diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index b2802682..d65a2cef 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -76,6 +76,7 @@ declare global { } import { Url, URL, URLSearchParams } from "url"; +import { Supporters } from "../data/Supporters"; const ConfigSchema = t.type({ can_roles: t.boolean, can_level: t.boolean, @@ -137,6 +138,7 @@ export class UtilityPlugin extends ZeppelinPlugin { protected cases: GuildCases; protected savedMessages: GuildSavedMessages; protected archives: GuildArchives; + protected supporters: Supporters; protected lastFullMemberRefresh = 0; protected fullMemberRefreshPromise; @@ -199,6 +201,7 @@ export class UtilityPlugin extends ZeppelinPlugin { this.cases = GuildCases.getGuildInstance(this.guildId); this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); this.archives = GuildArchives.getGuildInstance(this.guildId); + this.supporters = new Supporters(); this.lastReload = Date.now(); @@ -1495,38 +1498,41 @@ export class UtilityPlugin extends ZeppelinPlugin { const loadedPlugins = Array.from(this.knub.getGuildData(this.guildId).loadedPlugins.keys()); loadedPlugins.sort(); - const supporters = [ - ["Flokie", 10], - ["CmdData", 1], - ["JackDaniel", 1], - ]; - supporters.sort(sorter(r => r[1], "DESC")); - const aboutContent: MessageContent = { embed: { title: `About ${this.bot.user.username}`, fields: [ { name: "Status", - value: - basicInfoRows - .map(([label, value]) => { - return `${label}: **${value}**`; - }) - .join("\n") + embedPadding, + value: basicInfoRows + .map(([label, value]) => { + return `${label}: **${value}**`; + }) + .join("\n"), }, { name: `Loaded plugins on this server (${loadedPlugins.length})`, value: loadedPlugins.join(", "), }, - { - name: "Zeppelin supporters 🎉", - value: supporters.map(s => `**${s[0]}** ${s[1]}€/mo`).join("\n"), - }, ], }, }; + const supporters = await this.supporters.getAll(); + supporters.sort( + multiSorter([ + [r => r.amount, "DESC"], + [r => r.name.toLowerCase(), "ASC"], + ]), + ); + + if (supporters.length) { + aboutContent.embed.fields.push({ + name: "Zeppelin supporters 🎉", + value: supporters.map(s => `**${s.name}** ${s.amount && `${s.amount}€/mo`}`).join("\n"), + }); + } + // For the embed color, find the highest colored role the bot has - this is their color on the server as well const botMember = await resolveMember(this.bot, this.guild, this.bot.user.id); let botRoles = botMember.roles.map(r => (msg.channel as GuildChannel).guild.roles.get(r)); From e54fa36df7b09b7fe1d0e171a6a871bea45cec26 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 01:45:18 +0300 Subject: [PATCH 07/49] Improvements to the bot control !guilds command --- backend/src/plugins/BotControl.ts | 76 ++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/backend/src/plugins/BotControl.ts b/backend/src/plugins/BotControl.ts index 1cb5f85a..776a34e0 100644 --- a/backend/src/plugins/BotControl.ts +++ b/backend/src/plugins/BotControl.ts @@ -8,6 +8,7 @@ import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { GuildArchives } from "../data/GuildArchives"; import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; import * as t from "io-ts"; +import escapeStringRegexp from "escape-string-regexp"; let activeReload: [string, string] = null; @@ -121,22 +122,75 @@ export class BotControlPlugin extends GlobalZeppelinPlugin { } } - @d.command("guilds") + @d.command("guilds", "[search:string$]", { + aliases: ["servers"], + options: [ + { + name: "all", + shortcut: "a", + isSwitch: true, + }, + { + name: "initialized", + shortcut: "i", + isSwitch: true, + }, + { + name: "uninitialized", + shortcut: "u", + isSwitch: true, + }, + ], + }) @d.permission("can_use") - async serversCmd(msg: Message) { + async serversCmd( + msg: Message, + args: { search?: string; all?: boolean; initialized?: boolean; uninitialized?: boolean }, + ) { + const showList = Boolean(args.all || args.initialized || args.uninitialized || args.search); + const search = args.search && new RegExp([...args.search].map(s => escapeStringRegexp(s)).join(".*"), "i"); + const joinedGuilds = Array.from(this.bot.guilds.values()); const loadedGuilds = this.knub.getLoadedGuilds(); const loadedGuildsMap = loadedGuilds.reduce((map, guildData) => map.set(guildData.id, guildData), new Map()); - joinedGuilds.sort(sorter(g => g.name.toLowerCase())); - const longestId = joinedGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0); - const lines = joinedGuilds.map(g => { - const paddedId = g.id.padEnd(longestId, " "); - return `\`${paddedId}\` **${g.name}** (${loadedGuildsMap.has(g.id) ? "initialized" : "not initialized"}) (${ - g.memberCount - } members)`; - }); - createChunkedMessage(msg.channel, lines.join("\n")); + if (showList) { + let filteredGuilds = Array.from(joinedGuilds); + + if (args.initialized) { + filteredGuilds = filteredGuilds.filter(g => loadedGuildsMap.has(g.id)); + } + + if (args.uninitialized) { + filteredGuilds = filteredGuilds.filter(g => !loadedGuildsMap.has(g.id)); + } + + if (args.search) { + filteredGuilds = filteredGuilds.filter(g => search.test(`${g.id} ${g.name}`)); + } + + if (filteredGuilds.length) { + filteredGuilds.sort(sorter(g => g.name.toLowerCase())); + const longestId = filteredGuilds.reduce((longest, guild) => Math.max(longest, guild.id.length), 0); + const lines = filteredGuilds.map(g => { + const paddedId = g.id.padEnd(longestId, " "); + return `\`${paddedId}\` **${g.name}** (${loadedGuildsMap.has(g.id) ? "initialized" : "not initialized"}) (${ + g.memberCount + } members)`; + }); + createChunkedMessage(msg.channel, lines.join("\n")); + } else { + msg.channel.createMessage("No servers matched the filters"); + } + } else { + const total = joinedGuilds.length; + const initialized = joinedGuilds.filter(g => loadedGuildsMap.has(g.id)).length; + const unInitialized = total - initialized; + + msg.channel.createMessage( + `I am on **${total} total servers**, of which **${initialized} are initialized** and **${unInitialized} are not initialized**`, + ); + } } @d.command("leave_guild", "") From 64d633c82077347c69c9602d39359b67986f816b Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 01:50:26 +0300 Subject: [PATCH 08/49] Optimize !server by removing status counts --- backend/src/plugins/Utility.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index d65a2cef..dbd9c570 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -1072,8 +1072,6 @@ export class UtilityPlugin extends ZeppelinPlugin { }) @d.permission("can_server") async serverCmd(msg: Message) { - await this.refreshMembersIfNeeded(); - const embed: EmbedOptions = { fields: [], color: parseInt("6b80cf", 16), @@ -1124,10 +1122,6 @@ export class UtilityPlugin extends ZeppelinPlugin { : this.guild.members.filter(m => m.status !== "offline").length; const offlineMemberCount = this.guild.memberCount - onlineMemberCount; - const onlineStatusMemberCount = this.guild.members.filter(m => m.status === "online").length; - const dndStatusMemberCount = this.guild.members.filter(m => m.status === "dnd").length; - const idleStatusMemberCount = this.guild.members.filter(m => m.status === "idle").length; - let memberCountTotalLines = `Total: **${formatNumber(totalMembers)}**`; if (restGuild.maxMembers) { memberCountTotalLines += `\nMax: **${formatNumber(restGuild.maxMembers)}**`; @@ -1145,9 +1139,6 @@ export class UtilityPlugin extends ZeppelinPlugin { ${memberCountTotalLines} ${memberCountOnlineLines} Offline: **${formatNumber(offlineMemberCount)}** - <:zep_online:665907874450636810> Online: **${formatNumber(onlineStatusMemberCount)}** - <:zep_idle:665908128331726848> Idle: **${formatNumber(idleStatusMemberCount)}** - <:zep_dnd:665908138741858365> DND: **${formatNumber(dndStatusMemberCount)}** `), }); From e4aa101edd65d2a97ab260716fa4118c52d4d061 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 01:54:38 +0300 Subject: [PATCH 09/49] Lower message retention period to 1 day --- backend/src/data/GuildSavedMessages.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 8507a0dd..340fbd0f 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -4,10 +4,15 @@ import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage"; import { QueuedEventEmitter } from "../QueuedEventEmitter"; import { GuildChannel, Message } from "eris"; import moment from "moment-timezone"; +import { DAYS, MINUTES } from "../utils"; -const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 min +const CLEANUP_INTERVAL = 5 * MINUTES; -const RETENTION_PERIOD = 5 * 24 * 60 * 60 * 1000; // 5 days +/** + * How long message edits, deletions, etc. will include the original message content. + * This is very heavy storage-wise, so keeping it as low as possible is ideal. + */ +const RETENTION_PERIOD = 1 * DAYS; async function cleanup() { const repository = getRepository(SavedMessage); From 8f71e51041786810d3ac96de8dc929fe654df6aa Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:30:03 +0300 Subject: [PATCH 10/49] Small tweak to message cleanup --- backend/src/data/GuildSavedMessages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 340fbd0f..f3398270 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -32,6 +32,7 @@ async function cleanup() { qb.andWhere(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`); }), ) + .limit(100_000) // To avoid long table locks, delete max 100,000 rows at once .delete() .execute(); From 51a16ee1aa57fee003b6abf690b306863de8d7d6 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:34:26 +0300 Subject: [PATCH 11/49] Fix --- backend/src/data/GuildSavedMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index f3398270..9ba23963 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -12,7 +12,7 @@ const CLEANUP_INTERVAL = 5 * MINUTES; * How long message edits, deletions, etc. will include the original message content. * This is very heavy storage-wise, so keeping it as low as possible is ideal. */ -const RETENTION_PERIOD = 1 * DAYS; +const RETENTION_PERIOD = 4 * DAYS; async function cleanup() { const repository = getRepository(SavedMessage); From ca9af4f24e6141fc8ecb95504c37c11ef08d7199 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:37:14 +0300 Subject: [PATCH 12/49] Fix 2 --- backend/src/data/GuildSavedMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 9ba23963..f3398270 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -12,7 +12,7 @@ const CLEANUP_INTERVAL = 5 * MINUTES; * How long message edits, deletions, etc. will include the original message content. * This is very heavy storage-wise, so keeping it as low as possible is ideal. */ -const RETENTION_PERIOD = 4 * DAYS; +const RETENTION_PERIOD = 1 * DAYS; async function cleanup() { const repository = getRepository(SavedMessage); From 1ff86defc3885872fd9775be8ff2778c71ef7fc2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:39:04 +0300 Subject: [PATCH 13/49] Fix 3 --- backend/src/data/GuildSavedMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index f3398270..ab79fdff 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -32,7 +32,7 @@ async function cleanup() { qb.andWhere(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`); }), ) - .limit(100_000) // To avoid long table locks, delete max 100,000 rows at once + .limit(50_000) // To avoid long table locks, limit the amount of messages deleted at once .delete() .execute(); From 6cd07ed696bc50c07d13766a3d2d9ef5ff4fe5b7 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:45:07 +0300 Subject: [PATCH 14/49] =?UTF-8?q?Don'=C3=83t=20run=20message=20cleanup=20q?= =?UTF-8?q?ueries=20in=20the=20API=20process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/index.ts | 3 +++ backend/src/data/GuildSavedMessages.ts | 7 +++++-- backend/src/globals.ts | 9 +++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 backend/src/globals.ts diff --git a/backend/src/api/index.ts b/backend/src/api/index.ts index 575b8b51..97049892 100644 --- a/backend/src/api/index.ts +++ b/backend/src/api/index.ts @@ -1,5 +1,6 @@ import { connect } from "../data/db"; import path from "path"; +import { setIsAPI } from "../globals"; require("dotenv").config({ path: path.resolve(process.cwd(), "api.env") }); @@ -10,6 +11,8 @@ function errorHandler(err) { process.on("unhandledRejection", errorHandler); +setIsAPI(true); + // Connect to the database before loading the rest of the code (that depend on the database connection) console.log("Connecting to database..."); // tslint:disable-line connect().then(() => { diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index ab79fdff..58e5210b 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -5,6 +5,7 @@ import { QueuedEventEmitter } from "../QueuedEventEmitter"; import { GuildChannel, Message } from "eris"; import moment from "moment-timezone"; import { DAYS, MINUTES } from "../utils"; +import { isAPI } from "../globals"; const CLEANUP_INTERVAL = 5 * MINUTES; @@ -39,8 +40,10 @@ async function cleanup() { setTimeout(cleanup, CLEANUP_INTERVAL); } -// Start first cleanup 30 seconds after startup -setTimeout(cleanup, 30 * 1000); +if (!isAPI()) { + // Start first cleanup 30 seconds after startup + setTimeout(cleanup, 30 * 1000); +} export class GuildSavedMessages extends BaseGuildRepository { private messages: Repository; diff --git a/backend/src/globals.ts b/backend/src/globals.ts new file mode 100644 index 00000000..0c5abcd4 --- /dev/null +++ b/backend/src/globals.ts @@ -0,0 +1,9 @@ +let isAPIValue = false; + +export function isAPI() { + return isAPIValue; +} + +export function setIsAPI(value: boolean) { + isAPIValue = value; +} From 26f1042b8e00264ba1e22638f5beb2abafa9f1c9 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:50:07 +0300 Subject: [PATCH 15/49] Retain bot messages for a shorter time, raise deletion limit --- backend/src/data/GuildSavedMessages.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 58e5210b..9601b38d 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -14,6 +14,7 @@ const CLEANUP_INTERVAL = 5 * MINUTES; * This is very heavy storage-wise, so keeping it as low as possible is ideal. */ const RETENTION_PERIOD = 1 * DAYS; +const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; async function cleanup() { const repository = getRepository(SavedMessage); @@ -29,11 +30,19 @@ async function cleanup() { .orWhere( // Clear old messages new Brackets(qb => { - qb.where("is_permanent = 0"); - qb.andWhere(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`); + qb.where(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`); + qb.andWhere("is_permanent = 0"); }), ) - .limit(50_000) // To avoid long table locks, limit the amount of messages deleted at once + .orWhere( + // Clear old bot messages + new Brackets(qb => { + qb.where("is_bot = 1"); + qb.andWhere(`posted_at <= (NOW() - INTERVAL ${BOT_MESSAGE_RETENTION_PERIOD}000 MICROSECOND)`); + qb.andWhere("is_permanent = 0"); + }), + ) + .limit(100_000) // To avoid long table locks, limit the amount of messages deleted at once .delete() .execute(); From bf3cae2201972bc2ca1a1b184fc09da3f5a365ec Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 02:52:31 +0300 Subject: [PATCH 16/49] Run message cleanup more frequently with a lower limit --- backend/src/data/GuildSavedMessages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 9601b38d..690d8ca2 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -7,7 +7,7 @@ import moment from "moment-timezone"; import { DAYS, MINUTES } from "../utils"; import { isAPI } from "../globals"; -const CLEANUP_INTERVAL = 5 * MINUTES; +const CLEANUP_INTERVAL = 1 * MINUTES; /** * How long message edits, deletions, etc. will include the original message content. @@ -42,7 +42,7 @@ async function cleanup() { qb.andWhere("is_permanent = 0"); }), ) - .limit(100_000) // To avoid long table locks, limit the amount of messages deleted at once + .limit(10_000) // To avoid long table locks, limit the amount of messages deleted at once .delete() .execute(); From d781c6c3b47fd980f0eb7b8e1fcf9e6356b2e206 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 03:09:27 +0300 Subject: [PATCH 17/49] Fix deletion limit in message cleanup --- backend/src/data/GuildSavedMessages.ts | 42 +++++++++++--------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 690d8ca2..47366b05 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -6,6 +6,7 @@ import { GuildChannel, Message } from "eris"; import moment from "moment-timezone"; import { DAYS, MINUTES } from "../utils"; import { isAPI } from "../globals"; +import { connection } from "./db"; const CLEANUP_INTERVAL = 1 * MINUTES; @@ -17,34 +18,25 @@ const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; async function cleanup() { - const repository = getRepository(SavedMessage); - await repository - .createQueryBuilder("messages") - .where( - // Clear deleted messages - new Brackets(qb => { - qb.where("deleted_at IS NOT NULL"); - qb.andWhere(`deleted_at <= (NOW() - INTERVAL ${CLEANUP_INTERVAL}000 MICROSECOND)`); - }), + const query = ` + DELETE FROM messages + WHERE ( + deleted_at IS NOT NULL + AND deleted_at <= (NOW() - INTERVAL ${CLEANUP_INTERVAL}000 MICROSECOND) ) - .orWhere( - // Clear old messages - new Brackets(qb => { - qb.where(`posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND)`); - qb.andWhere("is_permanent = 0"); - }), + OR ( + posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND) + AND is_permanent = 0 ) - .orWhere( - // Clear old bot messages - new Brackets(qb => { - qb.where("is_bot = 1"); - qb.andWhere(`posted_at <= (NOW() - INTERVAL ${BOT_MESSAGE_RETENTION_PERIOD}000 MICROSECOND)`); - qb.andWhere("is_permanent = 0"); - }), + OR ( + is_bot = 1 + AND posted_at <= (NOW() - INTERVAL ${BOT_MESSAGE_RETENTION_PERIOD}000 MICROSECOND) + AND is_permanent = 0 ) - .limit(10_000) // To avoid long table locks, limit the amount of messages deleted at once - .delete() - .execute(); + LIMIT ${50_000} + `; + + await connection.query(query); setTimeout(cleanup, CLEANUP_INTERVAL); } From a011d4524dc0f6a6a2dbec038c1de67e4e16c298 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 03:13:34 +0300 Subject: [PATCH 18/49] Push cleanup interval back to 5min --- backend/src/data/GuildSavedMessages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 47366b05..394035fe 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -8,7 +8,7 @@ import { DAYS, MINUTES } from "../utils"; import { isAPI } from "../globals"; import { connection } from "./db"; -const CLEANUP_INTERVAL = 1 * MINUTES; +const CLEANUP_INTERVAL = 5 * MINUTES; /** * How long message edits, deletions, etc. will include the original message content. From 9b263957a3dad12292796daaff3f814be83c470c Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 03:15:06 +0300 Subject: [PATCH 19/49] RATELIMITED -> 429 --- backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 45bba7f7..80e87cbc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -100,7 +100,7 @@ connect().then(async conn => { client.on("debug", message => { if (message.includes(" 429 ")) { - logger.info(`[RATELIMITED] ${message}`); + logger.info(`[429] ${message}`); } }); From 1fcf57cf13e71c35d3d34847f91e759d00e98a80 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 03:28:25 +0300 Subject: [PATCH 20/49] automod: add guild info to matched invites --- backend/src/plugins/Automod/Automod.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index cb68664d..e4aa1344 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -428,9 +428,9 @@ export class AutomodPlugin extends ZeppelinPlugin { + protected async evaluateMatchInvitesTrigger(trigger: TMatchInvitesTrigger, str: string): Promise { const inviteCodes = getInviteCodesInString(str); if (inviteCodes.length === 0) return null; @@ -438,22 +438,22 @@ export class AutomodPlugin extends ZeppelinPlugin Date: Thu, 28 May 2020 04:01:07 +0300 Subject: [PATCH 21/49] automod: fix match_attachment_type no longer matching on messages with no text content --- backend/src/plugins/Automod/Automod.ts | 37 +++++++++++++++++--------- backend/src/plugins/Automod/types.ts | 6 ----- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index e4aa1344..780dab9f 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -125,12 +125,6 @@ const defaultMatchAttachmentTypeTrigger: Partial = blacklist_enabled: false, filetype_whitelist: [], whitelist_enabled: false, - match_messages: true, - match_embeds: true, - match_visible_names: false, - match_usernames: false, - match_nicknames: false, - match_custom_status: false, }; const defaultTextSpamTrigger: Partial> = { @@ -541,17 +535,26 @@ export class AutomodPlugin extends ZeppelinPlugin { - return this.evaluateMatchAttachmentTypeTrigger(trigger.match_attachment_type, msg); - }); - if (match) return { ...match, trigger: "match_attachment_type" } as TextTriggerMatchResult; + const match = this.evaluateMatchAttachmentTypeTrigger(trigger.match_attachment_type, msg); + // TODO: Add "attachment" type + if (match) { + const messageInfo: MessageInfo = { channelId: msg.channel_id, messageId: msg.id, userId: msg.user_id }; + return { + type: "message", + userId: msg.user_id, + messageInfo, + ...match, + trigger: "match_attachment_type", + }; + } } if (trigger.message_spam) { diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 153d94ee..dbda6dad 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -189,12 +189,6 @@ export const MatchAttachmentTypeTrigger = t.type({ blacklist_enabled: t.boolean, filetype_whitelist: t.array(t.string), whitelist_enabled: t.boolean, - match_messages: t.boolean, - match_embeds: t.boolean, - match_visible_names: t.boolean, - match_usernames: t.boolean, - match_nicknames: t.boolean, - match_custom_status: t.boolean, }); export type TMatchAttachmentTypeTrigger = t.TypeOf; From 213db4d168714e716b2cef53d4b6671f355dc546 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 28 May 2020 04:40:35 +0300 Subject: [PATCH 22/49] mod_actions: add warn_notify_enabled option, false by default --- backend/src/plugins/ModActions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index 5518c396..6079c383 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -46,6 +46,7 @@ const ConfigSchema = t.type({ ban_message: tNullable(t.string), alert_on_rejoin: t.boolean, alert_channel: tNullable(t.string), + warn_notify_enabled: t.boolean, warn_notify_threshold: t.number, warn_notify_message: t.string, ban_delete_message_days: t.number, @@ -166,6 +167,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", alert_on_rejoin: false, alert_channel: null, + warn_notify_enabled: false, warn_notify_threshold: 5, warn_notify_message: "The user already has **{priorWarnings}** warnings!\n Please check their prior cases and assess whether or not to warn anyways.\n Proceed with the warning?", @@ -690,7 +692,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { const casesPlugin = this.getPlugin("cases"); const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn); - if (priorWarnAmount >= config.warn_notify_threshold) { + if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) { const tooManyWarningsMsg = await msg.channel.createMessage( config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`), ); From 23a7b21698cb37d8b92e2b0eb0087ed5a26409ae Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 30 May 2020 03:54:03 +0300 Subject: [PATCH 23/49] Fix null values in supporter amounts --- backend/src/plugins/Utility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/plugins/Utility.ts b/backend/src/plugins/Utility.ts index dbd9c570..8e5a63d1 100644 --- a/backend/src/plugins/Utility.ts +++ b/backend/src/plugins/Utility.ts @@ -1520,7 +1520,7 @@ export class UtilityPlugin extends ZeppelinPlugin { if (supporters.length) { aboutContent.embed.fields.push({ name: "Zeppelin supporters 🎉", - value: supporters.map(s => `**${s.name}** ${s.amount && `${s.amount}€/mo`}`).join("\n"), + value: supporters.map(s => `**${s.name}** ${s.amount ? `${s.amount}€/mo` : ""}`.trim()).join("\n"), }); } From 0eb6a0ef9714c45d3aab7902718011eaed0f87bb Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 30 May 2020 22:30:44 +0300 Subject: [PATCH 24/49] Tweaks to avoid deadlocks in GuildSavedMessages --- backend/src/data/GuildSavedMessages.ts | 39 +++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index 394035fe..d9e90d4e 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -4,11 +4,12 @@ import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage"; import { QueuedEventEmitter } from "../QueuedEventEmitter"; import { GuildChannel, Message } from "eris"; import moment from "moment-timezone"; -import { DAYS, MINUTES } from "../utils"; +import { DAYS, DBDateFormat, MINUTES, SECONDS } from "../utils"; import { isAPI } from "../globals"; import { connection } from "./db"; const CLEANUP_INTERVAL = 5 * MINUTES; +let cleanupPromise = Promise.resolve(); /** * How long message edits, deletions, etc. will include the original message content. @@ -18,32 +19,45 @@ const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; async function cleanup() { + const deletedAtThreshold = moment() + .subtract(CLEANUP_INTERVAL, "ms") + .format(DBDateFormat); + const postedAtThreshold = moment() + .subtract(RETENTION_PERIOD, "ms") + .format(DBDateFormat); + const botPostedAtThreshold = moment() + .subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms") + .format(DBDateFormat); + const query = ` DELETE FROM messages WHERE ( deleted_at IS NOT NULL - AND deleted_at <= (NOW() - INTERVAL ${CLEANUP_INTERVAL}000 MICROSECOND) + AND deleted_at <= ? ) OR ( - posted_at <= (NOW() - INTERVAL ${RETENTION_PERIOD}000 MICROSECOND) + posted_at <= ? AND is_permanent = 0 ) OR ( is_bot = 1 - AND posted_at <= (NOW() - INTERVAL ${BOT_MESSAGE_RETENTION_PERIOD}000 MICROSECOND) + AND posted_at <= ? AND is_permanent = 0 ) - LIMIT ${50_000} + LIMIT ${25_000} `; - await connection.query(query); + cleanupPromise = (async () => { + await connection.query(query, [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold]); + })(); + await cleanupPromise; setTimeout(cleanup, CLEANUP_INTERVAL); } if (!isAPI()) { // Start first cleanup 30 seconds after startup - setTimeout(cleanup, 30 * 1000); + setTimeout(cleanup, 30 * SECONDS); } export class GuildSavedMessages extends BaseGuildRepository { @@ -152,6 +166,8 @@ export class GuildSavedMessages extends BaseGuildRepository { this.toBePermanent.delete(data.id); } + await cleanupPromise; + try { await this.messages.insert(data); } catch (e) { @@ -168,6 +184,8 @@ export class GuildSavedMessages extends BaseGuildRepository { const existingSavedMsg = await this.find(msg.id); if (existingSavedMsg) return; + await cleanupPromise; + const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS"); @@ -185,6 +203,8 @@ export class GuildSavedMessages extends BaseGuildRepository { } async markAsDeleted(id) { + await cleanupPromise; + await this.messages .createQueryBuilder("messages") .update() @@ -208,6 +228,8 @@ export class GuildSavedMessages extends BaseGuildRepository { * If any messages were marked as deleted, also emits the deleteBulk event. */ async markBulkAsDeleted(ids) { + await cleanupPromise; + const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss.SSS"); await this.messages @@ -234,6 +256,8 @@ export class GuildSavedMessages extends BaseGuildRepository { const oldMessage = await this.messages.findOne(id); if (!oldMessage) return; + await cleanupPromise; + const newMessage = { ...oldMessage, data: newData }; await this.messages.update( @@ -255,6 +279,7 @@ export class GuildSavedMessages extends BaseGuildRepository { async setPermanent(id: string) { const savedMsg = await this.find(id); if (savedMsg) { + await cleanupPromise; await this.messages.update( { id }, { From 5a2948d8a9d07dea5a8eaa2d105724988bca0f7b Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 1 Jun 2020 19:41:05 +0300 Subject: [PATCH 25/49] Bind tag functions to the tagFunctions object so it's easier to cross-reference tags --- backend/src/plugins/Tags.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/plugins/Tags.ts b/backend/src/plugins/Tags.ts index 73a0e583..43057703 100644 --- a/backend/src/plugins/Tags.ts +++ b/backend/src/plugins/Tags.ts @@ -222,6 +222,10 @@ export class TagsPlugin extends ZeppelinPlugin { return ""; }, }; + + for (const [name, fn] of Object.entries(this.tagFunctions)) { + this.tagFunctions[name] = (fn as any).bind(this.tagFunctions); + } } onUnload() { From 97d144e9b43795284062cab3684b0da58e4fc5ca Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 1 Jun 2020 19:44:28 +0300 Subject: [PATCH 26/49] Properly fix countdown() in tags --- backend/src/plugins/Tags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/plugins/Tags.ts b/backend/src/plugins/Tags.ts index 43057703..01c95f65 100644 --- a/backend/src/plugins/Tags.ts +++ b/backend/src/plugins/Tags.ts @@ -133,7 +133,7 @@ export class TagsPlugin extends ZeppelinPlugin { }, countdown(toDate) { - const target = this.parseDateTime(toDate); + const target = moment(this.parseDateTime(toDate)); const now = moment(); if (!target.isValid()) return ""; From 80f6f69ccd5d2496a9d97cb199b9e1b72a98aa9a Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 1 Jun 2020 21:28:07 +0300 Subject: [PATCH 27/49] Message cleanup tweaks --- backend/src/data/GuildSavedMessages.ts | 72 +++++++++++++------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index d9e90d4e..fcc6cb02 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -1,4 +1,4 @@ -import { Brackets, getRepository, Repository } from "typeorm"; +import { getRepository, In, Repository } from "typeorm"; import { BaseGuildRepository } from "./BaseGuildRepository"; import { ISavedMessageData, SavedMessage } from "./entities/SavedMessage"; import { QueuedEventEmitter } from "../QueuedEventEmitter"; @@ -9,7 +9,6 @@ import { isAPI } from "../globals"; import { connection } from "./db"; const CLEANUP_INTERVAL = 5 * MINUTES; -let cleanupPromise = Promise.resolve(); /** * How long message edits, deletions, etc. will include the original message content. @@ -17,8 +16,11 @@ let cleanupPromise = Promise.resolve(); */ const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; +const CLEAN_PER_LOOP = 250; async function cleanup() { + const repo = getRepository(SavedMessage); + const deletedAtThreshold = moment() .subtract(CLEANUP_INTERVAL, "ms") .format(DBDateFormat); @@ -29,28 +31,39 @@ async function cleanup() { .subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms") .format(DBDateFormat); - const query = ` - DELETE FROM messages - WHERE ( - deleted_at IS NOT NULL - AND deleted_at <= ? - ) - OR ( - posted_at <= ? - AND is_permanent = 0 - ) - OR ( - is_bot = 1 - AND posted_at <= ? - AND is_permanent = 0 - ) - LIMIT ${25_000} - `; + // SELECT + DELETE messages in batches + // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below + // when a message was being inserted at the same time + let rows; + do { + rows = await connection.query( + ` + SELECT id + FROM messages + WHERE ( + deleted_at IS NOT NULL + AND deleted_at <= ? + ) + OR ( + posted_at <= ? + AND is_permanent = 0 + ) + OR ( + is_bot = 1 + AND posted_at <= ? + AND is_permanent = 0 + ) + LIMIT ${CLEAN_PER_LOOP} + `, + [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold], + ); - cleanupPromise = (async () => { - await connection.query(query, [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold]); - })(); - await cleanupPromise; + if (rows.length > 0) { + await repo.delete({ + id: In(rows.map(r => r.id)), + }); + } + } while (rows.length === CLEAN_PER_LOOP); setTimeout(cleanup, CLEANUP_INTERVAL); } @@ -139,8 +152,8 @@ export class GuildSavedMessages extends BaseGuildRepository { let query = this.messages .createQueryBuilder() .where("guild_id = :guild_id", { guild_id: this.guildId }) - .andWhere("user_id = :user_id", { user_id: userId }) .andWhere("channel_id = :channel_id", { channel_id: channelId }) + .andWhere("user_id = :user_id", { user_id: userId }) .andWhere("id > :afterId", { afterId }) .andWhere("deleted_at IS NULL"); @@ -166,8 +179,6 @@ export class GuildSavedMessages extends BaseGuildRepository { this.toBePermanent.delete(data.id); } - await cleanupPromise; - try { await this.messages.insert(data); } catch (e) { @@ -184,8 +195,6 @@ export class GuildSavedMessages extends BaseGuildRepository { const existingSavedMsg = await this.find(msg.id); if (existingSavedMsg) return; - await cleanupPromise; - const savedMessageData = this.msgToSavedMessageData(msg); const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS"); @@ -203,8 +212,6 @@ export class GuildSavedMessages extends BaseGuildRepository { } async markAsDeleted(id) { - await cleanupPromise; - await this.messages .createQueryBuilder("messages") .update() @@ -228,8 +235,6 @@ export class GuildSavedMessages extends BaseGuildRepository { * If any messages were marked as deleted, also emits the deleteBulk event. */ async markBulkAsDeleted(ids) { - await cleanupPromise; - const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss.SSS"); await this.messages @@ -256,8 +261,6 @@ export class GuildSavedMessages extends BaseGuildRepository { const oldMessage = await this.messages.findOne(id); if (!oldMessage) return; - await cleanupPromise; - const newMessage = { ...oldMessage, data: newData }; await this.messages.update( @@ -279,7 +282,6 @@ export class GuildSavedMessages extends BaseGuildRepository { async setPermanent(id: string) { const savedMsg = await this.find(id); if (savedMsg) { - await cleanupPromise; await this.messages.update( { id }, { From a6e650810cd7643987d742ca95770e518a609b39 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Mon, 1 Jun 2020 22:21:42 +0300 Subject: [PATCH 28/49] DB optimizations --- .../1591036185142-OptimizeMessageIndices.ts | 45 +++++++++++++++++++ ...1591038041635-OptimizeMessageTimestamps.ts | 21 +++++++++ 2 files changed, 66 insertions(+) create mode 100644 backend/src/migrations/1591036185142-OptimizeMessageIndices.ts create mode 100644 backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts diff --git a/backend/src/migrations/1591036185142-OptimizeMessageIndices.ts b/backend/src/migrations/1591036185142-OptimizeMessageIndices.ts new file mode 100644 index 00000000..46ecb42d --- /dev/null +++ b/backend/src/migrations/1591036185142-OptimizeMessageIndices.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; + +export class OptimizeMessageIndices1591036185142 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // guild_id, channel_id, user_id indices -> composite(guild_id, channel_id, user_id) + await queryRunner.dropIndex("messages", "IDX_b193588441b085352a4c010942"); // guild_id + await queryRunner.dropIndex("messages", "IDX_86b9109b155eb70c0a2ca3b4b6"); // channel_id + await queryRunner.dropIndex("messages", "IDX_830a3c1d92614d1495418c4673"); // user_id + await queryRunner.createIndex( + "messages", + new TableIndex({ + columnNames: ["guild_id", "channel_id", "user_id"], + }), + ); + + // posted_at, is_permanent indices -> composite(posted_at, is_permanent) + await queryRunner.dropIndex("messages", "IDX_08e1f5a0fef0175ea402c6b2ac"); // posted_at + await queryRunner.dropIndex("messages", "IDX_f520029c07824f8d96c6cd98e8"); // is_permanent + await queryRunner.createIndex( + "messages", + new TableIndex({ + columnNames: ["posted_at", "is_permanent"], + }), + ); + + // is_bot -> no index (the database doesn't appear to use this index anyway) + await queryRunner.dropIndex("messages", "IDX_eec2c581ff6f13595902c31840"); + } + + public async down(queryRunner: QueryRunner): Promise { + // no index -> is_bot index + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["is_bot"] })); + + // composite(posted_at, is_permanent) -> posted_at, is_permanent indices + await queryRunner.dropIndex("messages", "IDX_afe125bfd65341cd90eee0b310"); // composite(posted_at, is_permanent) + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["posted_at"] })); + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["is_permanent"] })); + + // composite(guild_id, channel_id, user_id) -> guild_id, channel_id, user_id indices + await queryRunner.dropIndex("messages", "IDX_dedc3ea6396e1de8ac75533589"); // composite(guild_id, channel_id, user_id) + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["guild_id"] })); + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["channel_id"] })); + await queryRunner.createIndex("messages", new TableIndex({ columnNames: ["user_id"] })); + } +} diff --git a/backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts b/backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts new file mode 100644 index 00000000..80bdbc96 --- /dev/null +++ b/backend/src/migrations/1591038041635-OptimizeMessageTimestamps.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class OptimizeMessageTimestamps1591038041635 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // DATETIME(3) -> DATETIME(0) + await queryRunner.query(` + ALTER TABLE \`messages\` + CHANGE COLUMN \`posted_at\` \`posted_at\` DATETIME(0) NOT NULL AFTER \`data\`, + CHANGE COLUMN \`deleted_at\` \`deleted_at\` DATETIME(0) NULL DEFAULT NULL AFTER \`posted_at\` + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // DATETIME(0) -> DATETIME(3) + await queryRunner.query(` + ALTER TABLE \`messages\` + CHANGE COLUMN \`posted_at\` \`posted_at\` DATETIME(3) NOT NULL AFTER \`data\`, + CHANGE COLUMN \`deleted_at\` \`deleted_at\` DATETIME(3) NULL DEFAULT NULL AFTER \`posted_at\` + `); + } +} From de715207470e10c2353f7b674de6764482569af7 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 00:26:06 +0300 Subject: [PATCH 29/49] Add username/nickname history retention periods --- backend/src/data/GuildNicknameHistory.ts | 50 ++++++++++------- backend/src/data/GuildSavedMessages.ts | 68 +++--------------------- backend/src/data/UsernameHistory.ts | 48 ++++++++++------- backend/src/data/cleanup/messages.ts | 68 ++++++++++++++++++++++++ backend/src/data/cleanup/nicknames.ts | 41 ++++++++++++++ backend/src/data/cleanup/usernames.ts | 45 ++++++++++++++++ backend/src/plugins/NameHistory.ts | 30 ++++++++--- backend/src/plugins/UsernameSaver.ts | 14 ++--- 8 files changed, 253 insertions(+), 111 deletions(-) create mode 100644 backend/src/data/cleanup/messages.ts create mode 100644 backend/src/data/cleanup/nicknames.ts create mode 100644 backend/src/data/cleanup/usernames.ts diff --git a/backend/src/data/GuildNicknameHistory.ts b/backend/src/data/GuildNicknameHistory.ts index 58ed7273..91d9d533 100644 --- a/backend/src/data/GuildNicknameHistory.ts +++ b/backend/src/data/GuildNicknameHistory.ts @@ -1,7 +1,22 @@ import { BaseGuildRepository } from "./BaseGuildRepository"; -import { getRepository, Repository } from "typeorm"; +import { getRepository, In, Repository } from "typeorm"; import { NicknameHistoryEntry } from "./entities/NicknameHistoryEntry"; -import { sorter } from "../utils"; +import { MINUTES, SECONDS, sorter } from "../utils"; +import { MAX_USERNAME_ENTRIES_PER_USER } from "./UsernameHistory"; +import { isAPI } from "../globals"; +import { cleanupNicknames } from "./cleanup/nicknames"; + +if (!isAPI()) { + const CLEANUP_INTERVAL = 5 * MINUTES; + + async function cleanup() { + await cleanupNicknames(); + setTimeout(cleanup, CLEANUP_INTERVAL); + } + + // Start first cleanup 30 seconds after startup + setTimeout(cleanup, 30 * SECONDS); +} export const MAX_NICKNAME_ENTRIES_PER_USER = 10; @@ -44,25 +59,20 @@ export class GuildNicknameHistory extends BaseGuildRepository { nickname, }); - // Cleanup (leave only the last MAX_NICKNAME_ENTRIES_PER_USER entries) - const lastEntries = await this.getByUserId(userId); - if (lastEntries.length > MAX_NICKNAME_ENTRIES_PER_USER) { - const earliestEntry = lastEntries - .sort(sorter("timestamp", "DESC")) - .slice(0, 10) - .reduce((earliest, entry) => { - if (earliest == null) return entry; - if (entry.id < earliest.id) return entry; - return earliest; - }, null); + // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries) + const toDelete = await this.nicknameHistory + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("user_id = :userId", { userId }) + .orderBy("id", "DESC") + .skip(MAX_USERNAME_ENTRIES_PER_USER) + .take(99_999) + .getMany(); - this.nicknameHistory - .createQueryBuilder() - .where("guild_id = :guildId", { guildId: this.guildId }) - .andWhere("user_id = :userId", { userId }) - .andWhere("id < :id", { id: earliestEntry.id }) - .delete() - .execute(); + if (toDelete.length > 0) { + await this.nicknameHistory.delete({ + id: In(toDelete.map(v => v.id)), + }); } } } diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index fcc6cb02..a052ef66 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -7,68 +7,16 @@ import moment from "moment-timezone"; import { DAYS, DBDateFormat, MINUTES, SECONDS } from "../utils"; import { isAPI } from "../globals"; import { connection } from "./db"; - -const CLEANUP_INTERVAL = 5 * MINUTES; - -/** - * How long message edits, deletions, etc. will include the original message content. - * This is very heavy storage-wise, so keeping it as low as possible is ideal. - */ -const RETENTION_PERIOD = 1 * DAYS; -const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; -const CLEAN_PER_LOOP = 250; - -async function cleanup() { - const repo = getRepository(SavedMessage); - - const deletedAtThreshold = moment() - .subtract(CLEANUP_INTERVAL, "ms") - .format(DBDateFormat); - const postedAtThreshold = moment() - .subtract(RETENTION_PERIOD, "ms") - .format(DBDateFormat); - const botPostedAtThreshold = moment() - .subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms") - .format(DBDateFormat); - - // SELECT + DELETE messages in batches - // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below - // when a message was being inserted at the same time - let rows; - do { - rows = await connection.query( - ` - SELECT id - FROM messages - WHERE ( - deleted_at IS NOT NULL - AND deleted_at <= ? - ) - OR ( - posted_at <= ? - AND is_permanent = 0 - ) - OR ( - is_bot = 1 - AND posted_at <= ? - AND is_permanent = 0 - ) - LIMIT ${CLEAN_PER_LOOP} - `, - [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold], - ); - - if (rows.length > 0) { - await repo.delete({ - id: In(rows.map(r => r.id)), - }); - } - } while (rows.length === CLEAN_PER_LOOP); - - setTimeout(cleanup, CLEANUP_INTERVAL); -} +import { cleanupMessages } from "./cleanup/messages"; if (!isAPI()) { + const CLEANUP_INTERVAL = 5 * MINUTES; + + async function cleanup() { + await cleanupMessages(); + setTimeout(cleanup, CLEANUP_INTERVAL); + } + // Start first cleanup 30 seconds after startup setTimeout(cleanup, 30 * SECONDS); } diff --git a/backend/src/data/UsernameHistory.ts b/backend/src/data/UsernameHistory.ts index e97d58cb..addbf823 100644 --- a/backend/src/data/UsernameHistory.ts +++ b/backend/src/data/UsernameHistory.ts @@ -1,9 +1,24 @@ -import { getRepository, Repository } from "typeorm"; +import { getRepository, In, Repository } from "typeorm"; import { UsernameHistoryEntry } from "./entities/UsernameHistoryEntry"; -import { sorter } from "../utils"; +import { MINUTES, SECONDS, sorter } from "../utils"; import { BaseRepository } from "./BaseRepository"; +import { connection } from "./db"; +import { isAPI } from "../globals"; +import { cleanupUsernames } from "./cleanup/usernames"; -export const MAX_USERNAME_ENTRIES_PER_USER = 10; +if (!isAPI()) { + const CLEANUP_INTERVAL = 5 * MINUTES; + + async function cleanup() { + await cleanupUsernames(); + setTimeout(cleanup, CLEANUP_INTERVAL); + } + + // Start first cleanup 30 seconds after startup + setTimeout(cleanup, 1 * SECONDS); +} + +export const MAX_USERNAME_ENTRIES_PER_USER = 5; export class UsernameHistory extends BaseRepository { private usernameHistory: Repository; @@ -43,23 +58,18 @@ export class UsernameHistory extends BaseRepository { }); // Cleanup (leave only the last MAX_USERNAME_ENTRIES_PER_USER entries) - const lastEntries = await this.getByUserId(userId); - if (lastEntries.length > MAX_USERNAME_ENTRIES_PER_USER) { - const earliestEntry = lastEntries - .sort(sorter("timestamp", "DESC")) - .slice(0, 10) - .reduce((earliest, entry) => { - if (earliest == null) return entry; - if (entry.id < earliest.id) return entry; - return earliest; - }, null); + const toDelete = await this.usernameHistory + .createQueryBuilder() + .where("user_id = :userId", { userId }) + .orderBy("id", "DESC") + .skip(MAX_USERNAME_ENTRIES_PER_USER) + .take(99_999) + .getMany(); - this.usernameHistory - .createQueryBuilder() - .andWhere("user_id = :userId", { userId }) - .andWhere("id < :id", { id: earliestEntry.id }) - .delete() - .execute(); + if (toDelete.length > 0) { + await this.usernameHistory.delete({ + id: In(toDelete.map(v => v.id)), + }); } } } diff --git a/backend/src/data/cleanup/messages.ts b/backend/src/data/cleanup/messages.ts new file mode 100644 index 00000000..626a062e --- /dev/null +++ b/backend/src/data/cleanup/messages.ts @@ -0,0 +1,68 @@ +import { DAYS, DBDateFormat, MINUTES } from "../../utils"; +import { getRepository, In } from "typeorm"; +import { SavedMessage } from "../entities/SavedMessage"; +import moment from "moment-timezone"; +import { connection } from "../db"; + +/** + * How long message edits, deletions, etc. will include the original message content. + * This is very heavy storage-wise, so keeping it as low as possible is ideal. + */ +const RETENTION_PERIOD = 1 * DAYS; +const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; +const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES; +const CLEAN_PER_LOOP = 250; + +export async function cleanupMessages(): Promise { + let cleaned = 0; + + const messagesRepository = getRepository(SavedMessage); + + const deletedAtThreshold = moment() + .subtract(DELETED_MESSAGE_RETENTION_PERIOD, "ms") + .format(DBDateFormat); + const postedAtThreshold = moment() + .subtract(RETENTION_PERIOD, "ms") + .format(DBDateFormat); + const botPostedAtThreshold = moment() + .subtract(BOT_MESSAGE_RETENTION_PERIOD, "ms") + .format(DBDateFormat); + + // SELECT + DELETE messages in batches + // This is to avoid deadlocks that happened frequently when deleting with the same criteria as the select below + // when a message was being inserted at the same time + let rows; + do { + rows = await connection.query( + ` + SELECT id + FROM messages + WHERE ( + deleted_at IS NOT NULL + AND deleted_at <= ? + ) + OR ( + posted_at <= ? + AND is_permanent = 0 + ) + OR ( + is_bot = 1 + AND posted_at <= ? + AND is_permanent = 0 + ) + LIMIT ${CLEAN_PER_LOOP} + `, + [deletedAtThreshold, postedAtThreshold, botPostedAtThreshold], + ); + + if (rows.length > 0) { + await messagesRepository.delete({ + id: In(rows.map(r => r.id)), + }); + } + + cleaned += rows.length; + } while (rows.length === CLEAN_PER_LOOP); + + return cleaned; +} diff --git a/backend/src/data/cleanup/nicknames.ts b/backend/src/data/cleanup/nicknames.ts new file mode 100644 index 00000000..3f41084d --- /dev/null +++ b/backend/src/data/cleanup/nicknames.ts @@ -0,0 +1,41 @@ +import { getRepository, In } from "typeorm"; +import moment from "moment-timezone"; +import { NicknameHistoryEntry } from "../entities/NicknameHistoryEntry"; +import { DAYS, DBDateFormat } from "../../utils"; +import { connection } from "../db"; + +export const NICKNAME_RETENTION_PERIOD = 30 * DAYS; +const CLEAN_PER_LOOP = 500; + +export async function cleanupNicknames(): Promise { + let cleaned = 0; + + const nicknameHistoryRepository = getRepository(NicknameHistoryEntry); + const dateThreshold = moment() + .subtract(NICKNAME_RETENTION_PERIOD, "ms") + .format(DBDateFormat); + + // Clean old nicknames (NICKNAME_RETENTION_PERIOD) + let rows; + do { + rows = await connection.query( + ` + SELECT id + FROM nickname_history + WHERE timestamp < ? + LIMIT ${CLEAN_PER_LOOP} + `, + [dateThreshold], + ); + + if (rows.length > 0) { + await nicknameHistoryRepository.delete({ + id: In(rows.map(r => r.id)), + }); + } + + cleaned += rows.length; + } while (rows.length === CLEAN_PER_LOOP); + + return cleaned; +} diff --git a/backend/src/data/cleanup/usernames.ts b/backend/src/data/cleanup/usernames.ts new file mode 100644 index 00000000..3e385b25 --- /dev/null +++ b/backend/src/data/cleanup/usernames.ts @@ -0,0 +1,45 @@ +import { getRepository, In } from "typeorm"; +import moment from "moment-timezone"; +import { UsernameHistoryEntry } from "../entities/UsernameHistoryEntry"; +import { DAYS, DBDateFormat } from "../../utils"; +import { connection } from "../db"; + +export const USERNAME_RETENTION_PERIOD = 30 * DAYS; +const CLEAN_PER_LOOP = 500; + +export async function cleanupUsernames(): Promise { + let cleaned = 0; + + const usernameHistoryRepository = getRepository(UsernameHistoryEntry); + const dateThreshold = moment() + .subtract(USERNAME_RETENTION_PERIOD, "ms") + .format(DBDateFormat); + + // Clean old usernames (USERNAME_RETENTION_PERIOD) + let rows; + do { + rows = await connection.query( + ` + SELECT id + FROM username_history + WHERE timestamp < ? + LIMIT ${CLEAN_PER_LOOP} + `, + [dateThreshold], + ); + + if (rows.length > 0) { + console.log( + "ids", + rows.map(r => r.id), + ); + await usernameHistoryRepository.delete({ + id: In(rows.map(r => r.id)), + }); + } + + cleaned += rows.length; + } while (rows.length === CLEAN_PER_LOOP); + + return cleaned; +} diff --git a/backend/src/plugins/NameHistory.ts b/backend/src/plugins/NameHistory.ts index 19ce73f2..031841e7 100644 --- a/backend/src/plugins/NameHistory.ts +++ b/backend/src/plugins/NameHistory.ts @@ -1,10 +1,13 @@ import { decorators as d, IPluginOptions } from "knub"; import { GuildNicknameHistory, MAX_NICKNAME_ENTRIES_PER_USER } from "../data/GuildNicknameHistory"; import { Member, Message } from "eris"; -import { createChunkedMessage, disableCodeBlocks } from "../utils"; +import { createChunkedMessage, DAYS, disableCodeBlocks } from "../utils"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { MAX_USERNAME_ENTRIES_PER_USER, UsernameHistory } from "../data/UsernameHistory"; import * as t from "io-ts"; +import { NICKNAME_RETENTION_PERIOD } from "../data/cleanup/nicknames"; +import moment from "moment-timezone"; +import { USERNAME_RETENTION_PERIOD } from "../data/cleanup/usernames"; const ConfigSchema = t.type({ can_view: t.boolean, @@ -59,23 +62,38 @@ export class NameHistoryPlugin extends ZeppelinPlugin { const user = this.bot.users.get(args.userId); const currentUsername = user ? `${user.username}#${user.discriminator}` : args.userId; + const nicknameDays = Math.round(NICKNAME_RETENTION_PERIOD / DAYS); + const usernameDays = Math.round(USERNAME_RETENTION_PERIOD / DAYS); + let message = `Name history for **${currentUsername}**:`; if (nicknameRows.length) { - message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames:__\n${nicknameRows.join("\n")}`; + message += `\n\n__Last ${MAX_NICKNAME_ENTRIES_PER_USER} nicknames within ${nicknameDays} days:__\n${nicknameRows.join( + "\n", + )}`; } if (usernameRows.length) { - message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames:__\n${usernameRows.join("\n")}`; + message += `\n\n__Last ${MAX_USERNAME_ENTRIES_PER_USER} usernames within ${usernameDays} days:__\n${usernameRows.join( + "\n", + )}`; } createChunkedMessage(msg.channel, message); } - @d.event("guildMemberUpdate") - async onGuildMemberUpdate(_, member: Member) { + async updateNickname(member: Member) { const latestEntry = await this.nicknameHistory.getLastEntry(member.id); if (!latestEntry || latestEntry.nickname !== member.nick) { - // tslint:disable-line await this.nicknameHistory.addEntry(member.id, member.nick); } } + + @d.event("messageCreate") + async onMessage(msg: Message) { + this.updateNickname(msg.member); + } + + @d.event("voiceChannelJoin") + async onVoiceChannelJoin(member: Member) { + this.updateNickname(member); + } } diff --git a/backend/src/plugins/UsernameSaver.ts b/backend/src/plugins/UsernameSaver.ts index fe6388b0..140627d6 100644 --- a/backend/src/plugins/UsernameSaver.ts +++ b/backend/src/plugins/UsernameSaver.ts @@ -1,6 +1,6 @@ import { decorators as d, GlobalPlugin } from "knub"; import { UsernameHistory } from "../data/UsernameHistory"; -import { Member, User } from "eris"; +import { Member, Message, User } from "eris"; import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; export class UsernameSaver extends GlobalZeppelinPlugin { @@ -21,13 +21,15 @@ export class UsernameSaver extends GlobalZeppelinPlugin { } } - @d.event("userUpdate", null, false) - async onUserUpdate(user: User) { - this.updateUsername(user); + @d.event("messageCreate") + async onMessage(msg: Message) { + if (msg.author.bot) return; + this.updateUsername(msg.author); } - @d.event("guildMemberAdd", null, false) - async onGuildMemberAdd(_, member: Member) { + @d.event("voiceChannelJoin") + async onVoiceChannelJoin(member: Member) { + if (member.user.bot) return; this.updateUsername(member.user); } } From 6f1391aead163781f70305be68f4a7a85e7a0e4e Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 00:47:22 +0300 Subject: [PATCH 30/49] Remove debug log --- backend/src/data/cleanup/usernames.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/data/cleanup/usernames.ts b/backend/src/data/cleanup/usernames.ts index 3e385b25..71afcfbc 100644 --- a/backend/src/data/cleanup/usernames.ts +++ b/backend/src/data/cleanup/usernames.ts @@ -29,10 +29,6 @@ export async function cleanupUsernames(): Promise { ); if (rows.length > 0) { - console.log( - "ids", - rows.map(r => r.id), - ); await usernameHistoryRepository.delete({ id: In(rows.map(r => r.id)), }); From 8c9f9dc42f5b8c25c1f0614239e3a4cc94c5cda6 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 00:47:37 +0300 Subject: [PATCH 31/49] Add config retention period / cleanup --- backend/src/data/Configs.ts | 25 +++++--- backend/src/data/cleanup/configs.ts | 96 +++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 backend/src/data/cleanup/configs.ts diff --git a/backend/src/data/Configs.ts b/backend/src/data/Configs.ts index 236713a4..054ce255 100644 --- a/backend/src/data/Configs.ts +++ b/backend/src/data/Configs.ts @@ -1,15 +1,22 @@ import { Config } from "./entities/Config"; -import { - getConnection, - getRepository, - Repository, - Transaction, - TransactionManager, - TransactionRepository, -} from "typeorm"; -import { BaseGuildRepository } from "./BaseGuildRepository"; +import { getRepository, Repository } from "typeorm"; import { connection } from "./db"; import { BaseRepository } from "./BaseRepository"; +import { isAPI } from "../globals"; +import { HOURS, SECONDS } from "../utils"; +import { cleanupConfigs } from "./cleanup/configs"; + +if (isAPI()) { + const CLEANUP_INTERVAL = 1 * HOURS; + + async function cleanup() { + await cleanupConfigs(); + setTimeout(cleanup, CLEANUP_INTERVAL); + } + + // Start first cleanup 30 seconds after startup + setTimeout(cleanup, 30 * SECONDS); +} export class Configs extends BaseRepository { private configs: Repository; diff --git a/backend/src/data/cleanup/configs.ts b/backend/src/data/cleanup/configs.ts new file mode 100644 index 00000000..4775b74f --- /dev/null +++ b/backend/src/data/cleanup/configs.ts @@ -0,0 +1,96 @@ +import { connection } from "../db"; +import { getRepository, In } from "typeorm"; +import { Config } from "../entities/Config"; +import moment from "moment-timezone"; +import { DBDateFormat } from "../../utils"; + +const CLEAN_PER_LOOP = 50; + +export async function cleanupConfigs() { + const configRepository = getRepository(Config); + + let cleaned = 0; + let rows; + + // >1 month old: 1 config retained per month + const oneMonthCutoff = moment() + .subtract(30, "days") + .format(DBDateFormat); + do { + rows = await connection.query( + ` + WITH _configs + AS ( + SELECT + id, + \`key\`, + YEAR(edited_at) AS \`year\`, + MONTH(edited_at) AS \`month\`, + ROW_NUMBER() OVER ( + PARTITION BY \`key\`, \`year\`, \`month\` + ORDER BY edited_at + ) AS row_num + FROM + configs + WHERE + is_active = 0 + AND edited_at < ? + ) + SELECT * + FROM _configs + WHERE row_num > 1 + `, + [oneMonthCutoff], + ); + + if (rows.length > 0) { + await configRepository.delete({ + id: In(rows.map(r => r.id)), + }); + } + + cleaned += rows.length; + } while (rows.length === CLEAN_PER_LOOP); + + // >2 weeks old: 1 config retained per day + const twoWeekCutoff = moment() + .subtract(2, "weeks") + .format(DBDateFormat); + do { + rows = await connection.query( + ` + WITH _configs + AS ( + SELECT + id, + \`key\`, + DATE(edited_at) AS \`date\`, + ROW_NUMBER() OVER ( + PARTITION BY \`key\`, \`date\` + ORDER BY edited_at + ) AS row_num + FROM + configs + WHERE + is_active = 0 + AND edited_at < ? + AND edited_at >= ? + ) + SELECT * + FROM _configs + WHERE row_num > 1 + `, + [twoWeekCutoff, oneMonthCutoff], + ); + + if (rows.length > 0) { + await configRepository.delete({ + id: In(rows.map(r => r.id)), + }); + } + + cleaned += rows.length; + } while (rows.length === CLEAN_PER_LOOP); + + return cleaned; +} From 59918858ec18b1c7f86ea861f893d93d70461cd2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 00:55:49 +0300 Subject: [PATCH 32/49] Fix username history cleanup startup delay --- backend/src/data/UsernameHistory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/UsernameHistory.ts b/backend/src/data/UsernameHistory.ts index addbf823..0d334adb 100644 --- a/backend/src/data/UsernameHistory.ts +++ b/backend/src/data/UsernameHistory.ts @@ -15,7 +15,7 @@ if (!isAPI()) { } // Start first cleanup 30 seconds after startup - setTimeout(cleanup, 1 * SECONDS); + setTimeout(cleanup, 30 * SECONDS); } export const MAX_USERNAME_ENTRIES_PER_USER = 5; From 3795ce6f1b294d54f1021813894ab3930abec814 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 00:58:17 +0300 Subject: [PATCH 33/49] Increase messages CLEAN_PER_LOOP to 500 --- backend/src/data/cleanup/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/data/cleanup/messages.ts b/backend/src/data/cleanup/messages.ts index 626a062e..ddb42232 100644 --- a/backend/src/data/cleanup/messages.ts +++ b/backend/src/data/cleanup/messages.ts @@ -11,7 +11,7 @@ import { connection } from "../db"; const RETENTION_PERIOD = 1 * DAYS; const BOT_MESSAGE_RETENTION_PERIOD = 30 * MINUTES; const DELETED_MESSAGE_RETENTION_PERIOD = 5 * MINUTES; -const CLEAN_PER_LOOP = 250; +const CLEAN_PER_LOOP = 500; export async function cleanupMessages(): Promise { let cleaned = 0; From a3f697ad6e7d1cc379f4003f065f53a8de107361 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 01:04:42 +0300 Subject: [PATCH 34/49] Fix error in updateNickname() --- backend/src/plugins/NameHistory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/plugins/NameHistory.ts b/backend/src/plugins/NameHistory.ts index 031841e7..dbc2946d 100644 --- a/backend/src/plugins/NameHistory.ts +++ b/backend/src/plugins/NameHistory.ts @@ -81,6 +81,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin { } async updateNickname(member: Member) { + if (!member) return; const latestEntry = await this.nicknameHistory.getLastEntry(member.id); if (!latestEntry || latestEntry.nickname !== member.nick) { await this.nicknameHistory.addEntry(member.id, member.nick); From e684bf7dacb932e58fef193c5f678dc27751c918 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 01:36:42 +0300 Subject: [PATCH 35/49] Don't save empty nicknames to nickname history if there's no previous entry --- backend/src/plugins/NameHistory.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/plugins/NameHistory.ts b/backend/src/plugins/NameHistory.ts index dbc2946d..ddc0ed6b 100644 --- a/backend/src/plugins/NameHistory.ts +++ b/backend/src/plugins/NameHistory.ts @@ -84,6 +84,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin { if (!member) return; const latestEntry = await this.nicknameHistory.getLastEntry(member.id); if (!latestEntry || latestEntry.nickname !== member.nick) { + if (!latestEntry && member.nick == null) return; // No need to save "no nickname" if there's no previous data await this.nicknameHistory.addEntry(member.id, member.nick); } } From 69feccbcabb7e0d40e21036a8dc53df07b8876cb Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Tue, 2 Jun 2020 01:40:02 +0300 Subject: [PATCH 36/49] Save names using a queue to avoid race conditions --- backend/src/plugins/NameHistory.ts | 8 ++++++-- backend/src/plugins/UsernameSaver.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/src/plugins/NameHistory.ts b/backend/src/plugins/NameHistory.ts index ddc0ed6b..f77e69a8 100644 --- a/backend/src/plugins/NameHistory.ts +++ b/backend/src/plugins/NameHistory.ts @@ -8,6 +8,7 @@ import * as t from "io-ts"; import { NICKNAME_RETENTION_PERIOD } from "../data/cleanup/nicknames"; import moment from "moment-timezone"; import { USERNAME_RETENTION_PERIOD } from "../data/cleanup/usernames"; +import { Queue } from "../Queue"; const ConfigSchema = t.type({ can_view: t.boolean, @@ -22,6 +23,8 @@ export class NameHistoryPlugin extends ZeppelinPlugin { protected nicknameHistory: GuildNicknameHistory; protected usernameHistory: UsernameHistory; + protected updateQueue: Queue; + public static getStaticDefaultOptions(): IPluginOptions { return { config: { @@ -42,6 +45,7 @@ export class NameHistoryPlugin extends ZeppelinPlugin { onLoad() { this.nicknameHistory = GuildNicknameHistory.getGuildInstance(this.guildId); this.usernameHistory = new UsernameHistory(); + this.updateQueue = new Queue(); } @d.command("names", "") @@ -91,11 +95,11 @@ export class NameHistoryPlugin extends ZeppelinPlugin { @d.event("messageCreate") async onMessage(msg: Message) { - this.updateNickname(msg.member); + this.updateQueue.add(() => this.updateNickname(msg.member)); } @d.event("voiceChannelJoin") async onVoiceChannelJoin(member: Member) { - this.updateNickname(member); + this.updateQueue.add(() => this.updateNickname(member)); } } diff --git a/backend/src/plugins/UsernameSaver.ts b/backend/src/plugins/UsernameSaver.ts index 140627d6..25cdcde4 100644 --- a/backend/src/plugins/UsernameSaver.ts +++ b/backend/src/plugins/UsernameSaver.ts @@ -2,14 +2,17 @@ import { decorators as d, GlobalPlugin } from "knub"; import { UsernameHistory } from "../data/UsernameHistory"; import { Member, Message, User } from "eris"; import { GlobalZeppelinPlugin } from "./GlobalZeppelinPlugin"; +import { Queue } from "../Queue"; export class UsernameSaver extends GlobalZeppelinPlugin { public static pluginName = "username_saver"; protected usernameHistory: UsernameHistory; + protected updateQueue: Queue; async onLoad() { this.usernameHistory = new UsernameHistory(); + this.updateQueue = new Queue(); } protected async updateUsername(user: User) { @@ -21,15 +24,15 @@ export class UsernameSaver extends GlobalZeppelinPlugin { } } - @d.event("messageCreate") + @d.event("messageCreate", null) async onMessage(msg: Message) { if (msg.author.bot) return; - this.updateUsername(msg.author); + this.updateQueue.add(() => this.updateUsername(msg.author)); } - @d.event("voiceChannelJoin") + @d.event("voiceChannelJoin", null) async onVoiceChannelJoin(member: Member) { if (member.user.bot) return; - this.updateUsername(member.user); + this.updateQueue.add(() => this.updateUsername(member.user)); } } From 69db4108088a9665d96151e94bec5c7b4870b93d Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 02:32:27 +0300 Subject: [PATCH 37/49] mutes: fix error when member to unmute cannot be found --- backend/src/plugins/ModActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index 6079c383..e10d8a71 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -1044,7 +1044,7 @@ export class ModActionsPlugin extends ZeppelinPlugin { if (!user) return this.sendErrorMessage(msg.channel, `User not found`); const memberToUnmute = await this.getMember(user.id); const mutesPlugin = this.getPlugin("mutes"); - const hasMuteRole = mutesPlugin.hasMutedRole(memberToUnmute); + const hasMuteRole = memberToUnmute && mutesPlugin.hasMutedRole(memberToUnmute); // Check if they're muted in the first place if (!(await this.mutes.isMuted(args.user)) && !hasMuteRole) { From ff9bcca3502fb28895281ab5219e31f65b351edd Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 02:37:13 +0300 Subject: [PATCH 38/49] cases: add safeguard check for existing cases with the same audit log id --- backend/src/plugins/Cases.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts index 98e30c7c..530dcc9e 100644 --- a/backend/src/plugins/Cases.ts +++ b/backend/src/plugins/Cases.ts @@ -94,6 +94,14 @@ export class CasesPlugin extends ZeppelinPlugin { ppName = `${pp.username}#${pp.discriminator}`; } + if (args.auditLogId) { + const existingAuditLogCase = await this.cases.findByAuditLogId(args.auditLogId); + if (existingAuditLogCase) { + delete args.auditLogId; + logger.warn(`Duplicate audit log ID for mod case: ${args.auditLogId}`); + } + } + const createdCase = await this.cases.create({ type: args.type, user_id: args.userId, From d281996b44cef87666b886db93286cc68520bc5a Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 02:45:05 +0300 Subject: [PATCH 39/49] roles: mention role and user in success messages --- backend/src/plugins/Roles.ts | 43 ++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/backend/src/plugins/Roles.ts b/backend/src/plugins/Roles.ts index 6fc1d601..0b852218 100644 --- a/backend/src/plugins/Roles.ts +++ b/backend/src/plugins/Roles.ts @@ -1,6 +1,6 @@ import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import * as t from "io-ts"; -import { resolveMember, stripObjectToScalars, successMessage } from "../utils"; +import { resolveMember, stripObjectToScalars, successMessage, verboseUserMention } from "../utils"; import { decorators as d, IPluginOptions, logger } from "knub"; import { GuildChannel, Member, Message } from "eris"; import { GuildLogs } from "../data/GuildLogs"; @@ -100,7 +100,7 @@ export class RolesPlugin extends ZeppelinPlugin { mod: stripObjectToScalars(msg.author), }); - this.sendSuccessMessage(msg.channel, "Role added to user!"); + this.sendSuccessMessage(msg.channel, `Added role **${role.name}** to ${verboseUserMention(args.member.user)}!`); } @d.command("massaddrole", " ") @@ -129,19 +129,30 @@ export class RolesPlugin extends ZeppelinPlugin { if (!roleId) { return this.sendErrorMessage(msg.channel, "Invalid role id"); } - const role = this.guild.roles.get(roleId); const config = this.getConfigForMsg(msg); if (!config.assignable_roles.includes(roleId)) { return this.sendErrorMessage(msg.channel, "You cannot assign that role"); } + const role = this.guild.roles.get(roleId); + if (!role) { + this.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return this.sendErrorMessage(msg.channel, "You cannot assign that role"); + } + const membersWithoutTheRole = members.filter(m => !m.roles.includes(roleId)); let assigned = 0; const failed = []; const alreadyHadRole = members.length - membersWithoutTheRole.length; - msg.channel.createMessage(`Adding role to specified members...`); + msg.channel.createMessage( + `Adding role **${role.name}** to ${membersWithoutTheRole.length} ${ + membersWithoutTheRole.length === 1 ? "member" : "members" + }...`, + ); for (const member of membersWithoutTheRole) { try { @@ -159,7 +170,7 @@ export class RolesPlugin extends ZeppelinPlugin { } } - let resultMessage = `Role added to ${assigned} ${assigned === 1 ? "member" : "members"}!`; + let resultMessage = `Added role **${role.name}** to ${assigned} ${assigned === 1 ? "member" : "members"}!`; if (alreadyHadRole) { resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`; } @@ -221,7 +232,10 @@ export class RolesPlugin extends ZeppelinPlugin { mod: stripObjectToScalars(msg.author), }); - this.sendSuccessMessage(msg.channel, "Role removed from user!"); + this.sendSuccessMessage( + msg.channel, + `Removed role **${role.name}** removed from ${verboseUserMention(args.member.user)}!`, + ); } @d.command("massremoverole", " ") @@ -248,19 +262,30 @@ export class RolesPlugin extends ZeppelinPlugin { if (!roleId) { return this.sendErrorMessage(msg.channel, "Invalid role id"); } - const role = this.guild.roles.get(roleId); const config = this.getConfigForMsg(msg); if (!config.assignable_roles.includes(roleId)) { return this.sendErrorMessage(msg.channel, "You cannot remove that role"); } + const role = this.guild.roles.get(roleId); + if (!role) { + this.logs.log(LogType.BOT_ALERT, { + body: `Unknown role configured for 'roles' plugin: ${roleId}`, + }); + return this.sendErrorMessage(msg.channel, "You cannot remove that role"); + } + const membersWithTheRole = members.filter(m => m.roles.includes(roleId)); let assigned = 0; const failed = []; const didNotHaveRole = members.length - membersWithTheRole.length; - msg.channel.createMessage(`Removing role from specified members...`); + msg.channel.createMessage( + `Removing role **${role.name}** from ${membersWithTheRole.length} ${ + membersWithTheRole.length === 1 ? "member" : "members" + }...`, + ); for (const member of membersWithTheRole) { try { @@ -278,7 +303,7 @@ export class RolesPlugin extends ZeppelinPlugin { } } - let resultMessage = `Role removed from ${assigned} ${assigned === 1 ? "member" : "members"}!`; + let resultMessage = `Removed role **${role.name}** from ${assigned} ${assigned === 1 ? "member" : "members"}!`; if (didNotHaveRole) { resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`; } From c308a9b5ce7c2f219234266323fd68d4f8ee040f Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 02:51:15 +0300 Subject: [PATCH 40/49] logs: fix bulk deletes not being logged --- backend/src/data/GuildSavedMessages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/data/GuildSavedMessages.ts b/backend/src/data/GuildSavedMessages.ts index a052ef66..76047dcd 100644 --- a/backend/src/data/GuildSavedMessages.ts +++ b/backend/src/data/GuildSavedMessages.ts @@ -144,7 +144,7 @@ export class GuildSavedMessages extends BaseGuildRepository { if (existingSavedMsg) return; const savedMessageData = this.msgToSavedMessageData(msg); - const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss.SSS"); + const postedAt = moment.utc(msg.timestamp, "x").format("YYYY-MM-DD HH:mm:ss"); const data = { id: msg.id, @@ -183,7 +183,7 @@ export class GuildSavedMessages extends BaseGuildRepository { * If any messages were marked as deleted, also emits the deleteBulk event. */ async markBulkAsDeleted(ids) { - const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss.SSS"); + const deletedAt = moment().format("YYYY-MM-DD HH:mm:ss"); await this.messages .createQueryBuilder() From b64335c901dcd66ef4421fb580eb780dc6e4aaa5 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 03:20:31 +0300 Subject: [PATCH 41/49] automod: clear old message spam data when re-evaluating an edited message --- backend/src/plugins/Automod/Automod.ts | 42 +++++++++++++++++++------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/backend/src/plugins/Automod/Automod.ts b/backend/src/plugins/Automod/Automod.ts index 780dab9f..cb777e72 100644 --- a/backend/src/plugins/Automod/Automod.ts +++ b/backend/src/plugins/Automod/Automod.ts @@ -232,6 +232,7 @@ export class AutomodPlugin extends ZeppelinPlugin this.onMessageCreate(msg); + this.onMessageCreateFn = msg => this.runAutomodOnMessage(msg, false); this.savedMessages.events.on("create", this.onMessageCreateFn); - this.savedMessages.events.on("update", this.onMessageCreateFn); + + this.onMessageUpdateFn = msg => this.runAutomodOnMessage(msg, true); + this.savedMessages.events.on("update", this.onMessageUpdateFn); + this.actionedMessageIds = []; } @@ -368,7 +372,7 @@ export class AutomodPlugin extends ZeppelinPlugin { const since = moment.utc(msg.posted_at).valueOf() - convertDelayStringToMS(trigger.within); + const to = moment.utc(msg.posted_at).valueOf(); const identifier = trigger.per_channel ? `${msg.channel_id}-${msg.user_id}` : msg.user_id; - const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since); + const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since, to); const totalCount = recentActions.reduce((total, action) => { return total + action.count; }, 0); @@ -591,7 +596,8 @@ export class AutomodPlugin extends ZeppelinPlugin { const since = moment.utc().valueOf() - convertDelayStringToMS(trigger.within); - const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since) as OtherRecentAction[]; + const to = moment.utc().valueOf(); + const recentActions = this.getMatchingRecentActions(recentActionType, identifier, since, to) as OtherRecentAction[]; const totalCount = recentActions.reduce((total, action) => { return total + action.count; }, 0); @@ -840,7 +846,7 @@ export class AutomodPlugin extends ZeppelinPlugin { - return action.type === type && (!identifier || action.identifier === identifier) && action.timestamp >= since; + return ( + action.type === type && + (!identifier || action.identifier === identifier) && + action.timestamp >= since && + action.timestamp <= to + ); + }); + } + + protected clearRecentActionsForMessage(messageId: string) { + this.recentActions = this.recentActions.filter(info => { + return !((info as TextRecentAction).messageInfo?.messageId === messageId); }); } @@ -1526,13 +1543,16 @@ export class AutomodPlugin extends ZeppelinPlugin { if (this.unloaded) return; - await this.logRecentActionsForMessage(msg); + if (isEdit) { + this.clearRecentActionsForMessage(msg.id); + } + this.logRecentActionsForMessage(msg); const member = this.guild.members.get(msg.user_id); const config = this.getMatchingConfig({ From a47836977c33afbe7e9b9c9fc17702f3e6949400 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 03:46:33 +0300 Subject: [PATCH 42/49] ZeppelinPlugin: read null options as { enabled: false } --- backend/src/plugins/ZeppelinPlugin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/plugins/ZeppelinPlugin.ts b/backend/src/plugins/ZeppelinPlugin.ts index e2e46e9b..b8f49a95 100644 --- a/backend/src/plugins/ZeppelinPlugin.ts +++ b/backend/src/plugins/ZeppelinPlugin.ts @@ -124,6 +124,12 @@ export class ZeppelinPlugin< * the plugin, which is why this has to be a static function. */ protected static mergeAndDecodeStaticOptions(options: any): IPluginOptions { + if (options == null) { + options = { + enabled: false, + }; + } + const defaultOptions: any = this.getStaticDefaultOptions(); let mergedConfig = configUtils.mergeConfig({}, defaultOptions.config || {}, options.config || {}); const mergedOverrides = options.replaceDefaultOverrides From cc69bc5be477e2619e656c720e7cc3734f4317a2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 03:55:54 +0300 Subject: [PATCH 43/49] Add debug logging for null user IDs in mutes --- backend/src/plugins/ModActions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index e10d8a71..0e4b0acb 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -842,6 +842,9 @@ export class ModActionsPlugin extends ZeppelinPlugin { this.sendErrorMessage(msg.channel, "Could not mute the user: unknown member"); } else { logger.error(`Failed to mute user ${user.id}: ${e.stack}`); + if (user.id == null) { + console.trace("[DEBUG] Null user.id for mute"); + } this.sendErrorMessage(msg.channel, "Could not mute the user"); } From ff4c934ca3d3896d3a0eeabb96fe5bbe1347915a Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 21:41:35 +0300 Subject: [PATCH 44/49] ReactionRoles: only clear pendingRoleChanges for a member after their roles have been applied --- backend/src/plugins/ReactionRoles.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/plugins/ReactionRoles.ts b/backend/src/plugins/ReactionRoles.ts index 67baeae3..44666937 100644 --- a/backend/src/plugins/ReactionRoles.ts +++ b/backend/src/plugins/ReactionRoles.ts @@ -189,8 +189,6 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { timeout: null, changes: [], applyFn: async () => { - this.pendingRoleChanges.delete(memberId); - const lock = await this.locks.acquire(`member-roles-${memberId}`); const member = await this.getMember(memberId); @@ -212,6 +210,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { } } + this.pendingRoleChanges.delete(memberId); lock.unlock(); }, }; From c19fd847e7a0f2d2f2eb289290cf778740548348 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Thu, 4 Jun 2020 21:50:08 +0300 Subject: [PATCH 45/49] ReactionRoles: apply roles in a queue to avoid hitting rate limits on e.g. member REST endpoints --- backend/src/plugins/ReactionRoles.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/src/plugins/ReactionRoles.ts b/backend/src/plugins/ReactionRoles.ts index 44666937..2e0e384a 100644 --- a/backend/src/plugins/ReactionRoles.ts +++ b/backend/src/plugins/ReactionRoles.ts @@ -54,6 +54,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { protected savedMessages: GuildSavedMessages; protected reactionRemoveQueue: Queue; + protected roleChangeQueue: Queue; protected pendingRoleChanges: Map; protected pendingRefreshes: Set; @@ -82,6 +83,7 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { this.reactionRoles = GuildReactionRoles.getGuildInstance(this.guildId); this.savedMessages = GuildSavedMessages.getGuildInstance(this.guildId); this.reactionRemoveQueue = new Queue(); + this.roleChangeQueue = new Queue(); this.pendingRoleChanges = new Map(); this.pendingRefreshes = new Set(); @@ -189,6 +191,8 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { timeout: null, changes: [], applyFn: async () => { + this.pendingRoleChanges.delete(memberId); + const lock = await this.locks.acquire(`member-roles-${memberId}`); const member = await this.getMember(memberId); @@ -200,17 +204,18 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { } try { - await member.edit({ - roles: Array.from(newRoleIds.values()), - }); + await member.edit( + { + roles: Array.from(newRoleIds.values()), + }, + "Reaction roles", + ); } catch (e) { logger.warn( `Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${e.message}`, ); } } - - this.pendingRoleChanges.delete(memberId); lock.unlock(); }, }; @@ -222,7 +227,10 @@ export class ReactionRolesPlugin extends ZeppelinPlugin { pendingRoleChangeObj.changes.push({ mode, roleId }); if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout); - setTimeout(() => pendingRoleChangeObj.applyFn(), ROLE_CHANGE_BATCH_DEBOUNCE_TIME); + pendingRoleChangeObj.timeout = setTimeout( + () => this.roleChangeQueue.add(pendingRoleChangeObj.applyFn), + ROLE_CHANGE_BATCH_DEBOUNCE_TIME, + ); } /** From 24241fa7d25d13af35ccf487d9159d9f36d31585 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 13 Jun 2020 09:36:55 +0300 Subject: [PATCH 46/49] Support embed fields/title in Starboard via copy_full_embed --- backend/src/plugins/Starboard.ts | 48 +++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/backend/src/plugins/Starboard.ts b/backend/src/plugins/Starboard.ts index 46868206..07a9b286 100644 --- a/backend/src/plugins/Starboard.ts +++ b/backend/src/plugins/Starboard.ts @@ -26,6 +26,7 @@ const StarboardOpts = t.type({ channel_id: t.string, stars_required: t.number, star_emoji: tNullable(t.array(t.string)), + copy_full_embed: tNullable(t.boolean), enabled: tNullable(t.boolean), }); type TStarboardOpts = t.TypeOf; @@ -148,6 +149,7 @@ export class StarboardPlugin extends ZeppelinPlugin { if (cfg.enabled == null) cfg.enabled = defaultStarboardOpts.enabled; if (cfg.star_emoji == null) cfg.star_emoji = defaultStarboardOpts.star_emoji; if (cfg.stars_required == null) cfg.stars_required = defaultStarboardOpts.stars_required; + if (cfg.copy_full_embed == null) cfg.copy_full_embed = false; }); return configs; @@ -224,7 +226,7 @@ export class StarboardPlugin extends ZeppelinPlugin { const reactions = await this.starboardReactions.getAllReactionsForMessageId(msg.id); const reactionsCount = reactions.length; if (reactionsCount >= starboard.stars_required) { - await this.saveMessageToStarboard(msg, starboard.channel_id); + await this.saveMessageToStarboard(msg, starboard); } } } @@ -243,8 +245,8 @@ export class StarboardPlugin extends ZeppelinPlugin { * Saves/posts a message to the specified starboard. * The message is posted as an embed and image attachments are included as the embed image. */ - async saveMessageToStarboard(msg: Message, starboardChannelId: string) { - const channel = this.guild.channels.get(starboardChannelId); + async saveMessageToStarboard(msg: Message, starboard: TStarboardOpts) { + const channel = this.guild.channels.get(starboard.channel_id); if (!channel) return; const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); @@ -256,6 +258,7 @@ export class StarboardPlugin extends ZeppelinPlugin { author: { name: `${msg.author.username}#${msg.author.discriminator}`, }, + fields: [], timestamp: new Date(msg.timestamp).toISOString(), }; @@ -267,24 +270,35 @@ export class StarboardPlugin extends ZeppelinPlugin { embed.description = msg.content; } - // Include attachments - if (msg.attachments.length) { - const attachment = msg.attachments[0]; - const ext = path - .extname(attachment.filename) - .slice(1) - .toLowerCase(); - if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) { - embed.image = { url: attachment.url }; + // Merge media and - if copy_full_embed is enabled - fields and title from the first embed in the original message + if (msg.embeds.length > 0) { + if (msg.embeds[0].image) embed.image = msg.embeds[0].image; + + if (starboard.copy_full_embed) { + if (msg.embeds[0].title) { + const titleText = msg.embeds[0].url ? `[${msg.embeds[0].title}](${msg.embeds[0].url})` : msg.embeds[0].title; + embed.fields.push({ name: EMPTY_CHAR, value: titleText }); + } + + if (msg.embeds[0].fields) embed.fields.push(...msg.embeds[0].fields); } } - // Include any embed images in the original message - if (msg.embeds.length && msg.embeds[0].image) { - embed.image = msg.embeds[0].image; + // If there are no embeds, add the first image attachment explicitly + else if (msg.attachments.length) { + for (const attachment of msg.attachments) { + const ext = path + .extname(attachment.filename) + .slice(1) + .toLowerCase(); + if (!["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) continue; + + embed.image = { url: attachment.url }; + break; + } } - embed.fields = [{ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }]; + embed.fields.push({ name: EMPTY_CHAR, value: `[Jump to message](${messageLink(msg)})` }); const starboardMessage = await (channel as TextChannel).createMessage({ embed }); await this.starboardMessages.createStarboardMessage(channel.id, msg.id, starboardMessage.id); @@ -364,7 +378,7 @@ export class StarboardPlugin extends ZeppelinPlugin { pin.id, ); if (existingStarboardMessage.length > 0) continue; - await this.saveMessageToStarboard(pin, starboardChannel.id); + await this.saveMessageToStarboard(pin, starboard); } this.sendSuccessMessage(msg.channel, `Pins migrated from <#${args.pinChannel.id}> to <#${starboardChannel.id}>!`); From 7ceb503d07169d113d68201a4697f5610c297716 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 14 Jun 2020 23:51:19 +0300 Subject: [PATCH 47/49] Logs: Fix error in excluded_channels checks --- backend/src/plugins/Logs.ts | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/backend/src/plugins/Logs.ts b/backend/src/plugins/Logs.ts index a8cc8e1a..1256288d 100644 --- a/backend/src/plugins/Logs.ts +++ b/backend/src/plugins/Logs.ts @@ -151,19 +151,14 @@ export class LogsPlugin extends ZeppelinPlugin { // If this entry is from an excluded channel, skip it if (opts.excluded_channels) { - if (type === LogType.MESSAGE_DELETE || type === LogType.MESSAGE_DELETE_BARE) { - if (opts.excluded_channels.includes(data.message.channel.id)) { - continue logChannelLoop; - } - } - - if (type === LogType.MESSAGE_EDIT) { - if (opts.excluded_channels.includes(data.before.channel.id)) { - continue logChannelLoop; - } - } - - if (type === LogType.MESSAGE_SPAM_DETECTED || type === LogType.CENSOR || type === LogType.CLEAN) { + if ( + type === LogType.MESSAGE_DELETE || + type === LogType.MESSAGE_DELETE_BARE || + type === LogType.MESSAGE_EDIT || + type === LogType.MESSAGE_SPAM_DETECTED || + type === LogType.CENSOR || + type === LogType.CLEAN + ) { if (opts.excluded_channels.includes(data.channel.id)) { continue logChannelLoop; } From 00aeab6cddf2dc1cd1c32abe55a1e273dc7f8dfe Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 14 Jun 2020 23:58:11 +0300 Subject: [PATCH 48/49] Fix missing error code handling when posting case logs --- backend/src/plugins/Cases.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/plugins/Cases.ts b/backend/src/plugins/Cases.ts index 530dcc9e..eb5daaaf 100644 --- a/backend/src/plugins/Cases.ts +++ b/backend/src/plugins/Cases.ts @@ -283,7 +283,7 @@ export class CasesPlugin extends ZeppelinPlugin { try { result = await caseLogChannel.createMessage(content, file); } catch (e) { - if (isDiscordRESTError(e) && e.code === 50013) { + if (isDiscordRESTError(e) && (e.code === 50013 || e.code === 50001)) { logger.warn( `Missing permissions to post mod cases in <#${caseLogChannel.id}> in guild ${this.guild.name} (${this.guild.id})`, ); From b4e034e3d8eb4f913e2a171eab0c25afad16eac2 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sun, 14 Jun 2020 23:59:51 +0300 Subject: [PATCH 49/49] Mutes: Add locks to prevent multiple simultaneous attempts to mute a user --- backend/src/plugins/Mutes.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/plugins/Mutes.ts b/backend/src/plugins/Mutes.ts index a5df9e09..aafb510e 100644 --- a/backend/src/plugins/Mutes.ts +++ b/backend/src/plugins/Mutes.ts @@ -150,8 +150,11 @@ export class MutesPlugin extends ZeppelinPlugin { reason: string = null, muteOptions: MuteOptions = {}, ): Promise { + const lock = await this.locks.acquire(`mute-${userId}`); + const muteRole = this.getConfig().mute_role; if (!muteRole) { + lock.unlock(); this.throwRecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG); } @@ -287,6 +290,8 @@ export class MutesPlugin extends ZeppelinPlugin { }); } + lock.unlock(); + return { case: theCase, notifyResult,