/* * Poll chat plugin * By bumbadadabum and Zarel. */ import {Utils} from '../../lib/utils'; const MINUTES = 60000; interface PollAnswer { name: string; votes: number; correct?: boolean; } export interface PollOptions { pollNumber?: number; question: string; supportHTML: boolean; multiPoll: boolean; pendingVotes?: {[userid: string]: number[]}; voters?: {[k: string]: number[]}; voterIps?: {[k: string]: number[]}; totalVotes?: number; timeoutMins?: number; timerEnd?: number; isQuiz?: boolean; answers: string[] | PollAnswer[]; } export interface PollData extends PollOptions { readonly activityId: 'poll'; } export abstract class MinorActivity { abstract readonly activityId: string; room: Room; timeout!: NodeJS.Timer | null; timeoutMins!: number; timerEnd!: number; constructor(room: Room) { this.room = room; } abstract save(): void; setTimer(options: {timeoutMins?: number, timerEnd?: number}) { if (this.timeout) clearTimeout(this.timeout); this.timeoutMins = options.timeoutMins || 0; if (!this.timeoutMins) { this.timerEnd = 0; this.timeout = null; return; } const now = Date.now(); this.timerEnd = options.timerEnd || now + this.timeoutMins * MINUTES; this.timeout = setTimeout(() => { const room = this.room; if (!room) return; // someone forgot to `.destroy()` MinorActivity.end(room); }, this.timerEnd - now); this.save(); } static end(room: Room) { if (room.minorActivity) room.minorActivity.end(); if (room.minorActivityQueue?.length) { const pollData = room.minorActivityQueue.shift()!; room.settings.minorActivityQueue!.shift(); if (!room.minorActivityQueue?.length) room.minorActivityQueue = null; if (!room.settings.minorActivityQueue?.length) delete room.settings.minorActivityQueue; if (pollData.activityId !== 'poll') throw new Error("unexpected value in queue"); room.add(`|c|&|/log ${room.tr`The queued poll was started.`}`).update(); room.modlog({ action: 'POLL', note: '(queued)', }); room.minorActivity = new Poll(room, pollData); room.minorActivity.save(); room.minorActivity.display(); } } endTimer() { if (!this.timeout) return false; clearTimeout(this.timeout); this.timeoutMins = 0; this.timerEnd = 0; return true; } } export class Poll extends MinorActivity { readonly activityId: 'poll'; pollNumber: number; question: string; supportHTML: boolean; multiPoll: boolean; pendingVotes: {[userid: string]: number[]}; voters: {[k: string]: number[]}; voterIps: {[k: string]: number[]}; totalVotes: number; isQuiz: boolean; answers: Map; constructor(room: Room, options: PollOptions) { super(room); this.activityId = 'poll'; this.pollNumber = options.pollNumber || room.nextGameNumber(); this.question = options.question; this.supportHTML = options.supportHTML; this.multiPoll = options.multiPoll; this.pendingVotes = options.pendingVotes || {}; this.voters = options.voters || {}; this.voterIps = options.voterIps || {}; this.totalVotes = options.totalVotes || 0; // backwards compatibility if (!options.answers) options.answers = (options as any).questions; this.answers = Poll.getAnswers(options.answers); this.isQuiz = options.isQuiz ?? [...this.answers.values()].some(answer => answer.correct); this.setTimer(options); } select(user: User, option: number) { const userid = user.id; if (!this.multiPoll) { // vote immediately this.pendingVotes[userid] = [option]; this.submit(user); return; } if (!this.pendingVotes[userid]) { this.pendingVotes[userid] = []; } this.pendingVotes[userid].push(option); this.updateFor(user); this.save(); } deselect(user: User, option: number) { const userid = user.id; const pendingVote = this.pendingVotes[userid]; if (!pendingVote || !pendingVote.includes(option)) { return user.sendTo(this.room, this.room.tr`That option is not selected.`); } pendingVote.splice(pendingVote.indexOf(option), 1); this.updateFor(user); this.save(); } submit(user: User) { const ip = user.latestIp; const userid = user.id; if (userid in this.voters || ip in this.voterIps) { delete this.pendingVotes[userid]; return user.sendTo(this.room, this.room.tr`You have already voted for this poll.`); } const selected = this.pendingVotes[userid]; if (!selected) return user.sendTo(this.room, this.room.tr`No options selected.`); this.voters[userid] = selected; this.voterIps[ip] = selected; for (const option of selected) { this.answers.get(option)!.votes++; } delete this.pendingVotes[userid]; this.totalVotes++; this.update(); this.save(); } blankvote(user: User) { const ip = user.latestIp; const userid = user.id; if (!(userid in this.voters) || !(ip in this.voterIps)) { this.voters[userid] = []; this.voterIps[ip] = []; } this.updateTo(user); this.save(); } generateVotes(user: User | null) { const iconText = this.isQuiz ? ` ${this.room.tr`Quiz`}` : ` ${this.room.tr`Poll`}`; let output = `

${iconText}`; output += ` ${Poll.getQuestionMarkup(this.question, this.supportHTML)}

`; if (this.multiPoll) { const empty = ``; const chosen = ``; const pendingVotes = (user && this.pendingVotes[user.id]) || []; for (const [num, answer] of this.answers) { const selected = pendingVotes.includes(num); output += `
`; } const submitButton = pendingVotes.length ? ( `` ) : ( `` ); output += `
${submitButton}
`; output += `
`; } else { for (const [num, answer] of this.answers) { output += `
`; } output += `
`; output += ``; } return output; } static generateResults(options: PollData, room: Room, ended = false, choice: number[] | null = null) { const iconText = options.isQuiz ? ` ${room.tr`Quiz`}` : ` ${room.tr`Poll`}`; const icon = `${iconText}${ended ? ' ' + room.tr`ended` : ""} ${options.totalVotes} ${room.tr`votes`}`; let output = `

${icon} ${this.getQuestionMarkup(options.question, options.supportHTML)}

`; const answers = Poll.getAnswers(options.answers); // indigo, blue, green // nums start at 1 so the actual order is 1. blue, 2. green, 3. indigo, 4. blue const colors = ['#88B', '#79A', '#8A8']; for (const [num, answer] of answers) { const chosen = choice?.includes(num); const percentage = Math.round((answer.votes * 100) / (options.totalVotes || 1)); const answerMarkup = options.isQuiz ? `${answer.correct ? '' : ''}${this.getAnswerMarkup(answer, options.supportHTML)}${answer.correct ? '' : ''}` : this.getAnswerMarkup(answer, options.supportHTML); output += `
${num}. ${chosen ? '' : ''}${answerMarkup}${chosen ? '' : ''} (${answer.votes} vote${answer.votes === 1 ? '' : 's'})
 ${percentage}%
`; } if (!choice && !ended) { output += `
(${room.tr`You can't vote after viewing results`})
`; } output += '
'; return output; } static getQuestionMarkup(question: string, supportHTML = false) { if (supportHTML) return question; return Chat.formatText(question); } static getAnswerMarkup(answer: PollAnswer, supportHTML = false) { if (supportHTML) return answer.name; return Chat.formatText(answer.name); } update() { const state = this.toJSON(); // Update the poll results for everyone that has voted const blankvote = Poll.generateResults(state, this.room, false); for (const id in this.room.users) { const user = this.room.users[id]; const selection = this.voters[user.id] || this.voterIps[user.latestIp]; if (selection) { if (selection.length) { user.sendTo( this.room, `|uhtmlchange|poll${this.pollNumber}|${Poll.generateResults(state, this.room, false, selection)}` ); } else { user.sendTo(this.room, `|uhtmlchange|poll${this.pollNumber}|${blankvote}`); } } } } updateTo(user: User, connection: Connection | null = null) { const state = this.toJSON(); const recipient = connection || user; const selection = this.voters[user.id] || this.voterIps[user.latestIp]; if (selection) { recipient.sendTo( this.room, `|uhtmlchange|poll${this.pollNumber}|${Poll.generateResults(state, this.room, false, selection)}` ); } else { recipient.sendTo(this.room, `|uhtmlchange|poll${this.pollNumber}|${this.generateVotes(user)}`); } } updateFor(user: User) { const state = this.toJSON(); if (user.id in this.voters) { user.sendTo( this.room, `|uhtmlchange|poll${this.pollNumber}|${Poll.generateResults(state, this.room, false, this.voters[user.id])}` ); } else { user.sendTo(this.room, `|uhtmlchange|poll${this.pollNumber}|${this.generateVotes(user)}`); } } display() { const state = this.toJSON(); const blankvote = Poll.generateResults(state, this.room, false); const blankquestions = this.generateVotes(null); for (const id in this.room.users) { const thisUser = this.room.users[id]; const selection = this.voters[thisUser.id] || this.voterIps[thisUser.latestIp]; if (selection) { if (selection.length) { thisUser.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${Poll.generateResults(state, this.room, false, selection)}`); } else { thisUser.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${blankvote}`); } } else { if (this.multiPoll && thisUser.id in this.pendingVotes) { thisUser.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${this.generateVotes(thisUser)}`); } else { thisUser.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${blankquestions}`); } } } } displayTo(user: User, connection: Connection | null = null) { const state = this.toJSON(); const recipient = connection || user; if (user.id in this.voters) { recipient.sendTo( this.room, `|uhtml|poll${this.pollNumber}|${Poll.generateResults(state, this.room, false, this.voters[user.id])}` ); } else if (user.latestIp in this.voterIps && !Config.noipchecks) { recipient.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${Poll.generateResults( state, this.room, false, this.voterIps[user.latestIp] )}`); } else { recipient.sendTo(this.room, `|uhtml|poll${this.pollNumber}|${this.generateVotes(user)}`); } } onConnect(user: User, connection: Connection | null = null) { this.displayTo(user, connection); } end() { const results = Poll.generateResults(this.toJSON(), this.room, true); this.room.send(`|uhtmlchange|poll${this.pollNumber}|
(${this.room.tr`The poll has ended – scroll down to see the results`})
`); this.room.add(`|html|${results}`).update(); this.endTimer(); this.room.minorActivity = null; delete this.room.settings.minorActivity; this.room.saveSettings(); } toJSON(): PollData { return { activityId: 'poll', pollNumber: this.pollNumber, question: this.question, supportHTML: this.supportHTML, multiPoll: this.multiPoll, pendingVotes: this.pendingVotes, voters: this.voters, voterIps: this.voterIps, totalVotes: this.totalVotes, timeoutMins: this.timeoutMins, timerEnd: this.timerEnd, isQuiz: this.isQuiz, answers: [...this.answers.values()], }; } save() { this.room.settings.minorActivity = this.toJSON(); this.room.saveSettings(); } static getAnswers(answers: string[] | PollAnswer[]) { const out = new Map(); if (answers.length && typeof answers[0] === 'string') { for (const [i, answer] of (answers as string[]).entries()) { out.set(i + 1, { name: answer.startsWith('+') ? answer.slice(1) : answer, votes: 0, correct: answer.startsWith('+'), }); } } else { for (const [i, answer] of (answers as PollAnswer[]).entries()) { out.set(i + 1, answer); } } return out; } destroy() { this.endTimer(); } } export const commands: ChatCommands = { poll: { htmlcreate: 'new', create: 'new', createmulti: 'new', htmlcreatemulti: 'new', queue: 'new', queuehtml: 'new', queuemulti: 'new', htmlqueuemulti: 'new', new(target, room, user, connection, cmd, message) { room = this.requireRoom(); if (!target) return this.parse('/help poll new'); target = target.trim(); if (target.length > 1024) return this.errorReply(this.tr`Poll too long.`); if (room.battle) return this.errorReply(this.tr`Battles do not support polls.`); const text = this.filter(target); if (target !== text) return this.errorReply(this.tr`You are not allowed to use filtered words in polls.`); const supportHTML = cmd.includes('html'); const multiPoll = cmd.includes('multi'); const queue = cmd.includes('queue'); let separator = ''; if (text.includes('\n')) { separator = '\n'; } else if (text.includes('|')) { separator = '|'; } else if (text.includes(',')) { separator = ','; } else { return this.errorReply(this.tr`Not enough arguments for /poll new.`); } let params = text.split(separator).map(param => param.trim()); this.checkCan('minigame', null, room); if (supportHTML) this.checkCan('declare', null, room); this.checkChat(); if (room.minorActivity && !queue) { return this.errorReply(this.tr`There is already a poll or announcement in progress in this room.`); } if (params.length < 3) return this.errorReply(this.tr`Not enough arguments for /poll new.`); // the function throws on failure, so no handling needs to be done anymore if (supportHTML) params = params.map(parameter => this.checkHTML(parameter)); const questions = params.splice(1); if (questions.length > 8) { return this.errorReply(this.tr`Too many options for poll (maximum is 8).`); } if (new Set(questions).size !== questions.length) { return this.errorReply(this.tr`There are duplicate options in the poll.`); } if (room.minorActivity) { if (!room.minorActivityQueue) room.minorActivityQueue = []; room.minorActivityQueue.push({ question: params[0], supportHTML, answers: questions, multiPoll, activityId: 'poll', }); room.settings.minorActivityQueue = room.minorActivityQueue; this.modlog('QUEUEPOLL'); return this.privateModAction(room.tr`${user.name} queued a poll.`); } room.minorActivity = new Poll(room, { question: params[0], supportHTML, answers: questions, multiPoll, }); room.minorActivity.display(); room.minorActivity.save(); this.roomlog(`${user.name} used ${message}`); this.modlog('POLL'); return this.addModAction(room.tr`A poll was started by ${user.name}.`); }, newhelp: [ `/poll create [question], [option1], [option2], [...] - Creates a poll. Requires: % @ # &`, `/poll createmulti [question], [option1], [option2], [...] - Creates a poll, allowing for multiple answers to be selected. Requires: % @ # &`, `To queue a poll, use [queue], [queuemulti], or [htmlqueuemulti].`, `Polls can be used as quiz questions. To do this, prepend all correct answers with a +.`, ], viewqueue(target, room, user) { room = this.requireRoom(); this.checkCan('mute', null, room); this.parse(`/join view-pollqueue-${room.roomid}`); }, viewqueuehelp: [`/viewqueue - view the queue of polls in the room. Requires: % @ # &`], clearqueue: 'deletequeue', deletequeue(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('mute', null, room); if (!room.minorActivityQueue) { return this.errorReply(this.tr`The queue is already empty.`); } if (cmd === 'deletequeue' && room.minorActivityQueue.length !== 1 && !target) { return this.parse('/help deletequeue'); } if (!target) { room.minorActivityQueue = null; this.modlog('CLEARQUEUE'); this.sendReply(this.tr`Cleared poll queue.`); } else { const [slotString, roomid, update] = target.split(','); const slot = parseInt(slotString); const curRoom = roomid ? (Rooms.search(roomid) as ChatRoom | GameRoom) : room; if (!curRoom) return this.errorReply(this.tr`Room "${roomid}" not found.`); if (isNaN(slot)) { return this.errorReply(this.tr`Can't delete poll at slot ${slotString} - "${slotString}" is not a number.`); } if (!room.minorActivityQueue[slot - 1]) return this.errorReply(this.tr`There is no poll in queue at slot ${slot}.`); curRoom.minorActivityQueue!.splice(slot - 1, 1); if (!curRoom.minorActivityQueue?.length) curRoom.minorActivityQueue = null; curRoom.modlog({ action: 'DELETEQUEUE', loggedBy: user.id, note: slot.toString(), }); curRoom.sendMods(this.tr`(${user.name} deleted the queued poll in slot ${slot}.)`); curRoom.update(); if (update) this.parse(`/j view-pollqueue-${curRoom}`); } }, deletequeuehelp: [ `/poll deletequeue [number] - deletes poll at the corresponding queue slot (1 = next, 2 = the one after that, etc). Requires: % @ # &`, `/poll clearqueue - deletes the queue of polls. Requires: % @ # &`, ], deselect: 'select', vote: 'select', select(target, room, user, connection, cmd) { room = this.requireRoom(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } if (!target) return this.parse('/help poll vote'); const poll = room.minorActivity; const parsed = parseInt(target); if (isNaN(parsed)) return this.errorReply(this.tr`To vote, specify the number of the option.`); if (!poll.answers.has(parsed)) return this.sendReply(this.tr`Option not in poll.`); if (cmd === 'deselect') { poll.deselect(user, parsed); } else { poll.select(user, parsed); } }, selecthelp: [ `/poll select [number] - Select option [number].`, `/poll deselect [number] - Deselects option [number].`, ], submit(target, room, user) { room = this.requireRoom(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } const poll = room.minorActivity; poll.submit(user); }, submithelp: [`/poll submit - Submits your vote.`], timer(target, room, user) { room = this.requireRoom(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } const poll = room.minorActivity; if (target) { this.checkCan('minigame', null, room); if (target === 'clear') { if (!poll.endTimer()) return this.errorReply(this.tr("There is no timer to clear.")); return this.add(this.tr`The poll timer was turned off.`); } const timeoutMins = parseFloat(target); if (isNaN(timeoutMins) || timeoutMins <= 0 || timeoutMins > 7 * 24 * 60) { return this.errorReply(this.tr`Time should be a number of minutes less than one week.`); } poll.setTimer({timeoutMins}); room.add(this.tr`The poll timer was turned on: the poll will end in ${Chat.toDurationString(timeoutMins * MINUTES)}.`); this.modlog('POLL TIMER', null, `${timeoutMins} minutes`); return this.privateModAction(room.tr`The poll timer was set to ${timeoutMins} minute(s) by ${user.name}.`); } else { if (!this.runBroadcast()) return; if (poll.timeout) { return this.sendReply(this.tr`The poll timer is on and will end in ${Chat.toDurationString(poll.timeoutMins)}.`); } else { return this.sendReply(this.tr`The poll timer is off.`); } } }, timerhelp: [ `/poll timer [minutes] - Sets the poll to automatically end after [minutes] minutes. Requires: % @ # &`, `/poll timer clear - Clears the poll's timer. Requires: % @ # &`, ], results(target, room, user) { room = this.requireRoom(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } const poll = room.minorActivity; return poll.blankvote(user); }, resultshelp: [ `/poll results - Shows the results of the poll without voting. NOTE: you can't go back and vote after using this.`, ], close: 'end', stop: 'end', end(target, room, user) { room = this.requireRoom(); this.checkCan('minigame', null, room); this.checkChat(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } this.modlog('POLL END'); this.privateModAction(room.tr`The poll was ended by ${user.name}.`); MinorActivity.end(room); }, endhelp: [`/poll end - Ends a poll and displays the results. Requires: % @ # &`], show: '', display: '', ''(target, room, user, connection) { room = this.requireRoom(); if (!room.minorActivity || room.minorActivity.activityId !== 'poll') { return this.errorReply(this.tr`There is no poll running in this room.`); } const poll = room.minorActivity; if (!this.runBroadcast()) return; room.update(); if (this.broadcasting) { poll.display(); } else { poll.displayTo(user, connection); } }, displayhelp: [`/poll display - Displays the poll`], }, pollhelp() { this.sendReply( `|html|
/poll allows rooms to run their own polls (limit 1 at a time).
` + `Polls can be used as quiz questions, by putting + before correct answers.
` + `/poll create [question], [option1], [option2], [...] - Creates a poll. Requires: % @ # &
` + `/poll createmulti [question], [option1], [option2], [...] - Creates a poll, allowing for multiple answers to be selected. Requires: % @ # &
` + `/poll htmlcreate(multi) [question], [option1], [option2], [...] - Creates a poll, with HTML allowed in the question and options. Requires: # &
` + `/poll vote [number] - Votes for option [number].
` + `/poll timer [minutes] - Sets the poll to automatically end after [minutes]. Requires: % @ # &.
` + `/poll results - Shows the results of the poll without voting. NOTE: you can't go back and vote after using this.
` + `/poll display - Displays the poll.
` + `/poll end - Ends a poll and displays the results. Requires: % @ # &.
` + `/poll queue [question], [option1], [option2], [...] - Add a poll in queue. Requires: % @ # &
` + `/poll deletequeue [number] - Deletes poll at the corresponding queue slot (1 = next, 2 = the one after that, etc).
` + `/poll clearqueue - Deletes the queue of polls. Requires: % @ # &.
` + `/poll viewqueue - View the queue of polls in the room. Requires: % @ # &
` + `
` ); }, }; export const pages: PageTable = { pollqueue(args, user) { const room = this.requireRoom(); let buf = `
${this.tr`Queued polls:`}`; buf += `
`; if (!room.minorActivityQueue?.length) { buf += `
${this.tr`No polls queued.`}
`; return buf; } for (const [i, poll] of room.minorActivityQueue.entries()) { const number = i + 1; // for translation convienence const button = ( `${this.tr`#${number} in queue`} ` + `` ); buf += `
`; buf += `${button}
${Poll.generateResults(poll, room, true)}`; } buf += `
`; return buf; }, }; process.nextTick(() => { Chat.multiLinePattern.register('/poll (new|create|createmulti|htmlcreate|htmlcreatemulti|queue|queuemulti|htmlqueuemulti) '); }); // should handle restarts and also hotpatches for (const room of Rooms.rooms.values()) { if (room.settings.minorActivity?.activityId === 'poll') { room.minorActivity?.destroy(); room.minorActivity = new Poll(room, room.settings.minorActivity); } }