1 /++ 2 A simple counter plugin. 3 4 Allows you to define runtime `!word` counters that you can increment, 5 decrement or assign specific values to. This can be used to track deaths in 6 video games, for instance. 7 8 See_Also: 9 https://github.com/zorael/kameloso/wiki/Current-plugins#counter, 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.counter; 20 21 version(WithCounterPlugin): 22 23 private: 24 25 import kameloso.plugins; 26 import kameloso.plugins.common.core; 27 import kameloso.plugins.common.awareness : MinimalAuthentication; 28 import kameloso.messaging; 29 import dialect.defs; 30 import std.typecons : Flag, No, Yes; 31 32 33 // CounterSettings 34 /++ 35 All Counter plugin settings aggregated. 36 +/ 37 @Settings struct CounterSettings 38 { 39 /++ 40 Whether or not this plugin should react to any events. 41 +/ 42 @Enabler bool enabled = true; 43 44 /++ 45 User level required to bump a counter. 46 +/ 47 IRCUser.Class minimumPermissionsNeeded = IRCUser.Class.elevated; 48 } 49 50 51 // Counter 52 /++ 53 Embodiment of a counter. Literally just a number with some ancillary metadata. 54 +/ 55 struct Counter 56 { 57 private: 58 import std.json : JSONValue; 59 60 public: 61 /++ 62 Current count. 63 +/ 64 long count; 65 66 /++ 67 Counter word. 68 +/ 69 string word; 70 71 /++ 72 The pattern to use when formatting answers to counter queries; 73 e.g. "The current $word count is currently $count.". 74 75 See_Also: 76 [formatMessage] 77 +/ 78 string patternQuery = "<b>$word<b> count so far: <b>$count<b>"; 79 80 /++ 81 The pattern to use when formatting confirmations of counter increments; 82 e.g. "$word count was increased by +$step and is now $count!". 83 84 See_Also: 85 [formatMessage] 86 +/ 87 string patternIncrement = "<b>$word +$step<b>! Current count: <b>$count<b>"; 88 89 /++ 90 The pattern to use when formatting confirmations of counter decrements; 91 e.g. "$word count was decreased by -$step and is now $count!". 92 93 See_Also: 94 [formatMessage] 95 +/ 96 string patternDecrement = "<b>$word -$step<b>! Current count: <b>$count<b>"; 97 98 /++ 99 The pattern to use when formatting confirmations of counter assignments; 100 e.g. "$word count was reset to $count!" 101 102 See_Also: 103 [formatMessage] 104 +/ 105 string patternAssign = "<b>$word<b> count assigned to <b>$count<b>!"; 106 107 /++ 108 Constructor. Only kept as a compatibility measure to ensure [word] alawys 109 has a value. Remove later. 110 +/ 111 this(const string word) 112 { 113 this.word = word; 114 } 115 116 // toJSON 117 /++ 118 Serialises this [Counter] into a JSON representation. 119 120 Returns: 121 A [std.json.JSONValue|JSONValue] that represents this [Counter]. 122 +/ 123 auto toJSON() const 124 { 125 JSONValue json; 126 json = null; 127 json.object = null; 128 129 json["count"] = JSONValue(count); 130 json["word"] = JSONValue(word); 131 json["patternQuery"] = JSONValue(patternQuery); 132 json["patternIncrement"] = JSONValue(patternIncrement); 133 json["patternDecrement"] = JSONValue(patternDecrement); 134 json["patternAssign"] = JSONValue(patternAssign); 135 return json; 136 } 137 138 // fromJSON 139 /++ 140 Deserialises a [Counter] from a JSON representation. 141 142 Params: 143 json = [std.json.JSONValue|JSONValue] to build a [Counter] from. 144 +/ 145 static auto fromJSON(const JSONValue json) 146 { 147 import std.json : JSONException, JSONType; 148 149 Counter counter; 150 151 if (json.type == JSONType.integer) 152 { 153 // Old format 154 counter.count = json.integer; 155 } 156 else if (json.type == JSONType.object) 157 { 158 // New format 159 counter.count = json["count"].integer; 160 counter.word = json["word"].str; 161 counter.patternQuery = json["patternQuery"].str; 162 counter.patternIncrement = json["patternIncrement"].str; 163 counter.patternDecrement = json["patternDecrement"].str; 164 counter.patternAssign = json["patternAssign"].str; 165 } 166 else 167 { 168 throw new JSONException("Malformed counter file entry"); 169 } 170 171 return counter; 172 } 173 174 // resetEmptyPatterns 175 /++ 176 Resets empty patterns with their default strings. 177 +/ 178 void resetEmptyPatterns() 179 { 180 const Counter counterInit; 181 if (!patternQuery.length) patternQuery = counterInit.patternQuery; 182 if (!patternIncrement.length) patternIncrement = counterInit.patternIncrement; 183 if (!patternDecrement.length) patternDecrement = counterInit.patternDecrement; 184 if (!patternAssign.length) patternAssign = counterInit.patternAssign; 185 } 186 } 187 188 189 // onCommandCounter 190 /++ 191 Manages runtime counters (adding, removing and listing). 192 +/ 193 @(IRCEventHandler() 194 .onEvent(IRCEvent.Type.CHAN) 195 .permissionsRequired(Permissions.operator) 196 .channelPolicy(ChannelPolicy.home) 197 .addCommand( 198 IRCEventHandler.Command() 199 .word("counter") 200 .policy(PrefixPolicy.prefixed) 201 .description("Adds, removes or lists counters.") 202 .addSyntax("$command add [counter word]") 203 .addSyntax("$command del [counter word]") 204 .addSyntax("$command format [counter word] [?+-=] [format pattern]") 205 .addSyntax("$command list") 206 ) 207 ) 208 void onCommandCounter(CounterPlugin plugin, const /*ref*/ IRCEvent event) 209 { 210 import kameloso.constants : BufferSize; 211 import lu.string : nom, stripped, strippedLeft; 212 import std.algorithm.comparison : among; 213 import std.algorithm.searching : canFind; 214 import std.format : format; 215 216 void sendUsage() 217 { 218 enum pattern = "Usage: <b>%s%s<b> [add|del|format|list] [counter word]"; 219 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 220 chan(plugin.state, event.channel, message); 221 } 222 223 void sendFormatUsage() 224 { 225 enum pattern = "Usage: <b>%s%s format<b> [counter word] [one of ?, +, - and =] [format pattern]"; 226 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 227 chan(plugin.state, event.channel, message); 228 } 229 230 void sendMustBeUniqueAndMayNotContain() 231 { 232 enum message = "Counter words must be unique and may not contain any of " ~ 233 "the following characters: [<b>+-=?<b>]"; 234 chan(plugin.state, event.channel, message); 235 } 236 237 void sendCounterAlreadyExists() 238 { 239 enum message = "A counter with that name already exists."; 240 chan(plugin.state, event.channel, message); 241 } 242 243 void sendNoSuchCounter() 244 { 245 enum message = "No such counter available."; 246 chan(plugin.state, event.channel, message); 247 } 248 249 void sendCounterRemoved(const string word) 250 { 251 enum pattern = "Counter <b>%s<b> removed."; 252 immutable message = pattern.format(word); 253 chan(plugin.state, event.channel, message); 254 } 255 256 void sendNoCountersActive() 257 { 258 enum message = "No counters currently active in this channel."; 259 chan(plugin.state, event.channel, message); 260 } 261 262 void sendCountersList(const string[] counters) 263 { 264 enum pattern = "Current counters: %s"; 265 immutable arrayPattern = "%-(<b>" ~ plugin.state.settings.prefix ~ "%s<b>, %)<b>"; 266 immutable list = arrayPattern.format(counters); 267 immutable message = pattern.format(list); 268 chan(plugin.state, event.channel, message); 269 } 270 271 void sendFormatPatternUpdated() 272 { 273 enum message = "Format pattern updated."; 274 chan(plugin.state, event.channel, message); 275 } 276 277 void sendFormatPatternCleared() 278 { 279 enum message = "Format pattern cleared."; 280 chan(plugin.state, event.channel, message); 281 } 282 283 void sendCurrentFormatPattern(const string mod, const string customPattern) 284 { 285 enum pattern = `Current <b>%s<b> format pattern: "<b>%s<b>"`; 286 immutable message = pattern.format(mod, customPattern); 287 chan(plugin.state, event.channel, message); 288 } 289 290 void sendNoFormatPattern(const string word) 291 { 292 enum pattern = "Counter <b>%s<b> does not have a custom format pattern."; 293 immutable message = pattern.format(word); 294 chan(plugin.state, event.channel, message); 295 } 296 297 string slice = event.content.stripped; // mutable 298 immutable verb = slice.nom!(Yes.inherit)(' '); 299 slice = slice.strippedLeft; 300 301 switch (verb) 302 { 303 case "add": 304 import kameloso.constants : BufferSize; 305 import kameloso.thread : CarryingFiber; 306 import std.typecons : Tuple; 307 import core.thread : Fiber; 308 309 if (!slice.length) goto default; 310 311 if (slice.canFind!(c => c.among!('+', '-', '=', '?'))) 312 { 313 return sendMustBeUniqueAndMayNotContain(); 314 } 315 316 if ((event.channel in plugin.counters) && (slice in plugin.counters[event.channel])) 317 { 318 return sendCounterAlreadyExists(); 319 } 320 321 /+ 322 We need to check both hardcoded and soft channel-specific commands 323 for conflicts. 324 +/ 325 326 bool triggerConflicts(const IRCPlugin.CommandMetadata[string][string] aa) 327 { 328 foreach (immutable pluginName, pluginCommands; aa) 329 { 330 if (!pluginCommands.length || (pluginName == "counter")) continue; 331 332 if (slice in pluginCommands) 333 { 334 enum pattern = `Counter word "<b>%s<b>" conflicts with a command of the <b>%s<b> plugin.`; 335 immutable message = pattern.format(slice, pluginName); 336 chan(plugin.state, event.channel, message); 337 return true; 338 } 339 } 340 return false; 341 } 342 343 alias Payload = Tuple!(IRCPlugin.CommandMetadata[string][string]); 344 345 void addCounterDg() 346 { 347 auto thisFiber = cast(CarryingFiber!Payload)Fiber.getThis; 348 assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof); 349 350 IRCPlugin.CommandMetadata[string][string] aa = thisFiber.payload[0]; 351 if (triggerConflicts(aa)) return; 352 353 // Get channel AAs 354 plugin.state.specialRequests ~= specialRequest(event.channel, thisFiber); 355 Fiber.yield(); 356 357 IRCPlugin.CommandMetadata[string][string] channelSpecificAA = thisFiber.payload[0]; 358 if (triggerConflicts(channelSpecificAA)) return; 359 360 // If we're here there were no conflicts 361 plugin.counters[event.channel][slice] = Counter(slice); 362 saveCounters(plugin); 363 364 enum pattern = "Counter <b>%s<b> added! Access it with <b>%s%s<b>."; 365 immutable message = pattern.format(slice, plugin.state.settings.prefix, slice); 366 chan(plugin.state, event.channel, message); 367 } 368 369 plugin.state.specialRequests ~= specialRequest!Payload(string.init, &addCounterDg); 370 break; 371 372 case "remove": 373 case "del": 374 if (!slice.length) goto default; 375 376 auto channelCounters = event.channel in plugin.counters; 377 if (!channelCounters || (slice !in *channelCounters)) return sendNoSuchCounter(); 378 379 (*channelCounters).remove(slice); 380 if (!channelCounters.length) plugin.counters.remove(event.channel); 381 saveCounters(plugin); 382 383 return sendCounterRemoved(slice); 384 385 case "format": 386 import lu.string : SplitResults, splitInto; 387 import std.algorithm.comparison : among; 388 389 string word; 390 string mod; 391 immutable results = slice.splitInto(word, mod); 392 393 with (SplitResults) 394 final switch (results) 395 { 396 case match: 397 // No pattern given but an empty query is ok 398 break; 399 400 case overrun: 401 // Pattern given 402 break; 403 404 case underrun: 405 // Not enough parameters 406 return sendFormatUsage(); 407 } 408 409 if (!mod.among!("?", "+", "-", "=")) 410 { 411 return sendFormatUsage(); 412 } 413 414 auto channelCounters = event.channel in plugin.counters; 415 if (!channelCounters) return sendNoSuchCounter(); 416 417 auto counter = word in *channelCounters; 418 if (!counter) return sendNoSuchCounter(); 419 420 alias newPattern = slice; 421 422 if (newPattern == "-") 423 { 424 if (mod == "?") counter.patternQuery = string.init; 425 else if (mod == "+") counter.patternIncrement = string.init; 426 else if (mod == "-") counter.patternDecrement = string.init; 427 else if (mod == "=") counter.patternAssign = string.init; 428 else assert(0, "Impossible case"); 429 430 saveCounters(plugin); 431 return sendFormatPatternCleared(); 432 } 433 else if (newPattern.length) 434 { 435 if (mod == "?") counter.patternQuery = newPattern; 436 else if (mod == "+") counter.patternIncrement = newPattern; 437 else if (mod == "-") counter.patternDecrement = newPattern; 438 else if (mod == "=") counter.patternAssign = newPattern; 439 else assert(0, "Impossible case"); 440 441 saveCounters(plugin); 442 return sendFormatPatternUpdated(); 443 } 444 else 445 { 446 immutable modverb = 447 (mod == "?") ? "query" : 448 (mod == "+") ? "increment" : 449 (mod == "-") ? "decrement" : 450 (mod == "=") ? "assign" : 451 string.init; 452 immutable pattern = 453 (mod == "?") ? counter.patternQuery : 454 (mod == "+") ? counter.patternIncrement : 455 (mod == "-") ? counter.patternDecrement : 456 (mod == "=") ? counter.patternAssign : 457 string.init; 458 459 if (!modverb.length || !pattern.length) assert(0, "Impossible case"); 460 return sendCurrentFormatPattern(modverb, pattern); 461 } 462 463 case "list": 464 if (event.channel !in plugin.counters) return sendNoCountersActive(); 465 return sendCountersList(plugin.counters[event.channel].keys); 466 467 default: 468 return sendUsage(); 469 } 470 } 471 472 473 // onCounterWord 474 /++ 475 Allows users to increment, decrement, and set counters. 476 477 This function fakes 478 [kameloso.plugins.common.core.IRCEventHandler.Command|IRCEventHandler.Command]s by 479 listening for prefixes (and the bot's nickname), and treating whatever comes 480 after it as a command word. If it doesn't match a previously added counter, 481 it is ignored. 482 +/ 483 @(IRCEventHandler() 484 .onEvent(IRCEvent.Type.CHAN) 485 .permissionsRequired(Permissions.anyone) 486 .channelPolicy(ChannelPolicy.home) 487 ) 488 void onCounterWord(CounterPlugin plugin, const ref IRCEvent event) 489 { 490 import kameloso.string : stripSeparatedPrefix; 491 import lu.string : beginsWith, stripped, strippedLeft, strippedRight; 492 import std.conv : ConvException, text, to; 493 import std.format : format; 494 import std.meta : aliasSeqOf; 495 import std.string : indexOf; 496 497 void sendCurrentCount(const Counter counter) 498 { 499 immutable message = formatMessage( 500 plugin, 501 counter.patternQuery, 502 event, 503 counter); 504 chan(plugin.state, event.channel, message); 505 } 506 507 void sendCounterModified(const Counter counter, const long step) 508 { 509 immutable pattern = (step >= 0) ? counter.patternIncrement : counter.patternDecrement; 510 immutable message = formatMessage( 511 plugin, 512 pattern, 513 event, 514 counter, 515 step); 516 chan(plugin.state, event.channel, message); 517 } 518 519 void sendCounterAssigned(const Counter counter, const long step) 520 { 521 immutable message = formatMessage( 522 plugin, 523 counter.patternAssign, 524 event, 525 counter, 526 step); 527 chan(plugin.state, event.channel, message); 528 } 529 530 void sendInputIsNaN(const string input) 531 { 532 enum pattern = "<b>%s<b> is not a number."; 533 immutable message = pattern.format(input); 534 chan(plugin.state, event.channel, message); 535 } 536 537 void sendMustSpecifyNumber() 538 { 539 enum message = "You must specify a number to set the count to."; 540 chan(plugin.state, event.channel, message); 541 } 542 543 string slice = event.content.stripped; // mutable 544 if ((slice.length < (plugin.state.settings.prefix.length+1)) && // !w 545 (slice.length < (plugin.state.client.nickname.length+2))) return; // nickname:w 546 547 if (slice.beginsWith(plugin.state.settings.prefix)) 548 { 549 slice = slice[plugin.state.settings.prefix.length..$]; 550 } 551 else if (slice.beginsWith(plugin.state.client.nickname)) 552 { 553 slice = slice.stripSeparatedPrefix(plugin.state.client.nickname, Yes.demandSeparatingChars); 554 } 555 else 556 { 557 version(TwitchSupport) 558 { 559 if (plugin.state.client.displayName.length && slice.beginsWith(plugin.state.client.displayName)) 560 { 561 slice = slice.stripSeparatedPrefix(plugin.state.client.displayName, Yes.demandSeparatingChars); 562 } 563 else 564 { 565 // Just a random message 566 return; 567 } 568 } 569 else 570 { 571 // As above 572 return; 573 } 574 } 575 576 if (!slice.length) return; 577 578 auto channelCounters = event.channel in plugin.counters; 579 if (!channelCounters) return; 580 581 ptrdiff_t signPos; 582 583 foreach (immutable sign; aliasSeqOf!"?=+-") // '-' after '=' to support "!word=-5" 584 { 585 signPos = slice.indexOf(sign); 586 if (signPos != -1) break; 587 } 588 589 immutable word = (signPos != -1) ? slice[0..signPos].strippedRight : slice; 590 591 auto counter = word in *channelCounters; 592 if (!counter) return; 593 594 slice = (signPos != -1) ? slice[signPos..$] : string.init; 595 596 if (!slice.length || (slice[0] == '?')) 597 { 598 return sendCurrentCount(*counter); 599 } 600 601 // Limit modifications to the configured class 602 if (event.sender.class_ < plugin.counterSettings.minimumPermissionsNeeded) return; 603 604 assert(slice.length, "Empty slice after slicing"); 605 immutable sign = slice[0]; 606 607 switch (sign) 608 { 609 case '+': 610 case '-': 611 long step; 612 613 if ((slice == "+") || (slice == "++")) 614 { 615 step = 1; 616 } 617 else if ((slice == "-") || (slice == "--")) 618 { 619 step = -1; 620 } 621 else if (slice.length > 1) 622 { 623 slice = slice[1..$].strippedLeft; 624 step = (sign == '+') ? 1 : -1; // implicitly (sign == '-') 625 626 try 627 { 628 step = slice.to!long * step; 629 } 630 catch (ConvException _) 631 { 632 return sendInputIsNaN(slice); 633 } 634 } 635 636 counter.count += step; 637 saveCounters(plugin); 638 return sendCounterModified(*counter, step); 639 640 case '=': 641 slice = slice[1..$].strippedLeft; 642 643 if (!slice.length) 644 { 645 return sendMustSpecifyNumber(); 646 } 647 648 long newCount; 649 650 try 651 { 652 newCount = slice.to!long; 653 } 654 catch (ConvException _) 655 { 656 return sendInputIsNaN(slice); 657 } 658 659 immutable step = (newCount - counter.count); 660 counter.count = newCount; 661 saveCounters(plugin); 662 return sendCounterAssigned(*counter, step); 663 664 default: 665 assert(0, "Hit impossible default case in onCounterWord sign switch"); 666 } 667 } 668 669 670 // onWelcome 671 /++ 672 Populate the counters array after we have successfully logged onto the server. 673 +/ 674 @(IRCEventHandler() 675 .onEvent(IRCEvent.Type.RPL_WELCOME) 676 ) 677 void onWelcome(CounterPlugin plugin) 678 { 679 plugin.reload(); 680 } 681 682 683 // formatMessage 684 /++ 685 Formats a message by a string pattern, replacing select keywords with more 686 helpful values. 687 688 Example: 689 --- 690 immutable pattern = "The $word count was bumped by +$step to $count!"; 691 immutable message = formatMessage(plugin, pattern, event, counter, step); 692 assert(message == "The curse count was bumped by +1 to 92!"); 693 --- 694 695 Params: 696 plugin = The current [CounterPlugin]. 697 pattern = The custom string pattern we're formatting. 698 event = The [dialect.defs.IRCEvent|IRCEvent] that triggered the format. 699 counter = The [Counter] that the message relates to. 700 step = By what step the counter was modified, if any. 701 702 Returns: 703 A new string, with keywords replaced. 704 +/ 705 auto formatMessage( 706 CounterPlugin plugin, 707 const string pattern, 708 const ref IRCEvent event, 709 const Counter counter, 710 const long step = long.init) 711 { 712 import kameloso.plugins.common.misc : nameOf; 713 import kameloso.string : replaceRandom; 714 import std.conv : to; 715 import std.array : replace; 716 import std.math : abs; 717 718 auto signedStep() 719 { 720 import std.conv : text; 721 return (step >= 0) ? 722 text('+', step) : 723 step.to!string; 724 } 725 726 string toReturn = pattern // mutable 727 .replace("$step", abs(step).to!string) 728 .replace("$signedstep", signedStep()) 729 .replace("$count", counter.count.to!string) 730 .replace("$word", counter.word) 731 .replace("$channel", event.channel) 732 .replace("$senderNickname", event.sender.nickname) 733 .replace("$sender", nameOf(event.sender)) 734 .replace("$botNickname", plugin.state.client.nickname) 735 .replace("$bot", nameOf(plugin, plugin.state.client.nickname)) 736 .replaceRandom(); 737 738 version(TwitchSupport) 739 { 740 if (plugin.state.server.daemon == IRCServer.Daemon.twitch) 741 { 742 toReturn = toReturn 743 .replace("$streamerNickname", event.channel[1..$]) 744 .replace("$streamer", nameOf(plugin, event.channel[1..$])); 745 } 746 } 747 748 return toReturn; 749 } 750 751 752 // reload 753 /++ 754 Reloads counters from disk. 755 +/ 756 void reload(CounterPlugin plugin) 757 { 758 return loadCounters(plugin); 759 } 760 761 762 // saveCounters 763 /++ 764 Saves [Counter]s to disk in JSON format. 765 766 Params: 767 plugin = The current [CounterPlugin]. 768 +/ 769 void saveCounters(CounterPlugin plugin) 770 { 771 import lu.json : JSONStorage; 772 import std.json : JSONType; 773 774 JSONStorage json; 775 776 foreach (immutable channelName, ref channelCounters; plugin.counters) 777 { 778 json[channelName] = null; 779 json[channelName].object = null; 780 781 foreach (immutable word, ref counter; channelCounters) 782 { 783 counter.resetEmptyPatterns(); 784 json[channelName][word] = counter.toJSON(); 785 } 786 } 787 788 if (json.type == JSONType.null_) json.object = null; // reset to type object if null_ 789 json.save(plugin.countersFile); 790 } 791 792 793 // loadCounters 794 /++ 795 Loads [Counter]s from disk. 796 797 Params: 798 plugin = The current [CounterPlugin]. 799 +/ 800 void loadCounters(CounterPlugin plugin) 801 { 802 import lu.json : JSONStorage; 803 804 JSONStorage json; 805 json.load(plugin.countersFile); 806 plugin.counters.clear(); 807 808 foreach (immutable channelName, channelCountersJSON; json.object) 809 { 810 // Initialise the AA 811 plugin.counters[channelName][string.init] = Counter.init; 812 auto channelCounters = channelName in plugin.counters; 813 (*channelCounters).remove(string.init); 814 815 foreach (immutable word, counterJSON; channelCountersJSON.object) 816 { 817 (*channelCounters)[word] = Counter.fromJSON(counterJSON); 818 auto counter = word in *channelCounters; 819 820 // Backwards compatibility with old counters files 821 if (!counter.word.length) 822 { 823 counter.word = word; 824 } 825 826 // ditto 827 counter.resetEmptyPatterns(); 828 } 829 830 (*channelCounters).rehash(); 831 } 832 833 plugin.counters.rehash(); 834 } 835 836 837 // initResources 838 /++ 839 Reads and writes the file of persistent counters to disk, ensuring that it's 840 there and properly formatted. 841 +/ 842 void initResources(CounterPlugin plugin) 843 { 844 import lu.json : JSONStorage; 845 import std.json : JSONException; 846 847 JSONStorage countersJSON; 848 849 try 850 { 851 countersJSON.load(plugin.countersFile); 852 } 853 catch (JSONException e) 854 { 855 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 856 import kameloso.common : logger; 857 858 version(PrintStacktraces) logger.trace(e); 859 throw new IRCPluginInitialisationException( 860 "Counters file is malformed", 861 plugin.name, 862 plugin.countersFile, 863 __FILE__, 864 __LINE__); 865 } 866 867 // Let other Exceptions pass. 868 869 countersJSON.save(plugin.countersFile); 870 } 871 872 873 mixin MinimalAuthentication; 874 mixin PluginRegistration!CounterPlugin; 875 876 public: 877 878 879 // CounterPlugin 880 /++ 881 The Counter plugin allows for users to define counter commands at runtime. 882 +/ 883 final class CounterPlugin : IRCPlugin 884 { 885 private: 886 /++ 887 All Counter plugin settings. 888 +/ 889 CounterSettings counterSettings; 890 891 /++ 892 [Counter]s by counter word by channel name. 893 +/ 894 Counter[string][string] counters; 895 896 /++ 897 Filename of file with persistent counters. 898 +/ 899 @Resource string countersFile = "counters.json"; 900 901 // channelSpecificCommands 902 /++ 903 Compile a list of our runtime counter commands. 904 905 Params: 906 channelName = Name of channel whose commands we want to summarise. 907 908 Returns: 909 An associative array of 910 [kameloso.plugins.common.core.IRCPlugin.CommandMetadata|IRCPlugin.CommandMetadata]s, 911 one for each counter active in the passed channel. 912 +/ 913 override public IRCPlugin.CommandMetadata[string] channelSpecificCommands(const string channelName) @system 914 { 915 IRCPlugin.CommandMetadata[string] aa; 916 917 const channelCounters = channelName in counters; 918 if (!channelCounters) return aa; 919 920 foreach (immutable trigger, immutable _; *channelCounters) 921 { 922 IRCPlugin.CommandMetadata metadata; 923 metadata.description = "A counter"; 924 aa[trigger] = metadata; 925 } 926 927 return aa; 928 } 929 930 mixin IRCPluginImpl; 931 }