1 /++ 2 The Poll plugin offers the ability to hold votes/polls in a channel. Any 3 number of choices is supported, as long as they're more than one. 4 5 Cheating by changing nicknames is warded against. 6 7 See_Also: 8 https://github.com/zorael/kameloso/wiki/Current-plugins#poll, 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.poll; 19 20 version(WithPollPlugin): 21 22 private: 23 24 import kameloso.plugins; 25 import kameloso.plugins.common.core; 26 import kameloso.plugins.common.awareness : MinimalAuthentication; 27 import kameloso.common : logger; 28 import kameloso.messaging; 29 import dialect.defs; 30 import std.typecons : Flag, No, Yes; 31 import core.time : Duration; 32 33 34 // PollSettings 35 /++ 36 All Poll plugin runtime settings aggregated. 37 +/ 38 @Settings struct PollSettings 39 { 40 /++ 41 Whether or not this plugin should react to any events. 42 +/ 43 @Enabler bool enabled = true; 44 45 /++ 46 Whether or not only votes placed by online users count. 47 +/ 48 bool onlyOnlineUsersCount = true; 49 50 /++ 51 Whether or not poll choices may start with the command prefix. 52 53 There's no check in place that a prefixed choice won't conflict with a 54 command, so make it opt-in at your own risk. 55 +/ 56 bool forbidPrefixedChoices = true; 57 58 /++ 59 User level required to vote. 60 +/ 61 IRCUser.Class minimumPermissionsNeeded = IRCUser.Class.anyone; 62 } 63 64 65 // Poll 66 /++ 67 Embodies the notion of a channel poll. 68 +/ 69 struct Poll 70 { 71 private: 72 import kameloso.common : RehashingAA; 73 import std.datetime.systime : SysTime; 74 import std.json : JSONValue; 75 76 public: 77 /++ 78 Timestamp of when the poll was created. 79 +/ 80 SysTime start; 81 82 /++ 83 Current vote tallies. 84 +/ 85 uint[string] voteCounts; 86 87 /++ 88 Map of the original names of the choices keyed by what they were simplified to. 89 +/ 90 string[string] origChoiceNames; 91 92 /++ 93 Choices, sorted in alphabetical order. 94 +/ 95 string[] sortedChoices; 96 97 /++ 98 Individual votes, keyed by nicknames of the people who placed them. 99 +/ 100 RehashingAA!(string, string) votes; 101 102 /++ 103 Poll duration. 104 +/ 105 Duration duration; 106 107 /++ 108 Unique identifier to help Fibers know if the poll they belong to is stale 109 or has been replaced. 110 +/ 111 uint uniqueID; 112 113 // toJSON 114 /++ 115 Serialises this [Poll] into a [std.json.JSONValue|JSONValue]. 116 117 Returns: 118 A [std.json.JSONValue|JSONValue] that describes this poll. 119 +/ 120 auto toJSON() const 121 { 122 JSONValue json; 123 json.object = null; 124 json["start"] = this.start.toUnixTime(); 125 json["voteCounts"] = JSONValue(this.voteCounts); 126 json["origChoiceNames"] = JSONValue(this.origChoiceNames); 127 json["sortedChoices"] = JSONValue(this.sortedChoices); 128 json["votes"] = JSONValue(this.votes.aaOf); 129 json["duration"] = JSONValue(duration.total!"seconds"); 130 json["uniqueID"] = JSONValue(uniqueID); 131 return json; 132 } 133 134 // fromJSON 135 /++ 136 Deserialises a [Poll] from a [std.json.JSONValue|JSONValue]. 137 138 Params: 139 json = [std.json.JSONValue|JSONValue] to deserialise. 140 141 Returns: 142 A new [Poll] with values loaded from the passed JSON. 143 +/ 144 static auto fromJSON(const JSONValue json) 145 { 146 import core.time : seconds; 147 148 Poll poll; 149 150 foreach (immutable voteName, const voteCountJSON; json["voteCounts"].object) 151 { 152 poll.voteCounts[voteName] = cast(uint)voteCountJSON.integer; 153 } 154 155 foreach (immutable newName, const origNameJSON; json["origChoiceNames"].object) 156 { 157 poll.origChoiceNames[newName] = origNameJSON.str; 158 } 159 160 foreach (const choiceJSON; json["sortedChoices"].array) 161 { 162 poll.sortedChoices ~= choiceJSON.str; 163 } 164 165 foreach (immutable nickname, const voteJSON; json["votes"].object) 166 { 167 poll.votes[nickname] = voteJSON.str; 168 } 169 170 poll.start = SysTime.fromUnixTime(json["start"].integer); 171 poll.duration = json["duration"].integer.seconds; 172 poll.uniqueID = cast(uint)json["uniqueID"].integer; 173 poll.votes = poll.votes.rehash(); 174 return poll; 175 } 176 } 177 178 179 // onCommandPoll 180 /++ 181 Instigates a poll or stops an ongoing one. 182 183 If starting one a duration and two or more voting choices have to be passed. 184 +/ 185 @(IRCEventHandler() 186 .onEvent(IRCEvent.Type.CHAN) 187 .permissionsRequired(Permissions.operator) 188 .channelPolicy(ChannelPolicy.home) 189 .addCommand( 190 IRCEventHandler.Command() 191 .word("poll") 192 .policy(PrefixPolicy.prefixed) 193 .description(`Starts or stops a poll. Pass "abort" to abort, or "end" to end early.`) 194 .addSyntax("$command [duration] [choice 1] [choice 2] ...") 195 .addSyntax("$command abort") 196 .addSyntax("$command end") 197 ) 198 .addCommand( 199 IRCEventHandler.Command() 200 .word("vote") 201 .policy(PrefixPolicy.prefixed) 202 .hidden(true) 203 ) 204 ) 205 void onCommandPoll(PollPlugin plugin, const ref IRCEvent event) 206 { 207 import kameloso.time : DurationStringException, abbreviatedDuration, timeSince; 208 import lu.string : stripped; 209 import std.algorithm.searching : count; 210 import std.algorithm.sorting : sort; 211 import std.conv : ConvException; 212 import std.datetime.systime : Clock; 213 import std.format : format; 214 import std.random : uniform; 215 216 void sendUsage() 217 { 218 if (event.sender.class_ < IRCUser.Class.operator) 219 { 220 enum message = "You are not authorised to start new polls."; 221 chan(plugin.state, event.channel, message); 222 } 223 else 224 { 225 import std.format : format; 226 227 enum pattern = "Usage: <b>%s%s<b> [duration] [choice1] [choice2] ..."; 228 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 229 chan(plugin.state, event.channel, message); 230 } 231 } 232 233 void sendNoOngoingPoll() 234 { 235 enum message = "There is no ongoing poll."; 236 chan(plugin.state, event.channel, message); 237 } 238 239 void sendPollAborted() 240 { 241 enum message = "Poll aborted."; 242 chan(plugin.state, event.channel, message); 243 } 244 245 void sendSyntaxHelp() 246 { 247 enum message = "Need one duration and at least two choices."; 248 chan(plugin.state, event.channel, message); 249 } 250 251 void sendMalformedDuration() 252 { 253 enum message = "Malformed duration."; 254 chan(plugin.state, event.channel, message); 255 } 256 257 void sendNegativeDuration() 258 { 259 enum message = "Duration must not be negative."; 260 chan(plugin.state, event.channel, message); 261 } 262 263 void sendNeedTwoUniqueChoices() 264 { 265 enum message = "Need at least two unique poll choices."; 266 chan(plugin.state, event.channel, message); 267 } 268 269 const currentPoll = event.channel in plugin.channelPolls; 270 271 switch (event.content) 272 { 273 case string.init: 274 if (currentPoll) 275 { 276 goto case "status"; 277 } 278 else 279 { 280 // Can't use a tertiary or it fails to build with older compilers 281 // Error: variable operator used before set 282 if (event.sender.class_ < IRCUser.Class.operator) 283 { 284 return sendNoOngoingPoll(); 285 } 286 else 287 { 288 return sendUsage(); 289 } 290 } 291 292 case "status": 293 if (!currentPoll) return sendNoOngoingPoll(); 294 return reportStatus(plugin, event.channel, *currentPoll); 295 296 case "abort": 297 if (!currentPoll) return sendNoOngoingPoll(); 298 299 plugin.channelPolls.remove(event.channel); 300 return sendPollAborted(); 301 302 case "end": 303 if (!currentPoll) return sendNoOngoingPoll(); 304 305 reportEndResults(plugin, event.channel, *currentPoll); 306 plugin.channelPolls.remove(event.channel); 307 return; 308 309 default: 310 // Drop down 311 break; 312 } 313 314 if (event.content.count(' ') < 2) 315 { 316 return sendSyntaxHelp(); 317 } 318 319 Poll poll; 320 string slice = event.content.stripped; // mutable 321 322 try 323 { 324 import lu.string : nom; 325 poll.duration = abbreviatedDuration(slice.nom!(Yes.decode)(' ')); 326 } 327 catch (ConvException _) 328 { 329 return sendMalformedDuration(); 330 } 331 catch (DurationStringException e) 332 { 333 return chan(plugin.state, event.channel, e.msg); 334 } 335 catch (Exception e) 336 { 337 chan(plugin.state, event.channel, e.msg); 338 version(PrintStacktraces) logger.trace(e.info); 339 return; 340 } 341 342 if (poll.duration <= Duration.zero) 343 { 344 return sendNegativeDuration(); 345 } 346 347 auto choicesVoldemort = getPollChoices(plugin, event.channel, slice); // must be mutable 348 if (!choicesVoldemort.success) return; 349 350 if (choicesVoldemort.choices.length < 2) 351 { 352 return sendNeedTwoUniqueChoices(); 353 } 354 355 poll.start = Clock.currTime; 356 poll.uniqueID = uniform(1, uint.max); 357 poll.voteCounts = choicesVoldemort.choices; 358 poll.origChoiceNames = choicesVoldemort.origChoiceNames; 359 poll.sortedChoices = poll.voteCounts 360 .keys 361 .sort 362 .release; 363 plugin.channelPolls[event.channel] = poll; 364 365 generatePollFiber(plugin, event.channel, poll); 366 generateVoteReminders(plugin, event.channel, poll); 367 generateEndFiber(plugin, event.channel, poll); 368 369 immutable timeInWords = poll.duration.timeSince!(7, 0); 370 enum pattern = "<b>Voting commenced!<b> Please place your vote for one of: " ~ 371 "%-(<b>%s<b>, %)<b> (%s)"; // extra <b> needed outside of %-(%s, %) 372 immutable message = pattern.format(poll.sortedChoices, timeInWords); 373 chan(plugin.state, event.channel, message); 374 } 375 376 377 // getPollChoices 378 /++ 379 Sifts out unique choice words from a string. 380 381 Params: 382 plugin = The current [PollPlugin]. 383 channelName = The name of the channel the poll belongs to. 384 slice = Mutable slice of the input. 385 386 Returns: 387 A Voldemort struct with members `choices` and `origChoiceNames` representing 388 the choices found in the input string. 389 +/ 390 auto getPollChoices( 391 PollPlugin plugin, 392 const string channelName, 393 const string slice) 394 { 395 import lu.string : splitWithQuotes; 396 import std.format : format; 397 398 void sendChoiceMustNotStartWithPrefix() 399 { 400 enum pattern = `Poll choices may not start with the command prefix ("%s").`; 401 immutable message = pattern.format(plugin.state.settings.prefix); 402 chan(plugin.state, channelName, message); 403 } 404 405 void sendDuplicateChoice(const string choice) 406 { 407 enum pattern = `Duplicate choice: "<b>%s<b>"`; 408 immutable message = pattern.format(choice); 409 chan(plugin.state, channelName, message); 410 } 411 412 static struct PollChoices 413 { 414 bool success; 415 uint[string] choices; 416 string[string] origChoiceNames; 417 } 418 419 PollChoices result; 420 421 foreach (immutable rawChoice; splitWithQuotes(slice)) 422 { 423 import lu.string : beginsWith, strippedRight; 424 import std.uni : toLower; 425 426 if (plugin.pollSettings.forbidPrefixedChoices && rawChoice.beginsWith(plugin.state.settings.prefix)) 427 { 428 /*return*/ sendChoiceMustNotStartWithPrefix(); 429 return result; 430 } 431 432 // Strip any trailing commas, unless the choice is literally just commas 433 // We can tell if the comma-stripped string is empty 434 immutable strippedChoice = rawChoice.strippedRight(','); 435 immutable choice = strippedChoice.length ? 436 strippedChoice : 437 rawChoice; 438 439 if (!choice.length) continue; 440 441 immutable lower = choice.toLower; 442 if (lower in result.origChoiceNames) 443 { 444 /*return*/ sendDuplicateChoice(choice); 445 return result; 446 } 447 448 result.origChoiceNames[lower] = choice; 449 result.choices[lower] = 0; 450 } 451 452 result.success = true; 453 return result; 454 } 455 456 457 // generatePollFiber 458 /++ 459 Implementation function for generating a poll Fiber. 460 461 Params: 462 plugin = The current [PollPlugin]. 463 channelName = Name of the channel the poll belongs to. 464 poll = The [Poll] to generate a Fiber for. 465 +/ 466 void generatePollFiber( 467 PollPlugin plugin, 468 const string channelName, 469 Poll poll) 470 { 471 import kameloso.plugins.common.delayawait : await; 472 import kameloso.constants : BufferSize; 473 import kameloso.thread : CarryingFiber; 474 import std.format : format; 475 import core.thread : Fiber; 476 477 // Take into account people leaving or changing nicknames on non-Twitch servers 478 // On Twitch NICKs and QUITs don't exist, and PARTs are unreliable. 479 // ACCOUNTs also aren't a thing. 480 static immutable IRCEvent.Type[4] nonTwitchVoteEventTypes = 481 [ 482 IRCEvent.Type.NICK, 483 IRCEvent.Type.PART, 484 IRCEvent.Type.QUIT, 485 IRCEvent.Type.ACCOUNT, 486 ]; 487 488 void pollDg() 489 { 490 scope(exit) 491 { 492 import kameloso.plugins.common.delayawait : unawait; 493 494 unawait(plugin, nonTwitchVoteEventTypes[]); 495 unawait(plugin, IRCEvent.Type.CHAN); 496 497 const currentPoll = channelName in plugin.channelPolls; 498 if (currentPoll && (currentPoll.uniqueID == poll.uniqueID)) 499 { 500 // Only remove it if it's the same poll as when the delegate started 501 plugin.channelPolls.remove(channelName); 502 } 503 } 504 505 while (true) 506 { 507 import kameloso.plugins.common.misc : idOf; 508 509 auto currentPoll = channelName in plugin.channelPolls; 510 if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return; 511 512 auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis); 513 assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof); 514 immutable thisEvent = thisFiber.payload; 515 516 if (!thisEvent.sender.nickname.length) // == IRCEvent.init 517 { 518 // Invoked by timer, not by event 519 // Should never happen now 520 logger.error("Poll Fiber invoked via delay"); 521 Fiber.yield(); 522 continue; 523 } 524 525 if (thisEvent.sender.class_ < plugin.pollSettings.minimumPermissionsNeeded) 526 { 527 // User not authorised to vote. Yield and await a new event 528 Fiber.yield(); 529 continue; 530 } 531 532 immutable id = idOf(thisEvent.sender); 533 534 // Triggered by an event 535 with (IRCEvent.Type) 536 switch (thisEvent.type) 537 { 538 case NICK: 539 if (auto previousVote = id in currentPoll.votes) 540 { 541 immutable newID = idOf(thisEvent.target); 542 543 if (id != newID) 544 { 545 currentPoll.votes[newID] = *previousVote; 546 currentPoll.votes.remove(id); 547 } 548 } 549 break; 550 551 case CHAN: 552 import lu.string : stripped; 553 import std.uni : toLower; 554 555 if (thisEvent.channel != channelName) break; 556 557 immutable vote = thisEvent.content.stripped.toLower; 558 559 if (auto ballot = vote in currentPoll.voteCounts) 560 { 561 if (auto previousVote = id in currentPoll.votes) 562 { 563 if (*previousVote != vote) 564 { 565 // User changed their mind 566 --currentPoll.voteCounts[*previousVote]; 567 ++(*ballot); 568 currentPoll.votes[id] = vote; 569 } 570 else 571 { 572 // User is double-voting the same choice, ignore 573 } 574 } 575 else 576 { 577 // New user 578 // Valid entry, increment vote count 579 // Record user as having voted 580 ++(*ballot); 581 currentPoll.votes[id] = vote; 582 } 583 } 584 break; 585 586 case ACCOUNT: 587 if (!thisEvent.sender.account.length) 588 { 589 // User logged out 590 // Old account is in aux[0]; move vote to nickname if necessary 591 if (thisEvent.aux[0] != thisEvent.sender.nickname) 592 { 593 if (const previousVote = thisEvent.aux[0] in currentPoll.votes) 594 { 595 currentPoll.votes[thisEvent.sender.nickname] = *previousVote; 596 currentPoll.votes.remove(thisEvent.aux[0]); 597 } 598 } 599 } 600 else if (thisEvent.sender.account != thisEvent.sender.nickname) 601 { 602 if (const previousVote = thisEvent.sender.nickname in currentPoll.votes) 603 { 604 // Move the old entry to a new one with the account as key 605 currentPoll.votes[thisEvent.sender.account] = *previousVote; 606 currentPoll.votes.remove(thisEvent.sender.nickname); 607 } 608 } 609 break; 610 611 case PART: 612 case QUIT: 613 if (plugin.pollSettings.onlyOnlineUsersCount) 614 { 615 if (auto previousVote = id in currentPoll.votes) 616 { 617 --currentPoll.voteCounts[*previousVote]; 618 currentPoll.votes.remove(id); 619 } 620 } 621 break; 622 623 default: 624 assert(0, "Unexpected IRCEvent type seen in poll delegate"); 625 } 626 627 // Yield and await a new event 628 Fiber.yield(); 629 } 630 } 631 632 Fiber fiber = new CarryingFiber!IRCEvent(&pollDg, BufferSize.fiberStack); 633 634 if (plugin.state.server.daemon != IRCServer.Daemon.twitch) 635 { 636 await(plugin, fiber, nonTwitchVoteEventTypes[]); 637 } 638 639 await(plugin, fiber, IRCEvent.Type.CHAN); 640 } 641 642 643 // reportEndResults 644 /++ 645 Reports the result of a [Poll], as if it just ended. 646 647 Params: 648 plugin = The current [PollPlugin]. 649 channelName = Name of the channel the poll belongs to. 650 poll = The [Poll] that just ended. 651 +/ 652 void reportEndResults( 653 PollPlugin plugin, 654 const string channelName, 655 const Poll poll) 656 { 657 import std.algorithm.iteration : sum; 658 import std.algorithm.sorting : sort; 659 import std.array : array; 660 import std.format : format; 661 662 immutable total = cast(double)poll.voteCounts 663 .byValue 664 .sum; 665 666 if (total == 0) 667 { 668 enum message = "Voting complete, no one voted."; 669 return chan(plugin.state, channelName, message); 670 } 671 672 enum completeMessage = "Voting complete! Here are the results:"; 673 chan(plugin.state, channelName, completeMessage); 674 675 auto sorted = poll.voteCounts 676 .byKeyValue 677 .array 678 .sort!((a, b) => a.value < b.value); 679 680 foreach (const result; sorted) 681 { 682 if (result.value == 0) 683 { 684 enum pattern = "<b>%s<b> : 0 votes"; 685 immutable message = pattern.format(poll.origChoiceNames[result.key]); 686 chan(plugin.state, channelName, message); 687 } 688 else 689 { 690 import lu.string : plurality; 691 692 immutable noun = result.value.plurality("vote", "votes"); 693 immutable double voteRatio = cast(double)result.value / total; 694 immutable double votePercentage = 100 * voteRatio; 695 696 enum pattern = "<b>%s<b> : %d %s (%.1f%%)"; 697 immutable message = pattern.format( 698 poll.origChoiceNames[result.key], 699 result.value, 700 noun, 701 votePercentage); 702 chan(plugin.state, channelName, message); 703 } 704 } 705 } 706 707 708 // reportStatus 709 /++ 710 Reports the status of a [Poll], mid-progress. 711 712 Params: 713 plugin = The current [PollPlugin]. 714 channelName = The channel the poll belongs to. 715 poll = The [Poll] that is still ongoing. 716 +/ 717 void reportStatus( 718 PollPlugin plugin, 719 const string channelName, 720 const Poll poll) 721 { 722 import kameloso.time : timeSince; 723 import std.datetime.systime : Clock; 724 import std.format : format; 725 726 immutable now = Clock.currTime; 727 immutable end = (poll.start + poll.duration); 728 immutable delta = (end - now); 729 immutable timeInWords = delta.timeSince!(7, 0); 730 731 enum pattern = "There is an ongoing poll! Place your vote for one of: %-(<b>%s<b>, %)<b> (%s)"; 732 immutable message = pattern.format(poll.sortedChoices, timeInWords); 733 chan(plugin.state, channelName, message); 734 } 735 736 737 // generateVoteReminders 738 /++ 739 Generates some vote reminder Fibers. 740 741 Params: 742 plugin = The current [PollPlugin]. 743 channelName = The channel the poll belongs to. 744 poll = [Poll] to generate reminders for. 745 +/ 746 void generateVoteReminders( 747 PollPlugin plugin, 748 const string channelName, 749 const Poll poll) 750 { 751 import std.datetime.systime : Clock; 752 import std.meta : AliasSeq; 753 import core.time : days, hours, minutes, seconds; 754 755 void reminderDg(const Duration reminderPoint) 756 { 757 import lu.string : plurality; 758 import std.format : format; 759 760 if (reminderPoint == Duration.zero) return; 761 762 const currentPoll = channelName in plugin.channelPolls; 763 if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return; // Aborted or replaced 764 765 enum pattern = "<b>%d<b> %s left to vote! (%-(<b>%s<b>, %)<b>)"; 766 immutable numSeconds = reminderPoint.total!"seconds"; 767 768 if ((numSeconds % (24*3600)) == 0) 769 { 770 // An even day 771 immutable numDays = cast(int)(numSeconds / (24*3600)); 772 immutable message = pattern.format( 773 numDays, 774 numDays.plurality("day", "days"), 775 poll.sortedChoices); 776 chan(plugin.state, channelName, message); 777 } 778 else if ((numSeconds % 3600) == 0) 779 { 780 // An even hour 781 immutable numHours = cast(int)(numSeconds / 3600); 782 immutable message = pattern.format( 783 numHours, 784 numHours.plurality("hour", "hours"), 785 poll.sortedChoices); 786 chan(plugin.state, channelName, message); 787 } 788 else if ((numSeconds % 60) == 0) 789 { 790 // An even minute 791 immutable numMinutes = cast(int)(numSeconds / 60); 792 immutable message = pattern.format( 793 numMinutes, 794 numMinutes.plurality("minute", "minutes"), 795 poll.sortedChoices); 796 chan(plugin.state, channelName, message); 797 } 798 else 799 { 800 enum secondsPattern = "<b>%d<b> seconds! (%-(<b>%s<b>, %)<b>)"; 801 immutable message = secondsPattern.format(numSeconds, poll.sortedChoices); 802 chan(plugin.state, channelName, message); 803 } 804 } 805 806 // Warn about the poll ending at certain points, depending on how long the duration is. 807 808 alias reminderPoints = AliasSeq!( 809 7.days, 810 3.days, 811 2.days, 812 1.days, 813 12.hours, 814 6.hours, 815 3.hours, 816 1.hours, 817 30.minutes, 818 10.minutes, 819 5.minutes, 820 2.minutes, 821 30.seconds, 822 10.seconds, 823 ); 824 825 immutable elapsed = (Clock.currTime - poll.start); 826 immutable remaining = (poll.duration - elapsed); 827 828 foreach (immutable reminderPoint; reminderPoints) 829 { 830 if (poll.duration >= (reminderPoint * 2)) 831 { 832 immutable untilReminder = (remaining - reminderPoint); 833 834 if (untilReminder > Duration.zero) 835 { 836 import kameloso.plugins.common.delayawait : delay; 837 delay(plugin, (() => reminderDg(reminderPoint)), untilReminder); 838 } 839 } 840 } 841 } 842 843 844 // generateEndFiber 845 /++ 846 Generates a Fiber that ends a poll, reporting end results and cleaning up. 847 848 Params: 849 plugin = The current [PollPlugin]. 850 channelName = The channel the poll belongs to. 851 poll = [Poll] to generate end Fiber for. 852 +/ 853 void generateEndFiber( 854 PollPlugin plugin, 855 const string channelName, 856 const Poll poll) 857 { 858 import kameloso.plugins.common.delayawait : await, delay, unawait; 859 import kameloso.thread : CarryingFiber; 860 import kameloso.constants : BufferSize; 861 import std.datetime.systime : Clock; 862 import core.thread : Fiber; 863 864 void endPollDg() 865 { 866 scope(exit) plugin.channelPolls.remove(channelName); 867 868 const currentPoll = channelName in plugin.channelPolls; 869 if (!currentPoll || (currentPoll.uniqueID != poll.uniqueID)) return; 870 871 if (channelName in plugin.state.channels) 872 { 873 return reportEndResults(plugin, channelName, *currentPoll); 874 } 875 876 scope(exit) unawait(plugin, IRCEvent.Type.SELFJOIN); 877 await(plugin, IRCEvent.Type.SELFJOIN, Yes.yield); 878 879 while (true) 880 { 881 auto thisFiber = cast(CarryingFiber!IRCEvent)(Fiber.getThis); 882 assert(thisFiber, "Incorrectly cast Fiber: " ~ typeof(thisFiber).stringof); 883 884 if (thisFiber.payload.channel == channelName) 885 { 886 return reportEndResults(plugin, channelName, *currentPoll); 887 } 888 889 Fiber.yield(); 890 } 891 } 892 893 Fiber endFiber = new CarryingFiber!IRCEvent(&endPollDg, BufferSize.fiberStack); 894 immutable elapsed = Clock.currTime - poll.start; 895 immutable remaining = poll.duration - elapsed; 896 delay(plugin, endFiber, remaining); 897 } 898 899 900 // serialisePolls 901 /++ 902 Serialises ongoing [Poll]s to disk. 903 +/ 904 void serialisePolls(PollPlugin plugin) 905 { 906 import lu.json : JSONStorage; 907 908 JSONStorage json; 909 json.reset(); 910 911 foreach (immutable channelName, const poll; plugin.channelPolls) 912 { 913 json[channelName] = poll.toJSON(); 914 } 915 916 json.save(plugin.pollTempFile); 917 } 918 919 920 // deserialisePolls 921 /++ 922 Deserialises [Poll]s from disk. 923 +/ 924 void deserialisePolls(PollPlugin plugin) 925 { 926 import lu.json : JSONStorage; 927 928 JSONStorage json; 929 json.load(plugin.pollTempFile); 930 931 foreach (immutable channelName, const pollJSON; json.object) 932 { 933 auto poll = Poll.fromJSON(pollJSON); 934 plugin.channelPolls[channelName] = poll; 935 generatePollFiber(plugin, channelName, poll); 936 generateVoteReminders(plugin, channelName, poll); 937 generateEndFiber(plugin, channelName, poll); 938 } 939 } 940 941 942 // onWelcome 943 /++ 944 Deserialises [Poll]s saved to disk upon successfully registering to the server, 945 restoring any ongoing polls. 946 947 The temporary file is removed immediately afterwards. 948 +/ 949 @(IRCEventHandler() 950 .onEvent(IRCEvent.Type.RPL_WELCOME) 951 ) 952 void onWelcome(PollPlugin plugin) 953 { 954 import std.file : exists, remove; 955 956 if (plugin.pollTempFile.exists) 957 { 958 deserialisePolls(plugin); 959 remove(plugin.pollTempFile); 960 } 961 } 962 963 964 // onSelfjoin 965 /++ 966 Registers a channel entry in 967 [kameloso.plugins.common.core.IRCPluginState.channels|IRCPluginState.channels] 968 upon joining one. 969 970 This would normally be done using 971 [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness], but we 972 only need the channel registration and not the whole user tracking, so just 973 copy/paste these bits. 974 +/ 975 @(IRCEventHandler() 976 .onEvent(IRCEvent.Type.SELFJOIN) 977 ) 978 void onSelfjoin(PollPlugin plugin, const ref IRCEvent event) 979 { 980 if (event.channel in plugin.state.channels) return; 981 982 plugin.state.channels[event.channel] = IRCChannel.init; 983 plugin.state.channels[event.channel].name = event.channel; 984 } 985 986 987 // onSelfpart 988 /++ 989 De-registers a channel entry in 990 [kameloso.plugins.common.core.IRCPluginState.channels|IRCPluginState.channels] 991 upon parting from one. 992 993 This would normally be done using 994 [kameloso.plugins.common.awareness.ChannelAwareness|ChannelAwareness], but we 995 only need the channel registration and not the whole user tracking, so just 996 copy/paste these bits. 997 +/ 998 @(IRCEventHandler() 999 .onEvent(IRCEvent.Type.SELFPART) 1000 ) 1001 void onSelfpart(PollPlugin plugin, const ref IRCEvent event) 1002 { 1003 plugin.state.channels.remove(event.channel); 1004 } 1005 1006 1007 // teardown 1008 /++ 1009 Tears down the [PollPlugin], serialising any ongoing [Poll]s to file, so they 1010 aren't lost to the ether. 1011 +/ 1012 void teardown(PollPlugin plugin) 1013 { 1014 if (!plugin.channelPolls.length) return; 1015 serialisePolls(plugin); 1016 } 1017 1018 1019 mixin MinimalAuthentication; 1020 mixin PluginRegistration!PollPlugin; 1021 1022 public: 1023 1024 1025 // PollPlugin 1026 /++ 1027 The Poll plugin offers the ability to hold votes/polls in a channel. 1028 +/ 1029 final class PollPlugin : IRCPlugin 1030 { 1031 private: 1032 /++ 1033 All Poll plugin settings. 1034 +/ 1035 PollSettings pollSettings; 1036 1037 /++ 1038 Active polls by channel. 1039 +/ 1040 Poll[string] channelPolls; 1041 1042 /++ 1043 Temporary file to store ongoing polls to, between connections 1044 (and executions of the program). 1045 +/ 1046 @Resource pollTempFile = "polls.json"; 1047 1048 mixin IRCPluginImpl; 1049 }