1 /++
2     The Quotes plugin allows for saving and replaying user quotes.
3 
4     On Twitch, the commands do not take a nickname parameter; instead
5     the owner of the channel (the broadcaster) is assumed to be the target.
6 
7     See_Also:
8         https://github.com/zorael/kameloso/wiki/Current-plugins#quotes,
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.quotes;
19 
20 version(WithQuotesPlugin):
21 
22 private:
23 
24 import kameloso.plugins;
25 import kameloso.plugins.common.core;
26 import kameloso.plugins.common.awareness : UserAwareness;
27 import kameloso.common : logger;
28 import kameloso.messaging;
29 import dialect.defs;
30 
31 mixin UserAwareness;
32 mixin PluginRegistration!QuotesPlugin;
33 
34 
35 // QuotesSettings
36 /++
37     All settings for a Quotes plugin, gathered in a struct.
38  +/
39 @Settings struct QuotesSettings
40 {
41     /++
42         Whether or not the Quotes plugin should react to events at all.
43      +/
44     @Enabler bool enabled = true;
45 
46     /++
47         Whether or not a random result should be picked in case some quote search
48         terms had multiple matches.
49      +/
50     bool alwaysPickFirstMatch = false;
51 }
52 
53 
54 // Quote
55 /++
56     Embodies the notion of a quote. A string line paired with a UNIX timestamp.
57  +/
58 struct Quote
59 {
60 private:
61     import std.json : JSONValue;
62 
63 public:
64     /++
65         Quote string line.
66      +/
67     string line;
68 
69     /++
70         When the line was uttered, expressed in UNIX time.
71      +/
72     long timestamp;
73 
74     // toJSON
75     /++
76         Serialises this [Quote] into a [std.json.JSONValue|JSONValue].
77 
78         Returns:
79             A [std.json.JSONValue|JSONValue] that describes this quote.
80      +/
81     auto toJSON() const
82     {
83         JSONValue json;
84         json["line"] = JSONValue(this.line);
85         json["timestamp"] = JSONValue(this.timestamp);
86         return json;
87     }
88 
89     // fromJSON
90     /++
91         Deserialises a [Quote] from a [std.json.JSONValue|JSONValue].
92 
93         Params:
94             json = [std.json.JSONValue|JSONValue] to deserialise.
95 
96         Returns:
97             A new [Quote] with values loaded from the passed JSON.
98      +/
99     static auto fromJSON(const JSONValue json)
100     {
101         Quote quote;
102         quote.line = json["line"].str;
103         quote.timestamp = json["timestamp"].integer;
104         return quote;
105     }
106 }
107 
108 
109 // onCommandQuote
110 /++
111     Replies with a quote, either fetched randomly, by search terms or by stored index.
112  +/
113 @(IRCEventHandler()
114     .onEvent(IRCEvent.Type.CHAN)
115     .permissionsRequired(Permissions.anyone)
116     .channelPolicy(ChannelPolicy.home)
117     .addCommand(
118         IRCEventHandler.Command()
119             .word("quote")
120             .policy(PrefixPolicy.prefixed)
121             .description("Repeats a random quote of a supplied nickname, " ~
122                 "or finds one by search terms (best-effort)")
123             .addSyntax("On Twitch: $command")
124             .addSyntax("On Twitch: $command [search terms]")
125             .addSyntax("On Twitch: $command [#index]")
126             .addSyntax("Elsewhere: $command [nickname]")
127             .addSyntax("Elsewhere: $command [nickname] [search terms]")
128             .addSyntax("Elsewhere: $command [nickname] [#index]")
129     )
130 )
131 void onCommandQuote(QuotesPlugin plugin, const ref IRCEvent event)
132 {
133     import dialect.common : isValidNickname;
134     import lu.string : stripped;
135     import std.conv : ConvException;
136     import std.format : format;
137     import std.string : representation;
138 
139     immutable isTwitch = (plugin.state.server.daemon == IRCServer.Daemon.twitch);
140 
141     void sendNonTwitchUsage()
142     {
143         enum pattern = "Usage: <b>%s%s<b> [nickname] [optional search terms or #index]";
144         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
145         chan(plugin.state, event.channel, message);
146     }
147 
148     if (!isTwitch && !event.content.length) return sendNonTwitchUsage();
149 
150     try
151     {
152         if (isTwitch)
153         {
154             immutable nickname = event.channel[1..$];
155             immutable searchTerms = event.content.stripped;
156 
157             const channelQuotes = event.channel in plugin.quotes;
158             if (!channelQuotes)
159             {
160                 return Senders.sendNoQuotesForNickname(plugin, event, nickname);
161             }
162 
163             const quotes = nickname in *channelQuotes;
164             if (!quotes || !quotes.length)
165             {
166                 return Senders.sendNoQuotesForNickname(plugin, event, nickname);
167             }
168 
169             size_t index;  // mutable
170             immutable quote = !searchTerms.length ?
171                 getRandomQuote(*quotes, nickname, index) :
172                 (searchTerms.representation[0] == '#') ?
173                     getQuoteByIndexString(*quotes, searchTerms[1..$], index) :
174                     getQuoteBySearchTerms(plugin, *quotes, searchTerms, index);
175 
176             return sendQuoteToChannel(plugin, quote, event.channel, nickname, index);
177         }
178         else /*if (!isTwitch)*/
179         {
180             import lu.string : SplitResults, splitInto;
181 
182             string slice = event.content.stripped;  // mutable
183             string nickname;  // mutable
184             immutable results = slice.splitInto(nickname);
185 
186             if (results == SplitResults.underrun)
187             {
188                 // Message was just !quote which only works on Twitch
189                 return sendNonTwitchUsage();
190             }
191 
192             if (!nickname.isValidNickname(plugin.state.server))
193             {
194                 return Senders.sendInvalidNickname(plugin, event, nickname);
195             }
196 
197             const channelQuotes = event.channel in plugin.quotes;
198             if (!channelQuotes)
199             {
200                 return Senders.sendNoQuotesForNickname(plugin, event, nickname);
201             }
202 
203             const quotes = nickname in *channelQuotes;
204             if (!quotes || !quotes.length)
205             {
206                 return Senders.sendNoQuotesForNickname(plugin, event, nickname);
207             }
208 
209             with (SplitResults)
210             final switch (results)
211             {
212             case match:
213                 // No search terms
214                 size_t index;  // out reference!
215                 immutable quote = getRandomQuote(*quotes, nickname, index);
216                 return sendQuoteToChannel(plugin, quote, event.channel, nickname, index);
217 
218             case overrun:
219                 // Search terms given
220                 alias searchTerms = slice;
221                 size_t index;  // out reference!
222                 immutable quote = (searchTerms.representation[0] == '#') ?
223                     getQuoteByIndexString(*quotes, searchTerms[1..$], index) :
224                     getQuoteBySearchTerms(plugin, *quotes, searchTerms, index);
225                 return sendQuoteToChannel(plugin, quote, event.channel, nickname, index);
226 
227             case underrun:
228                 // Handled above
229                 assert(0, "Impossible case");
230             }
231         }
232     }
233     catch (NoQuotesFoundException e)
234     {
235         Senders.sendNoQuotesForNickname(plugin, event, e.nickname);
236     }
237     catch (QuoteIndexOutOfRangeException e)
238     {
239         Senders.sendIndexOutOfRange(plugin, event, e.indexGiven, e.upperBound);
240     }
241     catch (NoQuotesSearchMatchException e)
242     {
243         enum pattern = "No quotes found for search terms \"<b>%s<b>\"";
244         immutable message = pattern.format(e.searchTerms);
245         chan(plugin.state, event.channel, message);
246     }
247     catch (ConvException _)
248     {
249         Senders.sendIndexMustBePositiveNumber(plugin, event);
250     }
251 }
252 
253 
254 // onCommandAddQuote
255 /++
256     Adds a quote to the local storage.
257  +/
258 @(IRCEventHandler()
259     .onEvent(IRCEvent.Type.CHAN)
260     .permissionsRequired(Permissions.elevated)
261     .channelPolicy(ChannelPolicy.home)
262     .addCommand(
263         IRCEventHandler.Command()
264             .word("addquote")
265             .policy(PrefixPolicy.prefixed)
266             .description("Adds a new quote.")
267             .addSyntax("On Twitch: $command [new quote]")
268             .addSyntax("Elsewhere: $command [nickname] [new quote]")
269     )
270 )
271 void onCommandAddQuote(QuotesPlugin plugin, const ref IRCEvent event)
272 {
273     import lu.string : stripped, strippedRight, unquoted;
274     import std.format : format;
275     import std.datetime.systime : Clock;
276 
277     immutable isTwitch = (plugin.state.server.daemon == IRCServer.Daemon.twitch);
278 
279     void sendUsage()
280     {
281         immutable pattern = isTwitch ?
282             "Usage: %s%s [new quote]" :
283             "Usage: <b>%s%s<b> [nickname] [new quote]";
284         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
285         chan(plugin.state, event.channel, message);
286     }
287 
288     string nickname;  // mutable
289     string slice = event.content.stripped;  // mutable
290 
291     if (isTwitch)
292     {
293         if (!slice.length) return sendUsage();
294         nickname = event.channel[1..$];
295         // Drop down to create the Quote
296     }
297     else /*if (!isTwitch)*/
298     {
299         import dialect.common : isValidNickname;
300         import lu.string : SplitResults, splitInto;
301 
302         immutable results = slice.splitInto(nickname);
303 
304         with (SplitResults)
305         final switch (results)
306         {
307         case overrun:
308             // Nickname plus new quote given
309             // Drop down to create the Quote
310             break;
311 
312         case match:
313         case underrun:
314             // match: Only nickname given which only works on Twitch
315             // underrun: Message was just !addquote
316             return sendUsage();
317         }
318 
319         if (!nickname.isValidNickname(plugin.state.server))
320         {
321             return Senders.sendInvalidNickname(plugin, event, nickname);
322         }
323     }
324 
325     immutable prefixSigns = cast(string)plugin.state.server.prefixchars.keys;
326     immutable altered = removeWeeChatHead(slice.unquoted, nickname, prefixSigns).unquoted;
327     immutable line = altered.length ? altered : slice;
328 
329     Quote quote;
330     quote.line = line.strippedRight;
331     quote.timestamp = Clock.currTime.toUnixTime();
332 
333     plugin.quotes[event.channel][nickname] ~= quote;
334     immutable pos = plugin.quotes[event.channel][nickname].length+(-1);
335     saveQuotes(plugin);
336 
337     enum pattern = "Quote added at index <b>#%d<b>.";
338     immutable message = pattern.format(pos);
339     chan(plugin.state, event.channel, message);
340 }
341 
342 
343 // onCommandModQuote
344 /++
345     Modifies a quote given its index in the storage.
346  +/
347 @(IRCEventHandler()
348     .onEvent(IRCEvent.Type.CHAN)
349     .permissionsRequired(Permissions.operator)
350     .channelPolicy(ChannelPolicy.home)
351     .addCommand(
352         IRCEventHandler.Command()
353             .word("modquote")
354             .policy(PrefixPolicy.prefixed)
355             .description("Modifies an existing quote.")
356             .addSyntax("On Twitch: $command [index] [new quote text]")
357             .addSyntax("Elsewhere: $command [nickname] [index] [new quote text]")
358     )
359 )
360 void onCommandModQuote(QuotesPlugin plugin, const ref IRCEvent event)
361 {
362     import lu.string : SplitResults, splitInto, stripped, strippedRight, unquoted;
363     import std.conv : ConvException, to;
364     import std.format : format;
365 
366     immutable isTwitch = (plugin.state.server.daemon == IRCServer.Daemon.twitch);
367 
368     void sendUsage()
369     {
370         immutable pattern = isTwitch ?
371             "Usage: %s%s [index] [new quote text]" :
372             "Usage: <b>%s%s<b> [nickname] [index] [new quote text]";
373         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
374         chan(plugin.state, event.channel, message);
375     }
376 
377     string slice = event.content.stripped;  // mutable
378     string nickname;  // mutable
379     string indexString;  // mutable
380     ptrdiff_t index;  // mutable
381 
382     if (isTwitch)
383     {
384         nickname = event.channel[1..$];
385         immutable results = slice.splitInto(indexString);
386 
387         with (SplitResults)
388         final switch (results)
389         {
390         case overrun:
391             // Index and new quote line was given, drop down
392             break;
393 
394         case match:
395         case underrun:
396             // match: Only an index was given
397             // underrun: Message was just !addquote
398             return sendUsage();
399         }
400     }
401     else /*if (!isTwitch)*/
402     {
403         immutable results = slice.splitInto(nickname, indexString);
404 
405         with (SplitResults)
406         final switch (results)
407         {
408         case overrun:
409             // Index and new quote line was given, drop down
410             break;
411 
412         case match:
413         case underrun:
414             // match: Only an index was given
415             // underrun: Message was just !addquote
416             return sendUsage();
417         }
418     }
419 
420     try
421     {
422         import lu.string : beginsWith;
423         if (indexString.beginsWith('#')) indexString = indexString[1..$];
424         index = indexString.to!ptrdiff_t;
425     }
426     catch (ConvException _)
427     {
428         return Senders.sendIndexMustBePositiveNumber(plugin, event);
429     }
430 
431     if ((event.channel !in plugin.quotes) ||
432         (nickname !in plugin.quotes[event.channel]))
433     {
434         // If there are no prior quotes, allocate an array so we can test the length below
435         plugin.quotes[event.channel][nickname] = [];
436     }
437 
438     auto quotes = nickname in plugin.quotes[event.channel];
439 
440     if (!quotes.length)
441     {
442         return Senders.sendNoQuotesForNickname(plugin, event, nickname);
443     }
444     else if ((index < 0) || (index >= quotes.length))
445     {
446         return Senders.sendIndexOutOfRange(plugin, event, index, quotes.length);
447     }
448 
449     immutable prefixSigns = cast(string)plugin.state.server.prefixchars.keys;
450     immutable altered = removeWeeChatHead(slice.unquoted, nickname, prefixSigns).unquoted;
451     immutable line = altered.length ? altered : slice;
452 
453     (*quotes)[index].line = line.strippedRight;
454     saveQuotes(plugin);
455 
456     enum message = "Quote modified.";
457     chan(plugin.state, event.channel, message);
458 }
459 
460 
461 // onCommandMergeQuotes
462 /++
463     Merges all quotes of one user to that of another.
464  +/
465 @(IRCEventHandler()
466     .onEvent(IRCEvent.Type.CHAN)
467     .permissionsRequired(Permissions.operator)
468     .channelPolicy(ChannelPolicy.home)
469     .addCommand(
470         IRCEventHandler.Command()
471             .word("mergequotes")
472             .policy(PrefixPolicy.prefixed)
473             .description("Merges the quotes of two users.")
474             .addSyntax("$command [source nickname] [target nickname]")
475     )
476 )
477 void onCommandMergeQuotes(QuotesPlugin plugin, const ref IRCEvent event)
478 {
479     import dialect.common : isValidNickname;
480     import lu.string : SplitResults, plurality, splitInto, stripped;
481     import std.format : format;
482 
483     version(TwitchSupport)
484     {
485         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
486         {
487             enum message = "You cannot merge quotes on Twitch.";
488             return chan(plugin.state, event.channel, message);
489         }
490     }
491 
492     void sendUsage()
493     {
494         enum pattern = "Usage: <b>%s%s<b> [source nickname] [target nickname]";
495         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
496         chan(plugin.state, event.channel, message);
497     }
498 
499     string slice = event.content.stripped;  // mutable
500     string source;  // mutable
501     string target;  // mutable
502 
503     immutable results = slice.splitInto(source, target);
504     if (results != SplitResults.match) return sendUsage();
505 
506     if (!target.isValidNickname(plugin.state.server))
507     {
508         return Senders.sendInvalidNickname(plugin, event, target);
509     }
510 
511     const channelQuotes = event.channel in plugin.quotes;
512     if (!channelQuotes)
513     {
514         return Senders.sendNoQuotesForNickname(plugin, event, source);
515     }
516 
517     const quotes = source in *channelQuotes;
518     if (!quotes || !quotes.length)
519     {
520         return Senders.sendNoQuotesForNickname(plugin, event, source);
521     }
522 
523     plugin.quotes[event.channel][target] ~= *quotes;
524 
525     enum pattern = "<b>%d<b> %s merged.";
526     immutable message = pattern.format(
527         quotes.length,
528         quotes.length.plurality("quote", "quotes"));
529     chan(plugin.state, event.channel, message);
530 
531     plugin.quotes[event.channel].remove(source);
532     saveQuotes(plugin);
533 }
534 
535 
536 // onCommandDelQuote
537 /++
538     Deletes a quote, given its index in the storage.
539  +/
540 @(IRCEventHandler()
541     .onEvent(IRCEvent.Type.CHAN)
542     .permissionsRequired(Permissions.operator)
543     .channelPolicy(ChannelPolicy.home)
544     .addCommand(
545         IRCEventHandler.Command()
546             .word("delquote")
547             .policy(PrefixPolicy.prefixed)
548             .description("Deletes a quote.")
549             .addSyntax("On Twitch: $command [index]")
550             .addSyntax("Elsewhere: $command [nickname] [index]")
551     )
552 )
553 void onCommandDelQuote(QuotesPlugin plugin, const ref IRCEvent event)
554 {
555     import lu.string : SplitResults, splitInto, stripped;
556     import std.format : format;
557 
558     immutable isTwitch = (plugin.state.server.daemon == IRCServer.Daemon.twitch);
559 
560     void sendUsage()
561     {
562         immutable pattern = isTwitch ?
563             "Usage: %s%s [index]" :
564             "Usage: <b>%s%s<b> [nickname] [index]";
565         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
566         chan(plugin.state, event.channel, message);
567     }
568 
569     string nickname;  // mutable
570     string indexString;  // mutable
571 
572     if (isTwitch)
573     {
574         if (!event.content.length) return sendUsage();
575 
576         nickname = event.channel[1..$];
577         indexString = event.content.stripped;
578     }
579     else /*if (!isTwitch)*/
580     {
581         string slice = event.content.stripped;  // mutable
582 
583         immutable results = slice.splitInto(nickname, indexString);
584         if (results != SplitResults.match) return sendUsage();
585     }
586 
587     auto channelQuotes = event.channel in plugin.quotes;  // mutable
588     if (!channelQuotes)
589     {
590         return Senders.sendNoQuotesForNickname(plugin, event, nickname);
591     }
592 
593     if (indexString == "*")
594     {
595         (*channelQuotes).remove(nickname);
596 
597         enum pattern = "All quotes for <h>%s<h> removed.";
598         immutable message = pattern.format(nickname);
599         chan(plugin.state, event.channel, message);
600         // Drop down
601     }
602     else
603     {
604         import std.algorithm.mutation : SwapStrategy, remove;
605         import std.conv : ConvException, to;
606 
607         auto quotes = nickname in *channelQuotes;  // mutable
608         if (!quotes || !quotes.length)
609         {
610             return Senders.sendNoQuotesForNickname(plugin, event, nickname);
611         }
612 
613         ptrdiff_t index;
614 
615         try
616         {
617             import lu.string : beginsWith;
618             if (indexString.beginsWith('#')) indexString = indexString[1..$];
619             index = indexString.to!ptrdiff_t;
620         }
621         catch (ConvException _)
622         {
623             return Senders.sendIndexMustBePositiveNumber(plugin, event);
624         }
625 
626         if ((index < 0) || (index >= quotes.length))
627         {
628             return Senders.sendIndexOutOfRange(plugin, event, index, quotes.length);
629         }
630 
631         *quotes = (*quotes).remove!(SwapStrategy.stable)(index);
632 
633         enum message = "Quote removed, indexes updated.";
634         chan(plugin.state, event.channel, message);
635         // Drop down
636     }
637 
638     saveQuotes(plugin);
639 }
640 
641 
642 // sendQuoteToChannel
643 /++
644     Sends a [Quote] to a channel.
645 
646     Params:
647         plugin = The current [QuotesPlugin].
648         quote = The [Quote] to report.
649         channelName = Name of the channel to send to.
650         nickname = Nickname whose quote it is.
651         index = Index of the quote in the local storage.
652  +/
653 void sendQuoteToChannel(
654     QuotesPlugin plugin,
655     const Quote quote,
656     const string channelName,
657     const string nickname,
658     const size_t index)
659 {
660     import std.datetime.systime : SysTime;
661     import std.format : format;
662 
663     string possibleDisplayName = nickname;  // mutable
664 
665     version(TwitchSupport)
666     {
667         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
668         {
669             import kameloso.plugins.common.misc : nameOf;
670             possibleDisplayName = nameOf(plugin, nickname);
671         }
672     }
673 
674     const when = SysTime.fromUnixTime(quote.timestamp);
675     enum pattern = "%s (<h>%s<h> #%d %02d-%02d-%02d)";
676     immutable message = pattern.format(
677         quote.line,
678         possibleDisplayName,
679         index,
680         when.year,
681         when.month,
682         when.day);
683     chan(plugin.state, channelName, message);
684 }
685 
686 
687 // onWelcome
688 /++
689     Initialises the passed [QuotesPlugin]. Loads the quotes from disk.
690  +/
691 @(IRCEventHandler()
692     .onEvent(IRCEvent.Type.RPL_WELCOME)
693 )
694 void onWelcome(QuotesPlugin plugin)
695 {
696     plugin.reload();
697 }
698 
699 
700 // Senders
701 /++
702     Functions that send common brief snippets of text to the server.
703  +/
704 struct Senders
705 {
706 private:
707     import std.format : format;
708 
709     // sendIndexOutOfRange
710     /++
711         Called when a supplied quote index was out of range.
712 
713         Params:
714             plugin = The current [QuotesPlugin].
715             event = The original triggering [dialect.defs.IRCEvent|IRCEvent].
716             indexGiven = The index given by the triggering user.
717             upperBound = The actual upper bounds that `indexGiven` failed to fall within.
718      +/
719     static void sendIndexOutOfRange(
720         QuotesPlugin plugin,
721         const ref IRCEvent event,
722         const ptrdiff_t indexGiven,
723         const size_t upperBound)
724     {
725         enum pattern = "Index <b>#%d<b> out of range; valid is <b>[0..%d]<b> (inclusive).";
726         immutable message = pattern.format(indexGiven, upperBound-1);
727         chan(plugin.state, event.channel, message);
728     }
729 
730     // sendInvalidNickname
731     /++
732         Called when a passed nickname contained invalid characters (or similar).
733 
734         Params:
735             plugin = The current [QuotesPlugin].
736             event = The original triggering [dialect.defs.IRCEvent|IRCEvent].
737             nickname = The would-be nickname given by the triggering user.
738      +/
739     static void sendInvalidNickname(
740         QuotesPlugin plugin,
741         const ref IRCEvent event,
742         const string nickname)
743     {
744         enum pattern = "Invalid nickname: <h>%s<h>";
745         immutable message = pattern.format(nickname);
746         chan(plugin.state, event.channel, message);
747     }
748 
749     // sendNoQuotesForNickname
750     /++
751         Called when there were no quotes to be found for a given nickname.
752 
753         Params:
754             plugin = The current [QuotesPlugin].
755             event = The original triggering [dialect.defs.IRCEvent|IRCEvent].
756             nickname = The nickname given by the triggering user.
757      +/
758     static void sendNoQuotesForNickname(
759         QuotesPlugin plugin,
760         const ref IRCEvent event,
761         const string nickname)
762     {
763         string possibleDisplayName = nickname;  // mutable
764 
765         version(TwitchSupport)
766         {
767             if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
768             {
769                 import kameloso.plugins.common.misc : nameOf;
770                 possibleDisplayName = nameOf(plugin, nickname);
771             }
772         }
773 
774         enum pattern = "No quotes on record for <h>%s<h>!";
775         immutable message = pattern.format(possibleDisplayName);
776         chan(plugin.state, event.channel, message);
777     }
778 
779     // sendIndexMustBePositiveNumber
780     /++
781         Called when a non-integer or negative integer was given as index.
782 
783         Params:
784             plugin = The current [QuotesPlugin].
785             event = The original triggering [dialect.defs.IRCEvent|IRCEvent].
786      +/
787     static void sendIndexMustBePositiveNumber(
788         QuotesPlugin plugin,
789         const ref IRCEvent event)
790     {
791         enum message = "Index must be a positive number.";
792         chan(plugin.state, event.channel, message);
793     }
794 }
795 
796 
797 // getRandomQuote
798 /++
799     Fethes a random [Quote] from an array of such.
800 
801     Params:
802         quotes = Array of [Quote]s to get a random one from.
803         nickname = The nickname whose quotes the array contains.
804         index = `out` reference index of the quote selected, in the local storage.
805 
806     Returns:
807         A [Quote], randomly selected.
808  +/
809 auto getRandomQuote(
810     const Quote[] quotes,
811     const string nickname,
812     out size_t index)
813 {
814     import std.random : uniform;
815 
816     if (!quotes.length)
817     {
818         throw new NoQuotesFoundException(
819             "No quotes found",
820             nickname,
821             __FILE__,
822             __LINE__);
823     }
824 
825     index = uniform(0, quotes.length);
826     return quotes[index];
827 }
828 
829 
830 // getQuoteByIndexString
831 /++
832     Fetches a quote given an index.
833 
834     Params:
835         quotes = Array of [Quote]s to get a random one from.
836         indexStringWithPotentialHash = The index of the [Quote] to fetch,
837             as a string, potentially with a leading octothorpe.
838         index = `out` reference index of the quote selected, in the local storage.
839 
840     Returns:
841         A [Quote], selected based on its index in the internal storage.
842  +/
843 auto getQuoteByIndexString(
844     const Quote[] quotes,
845     const string indexStringWithPotentialHash,
846     out size_t index)
847 {
848     import lu.string : beginsWith;
849     import std.conv : to;
850     import std.random : uniform;
851 
852     immutable indexString = indexStringWithPotentialHash.beginsWith('#') ?
853         indexStringWithPotentialHash[1..$] :
854         indexStringWithPotentialHash;
855     index = indexString.to!size_t;
856 
857     if (index >= quotes.length)
858     {
859         throw new QuoteIndexOutOfRangeException(
860             "Quote index out of range",
861             index,
862             quotes.length,
863             __FILE__,
864             __LINE__);
865     }
866 
867     return quotes[index];
868 }
869 
870 
871 // getQuoteBySearchTerms
872 /++
873     Fetches a [Quote] whose line matches the passed search terms.
874 
875     Params:
876         plugin = The current [QuotesPlugin].
877         quotes = Array of [Quote]s to get a specific one from based on search terms.
878         searchTermsCased = Search terms to apply to the `quotes` array, with letters
879             in original casing.
880         index = `out` reference index of the quote selected, in the local storage.
881 
882     Returns:
883         A [Quote] whose line matches the passed search terms.
884  +/
885 Quote getQuoteBySearchTerms(
886     QuotesPlugin plugin,
887     const Quote[] quotes,
888     const string searchTermsCased,
889     out size_t index)
890 {
891     import lu.string : contains;
892     import std.random : uniform;
893     import std.uni : toLower;
894 
895     auto stripPunctuation(const string inputString)
896     {
897         import std.array : replace;
898 
899         return inputString
900             .replace(".", " ")
901             .replace("!", " ")
902             .replace("?", " ")
903             .replace(",", " ")
904             .replace("-", " ")
905             .replace("_", " ")
906             .replace(`"`, " ")
907             .replace("/", " ")
908             .replace(";", " ")
909             .replace("~", " ")
910             .replace(":", " ")
911             .replace("<", " ")
912             .replace(">", " ")
913             .replace("|", " ")
914             .replace("'", string.init);
915     }
916 
917     auto stripDoubleSpaces(const string inputString)
918     {
919         string output = inputString;  // mutable
920 
921         bool hasDoubleSpace = output.contains("  ");  // mutable
922 
923         while (hasDoubleSpace)
924         {
925             import std.array : replace;
926             output = output.replace("  ", " ");
927             hasDoubleSpace = output.contains("  ");
928         }
929 
930         return output;
931     }
932 
933     auto stripBoth(const string inputString)
934     {
935         return stripDoubleSpaces(stripPunctuation(inputString));
936     }
937 
938     static struct SearchHit
939     {
940         size_t index;
941         string line;
942     }
943 
944     SearchHit[] searchHits;
945 
946     // Try with the search terms that were given first (lowercased)
947     string[] flattenedQuotes;  // mutable
948 
949     foreach (immutable quote; quotes)
950     {
951         flattenedQuotes ~= stripDoubleSpaces(quote.line).toLower;
952     }
953 
954     immutable searchTerms = stripDoubleSpaces(searchTermsCased).toLower;
955 
956     foreach (immutable i, immutable flattenedQuote; flattenedQuotes)
957     {
958         if (!flattenedQuote.contains(searchTerms)) continue;
959 
960         if (plugin.quotesSettings.alwaysPickFirstMatch)
961         {
962             index = i;
963             return quotes[index];
964         }
965         else
966         {
967             searchHits ~= SearchHit(i, quotes[i].line);
968         }
969     }
970 
971     if (searchHits.length)
972     {
973         immutable randomHitsIndex = uniform(0, searchHits.length);
974         index = searchHits[randomHitsIndex].index;
975         return quotes[index];
976     }
977 
978     // Nothing was found; simplify and try again.
979     immutable strippedSearchTerms = stripBoth(searchTerms);
980     searchHits = null;
981 
982     foreach (immutable i, immutable flattenedQuote; flattenedQuotes)
983     {
984         if (!stripBoth(flattenedQuote).contains(strippedSearchTerms)) continue;
985 
986         if (plugin.quotesSettings.alwaysPickFirstMatch)
987         {
988             index = i;
989             return quotes[index];
990         }
991         else
992         {
993             searchHits ~= SearchHit(i, quotes[i].line);
994         }
995     }
996 
997     if (searchHits.length)
998     {
999         immutable randomHitsIndex = uniform(0, searchHits.length);
1000         index = searchHits[randomHitsIndex].index;
1001         return quotes[index];
1002     }
1003     else
1004     {
1005         throw new NoQuotesSearchMatchException(
1006             "No quotes found for given search terms",
1007             searchTermsCased);
1008     }
1009 }
1010 
1011 
1012 // removeWeeChatHead
1013 /++
1014     Removes the WeeChat timestamp and nickname from the front of a string.
1015 
1016     Params:
1017         line = Full string line as copy/pasted from WeeChat.
1018         nickname = The nickname to remove (along with the timestamp).
1019         prefixes = The available user prefixes on the current server.
1020 
1021     Returns:
1022         The original line with the WeeChat timestamp and nickname sliced away,
1023         or as it was passed. No new string is ever allocated.
1024  +/
1025 auto removeWeeChatHead(
1026     const string line,
1027     const string nickname,
1028     const string prefixes) pure @safe
1029 in (nickname.length, "Tried to remove WeeChat head for a nickname but the nickname was empty")
1030 {
1031     import lu.string : beginsWith, contains, nom, strippedLeft;
1032 
1033     static bool isN(const char c)
1034     {
1035         return ((c >= '0') && (c <= '9'));
1036     }
1037 
1038     string slice = line.strippedLeft;  // mutable
1039 
1040     // See if it has WeeChat timestamps at the front of the message
1041     // e.g. "12:34:56   @zorael | text text text"
1042 
1043     if (slice.length > 8)
1044     {
1045         if (isN(slice[0]) && isN(slice[1]) && (slice[2] == ':') &&
1046             isN(slice[3]) && isN(slice[4]) && (slice[5] == ':') &&
1047             isN(slice[6]) && isN(slice[7]) && (slice[8] == ' '))
1048         {
1049             // Might yet be WeeChat, keep going
1050             slice = slice[9..$].strippedLeft;
1051         }
1052     }
1053 
1054     // See if it has WeeChat nickname at the front of the message
1055     // e.g. "@zorael | text text text"
1056 
1057     if (slice.length > nickname.length)
1058     {
1059         if ((prefixes.contains(slice[0]) &&
1060             slice[1..$].beginsWith(nickname)) ||
1061             slice.beginsWith(nickname))
1062         {
1063             slice.nom(nickname);
1064             slice = slice.strippedLeft;
1065 
1066             if ((slice.length > 2) && (slice[0] == '|'))
1067             {
1068                 slice = slice[1..$];
1069 
1070                 if (slice[0] == ' ')
1071                 {
1072                     slice = slice.strippedLeft;
1073                     // Finished
1074                 }
1075                 else
1076                 {
1077                     // Does not match pattern; undo
1078                     slice = line;
1079                 }
1080             }
1081             else
1082             {
1083                 // Does not match pattern; undo
1084                 slice = line;
1085             }
1086         }
1087         else
1088         {
1089             // Does not match pattern; undo
1090             slice = line;
1091         }
1092     }
1093     else
1094     {
1095         // Only matches the timestmp so don't trust it
1096         slice = line;
1097     }
1098 
1099     return slice;
1100 }
1101 
1102 ///
1103 unittest
1104 {
1105     immutable prefixes = "!~&@%+";
1106 
1107     {
1108         enum line = "20:08:27 @zorael | dresing";
1109         immutable modified = removeWeeChatHead(line, "zorael", prefixes);
1110         assert((modified == "dresing"), modified);
1111     }
1112     {
1113         enum line = "               20:08:27                   @zorael | dresing";
1114         immutable modified = removeWeeChatHead(line, "zorael", prefixes);
1115         assert((modified == "dresing"), modified);
1116     }
1117     {
1118         enum line = "+zorael | dresing";
1119         immutable modified = removeWeeChatHead(line, "zorael", prefixes);
1120         assert((modified == "dresing"), modified);
1121     }
1122     {
1123         enum line = "2y:08:27 @zorael | dresing";
1124         immutable modified = removeWeeChatHead(line, "zorael", prefixes);
1125         assert((modified == line), modified);
1126     }
1127     {
1128         enum line = "16:08:27       <-- | kameloso (~kameloso@2001:41d0:2:80b4::) " ~
1129             "has quit (Remote host closed the connection)";
1130         immutable modified = removeWeeChatHead(line, "kameloso", prefixes);
1131         assert((modified == line), modified);
1132     }
1133 }
1134 
1135 
1136 // loadQuotes
1137 /++
1138     Loads quotes from disk into an associative array of [Quote]s.
1139  +/
1140 auto loadQuotes(const string quotesFile)
1141 {
1142     import lu.json : JSONStorage;
1143     import std.json : JSONException;
1144 
1145     JSONStorage json;
1146     Quote[][string][string] quotes;
1147 
1148     // No need to try-catch loading the JSON; trust in initResources
1149     json.load(quotesFile);
1150 
1151     foreach (immutable channelName, channelQuotes; json.object)
1152     {
1153         foreach (immutable nickname, nicknameQuotesJSON; channelQuotes.object)
1154         {
1155             foreach (quoteJSON; nicknameQuotesJSON.array)
1156             {
1157                 quotes[channelName][nickname] ~= Quote.fromJSON(quoteJSON);
1158             }
1159         }
1160     }
1161 
1162     foreach (ref channelQuotes; quotes)
1163     {
1164         channelQuotes = channelQuotes.rehash();
1165     }
1166 
1167     return quotes.rehash();
1168 }
1169 
1170 
1171 // saveQuotes
1172 /++
1173     Saves quotes to disk in JSON file format.
1174  +/
1175 void saveQuotes(QuotesPlugin plugin)
1176 {
1177     import lu.json : JSONStorage;
1178 
1179     JSONStorage json;
1180     json.reset();
1181     json.object = null;
1182 
1183     foreach (immutable channelName, channelQuotes; plugin.quotes)
1184     {
1185         json[channelName] = null;
1186         json[channelName].object = null;
1187         //auto channelQuotesJSON = channelName in json;  // mutable
1188 
1189         foreach (immutable nickname, quotes; channelQuotes)
1190         {
1191             //(*channelQuotesJSON)[nickname] = null;
1192             //(*channelQuotesJSON)[nickname].array = null;
1193             //auto quotesJSON = nickname in *channelQuotesJSON;  // mutable
1194 
1195             json[channelName][nickname] = null;
1196             json[channelName][nickname].array = null;
1197 
1198             foreach (quote; quotes)
1199             {
1200                 //quotesJSON.array ~= quote.toJSON();
1201                 json[channelName][nickname].array ~= quote.toJSON();
1202             }
1203         }
1204     }
1205 
1206     json.save(plugin.quotesFile);
1207 }
1208 
1209 
1210 // NoQuotesFoundException
1211 /++
1212     Exception, to be thrown when there were no quotes found for a given user.
1213  +/
1214 final class NoQuotesFoundException : Exception
1215 {
1216     /// Nickname whose quotes could not be found.
1217     string nickname;
1218 
1219     /++
1220         Constructor taking an extra nickname string.
1221      +/
1222     this(
1223         const string message,
1224         const string nickname,
1225         const string file = __FILE__,
1226         const size_t line = __LINE__,
1227         Throwable nextInChain = null) pure nothrow @nogc @safe
1228     {
1229         this.nickname = nickname;
1230         super(message, file, line, nextInChain);
1231     }
1232 
1233     /++
1234         Constructor.
1235      +/
1236     this(
1237         const string message,
1238         const string file = __FILE__,
1239         const size_t line = __LINE__,
1240         Throwable nextInChain = null) pure nothrow @nogc @safe
1241     {
1242         super(message, file, line, nextInChain);
1243     }
1244 }
1245 
1246 
1247 // QuoteIndexOutOfRangeException
1248 /++
1249     Exception, to be thrown when a given quote index was out of bounds.
1250  +/
1251 final class QuoteIndexOutOfRangeException : Exception
1252 {
1253     /// Given index (that ended up being out of range).
1254     ptrdiff_t indexGiven;
1255 
1256     /// Acutal upper bound.
1257     size_t upperBound;
1258 
1259     /++
1260         Creates a new [QuoteIndexOutOfRangeException], attaching a given index
1261         and an index upper bound.
1262      +/
1263     this(
1264         const string message,
1265         const ptrdiff_t indexGiven,
1266         const size_t upperBound,
1267         const string file = __FILE__,
1268         const size_t line = __LINE__,
1269         Throwable nextInChain = null) pure nothrow @nogc @safe
1270     {
1271         this.indexGiven = indexGiven;
1272         this.upperBound = upperBound;
1273         super(message, file, line, nextInChain);
1274     }
1275 
1276     /++
1277         Constructor.
1278      +/
1279     this(
1280         const string message,
1281         const string file = __FILE__,
1282         const size_t line = __LINE__,
1283         Throwable nextInChain = null) pure nothrow @nogc @safe
1284     {
1285         super(message, file, line, nextInChain);
1286     }
1287 }
1288 
1289 
1290 // NoQuotesSearchMatchException
1291 /++
1292     Exception, to be thrown when given search terms failed to match any stored quotes.
1293  +/
1294 final class NoQuotesSearchMatchException : Exception
1295 {
1296     /// Given search terms string.
1297     string searchTerms;
1298 
1299     /++
1300         Creates a new [NoQuotesSearchMatchException], attaching a search terms string.
1301      +/
1302     this(
1303         const string message,
1304         const string searchTerms,
1305         const string file = __FILE__,
1306         const size_t line = __LINE__,
1307         Throwable nextInChain = null) pure nothrow @nogc @safe
1308     {
1309         this.searchTerms = searchTerms;
1310         super(message, file, line, nextInChain);
1311     }
1312 }
1313 
1314 
1315 // initResources
1316 /++
1317     Reads and writes the file of quotes to disk, ensuring that it's there.
1318  +/
1319 void initResources(QuotesPlugin plugin)
1320 {
1321     import lu.json : JSONStorage;
1322     import lu.string : beginsWith;
1323     import std.json : JSONException;
1324 
1325     enum placeholderChannel = "#<lost+found>";
1326 
1327     JSONStorage json;
1328     bool dirty;
1329 
1330     try
1331     {
1332         json.load(plugin.quotesFile);
1333 
1334         // Convert legacy quotes to new ones
1335         JSONStorage scratchJSON;
1336 
1337         foreach (immutable key, firstLevel; json.object)
1338         {
1339             if (key.beginsWith('#')) continue;
1340 
1341             scratchJSON[placeholderChannel] = null;
1342             scratchJSON[placeholderChannel].object = null;
1343             scratchJSON[placeholderChannel][key] = firstLevel;
1344             dirty = true;
1345         }
1346 
1347         if (dirty)
1348         {
1349             foreach (immutable key, firstLevel; json.object)
1350             {
1351                 if (!key.beginsWith('#')) continue;
1352                 scratchJSON[key] = firstLevel;
1353             }
1354 
1355             json = scratchJSON;
1356         }
1357     }
1358     catch (JSONException e)
1359     {
1360         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
1361 
1362         version(PrintStacktraces) logger.trace(e);
1363         throw new IRCPluginInitialisationException(
1364             "Quotes file is malformed",
1365             plugin.name,
1366             plugin.quotesFile,
1367             __FILE__,
1368             __LINE__);
1369     }
1370 
1371     // Let other Exceptions pass.
1372 
1373     json.save(plugin.quotesFile);
1374 }
1375 
1376 
1377 // reload
1378 /++
1379     Reloads the JSON quotes from disk.
1380  +/
1381 void reload(QuotesPlugin plugin)
1382 {
1383     plugin.quotes = loadQuotes(plugin.quotesFile);
1384 }
1385 
1386 
1387 public:
1388 
1389 
1390 // QuotesPlugin
1391 /++
1392     The Quotes plugin provides the ability to save and replay user quotes.
1393 
1394     These are not currently automatically replayed, such as when a user joins,
1395     but can rather be actively queried by use of the `quote` verb.
1396 
1397     It was historically part of [kameloso.plugins.chatbot.ChatbotPlugin|ChatbotPlugin].
1398  +/
1399 final class QuotesPlugin : IRCPlugin
1400 {
1401 private:
1402     import lu.json : JSONStorage;
1403 
1404     /// All Quotes plugin settings gathered.
1405     QuotesSettings quotesSettings;
1406 
1407     /++
1408         The in-memory JSON storage of all user quotes.
1409 
1410         It is in the JSON form of `Quote[][string][string]`, where the first key
1411         is a channel name and the second a nickname.
1412      +/
1413     Quote[][string][string] quotes;
1414 
1415     /// Filename of file to save the quotes to.
1416     @Resource string quotesFile = "quotes.json";
1417 
1418     mixin IRCPluginImpl;
1419 }