diff --git a/backend/src/data/DefaultLogMessages.json b/backend/src/data/DefaultLogMessages.json index 62703878..3fa918a2 100644 --- a/backend/src/data/DefaultLogMessages.json +++ b/backend/src/data/DefaultLogMessages.json @@ -49,6 +49,7 @@ "CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "MASSBAN": "⚒ {userMention(mod)} massbanned {count} users", + "MASSMUTE": "📢🚫 {userMention(mod)} massmuted {count} users", "MEMBER_JOIN_WITH_PRIOR_RECORDS": "⚠ {userMention(member)} joined with prior records. Recent cases:\n{recentCaseSummary}", diff --git a/backend/src/data/LogType.ts b/backend/src/data/LogType.ts index 2a6d6f70..8ae42cd7 100644 --- a/backend/src/data/LogType.ts +++ b/backend/src/data/LogType.ts @@ -40,6 +40,7 @@ export enum LogType { CASE_CREATE, MASSBAN, + MASSMUTE, MEMBER_TIMED_MUTE, MEMBER_TIMED_UNMUTE, diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts index 76fc260f..fea68069 100644 --- a/backend/src/plugins/ModActions/ModActionsPlugin.ts +++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts @@ -32,6 +32,7 @@ import { warnMember } from "./functions/warnMember"; import { Member } from "eris"; import { kickMember } from "./functions/kickMember"; import { banUserId } from "./functions/banUserId"; +import { MassmuteCmd } from "./commands/MassmuteCmd"; import { trimPluginDescription } from "../../utils"; const defaultOptions = { @@ -62,6 +63,7 @@ const defaultOptions = { can_view: false, can_addcase: false, can_massban: false, + can_massmute: false, can_hidecase: false, can_act_as_other: false, }, @@ -82,6 +84,7 @@ const defaultOptions = { level: ">=100", config: { can_massban: true, + can_massmute: true, can_hidecase: true, can_act_as_other: true, }, @@ -124,6 +127,7 @@ export const ModActionsPlugin = zeppelinPlugin()("mod_acti UnbanCmd, ForcebanCmd, MassbanCmd, + MassmuteCmd, AddCaseCmd, CaseCmd, CasesUserCmd, diff --git a/backend/src/plugins/ModActions/commands/MassmuteCmd.ts b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts new file mode 100644 index 00000000..5b446c03 --- /dev/null +++ b/backend/src/plugins/ModActions/commands/MassmuteCmd.ts @@ -0,0 +1,105 @@ +import { modActionsCommand } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { stripObjectToScalars } from "../../../utils"; +import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; +import { TextChannel } from "eris"; +import { waitForReply } from "knub/dist/helpers"; +import { LogType } from "src/data/LogType"; +import { logger } from "src/logger"; +import { MutesPlugin } from "src/plugins/Mutes/MutesPlugin"; + +export const MassmuteCmd = modActionsCommand({ + trigger: "massmute", + permission: "can_massmute", + description: "Mass-mute a list of user IDs", + + signature: [ + { + userIds: ct.string({ rest: true }), + }, + ], + + async run({ pluginData, message: msg, args }) { + // Limit to 100 users at once (arbitrary?) + if (args.userIds.length > 100) { + sendErrorMessage(pluginData, msg.channel, `Can only massmute max 100 users at once`); + return; + } + + // Ask for mute reason + msg.channel.createMessage("Mute reason? `cancel` to cancel"); + const muteReasonReceived = await waitForReply(pluginData.client, msg.channel as TextChannel, msg.author.id); + if ( + !muteReasonReceived || + !muteReasonReceived.content || + muteReasonReceived.content.toLowerCase().trim() === "cancel" + ) { + sendErrorMessage(pluginData, msg.channel, "Cancelled"); + return; + } + + const muteReason = formatReasonWithAttachments(muteReasonReceived.content, msg.attachments); + + // Verify we can act upon all users + for (const userId of args.userIds) { + const member = pluginData.guild.members.get(userId); + if (member && !canActOn(pluginData, msg.member, member)) { + sendErrorMessage(pluginData, msg.channel, "Cannot massmute one or more users: insufficient permissions"); + return; + } + } + + // Ignore automatic mute cases and logs for these users + // We'll create our own cases below and post a single "mass muted" log instead + args.userIds.forEach(userId => { + // Use longer timeouts since this can take a while + pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000); + }); + + // Show loading indicator + const loadingMsg = await msg.channel.createMessage("Muting..."); + + // Mute everyone and count fails + const modId = msg.author.id; + const failedMutes = []; + const mutesPlugin = pluginData.getPlugin(MutesPlugin); + for (const userId of args.userIds) { + try { + await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, { + caseArgs: { + modId, + }, + }); + } catch (e) { + logger.info(e); + failedMutes.push(userId); + } + } + + // Clear loading indicator + loadingMsg.delete(); + + const successfulMuteCount = args.userIds.length - failedMutes.length; + if (successfulMuteCount === 0) { + // All mutes failed + sendErrorMessage(pluginData, msg.channel, "All mutes failed. Make sure the IDs are valid."); + } else { + // Success on all or some mutes + pluginData.state.serverLogs.log(LogType.MASSMUTE, { + mod: stripObjectToScalars(msg.author), + count: successfulMuteCount, + }); + + if (failedMutes.length) { + sendSuccessMessage( + pluginData, + msg.channel, + `Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`, + ); + } else { + sendSuccessMessage(pluginData, msg.channel, `Muted ${successfulMuteCount} users successfully`); + } + } + }, +}); diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts index 34ef8669..74546de6 100644 --- a/backend/src/plugins/ModActions/types.ts +++ b/backend/src/plugins/ModActions/types.ts @@ -33,6 +33,7 @@ export const ConfigSchema = t.type({ can_view: t.boolean, can_addcase: t.boolean, can_massban: t.boolean, + can_massmute: t.boolean, can_hidecase: t.boolean, can_act_as_other: t.boolean, }); diff --git a/backend/src/plugins/Utility/UtilityPlugin.ts b/backend/src/plugins/Utility/UtilityPlugin.ts index 23657d84..55669576 100644 --- a/backend/src/plugins/Utility/UtilityPlugin.ts +++ b/backend/src/plugins/Utility/UtilityPlugin.ts @@ -26,6 +26,7 @@ import { ReloadGuildCmd } from "./commands/ReloadGuildCmd"; import { JumboCmd } from "./commands/JumboCmd"; import { AvatarCmd } from "./commands/AvatarCmd"; import { CleanCmd } from "./commands/CleanCmd"; +import { Message } from "eris"; const defaultOptions: PluginOptions = { config: { @@ -46,6 +47,7 @@ const defaultOptions: PluginOptions = { can_jumbo: false, jumbo_size: 128, can_avatar: false, + info_on_single_result: true, }, overrides: [ { diff --git a/backend/src/plugins/Utility/actualInfoCmd.ts b/backend/src/plugins/Utility/actualInfoCmd.ts new file mode 100644 index 00000000..7a503edc --- /dev/null +++ b/backend/src/plugins/Utility/actualInfoCmd.ts @@ -0,0 +1,129 @@ +import { Message, GuildTextableChannel, EmbedOptions } from "eris"; +import { PluginData } from "knub"; +import { UtilityPluginType } from "./types"; +import { UnknownUser, trimLines, embedPadding, resolveMember } from "src/utils"; +import moment from "moment-timezone"; +import { CaseTypes } from "src/data/CaseTypes"; +import humanizeDuration from "humanize-duration"; + +export async function actualInfoCmd(msg: Message, args: any, pluginData: PluginData) { + const user = args.user || msg.author; + + let member; + if (!(user instanceof UnknownUser)) { + member = await resolveMember(pluginData.client, (msg.channel as GuildTextableChannel).guild, user.id); + } + + const embed: EmbedOptions = { + fields: [], + }; + + if (user && !(user instanceof UnknownUser)) { + const createdAt = moment(user.createdAt); + const accountAge = humanizeDuration(moment().valueOf() - user.createdAt, { + largest: 2, + round: true, + }); + + embed.title = `${user.username}#${user.discriminator}`; + embed.thumbnail = { url: user.avatarURL }; + + if (args.compact) { + embed.fields.push({ + name: "User information", + value: trimLines(` + Profile: <@!${user.id}> + Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** + `), + }); + if (member) { + const joinedAt = moment(member.joinedAt); + const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { + largest: 2, + round: true, + }); + embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`; + } else { + embed.fields.push({ + name: "!! USER IS NOT ON THE SERVER !!", + value: embedPadding, + }); + } + msg.channel.createMessage({ embed }); + return; + } else { + embed.fields.push({ + name: "User information", + value: + trimLines(` + ID: **${user.id}** + Profile: <@!${user.id}> + Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** + `) + embedPadding, + }); + } + } else { + embed.title = `Unknown user`; + } + + if (member) { + const joinedAt = moment(member.joinedAt); + const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { + largest: 2, + round: true, + }); + const roles = member.roles.map(id => pluginData.guild.roles.get(id)).filter(r => !!r); + + embed.fields.push({ + name: "Member information", + value: + trimLines(` + Joined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})** + ${roles.length > 0 ? "Roles: " + roles.map(r => r.name).join(", ") : ""} + `) + embedPadding, + }); + + const voiceChannel = member.voiceState.channelID + ? pluginData.guild.channels.get(member.voiceState.channelID) + : null; + if (voiceChannel || member.voiceState.mute || member.voiceState.deaf) { + embed.fields.push({ + name: "Voice information", + value: + trimLines(` + ${voiceChannel ? `Current voice channel: **${voiceChannel ? voiceChannel.name : "None"}**` : ""} + ${member.voiceState.mute ? "Server voice muted: **Yes**" : ""} + ${member.voiceState.deaf ? "Server voice deafened: **Yes**" : ""} + `) + embedPadding, + }); + } + } else { + embed.fields.push({ + name: "!! USER IS NOT ON THE SERVER !!", + value: embedPadding, + }); + } + const cases = (await pluginData.state.cases.getByUserId(user.id)).filter(c => !c.is_hidden); + + if (cases.length > 0) { + cases.sort((a, b) => { + return a.created_at < b.created_at ? 1 : -1; + }); + + const caseSummary = cases.slice(0, 3).map(c => { + return `${CaseTypes[c.type]} (#${c.case_number})`; + }); + + const summaryText = cases.length > 3 ? "Last 3 cases" : "Summary"; + + embed.fields.push({ + name: "Cases", + value: trimLines(` + Total cases: **${cases.length}** + ${summaryText}: ${caseSummary.join(", ")} + `), + }); + } + + msg.channel.createMessage({ embed }); +} diff --git a/backend/src/plugins/Utility/commands/InfoCmd.ts b/backend/src/plugins/Utility/commands/InfoCmd.ts index 81597bdb..a96e750b 100644 --- a/backend/src/plugins/Utility/commands/InfoCmd.ts +++ b/backend/src/plugins/Utility/commands/InfoCmd.ts @@ -1,10 +1,6 @@ import { utilityCmd } from "../types"; import { commandTypeHelpers as ct } from "../../../commandTypes"; -import { embedPadding, resolveMember, trimLines, UnknownUser } from "../../../utils"; -import { EmbedOptions, GuildTextableChannel } from "eris"; -import moment from "moment-timezone"; -import humanizeDuration from "humanize-duration"; -import { CaseTypes } from "../../../data/CaseTypes"; +import { actualInfoCmd } from "../actualInfoCmd"; export const InfoCmd = utilityCmd({ trigger: "info", @@ -19,124 +15,6 @@ export const InfoCmd = utilityCmd({ }, async run({ message: msg, args, pluginData }) { - const user = args.user || msg.author; - - let member; - if (!(user instanceof UnknownUser)) { - member = await resolveMember(pluginData.client, (msg.channel as GuildTextableChannel).guild, user.id); - } - - const embed: EmbedOptions = { - fields: [], - }; - - if (user && !(user instanceof UnknownUser)) { - const createdAt = moment(user.createdAt); - const accountAge = humanizeDuration(moment().valueOf() - user.createdAt, { - largest: 2, - round: true, - }); - - embed.title = `${user.username}#${user.discriminator}`; - embed.thumbnail = { url: user.avatarURL }; - - if (args.compact) { - embed.fields.push({ - name: "User information", - value: trimLines(` - Profile: <@!${user.id}> - Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - `), - }); - if (member) { - const joinedAt = moment(member.joinedAt); - const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { - largest: 2, - round: true, - }); - embed.fields[0].value += `\nJoined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})**`; - } else { - embed.fields.push({ - name: "!! USER IS NOT ON THE SERVER !!", - value: embedPadding, - }); - } - msg.channel.createMessage({ embed }); - return; - } else { - embed.fields.push({ - name: "User information", - value: - trimLines(` - ID: **${user.id}** - Profile: <@!${user.id}> - Created: **${accountAge} ago (${createdAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - `) + embedPadding, - }); - } - } else { - embed.title = `Unknown user`; - } - - if (member) { - const joinedAt = moment(member.joinedAt); - const joinAge = humanizeDuration(moment().valueOf() - member.joinedAt, { - largest: 2, - round: true, - }); - const roles = member.roles.map(id => pluginData.guild.roles.get(id)).filter(r => !!r); - - embed.fields.push({ - name: "Member information", - value: - trimLines(` - Joined: **${joinAge} ago (${joinedAt.format("YYYY-MM-DD[T]HH:mm:ss")})** - ${roles.length > 0 ? "Roles: " + roles.map(r => r.name).join(", ") : ""} - `) + embedPadding, - }); - - const voiceChannel = member.voiceState.channelID - ? pluginData.guild.channels.get(member.voiceState.channelID) - : null; - if (voiceChannel || member.voiceState.mute || member.voiceState.deaf) { - embed.fields.push({ - name: "Voice information", - value: - trimLines(` - ${voiceChannel ? `Current voice channel: **${voiceChannel ? voiceChannel.name : "None"}**` : ""} - ${member.voiceState.mute ? "Server voice muted: **Yes**" : ""} - ${member.voiceState.deaf ? "Server voice deafened: **Yes**" : ""} - `) + embedPadding, - }); - } - } else { - embed.fields.push({ - name: "!! USER IS NOT ON THE SERVER !!", - value: embedPadding, - }); - } - const cases = (await pluginData.state.cases.getByUserId(user.id)).filter(c => !c.is_hidden); - - if (cases.length > 0) { - cases.sort((a, b) => { - return a.created_at < b.created_at ? 1 : -1; - }); - - const caseSummary = cases.slice(0, 3).map(c => { - return `${CaseTypes[c.type]} (#${c.case_number})`; - }); - - const summaryText = cases.length > 3 ? "Last 3 cases" : "Summary"; - - embed.fields.push({ - name: "Cases", - value: trimLines(` - Total cases: **${cases.length}** - ${summaryText}: ${caseSummary.join(", ")} - `), - }); - } - - msg.channel.createMessage({ embed }); + actualInfoCmd(msg, args, pluginData); }, }); diff --git a/backend/src/plugins/Utility/search.ts b/backend/src/plugins/Utility/search.ts index 90e7153f..dae70a7e 100644 --- a/backend/src/plugins/Utility/search.ts +++ b/backend/src/plugins/Utility/search.ts @@ -10,6 +10,7 @@ import { searchCmdSignature } from "./commands/SearchCmd"; import { banSearchSignature } from "./commands/BanSearchCmd"; import { UtilityPluginType } from "./types"; import { refreshMembersIfNeeded } from "./refreshMembers"; +import { actualInfoCmd } from "./actualInfoCmd"; const SEARCH_RESULTS_PER_PAGE = 15; const SEARCH_ID_RESULTS_PER_PAGE = 50; @@ -109,6 +110,14 @@ export async function displaySearch( `); const searchMsg = await searchMsgPromise; + + const cfg = pluginData.config.getForUser(msg.author); + if (cfg.info_on_single_result && searchResult.totalResults === 1) { + searchMsg.edit("Only one result:"); + actualInfoCmd(msg, { user: searchResult.results[0], compact: false }, pluginData); + return; + } + searchMsg.edit(result); // Set up pagination reactions if needed. The reactions are cleared after a timeout. diff --git a/backend/src/plugins/Utility/types.ts b/backend/src/plugins/Utility/types.ts index c927034e..f2637dbd 100644 --- a/backend/src/plugins/Utility/types.ts +++ b/backend/src/plugins/Utility/types.ts @@ -24,6 +24,7 @@ export const ConfigSchema = t.type({ can_jumbo: t.boolean, jumbo_size: t.Integer, can_avatar: t.boolean, + info_on_single_result: t.boolean, }); export type TConfigSchema = t.TypeOf;