1 /++
2     The Poll plugin offers the ability to hold votes/polls in a channel. Any
3     number of choices is supported, as long as they're more than one.
4 
5     Cheating by changing nicknames is warded against.
6 
7     See_Also:
8         https://github.com/zorael/kameloso/wiki/Current-plugins#poll,
9         [kameloso.plugins.common.core],
10         [kameloso.plugins.common.misc]
11 
12     Copyright: [JR](https://github.com/zorael)
13     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
14 
15     Authors:
16         [JR](https://github.com/zorael)
17  +/
18 module kameloso.plugins.poll;
19 
20 version(WithPollPlugin):
21 
22 private:
23 
24 import kameloso.plugins;
25 import kameloso.plugins.common.core;
26 import kameloso.plugins.common.awareness : MinimalAuthentication;
27 import kameloso.common : logger;
28 import kameloso.messaging;
29 import dialect.defs;
30 import std.typecons : Flag, No, Yes;
31 import core.time : Duration;
32 
33 
34 // PollSettings
35 /++
36     All Poll plugin runtime settings aggregated.
37  +/
38 @Settings struct PollSettings
39 {
40     /++
41         Whether or not this plugin should react to any events.
42      +/
43     @Enabler bool enabled = true;
44 
45     /++
46         Whether or not only votes placed by online users count.
47      +/
48     bool onlyOnlineUsersCount = true;
49 
50     /++
51         Whether or not poll choices may start with the command prefix.
52 
53         There's no check in place that a prefixed choice won't conflict with a
54         command, so make it opt-in at your own risk.
55      +/
56     bool forbidPrefixedChoices = true;
57 
58     /++
59         User level required to vote.
60      +/
61     IRCUser.Class minimumPermissionsNeeded = IRCUser.Class.anyone;
62 }
63 
64 
65 // Poll
66 /++
67     Embodies the notion of a channel poll.
68  +/
69 struct Poll
70 {
71 private:
72     import kameloso.common : RehashingAA;
73     import std.datetime.systime : SysTime;
74     import std.json : JSONValue;
75 
76 public:
77     /++
78         Timestamp of when the poll was created.
79      +/
80     SysTime start;
81 
82     /++
83         Current vote tallies.
84      +/
85     uint[string] voteCounts;
86 
87     /++
88         Map of the original names of the choices keyed by what they were simplified to.
89      +/
90     string[string] origChoiceNames;
91 
92     /++
93         Choices, sorted in alphabetical order.
94      +/
95     string[] sortedChoices;
96 
97     /++
98         Individual votes, keyed by nicknames of the people who placed them.
99      +/
100     RehashingAA!(string, string) votes;
101 
102     /++
103         Poll duration.
104      +/
105     Duration duration;
106 
107     /++
108         Unique identifier to help Fibers know if the poll they belong to is stale
109         or has been replaced.
110      +/
111     uint uniqueID;
112 
113     // toJSON
114     /++
115         Serialises this [Poll] into a [std.json.JSONValue|JSONValue].
116 
117         Returns:
118             A [std.json.JSONValue|JSONValue] that describes this poll.
119      +/
120     auto toJSON() const
121     {
122         JSONValue json;
123         json.object = null;
124         json["start"] = this.start.toUnixTime();
125         json["voteCounts"] = JSONValue(this.voteCounts);
126         json["origChoiceNames"] = JSONValue(this.origChoiceNames);
127         json["sortedChoices"] = JSONValue(this.sortedChoices);
128         json["votes"] = JSONValue(this.votes.aaOf);
129         json["duration"] = JSONValue(duration.total!"seconds");
130         json["uniqueID"] = JSONValue(uniqueID);
131         return json;
132     }
133 
134     // fromJSON
135     /++
136         Deserialises a [Poll] from a [std.json.JSONValue|JSONValue].
137 
138         Params:
139             json = [std.json.JSONValue|JSONValue] to deserialise.
140 
141         Returns:
142             A new [Poll] with values loaded from the passed JSON.
143      +/
144     static auto fromJSON(const JSONValue json)
145     {
146         import core.time : seconds;
147 
148         Poll poll;
149 
150         foreach (immutable voteName, const voteCountJSON; json["voteCounts"].object)
151         {
152             poll.voteCounts[voteName] = cast(uint)voteCountJSON.integer;
153         }
154 
155         foreach (immutable newName, const origNameJSON; json["origChoiceNames"].object)
156         {
157             poll.origChoiceNames[newName] = origNameJSON.str;
158         }
159 
160         foreach (const choiceJSON; json["sortedChoices"].array)
161         {
162             poll.sortedChoices ~= choiceJSON.str;
163         }
164 
165         foreach (immutable nickname, const voteJSON; json["votes"].object)
166         {
167             poll.votes[nickname] = voteJSON.str;
168         }
169 
170         poll.start = SysTime.fromUnixTime(json["start"].integer);
171         poll.duration = json["duration"].integer.seconds;
172         poll.uniqueID = cast(uint)json["uniqueID"].integer;
173         poll.votes = poll.votes.rehash();
174         return poll;
175     }
176 }
177 
178 
179 // onCommandPoll
180 /++
181     Instigates a poll or stops an ongoing one.
182 
183     If starting one a duration and two or more voting choices have to be passed.
184  +/
185 @(IRCEventHandler()
186     .onEvent(IRCEvent.Type.CHAN)
187     .permissionsRequired(Permissions.operator)
188     .channelPolicy(ChannelPolicy.home)
189     .addCommand(
190         IRCEventHandler.Command()
191             .word("poll")
192             .policy(PrefixPolicy.prefixed)
193             .description(`Starts or stops a poll. Pass "abort" to abort, or "end" to end early.`)
194             .addSyntax("$command [duration] [choice 1] [choice 2] ...")
195             .addSyntax("$command abort")
196             .addSyntax("$command end")
197     )
198     .addCommand(
199         IRCEventHandler.Command()
200             .word("vote")
201             .policy(PrefixPolicy.prefixed)
202             .hidden(true)
203     )
204 )
205 void onCommandPoll(PollPlugin plugin, const ref IRCEvent event)
206 {
207     import kameloso.time : DurationStringException, abbreviatedDuration, timeSince;
208     import lu.string : stripped;
209     import std.algorithm.searching : count;
210     import std.algorithm.sorting : sort;
211     import std.conv : ConvException;
212     import std.datetime.systime : Clock;
213     import std.format : format;
214     import std.random : uniform;
215 
216     void sendUsage()
217     {
218         if (event.sender.class_ < IRCUser.Class.operator)
219         {
220             enum message = "You are not authorised to start new polls.";
221             chan(plugin.state, event.channel, message);
222         }
223         else
224         {
225             import std.format : format;
226 
227             enum pattern = "Usage: <b>%s%s<b> [duration] [choice1] [choice2] ...";
228             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
229             chan(plugin.state, event.channel, message);
230         }
231     }
232 
233     void sendNoOngoingPoll()
234     {
235         enum message = "There is no ongoing poll.";
236         chan(plugin.state, event.channel, message);
237     }
238 
239     void sendPollAborted()
240     {
241         enum message = "Poll aborted.";
242         chan(plugin.state, event.channel, message);
243     }
244 
245     void sendSyntaxHelp()
246     {
247         enum message = "Need one duration and at least two choices.";
248         chan(plugin.state, event.channel, message);
249     }
250 
251     void sendMalformedDuration()
252     {
253         enum message = "Malformed duration.";
254         chan(plugin.state, event.channel, message);
255     }
256 
257     void sendNegativeDuration()
258     {
259         enum message = "Duration must not be negative.";
260         chan(plugin.state, event.channel, message);
261     }
262 
263     void sendNeedTwoUniqueChoices()
264     {
265         enum message = "Need at least two unique poll choices.";
266         chan(plugin.state, event.channel, message);
267     }
268 
269     const currentPoll = event.channel in plugin.channelPolls;
270 
271     switch (event.content)
272     {
273     case string.init:
274         if (currentPoll)
275         {
276             goto case "status";
277         }
278         else
279         {
280             // Can't use a tertiary or it fails to build with older compilers
281             // Error: variable operator used before set
282             if (event.sender.class_ < IRCUser.Class.operator)
283             {
284                 return sendNoOngoingPoll();
285             }
286             else
287             {
288                 return sendUsage();
289             }
290         }
291 
292     case "status":
293         if (!currentPoll) return sendNoOngoingPoll();
294         return reportStatus(plugin, event.channel, *currentPoll);
295 
296     case "abort":
297         if (!currentPoll) return sendNoOngoingPoll();
298 
299         plugin.channelPolls.remove(event.channel);
300         return sendPollAborted();
301 
302     case "end":
303         if (!currentPoll) return sendNoOngoingPoll();
304 
305         reportEndResults(plugin, event.channel, *currentPoll);
306         plugin.channelPolls.remove(event.channel);
307         return;
308 
309     default:
310         // Drop down
311         break;
312     }
313 
314     if (event.content.count(' ') < 2)
315     {
316         return sendSyntaxHelp();
317     }
318 
319     Poll poll;
320     string slice = event.content.stripped;  // mutable
321 
322     try
323     {
324         import lu.string : nom;
325         poll.duration = abbreviatedDuration(slice.nom!(Yes.decode)(' '));
326     }
327     catch (ConvException _)
328     {
329         return sendMalformedDuration();
330     }
331     catch (DurationStringException e)
332     {
333         return chan(plugin.state, event.channel, e.msg);
334     }
335     catch (Exception e)
336     {
337         chan(plugin.state, event.channel, e.msg);
338         version(PrintStacktraces) logger.trace(e.info);
339         return;
340     }
341 
342     if (poll.duration <= Duration.zero)
343     {
344         return sendNegativeDuration();
345     }
346 
347     auto choicesVoldemort = getPollChoices(plugin, event.channel, slice);  // must be mutable
348     if (!choicesVoldemort.success) return;
349 
350     if (choicesVoldemort.choices.length < 2)
351     {
352         return sendNeedTwoUniqueChoices();
353     }
354 
355     poll.start = Clock.currTime;
356     poll.uniqueID = uniform(1, uint.max);
357     poll.voteCounts = choicesVoldemort.choices;
358     poll.origChoiceNames = choicesVoldemort.origChoiceNames;
359     poll.sortedChoices = poll.voteCounts
360         .keys
361         .sort
362         .release;
363     plugin.channelPolls[event.channel] = poll;
364 
365     generatePollFiber(plugin, event.channel, poll);
366     generateVoteReminders(plugin, event.channel, poll);
367     generateEndFiber(plugin, event.channel, poll);
368 
369     immutable timeInWords = poll.duration.timeSince!(7, 0);
370     enum pattern = "<b>Voting commenced!<b> Please place your vote for one of: " ~
371         "%-(<b>%s<b>, %)<b> (%s)";  // extra <b> needed outside of %-(%s, %)
372     immutable message = pattern.format(poll.sortedChoices, timeInWords);
373     chan(plugin.state, event.channel, message);
374 }
375 
376 
377 // getPollChoices
378 /++
379     Sifts out unique choice words from a string.
380 
381     Params:
382         plugin = The current [PollPlugin].
383         channelName = The name of the channel the poll belongs to.
384         slice = Mutable slice of the input.
385 
386     Returns:
387         A Voldemort struct with members `choices` and `origChoiceNames` representing
388         the choices found in the input string.
389  +/
390 auto getPollChoices(
391     PollPlugin plugin,
392     const string channelName,
393     const string slice)
394 {
395     import lu.string : splitWithQuotes;
396     import std.format : format;
397 
398     void sendChoiceMustNotStartWithPrefix()
399     {
400         enum pattern = `Poll choices may not start with the command prefix ("%s").`;
401         immutable message = pattern.format(plugin.state.settings.prefix);
402         chan(plugin.state, channelName, message);
403     }
404 
405     void sendDuplicateChoice(const string choice)
406     {
407         enum pattern = `Duplicate choice: "<b>%s<b>"`;
408         immutable message = pattern.format(choice);
409         chan(plugin.state, channelName, message);
410     }
411 
412     static struct PollChoices
413     {
414         bool success;
415         uint[string] choices;
416         string[string] origChoiceNames;
417     }
418 
419     PollChoices result;
420 
421     foreach (immutable rawChoice; splitWithQuotes(slice))
422     {
423         import lu.string : beginsWith, strippedRight;
424         import std.uni : toLower;
425 
426         if (plugin.pollSettings.forbidPrefixedChoices && rawChoice.beginsWith(plugin.state.settings.prefix))
427         {
428             /*return*/ sendChoiceMustNotStartWithPrefix();
429             return result;
430         }
431 
432         // Strip any trailing commas, unless the choice is literally just commas
433         // We can tell if the comma-stripped string is empty
434         immutable strippedChoice = rawChoice.strippedRight(',');
435         immutable choice = strippedChoice.length ?
436             strippedChoice :
437             rawChoice;
438 
439         if (!choice.length) continue;
440 
441         immutable lower = choice.toLower;
442         if (lower in result.origChoiceNames)
443         {
444             /*return*/ sendDuplicateChoice(choice);
445             return result;
446         }
447 
448         result.origChoiceNames[lower] = choice;
449         result.choices[lower] = 0;
450     }
451 
452     result.success = true;
453     return result;
454 }
455 
456 
457 // generatePollFiber
458 /++
459     Implementation function for generating a poll Fiber.
460 
461     Params:
462         plugin = The current [PollPlugin].
463         channelName = Name of the channel the poll belongs to.
464         poll = The [Poll] to generate a Fiber for.
465  +/
466 void generatePollFiber(
467     PollPlugin plugin,
468     const string channelName,
469     Poll poll)
470 {
471     import kameloso.plugins.common.delayawait : await;
472     import kameloso.constants : BufferSize;
473     import kameloso.thread : CarryingFiber;
474     import std.format : format;
475     import core.thread : Fiber;
476 
477     // Take into account people leaving or changing nicknames on non-Twitch servers
478     // On Twitch NICKs and QUITs don't exist, and PARTs are unreliable.
479     // ACCOUNTs also aren't a thing.
480     static immutable IRCEvent.Type[4] nonTwitchVoteEventTypes =
481     [
482         IRCEvent.Type.NICK,
483         IRCEvent.Type.PART,
484         IRCEvent.Type.QUIT,
485         IRCEvent.Type.ACCOUNT,
486     ];
487 
488     void pollDg()
489     {
490         scope(exit)
491         {
492             import kameloso.plugins.common.delayawait : unawait;
493 
494             unawait(plugin, nonTwitchVoteEventTypes[]);
495             unawait(plugin, IRCEvent.Type.CHAN);
496 
497             const currentPoll = channelName in plugin.channelPolls;
498             if (currentPoll && (currentPoll.uniqueID == poll.uniqueID))
499             {
500                 // Only remove it if it's the same poll as when the delegate started
501                 plugin.channelPolls.remove(channelName);
502             }
503         }
504 
505         while (true)
506         {
507             import kameloso.plugins.common.misc : idOf;
508 
509             auto currentPoll = channelName in plugin.channelPolls;
510             if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return;
511 
512             auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis);
513             assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
514             immutable thisEvent = thisFiber.payload;
515 
516             if (!thisEvent.sender.nickname.length) // == IRCEvent.init
517             {
518                 // Invoked by timer, not by event
519                 // Should never happen now
520                 logger.error("Poll Fiber invoked via delay");
521                 Fiber.yield();
522                 continue;
523             }
524 
525             if (thisEvent.sender.class_ < plugin.pollSettings.minimumPermissionsNeeded)
526             {
527                 // User not authorised to vote. Yield and await a new event
528                 Fiber.yield();
529                 continue;
530             }
531 
532             immutable id = idOf(thisEvent.sender);
533 
534             // Triggered by an event
535             with (IRCEvent.Type)
536             switch (thisEvent.type)
537             {
538             case NICK:
539                 if (auto previousVote = id in currentPoll.votes)
540                 {
541                     immutable newID = idOf(thisEvent.target);
542 
543                     if (id != newID)
544                     {
545                         currentPoll.votes[newID] = *previousVote;
546                         currentPoll.votes.remove(id);
547                     }
548                 }
549                 break;
550 
551             case CHAN:
552                 import lu.string : stripped;
553                 import std.uni : toLower;
554 
555                 if (thisEvent.channel != channelName) break;
556 
557                 immutable vote = thisEvent.content.stripped.toLower;
558 
559                 if (auto ballot = vote in currentPoll.voteCounts)
560                 {
561                     if (auto previousVote = id in currentPoll.votes)
562                     {
563                         if (*previousVote != vote)
564                         {
565                             // User changed their mind
566                             --currentPoll.voteCounts[*previousVote];
567                             ++(*ballot);
568                             currentPoll.votes[id] = vote;
569                         }
570                         else
571                         {
572                             // User is double-voting the same choice, ignore
573                         }
574                     }
575                     else
576                     {
577                         // New user
578                         // Valid entry, increment vote count
579                         // Record user as having voted
580                         ++(*ballot);
581                         currentPoll.votes[id] = vote;
582                     }
583                 }
584                 break;
585 
586             case ACCOUNT:
587                 if (!thisEvent.sender.account.length)
588                 {
589                     // User logged out
590                     // Old account is in aux[0]; move vote to nickname if necessary
591                     if (thisEvent.aux[0] != thisEvent.sender.nickname)
592                     {
593                         if (const previousVote = thisEvent.aux[0] in currentPoll.votes)
594                         {
595                             currentPoll.votes[thisEvent.sender.nickname] = *previousVote;
596                             currentPoll.votes.remove(thisEvent.aux[0]);
597                         }
598                     }
599                 }
600                 else if (thisEvent.sender.account != thisEvent.sender.nickname)
601                 {
602                     if (const previousVote = thisEvent.sender.nickname in currentPoll.votes)
603                     {
604                         // Move the old entry to a new one with the account as key
605                         currentPoll.votes[thisEvent.sender.account] = *previousVote;
606                         currentPoll.votes.remove(thisEvent.sender.nickname);
607                     }
608                 }
609                 break;
610 
611             case PART:
612             case QUIT:
613                 if (plugin.pollSettings.onlyOnlineUsersCount)
614                 {
615                     if (auto previousVote = id in currentPoll.votes)
616                     {
617                         --currentPoll.voteCounts[*previousVote];
618                         currentPoll.votes.remove(id);
619                     }
620                 }
621                 break;
622 
623             default:
624                 assert(0, "Unexpected IRCEvent type seen in poll delegate");
625             }
626 
627             // Yield and await a new event
628             Fiber.yield();
629         }
630     }
631 
632     Fiber fiber = new CarryingFiber!IRCEvent(&pollDg, BufferSize.fiberStack);
633 
634     if (plugin.state.server.daemon != IRCServer.Daemon.twitch)
635     {
636         await(plugin, fiber, nonTwitchVoteEventTypes[]);
637     }
638 
639     await(plugin, fiber, IRCEvent.Type.CHAN);
640 }
641 
642 
643 // reportEndResults
644 /++
645     Reports the result of a [Poll], as if it just ended.
646 
647     Params:
648         plugin = The current [PollPlugin].
649         channelName = Name of the channel the poll belongs to.
650         poll = The [Poll] that just ended.
651  +/
652 void reportEndResults(
653     PollPlugin plugin,
654     const string channelName,
655     const Poll poll)
656 {
657     import std.algorithm.iteration : sum;
658     import std.algorithm.sorting : sort;
659     import std.array : array;
660     import std.format : format;
661 
662     immutable total = cast(double)poll.voteCounts
663         .byValue
664         .sum;
665 
666     if (total == 0)
667     {
668         enum message = "Voting complete, no one voted.";
669         return chan(plugin.state, channelName, message);
670     }
671 
672     enum completeMessage = "Voting complete! Here are the results:";
673     chan(plugin.state, channelName, completeMessage);
674 
675     auto sorted = poll.voteCounts
676         .byKeyValue
677         .array
678         .sort!((a, b) => a.value < b.value);
679 
680     foreach (const result; sorted)
681     {
682         if (result.value == 0)
683         {
684             enum pattern = "<b>%s<b> : 0 votes";
685             immutable message = pattern.format(poll.origChoiceNames[result.key]);
686             chan(plugin.state, channelName, message);
687         }
688         else
689         {
690             import lu.string : plurality;
691 
692             immutable noun = result.value.plurality("vote", "votes");
693             immutable double voteRatio = cast(double)result.value / total;
694             immutable double votePercentage = 100 * voteRatio;
695 
696             enum pattern = "<b>%s<b> : %d %s (%.1f%%)";
697             immutable message = pattern.format(
698                 poll.origChoiceNames[result.key],
699                 result.value,
700                 noun,
701                 votePercentage);
702             chan(plugin.state, channelName, message);
703         }
704     }
705 }
706 
707 
708 // reportStatus
709 /++
710     Reports the status of a [Poll], mid-progress.
711 
712     Params:
713         plugin = The current [PollPlugin].
714         channelName = The channel the poll belongs to.
715         poll = The [Poll] that is still ongoing.
716  +/
717 void reportStatus(
718     PollPlugin plugin,
719     const string channelName,
720     const Poll poll)
721 {
722     import kameloso.time : timeSince;
723     import std.datetime.systime : Clock;
724     import std.format : format;
725 
726     immutable now = Clock.currTime;
727     immutable end = (poll.start + poll.duration);
728     immutable delta = (end - now);
729     immutable timeInWords = delta.timeSince!(7, 0);
730 
731     enum pattern = "There is an ongoing poll! Place your vote for one of: %-(<b>%s<b>, %)<b> (%s)";
732     immutable message = pattern.format(poll.sortedChoices, timeInWords);
733     chan(plugin.state, channelName, message);
734 }
735 
736 
737 // generateVoteReminders
738 /++
739     Generates some vote reminder Fibers.
740 
741     Params:
742         plugin = The current [PollPlugin].
743         channelName = The channel the poll belongs to.
744         poll = [Poll] to generate reminders for.
745  +/
746 void generateVoteReminders(
747     PollPlugin plugin,
748     const string channelName,
749     const Poll poll)
750 {
751     import std.datetime.systime : Clock;
752     import std.meta : AliasSeq;
753     import core.time : days, hours, minutes, seconds;
754 
755     void reminderDg(const Duration reminderPoint)
756     {
757         import lu.string : plurality;
758         import std.format : format;
759 
760         if (reminderPoint == Duration.zero) return;
761 
762         const currentPoll = channelName in plugin.channelPolls;
763         if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return;  // Aborted or replaced
764 
765         enum pattern = "<b>%d<b> %s left to vote! (%-(<b>%s<b>, %)<b>)";
766         immutable numSeconds = reminderPoint.total!"seconds";
767 
768         if ((numSeconds % (24*3600)) == 0)
769         {
770             // An even day
771             immutable numDays = cast(int)(numSeconds / (24*3600));
772             immutable message = pattern.format(
773                 numDays,
774                 numDays.plurality("day", "days"),
775                 poll.sortedChoices);
776             chan(plugin.state, channelName, message);
777         }
778         else if ((numSeconds % 3600) == 0)
779         {
780             // An even hour
781             immutable numHours = cast(int)(numSeconds / 3600);
782             immutable message = pattern.format(
783                 numHours,
784                 numHours.plurality("hour", "hours"),
785                 poll.sortedChoices);
786             chan(plugin.state, channelName, message);
787         }
788         else if ((numSeconds % 60) == 0)
789         {
790             // An even minute
791             immutable numMinutes = cast(int)(numSeconds / 60);
792             immutable message = pattern.format(
793                 numMinutes,
794                 numMinutes.plurality("minute", "minutes"),
795                 poll.sortedChoices);
796             chan(plugin.state, channelName, message);
797         }
798         else
799         {
800             enum secondsPattern = "<b>%d<b> seconds! (%-(<b>%s<b>, %)<b>)";
801             immutable message = secondsPattern.format(numSeconds, poll.sortedChoices);
802             chan(plugin.state, channelName, message);
803         }
804     }
805 
806     // Warn about the poll ending at certain points, depending on how long the duration is.
807 
808     alias reminderPoints = AliasSeq!(
809         7.days,
810         3.days,
811         2.days,
812         1.days,
813         12.hours,
814         6.hours,
815         3.hours,
816         1.hours,
817         30.minutes,
818         10.minutes,
819         5.minutes,
820         2.minutes,
821         30.seconds,
822         10.seconds,
823     );
824 
825     immutable elapsed = (Clock.currTime - poll.start);
826     immutable remaining = (poll.duration - elapsed);
827 
828     foreach (immutable reminderPoint; reminderPoints)
829     {
830         if (poll.duration >= (reminderPoint * 2))
831         {
832             immutable untilReminder = (remaining - reminderPoint);
833 
834             if (untilReminder > Duration.zero)
835             {
836                 import kameloso.plugins.common.delayawait : delay;
837                 delay(plugin, (() => reminderDg(reminderPoint)), untilReminder);
838             }
839         }
840     }
841 }
842 
843 
844 // generateEndFiber
845 /++
846     Generates a Fiber that ends a poll, reporting end results and cleaning up.
847 
848     Params:
849         plugin = The current [PollPlugin].
850         channelName = The channel the poll belongs to.
851         poll = [Poll] to generate end Fiber for.
852  +/
853 void generateEndFiber(
854     PollPlugin plugin,
855     const string channelName,
856     const Poll poll)
857 {
858     import kameloso.plugins.common.delayawait : await, delay, unawait;
859     import kameloso.thread : CarryingFiber;
860     import kameloso.constants : BufferSize;
861     import std.datetime.systime : Clock;
862     import core.thread : Fiber;
863 
864     void endPollDg()
865     {
866         scope(exit) plugin.channelPolls.remove(channelName);
867 
868         const currentPoll = channelName in plugin.channelPolls;
869         if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return;
870 
871         if (channelName in plugin.state.channels)
872         {
873             return reportEndResults(plugin, channelName, *currentPoll);
874         }
875 
876         scope(exit) unawait(plugin, IRCEvent.Type.SELFJOIN);
877         await(plugin, IRCEvent.Type.SELFJOIN, Yes.yield);
878 
879         while (true)
880         {
881             auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis);
882             assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
883 
884             if (thisFiber.payload.channel == channelName)
885             {
886                 return reportEndResults(plugin, channelName, *currentPoll);
887             }
888 
889             Fiber.yield();
890         }
891     }
892 
893     Fiber endFiber = new CarryingFiber!IRCEvent(&endPollDg, BufferSize.fiberStack);
894     immutable elapsed = Clock.currTime - poll.start;
895     immutable remaining = poll.duration - elapsed;
896     delay(plugin, endFiber, remaining);
897 }
898 
899 
900 // serialisePolls
901 /++
902     Serialises ongoing [Poll]s to disk.
903  +/
904 void serialisePolls(PollPlugin plugin)
905 {
906     import lu.json : JSONStorage;
907 
908     JSONStorage json;
909     json.reset();
910 
911     foreach (immutable channelName, const poll; plugin.channelPolls)
912     {
913         json[channelName] = poll.toJSON();
914     }
915 
916     json.save(plugin.pollTempFile);
917 }
918 
919 
920 // deserialisePolls
921 /++
922     Deserialises [Poll]s from disk.
923  +/
924 void deserialisePolls(PollPlugin plugin)
925 {
926     import lu.json : JSONStorage;
927 
928     JSONStorage json;
929     json.load(plugin.pollTempFile);
930 
931     foreach (immutable channelName, const pollJSON; json.object)
932     {
933         auto poll = Poll.fromJSON(pollJSON);
934         plugin.channelPolls[channelName] = poll;
935         generatePollFiber(plugin, channelName, poll);
936         generateVoteReminders(plugin, channelName, poll);
937         generateEndFiber(plugin, channelName, poll);
938     }
939 }
940 
941 
942 // onWelcome
943 /++
944     Deserialises [Poll]s saved to disk upon successfully registering to the server,
945     restoring any ongoing polls.
946 
947     The temporary file is removed immediately afterwards.
948  +/
949 @(IRCEventHandler()
950     .onEvent(IRCEvent.Type.RPL_WELCOME)
951 )
952 void onWelcome(PollPlugin plugin)
953 {
954     import std.file : exists, remove;
955 
956     if (plugin.pollTempFile.exists)
957     {
958         deserialisePolls(plugin);
959         remove(plugin.pollTempFile);
960     }
961 }
962 
963 
964 // onSelfjoin
965 /++
966     Registers a channel entry in
967     [kameloso.plugins.common.core.IRCPluginState.channels|IRCPluginState.channels]
968     upon joining one.
969 
970     This would normally be done using
971     [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness], but we
972     only need the channel registration and not the whole user tracking, so just
973     copy/paste these bits.
974  +/
975 @(IRCEventHandler()
976     .onEvent(IRCEvent.Type.SELFJOIN)
977 )
978 void onSelfjoin(PollPlugin plugin, const ref IRCEvent event)
979 {
980     if (event.channel in plugin.state.channels) return;
981 
982     plugin.state.channels[event.channel] = IRCChannel.init;
983     plugin.state.channels[event.channel].name = event.channel;
984 }
985 
986 
987 // onSelfpart
988 /++
989     De-registers a channel entry in
990     [kameloso.plugins.common.core.IRCPluginState.channels|IRCPluginState.channels]
991     upon parting from one.
992 
993     This would normally be done using
994     [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness], but we
995     only need the channel registration and not the whole user tracking, so just
996     copy/paste these bits.
997  +/
998 @(IRCEventHandler()
999     .onEvent(IRCEvent.Type.SELFPART)
1000 )
1001 void onSelfpart(PollPlugin plugin, const ref IRCEvent event)
1002 {
1003     plugin.state.channels.remove(event.channel);
1004 }
1005 
1006 
1007 // teardown
1008 /++
1009     Tears down the [PollPlugin], serialising any ongoing [Poll]s to file, so they
1010     aren't lost to the ether.
1011  +/
1012 void teardown(PollPlugin plugin)
1013 {
1014     if (!plugin.channelPolls.length) return;
1015     serialisePolls(plugin);
1016 }
1017 
1018 
1019 mixin MinimalAuthentication;
1020 mixin PluginRegistration!PollPlugin;
1021 
1022 public:
1023 
1024 
1025 // PollPlugin
1026 /++
1027     The Poll plugin offers the ability to hold votes/polls in a channel.
1028  +/
1029 final class PollPlugin : IRCPlugin
1030 {
1031 private:
1032     /++
1033         All Poll plugin settings.
1034      +/
1035     PollSettings pollSettings;
1036 
1037     /++
1038         Active polls by channel.
1039      +/
1040     Poll[string] channelPolls;
1041 
1042     /++
1043         Temporary file to store ongoing polls to, between connections
1044         (and executions of the program).
1045      +/
1046     @Resource pollTempFile = "polls.json";
1047 
1048     mixin IRCPluginImpl;
1049 }