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 }