Spaces:
Running
Running
import { FS, Utils } from '../../lib'; | |
import type { ModlogSearch, ModlogEntry } from '../modlog'; | |
import { | |
type 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']; | |
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<string, number>, 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? ` + | |
`<a href="https://www.smogon.com/forums/threads/3570628/#post-9056769">Visit the information thread</a>.` | |
), | |
}; | |
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 += `<a href="view-help-request--appeal"><button class="button"><strong>Appeal your punishment</strong></button></a>`; | |
} else if (Config.appealurl) { | |
appeal += `appeal: <a href="${Config.appealurl}">${Config.appealurl}</a>`; | |
} | |
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<string | void>, | |
} = { | |
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|<span class="username">${id}</span> ` + | |
`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.`; | |
}, | |
}; | |
function shouldNotProcess(message: string) { | |
return ( | |
// special 'command', blocks things like /log, /raw, /html | |
// (but not a // message) | |
(message.startsWith('/') && !message.startsWith('//')) || | |
// broadcasted chat command | |
message.startsWith('!') | |
); | |
} | |
export async function getMessageAverages(messages: string[]) { | |
const counts: Record<string, { count: number, raw: number }> = {}; | |
const classified = []; | |
for (const message of messages) { | |
if (shouldNotProcess(message)) continue; | |
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<string, number> = {}; | |
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<string, number>; | |
reason: string; | |
roomid?: string; | |
displayReason?: string; | |
proof?: string; | |
} | |
type CheckerOutput = void | Map<string, CheckerResult>; | |
export const checkers: { | |
[k: string]: (ticket: TicketState & { text: [string, string] }) => CheckerOutput | Promise<CheckerOutput>, | |
} = { | |
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<string, number>, 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<string, CheckerResult>(); | |
for (const url of urls) { | |
const log = await getBattleLog(url); | |
if (!log) continue; | |
const messages: Record<string, string[]> = {}; | |
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 } = determinePunishment('battleharassment', averages, []); | |
if (action) { | |
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 (https://${Config.routes.client}/${url})`, | |
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 (https://${Config.routes.client}/${url})`, | |
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<string, CheckerResult>(); | |
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<string, string[]> = {}; | |
const ids = new Set<ID>(); | |
// 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) { | |
if (!punishment || supersedes(curPunishment, punishment)) { | |
punishment = curPunishment; | |
} | |
if (punishment) { | |
actions.set(id, { | |
action: punishment, | |
user: id, | |
result: {}, | |
reason: `PM harassment (against ${ticket.userid === id ? targetId : ticket.userid})`, | |
}); | |
} | |
} | |
for (const result of classified) { | |
const { action } = 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 (against ${ticket.userid === id ? targetId : ticket.userid})`, | |
}); | |
} | |
} | |
} | |
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<string, CheckerResult> | 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 { | |
// eslint-disable-next-line require-atomic-updates | |
ticket.recommended = []; | |
for (const res of result.values()) { | |
Rooms.get('abuselog')?.add( | |
`|c|~|/log [${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`); | |
}, | |
async test(target) { | |
checkAccess(this); | |
target = target.trim(); | |
const response = await classifier.classify(target) || {}; | |
let buf = Utils.html`<strong>Results for "${target}":</strong><br />`; | |
buf += `<strong>Score breakdown:</strong><br />`; | |
for (const k in response) { | |
buf += `• ${k}: ${response[k]}<br />`; | |
} | |
this.runBroadcast(); | |
this.sendReplyBox(buf); | |
}, | |
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<AutoPunishment> = {}; | |
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 = `<strong>Artemis helpticket punishments</strong><hr />`; | |
if (!settings.punishments.length) { | |
buf += `None.`; | |
return this.sendReplyBox(buf); | |
} | |
buf += settings.punishments.map( | |
(curP, i) => `<strong>${i + 1}:</strong> ${visualizePunishment(curP)}` | |
).join('<br />'); | |
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-autohelpticket-stats-${target}`); | |
}, | |
logs(target) { | |
if (!target) target = Chat.toTimestamp(new Date()).split(' ')[0]; | |
return this.parse(`/j view-autohelpticket-logs-${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 = { | |
autohelpticket: { | |
async stats(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(`<div class="pad"><h3>Artemis ticket stats</h3><hr />Searching...`); | |
const found = await HelpTicket.getTextLogs(['recommendResult'], month); | |
const percent = (numerator: number, denom: number) => Math.floor((numerator / denom) * 100); | |
let buf = `<div class="pad">`; | |
buf += `<button style="float:right;" class="button" name="send" value="/join ${this.pageid}">`; | |
buf += `<i class="fa fa-refresh"></i> Refresh</button>`; | |
buf += `<h3>Artemis ticket stats</h3><hr />`; | |
const dayStats: Record<string, { successes: number, failures: number, total: number }> = {}; | |
const total = { successes: 0, failures: 0, total: 0 }; | |
const failed = []; | |
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++; | |
failed.push([ticket.userid, ticket.type]); | |
break; | |
} | |
} | |
buf += `<strong>Total:</strong> ${total.total}<br />`; | |
buf += `<strong>Success rate:</strong> ${percent(total.successes, total.total)}% (${total.successes})<br />`; | |
buf += `<strong>Failure rate:</strong> ${percent(total.failures, total.total)}% (${total.failures})<br />`; | |
buf += `<strong>Day stats:</strong><br />`; | |
buf += `<div class="ladder pad"><table>`; | |
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 += `<th>${day.split('-')[2]} (${cur.total})</th>`; | |
data += `<td><small>${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 += '</small></td>'; | |
// i + 1 ensures it's above 0 always (0 % 5 === 0) | |
if ((i + 1) % 5 === 0 && sortedDays[i + 1]) { | |
buf += `<tr>${header}</tr><tr>${data}</tr>`; | |
buf += `</div></table>`; | |
buf += `<div class="ladder pad"><table>`; | |
header = ''; | |
data = ''; | |
} | |
} | |
buf += `<tr>${header}</tr><tr>${data}</tr>`; | |
buf += `</div></table>`; | |
buf += `<br />`; | |
if (failed.length) { | |
buf += `<details class="readmore"><summary>Marked as inaccurate</summary>`; | |
buf += failed.map(([userid, type]) => ( | |
`<a href="/view-help-text-${userid}">${userid}</a> (${type})` | |
)).join('<br />'); | |
buf += `</details>`; | |
} | |
return buf; | |
}, | |
async logs(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 Logs]`; | |
let buf = `<div class="pad"><h3>Artemis ticket logs</h3><hr />`; | |
const allHits = await HelpTicket.getTextLogs(['recommended'], month); | |
Utils.sortBy(allHits, h => -h.created); | |
if (allHits.length) { | |
buf += `<strong>All hits:</strong><hr />`; | |
for (const hit of allHits) { | |
if (!hit.recommended) continue; // ??? | |
buf += `<a href="/view-help-text-${hit.userid}">${hit.userid}</a> (${hit.type}) `; | |
buf += `[${Chat.toTimestamp(new Date(hit.created))}]<br />`; | |
buf += Utils.html`• <code><small>${hit.recommended.join(', ')}</small></code><hr />`; | |
} | |
} else { | |
buf += `<div class="message-error">No hits found.</div>`; | |
} | |
return buf; | |
}, | |
}, | |
}; | |