import {FS} from '../../lib/fs'; import {Utils} from '../../lib/utils'; import {getCommonBattles} from '../chat-commands/info'; import type {Punishment} from '../punishments'; const TICKET_FILE = 'config/tickets.json'; const TICKET_CACHE_TIME = 24 * 60 * 60 * 1000; // 24 hours const TICKET_BAN_DURATION = 48 * 60 * 60 * 1000; // 48 hours Punishments.roomPunishmentTypes.set(`TICKETBAN`, 'banned from creating help tickets'); interface TicketState { creator: string; userid: ID; open: boolean; active: boolean; type: string; created: number; claimed: string | null; ip: string; } type TicketResult = 'approved' | 'valid' | 'assisted' | 'denied' | 'invalid' | 'unassisted' | 'ticketban' | 'deleted'; const tickets: {[k: string]: TicketState} = {}; try { const ticketData = JSON.parse(FS(TICKET_FILE).readSync()); for (const t in ticketData) { const ticket = ticketData[t]; if (ticket.banned) { if (ticket.expires && ticket.expires <= Date.now()) continue; Punishments.roomPunish(`staff`, ticket.userid, ['TICKETBAN', ticket.userid, ticket.expires, ticket.reason]); delete ticketData[t]; // delete the old format } else { if (ticket.created + TICKET_CACHE_TIME <= Date.now()) { // Tickets that have been open for 24+ hours will be automatically closed. const ticketRoom = Rooms.get(`help-${ticket.userid}`) as ChatRoom | null; if (ticketRoom) { const ticketGame = ticketRoom.game as HelpTicket; ticketGame.writeStats(false); ticketRoom.expire(); } continue; } // Close open tickets after a restart if (ticket.open && !Chat.oldPlugins.helptickets) ticket.open = false; tickets[t] = ticket; } } } catch (e) { if (e.code !== 'ENOENT') throw e; } function writeTickets() { FS(TICKET_FILE).writeUpdate( () => JSON.stringify(tickets) ); } function writeStats(line: string) { // ticketType\ttotalTime\ttimeToFirstClaim\tinactiveTime\tresolution\tresult\tstaff,userids,seperated,with,commas const date = new Date(); const month = Chat.toTimestamp(date).split(' ')[0].split('-', 2).join('-'); try { FS(`logs/tickets/${month}.tsv`).appendSync(line + '\n'); } catch (e) { if (e.code !== 'ENOENT') throw e; } } export class HelpTicket extends Rooms.RoomGame { room: ChatRoom; ticket: TicketState; claimQueue: string[]; involvedStaff: Set; createTime: number; activationTime: number; emptyRoom: boolean; firstClaimTime: number; unclaimedTime: number; lastUnclaimedStart: number; closeTime: number; resolution: 'unknown' | 'dead' | 'unresolved' | 'resolved'; result: TicketResult | null; constructor(room: ChatRoom, ticket: TicketState) { super(room); this.room = room; this.room.settings.language = Users.get(ticket.creator)?.language || 'english' as ID; this.title = `Help Ticket - ${ticket.type}`; this.gameid = "helpticket" as ID; this.allowRenames = true; this.ticket = ticket; this.claimQueue = []; /* Stats */ this.involvedStaff = new Set(); this.createTime = Date.now(); this.activationTime = (ticket.active ? this.createTime : 0); this.emptyRoom = false; this.firstClaimTime = 0; this.unclaimedTime = 0; this.lastUnclaimedStart = (ticket.active ? this.createTime : 0); this.closeTime = 0; this.resolution = 'unknown'; this.result = null; } onJoin(user: User, connection: Connection) { if (!this.ticket.open) return false; if (!user.isStaff || user.id === this.ticket.userid) { if (this.emptyRoom) this.emptyRoom = false; this.addPlayer(user); return false; } if (!this.ticket.claimed) { this.ticket.claimed = user.name; if (!this.firstClaimTime) { this.firstClaimTime = Date.now(); // I'd use the player list for this, but it dosen't track DCs so were checking the userlist // Non-staff users in the room currently (+ the ticket creator even if they are staff) const users = Object.entries(this.room.users).filter( u => !((u[1].isStaff && u[1].id !== this.ticket.userid) || !u[1].named) ); if (!users.length) this.emptyRoom = true; } if (this.ticket.active) { this.unclaimedTime += Date.now() - this.lastUnclaimedStart; this.lastUnclaimedStart = 0; // Set back to 0 so we know that it was active when closed } tickets[this.ticket.userid] = this.ticket; writeTickets(); this.room.modlog({action: 'TICKETCLAIM', isGlobal: true, loggedBy: user.id}); this.addText(`${user.name} claimed this ticket.`, user); notifyStaff(); } else { this.claimQueue.push(user.name); } } onLeave(user: User, oldUserid: ID) { const player = this.playerTable[oldUserid || user.id]; if (player) { this.removePlayer(player); return; } if (!this.ticket.open) return; if (toID(this.ticket.claimed) === user.id) { if (this.claimQueue.length) { this.ticket.claimed = this.claimQueue.shift() || null; this.room.modlog({action: 'TICKETCLAIM', isGlobal: true, loggedBy: toID(this.ticket.claimed)}); this.addText(`This ticket is now claimed by ${this.ticket.claimed}.`, user); } else { const oldClaimed = this.ticket.claimed; this.ticket.claimed = null; this.lastUnclaimedStart = Date.now(); this.room.modlog({action: 'TICKETUNCLAIM', isGlobal: true, loggedBy: toID(oldClaimed)}); this.addText(`This ticket is no longer claimed.`, user); notifyStaff(); } tickets[this.ticket.userid] = this.ticket; writeTickets(); } else { const index = this.claimQueue.map(toID).indexOf(user.id); if (index > -1) this.claimQueue.splice(index, 1); } } onLogMessage(message: string, user: User) { if (!this.ticket.open) return; if (user.isStaff && this.ticket.userid !== user.id) this.involvedStaff.add(user.id); if (this.ticket.active) return; const blockedMessages = [ 'hi', 'hello', 'hullo', 'hey', 'yo', 'ok', 'hesrude', 'shesrude', 'hesinappropriate', 'shesinappropriate', 'heswore', 'sheswore', 'help', 'yes', ]; if ((!user.isStaff || this.ticket.userid === user.id) && blockedMessages.includes(toID(message))) { this.room.add(`|c|&Staff|${this.room.tr`Hello! The global staff team would be happy to help you, but you need to explain what's going on first.`}`); this.room.add(`|c|&Staff|${this.room.tr`Please post the information I requested above so a global staff member can come to help.`}`); this.room.update(); return false; } if ((!user.isStaff || this.ticket.userid === user.id) && !this.ticket.active) { this.ticket.active = true; this.activationTime = Date.now(); if (!this.ticket.claimed) this.lastUnclaimedStart = Date.now(); notifyStaff(); this.room.add(`|c|&Staff|${this.room.tr`Thank you for the information, global staff will be here shortly. Please stay in the room.`}`).update(); } } forfeit(user: User) { if (!(user.id in this.playerTable)) return; this.removePlayer(user); if (!this.ticket.open) return; this.room.modlog({action: 'TICKETABANDON', isGlobal: true, loggedBy: user.id}); this.addText(`${user.name} is no longer interested in this ticket.`, user); if (this.playerCount - 1 > 0) return; // There are still users in the ticket room, dont close the ticket this.close(!!(this.firstClaimTime), user); return true; } addText(text: string, user?: User) { if (user) { this.room.addByUser(user, text); } else { this.room.add(text); } this.room.update(); } getButton() { const notifying = this.ticket.claimed ? `` : `notifying`; const creator = ( this.ticket.claimed ? Utils.html`${this.ticket.creator}` : Utils.html`${this.ticket.creator}` ); return ( `Help ${creator}: ${this.ticket.type} ` ); } getPreview() { if (!this.ticket.active) return `title="The ticket creator has not spoken yet."`; const hoverText = []; for (let i = this.room.log.log.length - 1; i >= 0; i--) { // Don't show anything after the first linebreak for multiline messages const entry = this.room.log.log[i].split('\n')[0].split('|'); entry.shift(); // Remove empty string if (!/c:?/.test(entry[0])) continue; if (entry[0] === 'c:') entry.shift(); // c: includes a timestamp and needs an extra shift entry.shift(); const user = entry.shift(); let message = entry.join('|'); message = message.startsWith('/log ') ? message.slice(5) : `${user}: ${message}`; hoverText.push(Utils.html`${message}`); if (hoverText.length >= 3) break; } if (!hoverText.length) return `title="The ticket creator has not spoken yet."`; return `title="${hoverText.reverse().join(` `)}"`; } close(result: boolean | 'ticketban' | 'deleted', staff?: User) { this.ticket.open = false; tickets[this.ticket.userid] = this.ticket; writeTickets(); this.room.modlog({action: 'TICKETCLOSE', isGlobal: true, loggedBy: staff?.id || 'unknown' as ID}); this.addText(staff ? `${staff.name} closed this ticket.` : `This ticket was closed.`, staff); notifyStaff(); this.room.pokeExpireTimer(); for (const ticketGameUser of Object.values(this.playerTable)) { this.removePlayer(ticketGameUser); const user = Users.get(ticketGameUser.id); if (user) user.updateSearch(); } if (!this.involvedStaff.size) { if (staff?.isStaff && staff.id !== this.ticket.userid) { this.involvedStaff.add(staff.id); } else { this.involvedStaff.add(toID(this.ticket.claimed)); } } this.writeStats(result); } writeStats(result: boolean | 'ticketban' | 'deleted') { // Only run when a ticket is closed/banned/deleted this.closeTime = Date.now(); if (this.lastUnclaimedStart) this.unclaimedTime += this.closeTime - this.lastUnclaimedStart; if (!this.ticket.active) { this.resolution = "dead"; } else if (!this.firstClaimTime || this.emptyRoom) { this.resolution = "unresolved"; } else { this.resolution = "resolved"; } if (typeof result === 'boolean') { switch (this.ticket.type) { case 'Appeal': case 'IP-Appeal': case 'ISP-Appeal': this.result = (result ? 'approved' : 'denied'); break; case 'PM Harassment': case 'Battle Harassment': case 'Inappropriate Username': case 'Inappropriate Pokemon Nicknames': this.result = (result ? 'valid' : 'invalid'); break; case 'Public Room Assistance Request': case 'Other': default: this.result = (result ? 'assisted' : 'unassisted'); break; } } else { this.result = result; } let firstClaimWait = 0; let involvedStaff = ''; if (this.activationTime) { firstClaimWait = (this.firstClaimTime ? this.firstClaimTime : this.closeTime) - this.activationTime; involvedStaff = Array.from(this.involvedStaff.entries()).map(s => s[0]).join(','); } // Write to TSV // ticketType\ttotalTime\ttimeToFirstClaim\tinactiveTime\tresolution\tresult\tstaff,userids,seperated,with,commas const line = `${this.ticket.type}\t${(this.closeTime - this.createTime)}\t${firstClaimWait}\t${this.unclaimedTime}\t${this.resolution}\t${this.result}\t${involvedStaff}`; writeStats(line); } deleteTicket(staff: User) { this.close('deleted', staff); this.room.modlog({action: 'TICKETDELETE', isGlobal: true, loggedBy: staff.id}); this.addText(`${staff.name} deleted this ticket.`, staff); delete tickets[this.ticket.userid]; writeTickets(); notifyStaff(); this.room.destroy(); } // Modified version of RoomGame.destory destroy() { if (tickets[this.ticket.userid] && this.ticket.open) { // Ticket was not deleted - deleted tickets already have this done to them - and was not closed. // Write stats and change flags as appropriate prior to deletion. this.ticket.open = false; tickets[this.ticket.userid] = this.ticket; notifyStaff(); writeTickets(); this.writeStats(false); } this.room.game = null; // @ts-ignore this.room = null; for (const player of this.players) { player.destroy(); } // @ts-ignore this.players = null; // @ts-ignore this.playerTable = null; } static ban(user: string, reason = '') { const userid = toID(user); const punishment: Punishment = ['TICKETBAN', userid, Date.now() + TICKET_BAN_DURATION, reason]; return Punishments.roomPunish('staff', userid, punishment); } static unban(user: string) { user = toID(user); return Punishments.roomUnpunish('staff', user, 'TICKETBAN'); } static checkBanned(user: User) { const staffRoom = Rooms.get('staff'); if (!staffRoom) return; const punishment = Punishments.getRoomPunishType(staffRoom, user.id); if (punishment === 'TICKETBAN') { return `You are banned from creating tickets.`; } // skip if the user is autoconfirmed and on a shared ip if (Punishments.sharedIps.has(user.latestIp) && user.autoconfirmed) return false; for (const ip of user.ips) { const curPunishment = Punishments.roomIps.get('staff')?.get(ip); if (curPunishment && curPunishment[0] === 'TICKETBAN') { const [, userid,, reason] = curPunishment; return ( `You are banned from creating help tickets` + `${userid !== user.id ? `, because you have the same IP as ${userid}` : ''}. ${reason ? `Reason: ${reason}` : ''}` ); } } return false; } } const NOTIFY_ALL_TIMEOUT = 5 * 60 * 1000; const NOTIFY_ASSIST_TIMEOUT = 60 * 1000; const unclaimedTicketTimer: {[k: string]: NodeJS.Timer | null} = {upperstaff: null, staff: null}; const timerEnds: {[k: string]: number} = {upperstaff: 0, staff: 0}; function pokeUnclaimedTicketTimer(hasUnclaimed: boolean, hasAssistRequest: boolean) { const room = Rooms.get('staff'); if (!room) return; if (hasUnclaimed && !unclaimedTicketTimer[room.roomid]) { unclaimedTicketTimer[room.roomid] = setTimeout( () => notifyUnclaimedTicket(hasAssistRequest), hasAssistRequest ? NOTIFY_ASSIST_TIMEOUT : NOTIFY_ALL_TIMEOUT ); timerEnds[room.roomid] = Date.now() + (hasAssistRequest ? NOTIFY_ASSIST_TIMEOUT : NOTIFY_ALL_TIMEOUT); } else if ( hasAssistRequest && (timerEnds[room.roomid] - NOTIFY_ASSIST_TIMEOUT) > NOTIFY_ASSIST_TIMEOUT && unclaimedTicketTimer[room.roomid] ) { // Shorten timer clearTimeout(unclaimedTicketTimer[room.roomid]!); unclaimedTicketTimer[room.roomid] = setTimeout(() => notifyUnclaimedTicket(hasAssistRequest), NOTIFY_ASSIST_TIMEOUT); timerEnds[room.roomid] = Date.now() + NOTIFY_ASSIST_TIMEOUT; } else if (!hasUnclaimed && unclaimedTicketTimer[room.roomid]) { clearTimeout(unclaimedTicketTimer[room.roomid]!); unclaimedTicketTimer[room.roomid] = null; timerEnds[room.roomid] = 0; } } function notifyUnclaimedTicket(hasAssistRequest: boolean) { const room = Rooms.get('staff'); if (!room) return; clearTimeout(unclaimedTicketTimer[room.roomid]!); unclaimedTicketTimer[room.roomid] = null; timerEnds[room.roomid] = 0; for (const i in room.users) { const user: User = room.users[i]; if (user.can('mute', null, room) && !user.settings.ignoreTickets) { user.sendTo( room, `|tempnotify|helptickets|Unclaimed help tickets!|${hasAssistRequest ? 'Public Room Staff need help' : 'There are unclaimed Help tickets'}` ); } } } export function notifyStaff() { const room = Rooms.get('staff'); if (!room) return; let buf = ``; const keys = Object.keys(tickets).sort((aKey, bKey) => { const a = tickets[aKey]; const b = tickets[bKey]; if (a.open !== b.open) { return (a.open ? -1 : 1); } else if (a.open && b.open) { if (a.active !== b.active) { return (a.active ? -1 : 1); } if (!!a.claimed !== !!b.claimed) { return (a.claimed ? 1 : -1); } return a.created - b.created; } return 0; }); let count = 0; let hiddenTicketUnclaimedCount = 0; let hiddenTicketCount = 0; let hasUnclaimed = false; let fourthTicketIndex = 0; let hasAssistRequest = false; for (const key of keys) { const ticket = tickets[key]; if (!ticket.open) continue; if (!ticket.active) continue; if (count >= 3) { hiddenTicketCount++; if (!ticket.claimed) hiddenTicketUnclaimedCount++; if (hiddenTicketCount === 1) { fourthTicketIndex = buf.length; } else { continue; } } // should always exist const ticketRoom = Rooms.get(`help-${ticket.userid}`) as ChatRoom; const ticketGame = ticketRoom.getGame(HelpTicket)!; if (!ticket.claimed) { hasUnclaimed = true; if (ticket.type === 'Public Room Assistance Request') hasAssistRequest = true; } buf += ticketGame.getButton(); count++; } if (hiddenTicketCount > 1) { const notifying = hiddenTicketUnclaimedCount > 0 ? ` notifying` : ``; if (hiddenTicketUnclaimedCount > 0) hasUnclaimed = true; buf = buf.slice(0, fourthTicketIndex) + `and ${hiddenTicketCount} more Help ticket${Chat.plural(hiddenTicketCount)} (${hiddenTicketUnclaimedCount} unclaimed)`; } buf = `|${hasUnclaimed ? 'uhtml' : 'uhtmlchange'}|latest-tickets|
${buf}${count === 0 ? `There were open Help tickets, but they've all been closed now.` : ``}
`; room.send(buf); if (hasUnclaimed) { buf = `|tempnotify|helptickets|Unclaimed help tickets!|${hasAssistRequest ? 'Public Room Staff need help' : 'There are unclaimed Help tickets'}`; } else { buf = `|tempnotifyoff|helptickets`; } if (hasUnclaimed) { // only notify for people highlighting buf = `${buf}|${hasAssistRequest ? 'Public Room Staff need help' : 'There are unclaimed Help tickets'}`; } for (const user of Object.values(room.users)) { if (user.can('lock') && !user.settings.ignoreTickets) user.sendTo(room, buf); } pokeUnclaimedTicketTimer(hasUnclaimed, hasAssistRequest); } function checkIp(ip: string) { for (const t in tickets) { if (tickets[t].ip === ip && tickets[t].open && !Punishments.sharedIps.has(ip)) { return tickets[t]; } } return false; } // Prevent a desynchronization issue when hotpatching for (const room of Rooms.rooms.values()) { if (!room.settings.isHelp || !room.game) continue; const game = room.getGame(HelpTicket)!; if (game.ticket) game.ticket = tickets[game.ticket.userid]; } const ticketTitles: {[k: string]: string} = Object.assign(Object.create(null), { pmharassment: `PM Harassment`, battleharassment: `Battle Harassment`, inapname: `Inappropriate Username`, inappokemon: `Inappropriate Pokemon Nicknames`, appeal: `Appeal`, ipappeal: `IP-Appeal`, appealsemi: `ISP-Appeal`, roomhelp: `Public Room Assistance Request`, other: `Other`, }); const ticketPages: {[k: string]: string} = Object.assign(Object.create(null), { report: `I want to report someone`, pmharassment: `Someone is harassing me in PMs`, battleharassment: `Someone is harassing me in a battle`, inapname: `Someone is using an offensive username`, inappokemon: `Someone is using offensive Pokemon nicknames`, appeal: `I want to appeal a punishment`, permalock: `I want to appeal my permalock`, lock: `I want to appeal my lock`, ip: `I'm locked because I have the same IP as someone I don't recognize`, semilock: `I can't talk in chat because of my ISP`, hostfilter: `I'm locked because of a proxy or VPN`, hasautoconfirmed: `Yes, I have an autoconfirmed account`, lacksautoconfirmed: `No, I don't have an autoconfirmed account`, appealother: `I want to appeal a mute/roomban/blacklist`, misc: `Something else`, password: `I lost my password`, roomhelp: `I need global staff to help watch a public room`, other: `Other`, confirmpmharassment: `Report harassment in a private message (PM)`, confirmbattleharassment: `Report harassment in a battle`, confirminapname: `Report an inappropriate username`, confirminappokemon: `Report inappropriate Pokemon nicknames`, confirmappeal: `Appeal your lock`, confirmipappeal: `Appeal IP lock`, confirmappealsemi: `Appeal ISP lock`, confirmroomhelp: `Call a Global Staff member to help`, confirmother: `Call a Global Staff member`, }); export const pages: PageTable = { help: { request(query, user, connection) { if (!user.named) { const buf = `>view-help-request${query.length ? '-' + query.join('-') : ''}\n` + `|init|html\n` + `|title|Request Help\n` + `|pagehtml|

