Add mass role add/remove commands. Fix UnknownUser mentions in logs. Fix unnecessary audit log reads when adding/removing roles and the log type is ignored.

This commit is contained in:
Dragory 2020-03-28 15:21:13 +02:00
parent 9aeae4f89e
commit 820c9b466e
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
5 changed files with 220 additions and 59 deletions

View file

@ -69,4 +69,7 @@ export enum LogType {
SET_ANTIRAID_AUTO,
AUTOMOD_SPAM_NEW,
MASS_ASSIGN_ROLES,
MASS_UNASSIGN_ROLES,
}

View file

@ -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<TConfigSchema> {
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,
);
}
}
}
}

View file

@ -10,6 +10,7 @@ import {
disableUserNotificationStrings,
errorMessage,
findRelevantAuditLogEntry,
MINUTES,
multiSorter,
notifyUser,
stripObjectToScalars,

View file

@ -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<typeof ConfigSchema>;
@ -33,6 +34,7 @@ export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
return {
config: {
can_assign: false,
can_mass_assign: false,
assignable_roles: [],
},
overrides: [
@ -42,11 +44,17 @@ export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
can_assign: true,
},
},
{
level: ">=100",
config: {
can_mass_assign: true,
},
},
],
};
}
@d.command("addrole", "<member:member> [role:string$]", {
@d.command("addrole", "<member:member> <role:string$>", {
extra: {
info: {
description: "Add a role to the specified member",
@ -95,7 +103,64 @@ export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
this.sendSuccessMessage(msg.channel, "Role added to user!");
}
@d.command("removerole", "<member:member> [role:string$]", {
@d.command("massaddrole", "<role:string> <members:member...>")
@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", "<member:member> <role:string$>", {
extra: {
info: {
description: "Remove a role from the specified member",
@ -143,4 +208,61 @@ export class RolesPlugin extends ZeppelinPlugin<TConfigSchema> {
this.sendSuccessMessage(msg.channel, "Role removed from user!");
}
@d.command("massremoverole", "<role:string> <members:member...>")
@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));
}
}

View file

@ -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<void> {
/**
* Attempts to find a relevant audit log entry for the given user and action
*/
const auditLogNextAttemptAfterFail: Map<string, number> = 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<GuildAuditLogEntry> {
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}\`)`;
}