diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 6b558dc6..cfd035e8 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -69,4 +69,7 @@ export enum LogType { SET_ANTIRAID_AUTO, AUTOMOD_SPAM_NEW, + + MASS_ASSIGN_ROLES, + MASS_UNASSIGN_ROLES, } diff --git a/backend/src/plugins/Logs.ts b/backend/src/plugins/Logs.ts index 0199bb40..da2e429c 100644 --- a/backend/src/plugins/Logs.ts +++ b/backend/src/plugins/Logs.ts @@ -1,12 +1,10 @@ import { decorators as d, IPluginOptions, logger } from "knub"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; -import { Attachment, Channel, Constants as ErisConstants, Embed, Guild, Member, TextChannel, User } from "eris"; +import { Attachment, Channel, Constants as ErisConstants, Embed, Member, TextChannel, User } from "eris"; import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import { createChunkedMessage, - disableCodeBlocks, - disableLinkPreviews, findRelevantAuditLogEntry, messageSummary, noop, @@ -26,7 +24,7 @@ import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { SavedMessage } from "../data/entities/SavedMessage"; import { GuildArchives } from "../data/GuildArchives"; import { GuildCases } from "../data/GuildCases"; -import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; import { renderTemplate, TemplateParseError } from "../templateFormatter"; import cloneDeep from "lodash.clonedeep"; import * as t from "io-ts"; @@ -376,59 +374,74 @@ export class LogsPlugin extends ZeppelinPlugin { if (!isEqual(oldMember.roles, member.roles)) { const addedRoles = diff(member.roles, oldMember.roles); const removedRoles = diff(oldMember.roles, member.roles); + let skip = false; - const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( - ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE, - member.id, - ); - const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); + if ( + addedRoles.length && + removedRoles.length && + this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_CHANGES, member.id) + ) { + skip = true; + } else if (addedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_ADD, member.id)) { + skip = true; + } else if (removedRoles.length && this.guildLogs.isLogIgnored(LogType.MEMBER_ROLE_REMOVE, member.id)) { + skip = true; + } - if (addedRoles.length && removedRoles.length) { - // Roles added *and* removed - this.guildLogs.log( - LogType.MEMBER_ROLE_CHANGES, - { - member: logMember, - addedRoles: addedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - removedRoles: removedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, - member.id, - ); - } else if (addedRoles.length) { - // Roles added - this.guildLogs.log( - LogType.MEMBER_ROLE_ADD, - { - member: logMember, - roles: addedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, - member.id, - ); - } else if (removedRoles.length && !addedRoles.length) { - // Roles removed - this.guildLogs.log( - LogType.MEMBER_ROLE_REMOVE, - { - member: logMember, - roles: removedRoles - .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) - .map(r => r.name) - .join(", "), - mod: stripObjectToScalars(mod), - }, + if (!skip) { + const relevantAuditLogEntry = await this.findRelevantAuditLogEntry( + ErisConstants.AuditLogActions.MEMBER_ROLE_UPDATE, member.id, ); + const mod = relevantAuditLogEntry ? relevantAuditLogEntry.user : new UnknownUser(); + + if (addedRoles.length && removedRoles.length) { + // Roles added *and* removed + this.guildLogs.log( + LogType.MEMBER_ROLE_CHANGES, + { + member: logMember, + addedRoles: addedRoles + .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + removedRoles: removedRoles + .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } else if (addedRoles.length) { + // Roles added + this.guildLogs.log( + LogType.MEMBER_ROLE_ADD, + { + member: logMember, + roles: addedRoles + .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } else if (removedRoles.length && !addedRoles.length) { + // Roles removed + this.guildLogs.log( + LogType.MEMBER_ROLE_REMOVE, + { + member: logMember, + roles: removedRoles + .map(roleId => this.guild.roles.get(roleId) || { id: roleId, name: `Unknown (${roleId})` }) + .map(r => r.name) + .join(", "), + mod: stripObjectToScalars(mod), + }, + member.id, + ); + } } } } diff --git a/backend/src/plugins/ModActions.ts b/backend/src/plugins/ModActions.ts index 78713dab..ef26c937 100644 --- a/backend/src/plugins/ModActions.ts +++ b/backend/src/plugins/ModActions.ts @@ -10,6 +10,7 @@ import { disableUserNotificationStrings, errorMessage, findRelevantAuditLogEntry, + MINUTES, multiSorter, notifyUser, stripObjectToScalars, diff --git a/backend/src/plugins/Roles.ts b/backend/src/plugins/Roles.ts index 2ae0db00..6edf69f0 100644 --- a/backend/src/plugins/Roles.ts +++ b/backend/src/plugins/Roles.ts @@ -1,13 +1,14 @@ -import { ZeppelinPlugin, trimPluginDescription } from "./ZeppelinPlugin"; +import { trimPluginDescription, ZeppelinPlugin } from "./ZeppelinPlugin"; import * as t from "io-ts"; -import { stripObjectToScalars, tNullable } from "../utils"; -import { decorators as d, IPluginOptions, logger, waitForReaction, waitForReply } from "knub"; -import { Attachment, Constants as ErisConstants, Guild, GuildChannel, Member, Message, TextChannel, User } from "eris"; +import { stripObjectToScalars, successMessage } from "../utils"; +import { decorators as d, IPluginOptions, logger } from "knub"; +import { GuildChannel, Member, Message } from "eris"; import { GuildLogs } from "../data/GuildLogs"; import { LogType } from "../data/LogType"; const ConfigSchema = t.type({ can_assign: t.boolean, + can_mass_assign: t.boolean, assignable_roles: t.array(t.string), }); type TConfigSchema = t.TypeOf; @@ -33,6 +34,7 @@ export class RolesPlugin extends ZeppelinPlugin { return { config: { can_assign: false, + can_mass_assign: false, assignable_roles: [], }, overrides: [ @@ -42,11 +44,17 @@ export class RolesPlugin extends ZeppelinPlugin { can_assign: true, }, }, + { + level: ">=100", + config: { + can_mass_assign: true, + }, + }, ], }; } - @d.command("addrole", " [role:string$]", { + @d.command("addrole", " ", { extra: { info: { description: "Add a role to the specified member", @@ -95,7 +103,64 @@ export class RolesPlugin extends ZeppelinPlugin { this.sendSuccessMessage(msg.channel, "Role added to user!"); } - @d.command("removerole", " [role:string$]", { + @d.command("massaddrole", " ") + @d.permission("can_mass_assign") + async massAddRoleCmd(msg: Message, args: { role: string; members: Member[] }) { + for (const member of args.members) { + if (!this.canActOn(msg.member, member, true)) { + return this.sendErrorMessage( + msg.channel, + "Cannot add roles to 1 or more specified members: insufficient permissions", + ); + } + } + + const roleId = await this.resolveRoleId(args.role); + 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 membersWithoutTheRole = args.members.filter(m => !m.roles.includes(roleId)); + let assigned = 0; + let failed = 0; + const alreadyHadRole = args.members.length - membersWithoutTheRole.length; + + msg.channel.createMessage(`Adding role to specified members...`); + + for (const member of membersWithoutTheRole) { + try { + this.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, member.id); + await member.addRole(roleId); + this.logs.log(LogType.MEMBER_ROLE_ADD, { + member: stripObjectToScalars(member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + assigned++; + } catch (e) { + logger.warn(`Error when adding role via !massaddrole: ${e.message}`); + failed++; + } + } + + let resultMessage = `Role added to ${assigned} ${assigned === 1 ? "member" : "members"}!`; + if (failed > 0) { + resultMessage += ` Failed to add the role to ${failed} ${failed === 1 ? "member" : "members"}.`; + } + if (alreadyHadRole) { + resultMessage += ` ${alreadyHadRole} ${alreadyHadRole === 1 ? "member" : "members"} already had the role.`; + } + + msg.channel.createMessage(successMessage(resultMessage)); + } + + @d.command("removerole", " ", { extra: { info: { description: "Remove a role from the specified member", @@ -143,4 +208,61 @@ export class RolesPlugin extends ZeppelinPlugin { this.sendSuccessMessage(msg.channel, "Role removed from user!"); } + + @d.command("massremoverole", " ") + @d.permission("can_mass_assign") + async massRemoveRoleCmd(msg: Message, args: { role: string; members: Member[] }) { + for (const member of args.members) { + if (!this.canActOn(msg.member, member, true)) { + return this.sendErrorMessage( + msg.channel, + "Cannot add roles to 1 or more specified members: insufficient permissions", + ); + } + } + + const roleId = await this.resolveRoleId(args.role); + 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 membersWithTheRole = args.members.filter(m => m.roles.includes(roleId)); + let assigned = 0; + let failed = 0; + const didNotHaveRole = args.members.length - membersWithTheRole.length; + + msg.channel.createMessage(`Removing role from specified members...`); + + for (const member of membersWithTheRole) { + try { + this.logs.ignoreLog(LogType.MEMBER_ROLE_REMOVE, member.id); + await member.removeRole(roleId); + this.logs.log(LogType.MEMBER_ROLE_REMOVE, { + member: stripObjectToScalars(member, ["user", "roles"]), + roles: role.name, + mod: stripObjectToScalars(msg.author), + }); + assigned++; + } catch (e) { + logger.warn(`Error when removing role via !massremoverole: ${e.message}`); + failed++; + } + } + + let resultMessage = `Role removed from ${assigned} ${assigned === 1 ? "member" : "members"}!`; + if (failed > 0) { + resultMessage += ` Failed to remove the role from ${failed} ${failed === 1 ? "member" : "members"}.`; + } + if (didNotHaveRole) { + resultMessage += ` ${didNotHaveRole} ${didNotHaveRole === 1 ? "member" : "members"} didn't have the role.`; + } + + msg.channel.createMessage(successMessage(resultMessage)); + } } diff --git a/backend/src/utils.ts b/backend/src/utils.ts index 789c84cd..b44032c8 100644 --- a/backend/src/utils.ts +++ b/backend/src/utils.ts @@ -16,6 +16,7 @@ import { User, } from "eris"; import DiscordHTTPError from "eris/lib/errors/DiscordHTTPError"; // tslint:disable-line +import DiscordRESTError from "eris/lib/errors/DiscordRESTError"; // tslint:disable-line import url from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; @@ -332,6 +333,9 @@ export function sleep(ms: number): Promise { /** * Attempts to find a relevant audit log entry for the given user and action */ +const auditLogNextAttemptAfterFail: Map = new Map(); +const AUDIT_LOG_FAIL_COOLDOWN = 2 * MINUTES; + export async function findRelevantAuditLogEntry( guild: Guild, actionType: number, @@ -339,10 +343,20 @@ export async function findRelevantAuditLogEntry( attempts: number = 3, attemptDelay: number = 3000, ): Promise { + if (auditLogNextAttemptAfterFail.has(guild.id) && auditLogNextAttemptAfterFail.get(guild.id) > Date.now()) { + return null; + } + let auditLogs: GuildAuditLog; try { auditLogs = await guild.getAuditLogs(5, null, actionType); } catch (e) { + // If we don't have permission to read audit log, set audit log requests on cooldown + if (e instanceof DiscordRESTError && e.code === 50013) { + auditLogNextAttemptAfterFail.set(guild.id, Date.now() + AUDIT_LOG_FAIL_COOLDOWN); + throw e; + } + // Ignore internal server errors which seem to be pretty common with audit log requests if (!(e instanceof DiscordHTTPError) || e.code !== 500) { throw e; @@ -1015,10 +1029,18 @@ export function messageSummary(msg: SavedMessage) { } export function verboseUserMention(user: User | UnknownUser): string { + if (user.id == null) { + return `**${user.username}#${user.discriminator}**`; + } + return `<@!${user.id}> (**${user.username}#${user.discriminator}**, \`${user.id}\`)`; } export function verboseUserName(user: User | UnknownUser): string { + if (user.id == null) { + return `**${user.username}#${user.discriminator}**`; + } + return `**${user.username}#${user.discriminator}** (\`${user.id}\`)`; }