${this.tr`Request help from global staff`}

${this.tr`Please to request help.`}

`; connection.send(buf); return Rooms.RETRY_AFTER_LOGIN; } this.title = this.tr`Request Help`; let buf = `

${this.tr`Request help from global staff`}

`; const banMsg = HelpTicket.checkBanned(user); if (banMsg) return connection.popup(banMsg); let ticket = tickets[user.id]; const ipTicket = checkIp(user.latestIp); if (ticket?.open || ipTicket) { if (!ticket && ipTicket) ticket = ipTicket; const helpRoom = Rooms.get(`help-${ticket.userid}`); if (!helpRoom) { // Should never happen tickets[ticket.userid].open = false; writeTickets(); } else { if (!helpRoom.auth.has(user.id)) helpRoom.auth.set(user.id, '+'); connection.popup(this.tr`You already have a Help ticket.`); user.joinRoom(`help-${ticket.userid}` as RoomID); return this.close(); } } const isStaff = user.can('lock'); // room / user being reported let meta = ''; const targetTypeIndex = Math.max(query.indexOf('user'), query.indexOf('room')); if (targetTypeIndex >= 0) meta = '-' + query.splice(targetTypeIndex).join('-'); if (!query.length) query = ['']; for (const [i, page] of query.entries()) { const isLast = (i === query.length - 1); const isFirst = i === 1; if (page && page in ticketPages && !page.startsWith('confirm')) { let prevPageLink = query.slice(0, i).join('-'); if (prevPageLink) prevPageLink = `-${prevPageLink}`; buf += `

