1 /++
2     The Oneliners plugin serves to provide custom commands, like `!vods`, `!youtube`,
3     and any other static-reply `!command` (provided a prefix of "`!`").
4 
5     More advanced commands that do more than just repeat the preset lines of text
6     will have to be written separately.
7 
8     See_Also:
9         https://github.com/zorael/kameloso/wiki/Current-plugins#oneliners,
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.oneliners;
20 
21 version(WithOnelinersPlugin):
22 
23 private:
24 
25 import kameloso.plugins;
26 import kameloso.plugins.common.core;
27 import kameloso.plugins.common.awareness : ChannelAwareness, TwitchAwareness, UserAwareness;
28 import kameloso.common : logger;
29 import kameloso.messaging;
30 import dialect.defs;
31 
32 
33 /// All Oneliner plugin runtime settings.
34 @Settings struct OnelinersSettings
35 {
36     /// Toggle whether or not this plugin should do anything at all.
37     @Enabler bool enabled = true;
38 
39     version(TwitchSupport)
40     {
41         /++
42             Send oneliners as Twitch replies to the triggering message.
43 
44             Only affects Twitch connections.
45          +/
46         bool onelinersAsReplies = false;
47     }
48 }
49 
50 
51 /++
52     Oneliner definition struct.
53  +/
54 struct Oneliner
55 {
56 private:
57     import std.json : JSONValue;
58 
59 public:
60     // Type
61     /++
62         The different kinds of [Oneliner]s. Either one that yields a
63         [Type.random|random] response each time, or one that yields a
64         [Type.ordered|ordered] one.
65      +/
66     enum Type
67     {
68         /++
69             Responses should be yielded in a random (technically uniform) order.
70          +/
71         random = 0,
72 
73         /++
74             Responses should be yielded in order, bumping an internal counter.
75          +/
76         ordered = 1,
77     }
78 
79     // trigger
80     /++
81         Trigger word for this oneliner.
82      +/
83     string trigger;
84 
85     // type
86     /++
87         What type of [Oneliner] this is.
88      +/
89     Type type;
90 
91     // position
92     /++
93         The current position, kept to keep track of what response should be
94         yielded next in the case of ordered oneliners.
95      +/
96     size_t position;
97 
98     // cooldown
99     /++
100         How many seconds must pass between two invocations of a oneliner.
101         Introduces an element of hysteresis.
102      +/
103     uint cooldown;
104 
105     // lastTriggered
106     /++
107         UNIX timestamp of when the oneliner last fired.
108      +/
109     long lastTriggered;
110 
111     // responses
112     /++
113         Array of responses.
114      +/
115     string[] responses;
116 
117     // getResponse
118     /++
119         Yields a response from the [responses] array, depending on the [type]
120         of this oneliner.
121 
122         Returns:
123             A response string. If the [responses] array is empty, then an empty
124             string is returned instead.
125      +/
126     auto getResponse()
127     {
128         return (type == Type.random) ?
129             randomResponse() :
130             nextOrderedResponse();
131     }
132 
133     // nextOrderedResponse
134     /++
135         Yields an ordered response from the [responses] array. Which response
136         is selected depends on the value of [position].
137 
138         Returns:
139             A response string. If the [responses] array is empty, then an empty
140             string is returned instead.
141      +/
142     auto nextOrderedResponse()
143     in ((type == Type.ordered), "Tried to get an ordered reponse from a random Oneliner")
144     {
145         if (!responses.length) return string.init;
146 
147         size_t i = position++;  // mutable
148 
149         if (position >= responses.length)
150         {
151             position = 0;
152         }
153 
154         return responses[i];
155     }
156 
157 
158     // randomResponse
159     /++
160         Yields a random response from the [responses] array.
161 
162         Returns:
163             A response string. If the [responses] array is empty, then an empty
164             string is returned instead.
165      +/
166     auto randomResponse() const
167     //in ((type == Type.random), "Tried to get an random reponse from an ordered Oneliner")
168     {
169         import std.random : uniform;
170 
171         if (!responses.length) return string.init;
172 
173         return responses[uniform(0, responses.length)];
174     }
175 
176     // toJSON
177     /++
178         Serialises this [Oneliner] into a [std.json.JSONValue|JSONValue].
179 
180         Returns:
181             A [std.json.JSONValue|JSONValue] that describes this oneliner.
182      +/
183     auto toJSON() const
184     {
185         JSONValue json;
186         json = null;
187         json.object = null;
188 
189         json["trigger"] = JSONValue(this.trigger);
190         json["type"] = JSONValue(cast(int)this.type);
191         json["responses"] = JSONValue(this.responses);
192         json["cooldown"] = JSONValue(this.cooldown);
193 
194         return json;
195     }
196 
197     // fromJSON
198     /++
199         Deserialises a [Oneliner] from a [std.json.JSONValue|JSONValue].
200 
201         Params:
202             json = [std.json.JSONValue|JSONValue] to deserialise.
203 
204         Returns:
205             A new [Oneliner] with values loaded from the passed JSON.
206      +/
207     static auto fromJSON(const JSONValue json)
208     {
209         Oneliner oneliner;
210         oneliner.trigger = json["trigger"].str;
211         oneliner.type = (json["type"].integer == cast(int)Type.random) ?
212             Type.random :
213             Type.ordered;
214 
215         if (const cooldownJSON = "cooldown" in json)
216         {
217             oneliner.cooldown = cast(uint)cooldownJSON.integer;
218         }
219 
220         foreach (const responseJSON; json["responses"].array)
221         {
222             oneliner.responses ~= responseJSON.str;
223         }
224 
225         return oneliner;
226     }
227 }
228 
229 
230 // onOneliner
231 /++
232     Responds to oneliners.
233 
234     Responses are stored in [OnelinersPlugin.onelinersByChannel].
235  +/
236 @(IRCEventHandler()
237     .onEvent(IRCEvent.Type.CHAN)
238     .permissionsRequired(Permissions.ignore)
239     .channelPolicy(ChannelPolicy.home)
240     .chainable(true)
241 )
242 void onOneliner(OnelinersPlugin plugin, const ref IRCEvent event)
243 {
244     import kameloso.plugins.common.misc : nameOf;
245     import kameloso.string : replaceRandom;
246     import lu.string : beginsWith, nom;
247     import std.array : replace;
248     import std.conv : text, to;
249     import std.format : format;
250     import std.random : uniform;
251     import std.typecons : Flag, No, Yes;
252     import std.uni : toLower;
253 
254     if (!event.content.beginsWith(plugin.state.settings.prefix)) return;
255 
256     void sendEmptyOneliner(const string trigger)
257     {
258         import std.format : format;
259 
260         enum pattern = "(Empty oneliner; use <b>%soneliner add %s<b> to add lines.)";
261         immutable message = pattern.format(plugin.state.settings.prefix, trigger);
262         sendOneliner(plugin, event, message);
263     }
264 
265     string slice = event.content[plugin.state.settings.prefix.length..$];  // mutable
266     if (!slice.length) return;
267 
268     auto channelOneliners = event.channel in plugin.onelinersByChannel;  // mustn't be const
269     if (!channelOneliners) return;
270 
271     immutable trigger = slice.nom!(Yes.inherit)(' ').toLower;
272 
273     auto oneliner = trigger in *channelOneliners;  // mustn't be const
274     if (!oneliner) return;
275 
276     if (!oneliner.responses.length) return sendEmptyOneliner(trigger);
277 
278     if (oneliner.cooldown > 0)
279     {
280         if ((oneliner.lastTriggered + oneliner.cooldown) > event.time)
281         {
282             // Too soon
283             return;
284         }
285         else
286         {
287             // Record time last fired
288             oneliner.lastTriggered = event.time;
289         }
290     }
291 
292     string line = oneliner.getResponse()  // mutable
293         .replace("$channel", event.channel)
294         .replace("$senderNickname", event.sender.nickname)
295         .replace("$sender", nameOf(event.sender))
296         .replace("$botNickname", plugin.state.client.nickname)
297         .replace("$bot", nameOf(plugin, plugin.state.client.nickname)) // likewise
298         .replaceRandom();
299 
300     version(TwitchSupport)
301     {
302         if (plugin.state.server.daemon == IRCServer.Daemon.twitch)
303         {
304             line = line
305                 .replace("$streamerNickname", event.channel[1..$])
306                 .replace("$streamer", nameOf(plugin, event.channel[1..$]));
307         }
308     }
309 
310     immutable target = slice.beginsWith('@') ? slice[1..$] : slice;
311     immutable message = target.length ?
312         text('@', nameOf(plugin, target), ' ', line) :
313         line;
314 
315     sendOneliner(plugin, event, message);
316 }
317 
318 
319 // onCommandModifyOneliner
320 /++
321     Adds, removes or modifies a oneliner, then saves the list to disk.
322  +/
323 @(IRCEventHandler()
324     .onEvent(IRCEvent.Type.CHAN)
325     .permissionsRequired(Permissions.operator)
326     .channelPolicy(ChannelPolicy.home)
327     .addCommand(
328         IRCEventHandler.Command()
329             .word("oneliner")
330             .policy(PrefixPolicy.prefixed)
331             .description("Manages oneliners.")
332             .addSyntax("$command new [trigger] [type] [optional cooldown]")
333             .addSyntax("$command add [trigger] [text]")
334             .addSyntax("$command edit [trigger] [position] [new text]")
335             .addSyntax("$command insert [trigger] [position] [text]")
336             .addSyntax("$command del [trigger] [optional position]")
337             .addSyntax("$command list")
338     )
339     .addCommand(
340         IRCEventHandler.Command()
341             .word("command")
342             .policy(PrefixPolicy.prefixed)
343             .hidden(true)
344     )
345 )
346 void onCommandModifyOneliner(OnelinersPlugin plugin, const ref IRCEvent event)
347 {
348     import lu.string : nom, stripped;
349     import std.format : format;
350     import std.typecons : Flag, No, Yes;
351     import std.uni : toLower;
352 
353     void sendUsage()
354     {
355         enum pattern = "Usage: <b>%s%s<b> [new|insert|add|edit|del|list] ...";
356         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
357         chan(plugin.state, event.channel, message);
358     }
359 
360     if (!event.content.length) return sendUsage();
361 
362     string slice = event.content.stripped;  // mutable
363     immutable verb = slice.nom!(Yes.inherit, Yes.decode)(' ');
364 
365     switch (verb)
366     {
367     case "new":
368         return handleNewOneliner(plugin, event, slice);
369 
370     case "insert":
371     case "add":
372     case "edit":
373         return handleAddToOneliner(plugin, event, slice, verb);
374 
375     case "del":
376     case "remove":
377         return handleDelFromOneliner(plugin, event, slice);
378 
379     case "list":
380         return listCommands(plugin, event);
381 
382     default:
383         return sendUsage();
384     }
385 }
386 
387 
388 // handleNewOneliner
389 /++
390     Creates a new and empty oneliner.
391 
392     Params:
393         plugin = The current [OnelinersPlugin].
394         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the creation.
395         slice = Relevant slice of the original request string.
396  +/
397 void handleNewOneliner(
398     OnelinersPlugin plugin,
399     const /*ref*/ IRCEvent event,
400     /*const*/ string slice)
401 {
402     import kameloso.constants : BufferSize;
403     import kameloso.thread : CarryingFiber;
404     import lu.string : SplitResults, splitInto;
405     import std.format : format;
406     import std.typecons : Tuple;
407     import std.uni : toLower;
408     import core.thread : Fiber;
409 
410     // copy/pasted
411     string stripPrefix(const string trigger)
412     {
413         import lu.string : beginsWith;
414         return trigger.beginsWith(plugin.state.settings.prefix) ?
415             trigger[plugin.state.settings.prefix.length..$] :
416             trigger;
417     }
418 
419     void sendNewUsage()
420     {
421         enum pattern = "Usage: <b>%s%s new<b> [trigger] [type] [optional cooldown]";
422         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
423         chan(plugin.state, event.channel, message);
424     }
425 
426     void sendMustBeRandomOrOrdered()
427     {
428         enum message = "Oneliner type must be one of <b>random<b> or <b>ordered<b>";
429         chan(plugin.state, event.channel, message);
430     }
431 
432     void sendCooldownMustBeValidPositiveDurationString()
433     {
434         enum message = "Oneliner cooldown must be in the hour-minute-seconds form of <b>*h*m*s<b> " ~
435             "and may not have negative values.";
436         chan(plugin.state, event.channel, message);
437     }
438 
439     void sendOnelinerAlreadyExists(const string trigger)
440     {
441         enum pattern = `A oneliner with the trigger word "<b>%s<b>" already exists.`;
442         immutable message = pattern.format(trigger);
443         chan(plugin.state, event.channel, message);
444     }
445 
446     string trigger;
447     string typestring;
448     string cooldownString;
449     cast(void)slice.splitInto(trigger, typestring, cooldownString);
450     if (!typestring.length) return sendNewUsage();
451 
452     Oneliner.Type type;
453 
454     switch (typestring)
455     {
456     case "random":
457     case "rnd":
458     case "rng":
459         type = Oneliner.Type.random;
460         break;
461 
462     case "ordered":
463     case "order":
464     case "sequential":
465     case "seq":
466     case "sequence":
467         type = Oneliner.Type.ordered;
468         break;
469 
470     default:
471         return sendMustBeRandomOrOrdered();
472     }
473 
474     trigger = stripPrefix(trigger).toLower;
475 
476     const channelTriggers = event.channel in plugin.onelinersByChannel;
477     if (channelTriggers && (trigger in *channelTriggers))
478     {
479         return sendOnelinerAlreadyExists(trigger);
480     }
481 
482     int cooldownSeconds = Oneliner.init.cooldown;
483 
484     if (cooldownString.length)
485     {
486         import kameloso.time : DurationStringException, abbreviatedDuration;
487 
488         try
489         {
490             cooldownSeconds = cast(int)abbreviatedDuration(cooldownString).total!"seconds";
491             if (cooldownSeconds < 0) return sendCooldownMustBeValidPositiveDurationString();
492         }
493         catch (DurationStringException _)
494         {
495             return sendCooldownMustBeValidPositiveDurationString();
496         }
497     }
498 
499     /+
500         We need to check both hardcoded and soft channel-specific commands
501         for conflicts.
502      +/
503     bool triggerConflicts(const IRCPlugin.CommandMetadata[string][string] aa)
504     {
505         foreach (immutable pluginName, pluginCommands; aa)
506         {
507             if (!pluginCommands.length || (pluginName == "oneliners")) continue;
508 
509             foreach (/*mutable*/ word, const _; pluginCommands)
510             {
511                 word = word.toLower;
512 
513                 if (word == trigger)
514                 {
515                     enum pattern = `Oneliner word "<b>%s<b>" conflicts with a command of the <b>%s<b> plugin.`;
516                     immutable message = pattern.format(trigger, pluginName);
517                     chan(plugin.state, event.channel, message);
518                     return true;
519                 }
520             }
521         }
522 
523         return false;
524     }
525 
526     alias Payload = Tuple!(IRCPlugin.CommandMetadata[string][string]);
527 
528     void addNewOnelinerDg()
529     {
530         auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
531         assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
532 
533         IRCPlugin.CommandMetadata[string][string] aa = thisFiber.payload[0];
534         if (triggerConflicts(aa)) return;
535 
536         // Get channel AAs
537         plugin.state.specialRequests ~= specialRequest(event.channel, thisFiber);
538         Fiber.yield();
539 
540         IRCPlugin.CommandMetadata[string][string] channelSpecificAA = thisFiber.payload[0];
541         if (triggerConflicts(channelSpecificAA)) return;
542 
543         // If we're here there were no conflicts
544         Oneliner oneliner;
545         oneliner.trigger = trigger;
546         oneliner.type = type;
547         oneliner.cooldown = cooldownSeconds;
548         //oneliner.responses ~= slice;
549 
550         plugin.onelinersByChannel[event.channel][trigger] = oneliner;
551         saveResourceToDisk(plugin.onelinersByChannel, plugin.onelinerFile);
552 
553         enum pattern = "Oneliner <b>%s%s<b> created! Use <b>%1$s%3$s add<b> to add lines.";
554         immutable message = pattern.format(plugin.state.settings.prefix, trigger, event.aux[$-1]);
555         chan(plugin.state, event.channel, message);
556     }
557 
558     plugin.state.specialRequests ~= specialRequest!Payload(string.init, &addNewOnelinerDg);
559 }
560 
561 
562 // handleAddToOneliner
563 /++
564     Adds or inserts a line into a oneliner, or modifies an existing line.
565 
566     Params:
567         plugin = The current [OnelinersPlugin].
568         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the addition (or modification).
569         slice = Relevant slice of the original request string.
570         verb = The string verb of what action was requested; "add", "insert" or "edit".
571  +/
572 void handleAddToOneliner(
573     OnelinersPlugin plugin,
574     const ref IRCEvent event,
575     /*const*/ string slice,
576     const string verb)
577 {
578     import lu.string : SplitResults, splitInto;
579     import std.conv : ConvException, to;
580     import std.format : format;
581     import std.uni : toLower;
582 
583     void sendInsertEditUsage(const string verb)
584     {
585         immutable pattern = (verb == "insert") ?
586             "Usage: <b>%s%s insert<b> [trigger] [position] [text]" :
587             "Usage: <b>%s%s edit<b> [trigger] [position] [new text]";
588         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
589         chan(plugin.state, event.channel, message);
590     }
591 
592     void sendAddUsage()
593     {
594         enum pattern = "Usage: <b>%s%s add<b> [existing trigger] [text]";
595         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
596         chan(plugin.state, event.channel, message);
597     }
598 
599     void sendNoSuchOneliner(const string trigger)
600     {
601         // Sent from more than one place so might as well make it a nested function
602         enum pattern = "No such oneliner: <b>%s%s<b>";
603         immutable message = pattern.format(plugin.state.settings.prefix, trigger);
604         chan(plugin.state, event.channel, message);
605     }
606 
607     void sendResponseIndexOutOfBounds(const size_t pos, const size_t upperBounds)
608     {
609         enum pattern = "Oneliner response index <b>%d<b> is out of bounds. <b>[0..%d]<b>";
610         immutable message = pattern.format(pos, upperBounds);
611         chan(plugin.state, event.channel, message);
612     }
613 
614     void sendPositionNotPositive()
615     {
616         enum message = "Position passed is not a positive number.";
617         chan(plugin.state, event.channel, message);
618     }
619 
620     void sendPositionNaN()
621     {
622         enum message = "Position passed is not a number.";
623         chan(plugin.state, event.channel, message);
624     }
625 
626     // copy/pasted
627     string stripPrefix(const string trigger)
628     {
629         import lu.string : beginsWith;
630         return trigger.beginsWith(plugin.state.settings.prefix) ?
631             trigger[plugin.state.settings.prefix.length..$] :
632             trigger;
633     }
634 
635     enum Action
636     {
637         insertAtPosition,
638         appendToEnd,
639         editExisting,
640     }
641 
642     void insert(
643         /*const*/ string trigger,
644         const string line,
645         const Action action,
646         const ptrdiff_t pos = 0)
647     {
648         trigger = stripPrefix(trigger).toLower;
649 
650         auto channelOneliners = event.channel in plugin.onelinersByChannel;
651         if (!channelOneliners) return sendNoSuchOneliner(trigger);
652 
653         auto oneliner = trigger in *channelOneliners;
654         if (!oneliner) return sendNoSuchOneliner(trigger);
655 
656         if ((action != Action.appendToEnd) && (pos >= oneliner.responses.length))
657         {
658             return sendResponseIndexOutOfBounds(pos, oneliner.responses.length);
659         }
660 
661         with (Action)
662         final switch (action)
663         {
664         case insertAtPosition:
665             import std.array : insertInPlace;
666 
667             oneliner.responses.insertInPlace(pos, line);
668 
669             if (oneliner.type == Oneliner.Type.ordered)
670             {
671                 // Reset ordered position to 0 on insertions
672                 oneliner.position = 0;
673             }
674 
675             enum message = "Oneliner line inserted.";
676             chan(plugin.state, event.channel, message);
677             break;
678 
679         case appendToEnd:
680             oneliner.responses ~= line;
681             enum message = "Oneliner line added.";
682             chan(plugin.state, event.channel, message);
683             break;
684 
685         case editExisting:
686             oneliner.responses[pos] = line;
687             enum message = "Oneliner line modified.";
688             chan(plugin.state, event.channel, message);
689             break;
690         }
691 
692         saveResourceToDisk(plugin.onelinersByChannel, plugin.onelinerFile);
693     }
694 
695     if ((verb == "insert") || (verb == "edit"))
696     {
697         string trigger;
698         string posString;
699         ptrdiff_t pos;
700 
701         immutable results = slice.splitInto(trigger, posString);
702         if (results != SplitResults.overrun)
703         {
704             return sendInsertEditUsage(verb);
705         }
706 
707         try
708         {
709             pos = posString.to!ptrdiff_t;
710 
711             if (pos < 0)
712             {
713                 return sendPositionNaN();
714             }
715         }
716         catch (ConvException _)
717         {
718             return sendPositionNaN();
719         }
720 
721         if (verb == "insert")
722         {
723             insert(trigger, slice, Action.insertAtPosition, pos);
724         }
725         else /*if (verb == "edit")*/
726         {
727             insert(trigger, slice, Action.editExisting, pos);
728         }
729     }
730     else if (verb == "add")
731     {
732         string trigger;
733 
734         immutable results = slice.splitInto(trigger);
735         if (results != SplitResults.overrun)
736         {
737             return sendAddUsage();
738         }
739 
740         insert(trigger, slice, Action.appendToEnd);
741     }
742     else
743     {
744         assert(0, "impossible case in onCommandOneliner switch");
745     }
746 }
747 
748 
749 // handleDelFromOneliner
750 /++
751     Deletes a oneliner entirely, alternatively a line from one.
752 
753     Params:
754         plugin = The current [OnelinersPlugin].
755         event = The [dialect.defs.IRCEvent|IRCEvent] that requested the deletion.
756         slice = Relevant slice of the original request string.
757  +/
758 void handleDelFromOneliner(
759     OnelinersPlugin plugin,
760     const ref IRCEvent event,
761     /*const*/ string slice)
762 {
763     import lu.string : nom;
764     import std.conv : ConvException, to;
765     import std.format : format;
766     import std.typecons : Flag, No, Yes;
767     import std.uni : toLower;
768 
769     void sendDelUsage()
770     {
771         enum pattern = "Usage: <b>%s%s del<b> [trigger] [optional position]";
772         immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
773         chan(plugin.state, event.channel, message);
774     }
775 
776     void sendNoSuchOneliner(const string trigger)
777     {
778         // Sent from more than one place so might as well make it a nested function
779         enum pattern = "No such oneliner: <b>%s%s<b>";
780         immutable message = pattern.format(plugin.state.settings.prefix, trigger);
781         chan(plugin.state, event.channel, message);
782     }
783 
784     void sendOnelinerEmpty(const string trigger)
785     {
786         enum pattern = "Oneliner <b>%s<b> is empty and has no responses to remove.";
787         immutable message = pattern.format(trigger);
788         chan(plugin.state, event.channel, message);
789     }
790 
791     void sendResponseIndexOutOfBounds(const size_t pos, const size_t upperBounds)
792     {
793         enum pattern = "Oneliner response index <b>%d<b> is out of bounds. <b>[0..%d]<b>";
794         immutable message = pattern.format(pos, upperBounds);
795         chan(plugin.state, event.channel, message);
796     }
797 
798     void sendLineRemoved(const string trigger, const size_t pos)
799     {
800         enum pattern = "Oneliner response <b>%s<b>#%d removed.";
801         immutable message = pattern.format(trigger, pos);
802         chan(plugin.state, event.channel, message);
803     }
804 
805     void sendRemoved(const string trigger)
806     {
807         enum pattern = "Oneliner <b>%s%s<b> removed.";
808         immutable message = pattern.format(plugin.state.settings.prefix, trigger);
809         chan(plugin.state, event.channel, message);
810     }
811 
812     // copy/pasted
813     string stripPrefix(const string trigger)
814     {
815         import lu.string : beginsWith;
816         return trigger.beginsWith(plugin.state.settings.prefix) ?
817             trigger[plugin.state.settings.prefix.length..$] :
818             trigger;
819     }
820 
821     if (!slice.length) return sendDelUsage();
822 
823     immutable trigger = stripPrefix(slice.nom!(Yes.inherit)(' ')).toLower;
824 
825     auto channelOneliners = event.channel in plugin.onelinersByChannel;
826     if (!channelOneliners) return sendNoSuchOneliner(trigger);
827 
828     auto oneliner = trigger in *channelOneliners;
829     if (!oneliner) return sendNoSuchOneliner(trigger);
830 
831     if (slice.length)
832     {
833         if (!oneliner.responses.length) return sendOnelinerEmpty(trigger);
834 
835         try
836         {
837             import std.algorithm.mutation : SwapStrategy, remove;
838 
839             immutable pos = slice.to!size_t;
840 
841             if (pos >= oneliner.responses.length)
842             {
843                 return sendResponseIndexOutOfBounds(pos, oneliner.responses.length);
844             }
845 
846             oneliner.responses = oneliner.responses.remove!(SwapStrategy.stable)(pos);
847             sendLineRemoved(trigger, pos);
848 
849             if (oneliner.type == Oneliner.Type.ordered)
850             {
851                 // Reset ordered position to 0 on removals
852                 oneliner.position = 0;
853             }
854         }
855         catch (ConvException _)
856         {
857             return sendDelUsage();
858         }
859     }
860     else
861     {
862         (*channelOneliners).remove(trigger);
863         sendRemoved(trigger);
864     }
865 
866     saveResourceToDisk(plugin.onelinersByChannel, plugin.onelinerFile);
867 }
868 
869 
870 // onCommandCommands
871 /++
872     Sends a list of the current oneliners to the channel.
873 
874     Merely calls [listCommands].
875  +/
876 @(IRCEventHandler()
877     .onEvent(IRCEvent.Type.CHAN)
878     .permissionsRequired(Permissions.anyone)
879     .channelPolicy(ChannelPolicy.home)
880     .addCommand(
881         IRCEventHandler.Command()
882             .word("commands")
883             .policy(PrefixPolicy.prefixed)
884             .description("Lists all available oneliners.")
885     )
886 )
887 void onCommandCommands(OnelinersPlugin plugin, const ref IRCEvent event)
888 {
889     return listCommands(plugin, event);
890 }
891 
892 
893 // listCommands
894 /++
895     Lists the current commands to the passed channel.
896 
897     Params:
898         plugin = The current [OnelinersPlugin].
899         event = The querying [dialect.defs.IRCEvent|IRCEvent].
900  +/
901 void listCommands(OnelinersPlugin plugin, const ref IRCEvent event)
902 {
903     import std.format : format;
904 
905     auto channelOneliners = event.channel in plugin.onelinersByChannel;
906 
907     if (channelOneliners && channelOneliners.length)
908     {
909         immutable rtPattern = "Available commands: %-(<b>" ~ plugin.state.settings.prefix ~ "%s<b>, %)<b>";
910         immutable message = rtPattern.format(channelOneliners.byKey);
911         sendOneliner(plugin, event, message);
912     }
913     else
914     {
915         enum message = "There are no commands available right now.";
916         sendOneliner(plugin, event, message);
917     }
918 }
919 
920 
921 // onWelcome
922 /++
923     Populate the oneliners array after we have successfully logged onto the server.
924  +/
925 @(IRCEventHandler()
926     .onEvent(IRCEvent.Type.RPL_WELCOME)
927 )
928 void onWelcome(OnelinersPlugin plugin)
929 {
930     plugin.reload();
931 }
932 
933 
934 // reload
935 /++
936     Reloads oneliners from disk.
937  +/
938 void reload(OnelinersPlugin plugin)
939 {
940     import lu.json : JSONStorage;
941 
942     JSONStorage allOnelinersJSON;
943     allOnelinersJSON.load(plugin.onelinerFile);
944     plugin.onelinersByChannel.clear();
945 
946     foreach (immutable channelName, const channelOnelinersJSON; allOnelinersJSON.object)
947     {
948         // Initialise the AA
949         plugin.onelinersByChannel[channelName][string.init] = Oneliner.init;
950         auto channelOneliners = channelName in plugin.onelinersByChannel;
951         (*channelOneliners).remove(string.init);
952 
953         foreach (immutable trigger, const onelinerJSON; channelOnelinersJSON.object)
954         {
955             import std.json : JSONException;
956 
957             try
958             {
959                 (*channelOneliners)[trigger] = Oneliner.fromJSON(onelinerJSON);
960             }
961             catch (JSONException _)
962             {
963                 import kameloso.string : doublyBackslashed;
964                 enum pattern = "Failed to load oneliner \"<l>%s</>\"; <l>%s</> is outdated or corrupt.";
965                 logger.errorf(pattern, trigger, plugin.onelinerFile.doublyBackslashed);
966             }
967         }
968 
969         (*channelOneliners).rehash();
970     }
971 
972     plugin.onelinersByChannel.rehash();
973 }
974 
975 
976 // sendOneliner
977 /++
978     Sends a oneliner reply.
979 
980     If connected to a Twitch server and with version `TwitchSupport` set and
981     [OnelinersSettings.onelinersAsReplies] true, sends the message as a
982     Twitch [kameloso.messaging.reply|reply].
983 
984     Params:
985         plugin = The current [OnelinersPlugin].
986         event = The querying [dialect.defs.IRCEvent|IRCEvent].
987         message = The message string to send.
988  +/
989 void sendOneliner(
990     OnelinersPlugin plugin,
991     const ref IRCEvent event,
992     const string message)
993 {
994     version(TwitchSupport)
995     {
996         if ((plugin.state.server.daemon == IRCServer.Daemon.twitch) &&
997             (plugin.onelinersSettings.onelinersAsReplies))
998         {
999             return reply(plugin.state, event, message);
1000         }
1001     }
1002 
1003     chan(plugin.state, event.channel, message);
1004 }
1005 
1006 
1007 // saveResourceToDisk
1008 /++
1009     Saves the passed resource to disk, but in JSON format.
1010 
1011     This is used with the associative arrays for oneliners.
1012 
1013     Example:
1014     ---
1015     plugin.oneliners["#channel"]["asdf"].responses ~= "asdf yourself";
1016     plugin.oneliners["#channel"]["fdsa"].responses ~= "hirr";
1017 
1018     saveResource(plugin.onelinersByChannel, plugin.onelinerFile);
1019     ---
1020 
1021     Params:
1022         aa = The JSON-convertible resource to save.
1023         filename = Filename of the file to write to.
1024  +/
1025 void saveResourceToDisk(const Oneliner[string][string] aa, const string filename)
1026 in (filename.length, "Tried to save resources to an empty filename string")
1027 {
1028     import std.json : JSONValue;
1029     import std.stdio : File;
1030 
1031     JSONValue json;
1032     json = null;
1033     json.object = null;
1034 
1035     foreach (immutable channelName, const channelOneliners; aa)
1036     {
1037         json[channelName] = null;
1038         json[channelName].object = null;
1039 
1040         foreach (immutable trigger, const oneliner; channelOneliners)
1041         {
1042             json[channelName][trigger] = null;
1043             json[channelName][trigger].object = null;
1044             json[channelName][trigger] = oneliner.toJSON();
1045         }
1046     }
1047 
1048     File(filename, "w").writeln(json.toPrettyString);
1049 }
1050 
1051 
1052 // initResources
1053 /++
1054     Reads and writes the file of oneliners and administrators to disk, ensuring
1055     that they're there and properly formatted.
1056  +/
1057 void initResources(OnelinersPlugin plugin)
1058 {
1059     import lu.json : JSONStorage;
1060     import std.json : JSONException;
1061 
1062     JSONStorage onelinerJSON;
1063 
1064     try
1065     {
1066         onelinerJSON.load(plugin.onelinerFile);
1067     }
1068     catch (JSONException e)
1069     {
1070         import kameloso.plugins.common.misc : IRCPluginInitialisationException;
1071 
1072         version(PrintStacktraces) logger.trace(e);
1073         throw new IRCPluginInitialisationException(
1074             "Oneliner file is malformed",
1075             plugin.name,
1076             plugin.onelinerFile,
1077             __FILE__,
1078             __LINE__);
1079     }
1080 
1081     // Let other Exceptions pass.
1082 
1083     onelinerJSON.save(plugin.onelinerFile);
1084 }
1085 
1086 
1087 mixin UserAwareness;
1088 mixin ChannelAwareness;
1089 mixin PluginRegistration!OnelinersPlugin;
1090 
1091 version(TwitchSupport)
1092 {
1093     mixin TwitchAwareness;
1094 }
1095 
1096 public:
1097 
1098 
1099 // OnelinersPlugin
1100 /++
1101     The Oneliners plugin serves to listen to custom commands that can be added,
1102     modified and removed at runtime. Think `!info`.
1103  +/
1104 final class OnelinersPlugin : IRCPlugin
1105 {
1106 private:
1107     /// All Oneliners plugin settings.
1108     OnelinersSettings onelinersSettings;
1109 
1110     /// Associative array of oneliners; [Oneliner] array, keyed by trigger, keyed by channel.
1111     Oneliner[string][string] onelinersByChannel;
1112 
1113     /// Filename of file with oneliners.
1114     @Resource string onelinerFile = "oneliners.json";
1115 
1116     // channelSpecificCommands
1117     /++
1118         Compile a list of our runtime oneliner commands.
1119 
1120         Params:
1121             channelName = Name of channel whose commands we want to summarise.
1122 
1123         Returns:
1124             An associative array of
1125             [kameloso.plugins.common.core.IRCPlugin.CommandMetadata|IRCPlugin.CommandMetadata]s,
1126             one for each oneliner active in the passed channel.
1127      +/
1128     override public IRCPlugin.CommandMetadata[string] channelSpecificCommands(const string channelName) @system
1129     {
1130         IRCPlugin.CommandMetadata[string] aa;
1131 
1132         const channelOneliners = channelName in onelinersByChannel;
1133         if (!channelOneliners) return aa;
1134 
1135         foreach (immutable trigger, const _; *channelOneliners)
1136         {
1137             IRCPlugin.CommandMetadata metadata;
1138             metadata.description = "A oneliner";
1139             aa[trigger] = metadata;
1140         }
1141 
1142         return aa;
1143     }
1144 
1145     mixin IRCPluginImpl;
1146 }