diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts new file mode 100644 index 00000000..ded80831 --- /dev/null +++ b/backend/src/plugins/Mutes/MutesPlugin.ts @@ -0,0 +1,84 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, MutesPluginType } from "./types"; +import { CasesPlugin } from "../Cases/CasesPlugin"; +import { GuildMutes } from "../../data/GuildMutes"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildArchives } from "../../data/GuildArchives"; +import { clearExpiredMutes } from "./functions/clearExpiredMutes"; +import { MutesCmd } from "./commands/MutesCmd"; +import { ClearBannedMutesCmd } from "./commands/ClearBannedMutesCmd"; +import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt"; +import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd"; +import { ClearMutesCmd } from "./commands/ClearMutesCmd"; + +const defaultOptions = { + config: { + mute_role: null, + move_to_voice_channel: null, + + dm_on_mute: false, + dm_on_update: false, + message_on_mute: false, + message_on_update: false, + message_channel: null, + mute_message: "You have been muted on the {guildName} server. Reason given: {reason}", + timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}", + update_mute_message: "Your mute on the {guildName} server has been updated to {time}.", + + can_view_list: false, + can_cleanup: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_view_list: true, + }, + }, + { + level: ">=100", + config: { + can_cleanup: true, + }, + }, + ], +}; + +const EXPIRED_MUTE_CHECK_INTERVAL = 60 * 1000; +let FIRST_CHECK_TIME = Date.now(); +const FIRST_CHECK_INCREMENT = 5 * 1000; + +export const MutesPlugin = zeppelinPlugin<MutesPluginType>()("mutes", { + configSchema: ConfigSchema, + defaultOptions, + + dependencies: [CasesPlugin], + + commands: [MutesCmd, ClearBannedMutesCmd, ClearMutesWithoutRoleCmd, ClearMutesCmd], + + events: [ClearActiveMuteOnRoleRemovalEvt], + + onLoad(pluginData) { + pluginData.state.mutes = GuildMutes.getGuildInstance(pluginData.guild.id); + pluginData.state.cases = GuildCases.getGuildInstance(pluginData.guild.id); + pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); + pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); + + // Check for expired mutes every 5s + const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT; + FIRST_CHECK_TIME = firstCheckTime; + + setTimeout(() => { + clearExpiredMutes(pluginData); + pluginData.state.muteClearIntervalId = setInterval( + () => clearExpiredMutes(pluginData), + EXPIRED_MUTE_CHECK_INTERVAL, + ); + }, firstCheckTime - Date.now()); + }, + + onUnload(pluginData) { + clearInterval(pluginData.state.muteClearIntervalId); + }, +}); diff --git a/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts new file mode 100644 index 00000000..f0e6deb4 --- /dev/null +++ b/backend/src/plugins/Mutes/commands/ClearBannedMutesCmd.ts @@ -0,0 +1,34 @@ +import { command } from "knub"; +import { MutesPluginType } from "../types"; +import { User } from "eris"; +import { sendSuccessMessage } from "../../../pluginUtils"; + +export const ClearBannedMutesCmd = command<MutesPluginType>()({ + trigger: "clear_banned_mutes", + permission: "can_cleanup", + description: "Clear dangling mutes for members who have been banned", + + async run({ pluginData, message: msg }) { + await msg.channel.createMessage("Clearing mutes from banned users..."); + + const activeMutes = await pluginData.state.mutes.getActiveMutes(); + + // Mismatch in Eris docs and actual result here, based on Eris's code comments anyway + const bans: Array<{ reason: string; user: User }> = (await pluginData.guild.getBans()) as any; + const bannedIds = bans.map(b => b.user.id); + + await msg.channel.createMessage( + `Found ${activeMutes.length} mutes and ${bannedIds.length} bans, cross-referencing...`, + ); + + let cleared = 0; + for (const mute of activeMutes) { + if (bannedIds.includes(mute.user_id)) { + await pluginData.state.mutes.clear(mute.user_id); + cleared++; + } + } + + sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from banned users!`); + }, +}); diff --git a/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts new file mode 100644 index 00000000..0ada21c9 --- /dev/null +++ b/backend/src/plugins/Mutes/commands/ClearMutesCmd.ts @@ -0,0 +1,39 @@ +import { command } from "knub"; +import { MutesPluginType } from "../types"; +import { User } from "eris"; +import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; + +export const ClearMutesCmd = command<MutesPluginType>()({ + trigger: "clear_mutes", + permission: "can_cleanup", + description: "Clear dangling mute records from the bot. Be careful not to clear valid mutes.", + + signature: { + userIds: ct.string({ rest: true }), + }, + + async run({ pluginData, message: msg, args }) { + const failed = []; + for (const id of args.userIds) { + const mute = await pluginData.state.mutes.findExistingMuteForUserId(id); + if (!mute) { + failed.push(id); + continue; + } + await pluginData.state.mutes.clear(id); + } + + if (failed.length !== args.userIds.length) { + sendSuccessMessage(pluginData, msg.channel, `**${args.userIds.length - failed.length} active mute(s) cleared**`); + } + + if (failed.length) { + sendErrorMessage( + pluginData, + msg.channel, + `**${failed.length}/${args.userIds.length} IDs failed**, they are not muted: ${failed.join(" ")}`, + ); + } + }, +}); diff --git a/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts new file mode 100644 index 00000000..42966ee1 --- /dev/null +++ b/backend/src/plugins/Mutes/commands/ClearMutesWithoutRoleCmd.ts @@ -0,0 +1,32 @@ +import { command } from "knub"; +import { MutesPluginType } from "../types"; +import { User } from "eris"; +import { sendSuccessMessage } from "../../../pluginUtils"; +import { resolveMember } from "../../../utils"; + +export const ClearMutesWithoutRoleCmd = command<MutesPluginType>()({ + trigger: "clear_mutes_without_role", + permission: "can_cleanup", + description: "Clear dangling mutes for members whose mute role was removed by other means", + + async run({ pluginData, message: msg }) { + const activeMutes = await pluginData.state.mutes.getActiveMutes(); + const muteRole = pluginData.config.get().mute_role; + if (!muteRole) return; + + await msg.channel.createMessage("Clearing mutes from members that don't have the mute role..."); + + let cleared = 0; + for (const mute of activeMutes) { + const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); + if (!member) continue; + + if (!member.roles.includes(muteRole)) { + await pluginData.state.mutes.clear(mute.user_id); + cleared++; + } + } + + sendSuccessMessage(pluginData, msg.channel, `Cleared ${cleared} mutes from members that don't have the mute role`); + }, +}); diff --git a/backend/src/plugins/Mutes/commands/MutesCmd.ts b/backend/src/plugins/Mutes/commands/MutesCmd.ts new file mode 100644 index 00000000..6a643da7 --- /dev/null +++ b/backend/src/plugins/Mutes/commands/MutesCmd.ts @@ -0,0 +1,226 @@ +import { command } from "knub"; +import { IMuteWithDetails, MutesPluginType } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { DBDateFormat, isFullMessage, MINUTES, noop, resolveMember } from "../../../utils"; +import moment from "moment-timezone"; +import { humanizeDurationShort } from "../../../humanizeDurationShort"; +import { getBaseUrl } from "../../../pluginUtils"; + +export const MutesCmd = command<MutesPluginType>()({ + trigger: "mutes", + permission: "can_view_list", + + signature: { + age: ct.delay({ + option: true, + shortcut: "a", + }), + + left: ct.switchOption({ shortcut: "l" }), + manual: ct.switchOption({ shortcut: "m" }), + export: ct.switchOption({ shortcut: "e" }), + }, + + async run({ pluginData, message: msg, args }) { + const listMessagePromise = msg.channel.createMessage("Loading mutes..."); + const mutesPerPage = 10; + let totalMutes = 0; + let hasFilters = false; + + let hasReactions = false; + let clearReactionsFn; + let clearReactionsTimeout; + const clearReactionsDebounce = 5 * MINUTES; + + let lines = []; + + // Active, logged mutes + const activeMutes = await pluginData.state.mutes.getActiveMutes(); + activeMutes.sort((a, b) => { + if (a.expires_at == null && b.expires_at != null) return 1; + if (b.expires_at == null && a.expires_at != null) return -1; + if (a.expires_at == null && b.expires_at == null) { + return a.created_at > b.created_at ? -1 : 1; + } + return a.expires_at > b.expires_at ? 1 : -1; + }); + + if (args.manual) { + // Show only manual mutes (i.e. "Muted" role added without a logged mute) + const muteUserIds = new Set(activeMutes.map(m => m.user_id)); + const manuallyMutedMembers = []; + const muteRole = pluginData.config.get().mute_role; + + if (muteRole) { + pluginData.guild.members.forEach(member => { + if (muteUserIds.has(member.id)) return; + if (member.roles.includes(muteRole)) manuallyMutedMembers.push(member); + }); + } + + totalMutes = manuallyMutedMembers.length; + + lines = manuallyMutedMembers.map(member => { + return `<@!${member.id}> (**${member.user.username}#${member.user.discriminator}**, \`${member.id}\`) 🔧 Manual mute`; + }); + } else { + // Show filtered active mutes (but not manual mutes) + let filteredMutes: IMuteWithDetails[] = activeMutes; + let bannedIds: string[] = null; + + // Filter: mute age + if (args.age) { + const cutoff = moment() + .subtract(args.age, "ms") + .format(DBDateFormat); + filteredMutes = filteredMutes.filter(m => m.created_at <= cutoff); + hasFilters = true; + } + + // Fetch some extra details for each mute: the muted member, and whether they've been banned + for (const [index, mute] of filteredMutes.entries()) { + const muteWithDetails = { ...mute }; + + const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); + + if (!member) { + if (!bannedIds) { + const bans = await pluginData.guild.getBans(); + bannedIds = bans.map(u => u.user.id); + } + + muteWithDetails.banned = bannedIds.includes(mute.user_id); + } else { + muteWithDetails.member = member; + } + + filteredMutes[index] = muteWithDetails; + } + + // Filter: left the server + if (args.left != null) { + filteredMutes = filteredMutes.filter(m => (args.left && !m.member) || (!args.left && m.member)); + hasFilters = true; + } + + totalMutes = filteredMutes.length; + + // Create a message line for each mute + const caseIds = filteredMutes.map(m => m.case_id).filter(v => !!v); + const muteCases = caseIds.length ? await pluginData.state.cases.get(caseIds) : []; + const muteCasesById = muteCases.reduce((map, c) => map.set(c.id, c), new Map()); + + lines = filteredMutes.map(mute => { + const user = pluginData.client.users.get(mute.user_id); + const username = user ? `${user.username}#${user.discriminator}` : "Unknown#0000"; + const theCase = muteCasesById.get(mute.case_id); + const caseName = theCase ? `Case #${theCase.case_number}` : "No case"; + + let line = `<@!${mute.user_id}> (**${username}**, \`${mute.user_id}\`) 📋 ${caseName}`; + + if (mute.expires_at) { + const timeUntilExpiry = moment().diff(moment(mute.expires_at, DBDateFormat)); + const humanizedTime = humanizeDurationShort(timeUntilExpiry, { largest: 2, round: true }); + line += ` ⏰ Expires in ${humanizedTime}`; + } else { + line += ` ⏰ Indefinite`; + } + + const timeFromMute = moment(mute.created_at, DBDateFormat).diff(moment()); + const humanizedTimeFromMute = humanizeDurationShort(timeFromMute, { largest: 2, round: true }); + line += ` 🕒 Muted ${humanizedTimeFromMute} ago`; + + if (mute.banned) { + line += ` 🔨 Banned`; + } else if (!mute.member) { + line += ` ❌ Left server`; + } + + return line; + }); + } + + const listMessage = await listMessagePromise; + + let currentPage = 1; + const totalPages = Math.ceil(lines.length / mutesPerPage); + + const drawListPage = async page => { + page = Math.max(1, Math.min(totalPages, page)); + currentPage = page; + + const pageStart = (page - 1) * mutesPerPage; + const pageLines = lines.slice(pageStart, pageStart + mutesPerPage); + + const pageRangeText = `${pageStart + 1}–${pageStart + pageLines.length} of ${totalMutes}`; + + let message; + if (args.manual) { + message = `Showing manual mutes ${pageRangeText}:`; + } else if (hasFilters) { + message = `Showing filtered active mutes ${pageRangeText}:`; + } else { + message = `Showing active mutes ${pageRangeText}:`; + } + + message += "\n\n" + pageLines.join("\n"); + + listMessage.edit(message); + bumpClearReactionsTimeout(); + }; + + const bumpClearReactionsTimeout = () => { + if (!hasReactions) return; + clearTimeout(clearReactionsTimeout); + clearReactionsTimeout = setTimeout(clearReactionsFn, clearReactionsDebounce); + }; + + if (totalMutes === 0) { + if (args.manual) { + listMessage.edit("No manual mutes found!"); + } else if (hasFilters) { + listMessage.edit("No mutes found with the specified filters!"); + } else { + listMessage.edit("No active mutes!"); + } + } else if (args.export) { + const archiveId = await pluginData.state.archives.create(lines.join("\n"), moment().add(1, "hour")); + const baseUrl = getBaseUrl(pluginData); + const url = await pluginData.state.archives.getUrl(baseUrl, archiveId); + + await listMessage.edit(`Exported mutes: ${url}`); + } else { + drawListPage(1); + + if (totalPages > 1) { + hasReactions = true; + listMessage.addReaction("⬅"); + listMessage.addReaction("➡"); + + const paginationReactionListener = pluginData.events.on( + "messageReactionAdd", + ({ args: { message: rMsg, emoji, userID } }) => { + if (!isFullMessage(rMsg)) return; + if (rMsg.id !== listMessage.id) return; + if (userID !== msg.author.id) return; + if (!["⬅", "➡"].includes(emoji.name)) return; + + if (emoji.name === "⬅" && currentPage > 1) { + drawListPage(currentPage - 1); + } else if (emoji.name === "➡" && currentPage < totalPages) { + drawListPage(currentPage + 1); + } + + rMsg.removeReaction(emoji.name, userID).catch(noop); + }, + ); + + clearReactionsFn = () => { + listMessage.removeReactions().catch(noop); + pluginData.events.off("messageReactionAdd", paginationReactionListener); + }; + bumpClearReactionsTimeout(); + } + } + }, +}); diff --git a/backend/src/plugins/Mutes/events/ClearActiveMuteOnRoleRemovalEvt.ts b/backend/src/plugins/Mutes/events/ClearActiveMuteOnRoleRemovalEvt.ts new file mode 100644 index 00000000..6edc6d7b --- /dev/null +++ b/backend/src/plugins/Mutes/events/ClearActiveMuteOnRoleRemovalEvt.ts @@ -0,0 +1,21 @@ +import { eventListener } from "knub"; +import { MutesPluginType } from "../types"; +import { memberHasMutedRole } from "../functions/memberHasMutedRole"; + +/** + * Clear active mute if the mute role is removed manually + */ +export const ClearActiveMuteOnRoleRemovalEvt = eventListener<MutesPluginType>()( + "guildMemberUpdate", + async ({ pluginData, args: { member } }) => { + const muteRole = pluginData.config.get().mute_role; + if (!muteRole) return; + + const mute = await pluginData.state.mutes.findExistingMuteForUserId(member.id); + if (!mute) return; + + if (!memberHasMutedRole(pluginData, member)) { + await pluginData.state.mutes.clear(muteRole); + } + }, +); diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts new file mode 100644 index 00000000..742c5532 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts @@ -0,0 +1,30 @@ +import { PluginData } from "knub"; +import { MutesPluginType } from "../types"; +import { LogType } from "../../../data/LogType"; +import { resolveMember, stripObjectToScalars, UnknownUser } from "../../../utils"; + +export async function clearExpiredMutes(pluginData: PluginData<MutesPluginType>) { + const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); + for (const mute of expiredMutes) { + const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id); + + if (member) { + try { + await member.removeRole(pluginData.config.get().mute_role); + } catch (e) { + pluginData.state.serverLogs.log(LogType.BOT_ALERT, { + body: `Failed to remove mute role from {userMention(member)}`, + member: stripObjectToScalars(member), + }); + } + } + + await pluginData.state.mutes.clear(mute.user_id); + + pluginData.state.serverLogs.log(LogType.MEMBER_MUTE_EXPIRED, { + member: member + ? stripObjectToScalars(member, ["user", "roles"]) + : { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) }, + }); + } +} diff --git a/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts b/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts new file mode 100644 index 00000000..474d6fa9 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/memberHasMutedRole.ts @@ -0,0 +1,7 @@ +import { Member } from "eris"; +import { PluginData } from "knub"; +import { MutesPluginType } from "../types"; + +export function memberHasMutedRole(pluginData: PluginData<MutesPluginType>, member: Member) { + return member.roles.includes(pluginData.config.get().mute_role); +} diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts new file mode 100644 index 00000000..b838ba9d --- /dev/null +++ b/backend/src/plugins/Mutes/functions/muteUser.ts @@ -0,0 +1,173 @@ +import { PluginData } from "knub"; +import { MuteOptions, MutesPluginType } from "../types"; +import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError"; +import humanizeDuration from "humanize-duration"; +import { + notifyUser, + resolveMember, + resolveUser, + stripObjectToScalars, + ucfirst, + UserNotificationResult, +} from "../../../utils"; +import { renderTemplate } from "../../../templateFormatter"; +import { TextChannel, User } from "eris"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { LogType } from "../../../data/LogType"; + +export async function muteUser( + pluginData: PluginData<MutesPluginType>, + userId: string, + muteTime: number = null, + reason: string = null, + muteOptions: MuteOptions = {}, +) { + const lock = await pluginData.locks.acquire(`mute-${userId}`); + + const muteRole = pluginData.config.get().mute_role; + if (!muteRole) { + lock.unlock(); + throw new RecoverablePluginError(ERRORS.NO_MUTE_ROLE_IN_CONFIG); + } + + const timeUntilUnmute = muteTime ? humanizeDuration(muteTime) : "indefinite"; + + // No mod specified -> mark Zeppelin as the mod + if (!muteOptions.caseArgs?.modId) { + muteOptions.caseArgs = muteOptions.caseArgs ?? {}; + muteOptions.caseArgs.modId = pluginData.client.user.id; + } + + const user = await resolveUser(pluginData.client, userId); + const member = await pluginData.client.getRESTGuildMember(pluginData.guild.id, user.id); // Grab the fresh member so we don't have stale role info + const config = pluginData.config.getMatchingConfig({ member, userId }); + + if (member) { + // Apply mute role if it's missing + if (!member.roles.includes(muteRole)) { + await member.addRole(muteRole); + } + + // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) + const moveToVoiceChannelId = pluginData.config.get().move_to_voice_channel; + if (moveToVoiceChannelId) { + // TODO: Add back the voiceState check once we figure out how to get voice state for guild members that are loaded on-demand + try { + await member.edit({ channelID: moveToVoiceChannelId }); + } catch (e) {} // tslint:disable-line + } + } + + // If the user is already muted, update the duration of their existing mute + const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(user.id); + let notifyResult: UserNotificationResult = { method: null, success: true }; + + if (existingMute) { + await pluginData.state.mutes.updateExpiryTime(user.id, muteTime); + } else { + await pluginData.state.mutes.addMute(user.id, muteTime); + } + + const template = existingMute + ? config.update_mute_message + : muteTime + ? config.timed_mute_message + : config.mute_message; + + const muteMessage = + template && + (await renderTemplate(template, { + guildName: pluginData.guild.name, + reason: reason || "None", + time: timeUntilUnmute, + })); + + if (muteMessage && user instanceof User) { + let contactMethods = []; + + if (muteOptions?.contactMethods) { + contactMethods = muteOptions.contactMethods; + } else { + const useDm = existingMute ? config.dm_on_update : config.dm_on_mute; + if (useDm) { + contactMethods.push({ type: "dm" }); + } + + const useChannel = existingMute ? config.message_on_update : config.message_on_mute; + const channel = config.message_channel && pluginData.guild.channels.get(config.message_channel); + if (useChannel && channel instanceof TextChannel) { + contactMethods.push({ type: "channel", channel }); + } + } + + notifyResult = await notifyUser(user, muteMessage, contactMethods); + } + + // Create/update a case + const casesPlugin = pluginData.getPlugin(CasesPlugin); + let theCase; + + if (existingMute && existingMute.case_id) { + // Update old case + // Since mutes can often have multiple notes (extraNotes), we won't post each case note individually, + // but instead we'll post the entire case afterwards + theCase = await pluginData.state.cases.find(existingMute.case_id); + const noteDetails = [`Mute updated to ${muteTime ? timeUntilUnmute : "indefinite"}`]; + const reasons = [reason, ...(muteOptions.caseArgs?.extraNotes || [])]; + for (const noteReason of reasons) { + await casesPlugin.createCaseNote({ + caseId: existingMute.case_id, + modId: muteOptions.caseArgs?.modId, + body: noteReason, + noteDetails, + postInCaseLogOverride: false, + }); + } + + if (muteOptions.caseArgs?.postInCaseLogOverride !== false) { + casesPlugin.postCaseToCaseLogChannel(existingMute.case_id); + } + } else { + // Create new case + const noteDetails = [`Muted ${muteTime ? `for ${timeUntilUnmute}` : "indefinitely"}`]; + if (notifyResult.text) { + noteDetails.push(ucfirst(notifyResult.text)); + } + + theCase = await casesPlugin.createCase({ + ...(muteOptions.caseArgs || {}), + userId, + modId: muteOptions.caseArgs?.modId, + type: CaseTypes.Mute, + reason, + noteDetails, + }); + await pluginData.state.mutes.setCaseId(user.id, theCase.id); + } + + // Log the action + const mod = await resolveUser(pluginData.client, muteOptions.caseArgs?.modId); + if (muteTime) { + pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_MUTE, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + time: timeUntilUnmute, + reason, + }); + } else { + pluginData.state.serverLogs.log(LogType.MEMBER_MUTE, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + reason, + }); + } + + lock.unlock(); + + return { + case: theCase, + notifyResult, + updatedExistingMute: !!existingMute, + }; +} diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts new file mode 100644 index 00000000..a5762216 --- /dev/null +++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts @@ -0,0 +1,89 @@ +import { PluginData } from "knub"; +import { MutesPluginType, UnmuteResult } from "../types"; +import { CaseArgs } from "../../Cases/types"; +import { resolveUser, stripObjectToScalars } from "../../../utils"; +import { memberHasMutedRole } from "./memberHasMutedRole"; +import humanizeDuration from "humanize-duration"; +import { CasesPlugin } from "../../Cases/CasesPlugin"; +import { CaseTypes } from "../../../data/CaseTypes"; +import { LogType } from "../../../data/LogType"; + +export async function unmuteUser( + pluginData: PluginData<MutesPluginType>, + userId: string, + unmuteTime: number = null, + caseArgs: Partial<CaseArgs> = {}, +): Promise<UnmuteResult> { + const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(userId); + const user = await resolveUser(pluginData.client, userId); + const member = await pluginData.client.getRESTGuildMember(pluginData.guild.id, userId); // Grab the fresh member so we don't have stale role info + + if (!existingMute && !memberHasMutedRole(pluginData, member)) return; + + if (unmuteTime) { + // Schedule timed unmute (= just set the mute's duration) + if (!existingMute) { + await pluginData.state.mutes.addMute(userId, unmuteTime); + } else { + await pluginData.state.mutes.updateExpiryTime(userId, unmuteTime); + } + } else { + // Unmute immediately + if (member) { + const muteRole = pluginData.config.get().mute_role; + if (member.roles.includes(muteRole)) { + await member.removeRole(muteRole); + } + } else { + console.warn( + `Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`, + ); + } + if (existingMute) { + await pluginData.state.mutes.clear(userId); + } + } + + const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime); + + // Create a case + const noteDetails = []; + if (unmuteTime) { + noteDetails.push(`Scheduled unmute in ${timeUntilUnmute}`); + } else { + noteDetails.push(`Unmuted immediately`); + } + if (!existingMute) { + noteDetails.push(`Removed external mute`); + } + + const casesPlugin = pluginData.getPlugin(CasesPlugin); + const createdCase = await casesPlugin.createCase({ + ...caseArgs, + userId, + modId: caseArgs.modId, + type: CaseTypes.Unmute, + noteDetails, + }); + + // Log the action + const mod = pluginData.client.users.get(caseArgs.modId); + if (unmuteTime) { + pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_UNMUTE, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + time: timeUntilUnmute, + reason: caseArgs.reason, + }); + } else { + pluginData.state.serverLogs.log(LogType.MEMBER_UNMUTE, { + mod: stripObjectToScalars(mod), + user: stripObjectToScalars(user), + reason: caseArgs.reason, + }); + } + + return { + case: createdCase, + }; +} diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts new file mode 100644 index 00000000..e3014838 --- /dev/null +++ b/backend/src/plugins/Mutes/types.ts @@ -0,0 +1,62 @@ +import * as t from "io-ts"; +import { tNullable, UserNotificationMethod, UserNotificationResult } from "../../utils"; +import { Mute } from "../../data/entities/Mute"; +import { Member } from "eris"; +import { Case } from "../../data/entities/Case"; +import { BasePluginType } from "knub"; +import { GuildLogs } from "../../data/GuildLogs"; +import { GuildCases } from "../../data/GuildCases"; +import { GuildArchives } from "../../data/GuildArchives"; +import { GuildMutes } from "../../data/GuildMutes"; +import Timeout = NodeJS.Timeout; +import { CaseArgs } from "../Cases/types"; + +export const ConfigSchema = t.type({ + mute_role: tNullable(t.string), + move_to_voice_channel: tNullable(t.string), + + dm_on_mute: t.boolean, + dm_on_update: t.boolean, + message_on_mute: t.boolean, + message_on_update: t.boolean, + message_channel: tNullable(t.string), + mute_message: tNullable(t.string), + timed_mute_message: tNullable(t.string), + update_mute_message: tNullable(t.string), + + can_view_list: t.boolean, + can_cleanup: t.boolean, +}); +export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; + +export interface MutesPluginType extends BasePluginType { + config: TConfigSchema; + state: { + mutes: GuildMutes; + cases: GuildCases; + serverLogs: GuildLogs; + archives: GuildArchives; + + muteClearIntervalId: Timeout; + }; +} + +export interface IMuteWithDetails extends Mute { + member?: Member; + banned?: boolean; +} + +export type MuteResult = { + case: Case; + notifyResult: UserNotificationResult; + updatedExistingMute: boolean; +}; + +export type UnmuteResult = { + case: Case; +}; + +export interface MuteOptions { + caseArgs?: Partial<CaseArgs>; + contactMethods?: UserNotificationMethod[]; +}