`; } switch (page) { case '': buf += `

${this.tr`What's going on?`}

`; if (isStaff) { buf += `

${this.tr`Global staff cannot make Help requests. This form is only for reference.`}

`; } else { buf += `

${this.tr`Abuse of Help requests can result in punishments.`}

`; } if (!isLast) break; buf += `

`; buf += `

`; buf += `

`; break; case 'report': buf += `

${this.tr`What do you want to report someone for?`}

`; if (!isLast) break; buf += `

`; buf += `

`; buf += `

`; buf += `

`; break; case 'pmharassment': buf += `

${this.tr`If someone is harassing you in private messages (PMs), click the button below and a global staff member will take a look. If you are being harassed in a chatroom, please ask a room staff member to handle it. If it's a minor issue, consider using /ignore [username] instead.`}

`; if (!isLast) break; buf += `

`; break; case 'battleharassment': buf += `

${this.tr`If someone is harassing you in a battle, click the button below and a global staff member will take a look. If you are being harassed in a chatroom, please ask a room staff member to handle it. If it's a minor issue, consider using /ignore [username] instead.`}

`; buf += `

${this.tr`Please save a replay of the battle if it has ended, or provide a link to the battle if it is still ongoing.`}

`; if (!isLast) break; buf += `

`; break; case 'inapname': buf += `

