{
"version": 3,
"sources": ["../../../../server/chat-commands/moderation.ts"],
"sourcesContent": ["/**\n * Moderation commands\n * Pokemon Showdown - http://pokemonshowdown.com/\n *\n * These are commands for staff.\n *\n * For the API, see chat-plugins/COMMANDS.md\n *\n * @license MIT\n */\nimport {Utils} from '../../lib';\nimport {RoomSection, RoomSections} from './room-settings';\n\n/* eslint no-else-return: \"error\" */\n\nconst MAX_REASON_LENGTH = 600;\nconst MUTE_LENGTH = 7 * 60 * 1000;\nconst HOURMUTE_LENGTH = 60 * 60 * 1000;\nconst DAY = 24 * 60 * 60 * 1000;\n\n/** Require reasons for punishment commands */\nconst REQUIRE_REASONS = true;\n\n/**\n * Promotes a user within a room. Returns a User object if a popup should be shown to the user,\n * and null otherwise. Throws a Chat.ErrorMesage on an error.\n *\n * @param promoter the User object of the user who is promoting\n * @param room the Room in which the promotion is happening\n * @param userid the ID of the user to promote\n * @param symbol the GroupSymbol to promote to\n * @param username the username of the user to promote\n * @param force whether or not to forcibly promote\n */\nexport function runPromote(\n\tpromoter: User,\n\troom: Room,\n\tuserid: ID,\n\tsymbol: GroupSymbol,\n\tusername?: string,\n\tforce?: boolean\n) {\n\tconst targetUser = Users.getExact(userid);\n\tusername = username || userid;\n\tif (!username) return;\n\n\tif (userid.length > 18) {\n\t\tthrow new Chat.ErrorMessage(`User '${username}' does not exist (the username is too long).`);\n\t}\n\tif (!targetUser && !Users.isUsernameKnown(userid) && !force) {\n\t\tthrow new Chat.ErrorMessage(`User '${username}' is offline and unrecognized, and so can't be promoted.`);\n\t}\n\tif (targetUser && !targetUser.registered) {\n\t\tthrow new Chat.ErrorMessage(`User '${username}' is unregistered, and so can't be promoted.`);\n\t}\n\n\tlet currentSymbol: GroupSymbol | 'whitelist' = room.auth.getDirect(userid);\n\tif (room.auth.has(userid) && currentSymbol === Users.Auth.defaultSymbol()) {\n\t\tcurrentSymbol = 'whitelist';\n\t}\n\tconst currentGroup = Users.Auth.getGroup(currentSymbol);\n\tconst currentGroupName = currentGroup.name || \"regular user\";\n\n\tconst nextGroup = Config.groups[symbol];\n\n\tif (currentSymbol === symbol) {\n\t\tthrow new Chat.ErrorMessage(`User '${username}' is already a ${nextGroup?.name || symbol || 'regular user'} in this room.`);\n\t}\n\tif (!promoter.can('makeroom')) {\n\t\tif (currentGroup.id && !promoter.can(`room${currentGroup.id || 'voice'}` as 'roomvoice', null, room)) {\n\t\t\tthrow new Chat.ErrorMessage(`Access denied for promoting/demoting ${username} from ${currentGroupName}.`);\n\t\t}\n\t\tif (symbol !== ' ' && !promoter.can(`room${nextGroup.id || 'voice'}` as 'roomvoice', null, room)) {\n\t\t\tthrow new Chat.ErrorMessage(`Access denied for promoting/demoting ${username} to ${nextGroup.name}.`);\n\t\t}\n\t}\n\tif (targetUser?.locked && room.persist && room.settings.isPrivate !== true && nextGroup.rank > 2) {\n\t\tthrow new Chat.ErrorMessage(`${username} is locked and can't be promoted.`);\n\t}\n\n\tif (symbol === Users.Auth.defaultSymbol()) {\n\t\troom.auth.delete(userid);\n\t} else {\n\t\troom.auth.set(userid, symbol);\n\t}\n\n\tif (targetUser) {\n\t\ttargetUser.updateIdentity(room.roomid);\n\t\tif (room.subRooms) {\n\t\t\tfor (const subRoom of room.subRooms.values()) {\n\t\t\t\ttargetUser.updateIdentity(subRoom.roomid);\n\t\t\t}\n\t\t}\n\t}\n\n\t// Only show popup if: user is online and in the room, the room is public, and not a groupchat or a battle.\n\tif (targetUser && room.users[targetUser.id] && room.persist && room.settings.isPrivate !== true) {\n\t\treturn targetUser;\n\t}\n\treturn null;\n}\n\nexport function runCrisisDemote(userid: ID) {\n\tconst from = [];\n\tconst section = Users.globalAuth.sectionLeaders.get(userid);\n\tif (section) {\n\t\tfrom.push(`Section Leader (${RoomSections.sectionNames[section] || section})`);\n\t\tUsers.globalAuth.deleteSection(userid);\n\t}\n\tconst globalGroup = Users.globalAuth.get(userid);\n\tif (globalGroup && globalGroup !== ' ') {\n\t\tfrom.push(globalGroup);\n\t\tUsers.globalAuth.delete(userid);\n\t}\n\tfor (const room of Rooms.global.chatRooms) {\n\t\tif (!room.settings.isPrivate && room.auth.isStaff(userid)) {\n\t\t\tlet oldGroup: string = room.auth.getDirect(userid);\n\t\t\tif (oldGroup === ' ') {\n\t\t\t\toldGroup = 'whitelist in ';\n\t\t\t} else {\n\t\t\t\troom.auth.set(userid, '+');\n\t\t\t}\n\t\t\tfrom.push(`${oldGroup}${room.roomid}`);\n\t\t}\n\t}\n\treturn from;\n}\n\nPunishments.addPunishmentType({\n\ttype: 'YEARLOCK',\n\tdesc: \"Locked for a year\",\n\tonActivate: (user, punishment) => {\n\t\tuser.locked = user.id;\n\t\tChat.punishmentfilter(user, punishment);\n\t},\n});\n\nexport const commands: Chat.ChatCommands = {\n\troomowner(target, room, user) {\n\t\troom = this.requireRoom();\n\t\tif (!room.persist) {\n\t\t\treturn this.sendReply(\"/roomowner - This room isn't designed for per-room moderation to be added\");\n\t\t}\n\t\tif (!target) return this.parse('/help roomowner');\n\t\tconst {targetUser, targetUsername, rest} = this.splitUser(target, {exactName: true});\n\t\tif (rest) return this.errorReply(`This command does not support specifying a reason.`);\n\t\tconst userid = toID(targetUsername);\n\n\t\tif (!Users.isUsernameKnown(userid)) {\n\t\t\treturn this.errorReply(`User '${targetUsername}' is offline and unrecognized, and so can't be promoted.`);\n\t\t}\n\n\t\tthis.checkCan('makeroom');\n\t\tif (room.auth.getDirect(userid) === '#') return this.errorReply(`${targetUsername} is already a room owner.`);\n\n\t\troom.auth.set(userid, '#');\n\t\tconst message = `${targetUsername} was appointed Room Owner by ${user.name}.`;\n\t\tif (room.settings.isPrivate === true) {\n\t\t\tthis.addModAction(message);\n\t\t\tRooms.get(`upperstaff`)?.addByUser(user, `<<${room.roomid}>> ${message}`).update();\n\t\t} else {\n\t\t\tthis.addGlobalModAction(message);\n\t\t}\n\t\tthis.modlog('ROOMOWNER', userid);\n\t\tif (targetUser) {\n\t\t\ttargetUser.popup(`You were appointed Room Owner by ${user.name} in ${room.roomid}.`);\n\t\t\troom.onUpdateIdentity(targetUser);\n\t\t\tif (room.subRooms) {\n\t\t\t\tfor (const subRoom of room.subRooms.values()) {\n\t\t\t\t\tsubRoom.onUpdateIdentity(targetUser);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\troom.saveSettings();\n\t},\n\troomownerhelp: [`/roomowner [username] - Appoints [username] as a room owner. Requires: &`],\n\n\troomdemote: 'roompromote',\n\tforceroompromote: 'roompromote',\n\tforceroomdemote: 'roompromote',\n\troompromote(target, room, user, connection, cmd) {\n\t\tif (!room) {\n\t\t\t// this command isn't marked as room-only because it's usable in PMs through /invite\n\t\t\treturn this.errorReply(\"This command is only available in rooms\");\n\t\t}\n\t\tthis.checkChat();\n\t\tif (!target) return this.parse('/help roompromote');\n\n\t\tconst force = cmd.startsWith('force');\n\t\tconst users = target.split(',').map(part => part.trim());\n\t\tlet nextSymbol = users.pop() as GroupSymbol | 'deauth';\n\t\tif (nextSymbol === 'deauth') nextSymbol = Users.Auth.defaultSymbol();\n\t\tconst nextGroup = Users.Auth.getGroup(nextSymbol);\n\n\t\tif (!nextSymbol) {\n\t\t\treturn this.errorReply(\"Please specify a group such as /roomvoice or /roomdeauth\");\n\t\t}\n\t\tif (!Config.groups[nextSymbol]) {\n\t\t\tif (!force || !user.can('bypassall')) {\n\t\t\t\tthis.errorReply(`Group '${nextSymbol}' does not exist.`);\n\t\t\t\tif (user.can('bypassall')) {\n\t\t\t\t\tthis.errorReply(`If you want to promote to a nonexistent group, use /forceroompromote`);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t} else if (!Users.Auth.isValidSymbol(nextSymbol)) {\n\t\t\t\t// yes I know this excludes astral-plane characters and includes combining characters\n\t\t\t\treturn this.errorReply(`Admins can forcepromote to nonexistent groups only if they are one character long`);\n\t\t\t}\n\t\t}\n\n\t\tif (!force && (nextGroup.globalonly || (nextGroup.battleonly && !room.battle))) {\n\t\t\treturn this.errorReply(`Group 'room${nextGroup.id || nextSymbol}' does not exist as a room rank.`);\n\t\t}\n\t\tconst nextGroupName = nextGroup.name || \"regular user\";\n\n\t\tfor (const toPromote of users) {\n\t\t\tconst userid = toID(toPromote);\n\t\t\tif (!userid) return this.parse('/help roompromote');\n\n\t\t\t// weird ts bug (?) - 7022\n\t\t\t// it implicitly is 'any' because it has no annotation and is \"is referenced directly or indirectly in its own initializer.\"\n\t\t\t// dunno why this happens, but for now we can just cast over it.\n\t\t\tconst oldSymbol: GroupSymbol = room.auth.getDirect(userid);\n\t\t\tlet shouldPopup;\n\t\t\ttry {\n\t\t\t\tshouldPopup = runPromote(user, room, userid, nextSymbol, toPromote, force);\n\t\t\t} catch (err: any) {\n\t\t\t\tif (err.name?.endsWith('ErrorMessage')) {\n\t\t\t\t\tthis.errorReply(err.message);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t\tconst targetUser = Users.getExact(userid);\n\t\t\tconst name = targetUser?.name || toPromote;\n\n\t\t\tif (this.pmTarget && targetUser) {\n\t\t\t\tconst text = `${targetUser.name} was invited (and promoted to Room ${nextGroupName}) by ${user.name}.`;\n\t\t\t\troom.add(`|c|${user.getIdentity(room)}|/log ${text}`).update();\n\t\t\t\tthis.modlog('INVITE', targetUser, null, {noip: 1, noalts: 1});\n\t\t\t} else if (\n\t\t\t\tnextSymbol in Config.groups && oldSymbol in Config.groups &&\n\t\t\t\tnextGroup.rank < Config.groups[oldSymbol].rank\n\t\t\t) {\n\t\t\t\tif (targetUser && room.users[targetUser.id] && !nextGroup.modlog) {\n\t\t\t\t\t// if the user can't see the demotion message (i.e. rank < %), it is shown in the chat\n\t\t\t\t\ttargetUser.send(`>${room.roomid}\\n(You were demoted to Room ${nextGroupName} by ${user.name}.)`);\n\t\t\t\t}\n\t\t\t\tthis.privateModAction(`${name} was demoted to Room ${nextGroupName} by ${user.name}.`);\n\t\t\t\tthis.modlog(`ROOM${nextGroupName.toUpperCase()}`, userid, '(demote)');\n\t\t\t\tshouldPopup?.popup(`You were demoted to Room ${nextGroupName} by ${user.name} in ${room.roomid}.`);\n\t\t\t} else if (nextSymbol === '#') {\n\t\t\t\tthis.addModAction(`${name} was promoted to ${nextGroupName} by ${user.name}.`);\n\t\t\t\tconst logRoom = Rooms.get(room.settings.isPrivate === true ? 'upperstaff' : 'staff');\n\t\t\t\tlogRoom?.addByUser(user, `<<${room.roomid}>> ${name} was appointed Room Owner by ${user.name}`);\n\t\t\t\tthis.modlog('ROOM OWNER', userid);\n\t\t\t\tshouldPopup?.popup(`You were promoted to ${nextGroupName} by ${user.name} in ${room.roomid}.`);\n\t\t\t} else {\n\t\t\t\tthis.addModAction(`${name} was promoted to Room ${nextGroupName} by ${user.name}.`);\n\t\t\t\tthis.modlog(`ROOM${nextGroupName.toUpperCase()}`, userid);\n\t\t\t\tshouldPopup?.popup(`You were promoted to Room ${nextGroupName} by ${user.name} in ${room.roomid}.`);\n\t\t\t}\n\n\t\t\tif (targetUser) {\n\t\t\t\ttargetUser.updateIdentity(room.roomid);\n\t\t\t\tif (room.subRooms) {\n\t\t\t\t\tfor (const subRoom of room.subRooms.values()) {\n\t\t\t\t\t\ttargetUser.updateIdentity(subRoom.roomid);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (targetUser.trusted && !Users.isTrusted(targetUser.id)) {\n\t\t\t\t\ttargetUser.trusted = '';\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\troom.saveSettings();\n\t},\n\troompromotehelp: [\n\t\t`/roompromote OR /roomdemote [comma-separated usernames], [group symbol] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # &`,\n\t\t`/room[group] [comma-separated usernames] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # &`,\n\t\t`/roomdeauth [comma-separated usernames] - Removes all room rank from the user(s). Requires: @ # &`,\n\t],\n\n\tauth: 'authority',\n\tstafflist: 'authority',\n\tglobalauth: 'authority',\n\tauthlist: 'authority',\n\tauthority(target, room, user, connection) {\n\t\tif (target && target !== '+') {\n\t\t\tconst targetRoom = Rooms.search(target);\n\t\t\tconst availableRoom = targetRoom?.checkModjoin(user);\n\t\t\tif (targetRoom && availableRoom) return this.parse(`/roomauth1 ${target}`);\n\t\t\treturn this.parse(`/userauth ${target}`);\n\t\t}\n\t\tconst showAll = !!target;\n\t\tconst rankLists: {[k: string]: string[]} = {};\n\t\tfor (const [id, symbol] of Users.globalAuth) {\n\t\t\tif (symbol === ' ' || (symbol === '+' && !showAll)) continue;\n\t\t\tif (!rankLists[symbol]) rankLists[symbol] = [];\n\t\t\trankLists[symbol].push(Users.globalAuth.usernames.get(id) || id);\n\t\t}\n\n\t\tconst buffer = Utils.sortBy(\n\t\t\tObject.entries(rankLists) as [GroupSymbol, string[]][],\n\t\t\t([symbol]) => -Users.Auth.getGroup(symbol).rank\n\t\t).filter(\n\t\t\t([symbol]) => symbol !== Users.SECTIONLEADER_SYMBOL\n\t\t).map(\n\t\t\t([symbol, names]) => (\n\t\t\t\t`${(Config.groups[symbol] ? `**${Config.groups[symbol].name}s** (${symbol})` : symbol)}:\\n` +\n\t\t\t\tUtils.sortBy(names, name => toID(name)).join(\", \")\n\t\t\t)\n\t\t);\n\t\tif (!showAll) buffer.push(`(Use \\`\\`/auth +\\`\\` to show global voice users.)`);\n\n\t\tif (!buffer.length) return connection.popup(\"This server has no global authority.\");\n\t\tconnection.popup(buffer.join(\"\\n\\n\"));\n\t},\n\tauthhelp: [\n\t\t`/auth - Show global staff for the server.`,\n\t\t`/auth + - Show global staff for the server, including voices.`,\n\t\t`/auth [room] - Show what roomauth a room has.`,\n\t\t`/auth [user] - Show what global and roomauth a user has.`,\n\t],\n\n\troomstaff: 'roomauth',\n\troomauth1: 'roomauth',\n\troomauth(target, room, user, connection, cmd) {\n\t\tlet userLookup = '';\n\t\tif (cmd === 'roomauth1') userLookup = `\\n\\nTo look up auth for a user, use /userauth ${target}`;\n\t\tlet targetRoom = room;\n\t\tif (target) targetRoom = Rooms.search(target)!;\n\t\tif (!targetRoom?.checkModjoin(user)) {\n\t\t\treturn this.errorReply(`The room \"${target}\" does not exist.`);\n\t\t}\n\t\tconst showAll = user.can('mute', null, targetRoom);\n\n\t\tconst rankLists: {[groupSymbol: string]: ID[]} = {};\n\t\tfor (const [id, rank] of targetRoom.auth) {\n\t\t\tif (rank === ' ' && !showAll) continue;\n\t\t\tif (!rankLists[rank]) rankLists[rank] = [];\n\t\t\trankLists[rank].push(id);\n\t\t}\n\n\t\tconst buffer = Utils.sortBy(\n\t\t\tObject.entries(rankLists) as [GroupSymbol, ID[]][],\n\t\t\t([symbol]) => -Users.Auth.getGroup(symbol).rank\n\t\t).map(([symbol, names]) => {\n\t\t\tlet group = Config.groups[symbol] ? `${Config.groups[symbol].name}s (${symbol})` : symbol;\n\t\t\tif (symbol === ' ') group = 'Whitelisted (this list is only visible to staff)';\n\t\t\treturn `${group}:\\n` +\n\t\t\t\tUtils.sortBy(names).map(userid => {\n\t\t\t\t\tconst isOnline = Users.get(userid)?.statusType === 'online';\n\t\t\t\t\t// targetRoom guaranteed to exist above\n\t\t\t\t\treturn userid in targetRoom!.users && isOnline ? `**${userid}**` : userid;\n\t\t\t\t}).join(', ');\n\t\t});\n\n\t\tlet curRoom = targetRoom;\n\t\twhile (curRoom.parent) {\n\t\t\tconst modjoinSetting = curRoom.settings.modjoin === true ? curRoom.settings.modchat : curRoom.settings.modjoin;\n\t\t\tconst roomType = (modjoinSetting ? `modjoin ${modjoinSetting} ` : '');\n\t\t\tconst inheritedUserType = (modjoinSetting ? ` of rank ${modjoinSetting} and above` : '');\n\t\t\tif (curRoom.parent) {\n\t\t\t\tconst also = buffer.length === 0 ? `` : ` also`;\n\t\t\t\tbuffer.push(`${curRoom.title} is a ${roomType}subroom of ${curRoom.parent.title}, so ${curRoom.parent.title} users${inheritedUserType}${also} have authority in this room.`);\n\t\t\t}\n\t\t\tcurRoom = curRoom.parent;\n\t\t}\n\t\tif (!buffer.length) {\n\t\t\tconnection.popup(`The room '${targetRoom.title}' has no auth. ${userLookup}`);\n\t\t\treturn;\n\t\t}\n\t\tif (!curRoom.settings.isPrivate) {\n\t\t\tbuffer.push(`${curRoom.title} is a public room, so global auth with no relevant roomauth will have authority in this room.`);\n\t\t} else if (curRoom.settings.isPrivate === 'hidden' || curRoom.settings.isPrivate === 'voice') {\n\t\t\tbuffer.push(`${curRoom.title} is a hidden room, so global auth with no relevant roomauth will have authority in this room.`);\n\t\t}\n\t\tbuffer.push(`Names in **bold** are online.`);\n\t\tif (targetRoom !== room) buffer.unshift(`${targetRoom.title} room auth:`);\n\t\tconnection.popup(`${buffer.join(\"\\n\\n\")}${userLookup}`);\n\t},\n\troomauthhelp: [\n\t\t`/roomauth [room] - Shows a list of the staff and authority in the given [room].`,\n\t\t`If no room is given, it defaults to the current room.`,\n\t],\n\n\tuserauth(target, room, user, connection) {\n\t\tconst targetId = toID(target) || user.id;\n\t\tconst targetUser = Users.getExact(targetId);\n\t\tconst targetUsername = targetUser?.name || target;\n\n\t\tconst buffer = [];\n\t\tlet innerBuffer = [];\n\t\tconst group = Users.globalAuth.get(targetId);\n\t\tif (group !== ' ' || Users.isTrusted(targetId)) {\n\t\t\tbuffer.push(`Global auth: ${group === ' ' ? 'trusted' : group}`);\n\t\t}\n\t\tconst sectionLeader = Users.globalAuth.sectionLeaders.get(targetId);\n\t\tif (sectionLeader) {\n\t\t\tbuffer.push(`Section leader: ${RoomSections.sectionNames[sectionLeader]}`);\n\t\t}\n\t\tfor (const curRoom of Rooms.rooms.values()) {\n\t\t\tif (curRoom.settings.isPrivate) continue;\n\t\t\tif (!curRoom.auth.has(targetId)) continue;\n\t\t\tinnerBuffer.push(curRoom.auth.getDirect(targetId).trim() + curRoom.roomid);\n\t\t}\n\t\tif (innerBuffer.length) {\n\t\t\tbuffer.push(`Room auth: ${innerBuffer.join(', ')}`);\n\t\t}\n\t\tif (targetId === user.id || user.can('lock')) {\n\t\t\tinnerBuffer = [];\n\t\t\tfor (const curRoom of Rooms.rooms.values()) {\n\t\t\t\tif (!curRoom.settings.isPrivate) continue;\n\t\t\t\tif (curRoom.settings.isPrivate === true) continue;\n\t\t\t\tif (!curRoom.auth.has(targetId)) continue;\n\t\t\t\tinnerBuffer.push(curRoom.auth.getDirect(targetId).trim() + curRoom.roomid);\n\t\t\t}\n\t\t\tif (innerBuffer.length) {\n\t\t\t\tbuffer.push(`Hidden room auth: ${innerBuffer.join(', ')}`);\n\t\t\t}\n\t\t}\n\t\tif (targetId === user.id || user.can('makeroom')) {\n\t\t\tinnerBuffer = [];\n\t\t\tfor (const chatRoom of Rooms.global.chatRooms) {\n\t\t\t\tif (!chatRoom.settings.isPrivate) continue;\n\t\t\t\tif (chatRoom.settings.isPrivate !== true) continue;\n\t\t\t\tif (!chatRoom.auth.has(targetId)) continue;\n\t\t\t\tinnerBuffer.push(chatRoom.auth.getDirect(targetId).trim() + chatRoom.roomid);\n\t\t\t}\n\t\t\tif (innerBuffer.length) {\n\t\t\t\tbuffer.push(`Private room auth: ${innerBuffer.join(', ')}`);\n\t\t\t}\n\t\t}\n\t\tif (!buffer.length) {\n\t\t\tbuffer.push(\"No global or room auth.\");\n\t\t}\n\n\t\tbuffer.unshift(`${targetUsername} user auth:`);\n\t\tconnection.popup(buffer.join(\"\\n\\n\"));\n\t},\n\tuserauthhelp: [\n\t\t`/userauth [username] - Shows all authority visible to the user for the given [username].`,\n\t\t`If no username is given, it defaults to the current user.`,\n\t],\n\n\tsectionleaders(target, room, user, connection) {\n\t\tconst usernames = Users.globalAuth.usernames;\n\t\tconst buffer = [];\n\t\tconst sections: {[k in RoomSection]: Set ${Utils.escapeHTML(user.name)} has banned you from the room ${room.roomid} ` +\n\t\t\t\t`${(room.subRooms ? ` and its subrooms` : ``)}${week ? ' for a week' : ''}.` +\n\t\t\t\t` Reason: ${Utils.escapeHTML(publicReason)} To appeal the ban, PM the staff member that banned you${room.persist ? ` or a room owner. ` +\n\t\t\t\t`
${Utils.escapeHTML(user.name)} has blacklisted you from the room ${room.roomid}${(room.subRooms ? ` and its subrooms` : '')}. Reason: ${Utils.escapeHTML(reason)}
` +\n\t\t\t\t`To appeal the ban, PM the staff member that blacklisted you${room.persist ? ` or a room owner.
` : `.`}`\n\t\t\t);\n\t\t}\n\n\n\t\tconst expireTime = cmd.includes('perma') ? Date.now() + (10 * 365 * 24 * 60 * 60 * 1000) : null;\n\t\tconst action = expireTime ? 'PERMABLACKLIST' : 'BLACKLIST';\n\n\t\tthis.privateModAction(\n\t\t\t`${name} was blacklisted from ${room.title} by ${user.name}${expireTime ? ' for ten years' : ''}.` +\n\t\t\t`${reason ? ` (${reason})` : ''}`\n\t\t);\n\n\t\tconst affected = Punishments.roomBlacklist(room, targetUser, expireTime, null, reason);\n\n\t\tif (!room.settings.isPrivate && room.persist) {\n\t\t\tconst acAccount = (targetUser.autoconfirmed !== userid && targetUser.autoconfirmed);\n\t\t\tlet displayMessage = '';\n\t\t\tif (affected.length > 1) {\n\t\t\t\tdisplayMessage = `${name}'s ${(acAccount ? ` ac account: ${acAccount},` : '')} blacklisted alts: ${affected.slice(1).map(curUser => curUser.getLastName()).join(\", \")}`;\n\t\t\t\tthis.privateModAction(displayMessage);\n\t\t\t} else if (acAccount) {\n\t\t\t\tdisplayMessage = `${name}'s ac account: ${acAccount}`;\n\t\t\t\tthis.privateModAction(displayMessage);\n\t\t\t}\n\t\t}\n\n\t\tif (!room.settings.isPrivate && room.persist) {\n\t\t\tthis.globalModlog(action, targetUser, reason);\n\t\t} else {\n\t\t\t// Room modlog only\n\t\t\tthis.modlog(action, targetUser, reason);\n\t\t}\n\t\treturn true;\n\t},\n\tblacklisthelp: [\n\t\t`/blacklist [username], [reason] - Blacklists the user from the room you are in for a year. Requires: # &`,\n\t\t`/permablacklist OR /permabl - blacklist a user for 10 years. Requires: # &`,\n\t\t`/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # &`,\n\t\t`/showblacklist OR /showbl - show a list of blacklisted users in the room. Requires: % @ # &`,\n\t\t`/expiringblacklists OR /expiringbls - show a list of blacklisted users from the room whose blacklists are expiring in 3 months or less. Requires: % @ # &`,\n\t],\n\n\tforcebattleban: 'battleban',\n\tasync battleban(target, room, user, connection, cmd) {\n\t\troom = this.requireRoom();\n\t\tif (!target) return this.parse(`/help battleban`);\n\n\t\tconst {targetUser, targetUsername, rest: reason} = this.splitUser(target);\n\t\tif (!targetUser) return this.errorReply(`User ${targetUsername} not found.`);\n\t\tif (target.length > MAX_REASON_LENGTH) {\n\t\t\treturn this.errorReply(`The reason is too long. It cannot exceed ${MAX_REASON_LENGTH} characters.`);\n\t\t}\n\t\tif (!reason) {\n\t\t\treturn this.errorReply(`Battle bans require a reason.`);\n\t\t}\n\t\tconst includesUrl = reason.includes(`.${Config.routes.root}/`); // lgtm [js/incomplete-url-substring-sanitization]\n\t\tif (!room.battle && !includesUrl && cmd !== 'forcebattleban') {\n\t\t\t return this.errorReply(`Battle bans require a battle replay if used outside of a battle; if the battle has expired, use /forcebattleban.`);\n\t\t}\n\t\tif (!user.can('rangeban', targetUser)) {\n\t\t\tthis.errorReply(`Battlebans have been deprecated. Alternatives:`);\n\t\t\tthis.errorReply(`- timerstalling and bragging about it: lock`);\n\t\t\tthis.errorReply(`- other timerstalling: they're not timerstalling, leave them alone`);\n\t\t\tthis.errorReply(`- bad nicknames: lock, locks prevent nicknames from appearing; you should always have been locking for this`);\n\t\t\tthis.errorReply(`- ladder cheating: gban, get a moderator if necessary`);\n\t\t\tthis.errorReply(`- serious ladder cheating: permaban, get an administrator`);\n\t\t\tthis.errorReply(`- other: get an administrator`);\n\t\t\treturn;\n\t\t}\n\t\tif (Punishments.isBattleBanned(targetUser)) {\n\t\t\treturn this.errorReply(`User '${targetUser.name}' is already banned from battling.`);\n\t\t}\n\t\tthis.privateGlobalModAction(`${targetUser.name} was banned from starting new battles by ${user.name} (${reason})`);\n\n\t\tif (targetUser.trusted) {\n\t\t\tMonitor.log(`[CrisisMonitor] Trusted user ${targetUser.name} was banned from battling by ${user.name}, and should probably be demoted.`);\n\t\t}\n\n\t\tthis.globalModlog(\"BATTLEBAN\", targetUser, reason);\n\t\tLadders.cancelSearches(targetUser);\n\t\tawait Punishments.battleban(targetUser, null, null, reason);\n\t\ttargetUser.popup(`|modal|${user.name} has prevented you from starting new battles for 2 days (${reason})`);\n\n\t\t// Automatically upload replays as evidence/reference to the punishment\n\t\tif (room.battle) this.parse('/savereplay forpunishment');\n\t\treturn true;\n\t},\n\tbattlebanhelp: [\n\t\t`/battleban [username], [reason] - [DEPRECATED]`,\n\t\t`Prevents the user from starting new battles for 2 days and shows them the [reason]. Requires: &`,\n\t],\n\n\tunbattleban(target, room, user) {\n\t\tif (!target) return this.parse('/help unbattleban');\n\t\tthis.checkCan('lock');\n\n\t\tconst targetUser = Users.get(target);\n\t\tconst unbanned = Punishments.unbattleban(target);\n\n\t\tif (unbanned) {\n\t\t\tthis.addModAction(`${unbanned} was allowed to battle again by ${user.name}.`);\n\t\t\tthis.globalModlog(\"UNBATTLEBAN\", toID(target));\n\t\t\tif (targetUser) targetUser.popup(`${user.name} has allowed you to battle again.`);\n\t\t} else {\n\t\t\tthis.errorReply(`User ${target} is not banned from battling.`);\n\t\t}\n\t},\n\tunbattlebanhelp: [`/unbattleban [username] - [DEPRECATED] Allows a user to battle again. Requires: % @ &`],\n\n\tmonthgroupchatban: 'groupchatban',\n\tmonthgcban: 'groupchatban',\n\tgcban: 'groupchatban',\n\tasync groupchatban(target, room, user, connection, cmd) {\n\t\troom = this.requireRoom();\n\t\tif (!target) return this.parse(`/help groupchatban`);\n\t\tif (!user.can('rangeban')) {\n\t\t\treturn this.errorReply(\n\t\t\t\t`/groupchatban has been deprecated.\\n` +\n\t\t\t\t`For future groupchat misuse, lock the creator, it will take away their trusted status and their ability to make groupchats.`\n\t\t\t);\n\t\t}\n\n\t\tconst {targetUser, targetUsername, rest: reason} = this.splitUser(target);\n\t\tif (!targetUser) return this.errorReply(`User ${targetUsername} not found.`);\n\t\tif (target.length > MAX_REASON_LENGTH) {\n\t\t\treturn this.errorReply(`The reason is too long. It cannot exceed ${MAX_REASON_LENGTH} characters.`);\n\t\t}\n\n\t\tconst isMonth = cmd.startsWith('month');\n\n\t\tif (!isMonth && Punishments.isGroupchatBanned(targetUser)) {\n\t\t\treturn this.errorReply(`User '${targetUser.name}' is already banned from using groupchats.`);\n\t\t}\n\n\t\tconst reasonText = reason ? `: ${reason}` : ``;\n\t\tthis.privateGlobalModAction(`${targetUser.name} was banned from using groupchats for a ${isMonth ? 'month' : 'week'} by ${user.name}${reasonText}.`);\n\n\t\tif (targetUser.trusted) {\n\t\t\tMonitor.log(`[CrisisMonitor] Trusted user ${targetUser.name} was banned from using groupchats by ${user.name}, and should probably be demoted.`);\n\t\t}\n\n\t\tconst createdGroupchats = await Punishments.groupchatBan(\n\t\t\ttargetUser, (isMonth ? Date.now() + 30 * DAY : null), null, reason\n\t\t);\n\t\ttargetUser.popup(`|modal|${user.name} has banned you from using groupchats for a ${isMonth ? 'month' : 'week'}${reasonText}`);\n\t\tthis.globalModlog(\"GROUPCHATBAN\", targetUser, ` by ${user.id}${reasonText}`);\n\n\t\tfor (const roomid of createdGroupchats) {\n\t\t\tconst targetRoom = Rooms.get(roomid);\n\t\t\tif (!targetRoom) continue;\n\t\t\tconst participants = targetRoom.warnParticipants?.(\n\t\t\t\t`This groupchat (${targetRoom.title}) has been deleted due to inappropriate conduct by its creator, ${targetUser.name}.` +\n\t\t\t\t` Do not attempt to recreate it, or you may be punished.${reason ? ` (reason: ${reason})` : ``}`\n\t\t\t);\n\n\t\t\tif (participants) {\n\t\t\t\tconst modlogEntry = {\n\t\t\t\t\taction: 'NOTE',\n\t\t\t\t\tloggedBy: user.id,\n\t\t\t\t\tisGlobal: true,\n\t\t\t\t\tnote: `participants in ${roomid} (creator: ${targetUser.id}): ${participants.join(', ')}`,\n\t\t\t\t};\n\t\t\t\ttargetRoom.modlog(modlogEntry);\n\t\t\t}\n\n\t\t\ttargetRoom.destroy();\n\t\t}\n\t},\n\tgroupchatbanhelp: [\n\t\t`/groupchatban [user], [optional reason]`,\n\t\t`/monthgroupchatban [user], [optional reason]`,\n\t\t`Bans the user from joining or creating groupchats for a week (or month). Requires: % @ &`,\n\t],\n\n\tungcban: 'ungroupchatban',\n\tgcunban: 'ungroupchatban',\n\tgroucphatunban: 'ungroupchatban',\n\tungroupchatban(target, room, user) {\n\t\tif (!target) return this.parse('/help ungroupchatban');\n\t\tthis.checkCan('lock');\n\n\t\tconst targetUser = Users.get(target);\n\t\tconst unbanned = Punishments.groupchatUnban(targetUser || toID(target));\n\n\t\tif (unbanned) {\n\t\t\tthis.addGlobalModAction(`${unbanned} was ungroupchatbanned by ${user.name}.`);\n\t\t\tthis.globalModlog(\"UNGROUPCHATBAN\", toID(target), ` by ${user.id}`);\n\t\t\tif (targetUser) targetUser.popup(`${user.name} has allowed you to use groupchats again.`);\n\t\t} else {\n\t\t\tthis.errorReply(`User ${target} is not banned from using groupchats.`);\n\t\t}\n\t},\n\tungroupchatbanhelp: [`/ungroupchatban [user] - Allows a groupchatbanned user to use groupchats again. Requires: % @ &`],\n\n\tnameblacklist: 'blacklistname',\n\tpermablacklistname: 'blacklistname',\n\tblacklistname(target, room, user) {\n\t\troom = this.requireRoom();\n\t\tif (!target) return this.parse('/help blacklistname');\n\t\tthis.checkChat();\n\t\tthis.checkCan('editroom', null, room);\n\t\tif (!room.persist) {\n\t\t\treturn this.errorReply(\"This room is not going to last long enough for a blacklist to matter - just ban the user\");\n\t\t}\n\n\t\tconst [targetStr, reason] = target.split('|').map(val => val.trim());\n\t\tif (!targetStr || (!reason && REQUIRE_REASONS)) {\n\t\t\treturn this.errorReply(\"Usage: /blacklistname name1, name2, ... | reason\");\n\t\t}\n\n\t\tconst targets = targetStr.split(',').map(s => toID(s));\n\n\t\tconst duplicates = targets.filter(userid => (\n\t\t\t// can be asserted, room should always exist\n\t\t\tPunishments.roomUserids.nestedGetByType(room!.roomid, userid, 'BLACKLIST')\n\t\t));\n\t\tif (duplicates.length) {\n\t\t\treturn this.errorReply(`[${duplicates.join(', ')}] ${Chat.plural(duplicates, \"are\", \"is\")} already blacklisted.`);\n\t\t}\n\t\tconst expireTime = this.cmd.includes('perma') ? Date.now() + (10 * 365 * 24 * 60 * 60 * 1000) : null;\n\t\tconst action = expireTime ? 'PERMANAMEBLACKLIST' : 'NAMEBLACKLIST';\n\n\t\tfor (const userid of targets) {\n\t\t\tif (!userid) return this.errorReply(`User '${userid}' is not a valid userid.`);\n\t\t\tif (!Users.Auth.hasPermission(user, 'ban', room.auth.get(userid), room)) {\n\t\t\t\treturn this.errorReply(`/blacklistname - Access denied: ${userid} is of equal or higher authority than you.`);\n\t\t\t}\n\n\t\t\tPunishments.roomBlacklist(room, userid, expireTime, null, reason);\n\n\t\t\tconst trusted = Users.isTrusted(userid);\n\t\t\tif (trusted && room.settings.isPrivate !== true) {\n\t\t\t\tMonitor.log(`[CrisisMonitor] Trusted user ${userid}${(trusted !== userid ? ` (${trusted})` : ``)} was nameblacklisted from ${room.roomid} by ${user.name}, and should probably be demoted.`);\n\t\t\t}\n\t\t\tif (!room.settings.isPrivate && room.persist) {\n\t\t\t\tthis.globalModlog(action, userid, reason);\n\t\t\t}\n\t\t}\n\n\t\tthis.privateModAction(\n\t\t\t`${targets.join(', ')}${Chat.plural(targets, \" were\", \" was\")} nameblacklisted from ${room.title} by ${user.name}` +\n\t\t\t`${expireTime ? ' for ten years' : ''}.`\n\t\t);\n\t\treturn true;\n\t},\n\tblacklistnamehelp: [\n\t\t`/blacklistname OR /nameblacklist [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for a year. Requires: # &`,\n\t\t`/permablacklistname [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for 10 years. Requires: # &`,\n\t],\n\n\tunab: 'unblacklist',\n\tunblacklist(target, room, user) {\n\t\troom = this.requireRoom();\n\t\tif (!target) return this.parse('/help unblacklist');\n\t\tthis.checkCan('editroom', null, room);\n\n\t\tconst name = Punishments.roomUnblacklist(room, target);\n\n\t\tif (name) {\n\t\t\tthis.privateModAction(`${name} was unblacklisted by ${user.name}.`);\n\t\t\tif (!room.settings.isPrivate && room.persist) {\n\t\t\t\tthis.globalModlog(\"UNBLACKLIST\", name);\n\t\t\t}\n\t\t} else {\n\t\t\tthis.errorReply(`User '${target}' is not blacklisted.`);\n\t\t}\n\t},\n\tunblacklisthelp: [`/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # &`],\n\n\tunblacklistall(target, room, user) {\n\t\troom = this.requireRoom();\n\t\tthis.checkCan('editroom', null, room);\n\n\t\tif (!target) {\n\t\t\tuser.lastCommand = '/unblacklistall';\n\t\t\tthis.errorReply(\"THIS WILL UNBLACKLIST ALL BLACKLISTED USERS IN THIS ROOM.\");\n\t\t\tthis.errorReply(\"To confirm, use: /unblacklistall confirm\");\n\t\t\treturn;\n\t\t}\n\t\tif (user.lastCommand !== '/unblacklistall' || target !== 'confirm') {\n\t\t\treturn this.parse('/help unblacklistall');\n\t\t}\n\t\tuser.lastCommand = '';\n\t\tconst unblacklisted = Punishments.roomUnblacklistAll(room);\n\t\tif (!unblacklisted) return this.errorReply(\"No users are currently blacklisted in this room to unblacklist.\");\n\t\tthis.addModAction(`All blacklists in this room have been lifted by ${user.name}.`);\n\t\tthis.modlog('UNBLACKLISTALL');\n\t\tthis.roomlog(`Unblacklisted users: ${unblacklisted.join(', ')}`);\n\t},\n\tunblacklistallhelp: [`/unblacklistall - Unblacklists all blacklisted users in the current room. Requires: # &`],\n\n\texpiringbls: 'showblacklist',\n\texpiringblacklists: 'showblacklist',\n\tblacklists: 'showblacklist',\n\tshowbl: 'showblacklist',\n\tshowblacklist(target, room, user, connection, cmd) {\n\t\tif (target) room = Rooms.search(target)!;\n\t\tif (!room) return this.errorReply(`The room \"${target}\" was not found.`);\n\t\tthis.checkCan('mute', null, room);\n\t\tconst SOON_EXPIRING_TIME = 3 * 30 * 24 * 60 * 60 * 1000; // 3 months\n\n\t\tif (!room.persist) return this.errorReply(\"This room does not support blacklists.\");\n\n\t\tconst roomUserids = Punishments.roomUserids.get(room.roomid);\n\t\tif (!roomUserids || roomUserids.size === 0) {\n\t\t\treturn this.sendReply(\"This room has no blacklisted users.\");\n\t\t}\n\t\tconst blMap = new Map