From c9f034784b0aae1b517a9532f6f317adeb5d2316 Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Wed, 23 Feb 2022 20:33:49 -0600 Subject: [PATCH] Helptickets: Support automatic punishments with Artemis (#8669) --- CODEOWNERS | 1 + server/artemis/local.ts | 9 + server/chat-plugins/helptickets-auto.ts | 782 ++++++++++++++++++++++++ server/chat-plugins/helptickets.ts | 143 ++++- server/chat.ts | 14 + 5 files changed, 926 insertions(+), 23 deletions(-) create mode 100644 server/chat-plugins/helptickets-auto.ts diff --git a/CODEOWNERS b/CODEOWNERS index b26ead9826df..f46c4f7212a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -8,6 +8,7 @@ server/artemis/* @mia-pi-git server/chat-plugins/friends.ts @mia-pi-git server/chat-plugins/github.ts @mia-pi-git server/chat-plugins/hosts.ts @AnnikaCodes +server/chat-plugins/helptickets*.ts @mia-pi-git server/chat-plugins/mafia.ts @HoeenCoder server/chat-plugins/othermetas.ts @KrisXV server/chat-plugins/quotes.ts @mia-pi-git @KrisXV diff --git a/server/artemis/local.ts b/server/artemis/local.ts index 05281d5f0261..f60674ee368f 100644 --- a/server/artemis/local.ts +++ b/server/artemis/local.ts @@ -75,6 +75,15 @@ export const PM = new ProcessManager.StreamProcessManager(module, () => new Arte export class LocalClassifier { static readonly PM = PM; + static readonly ATTRIBUTES: Record = { + sexual_explicit: {}, + severe_toxicity: {}, + toxicity: {}, + obscene: {}, + identity_attack: {}, + insult: {}, + threat: {}, + }; static classifiers: LocalClassifier[] = []; static destroy() { for (const classifier of this.classifiers) classifier.destroy(); diff --git a/server/chat-plugins/helptickets-auto.ts b/server/chat-plugins/helptickets-auto.ts new file mode 100644 index 000000000000..8142b282f353 --- /dev/null +++ b/server/chat-plugins/helptickets-auto.ts @@ -0,0 +1,782 @@ +import {FS, Utils} from '../../lib'; +import type {ModlogSearch, ModlogEntry} from '../modlog'; +import { + TicketState, getBattleLog, getBattleLinks, + writeTickets, notifyStaff, writeStats, HelpTicket, + tickets, +} from './helptickets'; +import * as Artemis from '../artemis'; + +const ORDERED_PUNISHMENTS = ['WARN', 'FORCERENAME', 'LOCK', 'NAMELOCK', 'WEEKLOCK', 'WEEKNAMELOCK']; +const PMLOG_IGNORE_TIME = 24 * 60 * 60 * 1000; +const WHITELIST = ['mia']; +const REASONS: Record = { + sexual_explicit: 'explicit messages', + severe_toxicity: 'extreme harassment', + toxicity: 'harassment', + obscene: 'obscene messages', + identity_attack: 'using identity-based insults', + insult: 'insulting others', + threat: 'threatening others', +}; + +export interface AutoPunishment { + modlogCount?: number; + severity?: {type: string[], certainty: number}; + /** + * Should it be run on just one message? + * or is it safe to run on averages (for the types that use averages) + */ + isSingleMessage?: boolean; + punishment: string; + ticketType: string; +} + +export interface AutoSettings { + punishments: AutoPunishment[]; + applyPunishments: boolean; +} + +const defaults: AutoSettings = { + punishments: [{ + ticketType: 'inapname', + punishment: 'forcerename', + severity: {type: ['sexual_explicit', 'severe_toxicity', 'identity_attack'], certainty: 0.4}, + }, { + ticketType: 'pmharassment', + punishment: 'warn', + severity: {type: ['sexual_explicit', 'severe_toxicity', 'identity_attack'], certainty: 0.15}, + }], + applyPunishments: false, +}; + +export const settings: AutoSettings = (() => { + try { + // spreading w/ default means that + // adding new things won't crash by not existing + return {...defaults, ...JSON.parse(FS('config/chat-plugins/ht-auto.json').readSync())}; + } catch { + return defaults; + } +})(); + +function saveSettings() { + return FS('config/chat-plugins/ht-auto.json').writeUpdate(() => JSON.stringify(settings)); +} + +function visualizePunishment(punishment: AutoPunishment) { + const buf = [`punishment: ${punishment.punishment?.toUpperCase()}`]; + buf.push(`ticket type: ${punishment.ticketType}`); + if (punishment.severity) { + buf.push(`severity: ${punishment.severity.certainty} (for ${punishment.severity.type.join(', ')})`); + } + if (punishment.modlogCount) { + buf.push(`required modlog: ${punishment.modlogCount}`); + } + if (punishment.isSingleMessage) { + buf.push(`for single messages only`); + } + return buf.join(', '); +} + +function checkAccess(context: Chat.CommandContext | Chat.PageContext) { + if (!WHITELIST.includes(context.user.id)) context.checkCan('bypassall'); +} + +export function punishmentsFor(type: string) { + return settings.punishments.filter(t => t.ticketType === type); +} + +/** Is punishment1 higher than punishment2 on the list? */ +function supersedes(p1: string, p2: string) { + return ORDERED_PUNISHMENTS.indexOf(p1) > ORDERED_PUNISHMENTS.indexOf(p2); +} + +export function determinePunishment( + ticketType: string, results: Record, modlog: ModlogEntry[], isSingleMessage = false +) { + const punishments = punishmentsFor(ticketType); + let action: string | null = null; + const types = []; + Utils.sortBy(punishments, p => -ORDERED_PUNISHMENTS.indexOf(p.punishment)); + for (const punishment of punishments) { + if (isSingleMessage && !punishment.isSingleMessage) continue; + if (punishment.modlogCount && modlog.length < punishment.modlogCount) continue; + if (punishment.severity) { + let hit = false; + for (const type of punishment.severity.type) { + if (results[type] < punishment.severity.certainty) continue; + hit = true; + types.push(type); + break; + } + if (!hit) continue; + } + if (!action || supersedes(punishment.punishment, action)) { + action = punishment.punishment; + } + } + return {action, types}; +} + +export function globalModlog(action: string, user: User | ID | null, note: string, roomid?: string) { + user = Users.get(user) || user; + void Rooms.Modlog.write(roomid || 'global', { + action, + ip: user && typeof user === 'object' ? user.latestIp : undefined, + userid: toID(user) || undefined, + loggedBy: 'artemis' as ID, + note, + }); +} + +export function addModAction(message: string) { + Rooms.get('staff')?.add(`|c|&|/log ${message}`).update(); +} + +export async function getModlog(params: {user?: ID, ip?: string, actions?: string[]}) { + const search: ModlogSearch = { + note: [], + user: [], + ip: [], + action: [], + actionTaker: [], + }; + if (params.user) search.user = [{search: params.user, isExact: true}]; + if (params.ip) search.ip = [{search: params.ip}]; + if (params.actions) search.action = params.actions.map(s => ({search: s})); + const res = await Rooms.Modlog.search('global', search); + return res?.results || []; +} + +function closeTicket(ticket: TicketState, msg?: string) { + if (!ticket.open) return; + ticket.open = false; + ticket.active = false; + ticket.resolved = { + time: Date.now(), + by: 'the Artemis AI', // we want it to be clear to end users that it was not a human + seen: false, + staffReason: '', + result: msg || '', + note: ( + `Want to learn more about the AI? ` + + `Visit the information thread.` + ), + }; + writeTickets(); + notifyStaff(); + const tarUser = Users.get(ticket.userid); + if (tarUser) { + HelpTicket.notifyResolved(tarUser, ticket, ticket.userid); + } + // TODO: Support closing as invalid + writeStats(`${ticket.type}\t${Date.now() - ticket.created}\t0\t0\tresolved\tvalid\tartemis`); +} + +async function lock( + user: User | ID, + result: CheckerResult, + ticket: TicketState, + isWeek?: boolean, + isName?: boolean +) { + const id = toID(user); + let desc, type; + const expireTime = isWeek ? Date.now() + 7 * 24 * 60 * 60 * 1000 : null; + if (isName) { + if (typeof user === 'object') user.resetName(); + desc = 'locked your username and prevented you from changing names'; + type = `locked from talking${isWeek ? ` for a week` : ""}`; + await Punishments.namelock(id, expireTime, null, false, result.reason || "Automatically locked due to a user report"); + } else { + type = isWeek ? 'weeknamelocked' : 'namelocked'; + desc = 'locked you from talking in chats, battles, and PMing regular users'; + await Punishments.lock(id, expireTime, null, false, result.reason || "Automatically locked due to a user report"); + } + if (typeof user !== 'string') { + let message = `|popup||html|${user.name} has ${desc} for ${isWeek ? '7' : '2'} days.`; + if (result.reason) message += `\n\nReason: ${result.reason}`; + let appeal = ''; + if (Chat.pages.help) { + appeal += ``; + } else if (Config.appealurl) { + appeal += `appeal: ${Config.appealurl}`; + } + if (appeal) message += `\n\nIf you feel that your lock was unjustified, you can ${appeal}.`; + message += `\n\nYour lock will expire in a few days.`; + user.send(message); + } + addModAction(`${id} was ${type} by Artemis. (${result.reason || `report from ${ticket.creator}`})`); + globalModlog( + `${isWeek ? 'WEEK' : ""}${isName ? "NAME" : ""}LOCK`, id, + (result.reason || `report from ${ticket.creator}`) + (result.proof ? ` PROOF: ${result.proof}` : "") + ); +} + +export const actionHandlers: { + [k: string]: (user: User | ID, result: CheckerResult, ticket: TicketState) => string | void | Promise, +} = { + forcerename(user, result, ticket) { + if (typeof user === 'string') return; // they can only submit users with existing userobjects anyway + const id = toID(user); + user.resetName(); + user.trackRename = id; + Monitor.forceRenames.set(id, true); + user.send( + '|nametaken|Your name was detected to be breaking our name rules. ' + + `${result.reason ? `Reason: ${result.reason}. ` : ""}` + + 'Please change it, or submit a help ticket by typing /ht in chat to appeal this action.' + ); + Rooms.get('staff')?.add( + `|html|${id} ` + + `was automatically forced to choose a new name by Artemis (report from ${ticket.userid}).` + ).update(); + globalModlog( + 'FORCERENAME', id, `username determined to be inappropriate due to a report by ${ticket.creator}`, result.roomid + ); + return `${id} was automatically forcerenamed. Thank you for reporting.`; + }, + async namelock(user, result, ticket) { + await lock(user, result, ticket, false, true); + return `${toID(user)} was automatically namelocked. Thank you for reporting.`; + }, + async weeknamelock(user, result, ticket) { + await lock(user, result, ticket, true, true); + return `${toID(user)} was automatically weeknamelocked. Thank you for reporting.`; + }, + async lock(user, result, ticket) { + await lock(user, result, ticket); + return `${toID(user)} was automatically locked. Thank you for reporting.`; + }, + async weeklock(user, result, ticket) { + await lock(user, result, ticket, true); + return `${toID(user)} was automatically weeklocked. Thank you for reporting.`; + }, + warn(user, result, ticket) { + user = toID(user); + user = Users.get(user) || user; + if (typeof user === 'object') { + user.send(`|c|~|/warn ${result.reason || ""}`); + } else { + Punishments.offlineWarns.set(user, result.reason); + } + addModAction( + `${user} was warned by Artemis. ${typeof user === 'string' ? 'while offline ' : ""}` + + `(${result.reason || `report from ${ticket.creator}`})` + ); + globalModlog( + 'WARN', user, result.reason || `report from ${ticket.creator}` + ); + return `${user} was automatically warned. Thank you for reporting.`; + }, +}; + +export async function getMessageAverages(messages: string[]) { + const counts: Record = {}; + const classified = []; + for (const message of messages) { + const res = await classifier.classify(message); + if (!res) continue; + classified.push(res); + for (const k in res) { + if (!counts[k]) counts[k] = {count: 0, raw: 0}; + counts[k].count++; + counts[k].raw += res[k]; + } + } + const averages: Record = {}; + for (const k in counts) { + averages[k] = counts[k].raw / counts[k].count; + } + return {averages, classified}; +} + +interface CheckerResult { + action: string; + user: User | ID; + result: Record; + reason: string; + roomid?: string; + displayReason?: string; + proof?: string; +} + +type CheckerOutput = void | Map; + +export const checkers: { + [k: string]: (ticket: TicketState & {text: [string, string]}) => CheckerOutput | Promise, +} = { + async inapname(ticket) { + const id = toID(ticket.text[0]); + const user = Users.getExact(id); + if (user && !user.trusted) { + const result = await classifier.classify(user.name); + if (!result) return; + const keys = ['identity_attack', 'sexual_explicit', 'severe_toxicity']; + const matched = keys.some(k => result[k] >= 0.4); + if (matched) { + const modlog = await getModlog({ + ip: user.latestIp, + actions: ['FORCERENAME', 'NAMELOCK', 'WEEKNAMELOCK'], + }); + let {action} = determinePunishment('inapname', result, modlog); + if (!action) action = 'forcerename'; + return new Map([[user.id, { + action, + user, + result, + reason: "Username detected to be breaking username rules", + }]]); + } + } + }, + async inappokemon(ticket) { + const actions = new Map(); + const links = [...getBattleLinks(ticket.text[0]), ...getBattleLinks(ticket.text[1])]; + for (const link of links) { + const log = await getBattleLog(link); + if (!log) continue; + for (const [user, pokemon] of Object.entries(log.pokemon)) { + const userid = toID(user); + let result: { + action: string, name: string, result: Record, replay: string, + } | null = null; + for (const set of pokemon) { + if (!set.name) continue; + const results = await classifier.classify(set.name); + if (!results) continue; + // atm don't factor in modlog + const curAction = determinePunishment('inappokemon', results, []).action; + if (curAction && (!result || supersedes(curAction, result.action))) { + result = {action: curAction, name: set.name, result: results, replay: link}; + } + } + if (result) { + actions.set(user, { + action: result.action, + user: userid, + result: result.result, + reason: `Pokemon name detected to be breaking rules - '${result.name}'`, + roomid: link, + }); + } + } + } + if (actions.size) return actions; + }, + async battleharassment(ticket) { + const urls = getBattleLinks(ticket.text[0]); + const actions = new Map(); + for (const url of urls) { + const log = await getBattleLog(url); + if (!log) continue; + const messages: Record = {}; + for (const message of log.log) { + const [username, text] = Utils.splitFirst(message.slice(3), '|').map(f => f.trim()); + const id = toID(username); + if (!id) continue; + if (!messages[id]) messages[id] = []; + messages[id].push(text); + } + for (const [id, messageList] of Object.entries(messages)) { + const {averages, classified} = await getMessageAverages(messageList); + const {action, types} = determinePunishment('battleharassment', averages, []); + if (!action) continue; + const existingPunishment = actions.get(id); + if (!existingPunishment || supersedes(action, existingPunishment.action)) { + actions.set(id, { + action, + user: toID(id), + result: averages, + reason: `Not following rules in battles (${types.map(r => REASONS[r])})`, + proof: urls.join(', '), + }); + } + for (const result of classified) { + const curPunishment = determinePunishment('battleharassment', result, [], true).action; + if (!curPunishment) continue; + const exists = actions.get(id); + if (!exists || supersedes(curPunishment, exists.action)) { + actions.set(id, { + action: curPunishment, + user: toID(id), + result: averages, + reason: `Not following rules in battles`, + proof: urls.join(', '), + }); + } + } + } + } + // ensure reasons are clear + const creatorWasPunished = actions.get(ticket.userid); + if (creatorWasPunished) { + let displayReason = 'You were punished for your behavior.'; + if (actions.size !== 1) { // more than 1 was punished + displayReason += ` ${actions.size - 1} other(s) were also punished.`; + } + creatorWasPunished.displayReason = displayReason; + } + + if (actions.size) return actions; + }, + async pmharassment(ticket) { + const actions = new Map(); + const targetId = toID(ticket.text[0]); + const creator = ticket.userid; + if (!Config.getpmlog) return; + const pmLog = await Config.getpmlog(targetId, creator) as { + message: string, from: string, to: string, timestamp: string, + }[]; + const messages: Record = {}; + const ids = new Set(); + // sort messages by user who sent them, also filter out old ones + for (const {from, message, timestamp} of pmLog) { + // ignore pmlogs more than 24h old + if ((Date.now() - new Date(timestamp).getTime()) > PMLOG_IGNORE_TIME) continue; + const id = toID(from); + ids.add(id); + if (!messages[id]) messages[id] = []; + messages[id].push(message); + } + for (const id of ids) { + let punishment; + const {averages, classified} = await getMessageAverages(messages[id]); + const curPunishment = determinePunishment('pmharassment', averages, []).action; + if (!curPunishment) continue; + if (!punishment || supersedes(curPunishment, punishment)) { + punishment = curPunishment; + } + if (punishment) { + actions.set(id, { + action: punishment, + user: id, + result: {}, + reason: "PM harassment", + }); + } + for (const result of classified) { + const {action, types} = determinePunishment('pmharassment', result, [], true); + if (!action) continue; + const exists = actions.get(id); + if (!exists || supersedes(action, exists.action)) { + actions.set(id, { + action, + user: id, + result: {}, + reason: `PM harassment (${types.map(r => REASONS[r])})`, + }); + } + } + } + + const creatorWasPunished = actions.get(ticket.userid); + if (creatorWasPunished) { + let displayReason = `You were punished for your behavior. `; + if (actions.has(targetId) && targetId !== ticket.userid) { + displayReason += ` The person you reported was also punished.`; + } + creatorWasPunished.displayReason = displayReason; + } + + if (actions.size) return actions; + }, +}; + +export const classifier = new Artemis.LocalClassifier(); + +export async function runPunishments(ticket: TicketState & {text: [string, string]}, typeId: string) { + let result: Map | null = null; + if (checkers[typeId]) { + result = await checkers[typeId](ticket) || null; + } + if (result) { + if (settings.applyPunishments) { + const responses: [string, string][] = []; + for (const res of result.values()) { + const curResult = await actionHandlers[res.action.toLowerCase()](res.user, res, ticket); + if (curResult) responses.push([res.action, res.displayReason || curResult]); + if (toID(res.user) === ticket.creator) { + // just close the ticket here. + closeTicket(ticket, res.displayReason); + } + } + if (responses.length) { + // if we don't have one for the user, find one. + Utils.sortBy(responses, r => -ORDERED_PUNISHMENTS.indexOf(r[0])); + closeTicket(ticket, responses[0][1]); + } else { + closeTicket(ticket); // no good response. just close it, because we __have__ dispatched an action. + } + } else { + ticket.recommended = []; + for (const res of result.values()) { + Rooms.get('abuselog')?.add( + `[${ticket.type} Monitor] Recommended: ${res.action}: for ${res.user} (${res.reason})` + ).update(); + ticket.recommended.push(`${res.action}: for ${res.user} (${res.reason})`); + } + } + } +} + +export const commands: Chat.ChatCommands = { + aht: 'autohelpticket', + autohelpticket: { + ''() { + return this.parse(`/help autohelpticket`); + }, + ap: 'addpunishment', + add: 'addpunishment', + addpunishment(target, room, user) { + checkAccess(this); + if (!toID(target)) return this.parse(`/help autohelpticket`); + const args = Chat.parseArguments(target); + const punishment: Partial = {}; + for (const [k, list] of Object.entries(args)) { + if (k !== 'type' && list.length > 1) throw new Chat.ErrorMessage(`More than one ${k} param provided.`); + const val = list[0]; // if key exists, val must exist too + switch (k) { + case 'type': case 't': + const types = list.map(f => f.toLowerCase().replace(/\s/g, '_')); + for (const type of types) { + if (!Artemis.LocalClassifier.ATTRIBUTES[type]) { + return this.errorReply( + `Invalid classifier type '${type}'. Valid types are ` + + Object.keys(Artemis.LocalClassifier.ATTRIBUTES).join(', ') + ); + } + } + if (!punishment.severity) { + punishment.severity = {certainty: 0, type: []}; + } + punishment.severity.type.push(...types); + break; + case 'certainty': case 'c': + const num = parseFloat(val); + if (isNaN(num) || num < 0 || num > 1) { + return this.errorReply(`Certainty must be a number below 1 and above 0.`); + } + if (!punishment.severity) { + punishment.severity = {certainty: 0, type: []}; + } + punishment.severity.certainty = num; + break; + case 'modlog': case 'm': + const count = parseInt(val); + if (isNaN(count) || count < 0) { + return this.errorReply(`Modlog count must be a number above 0.`); + } + punishment.modlogCount = count; + break; + case 'ticket': case 'tt': case 'tickettype': + const type = toID(val); + if (!(type in checkers)) { + return this.errorReply( + `The ticket type '${type}' does not exist or is not supported. ` + + `Supported types are ${Object.keys(checkers).join(', ')}.` + ); + } + punishment.ticketType = type; + break; + case 'p': case 'punishment': + const name = toID(val).toUpperCase(); + if (!ORDERED_PUNISHMENTS.includes(name)) { + return this.errorReply( + `Punishment '${name}' not supported. ` + + `Supported punishments: ${ORDERED_PUNISHMENTS.join(', ')}` + ); + } + punishment.punishment = name; + break; + case 'single': case 's': + if (!this.meansYes(toID(val))) { + return this.errorReply( + `The 'single' value must always be 'on'. ` + + `If you don't want it enabled, just do not use this argument type.` + ); + } + punishment.isSingleMessage = true; + break; + } + } + if (!punishment.ticketType) { + return this.errorReply(`Must specify a ticket type to handle.`); + } + if (!punishment.punishment) { + return this.errorReply(`Must specify a punishment to apply.`); + } + if (!(punishment.severity?.certainty && punishment.severity?.type.length)) { + return this.errorReply(`A severity to monitor for must be specified (certainty).`); + } + for (const curP of settings.punishments) { + let matches = 0; + for (const k in curP) { + if (punishment[k as keyof AutoPunishment] === curP[k as keyof AutoPunishment]) { + matches++; + } + } + if (matches === Object.keys(punishment).length) { + return this.errorReply(`That punishment is already added.`); + } + } + settings.punishments.push(punishment as AutoPunishment); + saveSettings(); + this.privateGlobalModAction( + `${user.name} added a ${punishment.punishment} punishment to the Artemis helpticket handler.` + ); + this.globalModlog(`AUTOHELPTICKET ADDPUNISHMENT`, null, visualizePunishment(punishment as AutoPunishment)); + }, + dp: 'deletepunishment', + delete: 'deletepunishment', + deletepunishment(target, room, user) { + checkAccess(this); + const num = parseInt(target) - 1; + if (isNaN(num)) return this.parse(`/h autohelpticket`); + const punishment = settings.punishments[num]; + if (!punishment) return this.errorReply(`There is no punishment at index ${num + 1}.`); + settings.punishments.splice(num, 1); + this.privateGlobalModAction( + `${user.name} removed the Artemis helpticket ${punishment.punishment} punishment indexed at ${num + 1}` + ); + this.globalModlog(`AUTOHELPTICKET REMOVE`, null, visualizePunishment(punishment)); + }, + vp: 'viewpunishments', + view: 'viewpunishments', + viewpunishments() { + checkAccess(this); + let buf = `Artemis helpticket punishments
`; + if (!settings.punishments.length) { + buf += `None.`; + return this.sendReplyBox(buf); + } + buf += settings.punishments.map( + (curP, i) => `${i + 1}: ${visualizePunishment(curP)}` + ).join('
'); + return this.sendReplyBox(buf); + }, + togglepunishments(target, room, user) { + checkAccess(this); + let message; + if (this.meansYes(target)) { + if (settings.applyPunishments) { + return this.errorReply(`Automatic punishments are already enabled.`); + } + settings.applyPunishments = true; + message = `${user.name} enabled automatic punishments for the Artemis ticket handler`; + } else if (this.meansNo(target)) { + if (!settings.applyPunishments) { + return this.errorReply(`Automatic punishments are already disabled.`); + } + settings.applyPunishments = false; + message = `${user.name} disabled automatic punishments for the Artemis ticket handler`; + } else { + return this.errorReply(`Invalid setting. Must be 'on' or 'off'.`); + } + this.privateGlobalModAction(message); + this.globalModlog(`AUTOHELPTICKET TOGGLE`, null, settings.applyPunishments ? 'on' : 'off'); + saveSettings(); + }, + stats(target) { + if (!target) target = Chat.toTimestamp(new Date()).split(' ')[0]; + return this.parse(`/j view-autohelpticketstats-${target}`); + }, + resolve(target, room, user) { + this.checkCan('lock'); + const [ticketId, result] = Utils.splitFirst(target, ',').map(toID); + const ticket = tickets[ticketId]; + if (!ticket?.open) { + return this.popupReply(`The user '${ticketId}' does not have a ticket open at present.`); + } + if (!['success', 'failure'].includes(result)) { + return this.popupReply(`The result must be 'success' or 'failure'.`); + } + (ticket.state ||= {}).recommendResult = result; + writeTickets(); + Chat.refreshPageFor(`help-text-${ticketId}`, 'staff'); + }, + }, + autohelptickethelp: [ + `/aht addpunishment [args] - Adds a punishment with the given [args]. Requires: whitelist &`, + `/aht deletepunishment [index] - Deletes the automatic helpticket punishment at [index]. Requires: whitelist &`, + `/aht viewpunishments - View automatic helpticket punishments. Requires: whitelist &`, + `/aht togglepunishments [on | off] - Turn [on | off] automatic helpticket punishments. Requires: whitelist &`, + `/aht stats - View success rates of the Artemis ticket handler. Requires: whitelist &`, + ], +}; + +export const pages: Chat.PageTable = { + async autohelpticketstats(query, user) { + checkAccess(this); + let month; + if (query.length) { + month = /[0-9]{4}-[0-9]{2}/.exec(query.join('-'))?.[0]; + } else { + month = Chat.toTimestamp(new Date()).split(' ')[0].slice(0, -3); + } + if (!month) { + return this.errorReply(`Invalid month. Must be in YYYY-MM format.`); + } + + this.title = `[Artemis Ticket Stats] ${month}`; + this.setHTML(`

Artemis ticket stats


Searching...`); + + const found = await HelpTicket.getTextLogs(['recommendResult'], month); + const percent = (numerator: number, denom: number) => Math.floor((numerator / denom) * 100); + + let buf = `
`; + buf += ``; + buf += `

Artemis ticket stats


`; + const dayStats: Record = {}; + const total = {successes: 0, failures: 0, total: 0}; + for (const ticket of found) { + const day = Chat.toTimestamp(new Date(ticket.created)).split(' ')[0]; + if (!dayStats[day]) dayStats[day] = {successes: 0, failures: 0, total: 0}; + dayStats[day].total++; + total.total++; + switch (ticket.state.recommendResult) { + case 'success': + dayStats[day].successes++; + total.successes++; + break; + case 'failure': + dayStats[day].failures++; + total.failures++; + break; + } + } + buf += `Total: ${total.total}
`; + buf += `Success rate: ${percent(total.successes, total.total)}% (${total.successes})
`; + buf += `Failure rate: ${percent(total.failures, total.total)}% (${total.failures})
`; + buf += `Day stats:
`; + buf += `
`; + let header = ''; + let data = ''; + const sortedDays = Utils.sortBy(Object.keys(dayStats), d => new Date(d).getTime()); + for (const [i, day] of sortedDays.entries()) { + const cur = dayStats[day]; + if (!cur.total) continue; + header += ``; + data += `'; + // i + 1 ensures it's above 0 always (0 % 5 === 0) + if ((i + 1) % 5 === 0 && sortedDays[i + 1]) { + buf += `${header}${data}`; + buf += `
${day.split('-')[2]} (${cur.total})${cur.successes} (${percent(cur.successes, cur.total)}%)`; + if (cur.failures) { + data += ` | ${cur.failures} (${percent(cur.failures, cur.total)}%)`; + } else { // so one cannot confuse dead tickets & false hit tickets + data += ' | 0 (0%)'; + } + data += '
`; + buf += `
`; + header = ''; + data = ''; + } + } + buf += `${header}${data}`; + buf += `
`; + return buf; + }, +}; diff --git a/server/chat-plugins/helptickets.ts b/server/chat-plugins/helptickets.ts index aec797a1e22c..0ba6983958cc 100644 --- a/server/chat-plugins/helptickets.ts +++ b/server/chat-plugins/helptickets.ts @@ -3,13 +3,14 @@ import {getCommonBattles} from '../chat-commands/info'; import {checkRipgrepAvailability} from '../config-loader'; import type {Punishment} from '../punishments'; import type {PartialModlogEntry, ModlogID} from '../modlog'; +import {runPunishments} from './helptickets-auto'; const TICKET_FILE = 'config/tickets.json'; const SETTINGS_FILE = 'config/chat-plugins/ticket-settings.json'; const TICKET_CACHE_TIME = 24 * 60 * 60 * 1000; // 24 hours const TICKET_BAN_DURATION = 48 * 60 * 60 * 1000; // 48 hours -const BATTLES_REGEX = /\bbattle-(?:[a-z0-9]+)-(?:[0-9]+)(?:-[a-z0-9]{31}pw)?/g; -const REPLAY_REGEX = new RegExp( +export const BATTLES_REGEX = /\bbattle-(?:[a-z0-9]+)-(?:[0-9]+)(?:-[a-z0-9]{31}pw)?/g; +export const REPLAY_REGEX = new RegExp( `${Utils.escapeRegex(Config.routes.replays)}/(?:[a-z0-9]-)?(?:[a-z0-9]+)-(?:[0-9]+)(?:-[a-z0-9]{31}pw)?`, "g" ); const REPORT_NAMECOLORS: {[k: string]: string} = { @@ -30,7 +31,7 @@ interface TicketSettings { responses: {[ticketType: string]: {[title: string]: string}}; } -interface TicketState { +export interface TicketState { creator: string; userid: ID; open: boolean; @@ -51,6 +52,8 @@ interface TicketState { * Use `TextTicketInfo#getState` to set it at creation (store properties of the user object, etc) */ state?: AnyObject & {claimTime?: number}; + /** Recommendations from the Artemis monitor, if it is set to only recommend. */ + recommended?: string[]; } interface ResolvedTicketInfo { @@ -59,6 +62,8 @@ interface ResolvedTicketInfo { by: string; seen: boolean; staffReason: string; + /** note under the resolved */ + note?: string; } export interface TextTicketInfo { @@ -86,6 +91,7 @@ interface BattleInfo { url: string; title: string; players: {p1: ID, p2: ID, p3?: ID, p4?: ID}; + pokemon: Record; } type TicketResult = 'approved' | 'valid' | 'assisted' | 'denied' | 'invalid' | 'unassisted' | 'ticketban' | 'deleted'; @@ -158,7 +164,7 @@ async function convertRoomPunishments() { } } -function writeStats(line: string) { +export 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('-'); @@ -505,10 +511,19 @@ export class HelpTicket extends Rooms.RoomGame { const date = Chat.toTimestamp(new Date()).split(' ')[0]; void FS(`logs/tickets/${date.slice(0, -3)}.jsonl`).append(JSON.stringify(entry) + '\n'); } - static async getTextLogs(userid: ID, date?: string) { + + /** + * @param search [search key, search value] (ie ['userid', 'mia'] + * returns tickets where the userid property === mia) + * If the [value] is omitted (index 1), searches just for tickets with the given property. + */ + static async getTextLogs(search: [string, string] | [string], date?: string) { const results = []; if (await checkRipgrepAvailability()) { - const args = [`-e`, `userid":"${userid}`, '--no-filename']; + const args = [ + `-e`, search.length > 1 ? `${search[0]}":"${search[1]}` : `${search[0]}":`, + '--no-filename', + ]; let lines; try { lines = await ProcessManager.exec([ @@ -540,9 +555,10 @@ export class HelpTicket extends Rooms.RoomGame { for await (const line of stream.byLine()) { if (line.trim()) { const data = JSON.parse(line); - if (data.userid === userid) { - results.push(data); - } + const searched = data[search[0]]; + let matched = !!searched; + if (search[1]) matched = searched === search[1]; + if (matched) results.push(data); } } } @@ -679,13 +695,16 @@ export class HelpTicket extends Rooms.RoomGame { return `You are banned from creating help tickets.`; } static notifyResolved(user: User, ticket: TicketState, userid = user.id) { - const {result, time, by, seen} = ticket.resolved as {result: string, time: number, by: string, seen: boolean}; + const {result, time, by, seen, note} = ticket.resolved as ResolvedTicketInfo; if (seen) return; const timeString = (Date.now() - time) > 1000 ? `, ${Chat.toDurationString(Date.now() - time)} ago.` : '.'; user.send(`|pm|&Staff|${user.getIdentity()}|Hello! Your report was resolved by ${by}${timeString}`); if (result?.trim()) { user.send(`|pm|&Staff|${user.getIdentity()}|The result was "${result}"`); } + if (note?.trim()) { + user.send(`|pm|&Staff|${user.getIdentity()}|/raw ${note}`); + } tickets[userid].resolved!.seen = true; writeTickets(); } @@ -899,21 +918,54 @@ export async function getOpponent(link: string, submitter: ID): Promise { const battleRoom = Rooms.get(battle); + const seenPokemon = new Set(); if (battleRoom && battleRoom.type !== 'chat') { const playerTable: Partial = {}; + const monTable: BattleInfo['pokemon'] = {}; // i kinda hate this, but this will always be accurate to the battle players. // consulting room.battle.playerTable might be invalid (if battle is over), etc. - const playerLines = battleRoom.log.log.filter(line => line.startsWith('|player|')); - for (const line of playerLines) { - // |player|p1|Mia|miapi.png|1000 - const [, , playerSlot, name] = line.split('|'); - playerTable[playerSlot as SideID] = toID(name); + for (const line of battleRoom.log.log) { + // |switch|p2a: badnite|Dragonite, M|323/323 + if (line.startsWith('|switch|')) { // name cannot have been seen until it switches in + const [, , playerWithNick, speciesWithGender] = line.split('|'); + let [slot, name] = playerWithNick.split(':'); + const species = speciesWithGender.split(',')[0].trim(); // should always exist + slot = slot.slice(0, -1); // p2a -> p2 + if (!monTable[slot]) monTable[slot] = []; + const identifier = `${name || ""}-${species}`; + if (seenPokemon.has(identifier)) continue; + // technically, if several mons have the same name and species, this will ignore them. + // BUT if they have the same name and species we only need to see it once + // so it doesn't matter + seenPokemon.add(identifier); + name = name?.trim() || ""; + monTable[slot].push({ + species, + name: species === name ? undefined : name, + }); + } + if (line.startsWith('|player|')) { + // |player|p1|Mia|miapi.png|1000 + const [, , playerSlot, name] = line.split('|'); + playerTable[playerSlot as SideID] = toID(name); + } + for (const k in monTable) { + // SideID => userID, cannot do conversion at time of collection + // because the playerID => userid mapping might not be there. + // strictly, yes it will, but this is for maximum safety. + const userid = playerTable[k as SideID]; + if (userid) { + monTable[userid] = monTable[k]; + delete monTable[k]; + } + } } return { log: battleRoom.log.log.filter(k => k.startsWith('|c|')), title: battleRoom.title, url: `/${battle}`, players: playerTable as BattleInfo['players'], + pokemon: monTable, }; } battle = battle.replace(`battle-`, ''); // don't wanna strip passwords @@ -921,16 +973,43 @@ export async function getBattleLog(battle: string): Promise { const raw = await Net(`https://${Config.routes.replays}/${battle}.json`).get(); const data = JSON.parse(raw); if (data.log?.length) { + const log = data.log.split('\n'); + const players = { + p1: toID(data.p1), + p2: toID(data.p2), + p3: toID(data.p3), + p4: toID(data.p4), + }; + const chat = []; + const mons: BattleInfo['pokemon'] = {}; + for (const line of log) { + if (line.startsWith('|c|')) { + chat.push(line); + } else if (line.startsWith('|switch|')) { + const [, , playerWithNick, speciesWithGender] = line.split('|'); + const species = speciesWithGender.split(',')[0].trim(); // should always exist + let [slot, name] = playerWithNick.split(':'); + slot = slot.slice(0, -1); // p2a -> p2 + // safe to not check here bc this should always exist in the players table. + // if it doesn't, there's a problem + const id = players[slot as SideID]; + if (!mons[id]) mons[id] = []; + name = name?.trim() || ""; + const setId = `${name || ""}-${species}`; + if (seenPokemon.has(setId)) continue; + seenPokemon.add(setId); + mons[id].push({ + species, // don't want to see a name if it's the same as the species + name: name === species ? undefined : name, + }); + } + } return { - log: data.log.split('\n').filter((k: string) => k.startsWith('|c|')), + log: chat, title: `${data.p1} vs ${data.p2}`, url: `https://${Config.routes.replays}/${battle}`, - players: { - p1: toID(data.p1), - p2: toID(data.p2), - p3: toID(data.p3), - p4: toID(data.p4), - }, + players, + pokemon: mons, }; } } catch {} @@ -1842,6 +1921,23 @@ export const pages: Chat.PageTable = { buf += `From: ${ticket.userid}`; buf += ` | `; buf += `
`; + if (ticket.recommended?.length) { + if (ticket.recommended.length > 1) { + buf += `
Recommended from Artemis`; + buf += ticket.recommended.join('
'); + buf += `
`; + } else { + buf += `Recommended from Artemis: ${ticket.recommended[0]}`; + } + if (!ticket.state?.recommendResult) { + buf += `
`; + buf += `Rate accuracy of result: `; + for (const [title, result] of [['Accurate', 'success'], ['Inaccurate', 'failure']]) { + buf += ``; + } + } + buf += `
`; + } buf += await ticketInfo.getReviewDisplay(ticket as TicketState & {text: [string, string]}, user, connection); buf += `
`; buf += `
`; @@ -1901,7 +1997,7 @@ export const pages: Chat.PageTable = { return this.errorReply(`Invalid date.`); } } - const logs = await HelpTicket.getTextLogs(userid, date); + const logs = await HelpTicket.getTextLogs(['userid', userid], date); this.title = `[Ticket Logs] ${userid}${date ? ` (${date})` : ''}`; let buf = `

Ticket logs for ${userid}${date ? ` in the month of ${date}` : ''}

`; buf += ``; @@ -2283,6 +2379,7 @@ export const commands: Chat.ChatCommands = { writeTickets(); notifyStaff(); textTicket.onSubmit?.(ticket, [text, contextString], this.user, this.connection); + void runPunishments(ticket as TicketState & {text: [string, string]}, typeId); if (textTicket.getState) { ticket.state = textTicket.getState(ticket, user); } diff --git a/server/chat.ts b/server/chat.ts index 184cb65ecb38..cb7ba7c797c8 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -2455,6 +2455,20 @@ export const Chat = new class { return probe(url); } + parseArguments(str: string, delim = ',', paramDelim = '=', useIDs = true) { + const result: Record = {}; + for (const part of str.split(delim)) { + let [key, val] = Utils.splitFirst(part, paramDelim).map(f => f.trim()); + if (useIDs) key = toID(key); + if (!toID(key) || !toID(val)) { + throw new Chat.ErrorMessage(`Invalid option ${part}. Must be in [key]${paramDelim}[value] format.`); + } + if (!result[key]) result[key] = []; + result[key].push(val); + } + return result; + } + /** * Normalize a message for the purposes of applying chat filters. *