${this.tr`If a user has an inappropriate name, click the button below and a global staff member will take a look.`}

`; if (!isLast) break; buf += `

`; break; case 'inappokemon': buf += `

${this.tr`If a user has inappropriate Pokemon nicknames, click the button below and a global staff member will take a look.`}

`; buf += `

${this.tr`Please save a replay of the battle if it has ended, or provide a link to the battle if it is still ongoing.`}

`; if (!isLast) break; buf += `

`; break; case 'appeal': buf += `

${this.tr`What would you like to appeal?`}

`; if (!isLast) break; if (user.locked || isStaff) { const namelocked = user.named && user.id.startsWith('guest'); if (user.locked === user.id || namelocked || isStaff) { if (user.permalocked || isStaff) { buf += `

`; } if (!user.permalocked || isStaff) { buf += `

`; } } if (user.locked === '#hostfilter' || (user.latestHostType === 'proxy' && user.locked !== user.id) || isStaff) { buf += `

`; } if ((user.locked !== '#hostfilter' && user.latestHostType !== 'proxy' && user.locked !== user.id) || isStaff) { buf += `

`; } } if (user.semilocked || isStaff) { buf += `

`; } buf += `

`; buf += `

`; break; case 'permalock': buf += `

${this.tr`Permalocks are usually for repeated incidents of poor behavior over an extended period of time, and rarely for a single severe infraction. Please keep this in mind when appealing a permalock.`}

`; buf += `

${this.tr`Please visit the Discipline Appeals page to appeal your permalock.`}

`; break; case 'lock': buf += `

${this.tr`If you want to appeal your lock or namelock, click the button below and a global staff member will be with you shortly.`}

`; if (!isLast) break; buf += `

`; break; case 'ip': buf += `

${this.tr`If you are locked or namelocked under a name you don't recognize, click the button below to call a global staff member so we can check.`}

`; if (!isLast) break; buf += `

`; break; case 'hostfilter': buf += `

${this.tr`We automatically lock proxies and VPNs to prevent evasion of punishments and other attacks on our server. To get unlocked, you need to disable your proxy or VPN.`}

`; break; case 'semilock': buf += `

${this.tr`Do you have an autoconfirmed account? An account is autoconfirmed when it has won at least one rated battle and has been registered for one week or longer.`}

`; if (!isLast) break; buf += `

`; break; case 'hasautoconfirmed': buf += `

