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 }