1 /++
2     Plugin offering announcement timers; routines that periodically sends lines
3     of text to a channel.
4 
5     See_Also:
6         https://github.com/zorael/kameloso/wiki/Current-plugins#timer,
7         [kameloso.plugins.common.core],
8         [kameloso.plugins.common.misc]
9 
10     Copyright: [JR](https://github.com/zorael)
11     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
12 
13     Authors:
14         [JR](https://github.com/zorael)
15  +/
16 module kameloso.plugins.timer;
17 
18 version(WithTimerPlugin):
19 
20 private:
21 
22 import kameloso.plugins;
23 import kameloso.plugins.common.core;
24 import kameloso.plugins.common.awareness : MinimalAuthentication, UserAwareness;
25 import kameloso.common : logger;
26 import kameloso.messaging;
27 import dialect.defs;
28 import std.typecons : Flag, No, Yes;
29 import core.thread : Fiber;
30 
31 
32 // TimerSettings
33 /++
34     All [TimerPlugin] runtime settings, aggregated in a struct.
35  +/
36 @Settings struct TimerSettings
37 {
38     /++
39         Toggle whether or not this plugin should do anything at all.
40      +/
41     @Enabler bool enabled = true;
42 }
43 
44 
45 // Timer
46 /++
47     Definitions of a timer.
48  +/
49 struct Timer
50 {
51 private:
52     import std.json : JSONValue;
53 
54 public:
55     /++
56         The different kinds of [Timer]s. Either one that yields a
57         [Type.random|random] response each time, or one that yields a
58         [Type.ordered|ordered] one.
59      +/
60     enum Type
61     {
62         /++
63             Lines should be yielded in a random (technically uniform) order.
64          +/
65         random = 0,
66 
67         /++
68             Lines should be yielded in order, bumping an internal counter.
69          +/
70         ordered = 1,
71     }
72 
73     /++
74         Conditions upon which timers decide whether they are to fire yet, or wait still.
75      +/
76     enum Condition
77     {
78         /++
79             Both message count and time criteria must be fulfilled.
80          +/
81         both = 0,
82 
83         /++
84             Either message count or time criteria may be fulfilled.
85          +/
86         either = 1,
87     }
88 
89     /++
90         String name identifier of this timer.
91      +/
92     string name;
93 
94     /++
95         The timered lines to send to the channel.
96      +/
97     string[] lines;
98 
99     /++
100         What type of [Timer] this is.
101      +/
102     Type type;
103 
104     /++
105         Workhorse [core.thread.fiber.Fiber|Fiber].
106      +/
107     Fiber fiber;
108 
109     /++
110         What message/time conditions this [Timer] abides by.
111      +/
112     Condition condition;
113 
114     /++
115         How many messages must have been sent since the last announce before we
116         will allow another one.
117      +/
118     long messageCountThreshold;
119 
120     /++
121         How many seconds must have passed since the last announce before we will
122         allow another one.
123      +/
124     long timeThreshold;
125 
126     /++
127         Delay in number of messages before the timer initially comes into effect.
128      +/
129     long messageCountStagger;
130 
131     /++
132         Delay in seconds before the timer initially comes into effect.
133      +/
134     long timeStagger;
135 
136     /++
137         The channel message count at last successful trigger.
138      +/
139     ulong lastMessageCount;
140 
141     /++
142         The timestamp at the last successful trigger.
143      +/
144     long lastTimestamp;
145 
146     /++
147         The current position, kept to keep track of what line should be yielded
148         next in the case of ordered timers.
149      +/
150     size_t position;
151 
152     /++
153         Whether or not this [Timer] is suspended and should not output anything.
154      +/
155     bool suspended;
156 
157     // getLine
158     /++
159         Yields a line from the [lines] array, depending on the [type] of this timer.
160 
161         Returns:
162             A line string. If the [lines] array is empty, then an empty string
163             is returned instead.
164      +/
165     auto getLine()
166     {
167         return (type == Type.random) ?
168             randomLine() :
169             nextOrderedLine();
170     }
171 
172     // nextOrderedLine
173     /++
174         Yields an ordered line from the [lines] array. Which line is selected
175         depends on the value of [position].
176 
177         Returns:
178             A line string. If the [lines] array is empty, then an empty string
179             is returned instead.
180      +/
181     auto nextOrderedLine()
182     {
183         if (!lines.length) return string.init;
184 
185         size_t i = position++;  // mutable
186 
187         if (i >= lines.length)
188         {
189             // Position needs to be zeroed on response removals
190             i = 0;
191             position = 1;
192         }
193         else if (position >= lines.length)
194         {
195             position = 0;
196         }
197 
198         return lines[i];
199     }
200 
201     // randomLine
202     /++
203         Yields a random line from the [lines] array.
204 
205         Returns:
206             A line string. If the [lines] array is empty, then an empty string
207             is returned instead.
208      +/
209     auto randomLine() const
210     {
211         import std.random : uniform;
212         return lines.length ?
213             lines[uniform(0, lines.length)] :
214             string.init;
215     }
216 
217     // toJSON
218     /++
219         Serialises this [Timer] into a [std.json.JSONValue|JSONValue].
220 
221         Returns:
222             A [std.json.JSONValue|JSONValue] that describes this timer.
223      +/
224     auto toJSON() const
225     {
226         JSONValue json;
227         json = null;
228         json.object = null;
229 
230         json["name"] = JSONValue(this.name);
231         json["type"] = JSONValue(cast(int)this.type);
232         json["condition"] = JSONValue(cast(int)this.condition);
233         json["messageCountThreshold"] = JSONValue(this.messageCountThreshold);
234         json["timeThreshold"] = JSONValue(this.timeThreshold);
235         json["messageCountStagger"] = JSONValue(this.messageCountStagger);
236         json["timeStagger"] = JSONValue(this.timeStagger);
237         json["suspended"] = JSONValue(this.suspended);
238         json["lines"] = null;
239         json["lines"].array = null;
240 
241         foreach (immutable line; this.lines)
242         {
243             json["lines"].array ~= JSONValue(line);
244         }
245 
246         return json;
247     }
248 
249     // fromJSON
250     /++
251         Deserialises a [Timer] from a [std.json.JSONValue|JSONValue].
252 
253         Params:
254             json = [std.json.JSONValue|JSONValue] to deserialise.
255 
256         Returns:
257             A new [Timer] with values loaded from the passed JSON.
258      +/
259     static auto fromJSON(const JSONValue json)
260     {
261         Timer timer;
262         timer.name = json["name"].str;
263         timer.messageCountThreshold = json["messageCountThreshold"].integer;
264         timer.timeThreshold = json["timeThreshold"].integer;
265         timer.messageCountStagger = json["messageCountStagger"].integer;
266         timer.timeStagger = json["timeStagger"].integer;
267         timer.type = (json["type"].integer == cast(int)Type.random) ?
268             Type.random :
269             Type.ordered;
270         timer.condition = (json["condition"].integer == cast(int)Condition.both) ?
271             Condition.both :
272             Condition.either;
273 
274         // Compatibility with older versions, remove later
275         if (const suspendedJSON = "suspended" in json.object)
276         {
277             timer.suspended = suspendedJSON.boolean;
278         }
279 
280         foreach (const lineJSON; json["lines"].array)
281         {
282             timer.lines ~= lineJSON.str;
283         }
284 
285         return timer;
286     }
287 }
288 
289 ///
290 unittest
291 {
292     Timer timer;
293     timer.lines = [ "abc", "def", "ghi" ];
294 
295     {
296         timer.type = Timer.Type.ordered;
297         assert(timer.getLine() == "abc");
298         assert(timer.getLine() == "def");
299         assert(timer.getLine() == "ghi");
300         assert(timer.getLine() == "abc");
301         assert(timer.getLine() == "def");
302         assert(timer.getLine() == "ghi");
303     }
304     {
305         import std.algorithm.comparison : among;
306 
307         timer.type = Timer.Type.random;
308         bool[string] linesSeen;
309 
310         foreach (immutable i; 0..300)
311         {
312             linesSeen[timer.getLine()] = true;
313         }
314 
315         assert("abc" in linesSeen);
316         assert("def" in linesSeen);
317         assert("ghi" in linesSeen);
318     }
319 }
320 
321 
322 // onCommandTimer
323 /++
324     Adds, deletes or lists timers for the specified target channel.
325 
326     Changes are persistently saved to the [TimerPlugin.timersFile] file.
327  +/
328 @(IRCEventHandler()
329     .onEvent(IRCEvent.Type.CHAN)
330     .permissionsRequired(Permissions.operator)
331     .channelPolicy(ChannelPolicy.home)
332     .addCommand(
333         IRCEventHandler.Command()
334             .word("timer")
335             .policy(PrefixPolicy.prefixed)
336             .description("Adds, removes or lists timers.")
337             .addSyntax("$command new [name] [type] [condition] [message count threshold] " ~
338                 "[time threshold] [stagger message count] [stagger time]")
339             .addSyntax("$command add [existing timer name] [new timer line]")
340             .addSyntax("$command insert [timer name] [position] [new timer line]")
341             .addSyntax("$command edit [timer name] [position] [new timer line]")
342             .addSyntax("$command del [timer name] [optional line number]")
343             .addSyntax("$command suspend [timer name]")
344             .addSyntax("$command resume [timer name]")
345             .addSyntax("$command list")
346     )
347 )
348 void onCommandTimer(TimerPlugin plugin, const ref IRCEvent event)
349 {
350     import lu.string : nom, stripped;
351     import std.format : format;
352 
353     void sendUsage()
354     {
355         enum pattern = "Usage: <b>%s%s<b> [new|add|del|suspend|resume|list] ...";
356         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
357         chan(plugin.state, event.channel, message);
358     }
359 
360     string slice = event.content.stripped;  // mutable
361     immutable verb = slice.nom!(Yes.inherit)(' ');
362 
363     switch (verb)
364     {
365     case "new":
366         return handleNewTimer(plugin, event, slice);
367 
368     case "insert":
369         return handleModifyTimerLines(plugin, event, slice, Yes.insert);
370 
371     case "edit":
372         return handleModifyTimerLines(plugin, event, slice, No.insert);  // --> Yes.edit
373 
374     case "add":
375         return handleAddToTimer(plugin, event, slice);
376 
377     case "del":
378         return handleDelTimer(plugin, event, slice);
379 
380     case "suspend":
381         return handleSuspendTimer(plugin, event, slice, Yes.suspend);
382 
383     case "resume":
384         return handleSuspendTimer(plugin, event, slice, No.suspend);  // --> Yes.resume
385 
386     case "list":
387         return handleListTimers(plugin, event);
388 
389     default:
390         return sendUsage();
391     }
392 }
393 
394 
395 // handleNewTimer
396 /++
397     Creates a new timer.
398 
399     Params:
400         plugin = The current [TimerPlugin].
401         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the creation.
402         slice = Relevant slice of the original request string.
403  +/
404 void handleNewTimer(
405     TimerPlugin plugin,
406     const /*ref*/ IRCEvent event,
407     /*const*/ string slice)
408 {
409     import kameloso.time : DurationStringException, abbreviatedDuration;
410     import lu.string : SplitResults, splitInto;
411     import std.conv : ConvException, to;
412     import std.format : format;
413 
414     void sendNewUsage()
415     {
416         enum pattern = "Usage: <b>%s%s new<b> [name] [type] [condition] [message count threshold] " ~
417             "[time threshold] [stagger message count] [stagger time]";
418         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
419         chan(plugin.state, event.channel, message);
420     }
421 
422     void sendBadNumerics()
423     {
424         enum message = "Arguments for threshold and stagger values must all be positive numbers.";
425         chan(plugin.state, event.channel, message);
426     }
427 
428     void sendZeroedConditions()
429     {
430         enum message = "A timer cannot have a message threshold *and* a time threshold of zero.";
431         chan(plugin.state, event.channel, message);
432     }
433 
434     Timer timer;
435 
436     string type;
437     string condition;
438     string messageCountThreshold;
439     string timeThreshold;
440     string messageCountStagger;
441     string timeStagger;
442 
443     immutable results = slice.splitInto(
444         timer.name,
445         type,
446         condition,
447         messageCountThreshold,
448         timeThreshold,
449         messageCountStagger,
450         timeStagger);
451 
452     with (SplitResults)
453     final switch (results)
454     {
455     case match:
456         break;
457 
458     case underrun:
459         if (messageCountThreshold.length) break;
460         else
461         {
462             return sendNewUsage();
463         }
464 
465     case overrun:
466         return sendNewUsage();
467     }
468 
469     switch (type)
470     {
471     case "random":
472         timer.type = Timer.Type.random;
473         break;
474 
475     case "ordered":
476         timer.type = Timer.Type.ordered;
477         break;
478 
479     default:
480         enum message = "Type must be one of <b>random<b> or <b>ordered<b>.";
481         return chan(plugin.state, event.channel, message);
482     }
483 
484     switch (condition)
485     {
486     case "both":
487         timer.condition = Timer.Condition.both;
488         break;
489 
490     case "either":
491         timer.condition = Timer.Condition.either;
492         break;
493 
494     default:
495         enum message = "Condition must be one of <b>both<b> or <b>either<b>.";
496         return chan(plugin.state, event.channel, message);
497     }
498 
499     try
500     {
501         timer.messageCountThreshold = messageCountThreshold.to!long;
502         timer.timeThreshold = abbreviatedDuration(timeThreshold).total!"seconds";
503         if (messageCountStagger.length) timer.messageCountStagger = messageCountStagger.to!long;
504         if (timeStagger.length) timer.timeStagger = abbreviatedDuration(timeStagger).total!"seconds";
505     }
506     catch (ConvException _)
507     {
508         return sendBadNumerics();
509     }
510     catch (DurationStringException e)
511     {
512         return chan(plugin.state, event.channel, e.msg);
513     }
514 
515     if ((timer.messageCountThreshold < 0) ||
516         (timer.timeThreshold < 0) ||
517         (timer.messageCountStagger < 0) ||
518         (timer.timeStagger < 0))
519     {
520         return sendBadNumerics();
521     }
522     else if ((timer.messageCountThreshold == 0) && (timer.timeThreshold == 0))
523     {
524         return sendZeroedConditions();
525     }
526 
527     auto channel = event.channel in plugin.channels;
528     assert(channel, "Tried to create a timer in a channel with no Channel in plugin.channels");
529 
530     timer.lastMessageCount = channel.messageCount;
531     timer.lastTimestamp = event.time;
532     timer.fiber = createTimerFiber(plugin, event.channel, timer.name);
533 
534     plugin.timersByChannel[event.channel][timer.name] = timer;
535     channel.timerPointers[timer.name] = &plugin.timersByChannel[event.channel][timer.name];
536 
537     enum appendPattern = "New timer added! Use <b>%s%s add<b> to add lines.";
538     immutable message = appendPattern.format(plugin.state.settings.prefix, event.aux[$-1]);
539     chan(plugin.state, event.channel, message);
540 }
541 
542 
543 // handleDelTimer
544 /++
545     Deletes an existing timer.
546 
547     Params:
548         plugin = The current [TimerPlugin].
549         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the deletion.
550         slice = Relevant slice of the original request string.
551  +/
552 void handleDelTimer(
553     TimerPlugin plugin,
554     const ref IRCEvent event,
555     /*const*/ string slice)
556 {
557     import lu.string : SplitResults, splitInto;
558     import std.format : format;
559 
560     void sendDelUsage()
561     {
562         enum pattern = "Usage: <b>%s%s del<b> [timer name] [optional line number]";
563         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
564         chan(plugin.state, event.channel, message);
565     }
566 
567     void sendNoSuchTimer()
568     {
569         enum message = "There is no timer by that name.";
570         chan(plugin.state, event.channel, message);
571     }
572 
573     if (!slice.length) return sendDelUsage();
574 
575     auto channel = event.channel in plugin.channels;
576     if (!channel) return sendNoSuchTimer();
577 
578     string name;
579     string linePosString;
580 
581     immutable results = slice.splitInto(name, linePosString);
582 
583     with (SplitResults)
584     final switch (results)
585     {
586     case underrun:
587         // Remove the entire timer
588         if (!name.length) return sendDelUsage();
589 
590         const timerPtr = name in channel.timerPointers;
591         if (!timerPtr) return sendNoSuchTimer();
592 
593         channel.timerPointers.remove(name);
594         if (!channel.timerPointers.length) plugin.channels.remove(event.channel);
595 
596         auto channelTimers = event.channel in plugin.timersByChannel;
597         (*channelTimers).remove(name);
598         if (!channelTimers.length) plugin.timersByChannel.remove(event.channel);
599 
600         saveTimersToDisk(plugin);
601         enum message = "Timer removed.";
602         return chan(plugin.state, event.channel, message);
603 
604     case match:
605         import std.conv : ConvException, to;
606 
607         // Remove the specified lines position
608         auto channelTimers = event.channel in plugin.timersByChannel;
609         if (!channelTimers) return sendNoSuchTimer();
610 
611         auto timer = name in *channelTimers;
612         if (!timer) return sendNoSuchTimer();
613 
614         try
615         {
616             import std.algorithm.mutation : SwapStrategy, remove;
617 
618             immutable linePos = linePosString.to!size_t;
619             timer.lines = timer.lines.remove!(SwapStrategy.stable)(linePos);
620             saveTimersToDisk(plugin);
621 
622             enum pattern = "Line removed from timer <b>%s<b>. Lines remaining: <b>%d<b>";
623             immutable message = pattern.format(name, timer.lines.length);
624             return chan(plugin.state, event.channel, message);
625         }
626         catch (ConvException _)
627         {
628             enum message = "Argument for which line to remove must be a number.";
629             return chan(plugin.state, event.channel, message);
630         }
631 
632     case overrun:
633         sendDelUsage();
634     }
635 }
636 
637 
638 // handleModifyTimerLines
639 /++
640     Edits a line of an existing timer, or insert one at a specific line position.
641 
642     Params:
643         plugin = The current [TimerPlugin].
644         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the insert or edit.
645         slice = Relevant slice of the original request string.
646         insert = Whether or not an insert action was requested. If `No.insert`,
647             then an edit action was requested.
648  +/
649 void handleModifyTimerLines(
650     TimerPlugin plugin,
651     const /*ref*/ IRCEvent event,
652     /*const*/ string slice,
653     const Flag!"insert" insert)
654 {
655     import lu.string : SplitResults, splitInto;
656     import std.conv : ConvException, to;
657     import std.format : format;
658 
659     void sendInsertUsage()
660     {
661         if (insert)
662         {
663             enum pattern = "Usage: <b>%s%s insert<b> [timer name] [position] [timer text]";
664             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
665             chan(plugin.state, event.channel, message);
666         }
667         else
668         {
669             enum pattern = "Usage: <b>%s%s edit<b> [timer name] [position] [new timer text]";
670             immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
671             chan(plugin.state, event.channel, message);
672         }
673     }
674 
675     void sendNoSuchTimer()
676     {
677         enum message = "There is no timer by that name.";
678         chan(plugin.state, event.channel, message);
679     }
680 
681     void sendOutOfRange(const size_t upperBound)
682     {
683         enum pattern = "Line position out of range; valid is <b>[0..%d]<b> (inclusive).";
684         immutable message = pattern.format(upperBound);
685         chan(plugin.state, event.channel, message);
686     }
687 
688     string name;
689     string linePosString;
690 
691     immutable results = slice.splitInto(name, linePosString);
692     if (results != SplitResults.overrun) return sendInsertUsage();
693 
694     auto channel = event.channel in plugin.channels;
695     if (!channel) return sendNoSuchTimer();
696 
697     auto channelTimers = event.channel in plugin.timersByChannel;
698     if (!channelTimers) return sendNoSuchTimer();
699 
700     auto timer = name in *channelTimers;
701     if (!timer) return sendNoSuchTimer();
702 
703     void destroyUpdateSave()
704     {
705         destroy(timer.fiber);
706         timer.fiber = createTimerFiber(plugin, event.channel, timer.name);
707         saveTimersToDisk(plugin);
708     }
709 
710     try
711     {
712         immutable linePos = linePosString.to!ptrdiff_t;
713         if ((linePos < 0) || (linePos >= timer.lines.length)) return sendOutOfRange(timer.lines.length);
714 
715         if (insert)
716         {
717             import std.array : insertInPlace;
718 
719             timer.lines.insertInPlace(linePos, slice);
720             destroyUpdateSave();
721 
722             enum pattern = "Line added to timer <b>%s<b>.";
723             immutable message = pattern.format(name);
724             chan(plugin.state, event.channel, message);
725         }
726         else
727         {
728             timer.lines[linePos] = slice;
729             destroyUpdateSave();
730 
731             enum pattern = "Line <b>#%d<b> of timer <b>%s<b> edited.";
732             immutable message = pattern.format(linePos, name);
733             chan(plugin.state, event.channel, message);
734         }
735     }
736     catch (ConvException _)
737     {
738         enum message = "Position argument must be a number.";
739         chan(plugin.state, event.channel, message);
740     }
741 }
742 
743 
744 // handleAddToTimer
745 /++
746     Adds a line to an existing timer.
747 
748     Params:
749         plugin = The current [TimerPlugin].
750         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the addition.
751         slice = Relevant slice of the original request string.
752  +/
753 void handleAddToTimer(
754     TimerPlugin plugin,
755     const /*ref*/ IRCEvent event,
756     /*const*/ string slice)
757 {
758     import lu.string : nom;
759     import std.format : format;
760 
761     void sendAddUsage()
762     {
763         enum pattern = "Usage: <b>%s%s add<b> [existing timer name] [new timer line]";
764         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
765         chan(plugin.state, event.channel, message);
766     }
767 
768     void sendNoSuchTimer()
769     {
770         enum noSuchTimerPattern = "No such timer is defined. Add a new one with <b>%s%s new<b>.";
771         immutable noSuchTimerMessage = noSuchTimerPattern.format(plugin.state.settings.prefix, event.aux[$-1]);
772         chan(plugin.state, event.channel, noSuchTimerMessage);
773     }
774 
775     immutable name = slice.nom!(Yes.inherit)(' ');
776     if (!slice.length) return sendAddUsage();
777 
778     auto channel = event.channel in plugin.channels;
779     if (!channel) return sendNoSuchTimer();
780 
781     auto channelTimers = event.channel in plugin.timersByChannel;
782     if (!channelTimers) return sendNoSuchTimer();
783 
784     auto timer = name in *channelTimers;
785     if (!timer) return sendNoSuchTimer();
786 
787     void destroyUpdateSave()
788     {
789         destroy(timer.fiber);
790         timer.fiber = createTimerFiber(plugin, event.channel, timer.name);
791         saveTimersToDisk(plugin);
792     }
793 
794     timer.lines ~= slice;
795     destroyUpdateSave();
796 
797     enum pattern = "Line added to timer <b>%s<b>.";
798     immutable message = pattern.format(name);
799     chan(plugin.state, event.channel, message);
800 }
801 
802 
803 // handleListTimers
804 /++
805     Lists all timers.
806 
807     Params:
808         plugin = The current [TimerPlugin].
809         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the listing.
810  +/
811 void handleListTimers(
812     TimerPlugin plugin,
813     const ref IRCEvent event)
814 {
815     import std.format : format;
816 
817     void sendNoTimersForChannel()
818     {
819         enum message = "There are no timers registered for this channel.";
820         chan(plugin.state, event.channel, message);
821     }
822 
823     void sendNoSuchTimer()
824     {
825         enum message = "There is no timer by that name.";
826         chan(plugin.state, event.channel, message);
827     }
828 
829     const channel = event.channel in plugin.channels;
830     if (!channel) return sendNoTimersForChannel();
831 
832     auto channelTimers = event.channel in plugin.timersByChannel;
833     if (!channelTimers) return sendNoTimersForChannel();
834 
835     enum headerPattern = "Current timers for channel <b>%s<b>:";
836     immutable headerMessage = headerPattern.format(event.channel);
837     chan(plugin.state, event.channel, headerMessage);
838 
839     foreach (const timer; *channelTimers)
840     {
841         enum timerPattern =
842             "[\"%s\"] " ~
843             "lines:%d | " ~
844             "type:%s | " ~
845             "condition:%s | " ~
846             "message count threshold:%d | " ~
847             "time threshold:%d | " ~
848             "stagger message count:%d | " ~
849             "stagger time:%d | " ~
850             "suspended:%s";
851 
852         immutable timerMessage = timerPattern.format(
853             timer.name,
854             timer.lines.length,
855             ((timer.type == Timer.Type.random) ? "random" : "ordered"),
856             ((timer.condition == Timer.Condition.both) ? "both" : "either"),
857             timer.messageCountThreshold,
858             timer.timeThreshold,
859             timer.messageCountStagger,
860             timer.timeStagger,
861             timer.suspended,
862         );
863 
864         chan(plugin.state, event.channel, timerMessage);
865     }
866 }
867 
868 
869 // handleSuspendTimer
870 /++
871     Suspends or resumes a timer, by modifying [Timer.suspended].
872 
873     Params:
874         plugin = The current [TimerPlugin].
875         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the suspend or resume.
876         slice = Relevant slice of the original request string.
877         suspend = Whether or not a suspend action was requested. If `No.suspend`,
878             then a resume action was requested.
879  +/
880 void handleSuspendTimer(
881     TimerPlugin plugin,
882     const ref IRCEvent event,
883     /*const*/ string slice,
884     const Flag!"suspend" suspend)
885 {
886     import lu.string : SplitResults, splitInto;
887     import std.format : format;
888 
889     void sendUsage()
890     {
891         immutable verb = suspend ? "suspend" : "resume";
892         enum pattern = "Usage: <b>%s%s %s<b> [name]";
893         immutable message = pattern.format(
894             plugin.state.settings.prefix,
895             event.aux[$-1],
896             verb);
897         chan(plugin.state, event.channel, message);
898     }
899 
900     void sendNoSuchTimer()
901     {
902         enum message = "There is no timer by that name.";
903         chan(plugin.state, event.channel, message);
904     }
905 
906     string name;
907 
908     immutable results = slice.splitInto(name);
909     if (results != SplitResults.match) return sendUsage();
910 
911     auto channel = event.channel in plugin.channels;
912     if (!channel) return sendNoSuchTimer();
913 
914     auto channelTimers = event.channel in plugin.timersByChannel;
915     if (!channelTimers) return sendNoSuchTimer();
916 
917     auto timer = name in *channelTimers;
918     if (!timer) return sendNoSuchTimer();
919 
920     timer.suspended = suspend;
921     saveTimersToDisk(plugin);
922 
923     if (suspend)
924     {
925         enum pattern = "Timer suspended. Use <b>%s%s resume %s<b> to resume it.";
926         immutable message = pattern.format(
927             plugin.state.settings.prefix,
928             event.aux[$-1],
929             name);
930         chan(plugin.state, event.channel, message);
931     }
932     else
933     {
934         enum message = "Timer resumed!";
935         chan(plugin.state, event.channel, message);
936     }
937 }
938 
939 
940 // onAnyMessage
941 /++
942     Bumps the message count for any channel on incoming channel messages.
943  +/
944 @(IRCEventHandler()
945     .onEvent(IRCEvent.Type.CHAN)
946     .onEvent(IRCEvent.Type.EMOTE)
947     .permissionsRequired(Permissions.ignore)
948     .channelPolicy(ChannelPolicy.home)
949 )
950 void onAnyMessage(TimerPlugin plugin, const ref IRCEvent event)
951 {
952     auto channel = event.channel in plugin.channels;
953 
954     if (!channel)
955     {
956         // Race...
957         handleSelfjoin(plugin, event.channel, No.force);
958         channel = event.channel in plugin.channels;
959     }
960 
961     ++channel.messageCount;
962 }
963 
964 
965 // onWelcome
966 /++
967     Loads timers from disk. Additionally sets up a [core.thread.fiber.Fiber|Fiber]
968     to periodically call timer [core.thread.fiber.Fiber|Fiber]s with a periodicity
969     of [TimerPlugin.timerPeriodicity].
970 
971     Don't call `reload` for this! It undoes anything `handleSelfjoin` may have done.
972  +/
973 @(IRCEventHandler()
974     .onEvent(IRCEvent.Type.RPL_WELCOME)
975 )
976 void onWelcome(TimerPlugin plugin)
977 {
978     import kameloso.plugins.common.delayawait : delay;
979     import kameloso.constants : BufferSize;
980     import lu.json : JSONStorage;
981     import core.thread : Fiber;
982 
983     JSONStorage allTimersJSON;
984     allTimersJSON.load(plugin.timerFile);
985 
986     foreach (immutable channelName, const timersJSON; allTimersJSON.object)
987     {
988         auto channelTimers = channelName in plugin.timersByChannel;
989 
990         if (!channelTimers)
991         {
992             plugin.timersByChannel[channelName] = typeof(plugin.timersByChannel[channelName]).init;
993             channelTimers = channelName in plugin.timersByChannel;
994         }
995 
996         foreach (const timerJSON; timersJSON.array)
997         {
998             auto timer = Timer.fromJSON(timerJSON);
999             (*channelTimers)[timer.name] = timer;
1000         }
1001 
1002         *channelTimers = channelTimers.rehash();
1003     }
1004 
1005     plugin.timersByChannel = plugin.timersByChannel.rehash();
1006 
1007     void fiberTriggerDg()
1008     {
1009         while (true)
1010         {
1011             import std.datetime.systime : Clock;
1012 
1013             // Micro-optimise getting the current time
1014             long nowInUnix; // = Clock.currTime.toUnixTime;
1015 
1016             // Walk through channels, trigger fibers
1017             foreach (immutable channelName, channel; plugin.channels)
1018             {
1019                 innermost:
1020                 foreach (timerPtr; channel.timerPointers)
1021                 {
1022                     if (!timerPtr.fiber || (timerPtr.fiber.state != Fiber.State.HOLD))
1023                     {
1024                         logger.error("Dead or busy timer Fiber in channel ", channelName);
1025                         continue innermost;
1026                     }
1027 
1028                     // Get time here and cache it
1029                     if (nowInUnix == 0) nowInUnix = Clock.currTime.toUnixTime;
1030 
1031                     immutable timeConditionMet =
1032                         ((nowInUnix - timerPtr.lastTimestamp) >= timerPtr.timeThreshold);
1033                     immutable messageConditionMet =
1034                         ((channel.messageCount - timerPtr.lastMessageCount) >= timerPtr.messageCountThreshold);
1035 
1036                     if (timerPtr.condition == Timer.Condition.both)
1037                     {
1038                         if (timeConditionMet && messageConditionMet)
1039                         {
1040                             timerPtr.fiber.call();
1041                         }
1042                     }
1043                     else /*if (timerPtr.condition == Timer.Condition.either)*/
1044                     {
1045                         if (timeConditionMet || messageConditionMet)
1046                         {
1047                             timerPtr.fiber.call();
1048                         }
1049                     }
1050                 }
1051             }
1052 
1053             delay(plugin, plugin.timerPeriodicity, Yes.yield);
1054             // continue;
1055         }
1056     }
1057 
1058     Fiber fiberTriggerFiber = new Fiber(&fiberTriggerDg, BufferSize.fiberStack);
1059     delay(plugin, fiberTriggerFiber, plugin.timerPeriodicity);
1060 }
1061 
1062 
1063 // onSelfjoin
1064 /++
1065     Simply passes on execution to [handleSelfjoin].
1066  +/
1067 @(IRCEventHandler()
1068     .onEvent(IRCEvent.Type.SELFJOIN)
1069     .channelPolicy(ChannelPolicy.home)
1070 )
1071 void onSelfjoin(TimerPlugin plugin, const ref IRCEvent event)
1072 {
1073     return handleSelfjoin(plugin, event.channel, No.force);
1074 }
1075 
1076 
1077 // handleSelfjoin
1078 /++
1079     Registers a new [TimerPlugin.Channel] as we join a channel, so there's
1080     always a state struct available.
1081 
1082     Creates the timer [core.thread.fiber.Fiber|Fiber]s that there are definitions
1083     for in [TimerPlugin.timersByChannel].
1084 
1085     Params:
1086         plugin = The current [TimerPlugin].
1087         channelName = The name of the channel we're supposedly joining.
1088         force = Whether or not to always set up the channel, regardless of its
1089             current existence.
1090  +/
1091 void handleSelfjoin(
1092     TimerPlugin plugin,
1093     const string channelName,
1094     const Flag!"force" force = No.force)
1095 {
1096     auto channel = channelName in plugin.channels;
1097     auto channelTimers = channelName in plugin.timersByChannel;
1098 
1099     if (!channel || force)
1100     {
1101         // No channel or forcing; create
1102         plugin.channels[channelName] = TimerPlugin.Channel(channelName);  // as above
1103         if (!channel) channel = channelName in plugin.channels;
1104     }
1105 
1106     if (channelTimers)
1107     {
1108         import std.datetime.systime : Clock;
1109 
1110         immutable nowInUnix = Clock.currTime.toUnixTime;
1111 
1112         // Populate timers
1113         foreach (ref timer; *channelTimers)
1114         {
1115             destroy(timer.fiber);
1116             timer.lastMessageCount = channel.messageCount;
1117             timer.lastTimestamp = nowInUnix;
1118             timer.fiber = createTimerFiber(plugin, channelName, timer.name);
1119             channel.timerPointers[timer.name] = &timer;  // Will this work in release mode?
1120         }
1121     }
1122 }
1123 
1124 
1125 // createTimerFiber
1126 /++
1127     Given a [Timer] and a string channel name, creates a
1128     [core.thread.fiber.Fiber|Fiber] that implements the timer.
1129 
1130     Params:
1131         plugin = The current [TimerPlugin].
1132         channelName = String channel to which the timer belongs.
1133         name = Timer name, used as inner key in [TimerPlugin.timersByChannel].
1134  +/
1135 auto createTimerFiber(
1136     TimerPlugin plugin,
1137     const string channelName,
1138     const string name)
1139 {
1140     import kameloso.constants : BufferSize;
1141     import core.thread : Fiber;
1142 
1143     void createTimerDg()
1144     {
1145         import std.datetime.systime : Clock;
1146 
1147         /// Channel pointer.
1148         const channel = channelName in plugin.channels;
1149         assert(channel, channelName ~ " not in plugin.channels");
1150 
1151         auto channelTimers = channelName in plugin.timersByChannel;
1152         assert(channelTimers, channelName ~ " not in plugin.timersByChanel");
1153 
1154         auto timer = name in *channelTimers;
1155         assert(timer, name ~ " not in *channelTimers");
1156 
1157         // Ensure that the Timer was set up with a UNIX timestamp prior to creating this
1158         assert((timer.lastTimestamp > 0L), "Timer Fiber " ~ name ~ " created before initial timestamp was set");
1159 
1160         // Stagger based on message count and time thresholds
1161         while (true)
1162         {
1163             immutable timeStaggerMet =
1164                 ((Clock.currTime.toUnixTime - timer.lastTimestamp) >= timer.timeStagger);
1165             immutable messageStaggerMet =
1166                 ((channel.messageCount - timer.lastMessageCount) >= timer.messageCountStagger);
1167 
1168             if (timer.condition == Timer.Condition.both)
1169             {
1170                 if (timeStaggerMet && messageStaggerMet) break;
1171             }
1172             else /*if (timer.condition == Timer.Condition.either)*/
1173             {
1174                 if (timeStaggerMet || messageStaggerMet) break;
1175             }
1176 
1177             Fiber.yield();
1178             continue;
1179         }
1180 
1181         void updateTimer()
1182         {
1183             timer.lastMessageCount = channel.messageCount;
1184             timer.lastTimestamp = Clock.currTime.toUnixTime;
1185         }
1186 
1187         // Snapshot count and timestamp
1188         updateTimer();
1189 
1190         // Main loop
1191         while (true)
1192         {
1193             import kameloso.string : replaceRandom;
1194             import std.array : replace;
1195             import std.conv : to;
1196             import std.random : uniform;
1197 
1198             if (timer.suspended)
1199             {
1200                 updateTimer();
1201                 Fiber.yield();
1202                 continue;
1203             }
1204 
1205             string message = timer.getLine()  // mutable
1206                 .replace("$bot", plugin.state.client.nickname)
1207                 .replace("$channel", channelName[1..$])
1208                 .replaceRandom();
1209 
1210             version(TwitchSupport)
1211             {
1212                 if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
1213                 {
1214                     import kameloso.plugins.common.misc : nameOf;
1215                     message = message.replace("$streamer", nameOf(plugin, channelName[1..$]));
1216                 }
1217             }
1218 
1219             chan(plugin.state, channelName, message);
1220             updateTimer();
1221             Fiber.yield();
1222             //continue;
1223         }
1224     }
1225 
1226     return new Fiber(&createTimerDg, BufferSize.fiberStack);
1227 }
1228 
1229 
1230 // saveTimersToDisk
1231 /++
1232     Saves timers to disk in JSON format.
1233 
1234     Params:
1235         plugin = The current [TimerPlugin].
1236  +/
1237 void saveTimersToDisk(TimerPlugin plugin)
1238 {
1239     import lu.json : JSONStorage;
1240 
1241     JSONStorage json;
1242 
1243     foreach (immutable channelName, const timers; plugin.timersByChannel)
1244     {
1245         json[channelName] = null;
1246         json[channelName].array = null;
1247 
1248         foreach (const timer; timers)
1249         {
1250             json[channelName].array ~= timer.toJSON();
1251         }
1252     }
1253 
1254     json.save(plugin.timerFile);
1255 }
1256 
1257 
1258 // initResources
1259 /++
1260     Reads and writes the file of timers to disk, ensuring that they're there and
1261     properly formatted.
1262  +/
1263 void initResources(TimerPlugin plugin)
1264 {
1265     import lu.json : JSONStorage;
1266     import std.json : JSONException;
1267 
1268     JSONStorage timersJSON;
1269 
1270     try
1271     {
1272         timersJSON.load(plugin.timerFile);
1273     }
1274     catch (JSONException e)
1275     {
1276         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
1277 
1278         version(PrintStacktraces) logger.trace(e);
1279         throw new IRCPluginInitialisationException(
1280             "Timer file is malformed",
1281             plugin.name,
1282             plugin.timerFile,
1283             __FILE__,
1284             __LINE__);
1285     }
1286 
1287     // Let other Exceptions pass.
1288 
1289     timersJSON.save(plugin.timerFile);
1290 }
1291 
1292 
1293 // reload
1294 /++
1295     Reloads resources from disk.
1296  +/
1297 void reload(TimerPlugin plugin)
1298 {
1299     import lu.json : JSONStorage;
1300 
1301     JSONStorage allTimersJSON;
1302     allTimersJSON.load(plugin.timerFile);
1303 
1304     // Clear timerByChannel and reload from disk
1305     plugin.timersByChannel = null;
1306 
1307     foreach (immutable channelName, const timersJSON; allTimersJSON.object)
1308     {
1309         foreach (const timerJSON; timersJSON.array)
1310         {
1311             auto timer = Timer.fromJSON(timerJSON);
1312             plugin.timersByChannel[channelName][timer.name] = timer;
1313         }
1314     }
1315 
1316     plugin.timersByChannel = plugin.timersByChannel.rehash();
1317 
1318     // Recreate timers from definitions
1319     foreach (immutable channelName, channel; plugin.channels)
1320     {
1321         // Just reuse the SELFJOIN routine, but be sure to force it
1322         // it will destroy the fibers, so we don't have to here
1323         handleSelfjoin(plugin, channelName, Yes.force);
1324     }
1325 }
1326 
1327 
1328 mixin MinimalAuthentication;
1329 mixin PluginRegistration!TimerPlugin;
1330 
1331 version(TwitchSupport)
1332 {
1333     mixin UserAwareness;
1334 }
1335 
1336 public:
1337 
1338 
1339 // TimerPlugin
1340 /++
1341     The Timer plugin serves reoccuring (timered) announcements.
1342  +/
1343 final class TimerPlugin : IRCPlugin
1344 {
1345 private:
1346     import core.time : seconds;
1347 
1348 public:
1349     /++
1350         Contained state of a channel, so that there can be several alongside each other.
1351      +/
1352     static struct Channel
1353     {
1354         /++
1355             Name of the channel.
1356          +/
1357         string channelName;
1358 
1359         /++
1360             Current message count.
1361          +/
1362         ulong messageCount;
1363 
1364         /++
1365             Pointers to [Timer]s in [TimerPlugin.timersByChannel].
1366          +/
1367         Timer*[string] timerPointers;
1368     }
1369 
1370     /++
1371         All Timer plugin settings.
1372      +/
1373     TimerSettings timerSettings;
1374 
1375     /++
1376         Array of active channels' state.
1377      +/
1378     Channel[string] channels;
1379 
1380     /++
1381         Associative array of [Timer]s, keyed by nickname keyed by channel.
1382      +/
1383     Timer[string][string] timersByChannel;
1384 
1385     /++
1386         Filename of file with timer definitions.
1387      +/
1388     @Resource string timerFile = "timers.json";
1389 
1390     /++
1391         How often to check whether timers should fire. A smaller number means
1392         better precision, but also marginally higher gc pressure.
1393      +/
1394     static immutable timerPeriodicity = 10.seconds;
1395 
1396     mixin IRCPluginImpl;
1397 }