${this.tr`Login to your autoconfirmed account by using the /nick command in any chatroom, and the semilock will automatically be removed. Afterwords, you can use the /nick command to switch back to your current username without being semilocked again.`}

`; buf += `

${this.tr`If the semilock does not go away, you can try asking a global staff member for help. Click the button below to call a global staff member.`}

`; if (!isLast) break; buf += `

`; break; case 'lacksautoconfirmed': buf += `

${this.tr`If you don't have an autoconfirmed account, you will need to contact a global staff member to appeal your semilock. Click the button below to call a global staff member.`}

`; if (!isLast) break; buf += `

`; break; case 'appealother': buf += `

${this.tr`Please PM the staff member who punished you. If you don't know who punished you, ask another room staff member; they will redirect you to the correct user. If you are banned or blacklisted from the room, use /roomauth [name of room] to get a list of room staff members. Bold names are online.`}

`; buf += `

${this.tr`Do not PM staff if you are locked (signified by the symbol in front of your username). Locks are a different type of punishment; to appeal a lock, make a help ticket by clicking the Back button and then selecting the most relevant option.`}

`; break; case 'misc': buf += `

${this.tr`Maybe one of these options will be helpful?`}

`; if (!isLast) break; buf += `

`; if (user.trusted || isStaff) buf += `

`; buf += `

`; break; case 'password': buf += `

${this.tr`If you lost your password, click the button below to request a password reset. We will need to clarify a few pieces of information before resetting the account. Please note that password resets are low priority and may take a while; we recommend using a new account while waiting.`}

`; buf += `

${this.tr`Request a password reset`}

`; break; case 'roomhelp': buf += `

${this.tr`If you are a room driver or up in a public room, and you need help watching the chat, one or more global staff members would be happy to assist you!`}

`; buf += `

`; break; case 'other': buf += `

${this.tr`If your issue is not handled above, click the button below to talk to a global staff member. Please be ready to explain the situation.`}

`; if (!isLast) break; buf += `

`; break; default: if (!page.startsWith('confirm') || !ticketTitles[page.slice(7)]) { buf += `

${this.tr`Malformed help request.`}

`; buf += ``; break; } const type = this.tr(ticketTitles[page.slice(7)]); buf += `

${this.tr`Are you sure you want to submit a ticket for ${type}?`}

`; const submitMeta = Utils.splitFirst(meta, '-', 2).join('|'); // change the delimiter as some ticket titles include - buf += `

