1 /++
2     A simple counter plugin.
3 
4     Allows you to define runtime `!word` counters that you can increment,
5     decrement or assign specific values to. This can be used to track deaths in
6     video games, for instance.
7 
8     See_Also:
9         https://github.com/zorael/kameloso/wiki/Current-plugins#counter,
10         [kameloso.plugins.common.core],
11         [kameloso.plugins.common.misc]
12 
13     Copyright: [JR](https://github.com/zorael)
14     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
15 
16     Authors:
17         [JR](https://github.com/zorael)
18  +/
19 module kameloso.plugins.counter;
20 
21 version(WithCounterPlugin):
22 
23 private:
24 
25 import kameloso.plugins;
26 import kameloso.plugins.common.core;
27 import kameloso.plugins.common.awareness : MinimalAuthentication;
28 import kameloso.messaging;
29 import dialect.defs;
30 import std.typecons : Flag, No, Yes;
31 
32 
33 // CounterSettings
34 /++
35     All Counter plugin settings aggregated.
36  +/
37 @Settings struct CounterSettings
38 {
39     /++
40         Whether or not this plugin should react to any events.
41      +/
42     @Enabler bool enabled = true;
43 
44     /++
45         User level required to bump a counter.
46      +/
47     IRCUser.Class minimumPermissionsNeeded = IRCUser.Class.elevated;
48 }
49 
50 
51 // Counter
52 /++
53     Embodiment of a counter. Literally just a number with some ancillary metadata.
54  +/
55 struct Counter
56 {
57 private:
58     import std.json : JSONValue;
59 
60 public:
61     /++
62         Current count.
63      +/
64     long count;
65 
66     /++
67         Counter word.
68      +/
69     string word;
70 
71     /++
72         The pattern to use when formatting answers to counter queries;
73         e.g. "The current $word count is currently $count.".
74 
75         See_Also:
76             [formatMessage]
77      +/
78     string patternQuery = "<b>$word<b> count so far: <b>$count<b>";
79 
80     /++
81         The pattern to use when formatting confirmations of counter increments;
82         e.g. "$word count was increased by +$step and is now $count!".
83 
84         See_Also:
85             [formatMessage]
86      +/
87     string patternIncrement = "<b>$word +$step<b>! Current count: <b>$count<b>";
88 
89     /++
90         The pattern to use when formatting confirmations of counter decrements;
91         e.g. "$word count was decreased by -$step and is now $count!".
92 
93         See_Also:
94             [formatMessage]
95      +/
96     string patternDecrement = "<b>$word -$step<b>! Current count: <b>$count<b>";
97 
98     /++
99         The pattern to use when formatting confirmations of counter assignments;
100         e.g. "$word count was reset to $count!"
101 
102         See_Also:
103             [formatMessage]
104      +/
105     string patternAssign = "<b>$word<b> count assigned to <b>$count<b>!";
106 
107     /++
108         Constructor. Only kept as a compatibility measure to ensure [word] alawys
109         has a value. Remove later.
110      +/
111     this(const string word)
112     {
113         this.word = word;
114     }
115 
116     // toJSON
117     /++
118         Serialises this [Counter] into a JSON representation.
119 
120         Returns:
121             A [std.json.JSONValue|JSONValue] that represents this [Counter].
122      +/
123     auto toJSON() const
124     {
125         JSONValue json;
126         json = null;
127         json.object = null;
128 
129         json["count"] = JSONValue(count);
130         json["word"] = JSONValue(word);
131         json["patternQuery"] = JSONValue(patternQuery);
132         json["patternIncrement"] = JSONValue(patternIncrement);
133         json["patternDecrement"] = JSONValue(patternDecrement);
134         json["patternAssign"] = JSONValue(patternAssign);
135         return json;
136     }
137 
138     // fromJSON
139     /++
140         Deserialises a [Counter] from a JSON representation.
141 
142         Params:
143             json = [std.json.JSONValue|JSONValue] to build a [Counter] from.
144      +/
145     static auto fromJSON(const JSONValue json)
146     {
147         import std.json : JSONException, JSONType;
148 
149         Counter counter;
150 
151         if (json.type == JSONType.integer)
152         {
153             // Old format
154             counter.count = json.integer;
155         }
156         else if (json.type == JSONType.object)
157         {
158             // New format
159             counter.count = json["count"].integer;
160             counter.word = json["word"].str;
161             counter.patternQuery = json["patternQuery"].str;
162             counter.patternIncrement = json["patternIncrement"].str;
163             counter.patternDecrement = json["patternDecrement"].str;
164             counter.patternAssign = json["patternAssign"].str;
165         }
166         else
167         {
168             throw new JSONException("Malformed counter file entry");
169         }
170 
171         return counter;
172     }
173 
174     // resetEmptyPatterns
175     /++
176         Resets empty patterns with their default strings.
177      +/
178     void resetEmptyPatterns()
179     {
180         const Counter counterInit;
181         if (!patternQuery.length) patternQuery = counterInit.patternQuery;
182         if (!patternIncrement.length) patternIncrement = counterInit.patternIncrement;
183         if (!patternDecrement.length) patternDecrement = counterInit.patternDecrement;
184         if (!patternAssign.length) patternAssign = counterInit.patternAssign;
185     }
186 }
187 
188 
189 // onCommandCounter
190 /++
191     Manages runtime counters (adding, removing and listing).
192  +/
193 @(IRCEventHandler()
194     .onEvent(IRCEvent.Type.CHAN)
195     .permissionsRequired(Permissions.operator)
196     .channelPolicy(ChannelPolicy.home)
197     .addCommand(
198         IRCEventHandler.Command()
199             .word("counter")
200             .policy(PrefixPolicy.prefixed)
201             .description("Adds, removes or lists counters.")
202             .addSyntax("$command add [counter word]")
203             .addSyntax("$command del [counter word]")
204             .addSyntax("$command format [counter word] [?+-=] [format pattern]")
205             .addSyntax("$command list")
206     )
207 )
208 void onCommandCounter(CounterPlugin plugin, const /*ref*/ IRCEvent event)
209 {
210     import kameloso.constants : BufferSize;
211     import lu.string : nom, stripped, strippedLeft;
212     import std.algorithm.comparison : among;
213     import std.algorithm.searching : canFind;
214     import std.format : format;
215 
216     void sendUsage()
217     {
218         enum pattern = "Usage: <b>%s%s<b> [add|del|format|list] [counter word]";
219         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
220         chan(plugin.state, event.channel, message);
221     }
222 
223     void sendFormatUsage()
224     {
225         enum pattern = "Usage: <b>%s%s format<b> [counter word] [one of ?, +, - and =] [format pattern]";
226         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
227         chan(plugin.state, event.channel, message);
228     }
229 
230     void sendMustBeUniqueAndMayNotContain()
231     {
232         enum message = "Counter words must be unique and may not contain any of " ~
233             "the following characters: [<b>+-=?<b>]";
234         chan(plugin.state, event.channel, message);
235     }
236 
237     void sendCounterAlreadyExists()
238     {
239         enum message = "A counter with that name already exists.";
240         chan(plugin.state, event.channel, message);
241     }
242 
243     void sendNoSuchCounter()
244     {
245         enum message = "No such counter available.";
246         chan(plugin.state, event.channel, message);
247     }
248 
249     void sendCounterRemoved(const string word)
250     {
251         enum pattern = "Counter <b>%s<b> removed.";
252         immutable message = pattern.format(word);
253         chan(plugin.state, event.channel, message);
254     }
255 
256     void sendNoCountersActive()
257     {
258         enum message = "No counters currently active in this channel.";
259         chan(plugin.state, event.channel, message);
260     }
261 
262     void sendCountersList(const string[] counters)
263     {
264         enum pattern = "Current counters: %s";
265         immutable arrayPattern = "%-(<b>" ~ plugin.state.settings.prefix ~ "%s<b>, %)<b>";
266         immutable list = arrayPattern.format(counters);
267         immutable message = pattern.format(list);
268         chan(plugin.state, event.channel, message);
269     }
270 
271     void sendFormatPatternUpdated()
272     {
273         enum message = "Format pattern updated.";
274         chan(plugin.state, event.channel, message);
275     }
276 
277     void sendFormatPatternCleared()
278     {
279         enum message = "Format pattern cleared.";
280         chan(plugin.state, event.channel, message);
281     }
282 
283     void sendCurrentFormatPattern(const string mod, const string customPattern)
284     {
285         enum pattern = `Current <b>%s<b> format pattern: "<b>%s<b>"`;
286         immutable message = pattern.format(mod, customPattern);
287         chan(plugin.state, event.channel, message);
288     }
289 
290     void sendNoFormatPattern(const string word)
291     {
292         enum pattern = "Counter <b>%s<b> does not have a custom format pattern.";
293         immutable message = pattern.format(word);
294         chan(plugin.state, event.channel, message);
295     }
296 
297     string slice = event.content.stripped;  // mutable
298     immutable verb = slice.nom!(Yes.inherit)(' ');
299     slice = slice.strippedLeft;
300 
301     switch (verb)
302     {
303     case "add":
304         import kameloso.constants : BufferSize;
305         import kameloso.thread : CarryingFiber;
306         import std.typecons : Tuple;
307         import core.thread : Fiber;
308 
309         if (!slice.length) goto default;
310 
311         if (slice.canFind!(c => c.among!('+', '-', '=', '?')))
312         {
313             return sendMustBeUniqueAndMayNotContain();
314         }
315 
316         if ((event.channel in plugin.counters) && (slice in plugin.counters[event.channel]))
317         {
318             return sendCounterAlreadyExists();
319         }
320 
321         /+
322             We need to check both hardcoded and soft channel-specific commands
323             for conflicts.
324          +/
325 
326         bool triggerConflicts(const IRCPlugin.CommandMetadata[string][string] aa)
327         {
328             foreach (immutable pluginName, pluginCommands; aa)
329             {
330                 if (!pluginCommands.length || (pluginName == "counter")) continue;
331 
332                 if (slice in pluginCommands)
333                 {
334                     enum pattern = `Counter word "<b>%s<b>" conflicts with a command of the <b>%s<b> plugin.`;
335                     immutable message = pattern.format(slice, pluginName);
336                     chan(plugin.state, event.channel, message);
337                     return true;
338                 }
339             }
340             return false;
341         }
342 
343         alias Payload = Tuple!(IRCPlugin.CommandMetadata[string][string]);
344 
345         void addCounterDg()
346         {
347             auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
348             assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
349 
350             IRCPlugin.CommandMetadata[string][string] aa = thisFiber.payload[0];
351             if (triggerConflicts(aa)) return;
352 
353             // Get channel AAs
354             plugin.state.specialRequests ~= specialRequest(event.channel, thisFiber);
355             Fiber.yield();
356 
357             IRCPlugin.CommandMetadata[string][string] channelSpecificAA = thisFiber.payload[0];
358             if (triggerConflicts(channelSpecificAA)) return;
359 
360             // If we're here there were no conflicts
361             plugin.counters[event.channel][slice] = Counter(slice);
362             saveCounters(plugin);
363 
364             enum pattern = "Counter <b>%s<b> added! Access it with <b>%s%s<b>.";
365             immutable message = pattern.format(slice, plugin.state.settings.prefix, slice);
366             chan(plugin.state, event.channel, message);
367         }
368 
369         plugin.state.specialRequests ~= specialRequest!Payload(string.init, &addCounterDg);
370         break;
371 
372     case "remove":
373     case "del":
374         if (!slice.length) goto default;
375 
376         auto channelCounters = event.channel in plugin.counters;
377         if (!channelCounters || (slice !in *channelCounters)) return sendNoSuchCounter();
378 
379         (*channelCounters).remove(slice);
380         if (!channelCounters.length) plugin.counters.remove(event.channel);
381         saveCounters(plugin);
382 
383         return sendCounterRemoved(slice);
384 
385     case "format":
386         import lu.string : SplitResults, splitInto;
387         import std.algorithm.comparison : among;
388 
389         string word;
390         string mod;
391         immutable results = slice.splitInto(word, mod);
392 
393         with (SplitResults)
394         final switch (results)
395         {
396         case match:
397             // No pattern given but an empty query is ok
398             break;
399 
400         case overrun:
401             // Pattern given
402             break;
403 
404         case underrun:
405             // Not enough parameters
406             return sendFormatUsage();
407         }
408 
409         if (!mod.among!("?", "+", "-", "="))
410         {
411             return sendFormatUsage();
412         }
413 
414         auto channelCounters = event.channel in plugin.counters;
415         if (!channelCounters) return sendNoSuchCounter();
416 
417         auto counter = word in *channelCounters;
418         if (!counter) return sendNoSuchCounter();
419 
420         alias newPattern = slice;
421 
422         if (newPattern == "-")
423         {
424             if      (mod == "?") counter.patternQuery = string.init;
425             else if (mod == "+") counter.patternIncrement = string.init;
426             else if (mod == "-") counter.patternDecrement = string.init;
427             else if (mod == "=") counter.patternAssign = string.init;
428             else assert(0, "Impossible case");
429 
430             saveCounters(plugin);
431             return sendFormatPatternCleared();
432         }
433         else if (newPattern.length)
434         {
435             if      (mod == "?") counter.patternQuery = newPattern;
436             else if (mod == "+") counter.patternIncrement = newPattern;
437             else if (mod == "-") counter.patternDecrement = newPattern;
438             else if (mod == "=") counter.patternAssign = newPattern;
439             else assert(0, "Impossible case");
440 
441             saveCounters(plugin);
442             return sendFormatPatternUpdated();
443         }
444         else
445         {
446             immutable modverb =
447                 (mod == "?") ? "query" :
448                 (mod == "+") ? "increment" :
449                 (mod == "-") ? "decrement" :
450                 (mod == "=") ? "assign" :
451                     string.init;
452             immutable pattern =
453                 (mod == "?") ? counter.patternQuery :
454                 (mod == "+") ? counter.patternIncrement :
455                 (mod == "-") ? counter.patternDecrement :
456                 (mod == "=") ? counter.patternAssign :
457                     string.init;
458 
459             if (!modverb.length || !pattern.length) assert(0, "Impossible case");
460             return sendCurrentFormatPattern(modverb, pattern);
461         }
462 
463     case "list":
464         if (event.channel !in plugin.counters) return sendNoCountersActive();
465         return sendCountersList(plugin.counters[event.channel].keys);
466 
467     default:
468         return sendUsage();
469     }
470 }
471 
472 
473 // onCounterWord
474 /++
475     Allows users to increment, decrement, and set counters.
476 
477     This function fakes
478     [kameloso.plugins.common.core.IRCEventHandler.Command|IRCEventHandler.Command]s by
479     listening for prefixes (and the bot's nickname), and treating whatever comes
480     after it as a command word. If it doesn't match a previously added counter,
481     it is ignored.
482  +/
483 @(IRCEventHandler()
484     .onEvent(IRCEvent.Type.CHAN)
485     .permissionsRequired(Permissions.anyone)
486     .channelPolicy(ChannelPolicy.home)
487 )
488 void onCounterWord(CounterPlugin plugin, const ref IRCEvent event)
489 {
490     import kameloso.string : stripSeparatedPrefix;
491     import lu.string : beginsWith, stripped, strippedLeft, strippedRight;
492     import std.conv : ConvException, text, to;
493     import std.format : format;
494     import std.meta : aliasSeqOf;
495     import std.string : indexOf;
496 
497     void sendCurrentCount(const Counter counter)
498     {
499         immutable message = formatMessage(
500             plugin,
501             counter.patternQuery,
502             event,
503             counter);
504         chan(plugin.state, event.channel, message);
505     }
506 
507     void sendCounterModified(const Counter counter, const long step)
508     {
509         immutable pattern = (step >= 0) ? counter.patternIncrement : counter.patternDecrement;
510         immutable message = formatMessage(
511             plugin,
512             pattern,
513             event,
514             counter,
515             step);
516         chan(plugin.state, event.channel, message);
517     }
518 
519     void sendCounterAssigned(const Counter counter, const long step)
520     {
521         immutable message = formatMessage(
522             plugin,
523             counter.patternAssign,
524             event,
525             counter,
526             step);
527         chan(plugin.state, event.channel, message);
528     }
529 
530     void sendInputIsNaN(const string input)
531     {
532         enum pattern = "<b>%s<b> is not a number.";
533         immutable message = pattern.format(input);
534         chan(plugin.state, event.channel, message);
535     }
536 
537     void sendMustSpecifyNumber()
538     {
539         enum message = "You must specify a number to set the count to.";
540         chan(plugin.state, event.channel, message);
541     }
542 
543     string slice = event.content.stripped;  // mutable
544     if ((slice.length < (plugin.state.settings.prefix.length+1)) &&  // !w
545         (slice.length < (plugin.state.client.nickname.length+2))) return;  // nickname:w
546 
547     if (slice.beginsWith(plugin.state.settings.prefix))
548     {
549         slice = slice[plugin.state.settings.prefix.length..$];
550     }
551     else if (slice.beginsWith(plugin.state.client.nickname))
552     {
553         slice = slice.stripSeparatedPrefix(plugin.state.client.nickname, Yes.demandSeparatingChars);
554     }
555     else
556     {
557         version(TwitchSupport)
558         {
559             if (plugin.state.client.displayName.length && slice.beginsWith(plugin.state.client.displayName))
560             {
561                 slice = slice.stripSeparatedPrefix(plugin.state.client.displayName, Yes.demandSeparatingChars);
562             }
563             else
564             {
565                 // Just a random message
566                 return;
567             }
568         }
569         else
570         {
571             // As above
572             return;
573         }
574     }
575 
576     if (!slice.length) return;
577 
578     auto channelCounters = event.channel in plugin.counters;
579     if (!channelCounters) return;
580 
581     ptrdiff_t signPos;
582 
583     foreach (immutable sign; aliasSeqOf!"?=+-")  // '-' after '=' to support "!word=-5"
584     {
585         signPos = slice.indexOf(sign);
586         if (signPos != -1) break;
587     }
588 
589     immutable word = (signPos != -1) ? slice[0..signPos].strippedRight : slice;
590 
591     auto counter = word in *channelCounters;
592     if (!counter) return;
593 
594     slice = (signPos != -1) ? slice[signPos..$] : string.init;
595 
596     if (!slice.length || (slice[0] == '?'))
597     {
598         return sendCurrentCount(*counter);
599     }
600 
601     // Limit modifications to the configured class
602     if (event.sender.class_ < plugin.counterSettings.minimumPermissionsNeeded) return;
603 
604     assert(slice.length, "Empty slice after slicing");
605     immutable sign = slice[0];
606 
607     switch (sign)
608     {
609     case '+':
610     case '-':
611         long step;
612 
613         if ((slice == "+") || (slice == "++"))
614         {
615             step = 1;
616         }
617         else if ((slice == "-") || (slice == "--"))
618         {
619             step = -1;
620         }
621         else if (slice.length > 1)
622         {
623             slice = slice[1..$].strippedLeft;
624             step = (sign == '+') ? 1 : -1;  // implicitly (sign == '-')
625 
626             try
627             {
628                 step = slice.to!long * step;
629             }
630             catch (ConvException _)
631             {
632                 return sendInputIsNaN(slice);
633             }
634         }
635 
636         counter.count += step;
637         saveCounters(plugin);
638         return sendCounterModified(*counter, step);
639 
640     case '=':
641         slice = slice[1..$].strippedLeft;
642 
643         if (!slice.length)
644         {
645             return sendMustSpecifyNumber();
646         }
647 
648         long newCount;
649 
650         try
651         {
652             newCount = slice.to!long;
653         }
654         catch (ConvException _)
655         {
656             return sendInputIsNaN(slice);
657         }
658 
659         immutable step = (newCount - counter.count);
660         counter.count = newCount;
661         saveCounters(plugin);
662         return sendCounterAssigned(*counter, step);
663 
664     default:
665         assert(0, "Hit impossible default case in onCounterWord sign switch");
666     }
667 }
668 
669 
670 // onWelcome
671 /++
672     Populate the counters array after we have successfully logged onto the server.
673  +/
674 @(IRCEventHandler()
675     .onEvent(IRCEvent.Type.RPL_WELCOME)
676 )
677 void onWelcome(CounterPlugin plugin)
678 {
679     plugin.reload();
680 }
681 
682 
683 // formatMessage
684 /++
685     Formats a message by a string pattern, replacing select keywords with more
686     helpful values.
687 
688     Example:
689     ---
690     immutable pattern = "The $word count was bumped by +$step to $count!";
691     immutable message = formatMessage(plugin, pattern, event, counter, step);
692     assert(message == "The curse count was bumped by +1 to 92!");
693     ---
694 
695     Params:
696         plugin = The current [CounterPlugin].
697         pattern = The custom string pattern we're formatting.
698         event = The [dialect.defs.IRCEvent|IRCEvent] that triggered the format.
699         counter = The [Counter] that the message relates to.
700         step = By what step the counter was modified, if any.
701 
702     Returns:
703         A new string, with keywords replaced.
704  +/
705 auto formatMessage(
706     CounterPlugin plugin,
707     const string pattern,
708     const ref IRCEvent event,
709     const Counter counter,
710     const long step = long.init)
711 {
712     import kameloso.plugins.common.misc : nameOf;
713     import kameloso.string : replaceRandom;
714     import std.conv : to;
715     import std.array : replace;
716     import std.math : abs;
717 
718     auto signedStep()
719     {
720         import std.conv : text;
721         return (step >= 0) ?
722             text('+', step) :
723             step.to!string;
724     }
725 
726     string toReturn = pattern  // mutable
727         .replace("$step", abs(step).to!string)
728         .replace("$signedstep", signedStep())
729         .replace("$count", counter.count.to!string)
730         .replace("$word", counter.word)
731         .replace("$channel", event.channel)
732         .replace("$senderNickname", event.sender.nickname)
733         .replace("$sender", nameOf(event.sender))
734         .replace("$botNickname", plugin.state.client.nickname)
735         .replace("$bot", nameOf(plugin, plugin.state.client.nickname))
736         .replaceRandom();
737 
738     version(TwitchSupport)
739     {
740         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
741         {
742             toReturn = toReturn
743                 .replace("$streamerNickname", event.channel[1..$])
744                 .replace("$streamer", nameOf(plugin, event.channel[1..$]));
745         }
746     }
747 
748     return toReturn;
749 }
750 
751 
752 // reload
753 /++
754     Reloads counters from disk.
755  +/
756 void reload(CounterPlugin plugin)
757 {
758     return loadCounters(plugin);
759 }
760 
761 
762 // saveCounters
763 /++
764     Saves [Counter]s to disk in JSON format.
765 
766     Params:
767         plugin = The current [CounterPlugin].
768  +/
769 void saveCounters(CounterPlugin plugin)
770 {
771     import lu.json : JSONStorage;
772     import std.json : JSONType;
773 
774     JSONStorage json;
775 
776     foreach (immutable channelName, ref channelCounters; plugin.counters)
777     {
778         json[channelName] = null;
779         json[channelName].object = null;
780 
781         foreach (immutable word, ref counter; channelCounters)
782         {
783             counter.resetEmptyPatterns();
784             json[channelName][word] = counter.toJSON();
785         }
786     }
787 
788     if (json.type == JSONType.null_) json.object = null;  // reset to type object if null_
789     json.save(plugin.countersFile);
790 }
791 
792 
793 // loadCounters
794 /++
795     Loads [Counter]s from disk.
796 
797     Params:
798         plugin = The current [CounterPlugin].
799  +/
800 void loadCounters(CounterPlugin plugin)
801 {
802     import lu.json : JSONStorage;
803 
804     JSONStorage json;
805     json.load(plugin.countersFile);
806     plugin.counters.clear();
807 
808     foreach (immutable channelName, channelCountersJSON; json.object)
809     {
810         // Initialise the AA
811         plugin.counters[channelName][string.init] = Counter.init;
812         auto channelCounters = channelName in plugin.counters;
813         (*channelCounters).remove(string.init);
814 
815         foreach (immutable word, counterJSON; channelCountersJSON.object)
816         {
817             (*channelCounters)[word] = Counter.fromJSON(counterJSON);
818             auto counter = word in *channelCounters;
819 
820             // Backwards compatibility with old counters files
821             if (!counter.word.length)
822             {
823                 counter.word = word;
824             }
825 
826             // ditto
827             counter.resetEmptyPatterns();
828         }
829 
830         (*channelCounters).rehash();
831     }
832 
833     plugin.counters.rehash();
834 }
835 
836 
837 // initResources
838 /++
839     Reads and writes the file of persistent counters to disk, ensuring that it's
840     there and properly formatted.
841  +/
842 void initResources(CounterPlugin plugin)
843 {
844     import lu.json : JSONStorage;
845     import std.json : JSONException;
846 
847     JSONStorage countersJSON;
848 
849     try
850     {
851         countersJSON.load(plugin.countersFile);
852     }
853     catch (JSONException e)
854     {
855         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
856         import kameloso.common : logger;
857 
858         version(PrintStacktraces) logger.trace(e);
859         throw new IRCPluginInitialisationException(
860             "Counters file is malformed",
861             plugin.name,
862             plugin.countersFile,
863             __FILE__,
864             __LINE__);
865     }
866 
867     // Let other Exceptions pass.
868 
869     countersJSON.save(plugin.countersFile);
870 }
871 
872 
873 mixin MinimalAuthentication;
874 mixin PluginRegistration!CounterPlugin;
875 
876 public:
877 
878 
879 // CounterPlugin
880 /++
881     The Counter plugin allows for users to define counter commands at runtime.
882  +/
883 final class CounterPlugin : IRCPlugin
884 {
885 private:
886     /++
887         All Counter plugin settings.
888      +/
889     CounterSettings counterSettings;
890 
891     /++
892         [Counter]s by counter word by channel name.
893      +/
894     Counter[string][string] counters;
895 
896     /++
897         Filename of file with persistent counters.
898      +/
899     @Resource string countersFile = "counters.json";
900 
901     // channelSpecificCommands
902     /++
903         Compile a list of our runtime counter commands.
904 
905         Params:
906             channelName = Name of channel whose commands we want to summarise.
907 
908         Returns:
909             An associative array of
910             [kameloso.plugins.common.core.IRCPlugin.CommandMetadata|IRCPlugin.CommandMetadata]s,
911             one for each counter active in the passed channel.
912      +/
913     override public IRCPlugin.CommandMetadata[string] channelSpecificCommands(const string channelName) @system
914     {
915         IRCPlugin.CommandMetadata[string] aa;
916 
917         const channelCounters = channelName in counters;
918         if (!channelCounters) return aa;
919 
920         foreach (immutable trigger, immutable _; *channelCounters)
921         {
922             IRCPlugin.CommandMetadata metadata;
923             metadata.description = "A counter";
924             aa[trigger] = metadata;
925         }
926 
927         return aa;
928     }
929 
930     mixin IRCPluginImpl;
931 }