Spaces:
Paused
Paused
| /** | |
| * 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<IPData>; | |
| } 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<string, string> = {}; | |
| 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<string>(ips); | |
| const altTable = new Set<string>([...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 = `<div class="infobox">`; | |
| let title = nom.primaryID as string; | |
| if (canEdit) { | |
| title = `<a href="/view-permalocks-view-${nom.primaryID}" target="_replace">${nom.primaryID}</a>`; | |
| } | |
| buf += `<strong>${title}</strong> (submitted by ${nom.by})<br />`; | |
| buf += `Submitted ${Chat.toTimestamp(new Date(nom.date), { human: true })}<br />`; | |
| buf += `${Chat.count(nom.alts, 'alts')}, ${Chat.count(nom.ips, 'IPs')}`; | |
| buf += `</div>`; | |
| 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 = `<small>[${timestamp}] (${entryRoom})</small> ${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 += ` [<a href="https://whatismyipaddress.com/ip/${result.ip}" target="_blank">${result.ip}</a>]`; | |
| } | |
| if (result.loggedBy) line += `: by ${result.loggedBy}`; | |
| if (result.note) line += Utils.html`: ${result.note}`; | |
| if (dateString !== curDate) { | |
| curDate = dateString; | |
| dateString = `</p><p>[${dateString}]<br />`; | |
| } else { | |
| dateString = ``; | |
| } | |
| const thisRoomID = entryRoom?.split(' ')[0]; | |
| if (thisRoomID.startsWith('battle-')) { | |
| timestamp = `<a href="/${thisRoomID}">${timestamp}</a>`; | |
| } else { | |
| const [day, time] = Chat.toTimestamp(date).split(' '); | |
| timestamp = `<a href="/view-chatlog-${thisRoomID}--${day}--time-${toID(time)}">${timestamp}</a>`; | |
| } | |
| return `${dateString}${line}`; | |
| }).join(`<br />`); | |
| } | |
| async displayActionPage(nom: Nomination) { | |
| let buf = `<div class="pad">`; | |
| const standings = this.getStandings(); | |
| buf += `<button class="button" name="send" value="/perma viewnom ${nom.primaryID}" style="float:right">`; | |
| buf += `<i class="fa fa-refresh"></i> Refresh</button>`; | |
| buf += `<h3>Nomination: ${nom.primaryID}</h3><hr />`; | |
| buf += `<strong>By:</strong> ${nom.by} (on ${Chat.toTimestamp(new Date(nom.date))})<br />`; | |
| buf += `<strong>Recommended punishment:</strong> ${standings[nom.standing]}<br />`; | |
| buf += `<details class="readmore"><summary><strong>Modlog</strong></summary>`; | |
| buf += `<div class="infobox limited">`; | |
| const modlog = await this.fetchModlog(nom.primaryID); | |
| if (!modlog) { | |
| buf += `None found.`; | |
| } else { | |
| buf += this.displayModlog(modlog.results); | |
| } | |
| buf += `</div></details>`; | |
| if (nom.alts.length) { | |
| buf += `<details class="readmore"><summary><strong>Listed alts</strong></summary>`; | |
| for (const [i, alt] of nom.alts.entries()) { | |
| buf += `- ${alt}: `; | |
| buf += `<form data-submitsend="/perma standing ${alt},{standing},{reason}">`; | |
| buf += this.standingDropdown("standing"); | |
| buf += ` <button class="button notifying" type="submit">Change standing</button>`; | |
| buf += ` <input name="reason" placeholder="Reason" />`; | |
| buf += `</form>`; | |
| if (nom.alts[i + 1]) buf += `<br />`; | |
| } | |
| buf += `</details>`; | |
| } | |
| if (nom.ips.length) { | |
| buf += `<details class="readmore"><summary><strong>Listed IPs</strong></summary>`; | |
| for (const [i, ip] of nom.ips.entries()) { | |
| const ipData = await getIPData(ip); | |
| buf += `- <a href="https://whatismyipaddress.com/ip/${ip}">${ip}</a>`; | |
| if (ipData) { | |
| buf += `(ISP: ${ipData.isp}, loc: ${ipData.city}, ${ipData.regionName} in ${ipData.country})`; | |
| } | |
| buf += `: `; | |
| buf += `<form data-submitsend="/perma ipstanding ${ip},{standing},{reason}">`; | |
| buf += this.standingDropdown("standing"); | |
| buf += ` <button class="button notifying" type="submit">Change standing for all users on IP</button>`; | |
| buf += ` <input name="reason" placeholder="Reason" />`; | |
| buf += `</form>`; | |
| if (nom.ips[i + 1]) buf += `<br />`; | |
| } | |
| buf += `</details>`; | |
| } | |
| const [matches] = await LoginServer.request('ipmatches', { | |
| id: nom.primaryID, | |
| }); | |
| if (matches?.results?.length) { | |
| buf += `<details class="readmore"><summary><strong>Registration IP matches</strong></summary>`; | |
| for (const [i, { userid, banstate }] of matches.results.entries()) { | |
| buf += `- ${userid}: `; | |
| buf += `<form data-submitsend="/perma standing ${userid},{standing}">`; | |
| buf += this.standingDropdown("standing", `${banstate}`); | |
| buf += ` <button class="button notifying" type="submit">Change standing</button></form>`; | |
| if (matches.results[i + 1]) buf += `<br />`; | |
| } | |
| buf += `</details>`; | |
| } | |
| buf += `<p><strong>Staff notes:</strong></p>`; | |
| buf += `<p><div class="infobox">${Chat.formatText(nom.info).replace(/\n/ig, '<br />')}</div></p>`; | |
| buf += `<details class="readmore"><summary><strong>Act on primary:</strong></summary>`; | |
| buf += `<form data-submitsend="/perma actmain ${nom.primaryID},{standing},{note}">`; | |
| buf += `Standing: ${this.standingDropdown('standing')}`; | |
| buf += `<br />Notes:<br />`; | |
| buf += `<textarea name="note" style="width: 100%" cols="50" rows="10"></textarea><br />`; | |
| buf += `<button class="button notifying" type="submit">Change standing and make post</button>`; | |
| buf += `</form></details><br />`; | |
| buf += `<button class="button notifying" name="send" value="/perma resolve ${nom.primaryID}">Mark resolved</button>`; | |
| return buf; | |
| } | |
| standingDropdown(elemName: string, curStanding: string | null = null) { | |
| let buf = `<select name="${elemName}">`; | |
| const standings = this.getStandings(); | |
| for (const k in standings) { | |
| buf += `<option ${curStanding === k ? "disabled" : ""} value="${k}">${standings[k]}</option>`; | |
| } | |
| buf += `</select>`; | |
| 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 = `<div class="pad">`; | |
| buf += `<button class="button" name="send" value="/perma noms" style="float:right"><i class="fa fa-refresh"></i> Refresh</button>`; | |
| buf += `<h3>Pending perma nominations</h3><hr />`; | |
| if (!this.noms.length) { | |
| buf += `None found.`; | |
| return buf; | |
| } | |
| for (const nom of this.noms) { | |
| buf += this.display(nom, canEdit); | |
| buf += `<br />`; | |
| } | |
| return buf; | |
| } | |
| displayNomPage() { | |
| let buf = `<div class="pad"><h3>Make a nomination for a permanent punishment.</h3><hr />`; | |
| // const [primary, rawAlts, rawIps, details] = Utils.splitFirst(target, '|', 3).map(f => f.trim()); | |
| buf += `<form data-submitsend="/perma submit {primary}|{alts}|{ips}|{type}|{details}">`; | |
| buf += `<div class="infobox">`; | |
| buf += `<strong>Primary userid:</strong> <input name="primary" /><br />`; | |
| buf += `<strong>Alts:</strong><br /><textarea name="alts"></textarea><br /><small>(Separated by commas)</small><br />`; | |
| buf += `<strong>Static IPs:</strong><br /><textarea name="ips"></textarea><br /><small>(Separated by commas)</small></div><br />`; | |
| buf += `<strong>Punishment:</strong> `; | |
| buf += `<select name="type"><option value="20">Permalock</option><option value="30">Permaban</option></select>`; | |
| buf += `<div class="infobox">`; | |
| buf += `<strong>Please explain why this user deserves a permanent punishment</strong><br />`; | |
| buf += `<small>Note: Modlogs are automatically included in review and do not need to be added here.</small><br />`; | |
| buf += `<textarea style="width: 100%" name="details" cols="50" rows="10"></textarea></div>`; | |
| buf += `<button class="button notifying" type="submit">Submit nomination</button>`; | |
| return buf; | |
| } | |
| getDisplayButton() { | |
| const unclaimed = this.noms.filter(f => !f.claimed); | |
| let buf = `<div class="infobox">`; | |
| if (!this.noms.length) { | |
| buf += `No permalock nominations active.`; | |
| } else { | |
| let className = 'button'; | |
| if (unclaimed.length) className += ' notifying'; | |
| buf += `<button class="${className}" name="send" value="/j view-permalocks-list">`; | |
| buf += `${Chat.count(this.noms.length, 'nominations')}`; | |
| if (unclaimed.length !== this.noms.length) { | |
| buf += ` (${unclaimed.length} unclaimed)`; | |
| } | |
| buf += `</button>`; | |
| } | |
| buf += `</div>`; | |
| 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(/<br \/>/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 <a href="${url}">at this link</a>.`); | |
| }, | |
| 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 ` + | |
| `<psicon pokemon='${mon.name.toLowerCase()}' />` | |
| ); | |
| }, | |
| 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?)? '); | |
| }); | |