`; break; } } buf += '
'; const curPageLink = query.length ? '-' + query.join('-') : ''; buf = buf.replace( /

`; buf += ``; buf += ``; const keys = Object.keys(tickets).sort((aKey, bKey) => { const a = tickets[aKey]; const b = tickets[bKey]; if (a.open !== b.open) { return (a.open ? -1 : 1); } if (a.open) { if (a.active !== b.active) { return (a.active ? -1 : 1); } return a.created - b.created; } return b.created - a.created; }); let count = 0; for (const key of keys) { if (count >= 100 && query[0] !== 'all') { buf += ``; break; } const ticket = tickets[key]; let icon = ` ${this.tr`Closed`}`; if (ticket.open) { if (!ticket.active) { icon = ` ${this.tr`Inactive`}`; } else if (ticket.claimed) { icon = ` ${this.tr`Claimed`}`; } else { icon = ` ${this.tr`Unclaimed`}`; } } buf += ``; buf += Utils.html``; buf += ``; buf += Utils.html``; buf += `'; count++; } buf += ``; buf += ``; const ticketBans = Array.from(Punishments.getPunishments('staff')) .sort((a, b) => a[1].expireTime - b[1].expireTime) .filter(item => item[1].punishType === 'TICKETBAN'); for (const [userid, entry] of ticketBans) { let ids = [userid]; if (entry.userids) ids = ids.concat(entry.userids); buf += ``; buf += ``; buf += ``; buf += ``; } buf += `

${this.tr`Help tickets`}

${this.tr`Status`}${this.tr`Creator`}${this.tr`Ticket Type`}${this.tr`Claimed by`}${this.tr`Action`}
${this.tr`And ${keys.length - count} more tickets.`} ${this.tr`View all tickets`}
${icon}${ticket.creator}${ticket.type}${ticket.claimed ? ticket.claimed : `-`}`; const roomid = 'help-' + ticket.userid; let logUrl = ''; if (Config.modloglink) { logUrl = Config.modloglink(new Date(ticket.created), roomid); } const room = Rooms.get(roomid); if (room) { const ticketGame = room.getGame(HelpTicket)!; buf += ` `; } if (logUrl) { buf += ``; } buf += '

${this.tr`Ticket Bans`}

UseridsIPsExpiresReason
${ids.map(Utils.escapeHTML).join(', ')}${entry.ips.join(', ')}${Chat.toDurationString(entry.expireTime - Date.now(), {precision: 1})}${entry.reason || ''}
`; return buf; }, stats(query, user, connection) { // view-help-stats-TABLE-YYYY-MM-COL if (!user.named) return Rooms.RETRY_AFTER_LOGIN; this.title = this.tr`Ticket Stats`; this.checkCan('lock'); let [table, yearString, monthString, col] = query; if (!['staff', 'tickets'].includes(table)) table = 'tickets'; const year = parseInt(yearString); const month = parseInt(monthString) - 1; let date = null; if (isNaN(year) || isNaN(month) || month < 0 || month > 11 || year < 2010) { // year/month not provided or is invalid, use current date date = new Date(); } else { date = new Date(year, month); } const dateUrl = Chat.toTimestamp(date).split(' ')[0].split('-', 2).join('-'); const rawTicketStats = FS(`logs/tickets/${dateUrl}.tsv`).readIfExistsSync(); if (!rawTicketStats) return `

${this.tr`No ticket stats found.`}
`; // Calculate next/previous month for stats and validate stats exist for the month // date.getMonth() returns 0-11, we need 1-12 +/-1 for this const prevDate = new Date( date.getMonth() === 0 ? date.getFullYear() - 1 : date.getFullYear(), date.getMonth() === 0 ? 11 : date.getMonth() - 1 ); const nextDate = new Date( date.getMonth() === 11 ? date.getFullYear() + 1 : date.getFullYear(), date.getMonth() === 11 ? 0 : date.getMonth() + 1 ); const prevString = Chat.toTimestamp(prevDate).split(' ')[0].split('-', 2).join('-'); const nextString = Chat.toTimestamp(nextDate).split(' ')[0].split('-', 2).join('-'); let buttonBar = ''; if (FS(`logs/tickets/${prevString}.tsv`).readIfExistsSync()) { buttonBar += `< ${this.tr`Previous Month`}`; } else { buttonBar += `< ${this.tr`Previous Month`}Month`; } buttonBar += `${this.tr`Ticket Stats`} ${this.tr`Staff Stats`}`; if (FS(`logs/tickets/${nextString}.tsv`).readIfExistsSync()) { buttonBar += `${this.tr`Next Month`} >`; } else { buttonBar += `${this.tr`Next Month`} >`; } let buf = `
${buttonBar}

`; buf += ``; if (table === 'tickets') { if (!['type', 'totaltickets', 'total', 'initwait', 'wait', 'resolution', 'result'].includes(col)) col = 'type'; buf += ``; } else { if (!['staff', 'num', 'time'].includes(col)) col = 'num'; buf += ``; } const ticketStats: {[k: string]: string}[] = rawTicketStats.split('\n').filter( (line: string) => line ).map( (line: string) => { const splitLine = line.split('\t'); return { type: splitLine[0], total: splitLine[1], initwait: splitLine[2], wait: splitLine[3], resolution: splitLine[4], result: splitLine[5], staff: splitLine[6], }; } ); if (table === 'tickets') { const typeStats: {[key: string]: {[key: string]: number}} = {}; for (const stats of ticketStats) { if (!typeStats[stats.type]) { typeStats[stats.type] = { total: 0, initwait: 0, wait: 0, dead: 0, unresolved: 0, resolved: 0, result: 0, totaltickets: 0, }; } const type = typeStats[stats.type]; type.totaltickets++; type.total += parseInt(stats.total); type.initwait += parseInt(stats.initwait); type.wait += parseInt(stats.wait); if (['approved', 'valid', 'assisted'].includes(stats.result.toString())) type.result++; if (['dead', 'unresolved', 'resolved'].includes(stats.resolution.toString())) { type[stats.resolution.toString()]++; } } // Calculate averages/percentages for (const t in typeStats) { const type = typeStats[t]; // Averages for (const key of ['total', 'initwait', 'wait']) { type[key] = Math.round(type[key] / type.totaltickets); } // Percentages for (const key of ['result', 'dead', 'unresolved', 'resolved']) { type[key] = Math.round((type[key] / type.totaltickets) * 100); } } const sortedStats = Object.keys(typeStats).sort((a, b) => { if (col === 'type') { // Alphabetize strings return a.localeCompare(b, 'en'); } else if (col === 'resolution') { return (typeStats[b].resolved || 0) - (typeStats[a].resolved || 0); } return typeStats[b][col] - typeStats[a][col]; }); for (const type of sortedStats) { const resolution = `${this.tr`Resolved`}: ${typeStats[type].resolved}%
${this.tr`Unresolved`}: ${typeStats[type].unresolved}%
${this.tr`Dead`}: ${typeStats[type].dead}%`; buf += ``; } } else { const staffStats: {[key: string]: {[key: string]: number}} = {}; for (const stats of ticketStats) { const staffArray = (typeof stats.staff === 'string' ? stats.staff.split(',') : []); for (const staff of staffArray) { if (!staff) continue; if (!staffStats[staff]) staffStats[staff] = {num: 0, time: 0}; staffStats[staff].num++; staffStats[staff].time += (parseInt(stats.total) - parseInt(stats.initwait)); } } for (const staff in staffStats) { staffStats[staff].time = Math.round(staffStats[staff].time / staffStats[staff].num); } const sortedStaff = Object.keys(staffStats).sort((a, b) => { if (col === 'staff') { // Alphabetize strings return a.localeCompare(b, 'en'); } return staffStats[b][col] - staffStats[a][col]; }); for (const staff of sortedStaff) { buf += ``; } } buf += `

${this.tr`Help Ticket Stats`} - ${date.toLocaleString('en-us', {month: 'long', year: 'numeric'})}

${type}${typeStats[type].totaltickets}${Chat.toDurationString(typeStats[type].total, {hhmmss: true})}${Chat.toDurationString(typeStats[type].initwait, {hhmmss: true}) || '-'}${Chat.toDurationString(typeStats[type].wait, {hhmmss: true}) || '-'}${resolution}${typeStats[type].result}%
${staff}${staffStats[staff].num}${Chat.toDurationString(staffStats[staff].time, {precision: 1})}
`; const headerTitles: {[id: string]: string} = { type: 'Type', totaltickets: 'Total Tickets', total: 'Average Total Time', initwait: 'Average Initial Wait', wait: 'Average Total Wait', resolution: 'Resolutions', result: 'Positive Result', staff: 'Staff ID', num: 'Number of Tickets', time: 'Average Time Per Ticket', }; buf = buf.replace(/`); } return this.parse(`/join view-help-request--report${meta}`); }, appeal(target, room, user) { if (!this.runBroadcast()) return; const meta = this.pmTarget ? `-user-${this.pmTarget.id}` : this.room ? `-room-${this.room.roomid}` : ''; if (this.broadcasting) { if (room?.battle) return this.errorReply(this.tr`This command cannot be broadcast in battles.`); return this.sendReplyBox(``); } return this.parse(`/join view-help-request--appeal${meta}`); }, requesthelp: 'helpticket', helprequest: 'helpticket', ht: 'helpticket', helpticket: { '': 'create', create(target, room, user) { if (!this.runBroadcast()) return; const meta = this.pmTarget ? `-user-${this.pmTarget.id}` : this.room ? `-room-${this.room.roomid}` : ''; if (this.broadcasting) { return this.sendReplyBox(``); } if (user.can('lock')) { return this.parse('/join view-help-request'); // Globals automatically get the form for reference. } if (!user.named) return this.errorReply(this.tr`You need to choose a username before doing this.`); return this.parse(`/join view-help-request${meta}`); }, createhelp: [`/helpticket create - Creates a new ticket requesting help from global staff.`], submit(target, room, user, connection) { if (user.can('lock') && !user.can('bypassall')) { return this.popupReply(this.tr`Global staff can't make tickets. They can only use the form for reference.`); } if (!user.named) return this.popupReply(this.tr`You need to choose a username before doing this.`); const banMsg = HelpTicket.checkBanned(user); if (banMsg) return this.popupReply(banMsg); let ticket = tickets[user.id]; const ipTicket = checkIp(user.latestIp); if (ticket?.open || ipTicket) { if (!ticket && ipTicket) ticket = ipTicket; const helpRoom = Rooms.get(`help-${ticket.userid}`); if (!helpRoom) { // Should never happen tickets[ticket.userid].open = false; writeTickets(); } else { if (!helpRoom.auth.has(user.id)) helpRoom.auth.set(user.id, '+'); this.parse(`/join help-${ticket.userid}`); return this.popupReply(this.tr`You already have an open ticket; please wait for global staff to respond.`); } } if (Monitor.countTickets(user.latestIp)) { const maxTickets = Punishments.sharedIps.has(user.latestIp) ? `50` : `5`; return this.popupReply(this.tr`Due to high load, you are limited to creating ${maxTickets} tickets every hour.`); } let [ticketType, reportTargetType, reportTarget] = Utils.splitFirst(target, '|', 2).map(s => s.trim()); reportTarget = Utils.escapeHTML(reportTarget); if (!Object.values(ticketTitles).includes(ticketType)) return this.parse('/helpticket'); const contexts: {[k: string]: string} = { 'PM Harassment': `Hi! Who was harassing you in private messages?`, 'Battle Harassment': `Hi! Who was harassing you, and in which battle did it happen? Please post a link to the battle or a replay of the battle.`, 'Inappropriate Username': `Hi! Tell us the username that is inappropriate.`, 'Inappropriate Pokemon Nicknames': `Hi! Which user has Pokemon with inappropriate nicknames, and in which battle? Please post a link to the battle or a replay of the battle.`, Appeal: `Hi! Can you please explain why you feel your punishment is undeserved?`, 'IP-Appeal': `Hi! How are you connecting to Showdown right now? At home, at school, on a phone using mobile data, or some other way?`, 'Public Room Assistance Request': `Hi! Which room(s) do you need us to help you watch?`, Other: `Hi! What seems to be the problem? Tell us about any people involved,` + ` and if this happened in a specific place on the site.`, }; const staffContexts: {[k: string]: string} = { 'IP-Appeal': `

${user.name}'s IP Addresses: ${user.ips.map(ip => `${ip}`).join(', ')}

`, }; ticket = { creator: user.name, userid: user.id, open: true, active: !contexts[ticketType], type: ticketType, created: Date.now(), claimed: null, ip: user.latestIp, }; let closeButtons = ``; switch (ticket.type) { case 'Appeal': case 'IP-Appeal': case 'ISP-Appeal': closeButtons = ` `; break; case 'PM Harassment': case 'Battle Harassment': case 'Inappropriate Pokemon Nicknames': case 'Inappropriate Username': closeButtons = ` `; break; case 'Public Room Assistance Request': case 'Other': default: closeButtons = ` `; } let staffIntroButtons = ''; let pmRequestButton = ''; if (reportTargetType === 'user' && reportTarget) { switch (ticket.type) { case 'PM Harassment': if (!Config.pmLogButton) break; pmRequestButton = Config.pmLogButton(user.id, toID(reportTarget)); contexts['PM Harassment'] = this.tr`Hi! Please click the button below to give global staff permission to check PMs.` + this.tr` Or if ${reportTarget} is not the user you want to report, please tell us the name of the user who you want to report.`; break; case 'Inappropriate Username': staffIntroButtons = ` `; break; } staffIntroButtons += ` `; } if (ticket.type === 'Appeal') { staffIntroButtons += ``; } const introMsg = Utils.html`

${this.tr`Help Ticket`} - ${user.name}

` + `

${this.tr`Issue`}: ${ticket.type}
${this.tr`A Global Staff member will be with you shortly.`}

`; const staffMessage = `

${closeButtons}

More Options ${staffIntroButtons}

`; const staffHint = staffContexts[ticketType] || ''; let reportTargetInfo = ''; if (reportTargetType === 'room') { reportTargetInfo = `Reported in room: ${reportTarget}`; const reportRoom = Rooms.get(reportTarget); if (reportRoom && (reportRoom as GameRoom).uploadReplay) { void (reportRoom as GameRoom).uploadReplay(user, connection, 'forpunishment'); } } else if (reportTargetType === 'user') { reportTargetInfo = `Reported user: ${reportTarget}

`; const targetID = toID(reportTarget); if (targetID !== ticket.userid) { const commonBattles = getCommonBattles( targetID, Users.get(reportTarget), ticket.userid, Users.get(ticket.userid) ); if (!commonBattles.length) { reportTargetInfo += Utils.html`There are no common battles between '${reportTarget}' and '${ticket.creator}'.`; } else { reportTargetInfo += Utils.html`Showing ${commonBattles.length} common battle(s) between '${reportTarget}' and '${ticket.creator}': `; reportTargetInfo += commonBattles.map(roomid => Utils.html`${roomid.replace(/^battle-/, '')}`); } } } let helpRoom = Rooms.get(`help-${user.id}`) as ChatRoom | null; if (!helpRoom) { helpRoom = Rooms.createChatRoom(`help-${user.id}` as RoomID, `[H] ${user.name}`, { isPersonal: true, isHelp: true, isPrivate: 'hidden', modjoin: '%', auth: {[user.id]: '+'}, introMessage: introMsg, staffMessage: staffMessage + staffHint + reportTargetInfo, }); helpRoom.game = new HelpTicket(helpRoom, ticket); } else { helpRoom.pokeExpireTimer(); helpRoom.settings.introMessage = introMsg; helpRoom.settings.staffMessage = staffMessage + staffHint + reportTargetInfo; if (helpRoom.game) helpRoom.game.destroy(); helpRoom.game = new HelpTicket(helpRoom, ticket); } const ticketGame = helpRoom.getGame(HelpTicket)!; helpRoom.modlog({action: 'TICKETOPEN', isGlobal: true, loggedBy: user.id, note: ticket.type}); ticketGame.addText(`${user.name} opened a new ticket. Issue: ${ticket.type}`, user); this.parse(`/join help-${user.id}`); if (!(user.id in ticketGame.playerTable)) { // User was already in the room, manually add them to the "game" so they get a popup if they try to leave ticketGame.addPlayer(user); } if (contexts[ticket.type]) { helpRoom.add(`|c|&Staff|${this.tr(contexts[ticket.type])}`); helpRoom.update(); } if (pmRequestButton) { helpRoom.add(pmRequestButton); helpRoom.update(); } tickets[user.id] = ticket; writeTickets(); notifyStaff(); connection.send(`>view-help-request\n|deinit`); }, list(target, room, user) { this.checkCan('lock'); this.parse('/join view-help-tickets'); }, listhelp: [`/helpticket list - Lists all tickets. Requires: % @ &`], stats(target, room, user) { this.checkCan('lock'); this.parse('/join view-help-stats'); }, statshelp: [`/helpticket stats - List the stats for help tickets. Requires: % @ &`], close(target, room, user) { if (!target) return this.parse(`/help helpticket close`); let result = !(this.splitTarget(target) === 'false'); const ticket = tickets[toID(this.inputUsername)]; if (!ticket || !ticket.open || (ticket.userid !== user.id && !user.can('lock'))) { return this.errorReply(this.tr`${this.inputUsername} does not have an open ticket.`); } const helpRoom = Rooms.get(`help-${ticket.userid}`) as ChatRoom | null; if (helpRoom) { const ticketGame = helpRoom.getGame(HelpTicket)!; if (ticket.userid === user.id && !user.isStaff) { result = !!(ticketGame.firstClaimTime); } ticketGame.close(result, user); } else { ticket.open = false; notifyStaff(); writeTickets(); } ticket.claimed = user.name; this.sendReply(`You closed ${ticket.creator}'s ticket.`); }, closehelp: [`/helpticket close [user] - Closes an open ticket. Requires: % @ &`], ban(target, room, user) { if (!target) return this.parse('/help helpticket ban'); target = this.splitTarget(target, true); const targetUser = this.targetUser; this.checkCan('lock', targetUser); const punishment = Punishments.roomUserids.nestedGet('staff', toID(this.targetUsername)); if (!targetUser && !Punishments.search(toID(this.targetUsername)).length) { return this.errorReply(this.tr`User '${this.targetUsername}' not found.`); } if (target.length > 300) { return this.errorReply(this.tr`The reason is too long. It cannot exceed 300 characters.`); } let username; let userid; if (targetUser) { username = targetUser.getLastName(); userid = targetUser.getLastId(); if (punishment) { return this.privateModAction(`${username} would be ticket banned by ${user.name} but was already ticket banned.`); } if (targetUser.trusted) { Monitor.log(`[CrisisMonitor] Trusted user ${targetUser.name}${(targetUser.trusted !== targetUser.id ? ` (${targetUser.trusted})` : ``)} was ticket banned by ${user.name}, and should probably be demoted.`); } } else { username = this.targetUsername; userid = toID(this.targetUsername); if (punishment) { return this.privateModAction(`${username} would be ticket banned by ${user.name} but was already ticket banned.`); } } if (targetUser) { targetUser.popup(`|modal|${user.name} has banned you from creating help tickets.${(target ? `\n\nReason: ${target}` : ``)}\n\nYour ban will expire in a few days.`); } const affected = HelpTicket.ban(userid, target); this.addModAction(`${username} was ticket banned by ${user.name}.${target ? ` (${target})` : ``}`); const acAccount = (targetUser && targetUser.autoconfirmed !== userid && targetUser.autoconfirmed); let displayMessage = ''; if (affected.length > 1) { displayMessage = `${username}'s ${acAccount ? ` ac account: ${acAccount}, ` : ""}ticket banned alts: ${affected.slice(1).map(userObj => userObj.getLastName()).join(", ")}`; this.privateModAction(displayMessage); } else if (acAccount) { displayMessage = `${username}'s ac account: ${acAccount}`; this.privateModAction(displayMessage); } this.globalModlog(`TICKETBAN`, targetUser || userid, target); for (const userObj of affected) { const userObjID = (typeof userObj !== 'string' ? userObj.getLastId() : toID(userObj)); const targetTicket = tickets[userObjID]; if (targetTicket?.open) targetTicket.open = false; const helpRoom = Rooms.get(`help-${userObjID}`); if (helpRoom) { const ticketGame = helpRoom.getGame(HelpTicket)!; ticketGame.writeStats('ticketban'); helpRoom.destroy(); } } writeTickets(); notifyStaff(); return true; }, banhelp: [`/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ &`], unban(target, room, user) { if (!target) return this.parse('/help helpticket unban'); this.checkCan('lock'); const targetUser = Users.get(target, true); if (!targetUser) return this.errorReply(`User not found.`); const banned = HelpTicket.checkBanned(targetUser); if (!banned) { return this.errorReply(this.tr`${targetUser ? targetUser.name : target} is not ticket banned.`); } const affected = HelpTicket.unban(target); this.addModAction(`${affected} was ticket unbanned by ${user.name}.`); this.globalModlog("UNTICKETBAN", toID(target)); if (targetUser) targetUser.popup(`${user.name} has ticket unbanned you.`); }, unbanhelp: [`/helpticket unban [user] - Ticket unbans a user. Requires: % @ &`], ignore(target, room, user) { this.checkCan('lock'); if (user.settings.ignoreTickets) { return this.errorReply(this.tr`You are already ignoring help ticket notifications. Use /helpticket unignore to receive notifications again.`); } user.settings.ignoreTickets = true; user.update(); this.sendReply(this.tr`You are now ignoring help ticket notifications.`); }, ignorehelp: [`/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ &`], unignore(target, room, user) { this.checkCan('lock'); if (!user.settings.ignoreTickets) { return this.errorReply(this.tr`You are not ignoring help ticket notifications. Use /helpticket ignore to stop receiving notifications.`); } user.settings.ignoreTickets = false; user.update(); this.sendReply(this.tr`You will now receive help ticket notifications.`); }, unignorehelp: [`/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ &`], delete(target, room, user) { // This is a utility only to be used if something goes wrong this.checkCan('makeroom'); if (!target) return this.parse(`/help helpticket delete`); const ticket = tickets[toID(target)]; if (!ticket) return this.errorReply(this.tr`${target} does not have a ticket.`); const targetRoom = Rooms.get(`help-${ticket.userid}`); if (targetRoom) { targetRoom.getGame(HelpTicket)!.deleteTicket(user); } else { delete tickets[ticket.userid]; writeTickets(); notifyStaff(); } this.sendReply(this.tr`You deleted ${target}'s ticket.`); }, deletehelp: [`/helpticket delete [user] - Deletes a user's ticket. Requires: &`], }, helptickethelp: [ `/helpticket create - Creates a new ticket, requesting help from global staff.`, `/helpticket list - Lists all tickets. Requires: % @ &`, `/helpticket close [user] - Closes an open ticket. Requires: % @ &`, `/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ &`, `/helpticket unban [user] - Ticket unbans a user. Requires: % @ &`, `/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ &`, `/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ &`, `/helpticket delete [user] - Deletes a user's ticket. Requires: &`, ], }; export const punishmentfilter: Chat.PunishmentFilter = (user, punishment) => { if (punishment[0] !== 'BAN') return; const helpRoom = Rooms.get(`help-${toID(user)}`); if (helpRoom?.game?.gameid !== 'helpticket') return; const ticket = helpRoom.game as HelpTicket; ticket.close('ticketban'); };