/** * Wrapper to facilitate posting / interacting with Smogon. * By Mia. * @author mia-pi-git */ import { Net, FS, Utils } from '../../lib'; export interface Nomination { by: ID; ips: string[]; info: string; date: number; standing: string; alts: string[]; primaryID: ID; claimed?: ID; post?: string; } interface IPData { country: string; isp: string; city: string; regionName: string; lat: number; lon: number; } export function getIPData(ip: string) { try { return Net("https://miapi.dev/api/ip/" + ip).get().then(JSON.parse) as Promise; } catch { return null; } } export const Smogon = new class { async post(threadNum: string, postText: string) { if (!Config.smogon) return null; try { const raw = await Net(`https://www.smogon.com/forums/api/posts`).get({ method: 'POST', body: new URLSearchParams({ thread_id: threadNum, message: postText, }).toString(), headers: { 'XF-Api-Key': Config.smogon, 'Content-Type': 'application/x-www-form-urlencoded', }, }); // todo return URL of post const data = JSON.parse(raw); if (data.errors?.length) { const errData = data.errors.pop(); throw new Error(errData.message); } return data; } catch (e: any) { if (e.message.includes('Not Found')) { // special case to be loud throw new Error("WHO DELETED THE PERMA THREAD"); } return { error: e.message }; } } }; export const Nominations = new class { noms: Nomination[] = []; icons: Record = {}; constructor() { this.load(); } load() { try { let data = JSON.parse(FS('config/chat-plugins/permas.json').readSync()); if (Array.isArray(data)) { data = { noms: data, icons: {} }; FS('config/chat-plugins/permas.json').writeSync(JSON.stringify(data)); } this.noms = data.noms; this.icons = data.icons; } catch {} } fetchModlog(id: string) { return Rooms.Modlog.search('global', { user: [{ search: id, isExact: true }], note: [], ip: [], action: [], actionTaker: [], }, undefined, true); } save() { FS('config/chat-plugins/permas.json').writeUpdate(() => JSON.stringify({ noms: this.noms, icons: this.icons })); } notifyStaff() { const usRoom = Rooms.get('upperstaff'); if (!usRoom) return; usRoom.send(`|uhtml|permanoms|${this.getDisplayButton()}`); Chat.refreshPageFor('permalocks', usRoom); } async add(target: string, connection: Connection) { const user = connection.user; const [primary, rawAlts, rawIps, type, details] = Utils.splitFirst(target, '|', 4).map(f => f.trim()); const primaryID = toID(primary); const alts = rawAlts.split(',').map(toID).filter(Boolean); const ips = rawIps.split(',').map(f => f.trim()).filter(Boolean); for (const ip of ips) { if (!IPTools.ipRegex.test(ip)) this.error(`Invalid IP: ${ip}`, connection); } const standings = this.getStandings(); if (!standings[type]) { this.error(`Invalid standing: ${type}.`, connection); } if (!details) { this.error("Details must be provided. Explain why this user should be permalocked.", connection); } if (!primaryID) { this.error("A primary username must be provided. Use one of their alts if necessary.", connection); } for (const nom of this.noms) { if (nom.primaryID === primaryID) { this.error(`'${primaryID}' was already nominated for permalock by ${nom.by}.`, connection); } } const ipTable = new Set(ips); const altTable = new Set([...alts]); for (const alt of [primaryID, ...alts]) { const modlog = await this.fetchModlog(alt); if (!modlog?.results.length) continue; for (const entry of modlog.results) { if (entry.ip) ipTable.add(entry.ip); if (entry.autoconfirmedID) altTable.add(entry.autoconfirmedID); if (entry.alts) { for (const id of entry.alts) altTable.add(id); } } } altTable.delete(primaryID); this.noms.push({ by: user.id, alts: [...altTable], ips: Utils.sortBy([...ipTable], z => -(IPTools.ipToNumber(z) || Infinity)), info: details, primaryID, standing: type, date: Date.now(), }); Utils.sortBy(this.noms, nom => -nom.date); this.save(); this.notifyStaff(); Rooms.get('staff')?.addByUser(user, `${user.name} submitted a perma nomination for ${primaryID}`); } find(id: string) { return this.noms.find(f => f.primaryID === id); } error(message: string, conn: Connection): never { conn.popup(message); throw new Chat.Interruption(); } close(target: string, context: Chat.CommandContext) { const entry = this.find(target); if (!entry) { this.error(`There is no nomination pending for '${toID(target)}'.`, context.connection); } this.noms.splice(this.noms.findIndex(f => f.primaryID === entry.primaryID), 1); this.save(); this.notifyStaff(); // todo fix when on good comp return context.closePage(`permalocks-view-${entry.primaryID}`); } display(nom: Nomination, canEdit?: boolean) { let buf = `
`; let title = nom.primaryID as string; if (canEdit) { title = `${nom.primaryID}`; } buf += `${title} (submitted by ${nom.by})
`; buf += `Submitted ${Chat.toTimestamp(new Date(nom.date), { human: true })}
`; buf += `${Chat.count(nom.alts, 'alts')}, ${Chat.count(nom.ips, 'IPs')}`; buf += `
`; return buf; } displayModlog(results: import('../modlog').ModlogEntry[] | null) { if (!results) return ''; let curDate = ''; return results.map(result => { const date = new Date(result.time || Date.now()); const entryRoom = result.visualRoomID || result.roomID || 'global'; let [dateString, timestamp] = Chat.toTimestamp(date, { human: true }).split(' '); let line = `[${timestamp}] (${entryRoom}) ${result.action}`; if (result.userid) { line += `: [${result.userid}]`; if (result.autoconfirmedID) line += ` ac: [${result.autoconfirmedID}]`; if (result.alts.length) line += ` alts: [${result.alts.join('], [')}]`; if (result.ip) line += ` [${result.ip}]`; } if (result.loggedBy) line += `: by ${result.loggedBy}`; if (result.note) line += Utils.html`: ${result.note}`; if (dateString !== curDate) { curDate = dateString; dateString = `

[${dateString}]
`; } else { dateString = ``; } const thisRoomID = entryRoom?.split(' ')[0]; if (thisRoomID.startsWith('battle-')) { timestamp = `${timestamp}`; } else { const [day, time] = Chat.toTimestamp(date).split(' '); timestamp = `${timestamp}`; } return `${dateString}${line}`; }).join(`
`); } async displayActionPage(nom: Nomination) { let buf = `

`; const standings = this.getStandings(); buf += ``; buf += `

Nomination: ${nom.primaryID}


`; buf += `By: ${nom.by} (on ${Chat.toTimestamp(new Date(nom.date))})
`; buf += `Recommended punishment: ${standings[nom.standing]}
`; buf += `
Modlog`; buf += `
`; const modlog = await this.fetchModlog(nom.primaryID); if (!modlog) { buf += `None found.`; } else { buf += this.displayModlog(modlog.results); } buf += `
`; if (nom.alts.length) { buf += `
Listed alts`; for (const [i, alt] of nom.alts.entries()) { buf += `- ${alt}: `; buf += `
`; buf += this.standingDropdown("standing"); buf += ` `; buf += ` `; buf += `
`; if (nom.alts[i + 1]) buf += `
`; } buf += `
`; } if (nom.ips.length) { buf += `
Listed IPs`; for (const [i, ip] of nom.ips.entries()) { const ipData = await getIPData(ip); buf += `- ${ip}`; if (ipData) { buf += `(ISP: ${ipData.isp}, loc: ${ipData.city}, ${ipData.regionName} in ${ipData.country})`; } buf += `: `; buf += `
`; buf += this.standingDropdown("standing"); buf += ` `; buf += ` `; buf += `
`; if (nom.ips[i + 1]) buf += `
`; } buf += `
`; } const [matches] = await LoginServer.request('ipmatches', { id: nom.primaryID, }); if (matches?.results?.length) { buf += `
Registration IP matches`; for (const [i, { userid, banstate }] of matches.results.entries()) { buf += `- ${userid}: `; buf += `
`; buf += this.standingDropdown("standing", `${banstate}`); buf += `
`; if (matches.results[i + 1]) buf += `
`; } buf += `
`; } buf += `

Staff notes:

`; buf += `

${Chat.formatText(nom.info).replace(/\n/ig, '
')}

`; buf += `
Act on primary:`; buf += `
`; buf += `Standing: ${this.standingDropdown('standing')}`; buf += `
Notes:
`; buf += `
`; buf += ``; buf += `

`; buf += ``; return buf; } standingDropdown(elemName: string, curStanding: string | null = null) { let buf = ``; return buf; } getStandings() { if (Config.standings) return Config.standings; Config.standings = { '-20': "Confirmed", '-10': "Autoconfirmed", '0': "New", "20": "Permalock", "30": "Permaban", "100": "Disabled", }; return Config.standings; } displayAll(canEdit: boolean) { let buf = `
`; buf += ``; buf += `

Pending perma nominations


`; if (!this.noms.length) { buf += `None found.`; return buf; } for (const nom of this.noms) { buf += this.display(nom, canEdit); buf += `
`; } return buf; } displayNomPage() { let buf = `

Make a nomination for a permanent punishment.


`; // const [primary, rawAlts, rawIps, details] = Utils.splitFirst(target, '|', 3).map(f => f.trim()); buf += `
`; buf += `
`; buf += `Primary userid:
`; buf += `Alts:

(Separated by commas)
`; buf += `Static IPs:

(Separated by commas)

`; buf += `Punishment: `; buf += ``; buf += `
`; buf += `Please explain why this user deserves a permanent punishment
`; buf += `Note: Modlogs are automatically included in review and do not need to be added here.
`; buf += `
`; buf += ``; return buf; } getDisplayButton() { const unclaimed = this.noms.filter(f => !f.claimed); let buf = `
`; if (!this.noms.length) { buf += `No permalock nominations active.`; } else { let className = 'button'; if (unclaimed.length) className += ' notifying'; buf += ``; } buf += `
`; return buf; } }; export const commands: Chat.ChatCommands = { perma: { ''(target, room, user) { this.checkCan('lock'); if (!user.can('rangeban')) { return this.parse(`/j view-permalocks-submit`); } else { return this.parse(`/j view-permalocks-list`); } }, viewnom(target) { this.checkCan('rangeban'); return this.parse(`/j view-permalocks-view-${toID(target)}`); }, submit(target, room, user) { this.checkCan('lock'); return Nominations.add(target, this.connection); }, list() { this.checkCan('lock'); return this.parse(`/j view-permalocks-list`); }, nom() { this.checkCan('lock'); return this.parse(`/j view-permalocks-submit`); }, async actmain(target, room, user) { this.checkCan('rangeban'); const [primaryName, standingName, postReason] = Utils.splitFirst(target, ',', 2).map(f => f.trim()); const primary = toID(primaryName); if (!primary) return this.popupReply(`Invalid primary username.`); const nom = Nominations.find(primary); if (!nom) return this.popupReply(`No permalock nomination found for ${primary}.`); const standing = parseInt(standingName); const standings = Nominations.getStandings(); if (!standings[standing]) return this.popupReply(`Invalid standing.`); if (!toID(postReason)) return this.popupReply(`A reason must be given.`); // todo thread num const threadNum = Config.permathread; if (!threadNum) { throw new Chat.ErrorMessage("The link to the perma has not been set - the post could not be made."); } let postBuf = `[b][url="https://${Config.routes.root}/users/${primary}"]${primary}[/url][/b]`; const icon = Nominations.icons[user.id] ? `:${Nominations.icons[user.id]}: - ` : ``; postBuf += ` was added to ${standings[standing]} by ${user.name} (${icon}${postReason}).\n`; postBuf += `Nominated by ${nom.by}.\n[spoiler=Nomination notes]${nom.info}[/spoiler]\n`; postBuf += `${nom.alts.length ? `[spoiler=Alts]${nom.alts.join(', ')}[/spoiler]` : ""}\n`; if (nom.ips.length) { postBuf += `[spoiler=IPs]`; for (const ip of nom.ips) { const ipData = await getIPData(ip); postBuf += `- [url=https://whatismyipaddress.com/ip/${ip}]${ip}[/url]`; if (ipData) { postBuf += ` (ISP: ${ipData.isp}, loc: ${ipData.city}, ${ipData.regionName} in ${ipData.country})`; } postBuf += '\n'; } postBuf += `[/spoiler]`; } const modlog = await Nominations.fetchModlog(nom.primaryID); if (modlog?.results.length) { let rawHTML = Nominations.displayModlog(modlog.results); rawHTML = rawHTML.replace(/
/g, '\n'); rawHTML = Utils.stripHTML(rawHTML); rawHTML = rawHTML.replace(///g, '/'); postBuf += `\n[spoiler=Modlog]${rawHTML}[/spoiler]`; } const res = await Smogon.post( threadNum, postBuf, ); if (!res || res.error) { return this.popupReply(`Error making post: ${res?.error}`); } const url = `https://smogon.com/forums/threads/${threadNum}/post-${res.post.post_id}`; const result = await LoginServer.request('setstanding', { user: primary, standing, reason: url, actor: user.id, }); if (result[1]) { return this.popupReply(`Error changing standing: ${result[1].message}`); } nom.post = url; this.popupReply(`|html|Standing successfully changed. Smogon post can be found at this link.`); }, async standing(target) { this.checkCan('rangeban'); const [name, rawStanding, reason] = Utils.splitFirst(target, ',', 2).map(f => f.trim()); const id = toID(name); if (!id || id.length > 18) { return this.popupReply('Invalid username: ' + name); } const standingNum = parseInt(rawStanding); if (!standingNum) { return this.popupReply(`Invalid standing: ` + rawStanding); } if (!reason.length) { return this.popupReply(`A reason must be given.`); } const res = await LoginServer.request('setstanding', { user: id, standing: standingNum, reason, actor: this.user.id, }); if (res[1]) { return this.popupReply(`Error in standing change: ` + res[1].message); } this.popupReply(`Standing successfully changed to ${standingNum} for ${id}.`); // no need to modlog, is in usermodlog already }, async ipstanding(target) { this.checkCan('rangeban'); const [ip, standingName, reason] = Utils.splitFirst(target, ',', 2).map(f => f.trim()); if (!IPTools.ipToNumber(ip)) { return this.popupReply(`Invalid IP: ${ip}`); } const standingNum = parseInt(standingName); if (!Config.standings[`${standingNum}`]) { return this.popupReply(`Invalid standing: ${standingName}.`); } if (!reason.length) { return this.popupReply('Specify a reason.'); } const res = await LoginServer.request('ipstanding', { reason, standing: standingNum, ip, actor: this.user.id, }); if (res[1]) { return this.popupReply(`Error changing standing: ${res[1].message}`); } this.popupReply(`All standings on the IP ${ip} changed successfully to ${standingNum}.`); this.globalModlog(`IPSTANDING`, null, `${standingNum}${reason ? ` (${reason})` : ""}`, ip); }, resolve(target) { this.checkCan('rangeban'); Nominations.close(target, this); }, seticon(target, room, user) { this.checkCan('rangeban'); let [monName, targetId] = target.split(','); if (!targetId) targetId = user.id; const mon = Dex.species.get(monName); if (!mon.exists) { return this.errorReply(`Species ${monName} does not exist.`); } Nominations.icons[targetId] = mon.name.toLowerCase(); Nominations.save(); this.sendReply( `|html|Updated ${targetId === user.id ? 'your' : `${targetId}'s`} permalock post icon to ` + `` ); }, deleteicon(target, room, user) { this.checkCan('rangeban'); const targetID = toID(target); if (!Nominations.icons[targetID]) { return this.errorReply(`${targetID} does not have an icon set.`); } delete Nominations.icons[targetID]; Nominations.save(); this.sendReply(`Removed ${targetID}'s permalock post icon.`); }, help: [ '/perma nom OR /perma - Open the page to make a nomination for a permanent punishment. Requires: % @ ~', '/perma list - View open nominations. Requires: % @ ~', '/perma viewnom [userid] - View a nomination for the given [userid]. Requires: ~', ], }, }; export const pages: Chat.PageTable = { permalocks: { list(query, user, conn) { this.checkCan('lock'); this.title = '[Permalock Nominations]'; return Nominations.displayAll(user.can('rangeban')); }, view(query, user) { this.checkCan('rangeban'); const id = toID(query.shift()); if (!id) return this.errorReply(`Invalid userid.`); const nom = Nominations.find(id); if (!nom) return this.errorReply(`No nomination found for '${id}'.`); this.title = `[Perma Nom] ${nom.primaryID}`; return Nominations.displayActionPage(nom); }, submit() { this.checkCan('lock'); this.title = '[Perma Nom] Create'; return Nominations.displayNomPage(); }, }, }; process.nextTick(() => { Chat.multiLinePattern.register('/perma(noms?)? '); });