1 /++ 2 Plugin offering announcement timers; routines that periodically sends lines 3 of text to a channel. 4 5 See_Also: 6 https://github.com/zorael/kameloso/wiki/Current-plugins#timer, 7 [kameloso.plugins.common.core], 8 [kameloso.plugins.common.misc] 9 10 Copyright: [JR](https://github.com/zorael) 11 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 12 13 Authors: 14 [JR](https://github.com/zorael) 15 +/ 16 module kameloso.plugins.timer; 17 18 version(WithTimerPlugin): 19 20 private: 21 22 import kameloso.plugins; 23 import kameloso.plugins.common.core; 24 import kameloso.plugins.common.awareness : MinimalAuthentication, UserAwareness; 25 import kameloso.common : logger; 26 import kameloso.messaging; 27 import dialect.defs; 28 import std.typecons : Flag, No, Yes; 29 import core.thread : Fiber; 30 31 32 // TimerSettings 33 /++ 34 All [TimerPlugin] runtime settings, aggregated in a struct. 35 +/ 36 @Settings struct TimerSettings 37 { 38 /++ 39 Toggle whether or not this plugin should do anything at all. 40 +/ 41 @Enabler bool enabled = true; 42 } 43 44 45 // Timer 46 /++ 47 Definitions of a timer. 48 +/ 49 struct Timer 50 { 51 private: 52 import std.json : JSONValue; 53 54 public: 55 /++ 56 The different kinds of [Timer]s. Either one that yields a 57 [Type.random|random] response each time, or one that yields a 58 [Type.ordered|ordered] one. 59 +/ 60 enum Type 61 { 62 /++ 63 Lines should be yielded in a random (technically uniform) order. 64 +/ 65 random = 0, 66 67 /++ 68 Lines should be yielded in order, bumping an internal counter. 69 +/ 70 ordered = 1, 71 } 72 73 /++ 74 Conditions upon which timers decide whether they are to fire yet, or wait still. 75 +/ 76 enum Condition 77 { 78 /++ 79 Both message count and time criteria must be fulfilled. 80 +/ 81 both = 0, 82 83 /++ 84 Either message count or time criteria may be fulfilled. 85 +/ 86 either = 1, 87 } 88 89 /++ 90 String name identifier of this timer. 91 +/ 92 string name; 93 94 /++ 95 The timered lines to send to the channel. 96 +/ 97 string[] lines; 98 99 /++ 100 What type of [Timer] this is. 101 +/ 102 Type type; 103 104 /++ 105 Workhorse [core.thread.fiber.Fiber|Fiber]. 106 +/ 107 Fiber fiber; 108 109 /++ 110 What message/time conditions this [Timer] abides by. 111 +/ 112 Condition condition; 113 114 /++ 115 How many messages must have been sent since the last announce before we 116 will allow another one. 117 +/ 118 long messageCountThreshold; 119 120 /++ 121 How many seconds must have passed since the last announce before we will 122 allow another one. 123 +/ 124 long timeThreshold; 125 126 /++ 127 Delay in number of messages before the timer initially comes into effect. 128 +/ 129 long messageCountStagger; 130 131 /++ 132 Delay in seconds before the timer initially comes into effect. 133 +/ 134 long timeStagger; 135 136 /++ 137 The channel message count at last successful trigger. 138 +/ 139 ulong lastMessageCount; 140 141 /++ 142 The timestamp at the last successful trigger. 143 +/ 144 long lastTimestamp; 145 146 /++ 147 The current position, kept to keep track of what line should be yielded 148 next in the case of ordered timers. 149 +/ 150 size_t position; 151 152 /++ 153 Whether or not this [Timer] is suspended and should not output anything. 154 +/ 155 bool suspended; 156 157 // getLine 158 /++ 159 Yields a line from the [lines] array, depending on the [type] of this timer. 160 161 Returns: 162 A line string. If the [lines] array is empty, then an empty string 163 is returned instead. 164 +/ 165 auto getLine() 166 { 167 return (type == Type.random) ? 168 randomLine() : 169 nextOrderedLine(); 170 } 171 172 // nextOrderedLine 173 /++ 174 Yields an ordered line from the [lines] array. Which line is selected 175 depends on the value of [position]. 176 177 Returns: 178 A line string. If the [lines] array is empty, then an empty string 179 is returned instead. 180 +/ 181 auto nextOrderedLine() 182 { 183 if (!lines.length) return string.init; 184 185 size_t i = position++; // mutable 186 187 if (i >= lines.length) 188 { 189 // Position needs to be zeroed on response removals 190 i = 0; 191 position = 1; 192 } 193 else if (position >= lines.length) 194 { 195 position = 0; 196 } 197 198 return lines[i]; 199 } 200 201 // randomLine 202 /++ 203 Yields a random line from the [lines] array. 204 205 Returns: 206 A line string. If the [lines] array is empty, then an empty string 207 is returned instead. 208 +/ 209 auto randomLine() const 210 { 211 import std.random : uniform; 212 return lines.length ? 213 lines[uniform(0, lines.length)] : 214 string.init; 215 } 216 217 // toJSON 218 /++ 219 Serialises this [Timer] into a [std.json.JSONValue|JSONValue]. 220 221 Returns: 222 A [std.json.JSONValue|JSONValue] that describes this timer. 223 +/ 224 auto toJSON() const 225 { 226 JSONValue json; 227 json = null; 228 json.object = null; 229 230 json["name"] = JSONValue(this.name); 231 json["type"] = JSONValue(cast(int)this.type); 232 json["condition"] = JSONValue(cast(int)this.condition); 233 json["messageCountThreshold"] = JSONValue(this.messageCountThreshold); 234 json["timeThreshold"] = JSONValue(this.timeThreshold); 235 json["messageCountStagger"] = JSONValue(this.messageCountStagger); 236 json["timeStagger"] = JSONValue(this.timeStagger); 237 json["suspended"] = JSONValue(this.suspended); 238 json["lines"] = null; 239 json["lines"].array = null; 240 241 foreach (immutable line; this.lines) 242 { 243 json["lines"].array ~= JSONValue(line); 244 } 245 246 return json; 247 } 248 249 // fromJSON 250 /++ 251 Deserialises a [Timer] from a [std.json.JSONValue|JSONValue]. 252 253 Params: 254 json = [std.json.JSONValue|JSONValue] to deserialise. 255 256 Returns: 257 A new [Timer] with values loaded from the passed JSON. 258 +/ 259 static auto fromJSON(const JSONValue json) 260 { 261 Timer timer; 262 timer.name = json["name"].str; 263 timer.messageCountThreshold = json["messageCountThreshold"].integer; 264 timer.timeThreshold = json["timeThreshold"].integer; 265 timer.messageCountStagger = json["messageCountStagger"].integer; 266 timer.timeStagger = json["timeStagger"].integer; 267 timer.type = (json["type"].integer == cast(int)Type.random) ? 268 Type.random : 269 Type.ordered; 270 timer.condition = (json["condition"].integer == cast(int)Condition.both) ? 271 Condition.both : 272 Condition.either; 273 274 // Compatibility with older versions, remove later 275 if (const suspendedJSON = "suspended" in json.object) 276 { 277 timer.suspended = suspendedJSON.boolean; 278 } 279 280 foreach (const lineJSON; json["lines"].array) 281 { 282 timer.lines ~= lineJSON.str; 283 } 284 285 return timer; 286 } 287 } 288 289 /// 290 unittest 291 { 292 Timer timer; 293 timer.lines = [ "abc", "def", "ghi" ]; 294 295 { 296 timer.type = Timer.Type.ordered; 297 assert(timer.getLine() == "abc"); 298 assert(timer.getLine() == "def"); 299 assert(timer.getLine() == "ghi"); 300 assert(timer.getLine() == "abc"); 301 assert(timer.getLine() == "def"); 302 assert(timer.getLine() == "ghi"); 303 } 304 { 305 import std.algorithm.comparison : among; 306 307 timer.type = Timer.Type.random; 308 bool[string] linesSeen; 309 310 foreach (immutable i; 0..300) 311 { 312 linesSeen[timer.getLine()] = true; 313 } 314 315 assert("abc" in linesSeen); 316 assert("def" in linesSeen); 317 assert("ghi" in linesSeen); 318 } 319 } 320 321 322 // onCommandTimer 323 /++ 324 Adds, deletes or lists timers for the specified target channel. 325 326 Changes are persistently saved to the [TimerPlugin.timersFile] file. 327 +/ 328 @(IRCEventHandler() 329 .onEvent(IRCEvent.Type.CHAN) 330 .permissionsRequired(Permissions.operator) 331 .channelPolicy(ChannelPolicy.home) 332 .addCommand( 333 IRCEventHandler.Command() 334 .word("timer") 335 .policy(PrefixPolicy.prefixed) 336 .description("Adds, removes or lists timers.") 337 .addSyntax("$command new [name] [type] [condition] [message count threshold] " ~ 338 "[time threshold] [stagger message count] [stagger time]") 339 .addSyntax("$command add [existing timer name] [new timer line]") 340 .addSyntax("$command insert [timer name] [position] [new timer line]") 341 .addSyntax("$command edit [timer name] [position] [new timer line]") 342 .addSyntax("$command del [timer name] [optional line number]") 343 .addSyntax("$command suspend [timer name]") 344 .addSyntax("$command resume [timer name]") 345 .addSyntax("$command list") 346 ) 347 ) 348 void onCommandTimer(TimerPlugin plugin, const ref IRCEvent event) 349 { 350 import lu.string : nom, stripped; 351 import std.format : format; 352 353 void sendUsage() 354 { 355 enum pattern = "Usage: <b>%s%s<b> [new|add|del|suspend|resume|list] ..."; 356 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 357 chan(plugin.state, event.channel, message); 358 } 359 360 string slice = event.content.stripped; // mutable 361 immutable verb = slice.nom!(Yes.inherit)(' '); 362 363 switch (verb) 364 { 365 case "new": 366 return handleNewTimer(plugin, event, slice); 367 368 case "insert": 369 return handleModifyTimerLines(plugin, event, slice, Yes.insert); 370 371 case "edit": 372 return handleModifyTimerLines(plugin, event, slice, No.insert); // --> Yes.edit 373 374 case "add": 375 return handleAddToTimer(plugin, event, slice); 376 377 case "del": 378 return handleDelTimer(plugin, event, slice); 379 380 case "suspend": 381 return handleSuspendTimer(plugin, event, slice, Yes.suspend); 382 383 case "resume": 384 return handleSuspendTimer(plugin, event, slice, No.suspend); // --> Yes.resume 385 386 case "list": 387 return handleListTimers(plugin, event); 388 389 default: 390 return sendUsage(); 391 } 392 } 393 394 395 // handleNewTimer 396 /++ 397 Creates a new timer. 398 399 Params: 400 plugin = The current [TimerPlugin]. 401 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the creation. 402 slice = Relevant slice of the original request string. 403 +/ 404 void handleNewTimer( 405 TimerPlugin plugin, 406 const /*ref*/ IRCEvent event, 407 /*const*/ string slice) 408 { 409 import kameloso.time : DurationStringException, abbreviatedDuration; 410 import lu.string : SplitResults, splitInto; 411 import std.conv : ConvException, to; 412 import std.format : format; 413 414 void sendNewUsage() 415 { 416 enum pattern = "Usage: <b>%s%s new<b> [name] [type] [condition] [message count threshold] " ~ 417 "[time threshold] [stagger message count] [stagger time]"; 418 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 419 chan(plugin.state, event.channel, message); 420 } 421 422 void sendBadNumerics() 423 { 424 enum message = "Arguments for threshold and stagger values must all be positive numbers."; 425 chan(plugin.state, event.channel, message); 426 } 427 428 void sendZeroedConditions() 429 { 430 enum message = "A timer cannot have a message threshold *and* a time threshold of zero."; 431 chan(plugin.state, event.channel, message); 432 } 433 434 Timer timer; 435 436 string type; 437 string condition; 438 string messageCountThreshold; 439 string timeThreshold; 440 string messageCountStagger; 441 string timeStagger; 442 443 immutable results = slice.splitInto( 444 timer.name, 445 type, 446 condition, 447 messageCountThreshold, 448 timeThreshold, 449 messageCountStagger, 450 timeStagger); 451 452 with (SplitResults) 453 final switch (results) 454 { 455 case match: 456 break; 457 458 case underrun: 459 if (messageCountThreshold.length) break; 460 else 461 { 462 return sendNewUsage(); 463 } 464 465 case overrun: 466 return sendNewUsage(); 467 } 468 469 switch (type) 470 { 471 case "random": 472 timer.type = Timer.Type.random; 473 break; 474 475 case "ordered": 476 timer.type = Timer.Type.ordered; 477 break; 478 479 default: 480 enum message = "Type must be one of <b>random<b> or <b>ordered<b>."; 481 return chan(plugin.state, event.channel, message); 482 } 483 484 switch (condition) 485 { 486 case "both": 487 timer.condition = Timer.Condition.both; 488 break; 489 490 case "either": 491 timer.condition = Timer.Condition.either; 492 break; 493 494 default: 495 enum message = "Condition must be one of <b>both<b> or <b>either<b>."; 496 return chan(plugin.state, event.channel, message); 497 } 498 499 try 500 { 501 timer.messageCountThreshold = messageCountThreshold.to!long; 502 timer.timeThreshold = abbreviatedDuration(timeThreshold).total!"seconds"; 503 if (messageCountStagger.length) timer.messageCountStagger = messageCountStagger.to!long; 504 if (timeStagger.length) timer.timeStagger = abbreviatedDuration(timeStagger).total!"seconds"; 505 } 506 catch (ConvException _) 507 { 508 return sendBadNumerics(); 509 } 510 catch (DurationStringException e) 511 { 512 return chan(plugin.state, event.channel, e.msg); 513 } 514 515 if ((timer.messageCountThreshold < 0) || 516 (timer.timeThreshold < 0) || 517 (timer.messageCountStagger < 0) || 518 (timer.timeStagger < 0)) 519 { 520 return sendBadNumerics(); 521 } 522 else if ((timer.messageCountThreshold == 0) && (timer.timeThreshold == 0)) 523 { 524 return sendZeroedConditions(); 525 } 526 527 auto channel = event.channel in plugin.channels; 528 assert(channel, "Tried to create a timer in a channel with no Channel in plugin.channels"); 529 530 timer.lastMessageCount = channel.messageCount; 531 timer.lastTimestamp = event.time; 532 timer.fiber = createTimerFiber(plugin, event.channel, timer.name); 533 534 plugin.timersByChannel[event.channel][timer.name] = timer; 535 channel.timerPointers[timer.name] = &plugin.timersByChannel[event.channel][timer.name]; 536 537 enum appendPattern = "New timer added! Use <b>%s%s add<b> to add lines."; 538 immutable message = appendPattern.format(plugin.state.settings.prefix, event.aux[$-1]); 539 chan(plugin.state, event.channel, message); 540 } 541 542 543 // handleDelTimer 544 /++ 545 Deletes an existing timer. 546 547 Params: 548 plugin = The current [TimerPlugin]. 549 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the deletion. 550 slice = Relevant slice of the original request string. 551 +/ 552 void handleDelTimer( 553 TimerPlugin plugin, 554 const ref IRCEvent event, 555 /*const*/ string slice) 556 { 557 import lu.string : SplitResults, splitInto; 558 import std.format : format; 559 560 void sendDelUsage() 561 { 562 enum pattern = "Usage: <b>%s%s del<b> [timer name] [optional line number]"; 563 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 564 chan(plugin.state, event.channel, message); 565 } 566 567 void sendNoSuchTimer() 568 { 569 enum message = "There is no timer by that name."; 570 chan(plugin.state, event.channel, message); 571 } 572 573 if (!slice.length) return sendDelUsage(); 574 575 auto channel = event.channel in plugin.channels; 576 if (!channel) return sendNoSuchTimer(); 577 578 string name; 579 string linePosString; 580 581 immutable results = slice.splitInto(name, linePosString); 582 583 with (SplitResults) 584 final switch (results) 585 { 586 case underrun: 587 // Remove the entire timer 588 if (!name.length) return sendDelUsage(); 589 590 const timerPtr = name in channel.timerPointers; 591 if (!timerPtr) return sendNoSuchTimer(); 592 593 channel.timerPointers.remove(name); 594 if (!channel.timerPointers.length) plugin.channels.remove(event.channel); 595 596 auto channelTimers = event.channel in plugin.timersByChannel; 597 (*channelTimers).remove(name); 598 if (!channelTimers.length) plugin.timersByChannel.remove(event.channel); 599 600 saveTimersToDisk(plugin); 601 enum message = "Timer removed."; 602 return chan(plugin.state, event.channel, message); 603 604 case match: 605 import std.conv : ConvException, to; 606 607 // Remove the specified lines position 608 auto channelTimers = event.channel in plugin.timersByChannel; 609 if (!channelTimers) return sendNoSuchTimer(); 610 611 auto timer = name in *channelTimers; 612 if (!timer) return sendNoSuchTimer(); 613 614 try 615 { 616 import std.algorithm.mutation : SwapStrategy, remove; 617 618 immutable linePos = linePosString.to!size_t; 619 timer.lines = timer.lines.remove!(SwapStrategy.stable)(linePos); 620 saveTimersToDisk(plugin); 621 622 enum pattern = "Line removed from timer <b>%s<b>. Lines remaining: <b>%d<b>"; 623 immutable message = pattern.format(name, timer.lines.length); 624 return chan(plugin.state, event.channel, message); 625 } 626 catch (ConvException _) 627 { 628 enum message = "Argument for which line to remove must be a number."; 629 return chan(plugin.state, event.channel, message); 630 } 631 632 case overrun: 633 sendDelUsage(); 634 } 635 } 636 637 638 // handleModifyTimerLines 639 /++ 640 Edits a line of an existing timer, or insert one at a specific line position. 641 642 Params: 643 plugin = The current [TimerPlugin]. 644 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the insert or edit. 645 slice = Relevant slice of the original request string. 646 insert = Whether or not an insert action was requested. If `No.insert`, 647 then an edit action was requested. 648 +/ 649 void handleModifyTimerLines( 650 TimerPlugin plugin, 651 const /*ref*/ IRCEvent event, 652 /*const*/ string slice, 653 const Flag!"insert" insert) 654 { 655 import lu.string : SplitResults, splitInto; 656 import std.conv : ConvException, to; 657 import std.format : format; 658 659 void sendInsertUsage() 660 { 661 if (insert) 662 { 663 enum pattern = "Usage: <b>%s%s insert<b> [timer name] [position] [timer text]"; 664 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 665 chan(plugin.state, event.channel, message); 666 } 667 else 668 { 669 enum pattern = "Usage: <b>%s%s edit<b> [timer name] [position] [new timer text]"; 670 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 671 chan(plugin.state, event.channel, message); 672 } 673 } 674 675 void sendNoSuchTimer() 676 { 677 enum message = "There is no timer by that name."; 678 chan(plugin.state, event.channel, message); 679 } 680 681 void sendOutOfRange(const size_t upperBound) 682 { 683 enum pattern = "Line position out of range; valid is <b>[0..%d]<b> (inclusive)."; 684 immutable message = pattern.format(upperBound); 685 chan(plugin.state, event.channel, message); 686 } 687 688 string name; 689 string linePosString; 690 691 immutable results = slice.splitInto(name, linePosString); 692 if (results != SplitResults.overrun) return sendInsertUsage(); 693 694 auto channel = event.channel in plugin.channels; 695 if (!channel) return sendNoSuchTimer(); 696 697 auto channelTimers = event.channel in plugin.timersByChannel; 698 if (!channelTimers) return sendNoSuchTimer(); 699 700 auto timer = name in *channelTimers; 701 if (!timer) return sendNoSuchTimer(); 702 703 void destroyUpdateSave() 704 { 705 destroy(timer.fiber); 706 timer.fiber = createTimerFiber(plugin, event.channel, timer.name); 707 saveTimersToDisk(plugin); 708 } 709 710 try 711 { 712 immutable linePos = linePosString.to!ptrdiff_t; 713 if ((linePos < 0) || (linePos >= timer.lines.length)) return sendOutOfRange(timer.lines.length); 714 715 if (insert) 716 { 717 import std.array : insertInPlace; 718 719 timer.lines.insertInPlace(linePos, slice); 720 destroyUpdateSave(); 721 722 enum pattern = "Line added to timer <b>%s<b>."; 723 immutable message = pattern.format(name); 724 chan(plugin.state, event.channel, message); 725 } 726 else 727 { 728 timer.lines[linePos] = slice; 729 destroyUpdateSave(); 730 731 enum pattern = "Line <b>#%d<b> of timer <b>%s<b> edited."; 732 immutable message = pattern.format(linePos, name); 733 chan(plugin.state, event.channel, message); 734 } 735 } 736 catch (ConvException _) 737 { 738 enum message = "Position argument must be a number."; 739 chan(plugin.state, event.channel, message); 740 } 741 } 742 743 744 // handleAddToTimer 745 /++ 746 Adds a line to an existing timer. 747 748 Params: 749 plugin = The current [TimerPlugin]. 750 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the addition. 751 slice = Relevant slice of the original request string. 752 +/ 753 void handleAddToTimer( 754 TimerPlugin plugin, 755 const /*ref*/ IRCEvent event, 756 /*const*/ string slice) 757 { 758 import lu.string : nom; 759 import std.format : format; 760 761 void sendAddUsage() 762 { 763 enum pattern = "Usage: <b>%s%s add<b> [existing timer name] [new timer line]"; 764 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 765 chan(plugin.state, event.channel, message); 766 } 767 768 void sendNoSuchTimer() 769 { 770 enum noSuchTimerPattern = "No such timer is defined. Add a new one with <b>%s%s new<b>."; 771 immutable noSuchTimerMessage = noSuchTimerPattern.format(plugin.state.settings.prefix, event.aux[$-1]); 772 chan(plugin.state, event.channel, noSuchTimerMessage); 773 } 774 775 immutable name = slice.nom!(Yes.inherit)(' '); 776 if (!slice.length) return sendAddUsage(); 777 778 auto channel = event.channel in plugin.channels; 779 if (!channel) return sendNoSuchTimer(); 780 781 auto channelTimers = event.channel in plugin.timersByChannel; 782 if (!channelTimers) return sendNoSuchTimer(); 783 784 auto timer = name in *channelTimers; 785 if (!timer) return sendNoSuchTimer(); 786 787 void destroyUpdateSave() 788 { 789 destroy(timer.fiber); 790 timer.fiber = createTimerFiber(plugin, event.channel, timer.name); 791 saveTimersToDisk(plugin); 792 } 793 794 timer.lines ~= slice; 795 destroyUpdateSave(); 796 797 enum pattern = "Line added to timer <b>%s<b>."; 798 immutable message = pattern.format(name); 799 chan(plugin.state, event.channel, message); 800 } 801 802 803 // handleListTimers 804 /++ 805 Lists all timers. 806 807 Params: 808 plugin = The current [TimerPlugin]. 809 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the listing. 810 +/ 811 void handleListTimers( 812 TimerPlugin plugin, 813 const ref IRCEvent event) 814 { 815 import std.format : format; 816 817 void sendNoTimersForChannel() 818 { 819 enum message = "There are no timers registered for this channel."; 820 chan(plugin.state, event.channel, message); 821 } 822 823 void sendNoSuchTimer() 824 { 825 enum message = "There is no timer by that name."; 826 chan(plugin.state, event.channel, message); 827 } 828 829 const channel = event.channel in plugin.channels; 830 if (!channel) return sendNoTimersForChannel(); 831 832 auto channelTimers = event.channel in plugin.timersByChannel; 833 if (!channelTimers) return sendNoTimersForChannel(); 834 835 enum headerPattern = "Current timers for channel <b>%s<b>:"; 836 immutable headerMessage = headerPattern.format(event.channel); 837 chan(plugin.state, event.channel, headerMessage); 838 839 foreach (const timer; *channelTimers) 840 { 841 enum timerPattern = 842 "[\"%s\"] " ~ 843 "lines:%d | " ~ 844 "type:%s | " ~ 845 "condition:%s | " ~ 846 "message count threshold:%d | " ~ 847 "time threshold:%d | " ~ 848 "stagger message count:%d | " ~ 849 "stagger time:%d | " ~ 850 "suspended:%s"; 851 852 immutable timerMessage = timerPattern.format( 853 timer.name, 854 timer.lines.length, 855 ((timer.type == Timer.Type.random) ? "random" : "ordered"), 856 ((timer.condition == Timer.Condition.both) ? "both" : "either"), 857 timer.messageCountThreshold, 858 timer.timeThreshold, 859 timer.messageCountStagger, 860 timer.timeStagger, 861 timer.suspended, 862 ); 863 864 chan(plugin.state, event.channel, timerMessage); 865 } 866 } 867 868 869 // handleSuspendTimer 870 /++ 871 Suspends or resumes a timer, by modifying [Timer.suspended]. 872 873 Params: 874 plugin = The current [TimerPlugin]. 875 event = The [dialect.defs.IRCEvent|IRCEvent] that requested the suspend or resume. 876 slice = Relevant slice of the original request string. 877 suspend = Whether or not a suspend action was requested. If `No.suspend`, 878 then a resume action was requested. 879 +/ 880 void handleSuspendTimer( 881 TimerPlugin plugin, 882 const ref IRCEvent event, 883 /*const*/ string slice, 884 const Flag!"suspend" suspend) 885 { 886 import lu.string : SplitResults, splitInto; 887 import std.format : format; 888 889 void sendUsage() 890 { 891 immutable verb = suspend ? "suspend" : "resume"; 892 enum pattern = "Usage: <b>%s%s %s<b> [name]"; 893 immutable message = pattern.format( 894 plugin.state.settings.prefix, 895 event.aux[$-1], 896 verb); 897 chan(plugin.state, event.channel, message); 898 } 899 900 void sendNoSuchTimer() 901 { 902 enum message = "There is no timer by that name."; 903 chan(plugin.state, event.channel, message); 904 } 905 906 string name; 907 908 immutable results = slice.splitInto(name); 909 if (results != SplitResults.match) return sendUsage(); 910 911 auto channel = event.channel in plugin.channels; 912 if (!channel) return sendNoSuchTimer(); 913 914 auto channelTimers = event.channel in plugin.timersByChannel; 915 if (!channelTimers) return sendNoSuchTimer(); 916 917 auto timer = name in *channelTimers; 918 if (!timer) return sendNoSuchTimer(); 919 920 timer.suspended = suspend; 921 saveTimersToDisk(plugin); 922 923 if (suspend) 924 { 925 enum pattern = "Timer suspended. Use <b>%s%s resume %s<b> to resume it."; 926 immutable message = pattern.format( 927 plugin.state.settings.prefix, 928 event.aux[$-1], 929 name); 930 chan(plugin.state, event.channel, message); 931 } 932 else 933 { 934 enum message = "Timer resumed!"; 935 chan(plugin.state, event.channel, message); 936 } 937 } 938 939 940 // onAnyMessage 941 /++ 942 Bumps the message count for any channel on incoming channel messages. 943 +/ 944 @(IRCEventHandler() 945 .onEvent(IRCEvent.Type.CHAN) 946 .onEvent(IRCEvent.Type.EMOTE) 947 .permissionsRequired(Permissions.ignore) 948 .channelPolicy(ChannelPolicy.home) 949 ) 950 void onAnyMessage(TimerPlugin plugin, const ref IRCEvent event) 951 { 952 auto channel = event.channel in plugin.channels; 953 954 if (!channel) 955 { 956 // Race... 957 handleSelfjoin(plugin, event.channel, No.force); 958 channel = event.channel in plugin.channels; 959 } 960 961 ++channel.messageCount; 962 } 963 964 965 // onWelcome 966 /++ 967 Loads timers from disk. Additionally sets up a [core.thread.fiber.Fiber|Fiber] 968 to periodically call timer [core.thread.fiber.Fiber|Fiber]s with a periodicity 969 of [TimerPlugin.timerPeriodicity]. 970 971 Don't call `reload` for this! It undoes anything `handleSelfjoin` may have done. 972 +/ 973 @(IRCEventHandler() 974 .onEvent(IRCEvent.Type.RPL_WELCOME) 975 ) 976 void onWelcome(TimerPlugin plugin) 977 { 978 import kameloso.plugins.common.delayawait : delay; 979 import kameloso.constants : BufferSize; 980 import lu.json : JSONStorage; 981 import core.thread : Fiber; 982 983 JSONStorage allTimersJSON; 984 allTimersJSON.load(plugin.timerFile); 985 986 foreach (immutable channelName, const timersJSON; allTimersJSON.object) 987 { 988 auto channelTimers = channelName in plugin.timersByChannel; 989 990 if (!channelTimers) 991 { 992 plugin.timersByChannel[channelName] = typeof(plugin.timersByChannel[channelName]).init; 993 channelTimers = channelName in plugin.timersByChannel; 994 } 995 996 foreach (const timerJSON; timersJSON.array) 997 { 998 auto timer = Timer.fromJSON(timerJSON); 999 (*channelTimers)[timer.name] = timer; 1000 } 1001 1002 *channelTimers = channelTimers.rehash(); 1003 } 1004 1005 plugin.timersByChannel = plugin.timersByChannel.rehash(); 1006 1007 void fiberTriggerDg() 1008 { 1009 while (true) 1010 { 1011 import std.datetime.systime : Clock; 1012 1013 // Micro-optimise getting the current time 1014 long nowInUnix; // = Clock.currTime.toUnixTime; 1015 1016 // Walk through channels, trigger fibers 1017 foreach (immutable channelName, channel; plugin.channels) 1018 { 1019 innermost: 1020 foreach (timerPtr; channel.timerPointers) 1021 { 1022 if (!timerPtr.fiber || (timerPtr.fiber.state != Fiber.State.HOLD)) 1023 { 1024 logger.error("Dead or busy timer Fiber in channel ", channelName); 1025 continue innermost; 1026 } 1027 1028 // Get time here and cache it 1029 if (nowInUnix == 0) nowInUnix = Clock.currTime.toUnixTime; 1030 1031 immutable timeConditionMet = 1032 ((nowInUnix - timerPtr.lastTimestamp) >= timerPtr.timeThreshold); 1033 immutable messageConditionMet = 1034 ((channel.messageCount - timerPtr.lastMessageCount) >= timerPtr.messageCountThreshold); 1035 1036 if (timerPtr.condition == Timer.Condition.both) 1037 { 1038 if (timeConditionMet && messageConditionMet) 1039 { 1040 timerPtr.fiber.call(); 1041 } 1042 } 1043 else /*if (timerPtr.condition == Timer.Condition.either)*/ 1044 { 1045 if (timeConditionMet || messageConditionMet) 1046 { 1047 timerPtr.fiber.call(); 1048 } 1049 } 1050 } 1051 } 1052 1053 delay(plugin, plugin.timerPeriodicity, Yes.yield); 1054 // continue; 1055 } 1056 } 1057 1058 Fiber fiberTriggerFiber = new Fiber(&fiberTriggerDg, BufferSize.fiberStack); 1059 delay(plugin, fiberTriggerFiber, plugin.timerPeriodicity); 1060 } 1061 1062 1063 // onSelfjoin 1064 /++ 1065 Simply passes on execution to [handleSelfjoin]. 1066 +/ 1067 @(IRCEventHandler() 1068 .onEvent(IRCEvent.Type.SELFJOIN) 1069 .channelPolicy(ChannelPolicy.home) 1070 ) 1071 void onSelfjoin(TimerPlugin plugin, const ref IRCEvent event) 1072 { 1073 return handleSelfjoin(plugin, event.channel, No.force); 1074 } 1075 1076 1077 // handleSelfjoin 1078 /++ 1079 Registers a new [TimerPlugin.Channel] as we join a channel, so there's 1080 always a state struct available. 1081 1082 Creates the timer [core.thread.fiber.Fiber|Fiber]s that there are definitions 1083 for in [TimerPlugin.timersByChannel]. 1084 1085 Params: 1086 plugin = The current [TimerPlugin]. 1087 channelName = The name of the channel we're supposedly joining. 1088 force = Whether or not to always set up the channel, regardless of its 1089 current existence. 1090 +/ 1091 void handleSelfjoin( 1092 TimerPlugin plugin, 1093 const string channelName, 1094 const Flag!"force" force = No.force) 1095 { 1096 auto channel = channelName in plugin.channels; 1097 auto channelTimers = channelName in plugin.timersByChannel; 1098 1099 if (!channel || force) 1100 { 1101 // No channel or forcing; create 1102 plugin.channels[channelName] = TimerPlugin.Channel(channelName); // as above 1103 if (!channel) channel = channelName in plugin.channels; 1104 } 1105 1106 if (channelTimers) 1107 { 1108 import std.datetime.systime : Clock; 1109 1110 immutable nowInUnix = Clock.currTime.toUnixTime; 1111 1112 // Populate timers 1113 foreach (ref timer; *channelTimers) 1114 { 1115 destroy(timer.fiber); 1116 timer.lastMessageCount = channel.messageCount; 1117 timer.lastTimestamp = nowInUnix; 1118 timer.fiber = createTimerFiber(plugin, channelName, timer.name); 1119 channel.timerPointers[timer.name] = &timer; // Will this work in release mode? 1120 } 1121 } 1122 } 1123 1124 1125 // createTimerFiber 1126 /++ 1127 Given a [Timer] and a string channel name, creates a 1128 [core.thread.fiber.Fiber|Fiber] that implements the timer. 1129 1130 Params: 1131 plugin = The current [TimerPlugin]. 1132 channelName = String channel to which the timer belongs. 1133 name = Timer name, used as inner key in [TimerPlugin.timersByChannel]. 1134 +/ 1135 auto createTimerFiber( 1136 TimerPlugin plugin, 1137 const string channelName, 1138 const string name) 1139 { 1140 import kameloso.constants : BufferSize; 1141 import core.thread : Fiber; 1142 1143 void createTimerDg() 1144 { 1145 import std.datetime.systime : Clock; 1146 1147 /// Channel pointer. 1148 const channel = channelName in plugin.channels; 1149 assert(channel, channelName ~ " not in plugin.channels"); 1150 1151 auto channelTimers = channelName in plugin.timersByChannel; 1152 assert(channelTimers, channelName ~ " not in plugin.timersByChanel"); 1153 1154 auto timer = name in *channelTimers; 1155 assert(timer, name ~ " not in *channelTimers"); 1156 1157 // Ensure that the Timer was set up with a UNIX timestamp prior to creating this 1158 assert((timer.lastTimestamp > 0L), "Timer Fiber " ~ name ~ " created before initial timestamp was set"); 1159 1160 // Stagger based on message count and time thresholds 1161 while (true) 1162 { 1163 immutable timeStaggerMet = 1164 ((Clock.currTime.toUnixTime - timer.lastTimestamp) >= timer.timeStagger); 1165 immutable messageStaggerMet = 1166 ((channel.messageCount - timer.lastMessageCount) >= timer.messageCountStagger); 1167 1168 if (timer.condition == Timer.Condition.both) 1169 { 1170 if (timeStaggerMet && messageStaggerMet) break; 1171 } 1172 else /*if (timer.condition == Timer.Condition.either)*/ 1173 { 1174 if (timeStaggerMet || messageStaggerMet) break; 1175 } 1176 1177 Fiber.yield(); 1178 continue; 1179 } 1180 1181 void updateTimer() 1182 { 1183 timer.lastMessageCount = channel.messageCount; 1184 timer.lastTimestamp = Clock.currTime.toUnixTime; 1185 } 1186 1187 // Snapshot count and timestamp 1188 updateTimer(); 1189 1190 // Main loop 1191 while (true) 1192 { 1193 import kameloso.string : replaceRandom; 1194 import std.array : replace; 1195 import std.conv : to; 1196 import std.random : uniform; 1197 1198 if (timer.suspended) 1199 { 1200 updateTimer(); 1201 Fiber.yield(); 1202 continue; 1203 } 1204 1205 string message = timer.getLine() // mutable 1206 .replace("$bot", plugin.state.client.nickname) 1207 .replace("$channel", channelName[1..$]) 1208 .replaceRandom(); 1209 1210 version(TwitchSupport) 1211 { 1212 if (plugin.state.server.daemon == IRCServer.Daemon.twitch) 1213 { 1214 import kameloso.plugins.common.misc : nameOf; 1215 message = message.replace("$streamer", nameOf(plugin, channelName[1..$])); 1216 } 1217 } 1218 1219 chan(plugin.state, channelName, message); 1220 updateTimer(); 1221 Fiber.yield(); 1222 //continue; 1223 } 1224 } 1225 1226 return new Fiber(&createTimerDg, BufferSize.fiberStack); 1227 } 1228 1229 1230 // saveTimersToDisk 1231 /++ 1232 Saves timers to disk in JSON format. 1233 1234 Params: 1235 plugin = The current [TimerPlugin]. 1236 +/ 1237 void saveTimersToDisk(TimerPlugin plugin) 1238 { 1239 import lu.json : JSONStorage; 1240 1241 JSONStorage json; 1242 1243 foreach (immutable channelName, const timers; plugin.timersByChannel) 1244 { 1245 json[channelName] = null; 1246 json[channelName].array = null; 1247 1248 foreach (const timer; timers) 1249 { 1250 json[channelName].array ~= timer.toJSON(); 1251 } 1252 } 1253 1254 json.save(plugin.timerFile); 1255 } 1256 1257 1258 // initResources 1259 /++ 1260 Reads and writes the file of timers to disk, ensuring that they're there and 1261 properly formatted. 1262 +/ 1263 void initResources(TimerPlugin plugin) 1264 { 1265 import lu.json : JSONStorage; 1266 import std.json : JSONException; 1267 1268 JSONStorage timersJSON; 1269 1270 try 1271 { 1272 timersJSON.load(plugin.timerFile); 1273 } 1274 catch (JSONException e) 1275 { 1276 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 1277 1278 version(PrintStacktraces) logger.trace(e); 1279 throw new IRCPluginInitialisationException( 1280 "Timer file is malformed", 1281 plugin.name, 1282 plugin.timerFile, 1283 __FILE__, 1284 __LINE__); 1285 } 1286 1287 // Let other Exceptions pass. 1288 1289 timersJSON.save(plugin.timerFile); 1290 } 1291 1292 1293 // reload 1294 /++ 1295 Reloads resources from disk. 1296 +/ 1297 void reload(TimerPlugin plugin) 1298 { 1299 import lu.json : JSONStorage; 1300 1301 JSONStorage allTimersJSON; 1302 allTimersJSON.load(plugin.timerFile); 1303 1304 // Clear timerByChannel and reload from disk 1305 plugin.timersByChannel = null; 1306 1307 foreach (immutable channelName, const timersJSON; allTimersJSON.object) 1308 { 1309 foreach (const timerJSON; timersJSON.array) 1310 { 1311 auto timer = Timer.fromJSON(timerJSON); 1312 plugin.timersByChannel[channelName][timer.name] = timer; 1313 } 1314 } 1315 1316 plugin.timersByChannel = plugin.timersByChannel.rehash(); 1317 1318 // Recreate timers from definitions 1319 foreach (immutable channelName, channel; plugin.channels) 1320 { 1321 // Just reuse the SELFJOIN routine, but be sure to force it 1322 // it will destroy the fibers, so we don't have to here 1323 handleSelfjoin(plugin, channelName, Yes.force); 1324 } 1325 } 1326 1327 1328 mixin MinimalAuthentication; 1329 mixin PluginRegistration!TimerPlugin; 1330 1331 version(TwitchSupport) 1332 { 1333 mixin UserAwareness; 1334 } 1335 1336 public: 1337 1338 1339 // TimerPlugin 1340 /++ 1341 The Timer plugin serves reoccuring (timered) announcements. 1342 +/ 1343 final class TimerPlugin : IRCPlugin 1344 { 1345 private: 1346 import core.time : seconds; 1347 1348 public: 1349 /++ 1350 Contained state of a channel, so that there can be several alongside each other. 1351 +/ 1352 static struct Channel 1353 { 1354 /++ 1355 Name of the channel. 1356 +/ 1357 string channelName; 1358 1359 /++ 1360 Current message count. 1361 +/ 1362 ulong messageCount; 1363 1364 /++ 1365 Pointers to [Timer]s in [TimerPlugin.timersByChannel]. 1366 +/ 1367 Timer*[string] timerPointers; 1368 } 1369 1370 /++ 1371 All Timer plugin settings. 1372 +/ 1373 TimerSettings timerSettings; 1374 1375 /++ 1376 Array of active channels' state. 1377 +/ 1378 Channel[string] channels; 1379 1380 /++ 1381 Associative array of [Timer]s, keyed by nickname keyed by channel. 1382 +/ 1383 Timer[string][string] timersByChannel; 1384 1385 /++ 1386 Filename of file with timer definitions. 1387 +/ 1388 @Resource string timerFile = "timers.json"; 1389 1390 /++ 1391 How often to check whether timers should fire. A smaller number means 1392 better precision, but also marginally higher gc pressure. 1393 +/ 1394 static immutable timerPeriodicity = 10.seconds; 1395 1396 mixin IRCPluginImpl; 1397 }