1 /++
2     The Help plugin serves the `help` command, and nothing else at this point.
3 
4     It is used to query the bot for available commands in a tidy list.
5 
6     See_Also:
7         https://github.com/zorael/kameloso/wiki/Current-plugins#help,
8         [kameloso.plugins.common.core],
9         [kameloso.plugins.common.misc]
10 
11     Copyright: [JR](https://github.com/zorael)
12     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
13 
14     Authors:
15         [JR](https://github.com/zorael)
16  +/
17 module kameloso.plugins.help;
18 
19 version(WithHelpPlugin):
20 
21 private:
22 
23 import kameloso.plugins;
24 import kameloso.plugins.common.core;
25 import kameloso.plugins.common.awareness : MinimalAuthentication;
26 import kameloso.common : logger;
27 import kameloso.messaging;
28 import dialect.defs;
29 import std.typecons : Flag, No, Yes;
30 
31 
32 // HelpSettings
33 /++
34     Settings for the Help plugin, to toggle it enabled or disabled.
35  +/
36 @Settings struct HelpSettings
37 {
38     /// Whether or not the Help plugin should react to events at all.
39     @Enabler bool enabled = true;
40 
41     /// Whether or not replies are always sent in queries.
42     bool repliesInQuery = true;
43 
44     /// Whether or not to include prefix in command listing.
45     bool includePrefix = true;
46 }
47 
48 
49 // onCommandHelp
50 /++
51     Sends a list of all plugins' commands to the requesting user.
52 
53     Plugins don't know about other plugins; the only thing they know of the
54     outside world is the thread ID of the main thread ID (stored in
55     [kameloso.plugins.common.core.IRCPluginState.mainThread|IRCPluginState.mainThread]).
56     As such, we can't easily query each plugin for their
57     [kameloso.plugins.common.core.IRCEventHandler.Command|IRCEventHandler.Command]-annotated
58     functions.
59 
60     To work around this we construct a delegate that accepts an array of
61     [kameloso.plugins.common.core.IRCPlugin|IRCPlugins], and pass it to the main thread.
62     It will then invoke the delegate with the client-global `plugins` array as argument.
63 
64     Once we have the list we format it nicely and send it back to the requester.
65  +/
66 @(IRCEventHandler()
67     .onEvent(IRCEvent.Type.CHAN)
68     .onEvent(IRCEvent.Type.QUERY)
69     .permissionsRequired(Permissions.anyone)
70     .channelPolicy(ChannelPolicy.home)
71     .addCommand(
72         IRCEventHandler.Command()
73             .word("help")
74             .policy(PrefixPolicy.prefixed)
75             .description("Shows a list of all available commands.")
76             .addSyntax("$command [plugin] [command]")
77     )
78 )
79 void onCommandHelp(HelpPlugin plugin, const /*ref*/ IRCEvent event)
80 {
81     import kameloso.constants : BufferSize;
82     import kameloso.thread : CarryingFiber;
83     import std.typecons : Tuple;
84     import core.thread : Fiber;
85 
86     alias Payload = Tuple!(IRCPlugin.CommandMetadata[string][string]);
87 
88     void sendHelpDg()
89     {
90         import lu.string : beginsWith, contains, stripped;
91 
92         auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis;
93         assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof);
94 
95         IRCPlugin.CommandMetadata[string][string] allPluginCommands = thisFiber.payload[0];
96 
97         IRCEvent mutEvent = event;  // mutable
98         mutEvent.content = mutEvent.content.stripped;
99 
100         if (plugin.helpSettings.repliesInQuery) mutEvent.channel = string.init;
101 
102         if (mutEvent.content.length)
103         {
104             immutable shorthandNicknamePrefix = plugin.state.client.nickname[0..1] ~ ':';
105 
106             if (mutEvent.content.beginsWith(plugin.state.settings.prefix) ||
107                 mutEvent.content.beginsWith(plugin.state.client.nickname) ||
108                 mutEvent.content.beginsWith(shorthandNicknamePrefix))
109             {
110                 // Not a plugin, just a prefixed command (probably)
111                 sendOnlyCommandHelp(plugin, mutEvent, allPluginCommands);
112             }
113             else if (mutEvent.content.contains!(Yes.decode)(' '))
114             {
115                 // Likely a plugin and a command
116                 sendPluginCommandHelp(plugin, mutEvent, allPluginCommands);
117             }
118             else
119             {
120                 // Just one word; print a specified plugin's commands
121                 sendSpecificPluginListing(plugin, mutEvent, allPluginCommands);
122             }
123         }
124         else
125         {
126             // Nothing supplied, send the big list
127             sendFullPluginListing(plugin, mutEvent, allPluginCommands);
128         }
129     }
130 
131     plugin.state.specialRequests ~= specialRequest!Payload(string.init, &sendHelpDg);
132 }
133 
134 
135 // sendCommandHelpImpl
136 /++
137     Sends the help text for a command to the querying channel or user.
138 
139     Params:
140         plugin = The current [HelpPlugin].
141         otherPluginName = The name of the plugin that hosts the command we're to
142             send the help text for.
143         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
144         command = String of the command we're to send help text for (sans prefix).
145         description = The description text that the event handler function is annotated with.
146         syntaxes = The declared different syntaxes of the command.
147  +/
148 void sendCommandHelpImpl(
149     HelpPlugin plugin,
150     const string otherPluginName,
151     const ref IRCEvent event,
152     const string command,
153     const string description,
154     const string[] syntaxes)
155 {
156     import lu.string : beginsWith;
157     import std.array : replace;
158     import std.conv : text;
159     import std.format : format;
160 
161     enum pattern = "[<b>%s<b>] <b>%s<b>: %s";
162     immutable message = pattern.format(otherPluginName, command, description);
163     privmsg(plugin.state, event.channel, event.sender.nickname, message);
164 
165     foreach (immutable syntax; syntaxes)
166     {
167         immutable humanlyReadable = syntax
168             .replace("$command", command)
169             .replace("$bot", plugin.state.client.nickname)
170             .replace("$prefix", plugin.state.settings.prefix)
171             .replace("$nickname", event.sender.nickname);
172 
173         // Prepend the prefix to non-PrefixPolicy.nickname commands
174         immutable prefixedSyntax = (syntax.beginsWith("$bot") || syntax.beginsWith("$prefix")) ?
175             humanlyReadable :
176             plugin.state.settings.prefix ~ humanlyReadable;
177         immutable usage = (syntaxes.length == 1) ?
178             "<b>Usage<b>: " ~ prefixedSyntax :
179             "* " ~ prefixedSyntax;
180         privmsg(plugin.state, event.channel, event.sender.nickname, usage);
181     }
182 }
183 
184 
185 // sendFullPluginListing
186 /++
187     Sends the help list of all plugins and all commands.
188 
189     Params:
190         plugin = The current [HelpPlugin].
191         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
192         allPluginCommands = The metadata of all commands for a particular plugin.
193  +/
194 void sendFullPluginListing(
195     HelpPlugin plugin,
196     const ref IRCEvent event,
197     /*const*/ IRCPlugin.CommandMetadata[string][string] allPluginCommands)
198 {
199     import kameloso.constants : KamelosoInfo;
200     import std.algorithm.sorting : sort;
201     import std.format : format;
202 
203     enum banner = "kameloso IRC bot <b>v" ~
204         cast(string)KamelosoInfo.version_ ~
205         "<b>, built " ~
206         cast(string)KamelosoInfo.built;
207     enum availableMessage = "Available bot commands per plugin:";
208 
209     privmsg(plugin.state, event.channel, event.sender.nickname, banner);
210     privmsg(plugin.state, event.channel, event.sender.nickname, availableMessage);
211 
212     foreach (immutable pluginName, pluginCommands; allPluginCommands)
213     {
214         const nonhiddenCommands = filterHiddenCommands(pluginCommands);
215 
216         if (!nonhiddenCommands.length) continue;
217 
218         enum width = 12;
219         enum pattern = "* <b>%-*s<b> %-([%s]%| %)";
220         string[] keys = nonhiddenCommands.keys.sort.release();
221 
222         foreach (ref key; keys)
223         {
224             key = addPrefix(plugin, key, nonhiddenCommands[key].policy);
225         }
226 
227         immutable message = pattern.format(width, pluginName, keys);
228         privmsg(plugin.state, event.channel, event.sender.nickname, message);
229     }
230 
231     enum pattern = "Use <b>%s%s<b> [<b>plugin<b>] [<b>command<b>] " ~
232         "for information about a command.";
233     immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]);
234     privmsg(plugin.state, event.channel, event.sender.nickname, message);
235 }
236 
237 
238 // sendSpecificPluginListing
239 /++
240     Sends the command help listing for a specific plugin.
241 
242     Params:
243         plugin = The current [HelpPlugin].
244         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
245         allPluginCommands = The metadata of all commands for a particular plugin.
246  +/
247 void sendSpecificPluginListing(
248     HelpPlugin plugin,
249     const ref IRCEvent event,
250     /*const*/ IRCPlugin.CommandMetadata[string][string] allPluginCommands)
251 {
252     import lu.string : stripped;
253     import std.algorithm.sorting : sort;
254     import std.format : format;
255 
256     assert(event.content.length, "`sendSpecificPluginListing` was called incorrectly; event content is empty");
257 
258     void sendNoCommandOfPlugin(const string specifiedPlugin)
259     {
260         immutable message = "No commands available for plugin <b>" ~ specifiedPlugin ~ "<b>";
261         privmsg(plugin.state, event.channel, event.sender.nickname, message);
262     }
263 
264     // Just one word; print a specified plugin's commands
265     immutable specifiedPlugin = event.content.stripped;
266 
267     if (auto pluginCommands = specifiedPlugin in allPluginCommands)
268     {
269         const nonhiddenCommands = filterHiddenCommands(*pluginCommands);
270         if (!nonhiddenCommands.length)
271         {
272             return sendNoCommandOfPlugin(specifiedPlugin);
273         }
274 
275         enum width = 12;
276         enum pattern = "* <b>%-*s<b> %-([%s]%| %)";
277         string[] keys = nonhiddenCommands.keys.sort.release();
278 
279         foreach (ref key; keys)
280         {
281             key = addPrefix(plugin, key, nonhiddenCommands[key].policy);
282         }
283 
284         immutable message = pattern.format(width, specifiedPlugin, keys);
285         return privmsg(plugin.state, event.channel, event.sender.nickname, message);
286     }
287     else
288     {
289         immutable message = "No such plugin: <b>" ~ event.content ~ "<b>";
290         privmsg(plugin.state, event.channel, event.sender.nickname, message);
291     }
292 }
293 
294 
295 // sendPluginCommandHelp
296 /++
297     Sends the help list of a single command of a specific plugin. Both were supplied.
298 
299     Params:
300         plugin = The current [HelpPlugin].
301         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
302         allPluginCommands = The metadata of all commands for this particular plugin.
303  +/
304 void sendPluginCommandHelp(
305     HelpPlugin plugin,
306     const ref IRCEvent event,
307     /*const*/ IRCPlugin.CommandMetadata[string][string] allPluginCommands)
308 {
309     import lu.string : contains, nom, stripped;
310     import std.format : format;
311 
312     assert(event.content.contains(' '),
313         "`sendPluginCommandHelp` was called incorrectly; the content does not " ~
314         "have a space-separated plugin and command");
315 
316     void sendNoHelpForCommandOfPlugin(const string specifiedCommand, const string specifiedPlugin)
317     {
318         enum pattern = "No help available for command <b>%s<b> of plugin <b>%s<b>";
319         immutable message = pattern.format(specifiedCommand, specifiedPlugin);
320         privmsg(plugin.state, event.channel, event.sender.nickname, message);
321     }
322 
323     string slice = event.content.stripped;
324     immutable specifiedPlugin = slice.nom!(Yes.decode)(' ');
325     immutable specifiedCommand = stripPrefix(plugin, slice);
326 
327     if (const pluginCommands = specifiedPlugin in allPluginCommands)
328     {
329         if (const command = specifiedCommand in *pluginCommands)
330         {
331             sendCommandHelpImpl(
332                 plugin,
333                 specifiedPlugin,
334                 event,
335                 specifiedCommand,
336                 command.description,
337                 command.syntaxes);
338         }
339         else
340         {
341             return sendNoHelpForCommandOfPlugin(specifiedCommand, specifiedPlugin);
342         }
343     }
344     else
345     {
346         immutable message = "No such plugin: <b>" ~ specifiedPlugin ~ "<b>";
347         privmsg(plugin.state, event.channel, event.sender.nickname, message);
348     }
349 }
350 
351 
352 // sendOnlyCommandHelp
353 /++
354     Sends the help list of a single command of a specific plugin. Only the command
355     was supplied, prefixed with the command prefix.
356 
357     Params:
358         plugin = The current [HelpPlugin].
359         event = The triggering [dialect.defs.IRCEvent|IRCEvent].
360         allPluginCommands = The metadata of all commands for this particular plugin.
361  +/
362 void sendOnlyCommandHelp(
363     HelpPlugin plugin,
364     const ref IRCEvent event,
365     /*const*/ IRCPlugin.CommandMetadata[string][string] allPluginCommands)
366 {
367     import lu.string : beginsWith;
368 
369     void sendNoCommandSpecified()
370     {
371         enum message = "No command specified.";
372         privmsg(plugin.state, event.channel, event.sender.nickname, message);
373     }
374 
375     immutable specifiedCommand = stripPrefix(plugin, event.content);
376 
377     if (!specifiedCommand.length)
378     {
379         // Only a prefix was supplied
380         return sendNoCommandSpecified();
381     }
382 
383     foreach (immutable pluginName, pluginCommands; allPluginCommands)
384     {
385         if (const command = specifiedCommand in pluginCommands)
386         {
387             return sendCommandHelpImpl(
388                 plugin,
389                 pluginName,
390                 event,
391                 specifiedCommand,
392                 command.description,
393                 command.syntaxes);
394         }
395     }
396 
397     // If we're here there were no command matches
398     immutable message = "No such command found: <b>" ~ specifiedCommand ~ "<b>";
399     privmsg(plugin.state, event.channel, event.sender.nickname, message);
400 }
401 
402 
403 // filterHiddenCommands
404 /++
405     Filters out hidden commands from an associative array of [IRCPlugin.CommandMetadata].
406 
407     Params:
408         aa = An unfiltered associative array of command metadata.
409 
410     Returns:
411         A filtered associative array of command metadata.
412  +/
413 auto filterHiddenCommands(IRCPlugin.CommandMetadata[string] aa)
414 {
415     import std.algorithm.iteration : filter;
416     import std.array : assocArray, byPair;
417 
418     return aa
419         .byPair
420         .filter!(pair => !pair[1].hidden)
421         .assocArray;
422 }
423 
424 
425 // addPrefix
426 /++
427     Adds a prefix to a command word; the command prefix if the passed `policy` is
428     [kameloso.plugins.common.core.PrefixPolicy.prefixed], the bot nickname if it is
429     [kameloso.plugins.common.core.PrefixPolicy.nickname], and as is if it is
430     [kameloso.plugins.common.core.PrefixPolicy.direct].
431 
432     Params:
433         plugin = The current [HelpPlugin].
434         word = Command word to add a prefix to.
435         policy = The prefix policy of the command `word` relates to.
436 
437     Returns:
438         The passed `word`, optionally with a prefix prepended.
439  +/
440 auto addPrefix(HelpPlugin plugin, const string word, const PrefixPolicy policy)
441 {
442     with (PrefixPolicy)
443     final switch (policy)
444     {
445     case direct:
446         return word;
447 
448     case prefixed:
449         return plugin.state.settings.prefix ~ word;
450 
451     case nickname:
452         return plugin.state.client.nickname[0..1] ~ ':' ~ word;
453     }
454 }
455 
456 
457 // stripPrefix
458 /++
459     Strips any prefixes from the passed string; prefixes being the command prefix,
460     the bot's nickname, or the shorthand with only the first letter of the bot's nickname.
461 
462     Params:
463         plugin = The current [HelpPlugin].
464         prefixed = The prefixed string, to strip the prefix of.
465 
466     Returns:
467         The passed `prefixed` string with any prefixes sliced away.
468  +/
469 auto stripPrefix(HelpPlugin plugin, const string prefixed)
470 {
471     import lu.string : beginsWith;
472 
473     static string sliceAwaySeparators(const string orig)
474     {
475         string slice = orig;  // mutable
476 
477         outer:
478         while (slice.length > 0)
479         {
480             switch (slice[0])
481             {
482             case ':':
483             case '!':
484             case '?':
485             case ' ':
486                 slice = slice[1..$];
487                 break;
488 
489             default:
490                 break outer;
491             }
492         }
493 
494         return slice;
495     }
496 
497     if (prefixed.beginsWith(plugin.state.settings.prefix))
498     {
499         return prefixed[plugin.state.settings.prefix.length..$];
500     }
501     else if (prefixed.beginsWith(plugin.state.client.nickname))
502     {
503         return sliceAwaySeparators(prefixed[plugin.state.client.nickname.length..$]);
504     }
505     else if (prefixed.beginsWith(plugin.state.client.nickname[0..1] ~ ':'))
506     {
507         return sliceAwaySeparators(prefixed[2..$]);
508     }
509     else
510     {
511         return prefixed;
512     }
513 }
514 
515 
516 mixin MinimalAuthentication;
517 mixin PluginRegistration!HelpPlugin;
518 
519 public:
520 
521 
522 // HelpPlugin
523 /++
524     The Help plugin serves the `help` command.
525 
526     This was originally part of the Chatbot, but it was deemed important enough
527     to warrant its own plugin, so that the Chatbot could be disabled while
528     keeping this around.
529  +/
530 final class HelpPlugin : IRCPlugin
531 {
532 private:
533     /// All Help plugin settings gathered.
534     HelpSettings helpSettings;
535 
536     mixin IRCPluginImpl;
537 }