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