import { FS, Utils } from '../../lib'; import { YouTube } from './youtube'; const MINUTE = 60 * 1000; const PRENOM_BUMP_TIME = 2 * 60 * MINUTE; const PRENOMS_FILE = 'config/chat-plugins/otd-prenoms.json'; const DATA_FILE = 'config/chat-plugins/otds.json'; export const prenoms: { [k: string]: [string, AnyObject][] } = JSON.parse(FS(PRENOMS_FILE).readIfExistsSync() || "{}"); export const otdData: OtdData = JSON.parse(FS(DATA_FILE).readIfExistsSync() || "{}"); export const otds = new Map(); const FINISH_HANDLERS: { [k: string]: (winner: AnyObject) => Promise } = { cotw: async winner => { const { channel, nominator } = winner; const searchResults = await YouTube.searchChannel(channel, 1); const result = searchResults?.[0]; if (result) { if (YouTube.data.channels[result]) return; void YouTube.getChannelData(`https://www.youtube.com/channel/${result}`); const yt = Rooms.get('youtube'); if (!yt) return; yt.sendMods( `|c|~|/log The channel with ID ${result} was added to the YouTube channel database.` ); yt.modlog({ action: `ADDCHANNEL`, note: `${result} (${toID(nominator)})`, loggedBy: toID(`COTW`), }); } }, }; interface OtdSettings { id?: string; updateOnNom?: boolean; keys: string[]; title: string; keyLabels: string[]; timeLabel: string; roomid: RoomID; } interface OtdData { [k: string]: { settings: OtdSettings, winners: AnyObject[] }; } function savePrenoms() { return FS(PRENOMS_FILE).writeUpdate(() => JSON.stringify(prenoms)); } function toNominationId(nomination: string) { return nomination.toLowerCase().replace(/\s/g, '').replace(/\b&\b/g, ''); } class OtdHandler { id: string; name: string; room: Room; nominations: Map; removedNominations: Map; voting: boolean; timer: NodeJS.Timeout | null; keys: string[]; keyLabels: string[]; timeLabel: string; settings: OtdSettings; lastPrenom: number; winners: AnyObject[]; constructor( id: string, room: Room, settings: OtdSettings ) { this.id = id; this.name = settings.title; this.room = room; this.nominations = new Map(prenoms[id]); this.removedNominations = new Map(); this.voting = false; this.timer = null; this.keys = settings.keys; this.keyLabels = settings.keyLabels; this.timeLabel = settings.timeLabel; this.settings = settings; this.lastPrenom = 0; this.winners = otdData[this.id]?.winners || []; } static create(room: Room, settings: OtdSettings) { const { title, timeLabel } = settings; const id = settings.id || toID(title).charAt(0) + 'ot' + timeLabel.charAt(0); const handler = new OtdHandler(id, room, settings); otds.set(id, handler); let needsSave = false; for (const winner of handler.winners) { if (winner.timestamp) { winner.time = winner.timestamp; delete winner.timestamp; needsSave = true; } } if (needsSave) handler.save(); return handler; } static parseOldWinners(content: string, keyLabels: string[], keys: string[]) { const data = `${content}`.split("\n"); const winners = []; for (const arg of data) { if (!arg || arg === '\r') continue; if (arg.startsWith(`${keyLabels[0]}\t`)) { continue; } const entry: AnyObject = {}; const vals = arg.trim().split("\t"); for (let i = 0; i < vals.length; i++) { entry[keys[i]] = vals[i]; } entry.time = Number(entry.time) || 0; winners.push(entry); } return winners; } /** * Handles old-format data from the IP and userid refactor */ convertNominations() { for (const value of this.nominations.values()) { if (!Array.isArray(value.userids)) value.userids = Object.keys(value.userids); if (!Array.isArray(value.ips)) value.ips = Object.keys(value.ips); } for (const value of this.removedNominations.values()) { if (!Array.isArray(value.userids)) value.userids = Object.keys(value.userids); if (!Array.isArray(value.ips)) value.ips = Object.keys(value.ips); } } startVote() { this.voting = true; this.timer = setTimeout(() => this.rollWinner(), 20 * MINUTE); this.display(); } finish() { this.voting = false; this.nominations = new Map(); this.removedNominations = new Map(); delete prenoms[this.id]; void savePrenoms(); if (this.timer) clearTimeout(this.timer); } addNomination(user: User, nomination: string) { const id = toNominationId(nomination); if (this.winners.slice(-30) .some(entry => toNominationId(entry[this.keys[0]]) === id) ) { return user.sendTo(this.room, `This ${this.name.toLowerCase()} has already been ${this.id} in the past month.`); } for (const value of this.removedNominations.values()) { if (value.userids.includes(toID(user)) || (!Config.noipchecks && value.ips.includes(user.latestIp))) { return user.sendTo( this.room, `Since your nomination has been removed by staff, you cannot submit another ${this.name.toLowerCase()} until the next round.` ); } } const prevNom = this.nominations.get(id); if (prevNom) { if (!(prevNom.userids.includes(toID(user)) || (!Config.noipchecks && prevNom.ips.includes(user.latestIp)))) { return user.sendTo(this.room, `This ${this.name.toLowerCase()} has already been nominated.`); } } for (const [key, value] of this.nominations) { if (value.userids.includes(toID(user)) || (!Config.noipchecks && value.ips.includes(user.latestIp))) { user.sendTo(this.room, `Your previous vote for ${value.nomination} will be removed.`); this.nominations.delete(key); if (prenoms[this.id]) { const idx = prenoms[this.id].findIndex(val => val[0] === key); if (idx > -1) { prenoms[this.id].splice(idx, 1); void savePrenoms(); } } } } const obj: { [k: string]: string } = {}; obj[user.id] = user.name; const nomObj = { nomination, name: user.name, userids: user.previousIDs.concat(user.id), ips: user.ips.slice(), }; this.nominations.set(id, nomObj); if (!prenoms[this.id]) prenoms[this.id] = []; prenoms[this.id].push([id, nomObj]); void savePrenoms(); user.sendTo(this.room, `Your nomination for ${nomination} was successfully submitted.`); let updateOnly = !this.voting; if (updateOnly) { const now = Date.now(); if (now - this.lastPrenom > PRENOM_BUMP_TIME) { updateOnly = false; this.lastPrenom = now; } } if (this.settings.updateOnNom) { this.display(updateOnly); } } generateNomWindow() { let buffer = ''; if (this.voting) { buffer += `

Nominations for ${this.name} of the ${this.timeLabel} are in progress! Use /${this.id} nom to nominate a${['A', 'E', 'I', 'O', 'U'].includes(this.name[0]) ? 'n' : ''} ${this.name.toLowerCase()}!

`; if (this.nominations.size) buffer += `Nominations:`; } else { buffer += `

Pre-noms for ${this.name} of the ${this.timeLabel}. Use /${this.id} nom to nominate a${['A', 'E', 'I', 'O', 'U'].includes(this.name[0]) ? 'n' : ''} ${this.name.toLowerCase()}:

`; } if (this.winners.length) { const winner = this.winners[this.winners.length - 1]; buffer += `

Current winner: ${winner[this.keys[0]]} (nominated by ${winner.nominator})

`; } const entries = []; for (const value of this.nominations.values()) { entries.push(Utils.html`
  • ${value.nomination} (Submitted by ${value.name})
  • `); } if (entries.length > 20) { buffer += `
      ${entries.slice(0, Math.ceil(entries.length / 2)).join('')}
      ${entries.slice(Math.ceil(entries.length / 2)).join('')}
    `; } else { buffer += `
      ${entries.join('')}
    `; } buffer += `
    `; return buffer; } display(update = false) { if (update) { this.room.uhtmlchange('otd', this.generateNomWindow()); } else { this.room.add(`|uhtml|otd|${this.generateNomWindow()}`); } } displayTo(connection: Connection) { connection.sendTo(this.room, `|uhtml|otd|${this.generateNomWindow()}`); } rollWinner() { const keys = [...this.nominations.keys()]; if (!keys.length) return false; const winner = this.nominations.get(keys[Math.floor(Math.random() * keys.length)]); if (!winner) return false; // Should never happen but shuts typescript up. const winnerEntry = this.appendWinner(winner.nomination, winner.name); const names = [...this.nominations.values()].map(obj => obj.name); const columns = names.length > 27 ? 4 : names.length > 18 ? 3 : names.length > 9 ? 2 : 1; let content = ''; for (let i = 0; i < columns; i++) { content += `${names.slice(Math.ceil((i / columns) * names.length), Math.ceil(((i + 1) / columns) * names.length)).join('
    ')}`; } const namesHTML = `${content}

    `; const finishHandler = FINISH_HANDLERS[this.id]; if (finishHandler) { void finishHandler(winnerEntry); } this.room.add( Utils.html`|uhtml|otd|

    ` + `Nominations for ${this.name} of the ${this.timeLabel} are over!

    ` + `Out of ${keys.length} nominations, we randomly selected ${winner.nomination} as the winner! ` + `(Nomination by ${winner.name})

    Thanks to today's participants:` + namesHTML ); this.room.update(); this.finish(); return true; } removeNomination(name: string) { name = toID(name); let success = false; for (const [key, value] of this.nominations) { if (value.userids.includes(name)) { this.removedNominations.set(key, value); this.nominations.delete(key); if (prenoms[this.id]) { const idx = prenoms[this.id].findIndex(val => val[0] === key); if (idx > -1) { prenoms[this.id].splice(idx, 1); void savePrenoms(); } } success = true; } } if (this.voting) this.display(); return success; } forceWinner(winner: string, user: string) { this.appendWinner(winner, user); this.finish(); } appendWinner(nomination: string, user: string): AnyObject { const entry: AnyObject = { time: Date.now(), nominator: user }; entry[this.keys[0]] = nomination; this.winners.push(entry); this.save(); return entry; } removeWinner(nominationName: string) { for (const [i, entry] of this.winners.entries()) { if (toID(entry[this.keys[0]]) === toID(nominationName)) { const removed = this.winners.splice(i, 1); this.save(); return removed[0]; } } throw new Chat.ErrorMessage(`The winner with nomination ${nominationName} could not be found.`); } setWinnerProperty(properties: { [k: string]: string }) { if (!this.winners.length) return; for (const i in properties) { this.winners[this.winners.length - 1][i] = properties[i]; } return this.save(); } save(destroy = false) { if (!destroy) { otdData[this.id] = { settings: this.settings, winners: this.winners, }; } FS(DATA_FILE).writeUpdate(() => JSON.stringify(otdData)); } destroy() { this.room = null!; delete otdData[this.id]; otds.delete(this.id); delete Chat.commands[this.id]; delete Chat.pages[this.id]; this.save(true); } async generateWinnerDisplay() { if (!this.winners.length) return false; const winner = this.winners[this.winners.length - 1]; let output = `

    ` + Utils.html`

    The ${this.name} of the ${this.timeLabel} is ` + Utils.html`${winner[this.keys[0]]}${winner.author ? ` by ${winner.author}` : ''}.`; if (winner.quote) output += Utils.html`
    "${winner.quote}"`; if (winner.tagline) output += Utils.html`
    ${winner.tagline}`; output += `

    `; if (winner.image) { try { const [width, height] = await Chat.fitImage(winner.image, 100, 100); output += Utils.html``; } catch {} } output += `
    `; if (winner.event) output += Utils.html`Event: ${winner.event}
    `; if (winner.song) { output += `Song: `; if (winner.link) { output += Utils.html`${winner.song}`; } else { output += Utils.escapeHTML(winner.song); } output += `
    `; } else if (winner.link) { output += Utils.html`Link: ${winner.link}
    `; } // Batch these together on 2 lines. Order intentional. const athleteDetails = []; if (winner.sport) athleteDetails.push(Utils.html`Sport: ${winner.sport}`); if (winner.team) athleteDetails.push(Utils.html`Team: ${winner.team}`); if (winner.age) athleteDetails.push(Utils.html`Age: ${winner.age}`); if (winner.country) athleteDetails.push(Utils.html`Nationality: ${winner.country}`); if (athleteDetails.length) { output += athleteDetails.slice(0, 2).join(' | ') + '
    '; if (athleteDetails.length > 2) output += athleteDetails.slice(2).join(' | ') + '
    '; } output += Utils.html`Nominated by ${winner.nominator}.`; output += `
    `; return output; } generateWinnerList(context: Chat.PageContext) { context.title = `${this.id.toUpperCase()} Winners`; let buf = `

    ${this.name} of the ${this.timeLabel} Winners

    `; // Only use specific fields for displaying in winners list. const columns: string[] = []; const labels = []; for (let i = 0; i < this.keys.length; i++) { if (i === 0 || ['song', 'event', 'link', 'tagline', 'sport', 'country'] .includes(this.keys[i]) && !(this.keys[i] === 'link' && this.keys.includes('song')) ) { columns.push(this.keys[i]); labels.push(this.keyLabels[i]); } } if (!columns.includes('time')) { columns.push('time'); labels.push('Timestamp'); } let content = ``; content += `${labels.map(label => `

    ${label}

    `).join('')}`; for (let i = this.winners.length - 1; i >= 0; i--) { const entry = columns.map(col => { let val = this.winners[i][col]; if (!val) return ''; switch (col) { case 'time': const date = new Date(parseInt(this.winners[i].time)); const pad = (num: number) => `${num}`.padStart(2, '0'); return Utils.html`${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}`; case 'song': if (!this.winners[i].link) return val; // falls through case 'link': return `${val}`; case 'book': val = `${val}${this.winners[i].author ? ` by ${this.winners[i].author}` : ''}`; // falls through case columns[0]: return `${Utils.escapeHTML(val)}${this.winners[i].nominator ? Utils.html`
    nominated by ${this.winners[i].nominator}` : ''}`; default: return Utils.escapeHTML(val); } }); content += `${entry.map(val => `${val}`).join('')}`; } if (!content) { buf += `

    There have been no ${this.id} winners.

    `; } else { buf += `${content}
    `; } buf += `
    `; return buf; } } function selectHandler(message: string) { const id = toID(message.substring(1).split(' ')[0]); const handler = otds.get(id); if (!handler) throw new Error("Invalid type for otd handler."); return handler; } export const otdCommands: Chat.ChatCommands = { start(target, room, user, connection, cmd) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('mute', null, room); if (handler.voting) { return this.errorReply( `The nomination for the ${handler.name} of the ${handler.timeLabel} nomination is already in progress.` ); } handler.startVote(); this.privateModAction(`${user.name} has started nominations for the ${handler.name} of the ${handler.timeLabel}.`); this.modlog(`${handler.id.toUpperCase()} START`, null); }, starthelp: [`/-otd start - Starts nominations for the Thing of the Day. Requires: % @ # ~`], end(target, room, user) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('mute', null, room); if (!handler.voting) { return this.errorReply(`There is no ${handler.name} of the ${handler.timeLabel} nomination in progress.`); } if (!handler.nominations.size) { return this.errorReply(`Can't select the ${handler.name} of the ${handler.timeLabel} without nominations.`); } handler.rollWinner(); this.privateModAction(`${user.name} has ended nominations for the ${handler.name} of the ${handler.timeLabel}.`); this.modlog(`${handler.id.toUpperCase()} END`, null); }, endhelp: [ `/-otd end - End nominations for the Thing of the Day and set it to a randomly selected nomination. Requires: % @ # ~`, ], nom(target, room, user) { this.checkChat(target); if (!target) return this.parse('/help otd'); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); if (!toNominationId(target).length || target.length > 75) { return this.sendReply(`'${target}' is not a valid ${handler.name.toLowerCase()} name.`); } handler.addNomination(user, target); }, nomhelp: [`/-otd nom [nomination] - Nominate something for Thing of the Day.`], view(target, room, user, connection) { this.checkChat(); if (!this.runBroadcast()) return false; const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); if (this.broadcasting) { selectHandler(this.message).display(); } else { selectHandler(this.message).displayTo(connection); } }, viewhelp: [`/-otd view - View the current nominations for the Thing of the Day.`], remove(target, room, user) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('mute', null, room); const userid = toID(target); if (!userid) return this.errorReply(`'${target}' is not a valid username.`); if (handler.removeNomination(userid)) { this.privateModAction(`${user.name} removed ${target}'s nomination for the ${handler.name} of the ${handler.timeLabel}.`); this.modlog(`${handler.id.toUpperCase()} REMOVENOM`, userid); } else { this.sendReply(`User '${target}' has no nomination for the ${handler.name} of the ${handler.timeLabel}.`); } }, removehelp: [ `/-otd remove [username] - Remove a user's nomination for the Thing of the Day.`, `Prevents them from voting again until the next round. Requires: % @ # ~`, ], removewinner(target, room, user) { const handler = selectHandler(this.message); room = this.requireRoom(handler.room.roomid); this.checkCan('mute', null, room); if (!toID(target)) { return this.parse(`/help aotd removewinner`); } const removed = handler.removeWinner(target); this.privateModAction(`${user.name} removed the nomination for ${removed[handler.keys[0]]} from ${removed.nominator}`); this.modlog(`${handler.id.toUpperCase()} REMOVEWINNER`, removed.nominator, removed[handler.keys[0]]); }, removewinnerhelp: [ `/-otd removewinner [nomination] - Remove winners matching the given [nomination] from Thing of the Day.`, `Requires: % @ # ~`, ], force(target, room, user) { this.checkChat(); if (!target) return this.parse('/help aotd force'); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('declare', null, room); if (!toNominationId(target).length || target.length > 50) { return this.sendReply(`'${target}' is not a valid ${handler.name.toLowerCase()} name.`); } handler.forceWinner(target, user.name); this.privateModAction(`${user.name} forcibly set the ${handler.name} of the ${handler.timeLabel} to ${target}.`); this.modlog(`${handler.id.toUpperCase()} FORCE`, user.name, target); room.add(`The ${handler.name} of the ${handler.timeLabel} was forcibly set to '${target}'`); }, forcehelp: [ `/-otd force [nomination] - Forcibly sets the Thing of the Day without a nomination round. Requires: # ~`, ], delay(target, room, user) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('mute', null, room); if (!(handler.voting && handler.timer)) { return this.errorReply(`There is no ${handler.name} of the ${handler.timeLabel} nomination to disable the timer for.`); } clearTimeout(handler.timer); this.privateModAction(`${user.name} disabled the ${handler.name} of the ${handler.timeLabel} timer.`); }, delayhelp: [ `/-otd delay - Turns off the automatic 20 minute timer for Thing of the Day voting rounds. Requires: % @ # ~`, ], set(target, room, user) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); this.checkCan('mute', null, room); const params = target.split(target.includes('|') ? '|' : ',').map(param => param.trim()); const changelist: { [k: string]: string } = {}; for (const param of params) { let [key, ...values] = param.split(':'); if (!key || !values.length) return this.errorReply(`Syntax error in '${param}'`); key = key.trim(); const value = values.join(':').trim(); if (!handler.keys.includes(key)) { return this.errorReply(`Invalid key: '${key}'. Valid keys: ${handler.keys.join(', ')}`); } switch (key) { case 'artist': case 'film': case 'show': case 'channel': case 'book': case 'author': case 'athlete': if (!toNominationId(value) || value.length > 50) return this.errorReply(`Please enter a valid ${key} name.`); break; case 'quote': case 'tagline': case 'match': case 'event': case 'videogame': if (!value.length || value.length > 150) return this.errorReply(`Please enter a valid ${key}.`); break; case 'sport': case 'team': case 'song': case 'country': if (!value.length || value.length > 50) return this.errorReply(`Please enter a valid ${key} name.`); break; case 'link': case 'image': if (!/https?:\/\//.test(value)) { return this.errorReply(`Please enter a valid URL for the ${key} (starting with http:// or https://)`); } if (value.length > 200) return this.errorReply("URL too long."); break; case 'age': const num = parseInt(value); // let's assume someone isn't over 100 years old? Maybe we should for the memes // but i doubt there's any legit athlete over 100. if (isNaN(num) || num < 1 || num > 100) return this.errorReply('Please enter a valid number as an age'); break; default: // another custom key w/o validation if (!toNominationId(value)) { return this.errorReply(`No value provided for key ${key}.`); } break; } changelist[key] = value; } const keys = Object.keys(changelist); if (keys.length) { void handler.setWinnerProperty(changelist); this.modlog(handler.id.toUpperCase(), null, `changed ${keys.join(', ')}`); return this.privateModAction(`${user.name} changed the following propert${Chat.plural(keys, 'ies', 'y')} of the ${handler.name} of the ${handler.timeLabel}: ${keys.join(', ')}`); } }, sethelp: [ `/-otd set property: value[, property: value] - Set the winner, quote, song, link or image for the current Thing of the Day.`, `Requires: % @ # ~`, ], toggleupdate(target, room, user) { const otd = selectHandler(this.message); room = this.requireRoom(otd.room.roomid); this.checkCan('declare', null, room); let logMessage = ''; if (this.meansYes(target)) { if (otd.settings.updateOnNom) { throw new Chat.ErrorMessage(`This -OTD is already set to update automatically on nomination.`); } otd.settings.updateOnNom = true; logMessage = 'update automatically on nomination'; } else { if (!otd.settings.updateOnNom) { throw new Chat.ErrorMessage(`This -OTD is not set to update automatically on nomination.`); } delete otd.settings.updateOnNom; logMessage = 'not update on nomination'; } this.privateModAction(`${user.name} set the ${otd.name} of the ${otd.timeLabel} to ${logMessage}`); this.modlog(`OTD TOGGLEUPDATE`, null, logMessage); otd.save(); }, winners(target, room, user, connection) { this.checkChat(); const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); return this.parse(`/join view-${handler.id}`); }, winnershelp: [`/-otd winners - Displays a list of previous things of the day.`], async ''(target, room) { this.checkChat(); if (!this.runBroadcast()) return false; const handler = selectHandler(this.message); if (!handler.room) return this.errorReply(`The room for this -otd doesn't exist.`); if (room !== handler.room) return this.errorReply(`This command can only be used in ${handler.room.title}.`); const text = await handler.generateWinnerDisplay(); if (!text) return this.errorReply("There is no winner yet."); this.sendReplyBox(text); }, }; export const pages: Chat.PageTable = {}; export const commands: Chat.ChatCommands = { otd: { create(target, room, user) { room = this.requireRoom(); if (room.settings.isPrivate) { return this.errorReply(`This command is only available in public rooms`); } const count = [...otds.values()].filter(otd => otd.room.roomid === room.roomid).length; if (count > 3) { return this.errorReply(`This room already has 3+ -otd's.`); } this.checkCan('rangeban'); if (!toID(target)) { return this.parse(`/help otd`); } const [title, time, ...keyLabels] = target.split(',').map(i => i.trim()); if (!toID(title)) { return this.errorReply(`Invalid title.`); } const timeLabel = toID(time); if (!['week', 'day'].includes(timeLabel)) { return this.errorReply("Invalid time label - use 'week' or 'month'"); } const id = `${title.charAt(0)}ot${timeLabel.charAt(0)}`; const existing = otds.get(id); if (existing) { this.errorReply(`That -OTD already exists (${existing.name} of the ${existing.timeLabel}, in ${existing.room.title})`); return this.errorReply(`Try picking a new title.`); } const titleIdx = keyLabels.map(toID).indexOf(toID(title)); if (titleIdx > -1) { keyLabels.splice(titleIdx, 1); } keyLabels.unshift(title); const filteredKeys = keyLabels.map(toNominationId).filter(Boolean); if (!filteredKeys.length) { return this.errorReply(`No valid key labels given.`); } if (new Set(filteredKeys).size !== keyLabels.length) { return this.errorReply(`Invalid keys in set - do not use duplicate key labels.`); } if (filteredKeys.length < 3) { return this.errorReply(`Specify at least 3 key labels.`); } if (filteredKeys.some(k => k.length < 3 || k.length > 50)) { return this.errorReply(`All labels must be more than 3 characters and less than 50 characters long.`); } const otd = OtdHandler.create(room, { keyLabels, keys: filteredKeys, title, timeLabel, roomid: room.roomid, }); const name = `${otd.name} of the ${otd.timeLabel}`; this.globalModlog(`OTD CREATE`, null, `${name} - ${filteredKeys.join(', ')}`); this.privateGlobalModAction(`${user.name} created the ${name} for ${room.title}`); otd.save(); }, updateroom(target, room, user) { this.checkCan('rangeban'); const [otdId, roomid] = target.split(',').map(i => toID(i)); if (!otdId || !roomid) { return this.parse('/help otd'); } const otd = otds.get(otdId); if (!otd) { return this.errorReply(`OTD ${otd} not found.`); } const targetRoom = Rooms.get(roomid); if (!targetRoom) { return this.errorReply(`Room ${roomid} not found.`); } const oldRoom = otd.settings.roomid.slice(); otd.settings.roomid = targetRoom.roomid; otd.room = targetRoom; otd.save(); this.privateGlobalModAction( `${user.name} updated the room for the ${otd.name} of the ${otd.timeLabel} from ${oldRoom} to ${targetRoom}` ); this.globalModlog(`OTD UPDATEROOM`, null, `${otd.id} to ${targetRoom} from ${oldRoom}`); }, delete(target, room, user) { this.checkCan('rangeban'); target = toID(target); if (!target) { return this.parse(`/help otd`); } const otd = otds.get(target); if (!otd) return this.errorReply(`OTD ${target} not found.`); otd.destroy(); this.globalModlog(`OTD DELETE`, null, target); this.privateGlobalModAction(`${user.name} deleted the OTD ${otd.name} of the ${otd.timeLabel}`); }, }, otdhelp: [ `/otd create [title], [time], [...labels] - Creates a Thing of the Day with the given [name], [time], and [labels]. Requires: ~`, `/otd updateroom [otd], [room] - Updates the room for the given [otd] to the new [room]. Requires: ~`, `/otd delete [otd] - Removes the given Thing of the Day. Requires: ~`, ], }; const otdHelp = [ `Thing of the Day plugin commands (aotd, fotd, sotd, cotd, botw, motw, anotd):`, `- /-otd - View the current Thing of the Day.`, `- /-otd start - Starts nominations for the Thing of the Day. Requires: % @ # ~`, `- /-otd nom [nomination] - Nominate something for Thing of the Day.`, `- /-otd remove [username] - Remove a user's nomination for the Thing of the Day and prevent them from voting again until the next round. Requires: % @ # ~`, `- /-otd end - End nominations for the Thing of the Day and set it to a randomly selected nomination. Requires: % @ # ~`, `- /-otd removewinner [nomination] - Remove a winner with the given [nomination] from the winners list. Requires: % @ # ~`, `- /-otd force [nomination] - Forcibly sets the Thing of the Day without a nomination round. Requires: # ~`, `- /-otd delay - Turns off the automatic 20 minute timer for Thing of the Day voting rounds. Requires: % @ # ~`, `- /-otd set property: value[, property: value] - Set the winner, quote, song, link or image for the current Thing of the Day. Requires: % @ # ~`, `- /-otd winners - Displays a list of previous things of the day.`, `- /-otd toggleupdate [on|off] - Changes the Thing of the Day to display on nomination ([on] to update, [off] to turn off updates). Requires: # ~`, ]; for (const otd in otdData) { const data = otdData[otd]; const settings = data.settings; const room = Rooms.get(settings.roomid); if (!room) { Monitor.warn(`Room for -otd ${settings.title} of the ${settings.timeLabel} (${settings.roomid}) not found.`); continue; } OtdHandler.create(room, settings); } for (const [k, v] of otds) { pages[k] = function () { return v.generateWinnerList(this); }; commands[k] = otdCommands; commands[`${k}help`] = otdHelp; } export const handlers: Chat.Handlers = { onRenameRoom(oldID, newID, room) { for (const otd in otdData) { const data = otdData[otd]; if (data.settings.roomid === oldID) { data.settings.roomid = newID; const handler = otds.get(otd); handler!.room = room as Room; handler!.save(); } } }, }; export const punishmentfilter: Chat.PunishmentFilter = (user, punishment) => { user = toID(user); if (!['NAMELOCK', 'BAN'].includes(punishment.type)) return; for (const handler of otds.values()) { handler.removeNomination(user); } };