1 /++ 2 This is a Twitch channel bot. It supports song requests, counting how many 3 times an emote has been used, reporting how long a viewer has been a follower, 4 how much time they have spent watching the stream, and some miscellanea. 5 6 For local use it can also emit some terminal bells on certain events, to draw attention. 7 8 If the `promote*` settings are toggled, some viewers will be automatically given 9 privileges based on their channel "status"; one of broadcaster, moderator and 10 VIPs. Viewers that don't fall into any of those categories are not given any 11 special permissions unless awarded manually. Nothing promotes into the 12 `whitelist` class as it's meant to be assigned to manually. 13 14 Mind that the majority of the other plugins still work on Twitch, so you also have 15 the [kameloso.plugins.counter|Counter] plugin for death counters, the 16 [kameloso.plugins.quotes|Quotes] plugin for streamer quotes, the 17 [kameloso.plugins.timer|Timer] plugin for timed announcements, the 18 [kameloso.plugins.oneliners|Oneliners] plugin for oneliner commands, etc. 19 20 See_Also: 21 https://github.com/zorael/kameloso/wiki/Current-plugins#twitch, 22 [kameloso.plugins.twitch.api], 23 [kameloso.plugins.twitch.common], 24 [kameloso.plugins.twitch.keygen], 25 [kameloso.plugins.common.core], 26 [kameloso.plugins.common.misc] 27 28 Copyright: [JR](https://github.com/zorael) 29 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 30 31 Authors: 32 [JR](https://github.com/zorael) 33 +/ 34 module kameloso.plugins.twitch.base; 35 36 37 // TwitchSettings 38 /++ 39 All Twitch plugin runtime settings. 40 41 Placed outside of the `version` gates to make sure it is always available, 42 even on non-`WithTwitchPlugin` builds, so that the Twitch stub may 43 import it and provide lines to the configuration file. 44 +/ 45 @Settings package struct TwitchSettings 46 { 47 private: 48 import dialect.defs : IRCUser; 49 import lu.uda : Unserialisable; 50 51 public: 52 /++ 53 Whether or not this plugin should react to any events. 54 +/ 55 @Enabler bool enabled = true; 56 57 /++ 58 Whether or not to count emotes in chat, to be able to respond to `!ecount` 59 queries about how many times a specific one has been seen. 60 +/ 61 bool ecount = true; 62 63 /++ 64 Whether or not to count the time people spend watching streams, to be 65 able to respond to `!watchtime`. 66 +/ 67 bool watchtime = true; 68 69 /++ 70 Whether or not to only count watchtime for users that has shown activity 71 in the channel. This makes it ignore silent lurkers. 72 +/ 73 bool watchtimeExcludesLurkers = true; 74 75 /++ 76 What kind of song requests to accept, if any. 77 +/ 78 SongRequestMode songrequestMode = SongRequestMode.youtube; 79 80 /++ 81 What level of user permissions are needed to issue song requests. 82 +/ 83 IRCUser.Class songrequestPermsNeeded = IRCUser.Class.whitelist; 84 85 /++ 86 Whether or not to convert queries received by someone whose channel is a 87 home channel into a channel message in that channel. 88 +/ 89 bool fakeChannelFromQueries = false; 90 91 /++ 92 Whether or not broadcasters are always implicitly class 93 [dialect.defs.IRCUser.Class.staff|IRCUser.Class.staff]. 94 +/ 95 bool promoteBroadcasters = true; 96 97 /++ 98 Whether or not moderators are always implicitly (at least) class 99 [dialect.defs.IRCUser.Class.operator|IRCUser.Class.operator]. 100 +/ 101 bool promoteModerators = true; 102 103 /++ 104 Whether or not VIPs are always implicitly (at least) class 105 [dialect.defs.IRCUser.Class.elevated|IRCUser.Class.elevated]. 106 +/ 107 bool promoteVIPs = true; 108 109 @Unserialisable 110 { 111 /++ 112 Whether or not to bell on every message. 113 +/ 114 bool bellOnMessage = false; 115 116 /++ 117 Whether or not to bell on important events, like subscriptions. 118 +/ 119 bool bellOnImportant = false; 120 121 /++ 122 Whether or not to start a captive session for requesting a Twitch 123 access token with normal chat privileges. 124 +/ 125 bool keygen = false; 126 127 /++ 128 Whether or not to start a captive session for requesting a Twitch 129 access token with broadcaster privileges. 130 +/ 131 bool superKeygen = false; 132 133 /++ 134 Whether or not to start a captive session for requesting Google 135 access tokens. 136 +/ 137 bool googleKeygen = false; 138 139 /++ 140 Whether or not to start a captive session for requesting Spotify 141 access tokens. 142 +/ 143 bool spotifyKeygen = false; 144 } 145 } 146 147 148 // SRM 149 /++ 150 Song requests may be either disabled, or either in YouTube or Spotify mode. 151 152 `SongRequestMode` abbreviated to fit into `printObjects` output formatting. 153 +/ 154 private enum SRM 155 { 156 /++ 157 Song requests are disabled. 158 +/ 159 disabled, 160 161 /++ 162 Song requests relate to a YouTube playlist. 163 +/ 164 youtube, 165 166 /++ 167 Song requests relatet to a Spotify playlist. 168 +/ 169 spotify, 170 } 171 172 /++ 173 Alias to [SRM]. 174 +/ 175 alias SongRequestMode = SRM; 176 177 private import kameloso.plugins.common.core; 178 179 version(TwitchSupport): 180 version(WithTwitchPlugin): 181 182 private: 183 184 import kameloso.plugins.twitch.api; 185 import kameloso.plugins.twitch.common; 186 import dialect.postprocessors.twitch; // To trigger the module ctor 187 188 import kameloso.plugins; 189 import kameloso.plugins.common.awareness : ChannelAwareness, TwitchAwareness, UserAwareness; 190 import kameloso.common : RehashingAA, logger; 191 import kameloso.constants : BufferSize; 192 import kameloso.messaging; 193 import dialect.defs; 194 import std.datetime.systime : SysTime; 195 import std.json : JSONValue; 196 import std.typecons : Flag, No, Yes; 197 import core.thread : Fiber; 198 199 200 // Credentials 201 /++ 202 Credentials needed to access APIs like that of Google and Spotify. 203 204 See_Also: 205 https://console.cloud.google.com/apis/credentials 206 +/ 207 package struct Credentials 208 { 209 /++ 210 Broadcaster-level Twitch key. 211 +/ 212 string broadcasterKey; 213 214 /++ 215 Google client ID. 216 +/ 217 string googleClientID; 218 219 /++ 220 Google client secret. 221 +/ 222 string googleClientSecret; 223 224 /++ 225 Google API OAuth access token. 226 +/ 227 string googleAccessToken; 228 229 /++ 230 Google API OAuth refresh token. 231 +/ 232 string googleRefreshToken; 233 234 /++ 235 YouTube playlist ID. 236 +/ 237 string youtubePlaylistID; 238 239 /++ 240 Google client ID. 241 +/ 242 string spotifyClientID; 243 244 /++ 245 Google client secret. 246 +/ 247 string spotifyClientSecret; 248 249 /++ 250 Spotify API OAuth access token. 251 +/ 252 string spotifyAccessToken; 253 254 /++ 255 Spotify API OAuth refresh token. 256 +/ 257 string spotifyRefreshToken; 258 259 /++ 260 Spotify playlist ID. 261 +/ 262 string spotifyPlaylistID; 263 264 /++ 265 Serialises these [Credentials] into JSON. 266 267 Returns: 268 `this` represented in JSON. 269 +/ 270 auto toJSON() const 271 { 272 JSONValue json; 273 json = null; 274 json.object = null; 275 276 json["broadcasterKey"] = this.broadcasterKey; 277 json["googleClientID"] = this.googleClientID; 278 json["googleClientSecret"] = this.googleClientSecret; 279 json["googleAccessToken"] = this.googleAccessToken; 280 json["googleRefreshToken"] = this.googleRefreshToken; 281 json["youtubePlaylistID"] = this.youtubePlaylistID; 282 json["spotifyClientID"] = this.spotifyClientID; 283 json["spotifyClientSecret"] = this.spotifyClientSecret; 284 json["spotifyAccessToken"] = this.spotifyAccessToken; 285 json["spotifyRefreshToken"] = this.spotifyRefreshToken; 286 json["spotifyPlaylistID"] = this.spotifyPlaylistID; 287 288 return json; 289 } 290 291 /++ 292 Deserialises some [Credentials] from JSON. 293 294 Params: 295 json = JSON representation of some [Credentials]. 296 297 Returns: 298 A new [Credentials] with values from the paseed `json`. 299 +/ 300 static auto fromJSON(const JSONValue json) 301 { 302 Credentials creds; 303 304 creds.broadcasterKey = json["broadcasterKey"].str; 305 creds.googleClientID = json["googleClientID"].str; 306 creds.googleClientSecret = json["googleClientSecret"].str; 307 creds.googleAccessToken = json["googleAccessToken"].str; 308 creds.googleRefreshToken = json["googleRefreshToken"].str; 309 creds.youtubePlaylistID = json["youtubePlaylistID"].str; 310 creds.spotifyClientID = json["spotifyClientID"].str; 311 creds.spotifyClientSecret = json["spotifyClientSecret"].str; 312 creds.spotifyAccessToken = json["spotifyAccessToken"].str; 313 creds.spotifyRefreshToken = json["spotifyRefreshToken"].str; 314 creds.spotifyPlaylistID = json["spotifyPlaylistID"].str; 315 316 return creds; 317 } 318 } 319 320 321 // Follow 322 /++ 323 Embodiment of the notion of someone following someone else on Twitch. 324 325 This cannot be a Voldemort type inside [kameloso.plugins.twitch.api.getFollows|getFollows] 326 since we need an array of them inside [TwitchPlugin.Room]. 327 +/ 328 package struct Follow 329 { 330 private: 331 import std.datetime.systime : SysTime; 332 333 public: 334 /++ 335 Display name of follower. 336 +/ 337 string displayName; 338 339 /++ 340 Time when the follow action took place. 341 +/ 342 SysTime when; 343 344 /++ 345 Twitch ID of follower. 346 +/ 347 uint followerID; 348 349 // fromJSON 350 /++ 351 Constructs a [Follow] from a JSON representation. 352 353 Params: 354 json = JSON representation of a follow. 355 356 Returns: 357 A new [Follow] with values derived from the passed JSON. 358 +/ 359 static auto fromJSON(const JSONValue json) 360 { 361 import std.conv : to; 362 363 /*{ 364 "followed_at": "2019-09-13T13:07:43Z", 365 "from_id": "20739840", 366 "from_name": "mike_bison", 367 "to_id": "22216721", 368 "to_name": "Zorael" 369 }*/ 370 371 Follow follow; 372 373 follow.displayName = json["from_name"].str; 374 follow.when = SysTime.fromISOExtString(json["followed_at"].str); 375 follow.followerID = json["from_id"].str.to!uint; 376 377 return follow; 378 } 379 } 380 381 382 // Mixins 383 mixin UserAwareness; 384 mixin ChannelAwareness; 385 mixin TwitchAwareness; 386 mixin PluginRegistration!(TwitchPlugin, -5.priority); 387 388 389 // onImportant 390 /++ 391 Bells on any important event, like subscriptions, cheers and raids, if the 392 [TwitchSettings.bellOnImportant] setting is set. 393 +/ 394 @(IRCEventHandler() 395 .onEvent(IRCEvent.Type.TWITCH_SUB) 396 .onEvent(IRCEvent.Type.TWITCH_SUBGIFT) 397 .onEvent(IRCEvent.Type.TWITCH_CHEER) 398 .onEvent(IRCEvent.Type.TWITCH_DIRECTCHEER) 399 .onEvent(IRCEvent.Type.TWITCH_REWARDGIFT) 400 .onEvent(IRCEvent.Type.TWITCH_GIFTCHAIN) 401 .onEvent(IRCEvent.Type.TWITCH_BULKGIFT) 402 .onEvent(IRCEvent.Type.TWITCH_SUBUPGRADE) 403 .onEvent(IRCEvent.Type.TWITCH_CHARITY) 404 .onEvent(IRCEvent.Type.TWITCH_BITSBADGETIER) 405 .onEvent(IRCEvent.Type.TWITCH_RITUAL) 406 .onEvent(IRCEvent.Type.TWITCH_EXTENDSUB) 407 .onEvent(IRCEvent.Type.TWITCH_GIFTRECEIVED) 408 .onEvent(IRCEvent.Type.TWITCH_PAYFORWARD) 409 .onEvent(IRCEvent.Type.TWITCH_RAID) 410 .onEvent(IRCEvent.Type.TWITCH_CROWDCHANT) 411 .onEvent(IRCEvent.Type.TWITCH_ANNOUNCEMENT) 412 .permissionsRequired(Permissions.ignore) 413 .channelPolicy(ChannelPolicy.home) 414 .chainable(true) 415 ) 416 void onImportant(TwitchPlugin plugin, const ref IRCEvent event) 417 { 418 import kameloso.terminal : TerminalToken; 419 import std.stdio : stdout, write; 420 421 // Record viewer as active 422 if (auto room = event.channel in plugin.rooms) 423 { 424 if (room.stream.live) 425 { 426 room.stream.activeViewers[event.sender.nickname] = true; 427 } 428 } 429 430 if (plugin.twitchSettings.bellOnImportant) 431 { 432 write(plugin.bell); 433 stdout.flush(); 434 } 435 } 436 437 438 // onSelfjoin 439 /++ 440 Registers a new [TwitchPlugin.Room] as we join a channel, so there's 441 always a state struct available. 442 443 Simply passes on execution to [initRoom]. 444 +/ 445 @(IRCEventHandler() 446 .onEvent(IRCEvent.Type.SELFJOIN) 447 .channelPolicy(ChannelPolicy.home) 448 ) 449 void onSelfjoin(TwitchPlugin plugin, const ref IRCEvent event) 450 { 451 if (event.channel !in plugin.rooms) 452 { 453 initRoom(plugin, event.channel); 454 } 455 } 456 457 458 // initRoom 459 /++ 460 Registers a new [TwitchPlugin.Room] as we join a channel, so there's 461 always a state struct available. 462 463 Params: 464 plugin = The current [TwitchPlugin]. 465 channelName = The name of the channel we're supposedly joining. 466 +/ 467 void initRoom(TwitchPlugin plugin, const string channelName) 468 in (channelName.length, "Tried to init Room with an empty channel string") 469 { 470 plugin.rooms[channelName] = TwitchPlugin.Room(channelName); 471 } 472 473 474 // onUserstate 475 /++ 476 Warns if we're not a moderator when we join a home channel. 477 478 "You will not get USERSTATE for other people. Only for yourself." 479 https://discuss.dev.twitch.tv/t/no-userstate-on-people-joining/11598 480 481 This sometimes happens once per outgoing message sent, causing it to spam 482 the moderator warning. 483 +/ 484 @(IRCEventHandler() 485 .onEvent(IRCEvent.Type.USERSTATE) 486 .channelPolicy(ChannelPolicy.home) 487 ) 488 void onUserstate(TwitchPlugin plugin, const ref IRCEvent event) 489 { 490 import lu.string : contains; 491 492 if (event.target.badges.contains("moderator/") || 493 event.target.badges.contains("broadcaster/")) 494 { 495 if (auto channel = event.channel in plugin.state.channels) 496 { 497 if (auto ops = 'o' in channel.mods) 498 { 499 if (plugin.state.client.nickname !in *ops) 500 { 501 (*ops)[plugin.state.client.nickname] = true; 502 } 503 } 504 else 505 { 506 channel.mods['o'][plugin.state.client.nickname] = true; 507 } 508 } 509 } 510 else 511 { 512 auto room = event.channel in plugin.rooms; 513 514 if (!room) 515 { 516 // Race... 517 initRoom(plugin, event.channel); 518 room = event.channel in plugin.rooms; 519 } 520 521 if (!room.sawUserstate) 522 { 523 // First USERSTATE; warn about not being mod 524 room.sawUserstate = true; 525 enum pattern = "The bot is not a moderator of home channel <l>%s</>. " ~ 526 "Consider elevating it to such to avoid being as rate-limited."; 527 logger.warningf(pattern, event.channel); 528 } 529 } 530 } 531 532 533 // onGlobalUserstate 534 /++ 535 Fetches global custom BetterTV, FrankerFaceZ and 7tv emotes. 536 +/ 537 @(IRCEventHandler() 538 .onEvent(IRCEvent.Type.GLOBALUSERSTATE) 539 .fiber(true) 540 ) 541 void onGlobalUserstate(TwitchPlugin plugin) 542 { 543 // dialect sets the display name during parsing 544 //assert(plugin.state.client.displayName == event.target.displayName); 545 importCustomGlobalEmotes(plugin); 546 } 547 548 549 // onSelfpart 550 /++ 551 Removes a channel's corresponding [TwitchPlugin.Room] when we leave it. 552 553 This resets all that channel's transient state. 554 +/ 555 @(IRCEventHandler() 556 .onEvent(IRCEvent.Type.SELFPART) 557 .channelPolicy(ChannelPolicy.home) 558 ) 559 void onSelfpart(TwitchPlugin plugin, const ref IRCEvent event) 560 { 561 auto room = event.channel in plugin.rooms; 562 if (!room) return; 563 564 if (room.stream.live) 565 { 566 import std.datetime.systime : Clock; 567 568 // We're leaving in the middle of a stream? 569 // Close it and rotate, in case someone has a pointer to it 570 // copied from nested functions in uptimeMonitorDg 571 room.stream.live = false; 572 room.stream.stopTime = Clock.currTime; 573 room.stream.chattersSeen = null; 574 appendToStreamHistory(plugin, room.stream); 575 room.stream = TwitchPlugin.Room.Stream.init; 576 } 577 578 plugin.rooms.remove(event.channel); 579 } 580 581 582 // onCommandUptime 583 /++ 584 Reports how long the streamer has been streaming. 585 586 Technically, how much time has passed since `!start` was issued. 587 588 The streamer's name is divined from the `plugin.state.users` associative 589 array by looking at the entry for the nickname this channel corresponds to. 590 +/ 591 @(IRCEventHandler() 592 .onEvent(IRCEvent.Type.CHAN) 593 .permissionsRequired(Permissions.anyone) 594 .channelPolicy(ChannelPolicy.home) 595 .addCommand( 596 IRCEventHandler.Command() 597 .word("uptime") 598 .policy(PrefixPolicy.prefixed) 599 .description("Reports how long the streamer has been streaming.") 600 ) 601 ) 602 void onCommandUptime(TwitchPlugin plugin, const ref IRCEvent event) 603 { 604 const room = event.channel in plugin.rooms; 605 assert(room, "Tried to process `onCommandUptime` on a nonexistent room"); 606 607 reportStreamTime(plugin, *room); 608 } 609 610 611 // reportStreamTime 612 /++ 613 Reports how long a broadcast has currently been ongoing, up until now lasted, 614 or previously lasted. 615 616 Params: 617 plugin = The current [TwitchPlugin]. 618 room = The [TwitchPlugin.Room] of the channel. 619 +/ 620 void reportStreamTime( 621 TwitchPlugin plugin, 622 const TwitchPlugin.Room room) 623 { 624 import kameloso.time : timeSince; 625 import lu.json : JSONStorage; 626 import std.datetime.systime : Clock, SysTime; 627 import std.format : format; 628 import core.time : msecs; 629 630 if (room.stream.live) 631 { 632 // Remove fractional seconds from the current timestamp 633 auto now = Clock.currTime; 634 now.fracSecs = 0.msecs; 635 immutable delta = (now - room.stream.startTime); 636 immutable timestring = timeSince!(7, 1)(delta); 637 638 if (room.stream.maxViewerCount > 0) 639 { 640 enum pattern = "%s has been live streaming %s for %s, currently with %d viewers. " ~ 641 "(Maximum this stream has so far been %d concurrent viewers.)"; 642 immutable message = pattern.format( 643 room.broadcasterDisplayName, 644 room.stream.gameName, 645 timestring, 646 room.stream.viewerCount, 647 room.stream.maxViewerCount); 648 return chan(plugin.state, room.channelName, message); 649 } 650 else 651 { 652 enum pattern = "%s has been live streaming %s for %s."; 653 immutable message = pattern.format( 654 room.broadcasterDisplayName, 655 room.stream.gameName, 656 timestring); 657 return chan(plugin.state, room.channelName, message); 658 } 659 } 660 661 // Stream down, check if we have one on record to report instead 662 JSONStorage json; 663 json.load(plugin.streamHistoryFile); 664 665 if (!json.array.length) 666 { 667 // No streams this session and none on record 668 immutable message = room.broadcasterDisplayName ~ " is currently not streaming."; 669 return chan(plugin.state, room.channelName, message); 670 } 671 672 const previousStream = TwitchPlugin.Room.Stream.fromJSON(json.array[$-1]); 673 immutable delta = (previousStream.stopTime - previousStream.startTime); 674 immutable timestring = timeSince!(7, 1)(delta); 675 immutable gameName = previousStream.gameName.length ? 676 previousStream.gameName : 677 "something"; 678 679 if (previousStream.maxViewerCount > 0) 680 { 681 enum pattern = "%s is currently not streaming. " ~ 682 "Last streamed %s on %4d-%02d-%02d for %s, " ~ 683 "with a maximum of %d concurrent viewers."; 684 immutable message = pattern.format( 685 room.broadcasterDisplayName, 686 gameName, 687 previousStream.stopTime.year, 688 cast(int)previousStream.stopTime.month, 689 previousStream.stopTime.day, 690 timestring, 691 previousStream.maxViewerCount); 692 return chan(plugin.state, room.channelName, message); 693 } 694 else 695 { 696 enum pattern = "%s is currently not streaming. " ~ 697 "Last streamed %s on %4d-%02d-%02d for %s."; 698 immutable message = pattern.format( 699 room.broadcasterDisplayName, 700 gameName, 701 previousStream.stopTime.year, 702 cast(int)previousStream.stopTime.month, 703 previousStream.stopTime.day, 704 timestring); 705 return chan(plugin.state, room.channelName, message); 706 } 707 } 708 709 710 // onCommandFollowAge 711 /++ 712 Implements "Follow Age", or the ability to query the server how long you 713 (or a specified user) have been a follower of the current channel. 714 715 Lookups are done asynchronously in subthreads. 716 717 See_Also: 718 [kameloso.plugins.twitch.api.getFollows] 719 +/ 720 @(IRCEventHandler() 721 .onEvent(IRCEvent.Type.CHAN) 722 .permissionsRequired(Permissions.anyone) 723 .channelPolicy(ChannelPolicy.home) 724 .fiber(true) 725 .addCommand( 726 IRCEventHandler.Command() 727 .word("followage") 728 .policy(PrefixPolicy.prefixed) 729 .description("Queries the server for how long you have been a follower " ~ 730 "of the current channel. Optionally takes a nickname parameter, " ~ 731 "to query for someone else.") 732 .addSyntax("$command [optional nickname]") 733 ) 734 ) 735 void onCommandFollowAge(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 736 { 737 import lu.string : beginsWith, nom, stripped; 738 import std.conv : to; 739 740 void sendNoSuchUser(const string givenName) 741 { 742 immutable message = "No such user: " ~ givenName; 743 chan(plugin.state, event.channel, message); 744 } 745 746 string slice = event.content.stripped; // mutable 747 string idString; 748 string displayName; 749 immutable nameSpecified = (slice.length > 0); 750 751 if (!nameSpecified) 752 { 753 // Assume the user is asking about itself 754 idString = event.sender.id.to!string; 755 displayName = event.sender.displayName; 756 } 757 else 758 { 759 string givenName = slice.nom!(Yes.inherit)(' '); // mutable 760 if (givenName.beginsWith('@')) givenName = givenName[1..$]; 761 immutable user = getTwitchUser(plugin, givenName, string.init, Yes.searchByDisplayName); 762 if (!user.nickname.length) return sendNoSuchUser(givenName); 763 764 idString = user.idString; 765 displayName = user.displayName; 766 } 767 768 void reportFollowAge(const Follow follow) 769 { 770 import kameloso.time : timeSince; 771 import std.datetime.systime : Clock, SysTime; 772 import std.format : format; 773 774 static immutable string[12] months = 775 [ 776 "January", 777 "February", 778 "March", 779 "April", 780 "May", 781 "June", 782 "July", 783 "August", 784 "September", 785 "October", 786 "November", 787 "December", 788 ]; 789 790 enum datestampPattern = "%s %d"; 791 immutable diff = Clock.currTime - follow.when; 792 immutable timeline = diff.timeSince!(7, 3); 793 immutable datestamp = datestampPattern.format( 794 months[cast(int)follow.when.month-1], 795 follow.when.year); 796 797 if (nameSpecified) 798 { 799 enum pattern = "%s has been a follower for %s, since %s."; 800 immutable message = pattern.format(follow.displayName, timeline, datestamp); 801 chan(plugin.state, event.channel, message); 802 } 803 else 804 { 805 enum pattern = "You have been a follower for %s, since %s."; 806 immutable message = pattern.format(timeline, datestamp); 807 chan(plugin.state, event.channel, message); 808 } 809 } 810 811 // Identity ascertained; look up in cached list 812 813 auto room = event.channel in plugin.rooms; 814 assert(room, "Tried to look up follow age in a nonexistent room"); 815 816 if (!room.follows.length) 817 { 818 // Follows have not yet been cached! 819 // This can technically happen, though practically the caching is 820 // done immediately after joining so there should be no time for 821 // !followage queries to sneak in. 822 // Luckily we're inside a Fiber so we can cache it ourselves. 823 room.follows = getFollows(plugin, room.id); 824 room.followsLastCached = event.time; 825 } 826 827 enum minimumTimeBetweenRecaches = 10; 828 829 if (const thisFollow = idString in room.follows) 830 { 831 return reportFollowAge(*thisFollow); 832 } 833 else if (event.time > (room.followsLastCached + minimumTimeBetweenRecaches)) 834 { 835 // No match, but minimumTimeBetweenRecaches passed since last recache 836 room.follows = getFollows(plugin, room.id); 837 room.followsLastCached = event.time; 838 839 if (const thisFollow = idString in room.follows) 840 { 841 return reportFollowAge(*thisFollow); 842 } 843 } 844 845 // If we're here there were no matches. 846 847 if (nameSpecified) 848 { 849 import std.format : format; 850 851 enum pattern = "%s is currently not a follower."; 852 immutable message = pattern.format(displayName); 853 chan(plugin.state, event.channel, message); 854 } 855 else 856 { 857 enum message = "You are currently not a follower."; 858 chan(plugin.state, event.channel, message); 859 } 860 } 861 862 863 // onRoomState 864 /++ 865 Records the room ID of a home channel, and queries the Twitch servers for 866 the display name of its broadcaster. 867 868 Additionally fetches custom BetterTV, FrankerFaceZ and 7tv emotes for the channel. 869 +/ 870 @(IRCEventHandler() 871 .onEvent(IRCEvent.Type.ROOMSTATE) 872 .channelPolicy(ChannelPolicy.home) 873 .fiber(true) 874 ) 875 void onRoomState(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 876 { 877 import kameloso.thread : ThreadMessage, boxed; 878 import std.concurrency : send; 879 880 auto room = event.channel in plugin.rooms; 881 882 if (!room) 883 { 884 // Race... 885 initRoom(plugin, event.channel); 886 room = event.channel in plugin.rooms; 887 } 888 889 /+ 890 Only start a room monitor Fiber if the room doesn't seem initialised. 891 If it does, it should already have a monitor running. Since we're not 892 resetting the room unique ID, we'd get two duplicate monitors. So don't. 893 +/ 894 immutable shouldStartRoomMonitor = !room.id.length; 895 auto twitchUser = getTwitchUser(plugin, string.init, event.aux[0]); 896 897 if (!twitchUser.nickname.length) 898 { 899 // No such user? 900 return; 901 } 902 903 room.id = event.aux[0]; // Assign this here after the nickname.length check 904 room.broadcasterDisplayName = twitchUser.displayName; 905 auto storedUser = twitchUser.nickname in plugin.state.users; 906 907 if (!storedUser) 908 { 909 // Forge a new IRCUser 910 auto newUser = IRCUser( 911 twitchUser.nickname, 912 twitchUser.nickname, 913 twitchUser.nickname ~ ".tmi.twitch.tv"); 914 newUser.account = newUser.nickname; 915 newUser.class_ = IRCUser.Class.anyone; 916 plugin.state.users[newUser.nickname] = newUser; 917 storedUser = newUser.nickname in plugin.state.users; 918 } 919 920 IRCUser userCopy = *storedUser; // dereference and copy 921 plugin.state.mainThread.send(ThreadMessage.putUser(string.init, boxed(userCopy))); 922 923 if (shouldStartRoomMonitor) 924 { 925 startRoomMonitorFibers(plugin, event.channel); 926 importCustomEmotes(plugin, event.channel, room.id); // also only do this once 927 } 928 } 929 930 931 // onGuestRoomState 932 /++ 933 Fetches custom BetterTV, FrankerFaceZ and 7tv emotes for a guest channel iff 934 version `TwitchCustomEmotesEverywhere`. 935 +/ 936 version(TwitchCustomEmotesEverywhere) 937 @(IRCEventHandler() 938 .onEvent(IRCEvent.Type.ROOMSTATE) 939 .channelPolicy(ChannelPolicy.guest) 940 .fiber(true) 941 ) 942 void onGuestRoomState(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 943 { 944 if (event.channel in plugin.customEmotesByChannel) 945 { 946 // Already done 947 return; 948 } 949 950 importCustomEmotes(plugin, event.channel, event.aux[0]); 951 } 952 953 954 // onCommandShoutout 955 /++ 956 Emits a shoutout to another streamer. 957 958 Merely gives a link to their channel and echoes what game they last streamed 959 (or are currently streaming). 960 +/ 961 @(IRCEventHandler() 962 .onEvent(IRCEvent.Type.CHAN) 963 .permissionsRequired(Permissions.operator) 964 .channelPolicy(ChannelPolicy.home) 965 .fiber(true) 966 .addCommand( 967 IRCEventHandler.Command() 968 .word("shoutout") 969 .policy(PrefixPolicy.prefixed) 970 .description("Emits a shoutout to another streamer.") 971 .addSyntax("$command [name of streamer] [optional number of times to spam]") 972 ) 973 .addCommand( 974 IRCEventHandler.Command() 975 .word("so") 976 .policy(PrefixPolicy.prefixed) 977 .hidden(true) 978 ) 979 ) 980 void onCommandShoutout(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 981 { 982 import kameloso.plugins.common.misc : idOf; 983 import lu.string : SplitResults, beginsWith, splitInto, stripped; 984 import std.format : format; 985 import std.json : JSONType, parseJSON; 986 987 void sendUsage() 988 { 989 enum pattern = "Usage: %s%s [name of streamer] [optional number of times to spam]"; 990 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 991 chan(plugin.state, event.channel, message); 992 } 993 994 void sendCountNotANumber() 995 { 996 enum message = "The passed count is not a number."; 997 chan(plugin.state, event.channel, message); 998 } 999 1000 void sendInvalidStreamerName() 1001 { 1002 enum message = "Invalid streamer name."; 1003 chan(plugin.state, event.channel, message); 1004 } 1005 1006 void sendNoSuchUser(const string target) 1007 { 1008 immutable message = "No such user: " ~ target; 1009 chan(plugin.state, event.channel, message); 1010 } 1011 1012 void sendUserHasNoChannel() 1013 { 1014 enum message = "Impossible error; user has no channel?"; 1015 chan(plugin.state, event.channel, message); 1016 } 1017 1018 void sendNoShoutoutOfCurrentChannel() 1019 { 1020 enum message = "Can't give a shoutout to the current channel..."; 1021 chan(plugin.state, event.channel, message); 1022 } 1023 1024 void sendOtherError() 1025 { 1026 enum message = "An error occured when preparing the shoutout."; 1027 chan(plugin.state, event.channel, message); 1028 } 1029 1030 string slice = event.content.stripped; // mutable 1031 string target; // ditto 1032 string numTimesString; // ditto 1033 1034 immutable results = slice.splitInto(target, numTimesString); 1035 1036 if (target.beginsWith('@')) target = target[1..$].stripped; 1037 1038 if (!target.length || (results == SplitResults.overrun)) 1039 { 1040 return sendUsage(); 1041 } 1042 1043 immutable login = idOf(plugin, target); 1044 1045 if (login == event.channel[1..$]) 1046 { 1047 return sendNoShoutoutOfCurrentChannel(); 1048 } 1049 1050 // Limit number of times to spam to an outrageous 10 1051 enum numTimesCap = 10; 1052 uint numTimes = 1; 1053 1054 if (numTimesString.length) 1055 { 1056 import std.conv : ConvException, to; 1057 1058 try 1059 { 1060 import std.algorithm.comparison : min; 1061 numTimes = min(numTimesString.stripped.to!uint, numTimesCap); 1062 } 1063 catch (ConvException e) 1064 { 1065 return sendCountNotANumber(); 1066 } 1067 } 1068 1069 immutable shoutout = createShoutout(plugin, login); 1070 1071 with (typeof(shoutout).State) 1072 final switch (shoutout.state) 1073 { 1074 case success: 1075 // Drop down 1076 break; 1077 1078 case noSuchUser: 1079 return sendNoSuchUser(login); 1080 1081 case noChannel: 1082 return sendUserHasNoChannel(); 1083 1084 case otherError: 1085 return sendOtherError(); 1086 } 1087 1088 const stream = getStream(plugin, login); 1089 string lastSeenPlayingPattern = "%s"; // mutable 1090 1091 if (shoutout.gameName.length) 1092 { 1093 lastSeenPlayingPattern = stream.live ? 1094 " (currently playing %s)" : 1095 " (last seen playing %s)"; 1096 } 1097 1098 immutable pattern = "Shoutout to %s! Visit them at https://twitch.tv/%s !" ~ lastSeenPlayingPattern; 1099 immutable message = pattern.format(shoutout.displayName, login, shoutout.gameName); 1100 1101 foreach (immutable i; 0..numTimes) 1102 { 1103 chan(plugin.state, event.channel, message); 1104 } 1105 } 1106 1107 1108 // onCommandVanish 1109 /++ 1110 Hides a user's messages (making them "disappear") by briefly timing them out. 1111 +/ 1112 @(IRCEventHandler() 1113 .onEvent(IRCEvent.Type.CHAN) 1114 .channelPolicy(ChannelPolicy.home) 1115 .addCommand( 1116 IRCEventHandler.Command() 1117 .word("vanish") 1118 .policy(PrefixPolicy.prefixed) 1119 .description(`Hides a user's messages (making them "disappear") by briefly timing them out.`) 1120 ) 1121 .addCommand( 1122 IRCEventHandler.Command() 1123 .word("poof") 1124 .policy(PrefixPolicy.prefixed) 1125 .hidden(true) 1126 ) 1127 ) 1128 void onCommandVanish(TwitchPlugin plugin, const ref IRCEvent event) 1129 { 1130 immutable message = ".timeout " ~ event.sender.nickname ~ " 1"; 1131 chan(plugin.state, event.channel, message); 1132 } 1133 1134 1135 // onCommandRepeat 1136 /++ 1137 Repeats a given message n number of times. 1138 1139 Requires moderator privileges to work correctly. 1140 +/ 1141 @(IRCEventHandler() 1142 .onEvent(IRCEvent.Type.CHAN) 1143 .channelPolicy(ChannelPolicy.home) 1144 .addCommand( 1145 IRCEventHandler.Command() 1146 .word("repeat") 1147 .policy(PrefixPolicy.prefixed) 1148 .description("Repeats a given message n number of times.") 1149 ) 1150 .addCommand( 1151 IRCEventHandler.Command() 1152 .word("spam") 1153 .policy(PrefixPolicy.prefixed) 1154 .hidden(true) 1155 ) 1156 ) 1157 void onCommandRepeat(TwitchPlugin plugin, const ref IRCEvent event) 1158 { 1159 import lu.string : nom, stripped; 1160 import std.algorithm.searching : count; 1161 import std.algorithm.comparison : min; 1162 import std.conv : ConvException, to; 1163 import std.format : format; 1164 1165 void sendUsage() 1166 { 1167 enum pattern = "Usage: %s%s [number of times] [text...]"; 1168 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 1169 chan(plugin.state, event.channel, message); 1170 } 1171 1172 void sendNumTimesGTZero() 1173 { 1174 enum message = "Number of times must be greater than 0."; 1175 chan(plugin.state, event.channel, message); 1176 } 1177 1178 if (!event.content.length || !event.content.count(' ')) return sendUsage(); 1179 1180 string slice = event.content.stripped; // mutable 1181 immutable numTimesString = slice.nom(' '); 1182 1183 try 1184 { 1185 enum maxNumTimes = 10; 1186 immutable numTimes = min(numTimesString.to!int, maxNumTimes); 1187 if (numTimes < 1) return sendNumTimesGTZero(); 1188 1189 foreach (immutable i; 0..numTimes) 1190 { 1191 chan(plugin.state, event.channel, slice); 1192 } 1193 } 1194 catch (ConvException _) 1195 { 1196 return sendUsage(); 1197 } 1198 } 1199 1200 1201 // onCommandNuke 1202 /++ 1203 Deletes recent messages containing a supplied word or phrase. 1204 1205 See_Also: 1206 [TwitchPlugin.Room.lastNMessages] 1207 +/ 1208 @(IRCEventHandler() 1209 .onEvent(IRCEvent.Type.CHAN) 1210 .permissionsRequired(Permissions.operator) 1211 .channelPolicy(ChannelPolicy.home) 1212 .addCommand( 1213 IRCEventHandler.Command() 1214 .word("nuke") 1215 .policy(PrefixPolicy.prefixed) 1216 .description("Deletes recent messages containing a supplied word or phrase.") 1217 .addSyntax("$command [word or phrase]") 1218 ) 1219 ) 1220 void onCommandNuke(TwitchPlugin plugin, const ref IRCEvent event) 1221 { 1222 import std.conv : text; 1223 import std.uni : toLower; 1224 1225 if (!event.content.length) 1226 { 1227 import std.format : format; 1228 enum pattern = "Usage: %s%s [word or phrase]"; 1229 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 1230 return chan(plugin.state, event.channel, message); 1231 } 1232 1233 auto room = event.channel in plugin.rooms; 1234 assert(room, "Tried to nuke a word in a nonexistent room"); 1235 immutable phraseToLower = event.content.toLower; 1236 1237 foreach (immutable storedEvent; room.lastNMessages) 1238 { 1239 import std.algorithm.searching : canFind; 1240 import std.uni : asLowerCase; 1241 1242 if (storedEvent.sender.class_ >= IRCUser.Class.operator) continue; 1243 else if (!storedEvent.content.length) continue; 1244 1245 if (storedEvent.content.asLowerCase.canFind(phraseToLower)) 1246 { 1247 enum properties = Message.Property.priority; 1248 immutable message = text(".delete ", storedEvent.id); 1249 chan(plugin.state, event.channel, message, properties); 1250 } 1251 } 1252 1253 // Also nuke the nuking message in case there were spoilers in it 1254 immutable message = ".delete " ~ event.id; 1255 chan(plugin.state, event.channel, message); 1256 } 1257 1258 1259 // onCommandSongRequest 1260 /++ 1261 Implements `!songrequest`, allowing viewers to request songs (actually 1262 YouTube videos) to be added to the streamer's playlist. 1263 1264 See_Also: 1265 [kameloso.plugins.twitch.google.addVideoToYouTubePlaylist] 1266 [kameloso.plugins.twitch.spotify.addTrackToSpotifyPlaylist] 1267 +/ 1268 @(IRCEventHandler() 1269 .onEvent(IRCEvent.Type.CHAN) 1270 .permissionsRequired(Permissions.anyone) 1271 .channelPolicy(ChannelPolicy.home) 1272 .fiber(true) 1273 .addCommand( 1274 IRCEventHandler.Command() 1275 .word("songrequest") 1276 .policy(PrefixPolicy.prefixed) 1277 .description("Requests a song.") 1278 .addSyntax("$command [YouTube link, YouTube video ID, Spotify link or Spotify track ID]") 1279 ) 1280 .addCommand( 1281 IRCEventHandler.Command() 1282 .word("sr") 1283 .policy(PrefixPolicy.prefixed) 1284 .hidden(true) 1285 ) 1286 ) 1287 void onCommandSongRequest(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 1288 { 1289 import lu.string : contains, nom, stripped; 1290 import std.format : format; 1291 import core.time : seconds; 1292 1293 /+ 1294 The minimum amount of time in seconds that must have passed between 1295 two song requests by one non-operator person. 1296 +/ 1297 enum minimumTimeBetweenSongRequests = 60; 1298 1299 void sendUsage() 1300 { 1301 immutable pattern = (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) ? 1302 "Usage: %s%s [YouTube link or video ID]" : 1303 "Usage: %s%s [Spotify link or track ID]"; 1304 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 1305 chan(plugin.state, event.channel, message); 1306 } 1307 1308 void sendMissingCredentials() 1309 { 1310 immutable channelMessage = (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) ? 1311 "Missing Google API credentials and/or YouTube playlist ID." : 1312 "Missing Spotify API credentials and/or playlist ID."; 1313 immutable terminalMessage = (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) ? 1314 channelMessage ~ " Run the program with <l>--set twitch.googleKeygen</> to set it up." : 1315 channelMessage ~ " Run the program with <l>--set twitch.spotifyKeygen</> to set it up."; 1316 chan(plugin.state, event.channel, channelMessage); 1317 logger.error(terminalMessage); 1318 } 1319 1320 void sendInvalidCredentials() 1321 { 1322 immutable message = (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) ? 1323 "Invalid Google API credentials." : 1324 "Invalid Spotify API credentials."; 1325 chan(plugin.state, event.channel, message); 1326 } 1327 1328 void sendAtLastNSecondsMustPass() 1329 { 1330 enum pattern = "At least %d seconds must pass between song requests."; 1331 immutable message = pattern.format(minimumTimeBetweenSongRequests); 1332 chan(plugin.state, event.channel, message); 1333 } 1334 1335 void sendInsufficientPermissions() 1336 { 1337 enum message = "You do not have the needed permissions to issue song requests."; 1338 chan(plugin.state, event.channel, message); 1339 } 1340 1341 void sendInvalidURL() 1342 { 1343 immutable message = (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) ? 1344 "Invalid YouTube video URL." : 1345 "Invalid Spotify track URL."; 1346 chan(plugin.state, event.channel, message); 1347 } 1348 1349 void sendNonspecificError() 1350 { 1351 enum message = "A non-specific error occurred."; 1352 chan(plugin.state, event.channel, message); 1353 } 1354 1355 void sendAddedToYouTubePlaylist(const string title) 1356 { 1357 enum pattern = "%s added to playlist."; 1358 immutable message = pattern.format(title); 1359 chan(plugin.state, event.channel, message); 1360 } 1361 1362 void sendAddedToSpotifyPlaylist(const string artist, const string track) 1363 { 1364 enum pattern = "%s - %s added to playlist."; 1365 immutable message = pattern.format(artist, track); 1366 chan(plugin.state, event.channel, message); 1367 } 1368 1369 if (plugin.twitchSettings.songrequestMode == SongRequestMode.disabled) return; 1370 1371 if (event.sender.class_ < plugin.twitchSettings.songrequestPermsNeeded) 1372 { 1373 return sendInsufficientPermissions(); 1374 } 1375 1376 auto room = event.channel in plugin.rooms; // must be mutable for history 1377 assert(room, "Tried to make a song request in a nonexistent room"); 1378 1379 if (event.sender.class_ < IRCUser.class_.operator) 1380 { 1381 if (const lastRequestTimestamp = event.sender.nickname in room.songrequestHistory) 1382 { 1383 if ((event.time - *lastRequestTimestamp) < minimumTimeBetweenSongRequests) 1384 { 1385 return sendAtLastNSecondsMustPass(); 1386 } 1387 } 1388 } 1389 1390 if (plugin.twitchSettings.songrequestMode == SongRequestMode.youtube) 1391 { 1392 immutable url = event.content.stripped; 1393 1394 enum videoIDLength = 11; 1395 1396 if (url.length == videoIDLength) 1397 { 1398 // Probably a video ID 1399 } 1400 else if ( 1401 !url.length || 1402 url.contains(' ') || 1403 (!url.contains("youtube.com/") && !url.contains("youtu.be/"))) 1404 { 1405 return sendUsage(); 1406 } 1407 1408 auto creds = event.channel in plugin.secretsByChannel; 1409 if (!creds || !creds.googleAccessToken.length || !creds.youtubePlaylistID.length) 1410 { 1411 return sendMissingCredentials(); 1412 } 1413 1414 // Patterns: 1415 // https://www.youtube.com/watch?v=jW1KXvCg5bY&t=123 1416 // www.youtube.com/watch?v=jW1KXvCg5bY&t=123 1417 // https://youtu.be/jW1KXvCg5bY?t=123 1418 // youtu.be/jW1KXvCg5bY?t=123 1419 // jW1KXvCg5bY 1420 1421 string slice = url; // mutable 1422 string videoID; 1423 1424 if (slice.length == videoIDLength) 1425 { 1426 videoID = slice; 1427 } 1428 else if (slice.contains("youtube.com/watch?v=")) 1429 { 1430 slice.nom("youtube.com/watch?v="); 1431 videoID = slice.nom!(Yes.inherit)('&'); 1432 } 1433 else if (slice.contains("youtu.be/")) 1434 { 1435 slice.nom("youtu.be/"); 1436 videoID = slice.nom!(Yes.inherit)('?'); 1437 } 1438 else 1439 { 1440 //return logger.warning("Bad link parsing?"); 1441 return sendInvalidURL(); 1442 } 1443 1444 try 1445 { 1446 import kameloso.plugins.twitch.google : addVideoToYouTubePlaylist; 1447 import std.json : JSONType; 1448 1449 immutable json = addVideoToYouTubePlaylist(plugin, *creds, videoID); 1450 1451 if ((json.type != JSONType.object) || ("snippet" !in json)) 1452 { 1453 logger.error("Unexpected JSON in YouTube response."); 1454 logger.trace(json.toPrettyString); 1455 return; 1456 } 1457 1458 immutable title = json["snippet"]["title"].str; 1459 //immutable position = json["snippet"]["position"].integer; 1460 room.songrequestHistory[event.sender.nickname] = event.time; 1461 return sendAddedToYouTubePlaylist(title); 1462 } 1463 catch (InvalidCredentialsException _) 1464 { 1465 return sendInvalidCredentials(); 1466 } 1467 catch (ErrorJSONException _) 1468 { 1469 return sendNonspecificError(); 1470 } 1471 // Let other exceptions fall through 1472 } 1473 else if (plugin.twitchSettings.songrequestMode == SongRequestMode.spotify) 1474 { 1475 immutable url = event.content.stripped; 1476 1477 enum trackIDLength = 22; 1478 1479 if (url.length == trackIDLength) 1480 { 1481 // Probably a track ID 1482 } 1483 else if ( 1484 !url.length || 1485 url.contains(' ') || 1486 !url.contains("spotify.com/track/")) 1487 { 1488 return sendUsage(); 1489 } 1490 1491 auto creds = event.channel in plugin.secretsByChannel; 1492 if (!creds || !creds.spotifyAccessToken.length || !creds.spotifyPlaylistID) 1493 { 1494 return sendMissingCredentials(); 1495 } 1496 1497 // Patterns 1498 // https://open.spotify.com/track/65EGCfqn3di7gLMllw1Tg0?si=02eb9a0c9d6c4972 1499 1500 string slice = url; // mutable 1501 string trackID; 1502 1503 if (slice.length == trackIDLength) 1504 { 1505 trackID = slice; 1506 } 1507 else if (slice.contains("spotify.com/track/")) 1508 { 1509 slice.nom("spotify.com/track/"); 1510 trackID = slice.nom!(Yes.inherit)('?'); 1511 } 1512 else 1513 { 1514 return sendInvalidURL(); 1515 } 1516 1517 try 1518 { 1519 import kameloso.plugins.twitch.spotify : addTrackToSpotifyPlaylist, getSpotifyTrackByID; 1520 import std.json : JSONType; 1521 1522 immutable json = addTrackToSpotifyPlaylist(plugin, *creds, trackID); 1523 1524 if ((json.type != JSONType.object) || ("snapshot_id" !in json)) 1525 { 1526 logger.error("Unexpected JSON in Spotify response."); 1527 logger.trace(json.toPrettyString); 1528 return; 1529 } 1530 1531 const trackJSON = getSpotifyTrackByID(*creds, trackID); 1532 immutable artist = trackJSON["artists"].array[0].object["name"].str; 1533 immutable track = trackJSON["name"].str; 1534 room.songrequestHistory[event.sender.nickname] = event.time; 1535 return sendAddedToSpotifyPlaylist(artist, track); 1536 } 1537 catch (ErrorJSONException _) 1538 { 1539 return sendInvalidURL(); 1540 } 1541 // Let other exceptions fall through 1542 } 1543 } 1544 1545 1546 // onCommandStartPoll 1547 /++ 1548 Starts a Twitch poll. 1549 1550 Note: Experimental, since we cannot try it out ourselves. 1551 1552 See_Also: 1553 [kameloso.plugins.twitch.api.createPoll] 1554 +/ 1555 @(IRCEventHandler() 1556 .onEvent(IRCEvent.Type.CHAN) 1557 .permissionsRequired(Permissions.operator) 1558 .channelPolicy(ChannelPolicy.home) 1559 .fiber(true) 1560 .addCommand( 1561 IRCEventHandler.Command() 1562 .word("startpoll") 1563 .policy(PrefixPolicy.prefixed) 1564 .description("(Experimental) Starts a Twitch poll.") 1565 .addSyntax(`$command "[poll title]" [duration] "[choice 1]" "[choice 2]" ...`) 1566 ) 1567 .addCommand( 1568 IRCEventHandler.Command() 1569 .word("startvote") 1570 .policy(PrefixPolicy.prefixed) 1571 .hidden(true) 1572 ) 1573 .addCommand( 1574 IRCEventHandler.Command() 1575 .word("createpoll") 1576 .policy(PrefixPolicy.prefixed) 1577 .hidden(true) 1578 ) 1579 ) 1580 void onCommandStartPoll(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 1581 { 1582 import kameloso.time : DurationStringException, abbreviatedDuration; 1583 import lu.string : splitWithQuotes; 1584 import std.conv : ConvException, to; 1585 import std.format : format; 1586 import std.json : JSONType; 1587 1588 void sendUsage() 1589 { 1590 import std.format : format; 1591 enum pattern = `Usage: %s%s "[poll title]" [duration] "[choice 1]" "[choice 2]" ...`; 1592 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 1593 chan(plugin.state, event.channel, message); 1594 } 1595 1596 immutable chunks = splitWithQuotes(event.content); 1597 if (chunks.length < 4) return sendUsage(); 1598 1599 immutable title = chunks[0]; 1600 string durationString = chunks[1]; // mutable 1601 immutable choices = chunks[2..$]; 1602 1603 try 1604 { 1605 durationString = durationString 1606 .abbreviatedDuration 1607 .total!"seconds" 1608 .to!string; 1609 } 1610 catch (ConvException _) 1611 { 1612 enum message = "Invalid duration."; 1613 return chan(plugin.state, event.channel, message); 1614 } 1615 /*catch (DurationStringException e) 1616 { 1617 return chan(plugin.state, event.channel, e.msg); 1618 }*/ 1619 catch (Exception e) 1620 { 1621 return chan(plugin.state, event.channel, e.msg); 1622 } 1623 1624 try 1625 { 1626 immutable responseJSON = createPoll(plugin, event.channel, title, durationString, choices); 1627 enum pattern = `Poll "%s" created.`; 1628 immutable message = pattern.format(responseJSON[0].object["title"].str); 1629 chan(plugin.state, event.channel, message); 1630 } 1631 catch (MissingBroadcasterTokenException _) 1632 { 1633 enum message = "Missing broadcaster-level API token."; 1634 enum superMessage = message ~ " Run the program with <l>--set twitch.superKeygen</> to generate a new one."; 1635 chan(plugin.state, event.channel, message); 1636 logger.error(superMessage); 1637 } 1638 catch (TwitchQueryException e) 1639 { 1640 import std.algorithm.searching : endsWith; 1641 1642 if ((e.code == 403) && 1643 (e.error == "Forbidden") && 1644 e.msg.endsWith("is not a partner or affiliate")) 1645 { 1646 version(WithPollPlugin) 1647 { 1648 enum message = "You must be an affiliate to create Twitch polls. " ~ 1649 "(Consider using the generic Poll plugin.)"; 1650 } 1651 else 1652 { 1653 enum message = "You must be an affiliate to create Twitch polls."; 1654 } 1655 1656 chan(plugin.state, event.channel, message); 1657 } 1658 else 1659 { 1660 // Fall back to twitchTryCatchDg's exception handling 1661 throw e; 1662 } 1663 } 1664 // As above 1665 } 1666 1667 1668 // onCommandEndPoll 1669 /++ 1670 Ends a Twitch poll. 1671 1672 Currently ends the first active poll if there are several. 1673 1674 Note: Experimental, since we cannot try it out ourselves. 1675 1676 See_Also: 1677 [kameloso.plugins.twitch.api.endPoll] 1678 +/ 1679 @(IRCEventHandler() 1680 .onEvent(IRCEvent.Type.CHAN) 1681 .permissionsRequired(Permissions.operator) 1682 .channelPolicy(ChannelPolicy.home) 1683 .fiber(true) 1684 .addCommand( 1685 IRCEventHandler.Command() 1686 .word("endpoll") 1687 .policy(PrefixPolicy.prefixed) 1688 .description("(Experimental) Ends a Twitch poll.") 1689 //.addSyntax("$command [terminating]") 1690 ) 1691 .addCommand( 1692 IRCEventHandler.Command() 1693 .word("endvote") 1694 .policy(PrefixPolicy.prefixed) 1695 .hidden(true) 1696 ) 1697 ) 1698 void onCommandEndPoll(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 1699 { 1700 import std.json : JSONType; 1701 1702 try 1703 { 1704 const pollInfoJSON = getPolls(plugin, event.channel); 1705 1706 if (!pollInfoJSON.length) 1707 { 1708 enum message = "There are no active polls to end."; 1709 return chan(plugin.state, event.channel, message); 1710 } 1711 1712 immutable voteID = pollInfoJSON[0].object["id"].str; 1713 immutable endResponseJSON = endPoll(plugin, event.channel, voteID, Yes.terminate); 1714 1715 if ((endResponseJSON.type != JSONType.object) || 1716 ("choices" !in endResponseJSON) || 1717 (endResponseJSON["choices"].array.length < 2)) 1718 { 1719 // Invalid response in some way 1720 logger.error("Unexpected response from server when ending a poll"); 1721 logger.trace(endResponseJSON.toPrettyString); 1722 return; 1723 } 1724 1725 /*static struct Choice 1726 { 1727 string title; 1728 long votes; 1729 } 1730 1731 Choice[] choices; 1732 long totalVotes; 1733 1734 foreach (immutable i, const choiceJSON; endResponseJSON["choices"].array) 1735 { 1736 Choice choice; 1737 choice.title = choiceJSON["title"].str; 1738 choice.votes = 1739 choiceJSON["votes"].integer + 1740 choiceJSON["channel_points_votes"].integer + 1741 choiceJSON["bits_votes"].integer; 1742 choices ~= choice; 1743 totalVotes += choice.votes; 1744 } 1745 1746 auto sortedChoices = choices.sort!((a,b) => a.votes > b.votes);*/ 1747 1748 enum message = "Poll ended."; 1749 chan(plugin.state, event.channel, message); 1750 } 1751 catch (MissingBroadcasterTokenException e) 1752 { 1753 enum pattern = "Missing broadcaster-level API token for channel <l>%s</>."; 1754 logger.errorf(pattern, e.channelName); 1755 1756 enum superMessage = "Run the program with <l>--set twitch.superKeygen</> to generate a new one."; 1757 logger.error(superMessage); 1758 } 1759 } 1760 1761 1762 // onAnyMessage 1763 /++ 1764 Bells on any message, if the [TwitchSettings.bellOnMessage] setting is set. 1765 Also counts emotes for `ecount` and records active viewers. 1766 1767 Belling is useful with small audiences so you don't miss messages, but 1768 obviously only makes sense when run locally. 1769 +/ 1770 @(IRCEventHandler() 1771 .onEvent(IRCEvent.Type.CHAN) 1772 .onEvent(IRCEvent.Type.QUERY) 1773 .onEvent(IRCEvent.Type.EMOTE) 1774 .permissionsRequired(Permissions.ignore) 1775 .channelPolicy(ChannelPolicy.home) 1776 .chainable(true) 1777 ) 1778 void onAnyMessage(TwitchPlugin plugin, const ref IRCEvent event) 1779 { 1780 if (plugin.twitchSettings.bellOnMessage) 1781 { 1782 import kameloso.terminal : TerminalToken; 1783 import std.stdio : stdout, write; 1784 1785 write(plugin.bell); 1786 stdout.flush(); 1787 } 1788 1789 if (event.type == IRCEvent.Type.QUERY) 1790 { 1791 // Ignore queries for the rest of this function 1792 return; 1793 } 1794 1795 // ecount! 1796 if (plugin.twitchSettings.ecount && event.emotes.length) 1797 { 1798 import lu.string : nom; 1799 import std.algorithm.iteration : splitter; 1800 import std.algorithm.searching : count; 1801 import std.conv : to; 1802 1803 foreach (immutable emotestring; event.emotes.splitter('/')) 1804 { 1805 if (!emotestring.length) continue; 1806 1807 auto channelcount = event.channel in plugin.ecount; 1808 if (!channelcount) 1809 { 1810 plugin.ecount[event.channel][string.init] = 0L; 1811 channelcount = event.channel in plugin.ecount; 1812 (*channelcount).remove(string.init); 1813 } 1814 1815 string slice = emotestring; // mutable 1816 immutable id = slice.nom(':'); 1817 1818 auto thisEmoteCount = id in *channelcount; 1819 if (!thisEmoteCount) 1820 { 1821 (*channelcount)[id] = 0L; 1822 thisEmoteCount = id in *channelcount; 1823 } 1824 1825 *thisEmoteCount += slice.count(',') + 1; 1826 plugin.ecountDirty = true; 1827 } 1828 } 1829 1830 // Record viewer as active 1831 if (auto room = event.channel in plugin.rooms) 1832 { 1833 if (room.stream.live) 1834 { 1835 room.stream.activeViewers[event.sender.nickname] = true; 1836 } 1837 1838 room.lastNMessages.put(event); 1839 } 1840 } 1841 1842 1843 // onEndOfMOTD 1844 /++ 1845 Sets up various things after we have successfully 1846 logged onto the server. 1847 1848 Has to be done at MOTD, as we only know whether we're on Twitch after 1849 [dialect.defs.IRCEvent.Type.RPL_MYINFO|RPL_MYINFO] or so. 1850 1851 Some of this could be done in [initialise], like spawning the persistent 1852 worker thread, but then it'd always be spawned even if the plugin is disabled 1853 or if we end up on a non-Twitch server. 1854 +/ 1855 @(IRCEventHandler() 1856 .onEvent(IRCEvent.Type.RPL_ENDOFMOTD) 1857 .onEvent(IRCEvent.Type.ERR_NOMOTD) 1858 ) 1859 void onEndOfMOTD(TwitchPlugin plugin) 1860 { 1861 import lu.string : beginsWith; 1862 import std.concurrency : spawn; 1863 1864 // Concatenate the Bearer and OAuth headers once. 1865 // This has to be done *after* connect's register 1866 immutable pass = plugin.state.bot.pass.beginsWith("oauth:") ? 1867 plugin.state.bot.pass[6..$] : 1868 plugin.state.bot.pass; 1869 plugin.authorizationBearer = "Bearer " ~ pass; 1870 1871 // Initialise the bucket, just so that it isn't null 1872 plugin.bucket[0] = QueryResponse.init; 1873 plugin.bucket.remove(0); 1874 1875 // Spawn the persistent worker. 1876 plugin.persistentWorkerTid = spawn( 1877 &persistentQuerier, 1878 plugin.bucket, 1879 plugin.state.connSettings.caBundleFile); 1880 1881 startValidator(plugin); 1882 startSaver(plugin); 1883 } 1884 1885 1886 // onCommandEcount 1887 /++ 1888 `!ecount`; reporting how many times a Twitch emote has been seen. 1889 1890 See_Also: 1891 [TwitchPlugin.ecount] 1892 +/ 1893 @(IRCEventHandler() 1894 .onEvent(IRCEvent.Type.CHAN) 1895 .permissionsRequired(Permissions.anyone) 1896 .channelPolicy(ChannelPolicy.home) 1897 .addCommand( 1898 IRCEventHandler.Command() 1899 .word("ecount") 1900 .policy(PrefixPolicy.prefixed) 1901 .description("Reports how many times a Twitch emote has been used in the channel.") 1902 .addSyntax("$command [emote]") 1903 ) 1904 ) 1905 void onCommandEcount(TwitchPlugin plugin, const ref IRCEvent event) 1906 { 1907 import lu.string : nom; 1908 import std.array : replace; 1909 import std.format : format; 1910 import std.conv : to; 1911 1912 if (!plugin.twitchSettings.ecount) return; 1913 1914 void sendUsage() 1915 { 1916 enum pattern = "Usage: %s%s [emote]"; 1917 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 1918 chan(plugin.state, event.channel, message); 1919 } 1920 1921 void sendNotATwitchEmote() 1922 { 1923 enum message = "That is not a Twitch, BetterTTV, FrankerFaceZ or 7tv emote."; 1924 chan(plugin.state, event.channel, message); 1925 } 1926 1927 void sendResults(const long count) 1928 { 1929 // 425618:3-5,7-8/peepoLeave:9-18 1930 string slice = event.emotes; // mutable 1931 slice.nom(':'); 1932 1933 immutable start = slice.nom('-').to!size_t; 1934 immutable end = slice 1935 .nom!(Yes.inherit)('/') 1936 .nom!(Yes.inherit)(',') 1937 .to!size_t + 1; // upper-bound inclusive! 1938 1939 string rawSlice = event.raw; // mutable 1940 rawSlice.nom(event.channel); 1941 rawSlice.nom(" :"); 1942 1943 // Slice it as a dstring to (hopefully) get full characters 1944 // Undo replacements 1945 immutable dline = rawSlice.to!dstring; 1946 immutable emote = dline[start..end] 1947 .replace(dchar(';'), dchar(':')); 1948 1949 // No real point using plurality since most emotes should have a count > 1 1950 // Make the pattern "%,?d", and supply an extra ' ' argument to get European grouping 1951 enum pattern = "%s has been used %,d times!"; 1952 immutable message = pattern.format(emote, count); 1953 chan(plugin.state, event.channel, message); 1954 } 1955 1956 if (!event.content.length) 1957 { 1958 return sendUsage(); 1959 } 1960 else if (!event.emotes.length) 1961 { 1962 return sendNotATwitchEmote(); 1963 } 1964 1965 const channelcounts = event.channel in plugin.ecount; 1966 if (!channelcounts) return sendResults(0L); 1967 1968 string slice = event.emotes; 1969 1970 // Replace emote colons so as not to conflict with emote tag syntax 1971 immutable id = slice 1972 .nom(':') 1973 .replace(':', ';'); 1974 1975 auto thisEmoteCount = id in *channelcounts; 1976 if (!thisEmoteCount) return sendResults(0L); 1977 1978 sendResults(*thisEmoteCount); 1979 } 1980 1981 1982 // onCommandWatchtime 1983 /++ 1984 Implements `!watchtime`; the ability to query the bot for how long the user 1985 (or a specified user) has been watching any of the channel's streams. 1986 +/ 1987 @(IRCEventHandler() 1988 .onEvent(IRCEvent.Type.CHAN) 1989 .permissionsRequired(Permissions.anyone) 1990 .channelPolicy(ChannelPolicy.home) 1991 .fiber(true) 1992 .addCommand( 1993 IRCEventHandler.Command() 1994 .word("watchtime") 1995 .policy(PrefixPolicy.prefixed) 1996 .description("Reports how long a user has been watching the channel's streams.") 1997 .addSyntax("$command [optional nickname]") 1998 ) 1999 .addCommand( 2000 IRCEventHandler.Command() 2001 .word("wt") 2002 .policy(PrefixPolicy.prefixed) 2003 .hidden(true) 2004 ) 2005 .addCommand( 2006 IRCEventHandler.Command() 2007 .word("hours") 2008 .policy(PrefixPolicy.prefixed) 2009 .hidden(true) 2010 ) 2011 ) 2012 void onCommandWatchtime(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 2013 { 2014 import kameloso.time : timeSince; 2015 import lu.string : beginsWith, nom, stripped; 2016 import std.format : format; 2017 import core.time : Duration; 2018 2019 if (!plugin.twitchSettings.watchtime) return; 2020 2021 string slice = event.content.stripped; // mutable 2022 string nickname; 2023 string displayName; 2024 immutable nameSpecified = (slice.length > 0); 2025 2026 if (!nameSpecified) 2027 { 2028 // Assume the user is asking about itself 2029 nickname = event.sender.nickname; 2030 displayName = event.sender.displayName; 2031 } 2032 else 2033 { 2034 string givenName = slice.nom!(Yes.inherit)(' '); // mutable 2035 if (givenName.beginsWith('@')) givenName = givenName[1..$]; 2036 immutable user = getTwitchUser(plugin, givenName, string.init, Yes.searchByDisplayName); 2037 2038 if (!user.nickname.length) 2039 { 2040 immutable message = "No such user: " ~ givenName; 2041 return chan(plugin.state, event.channel, message); 2042 } 2043 2044 nickname = user.nickname; 2045 displayName = user.displayName; 2046 } 2047 2048 void reportNoViewerTime() 2049 { 2050 enum pattern = "%s has not been watching this channel's streams."; 2051 immutable message = pattern.format(displayName); 2052 chan(plugin.state, event.channel, message); 2053 } 2054 2055 void reportViewerTime(const Duration time) 2056 { 2057 enum pattern = "%s has been a viewer for a total of %s."; 2058 immutable message = pattern.format(displayName, timeSince(time)); 2059 chan(plugin.state, event.channel, message); 2060 } 2061 2062 void reportNoViewerTimeInvoker() 2063 { 2064 enum message = "You have not been watching this channel's streams."; 2065 chan(plugin.state, event.channel, message); 2066 } 2067 2068 void reportViewerTimeInvoker(const Duration time) 2069 { 2070 enum pattern = "You have been a viewer for a total of %s."; 2071 immutable message = pattern.format(timeSince(time)); 2072 chan(plugin.state, event.channel, message); 2073 } 2074 2075 if (nickname == event.channel[1..$]) 2076 { 2077 if (nameSpecified) 2078 { 2079 enum pattern = "%s is the streamer though..."; 2080 immutable message = pattern.format(nickname); 2081 chan(plugin.state, event.channel, message); 2082 } 2083 else 2084 { 2085 enum message = "You are the streamer though..."; 2086 chan(plugin.state, event.channel, message); 2087 } 2088 return; 2089 } 2090 else if (nickname == plugin.state.client.nickname) 2091 { 2092 enum message = "I've seen it all."; 2093 return chan(plugin.state, event.channel, message); 2094 } 2095 2096 if (auto channelViewerTimes = event.channel in plugin.viewerTimesByChannel) 2097 { 2098 if (auto viewerTime = nickname in *channelViewerTimes) 2099 { 2100 import core.time : seconds; 2101 2102 return nameSpecified ? 2103 reportViewerTime((*viewerTime).seconds) : 2104 reportViewerTimeInvoker((*viewerTime).seconds); 2105 } 2106 } 2107 2108 // If we're here, there were no matches 2109 return nameSpecified ? 2110 reportNoViewerTime() : 2111 reportNoViewerTimeInvoker(); 2112 } 2113 2114 2115 // onCommandSetTitle 2116 /++ 2117 Changes the title of the current channel. 2118 2119 See_Also: 2120 [kameloso.plugins.twitch.api.modifyChannel] 2121 +/ 2122 @(IRCEventHandler() 2123 .onEvent(IRCEvent.Type.CHAN) 2124 .permissionsRequired(Permissions.operator) 2125 .channelPolicy(ChannelPolicy.home) 2126 .fiber(true) 2127 .addCommand( 2128 IRCEventHandler.Command() 2129 .word("settitle") 2130 .policy(PrefixPolicy.prefixed) 2131 .description("Sets the channel title.") 2132 .addSyntax("$command [title]") 2133 ) 2134 .addCommand( 2135 IRCEventHandler.Command() 2136 .word("title") 2137 .policy(PrefixPolicy.prefixed) 2138 .hidden(true) 2139 ) 2140 ) 2141 void onCommandSetTitle(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 2142 { 2143 import lu.string : stripped, unquoted; 2144 import std.array : replace; 2145 import std.format : format; 2146 2147 immutable unescapedTitle = event.content.stripped; 2148 2149 if (!unescapedTitle.length) 2150 { 2151 enum pattern = "Usage: %s%s [title]"; 2152 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 2153 return chan(plugin.state, event.channel, message); 2154 } 2155 2156 immutable title = unescapedTitle.unquoted.replace(`"`, `\"`); 2157 2158 try 2159 { 2160 modifyChannel(plugin, event.channel, title, string.init); 2161 2162 enum pattern = "Channel title set to: %s"; 2163 immutable message = pattern.format(title); 2164 chan(plugin.state, event.channel, message); 2165 } 2166 catch (MissingBroadcasterTokenException _) 2167 { 2168 enum channelMessage = "Missing broadcaster-level API key."; 2169 enum terminalMessage = channelMessage ~ 2170 " Run the program with <l>--set twitch.superKeygen</> to set it up."; 2171 chan(plugin.state, event.channel, channelMessage); 2172 logger.error(terminalMessage); 2173 } 2174 catch (TwitchQueryException e) 2175 { 2176 if ((e.code == 401) && (e.error == "Unauthorized")) 2177 { 2178 static uint idWhenComplainedAboutExpiredKey; 2179 2180 if (idWhenComplainedAboutExpiredKey != plugin.state.connectionID) 2181 { 2182 // broadcaster "superkey" expired. 2183 enum message = "The broadcaster-level API key has expired."; 2184 chan(plugin.state, event.channel, message); 2185 idWhenComplainedAboutExpiredKey = plugin.state.connectionID; 2186 } 2187 } 2188 else 2189 { 2190 throw e; 2191 } 2192 } 2193 } 2194 2195 2196 // onCommandSetGame 2197 /++ 2198 Changes the game of the current channel. 2199 2200 See_Also: 2201 [kameloso.plugins.twitch.api.modifyChannel] 2202 +/ 2203 @(IRCEventHandler() 2204 .onEvent(IRCEvent.Type.CHAN) 2205 .permissionsRequired(Permissions.operator) 2206 .channelPolicy(ChannelPolicy.home) 2207 .fiber(true) 2208 .addCommand( 2209 IRCEventHandler.Command() 2210 .word("setgame") 2211 .policy(PrefixPolicy.prefixed) 2212 .description("Sets the channel game.") 2213 .addSyntax("$command [game name]") 2214 .addSyntax("$command") 2215 ) 2216 ) 2217 void onCommandSetGame(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 2218 { 2219 import lu.string : stripped, unquoted; 2220 import std.array : replace; 2221 import std.format : format; 2222 import std.string : isNumeric; 2223 import std.uri : encodeComponent; 2224 2225 immutable unescapedGameName = event.content.stripped; 2226 2227 if (!unescapedGameName.length) 2228 { 2229 const channelInfo = getChannel(plugin, event.channel); 2230 2231 enum pattern = "Currently playing game: %s"; 2232 immutable gameName = channelInfo.gameName.length ? 2233 channelInfo.gameName : 2234 "(nothing)"; 2235 immutable message = pattern.format(gameName); 2236 return chan(plugin.state, event.channel, message); 2237 } 2238 2239 immutable specified = unescapedGameName.unquoted.replace(`"`, `\"`); 2240 string id = specified.isNumeric ? specified : string.init; // mutable 2241 2242 try 2243 { 2244 string name; // mutable 2245 2246 if (!id.length) 2247 { 2248 immutable gameInfo = getTwitchGame(plugin, specified.encodeComponent, string.init); 2249 id = gameInfo.id; 2250 name = gameInfo.name; 2251 } 2252 else if (id == "0") 2253 { 2254 name = "(unset)"; 2255 } 2256 else /*if (id.length)*/ 2257 { 2258 immutable gameInfo = getTwitchGame(plugin, string.init, id); 2259 name = gameInfo.name; 2260 } 2261 2262 modifyChannel(plugin, event.channel, string.init, id); 2263 2264 enum pattern = "Game set to: %s"; 2265 immutable message = pattern.format(name); 2266 chan(plugin.state, event.channel, message); 2267 } 2268 catch (EmptyResponseException _) 2269 { 2270 enum message = "Empty response from server!"; 2271 chan(plugin.state, event.channel, message); 2272 } 2273 catch (EmptyDataJSONException _) 2274 { 2275 enum message = "Could not find a game by that name; check spelling."; 2276 chan(plugin.state, event.channel, message); 2277 } 2278 catch (MissingBroadcasterTokenException _) 2279 { 2280 enum channelMessage = "Missing broadcaster-level API key."; 2281 enum terminalMessage = channelMessage ~ 2282 " Run the program with <l>--set twitch.superKeygen</> to set it up."; 2283 chan(plugin.state, event.channel, channelMessage); 2284 logger.error(terminalMessage); 2285 } 2286 catch (TwitchQueryException e) 2287 { 2288 if ((e.code == 401) && (e.error == "Unauthorized")) 2289 { 2290 static uint idWhenComplainedAboutExpiredKey; 2291 2292 if (idWhenComplainedAboutExpiredKey != plugin.state.connectionID) 2293 { 2294 // broadcaster "superkey" expired. 2295 enum message = "The broadcaster-level API key has expired."; 2296 chan(plugin.state, event.channel, message); 2297 idWhenComplainedAboutExpiredKey = plugin.state.connectionID; 2298 } 2299 } 2300 else 2301 { 2302 throw e; 2303 } 2304 } 2305 } 2306 2307 2308 // onCommandCommercial 2309 /++ 2310 Starts a commercial in the current channel. 2311 2312 See_Also: 2313 [kameloso.plugins.twitch.api.startCommercial] 2314 +/ 2315 @(IRCEventHandler() 2316 .onEvent(IRCEvent.Type.CHAN) 2317 .permissionsRequired(Permissions.operator) 2318 .channelPolicy(ChannelPolicy.home) 2319 .fiber(true) 2320 .addCommand( 2321 IRCEventHandler.Command() 2322 .word("commercial") 2323 .policy(PrefixPolicy.prefixed) 2324 .description("Starts a commercial in the current channel.") 2325 .addSyntax("$command [commercial duration; valid values are 30, 60, 90, 120, 150 and 180]") 2326 ) 2327 ) 2328 void onCommandCommercial(TwitchPlugin plugin, const /*ref*/ IRCEvent event) 2329 { 2330 import lu.string : stripped; 2331 import std.algorithm.comparison : among; 2332 import std.algorithm.searching : endsWith; 2333 import std.format : format; 2334 2335 string lengthString = event.content.stripped; // mutable 2336 if (lengthString.endsWith('s')) lengthString = lengthString[0..$-1]; 2337 2338 if (!lengthString.length) 2339 { 2340 enum pattern = "Usage: %s%s [commercial duration; valid values are 30, 60, 90, 120, 150 and 180]"; 2341 immutable message = pattern.format(plugin.state.settings.prefix, event.aux[$-1]); 2342 return chan(plugin.state, event.channel, message); 2343 } 2344 2345 const room = event.channel in plugin.rooms; 2346 assert(room, "Tried to start a commercial in a nonexistent room"); 2347 2348 if (!room.stream.live) 2349 { 2350 enum message = "There is no ongoing stream."; 2351 return chan(plugin.state, event.channel, message); 2352 } 2353 2354 if (!lengthString.among!("30", "60", "90", "120", "150", "180")) 2355 { 2356 enum message = "Commercial duration must be one of 30, 60, 90, 120, 150 or 180."; 2357 return chan(plugin.state, event.channel, message); 2358 } 2359 2360 try 2361 { 2362 startCommercial(plugin, event.channel, lengthString); 2363 } 2364 catch (MissingBroadcasterTokenException e) 2365 { 2366 enum pattern = "Missing broadcaster-level API token for channel <l>%s</>."; 2367 logger.errorf(pattern, e.channelName); 2368 2369 enum superMessage = "Run the program with <l>--set twitch.superKeygen</> to generate a new one."; 2370 logger.error(superMessage); 2371 } 2372 catch (EmptyResponseException _) 2373 { 2374 enum message = "Empty response from server!"; 2375 chan(plugin.state, event.channel, message); 2376 } 2377 catch (TwitchQueryException e) 2378 { 2379 if ((e.code == 400) && (e.error == "Bad Request")) 2380 { 2381 chan(plugin.state, event.channel, e.msg); 2382 } 2383 else 2384 { 2385 throw e; 2386 } 2387 } 2388 } 2389 2390 2391 // importCustomEmotes 2392 /++ 2393 Fetches custom channel-specific BetterTTV, FrankerFaceZ and 7tv emotes via API calls. 2394 2395 Params: 2396 plugin = The current [TwitchPlugin]. 2397 channelName = Name of channel to import emotes for. 2398 idString = Twitch ID of channel, in string form. 2399 +/ 2400 void importCustomEmotes( 2401 TwitchPlugin plugin, 2402 const string channelName, 2403 const string idString) 2404 in (Fiber.getThis, "Tried to call `importCustomEmotes` from outside a Fiber") 2405 in (channelName.length, "Tried to import custom emotes with an empty channel name string") 2406 in (idString.length, "Tried to import custom emotes with an empty ID string") 2407 { 2408 import core.memory : GC; 2409 2410 GC.disable(); 2411 scope(exit) GC.enable(); 2412 2413 // Initialise the AA so we can get a pointer to it. 2414 plugin.customEmotesByChannel[channelName][dstring.init] = false; 2415 auto customEmotes = channelName in plugin.customEmotesByChannel; 2416 (*customEmotes).remove(dstring.init); 2417 2418 alias GetEmoteFun = void function(TwitchPlugin, ref bool[dstring], const string, const string); 2419 2420 void getEmoteSet(GetEmoteFun fun, const string setName) 2421 { 2422 try 2423 { 2424 fun(plugin, *customEmotes, idString, __FUNCTION__); 2425 } 2426 catch (Exception e) 2427 { 2428 enum pattern = "Failed to fetch custom <l>%s</> emotes for channel <l>%s</>: <t>%s"; 2429 logger.warningf(pattern, setName, channelName, e.msg); 2430 version(PrintStacktraces) logger.trace(e); 2431 //throw e; 2432 } 2433 } 2434 2435 getEmoteSet(&getBTTVEmotes, "BetterTTV"); 2436 getEmoteSet(&getFFZEmotes, "FrankerFaceZ"); 2437 getEmoteSet(&get7tvEmotes, "7tv"); 2438 customEmotes.rehash(); 2439 } 2440 2441 2442 // importCustomGlobalEmotes 2443 /++ 2444 Fetches custom global BetterTTV, FrankerFaceZ and 7tv emotes via API calls. 2445 2446 Params: 2447 plugin = The current [TwitchPlugin]. 2448 +/ 2449 void importCustomGlobalEmotes(TwitchPlugin plugin) 2450 in (Fiber.getThis, "Tried to call `importCustomGlobalEmotes` from outside a Fiber") 2451 { 2452 import core.memory : GC; 2453 2454 GC.disable(); 2455 scope(exit) GC.enable(); 2456 2457 alias GetGlobalEmoteFun = void function(TwitchPlugin, ref bool[dstring], const string); 2458 2459 void getGlobalEmoteSet(GetGlobalEmoteFun fun, const string setName) 2460 { 2461 try 2462 { 2463 fun(plugin, plugin.customGlobalEmotes, __FUNCTION__); 2464 } 2465 catch (Exception e) 2466 { 2467 enum pattern = "Failed to fetch global <l>%s</> emotes: <t>%s"; 2468 logger.warningf(pattern, setName, e.msg); 2469 version(PrintStacktraces) logger.trace(e.msg); 2470 //throw e; 2471 } 2472 } 2473 2474 getGlobalEmoteSet(&getBTTVGlobalEmotes, "BetterTTV"); 2475 getGlobalEmoteSet(&get7tvGlobalEmotes, "7tv"); 2476 plugin.customGlobalEmotes.rehash(); 2477 } 2478 2479 2480 // embedCustomEmotes 2481 /++ 2482 Embeds custom emotes into the [dialect.defs.IRCEvent|IRCEvent] passed by reference, 2483 so that the [kameloso.plugins.printer.base.PrinterPlugin|PrinterPlugin] can 2484 highlight them with colours. 2485 2486 This is called in [postprocess]. 2487 2488 Params: 2489 event = [dialect.defs.IRCEvent|IRCEvent] in flight. 2490 customEmotes = `bool[dstring]` associative array of channel-specific custom emotes. 2491 customGlobalEmotes = `bool[dstring]` associative array of global custom emotes. 2492 +/ 2493 void embedCustomEmotes( 2494 ref IRCEvent event, 2495 const bool[dstring] customEmotes, 2496 const bool[dstring] customGlobalEmotes) 2497 { 2498 import lu.string : strippedRight; 2499 import std.algorithm.comparison : among; 2500 import std.array : Appender; 2501 import std.conv : to; 2502 import std.string : indexOf; 2503 2504 static Appender!(char[]) sink; 2505 2506 scope(exit) 2507 { 2508 if (sink.data.length) 2509 { 2510 event.emotes ~= sink.data; 2511 sink.clear(); 2512 } 2513 } 2514 2515 if (sink.capacity == 0) sink.reserve(64); // guesstimate 2516 2517 immutable dline = event.content.strippedRight.to!dstring; 2518 ptrdiff_t pos = dline.indexOf(' '); 2519 dstring previousEmote; // mutable 2520 size_t prev; 2521 2522 static bool isEmoteCharacter(const dchar dc) 2523 { 2524 // Unsure about '-' and '(' but be conservative and keep 2525 return ( 2526 ((dc >= dchar('a')) && (dc <= dchar('z'))) || 2527 ((dc >= dchar('A')) && (dc <= dchar('Z'))) || 2528 ((dc >= dchar('0')) && (dc <= dchar('9'))) || 2529 dc.among!(dchar(':'), dchar(')'), dchar('-'), dchar('('))); 2530 } 2531 2532 void appendEmote(const dstring dword) 2533 { 2534 import std.array : replace; 2535 import std.format : formattedWrite; 2536 2537 enum pattern = "/%s:%d-%d"; 2538 immutable slicedPattern = (event.emotes.length || sink.data.length) ? 2539 pattern : 2540 pattern[1..$]; 2541 immutable dwordEscaped = dword.replace(dchar(':'), dchar(';')); 2542 immutable end = (pos == -1) ? 2543 dline.length : 2544 pos; 2545 sink.formattedWrite(slicedPattern, dwordEscaped, prev, end-1); 2546 previousEmote = dword; 2547 } 2548 2549 void checkWord(const dstring dword) 2550 { 2551 import std.format : formattedWrite; 2552 2553 // Micro-optimise a bit by skipping AA lookups of words that are unlikely to be emotes 2554 if ((dword.length > 1) && 2555 isEmoteCharacter(dword[$-1]) && 2556 isEmoteCharacter(dword[0])) 2557 { 2558 // Can reasonably be an emote 2559 } 2560 else 2561 { 2562 // Can reasonably not 2563 return; 2564 } 2565 2566 if (dword == previousEmote) 2567 { 2568 enum pattern = ",%d-%d"; 2569 immutable end = (pos == -1) ? 2570 dline.length : 2571 pos; 2572 sink.formattedWrite(pattern, prev, end-1); 2573 return; // cannot return non-void from `void` function 2574 } 2575 2576 if ((dword in customEmotes) || (dword in customGlobalEmotes)) 2577 { 2578 return appendEmote(dword); 2579 } 2580 } 2581 2582 if (pos == -1) 2583 { 2584 // No bounding space, check entire (one-word) line 2585 return checkWord(dline); 2586 } 2587 2588 while (true) 2589 { 2590 if (pos > prev) 2591 { 2592 checkWord(dline[prev..pos]); 2593 } 2594 2595 prev = (pos + 1); 2596 if (prev >= dline.length) return; 2597 2598 pos = dline.indexOf(' ', prev); 2599 if (pos == -1) 2600 { 2601 return checkWord(dline[prev..$]); 2602 } 2603 } 2604 2605 assert(0, "Unreachable"); 2606 } 2607 2608 /// 2609 unittest 2610 { 2611 bool[dstring] customEmotes = 2612 [ 2613 ":tf:"d : true, 2614 "FrankerZ"d : true, 2615 "NOTED"d : true, 2616 ]; 2617 2618 bool[dstring] customGlobalEmotes = 2619 [ 2620 "KEKW"d : true, 2621 "NotLikeThis"d : true, 2622 "gg"d : true, 2623 ]; 2624 2625 IRCEvent event; 2626 event.type = IRCEvent.Type.CHAN; 2627 2628 { 2629 event.content = "come on its easy, now rest then talk talk more left, left, " ~ 2630 "right re st, up, down talk some rest a bit talk poop :tf:"; 2631 //event.emotes = string.init; 2632 embedCustomEmotes(event, customEmotes, customGlobalEmotes); 2633 enum expectedEmotes = ";tf;:113-116"; 2634 assert((event.emotes == expectedEmotes), event.emotes); 2635 } 2636 { 2637 event.content = "NOTED FrankerZ NOTED NOTED gg"; 2638 event.emotes = string.init; 2639 embedCustomEmotes(event, customEmotes, customGlobalEmotes); 2640 enum expectedEmotes = "NOTED:0-4/FrankerZ:7-14/NOTED:17-21,23-27/gg:32-33"; 2641 assert((event.emotes == expectedEmotes), event.emotes); 2642 } 2643 { 2644 event.content = "No emotes here KAPPA"; 2645 event.emotes = string.init; 2646 embedCustomEmotes(event, customEmotes, customGlobalEmotes); 2647 enum expectedEmotes = string.init; 2648 assert((event.emotes == expectedEmotes), event.emotes); 2649 } 2650 } 2651 2652 2653 // start 2654 /++ 2655 Start the captive key generation routine immediately after connection has 2656 been established. 2657 +/ 2658 void start(TwitchPlugin plugin) 2659 { 2660 import std.algorithm.searching : endsWith; 2661 2662 immutable someKeygenWanted = 2663 plugin.twitchSettings.keygen || 2664 plugin.twitchSettings.superKeygen || 2665 plugin.twitchSettings.googleKeygen || 2666 plugin.twitchSettings.spotifyKeygen; 2667 2668 if (!plugin.state.server.address.endsWith(".twitch.tv")) 2669 { 2670 if (someKeygenWanted) 2671 { 2672 enum message = "A Twitch keygen was requested but the configuration " ~ 2673 "file is not set up to connect to Twitch. (<l>irc.chat.twitch.tv</>)"; 2674 logger.trace(); 2675 logger.warning(message); 2676 logger.trace(); 2677 } 2678 2679 // Not connecting to Twitch, return early 2680 return; 2681 } 2682 2683 if (someKeygenWanted || (!plugin.state.bot.pass.length && !plugin.state.settings.force)) 2684 { 2685 import kameloso.thread : ThreadMessage; 2686 import std.concurrency : prioritySend; 2687 2688 if (plugin.state.settings.headless) 2689 { 2690 // Headless mode is enabled, so a captive keygen session doesn't make sense 2691 enum message = "Cannot start a Twitch keygen session when in headless mode"; 2692 return quit(plugin.state, message); 2693 } 2694 2695 // Some keygen, reload to load secrets so existing ones are read 2696 // Not strictly needed for normal keygen 2697 loadResources(plugin); 2698 2699 bool needSeparator; 2700 enum separator = "---------------------------------------------------------------------"; 2701 2702 // Automatically keygen if no pass 2703 if (plugin.twitchSettings.keygen || 2704 (!plugin.state.bot.pass.length && !plugin.state.settings.force)) 2705 { 2706 import kameloso.plugins.twitch.keygen : requestTwitchKey; 2707 requestTwitchKey(plugin); 2708 if (*plugin.state.abort) return; 2709 plugin.twitchSettings.keygen = false; 2710 needSeparator = true; 2711 } 2712 2713 if (plugin.twitchSettings.superKeygen) 2714 { 2715 import kameloso.plugins.twitch.keygen : requestTwitchSuperKey; 2716 if (needSeparator) logger.trace(separator); 2717 requestTwitchSuperKey(plugin); 2718 if (*plugin.state.abort) return; 2719 plugin.twitchSettings.superKeygen = false; 2720 needSeparator = true; 2721 } 2722 2723 if (plugin.twitchSettings.googleKeygen) 2724 { 2725 import kameloso.plugins.twitch.google : requestGoogleKeys; 2726 if (needSeparator) logger.trace(separator); 2727 requestGoogleKeys(plugin); 2728 if (*plugin.state.abort) return; 2729 plugin.twitchSettings.googleKeygen = false; 2730 needSeparator = true; 2731 } 2732 2733 if (plugin.twitchSettings.spotifyKeygen) 2734 { 2735 import kameloso.plugins.twitch.spotify : requestSpotifyKeys; 2736 if (needSeparator) logger.trace(separator); 2737 requestSpotifyKeys(plugin); 2738 if (*plugin.state.abort) return; 2739 plugin.twitchSettings.spotifyKeygen = false; 2740 } 2741 2742 // Remove custom Twitch settings so we can reconnect without jumping 2743 // back into keygens. 2744 static immutable string[4] settingsToPop = 2745 [ 2746 "twitch.keygen", 2747 "twitch.superKeygen", 2748 "twitch.googleKeygen", 2749 "twitch.spotifyKeygen", 2750 ]; 2751 2752 foreach (immutable setting; settingsToPop[]) 2753 { 2754 plugin.state.mainThread.prioritySend(ThreadMessage.popCustomSetting(setting)); 2755 } 2756 2757 plugin.state.mainThread.prioritySend(ThreadMessage.reconnect); 2758 } 2759 } 2760 2761 2762 // onMyInfo 2763 /++ 2764 Sets up a Fiber to periodically cache followers. 2765 2766 Cannot be done on [dialect.defs.IRCEvent.Type.RPL_WELCOME|RPL_WELCOME] as the server 2767 daemon isn't known by then. 2768 +/ 2769 @(IRCEventHandler() 2770 .onEvent(IRCEvent.Type.RPL_MYINFO) 2771 ) 2772 void onMyInfo(TwitchPlugin plugin) 2773 { 2774 // Load ecounts and such. 2775 loadResources(plugin); 2776 } 2777 2778 2779 // startRoomMonitorFibers 2780 /++ 2781 Starts room monitor fibers. 2782 2783 These detect new streams (and updates ongoing ones), updates chatters, and caches followers. 2784 2785 Params: 2786 plugin = The current [TwitchPlugin]. 2787 channelName = String key of room to start the monitors of. 2788 +/ 2789 void startRoomMonitorFibers(TwitchPlugin plugin, const string channelName) 2790 in (channelName.length, "Tried to start room monitor fibers with an empty channel name string") 2791 { 2792 import kameloso.plugins.common.delayawait : delay; 2793 import std.datetime.systime : Clock, SysTime; 2794 import core.time : hours, seconds; 2795 2796 // How often to poll the servers for various information about a channel. 2797 static immutable monitorUpdatePeriodicity = 60.seconds; 2798 2799 void chatterMonitorDg() 2800 { 2801 auto room = channelName in plugin.rooms; 2802 assert(room, "Tried to start chatter monitor delegate on non-existing room"); 2803 2804 immutable idSnapshot = room.uniqueID; 2805 2806 static immutable botUpdatePeriodicity = 3.hours; 2807 SysTime lastBotUpdateTime; 2808 string[] botBlacklist; 2809 2810 while (true) 2811 { 2812 room = channelName in plugin.rooms; 2813 if (!room || (room.uniqueID != idSnapshot)) return; 2814 2815 if (!room.stream.live) 2816 { 2817 delay(plugin, monitorUpdatePeriodicity, Yes.yield); 2818 continue; 2819 } 2820 2821 try 2822 { 2823 immutable now = Clock.currTime; 2824 immutable sinceLastBotUpdate = (now - lastBotUpdateTime); 2825 2826 if (sinceLastBotUpdate >= botUpdatePeriodicity) 2827 { 2828 botBlacklist = getBotList(plugin); 2829 lastBotUpdateTime = now; 2830 } 2831 2832 immutable chattersJSON = getChatters(plugin, room.broadcasterName); 2833 2834 static immutable string[6] chatterTypes = 2835 [ 2836 "admins", 2837 //"broadcaster", 2838 "global_mods", 2839 "moderators", 2840 "staff", 2841 "viewers", 2842 "vips", 2843 ]; 2844 2845 foreach (immutable chatterType; chatterTypes[]) 2846 { 2847 foreach (immutable viewerJSON; chattersJSON["chatters"][chatterType].array) 2848 { 2849 import std.algorithm.searching : canFind, endsWith; 2850 2851 immutable viewer = viewerJSON.str; 2852 2853 if (viewer.endsWith("bot") || 2854 botBlacklist.canFind(viewer) || 2855 (viewer == plugin.state.client.nickname)) 2856 { 2857 continue; 2858 } 2859 2860 room.stream.chattersSeen[viewer] = true; 2861 2862 // continue early if we shouldn't monitor watchtime 2863 if (!plugin.twitchSettings.watchtime) continue; 2864 2865 if (plugin.twitchSettings.watchtimeExcludesLurkers) 2866 { 2867 // Exclude lurkers from watchtime monitoring 2868 if (viewer !in room.stream.activeViewers) continue; 2869 } 2870 2871 enum periodicitySeconds = monitorUpdatePeriodicity.total!"seconds"; 2872 2873 if (auto channelViewerTimes = room.channelName in plugin.viewerTimesByChannel) 2874 { 2875 if (auto viewerTime = viewer in *channelViewerTimes) 2876 { 2877 *viewerTime += periodicitySeconds; 2878 } 2879 else 2880 { 2881 (*channelViewerTimes)[viewer] = periodicitySeconds; 2882 } 2883 } 2884 else 2885 { 2886 plugin.viewerTimesByChannel[room.channelName][viewer] = periodicitySeconds; 2887 } 2888 2889 plugin.viewerTimesDirty = true; 2890 } 2891 } 2892 } 2893 catch (Exception _) 2894 { 2895 // Just swallow the exception and retry next time 2896 } 2897 2898 delay(plugin, monitorUpdatePeriodicity, Yes.yield); 2899 } 2900 } 2901 2902 void uptimeMonitorDg() 2903 { 2904 static void closeStream(TwitchPlugin.Room* room) 2905 { 2906 room.stream.live = false; 2907 room.stream.stopTime = Clock.currTime; 2908 room.stream.chattersSeen = null; 2909 } 2910 2911 void rotateStream(TwitchPlugin.Room* room) 2912 { 2913 appendToStreamHistory(plugin, room.stream); 2914 room.stream = TwitchPlugin.Room.Stream.init; 2915 } 2916 2917 auto room = channelName in plugin.rooms; 2918 assert(room, "Tried to start chatter monitor delegate on non-existing room"); 2919 2920 immutable idSnapshot = room.uniqueID; 2921 2922 while (true) 2923 { 2924 room = channelName in plugin.rooms; 2925 if (!room || (room.uniqueID != idSnapshot)) return; 2926 2927 try 2928 { 2929 auto streamFromServer = getStream(plugin, room.broadcasterName); // must not be const nor immutable 2930 2931 if (!streamFromServer.idString.length) // == TwitchPlugin.Room.Stream.init) 2932 { 2933 // Stream down 2934 if (room.stream.live) 2935 { 2936 // Was up but just ended 2937 closeStream(room); 2938 rotateStream(room); 2939 2940 if (plugin.twitchSettings.watchtime && plugin.viewerTimesDirty) 2941 { 2942 saveResourceToDisk(plugin.viewerTimesByChannel, plugin.viewersFile); 2943 plugin.viewerTimesDirty = false; 2944 } 2945 } 2946 } 2947 else 2948 { 2949 // Stream up 2950 if (!room.stream.idString.length) 2951 { 2952 // New stream! 2953 room.stream = streamFromServer; 2954 2955 /*if (plugin.twitchSettings.watchtime && plugin.viewerTimesDirty) 2956 { 2957 saveResourceToDisk(plugin.viewerTimesByChannel, plugin.viewersFile); 2958 plugin.viewerTimesDirty = false; 2959 }*/ 2960 } 2961 else if (room.stream.idString == streamFromServer.idString) 2962 { 2963 // Same stream running, just update it 2964 room.stream.update(streamFromServer); 2965 } 2966 else /*if (room.stream.idString != streamFromServer.idString)*/ 2967 { 2968 // New stream, but stale one exists. Rotate and insert 2969 closeStream(room); 2970 rotateStream(room); 2971 room.stream = streamFromServer; 2972 2973 if (plugin.twitchSettings.watchtime && plugin.viewerTimesDirty) 2974 { 2975 saveResourceToDisk(plugin.viewerTimesByChannel, plugin.viewersFile); 2976 plugin.viewerTimesDirty = false; 2977 } 2978 } 2979 } 2980 } 2981 catch (Exception _) 2982 { 2983 // Just swallow the exception and retry next time 2984 } 2985 2986 delay(plugin, monitorUpdatePeriodicity, Yes.yield); 2987 } 2988 } 2989 2990 // Clear and re-cache follows once every midnight 2991 void cacheFollowersDg() 2992 { 2993 auto room = channelName in plugin.rooms; 2994 assert(room, "Tried to start follower cache delegate on non-existing room"); 2995 2996 immutable idSnapshot = room.uniqueID; 2997 2998 while (true) 2999 { 3000 import kameloso.time : nextMidnight; 3001 3002 room = channelName in plugin.rooms; 3003 if (!room || (room.uniqueID != idSnapshot)) return; 3004 3005 immutable now = Clock.currTime; 3006 3007 try 3008 { 3009 room.follows = getFollows(plugin, room.id); 3010 room.followsLastCached = now.toUnixTime; 3011 } 3012 catch (Exception _) 3013 { 3014 // Just swallow the exception and retry next time 3015 } 3016 3017 delay(plugin, (now.nextMidnight - now), Yes.yield); 3018 } 3019 } 3020 3021 Fiber uptimeMonitorFiber = new Fiber(&uptimeMonitorDg, BufferSize.fiberStack); 3022 uptimeMonitorFiber.call(); 3023 3024 Fiber chatterMonitorFiber = new Fiber(&chatterMonitorDg, BufferSize.fiberStack); 3025 chatterMonitorFiber.call(); 3026 3027 Fiber cacheFollowersFiber = new Fiber(&cacheFollowersDg, BufferSize.fiberStack); 3028 cacheFollowersFiber.call(); 3029 } 3030 3031 3032 // startValidator 3033 /++ 3034 Starts a validator [core.thread.fiber.Fiber|Fiber]. 3035 3036 This will validate the API access token and output to the terminal for how 3037 much longer it is valid. If it has expired, it will exit the program. 3038 3039 Params: 3040 plugin = The current [TwitchPlugin]. 3041 +/ 3042 void startValidator(TwitchPlugin plugin) 3043 { 3044 import core.thread : Fiber; 3045 3046 void validatorDg() 3047 { 3048 import kameloso.plugins.common.delayawait : delay; 3049 import core.time : minutes; 3050 3051 while (!plugin.userID.length) 3052 { 3053 static immutable retryDelay = 1.minutes; 3054 3055 if (plugin.state.settings.headless) 3056 { 3057 try 3058 { 3059 import kameloso.messaging : quit; 3060 import std.datetime.systime : Clock, SysTime; 3061 3062 immutable validationJSON = getValidation(plugin, plugin.state.bot.pass, Yes.async); 3063 plugin.userID = validationJSON["user_id"].str; 3064 immutable expiresIn = validationJSON["expires_in"].integer; 3065 immutable expiresWhen = SysTime.fromUnixTime(Clock.currTime.toUnixTime + expiresIn); 3066 immutable now = Clock.currTime; 3067 immutable delta = (expiresWhen - now); 3068 3069 // Schedule quitting on expiry 3070 delay(plugin, (() => quit(plugin.state)), delta); 3071 } 3072 catch (TwitchQueryException e) 3073 { 3074 version(PrintStacktraces) logger.trace(e); 3075 delay(plugin, retryDelay, Yes.yield); 3076 continue; 3077 } 3078 catch (EmptyResponseException e) 3079 { 3080 version(PrintStacktraces) logger.trace(e); 3081 delay(plugin, retryDelay, Yes.yield); 3082 continue; 3083 } 3084 return; 3085 } 3086 3087 try 3088 { 3089 import std.datetime.systime : Clock; 3090 3091 /* 3092 { 3093 "client_id": "tjyryd2ojnqr8a51ml19kn1yi2n0v1", 3094 "expires_in": 5036421, 3095 "login": "zorael", 3096 "scopes": [ 3097 "bits:read", 3098 "channel:moderate", 3099 "channel:read:subscriptions", 3100 "channel_editor", 3101 "chat:edit", 3102 "chat:read", 3103 "user:edit:broadcast", 3104 "whispers:edit", 3105 "whispers:read" 3106 ], 3107 "user_id": "22216721" 3108 } 3109 */ 3110 3111 immutable validationJSON = getValidation(plugin, plugin.state.bot.pass, Yes.async); 3112 plugin.userID = validationJSON["user_id"].str; 3113 immutable expiresIn = validationJSON["expires_in"].integer; 3114 immutable expiresWhen = SysTime.fromUnixTime(Clock.currTime.toUnixTime + expiresIn); 3115 generateExpiryReminders(plugin, expiresWhen); 3116 } 3117 catch (TwitchQueryException e) 3118 { 3119 import kameloso.constants : MagicErrorStrings; 3120 3121 // Something is deeply wrong. 3122 if (e.code == 2) 3123 { 3124 enum wikiMessage = cast(string)MagicErrorStrings.visitWikiOneliner; 3125 3126 if (e.error == MagicErrorStrings.sslLibraryNotFound) 3127 { 3128 enum pattern = "Failed to validate Twitch API keys: <l>%s</> " ~ 3129 "<t>(is OpenSSL installed?)"; 3130 logger.errorf(pattern, cast(string)MagicErrorStrings.sslLibraryNotFoundRewritten); 3131 logger.error(wikiMessage); 3132 3133 version(Windows) 3134 { 3135 enum getoptMessage = cast(string)MagicErrorStrings.getOpenSSLSuggestion; 3136 logger.error(getoptMessage); 3137 } 3138 3139 // Unrecoverable 3140 return; 3141 } 3142 else 3143 { 3144 enum pattern = "Failed to validate Twitch API keys: <l>%s</> (<l>%s</>) (<t>%d</>)"; 3145 logger.errorf(pattern, e.msg, e.error, e.code); 3146 logger.error(wikiMessage); 3147 } 3148 } 3149 else 3150 { 3151 enum pattern = "Failed to validate Twitch API keys: <l>%s</> (<l>%s</>) (<t>%d</>)"; 3152 logger.errorf(pattern, e.msg, e.error, e.code); 3153 } 3154 3155 version(PrintStacktraces) logger.trace(e); 3156 delay(plugin, retryDelay, Yes.yield); 3157 continue; 3158 } 3159 catch (EmptyResponseException e) 3160 { 3161 // HTTP query failed; just retry 3162 enum pattern = "Failed to validate Twitch API keys: <t>%s</>"; 3163 logger.errorf(pattern, e.msg); 3164 version(PrintStacktraces) logger.trace(e); 3165 delay(plugin, retryDelay, Yes.yield); 3166 continue; 3167 } 3168 } 3169 } 3170 3171 Fiber validatorFiber = new Fiber(&validatorDg, BufferSize.fiberStack); 3172 validatorFiber.call(); 3173 } 3174 3175 3176 // startSaver 3177 /++ 3178 Starts a saver [core.thread.fiber.Fiber|Fiber]. 3179 3180 This will save resources to disk periodically. 3181 3182 Params: 3183 plugin = The current [TwitchPlugin]. 3184 +/ 3185 void startSaver(TwitchPlugin plugin) 3186 { 3187 import kameloso.plugins.common.delayawait : delay; 3188 import core.thread : Fiber; 3189 import core.time : hours; 3190 3191 // How often to save `ecount`s and viewer times, to ward against losing information to crashes. 3192 static immutable savePeriodicity = 2.hours; 3193 3194 void periodicallySaveDg() 3195 { 3196 // Periodically save ecounts and viewer times 3197 while (true) 3198 { 3199 if (plugin.twitchSettings.ecount && plugin.ecountDirty && plugin.ecount.length) 3200 { 3201 saveResourceToDisk(plugin.ecount, plugin.ecountFile); 3202 plugin.ecountDirty = false; 3203 } 3204 3205 /+ 3206 Only save watchtimes if there's at least one broadcast currently ongoing. 3207 Since we save at broadcast stop there won't be anything new to save otherwise. 3208 +/ 3209 if (plugin.twitchSettings.watchtime && plugin.viewerTimesDirty) 3210 { 3211 saveResourceToDisk(plugin.viewerTimesByChannel, plugin.viewersFile); 3212 plugin.viewerTimesDirty = false; 3213 } 3214 3215 delay(plugin, savePeriodicity, Yes.yield); 3216 } 3217 } 3218 3219 Fiber periodicallySaveFiber = new Fiber(&periodicallySaveDg, BufferSize.fiberStack); 3220 delay(plugin, periodicallySaveFiber, savePeriodicity); 3221 } 3222 3223 3224 // generateExpiryReminders 3225 /++ 3226 Generates and delays Twitch authorisation token expiry reminders. 3227 3228 Params: 3229 plugin = The current [TwitchPlugin]. 3230 expiresWhen = A [std.datetime.systime.SysTime|SysTime] of when the expiry occurs. 3231 +/ 3232 void generateExpiryReminders(TwitchPlugin plugin, const SysTime expiresWhen) 3233 { 3234 import kameloso.plugins.common.delayawait : delay; 3235 import lu.string : plurality; 3236 import std.datetime.systime : Clock; 3237 import std.meta : AliasSeq; 3238 import core.time : days, hours, minutes, seconds, weeks; 3239 3240 auto untilExpiry() 3241 { 3242 immutable now = Clock.currTime; 3243 return (expiresWhen - now) + 59.seconds; 3244 } 3245 3246 void warnOnWeeksDg() 3247 { 3248 immutable numDays = untilExpiry.total!"days"; 3249 if (numDays <= 0) return; 3250 3251 // More than a week away, just .info 3252 enum pattern = "Your Twitch authorisation token will expire " ~ 3253 "in <l>%d days</> on <l>%4d-%02d-%02d"; 3254 logger.infof(pattern, numDays, expiresWhen.year, expiresWhen.month, expiresWhen.day); 3255 } 3256 3257 void warnOnDaysDg() 3258 { 3259 int numDays; 3260 int numHours; 3261 untilExpiry.split!("days", "hours")(numDays, numHours); 3262 if ((numDays < 0) || (numHours < 0)) return; 3263 3264 // A week or less, more than a day; warning 3265 if (numHours > 0) 3266 { 3267 enum pattern = "Warning: Your Twitch authorisation token will expire " ~ 3268 "in <l>%d %s and %d %s</> at <l>%4d-%02d-%02d %02d:%02d"; 3269 logger.warningf(pattern, 3270 numDays, numDays.plurality("day", "days"), 3271 numHours, numHours.plurality("hour", "hours"), 3272 expiresWhen.year, expiresWhen.month, expiresWhen.day, 3273 expiresWhen.hour, expiresWhen.minute); 3274 } 3275 else 3276 { 3277 enum pattern = "Warning: Your Twitch authorisation token will expire " ~ 3278 "in <l>%d %s</> at <l>%4d-%02d-%02d %02d:%02d"; 3279 logger.warningf(pattern, 3280 numDays, numDays.plurality("day", "days"), 3281 expiresWhen.year, expiresWhen.month, expiresWhen.day, 3282 expiresWhen.hour, expiresWhen.minute); 3283 } 3284 } 3285 3286 void warnOnHoursDg() 3287 { 3288 int numHours; 3289 int numMinutes; 3290 untilExpiry.split!("hours", "minutes")(numHours, numMinutes); 3291 if ((numHours < 0) || (numMinutes < 0)) return; 3292 3293 // Less than a day; warning 3294 if (numMinutes > 0) 3295 { 3296 enum pattern = "WARNING: Your Twitch authorisation token will expire " ~ 3297 "in <l>%d %s and %d %s</> at <l>%02d:%02d"; 3298 logger.warningf(pattern, 3299 numHours, numHours.plurality("hour", "hours"), 3300 numMinutes, numMinutes.plurality("minute", "minutes"), 3301 expiresWhen.hour, expiresWhen.minute); 3302 } 3303 else 3304 { 3305 enum pattern = "WARNING: Your Twitch authorisation token will expire " ~ 3306 "in <l>%d %s</> at <l>%02d:%02d"; 3307 logger.warningf(pattern, 3308 numHours, numHours.plurality("hour", "hours"), 3309 expiresWhen.hour, expiresWhen.minute); 3310 } 3311 } 3312 3313 void warnOnMinutesDg() 3314 { 3315 immutable numMinutes = untilExpiry.total!"minutes"; 3316 if (numMinutes <= 0) return; 3317 3318 // Less than an hour; warning 3319 enum pattern = "WARNING: Your Twitch authorisation token will expire " ~ 3320 "in <l>%d minutes</> at <l>%02d:%02d"; 3321 logger.warningf(pattern, 3322 numMinutes, expiresWhen.hour, expiresWhen.minute); 3323 } 3324 3325 void quitOnExpiry() 3326 { 3327 import kameloso.messaging : quit; 3328 3329 // Key expired 3330 enum message = "Your Twitch authorisation token has expired. " ~ 3331 "Run the program with <l>--set twitch.keygen</> to generate a new one."; 3332 logger.error(message); 3333 quit(plugin.state, "Twitch authorisation token expired"); 3334 } 3335 3336 alias reminderPoints = AliasSeq!( 3337 14.days, 3338 7.days, 3339 3.days, 3340 1.days, 3341 12.hours, 3342 6.hours, 3343 1.hours, 3344 30.minutes, 3345 10.minutes, 3346 5.minutes, 3347 ); 3348 3349 immutable now = Clock.currTime; 3350 immutable trueExpiry = (expiresWhen - now); 3351 3352 foreach (immutable reminderPoint; reminderPoints) 3353 { 3354 if (trueExpiry >= reminderPoint) 3355 { 3356 immutable untilPoint = (trueExpiry - reminderPoint); 3357 if (reminderPoint >= 1.weeks) delay(plugin, &warnOnWeeksDg, untilPoint); 3358 else if (reminderPoint >= 1.days) delay(plugin, &warnOnDaysDg, untilPoint); 3359 else if (reminderPoint >= 1.hours) delay(plugin, &warnOnHoursDg, untilPoint); 3360 else /*if (reminderPoint >= 1.minutes)*/ delay(plugin, &warnOnMinutesDg, untilPoint); 3361 } 3362 } 3363 3364 // Schedule quitting on expiry 3365 delay(plugin, &quitOnExpiry, trueExpiry); 3366 3367 // Also announce once normally how much time is left 3368 if (trueExpiry >= 1.weeks) warnOnWeeksDg(); 3369 else if (trueExpiry >= 1.days) warnOnDaysDg(); 3370 else if (trueExpiry >= 1.hours) warnOnHoursDg(); 3371 else /*if (trueExpiry >= 1.minutes)*/ warnOnMinutesDg(); 3372 } 3373 3374 3375 // appendToStreamHistory 3376 /++ 3377 Appends a [TwitchPlugin.Room.Stream|Stream] to the history file. 3378 3379 Params: 3380 plugin = The current [TwitchPlugin]. 3381 stream = The (presumably ended) stream to save to record. 3382 +/ 3383 void appendToStreamHistory(TwitchPlugin plugin, const TwitchPlugin.Room.Stream stream) 3384 { 3385 import lu.json : JSONStorage; 3386 3387 JSONStorage json; 3388 json.load(plugin.streamHistoryFile); 3389 json.array ~= stream.toJSON(); 3390 json.save(plugin.streamHistoryFile); 3391 } 3392 3393 3394 // initialise 3395 /++ 3396 Initialises the Twitch plugin. 3397 +/ 3398 void initialise(TwitchPlugin plugin) 3399 { 3400 import kameloso.terminal : isTerminal; 3401 3402 if (!isTerminal) 3403 { 3404 // Not a TTY so replace our bell string with an empty one 3405 plugin.bell = string.init; 3406 } 3407 } 3408 3409 3410 // teardown 3411 /++ 3412 De-initialises the plugin. Shuts down any persistent worker threads. 3413 +/ 3414 void teardown(TwitchPlugin plugin) 3415 { 3416 import kameloso.thread : ThreadMessage; 3417 import std.concurrency : Tid, send; 3418 3419 if (plugin.persistentWorkerTid != Tid.init) 3420 { 3421 // It may not have been started if we're aborting very early. 3422 plugin.persistentWorkerTid.send(ThreadMessage.teardown()); 3423 } 3424 3425 if (plugin.twitchSettings.ecount && plugin.ecount.length) 3426 { 3427 // Might as well always save on exit. Ignore dirty flag. 3428 saveResourceToDisk(plugin.ecount, plugin.ecountFile); 3429 } 3430 3431 if (plugin.twitchSettings.watchtime && plugin.viewerTimesByChannel.length) 3432 { 3433 // As above 3434 saveResourceToDisk(plugin.viewerTimesByChannel, plugin.viewersFile); 3435 } 3436 } 3437 3438 3439 // postprocess 3440 /++ 3441 Hijacks a reference to a [dialect.defs.IRCEvent|IRCEvent] and modifies the 3442 sender and target class based on their badges (and the current settings). 3443 3444 Additionally embeds custom BTTV/FrankerFaceZ/7tv emotes into the event. 3445 +/ 3446 void postprocess(TwitchPlugin plugin, ref IRCEvent event) 3447 { 3448 import std.algorithm.comparison : among; 3449 import std.algorithm.searching : canFind; 3450 3451 if ((plugin.twitchSettings.fakeChannelFromQueries) && (event.type == IRCEvent.Type.QUERY)) 3452 { 3453 alias pred = (homeChannelEntry, senderNickname) => (homeChannelEntry[1..$] == senderNickname); 3454 3455 if (plugin.state.bot.homeChannels.canFind!pred(event.sender.nickname)) 3456 { 3457 event.type = IRCEvent.Type.CHAN; 3458 event.channel = '#' ~ event.sender.nickname; 3459 } 3460 } 3461 else if (!event.sender.nickname.length || !event.channel.length) 3462 { 3463 return; 3464 } 3465 3466 immutable eventCanContainEmotes = event.content.length && 3467 event.type.among!(IRCEvent.Type.CHAN, IRCEvent.Type.EMOTE); 3468 3469 version(TwitchCustomEmotesEverywhere) 3470 { 3471 if (eventCanContainEmotes) 3472 { 3473 // No checks needed 3474 if (const customEmotes = event.channel in plugin.customEmotesByChannel) 3475 { 3476 embedCustomEmotes(event, *customEmotes, plugin.customGlobalEmotes); 3477 } 3478 } 3479 } 3480 else 3481 { 3482 // Only embed if the event is in a home channel 3483 immutable isHomeChannel = plugin.state.bot.homeChannels.canFind(event.channel); 3484 3485 if (isHomeChannel && eventCanContainEmotes) 3486 { 3487 if (const customEmotes = event.channel in plugin.customEmotesByChannel) 3488 { 3489 embedCustomEmotes(event, *customEmotes, plugin.customGlobalEmotes); 3490 } 3491 } 3492 } 3493 3494 version(TwitchPromoteEverywhere) 3495 { 3496 // No checks needed 3497 } 3498 else 3499 { 3500 version(TwitchCustomEmotesEverywhere) 3501 { 3502 import std.algorithm.searching : canFind; 3503 3504 // isHomeChannel only defined if version not TwitchCustomEmotesEverywhere 3505 immutable isHomeChannel = plugin.state.bot.homeChannels.canFind(event.channel); 3506 } 3507 3508 if (!isHomeChannel) return; 3509 } 3510 3511 static void postprocessImpl( 3512 const TwitchPlugin plugin, 3513 const ref IRCEvent event, 3514 ref IRCUser user) 3515 { 3516 import lu.string : contains; 3517 3518 if (user.class_ == IRCUser.Class.blacklist) return; 3519 3520 if (plugin.twitchSettings.promoteBroadcasters) 3521 { 3522 if ((user.class_ < IRCUser.Class.staff) && 3523 (user.nickname == event.channel[1..$])) 3524 { 3525 // User is broadcaster but is not registered as staff 3526 user.class_ = IRCUser.Class.staff; 3527 return; 3528 } 3529 } 3530 3531 // Stop here if there are no badges to promote 3532 if (!user.badges.length) return; 3533 3534 if (plugin.twitchSettings.promoteModerators) 3535 { 3536 if ((user.class_ < IRCUser.Class.operator) && 3537 user.badges.contains("moderator/")) 3538 { 3539 // User is moderator but is not registered as at least operator 3540 user.class_ = IRCUser.Class.operator; 3541 return; 3542 } 3543 } 3544 3545 if (plugin.twitchSettings.promoteVIPs) 3546 { 3547 if ((user.class_ < IRCUser.Class.elevated) && 3548 user.badges.contains("vip/")) 3549 { 3550 // User is VIP but is not registered as at least elevated 3551 user.class_ = IRCUser.Class.elevated; 3552 return; 3553 } 3554 } 3555 3556 // There is no "registered" list; just map subscribers to registered 1:1 3557 if ((user.class_ < IRCUser.Class.registered) && 3558 user.badges.contains("subscriber/")) 3559 { 3560 user.class_ = IRCUser.Class.registered; 3561 } 3562 } 3563 3564 /*if (event.sender.nickname.length)*/ postprocessImpl(plugin, event, event.sender); 3565 if (event.target.nickname.length) postprocessImpl(plugin, event, event.target); 3566 } 3567 3568 3569 // initResources 3570 /++ 3571 Reads and writes resource files to disk, ensure that they're there and properly formatted. 3572 +/ 3573 void initResources(TwitchPlugin plugin) 3574 { 3575 import kameloso.plugins.common.misc : IRCPluginInitialisationException; 3576 import lu.json : JSONStorage; 3577 import std.file : exists, mkdir; 3578 import std.json : JSONException, JSONType; 3579 import std.path : baseName, dirName; 3580 3581 void loadFile( 3582 ref JSONStorage json, 3583 const string file, 3584 const size_t line = __LINE__) 3585 { 3586 try 3587 { 3588 json.load(file); 3589 } 3590 catch (JSONException e) 3591 { 3592 version(PrintStacktraces) logger.error("JSONException: ", e.msg); 3593 throw new IRCPluginInitialisationException( 3594 file.baseName ~ " is malformed", 3595 plugin.name, 3596 file, 3597 __FILE__, 3598 line); 3599 } 3600 3601 // Let other Exceptions pass. 3602 } 3603 3604 JSONStorage ecountJSON; 3605 JSONStorage viewersJSON; 3606 JSONStorage secretsJSON; 3607 JSONStorage historyJSON; 3608 3609 // Ensure the subdirectory exists 3610 immutable subdir = plugin.ecountFile.dirName; 3611 if (!subdir.exists) mkdir(subdir); 3612 3613 loadFile(ecountJSON, plugin.ecountFile); 3614 loadFile(viewersJSON, plugin.viewersFile); 3615 loadFile(secretsJSON, plugin.secretsFile); 3616 loadFile(historyJSON, plugin.streamHistoryFile); 3617 3618 if (historyJSON.type != JSONType.array) historyJSON.array = null; // coerce to array if needed 3619 3620 ecountJSON.save(plugin.ecountFile); 3621 viewersJSON.save(plugin.viewersFile); 3622 secretsJSON.save(plugin.secretsFile); 3623 historyJSON.save(plugin.streamHistoryFile); 3624 } 3625 3626 3627 // saveResourceToDisk 3628 /++ 3629 Saves the passed resource to disk, but in JSON format. 3630 3631 This is used with the associative arrays for `ecount`, as well as for keeping 3632 track of viewers. 3633 3634 Params: 3635 aa = The associative array to convert into JSON and save. 3636 filename = Filename of the file to write to. 3637 +/ 3638 void saveResourceToDisk(/*const*/ RehashingAA!(string, long)[string] aa, const string filename) 3639 { 3640 import std.json : JSONValue; 3641 import std.stdio : File; 3642 3643 long[string][string] tempAA; 3644 3645 foreach (immutable channelName, rehashingAA; aa) 3646 { 3647 tempAA[channelName] = rehashingAA.aaOf; 3648 } 3649 3650 immutable json = JSONValue(tempAA); 3651 File(filename, "w").writeln(json.toPrettyString); 3652 } 3653 3654 3655 // saveSecretsToDisk 3656 /++ 3657 Saves Twitch secrets to disk, in JSON format. 3658 3659 Params: 3660 aa = Associative array of credentials. 3661 filename = Filename of the file to write to. 3662 +/ 3663 package void saveSecretsToDisk(const Credentials[string] aa, const string filename) 3664 { 3665 import std.json : JSONValue; 3666 import std.stdio : File; 3667 3668 JSONValue json; 3669 json = null; 3670 json.object = null; 3671 3672 foreach (immutable channelName, creds; aa) 3673 { 3674 json[channelName] = null; 3675 json[channelName].object = null; 3676 json[channelName] = creds.toJSON(); 3677 } 3678 3679 File(filename, "w").writeln(json.toPrettyString); 3680 } 3681 3682 3683 // loadResources 3684 /++ 3685 Loads all resources from disk. 3686 +/ 3687 void loadResources(TwitchPlugin plugin) 3688 { 3689 import lu.json : JSONStorage, populateFromJSON; 3690 3691 JSONStorage ecountJSON; 3692 long[string][string] tempEcount; 3693 ecountJSON.load(plugin.ecountFile); 3694 tempEcount.populateFromJSON(ecountJSON); 3695 plugin.ecount.clear(); 3696 3697 foreach (immutable channelName, channelCounts; tempEcount) 3698 { 3699 plugin.ecount[channelName] = RehashingAA!(string, long)(channelCounts); 3700 } 3701 3702 JSONStorage viewersJSON; 3703 long[string][string] tempViewers; 3704 viewersJSON.load(plugin.viewersFile); 3705 tempViewers.populateFromJSON(viewersJSON); 3706 plugin.viewerTimesByChannel.clear(); 3707 3708 foreach (immutable channelName, channelViewers; tempViewers) 3709 { 3710 plugin.viewerTimesByChannel[channelName] = RehashingAA!(string, long)(channelViewers); 3711 } 3712 3713 JSONStorage secretsJSON; 3714 secretsJSON.load(plugin.secretsFile); 3715 plugin.secretsByChannel.clear(); 3716 3717 foreach (immutable channelName, credsJSON; secretsJSON.storage.object) 3718 { 3719 plugin.secretsByChannel[channelName] = Credentials.fromJSON(credsJSON); 3720 } 3721 3722 plugin.secretsByChannel = plugin.secretsByChannel.rehash(); 3723 } 3724 3725 3726 // reload 3727 /++ 3728 Reloads the plugin, loading resources from disk and re-importing custom emotes. 3729 +/ 3730 void reload(TwitchPlugin plugin) 3731 { 3732 import kameloso.constants : BufferSize; 3733 import core.thread : Fiber; 3734 3735 loadResources(plugin); 3736 3737 void importDg() 3738 { 3739 plugin.customGlobalEmotes = null; 3740 importCustomGlobalEmotes(plugin); 3741 3742 foreach (immutable channelName, const room; plugin.rooms) 3743 { 3744 plugin.customEmotesByChannel.remove(channelName); 3745 importCustomEmotes(plugin, channelName, room.id); 3746 } 3747 } 3748 3749 Fiber importFiber = new Fiber(&importDg, BufferSize.fiberStack); 3750 importFiber.call(); 3751 } 3752 3753 3754 public: 3755 3756 3757 // TwitchPlugin 3758 /++ 3759 The Twitch plugin is an example Twitch streamer bot. It contains some 3760 basic tools for streamers, and the audience thereof. 3761 +/ 3762 final class TwitchPlugin : IRCPlugin 3763 { 3764 private: 3765 import kameloso.terminal : TerminalToken; 3766 import lu.container : CircularBuffer; 3767 import std.concurrency : Tid; 3768 import std.datetime.systime : SysTime; 3769 3770 package: 3771 /++ 3772 Contained state of a channel, so that there can be several alongside each other. 3773 +/ 3774 static struct Room 3775 { 3776 private: 3777 /++ 3778 A unique ID for this instance of a room. 3779 +/ 3780 uint _uniqueID; 3781 3782 public: 3783 /++ 3784 Representation of a broadcast (stream). 3785 +/ 3786 static struct Stream 3787 { 3788 private: 3789 import std.json : JSONValue; 3790 3791 /++ 3792 The unique ID of a stream, as supplied by Twitch. 3793 3794 Cannot be made immutable or generated `opAssign`s break. 3795 +/ 3796 /*immutable*/ string _idString; 3797 3798 package: 3799 /++ 3800 Whether or not the stream is currently ongoing. 3801 +/ 3802 bool live; // = false; 3803 3804 /++ 3805 The numerical ID of the user/account of the channel owner. In string form. 3806 +/ 3807 string userIDString; 3808 3809 /++ 3810 The user/account name of the channel owner. 3811 +/ 3812 string userLogin; 3813 3814 /++ 3815 The display name of the channel owner. 3816 +/ 3817 string userDisplayName; 3818 3819 /++ 3820 The unique ID of a game, as supplied by Twitch. In string form. 3821 +/ 3822 string gameIDString; 3823 3824 /++ 3825 The name of the game that's being streamed. 3826 +/ 3827 string gameName; 3828 3829 /++ 3830 The title of the stream. 3831 +/ 3832 string title; 3833 3834 /++ 3835 Stream tags. 3836 +/ 3837 string[] tags; 3838 3839 /++ 3840 When the stream started. 3841 +/ 3842 SysTime startTime; 3843 3844 /++ 3845 When the stream ended. 3846 +/ 3847 SysTime stopTime; 3848 3849 /++ 3850 How many people were viewing the stream the last time the monitor 3851 [core.thread.fiber.Fiber|Fiber] checked. 3852 +/ 3853 long viewerCount; 3854 3855 /++ 3856 The maximum number of people seen watching this stream. 3857 +/ 3858 long maxViewerCount; 3859 3860 /++ 3861 Users seen in the channel. 3862 +/ 3863 RehashingAA!(string, bool) chattersSeen; 3864 3865 /++ 3866 Hashmap of active viewers (who have shown activity). 3867 +/ 3868 RehashingAA!(string, bool) activeViewers; 3869 3870 /++ 3871 Accessor to [_idString]. 3872 3873 Returns: 3874 This stream's ID, as reported by Twitch, in string form. 3875 +/ 3876 auto idString() const 3877 { 3878 return _idString; 3879 } 3880 3881 /++ 3882 Takes a second [Stream] and updates this one with values from it. 3883 3884 Params: 3885 A second [Stream] from which to inherit values. 3886 +/ 3887 void update(const Stream updated) 3888 { 3889 assert(_idString.length, "Stream not properly initialised"); 3890 3891 this.userDisplayName = updated.userDisplayName; 3892 this.gameIDString = updated.gameIDString; 3893 this.gameName = updated.gameName; 3894 this.title = updated.title; 3895 this.viewerCount = updated.viewerCount; 3896 this.tags = updated.tags.dup; 3897 3898 if (this.viewerCount > this.maxViewerCount) 3899 { 3900 this.maxViewerCount = this.viewerCount; 3901 } 3902 } 3903 3904 /++ 3905 Constructor. 3906 3907 Params: 3908 idString = This stream's ID, as reported by Twitch, in string form. 3909 +/ 3910 this(const string idString) 3911 { 3912 this._idString = idString; 3913 } 3914 3915 /++ 3916 Serialises this [Stream] into a JSON representation. 3917 3918 Returns: 3919 A [std.json.JSONValue|JSONValue] that represents this [Stream]. 3920 +/ 3921 auto toJSON() const 3922 { 3923 JSONValue json; 3924 json = null; 3925 json.object = null; 3926 3927 json["idString"] = JSONValue(this._idString); 3928 json["gameIDString"] = JSONValue(this.gameIDString); 3929 json["gameName"] = JSONValue(this.gameName); 3930 json["title"] = JSONValue(this.title); 3931 json["startTimeUnix"] = JSONValue(this.startTime.toUnixTime()); 3932 json["stopTimeUnix"] = JSONValue(this.stopTime.toUnixTime()); 3933 json["maxViewerCount"] = JSONValue(this.maxViewerCount); 3934 json["tags"] = JSONValue(this.tags); 3935 return json; 3936 } 3937 3938 /++ 3939 Deserialises a [Stream] from a JSON representation. 3940 3941 Params: 3942 json = [std.json.JSONValue|JSONValue] to build a [Stream] from. 3943 3944 Returns: 3945 A new [Stream] with values from the passed `json`. 3946 +/ 3947 static auto fromJSON(const JSONValue json) 3948 { 3949 import std.algorithm.iteration : map; 3950 import std.array : array; 3951 3952 if ("idString" !in json) 3953 { 3954 // Invalid entry 3955 enum message = "No `idString` key in Stream JSON representation"; 3956 throw new UnexpectedJSONException(message); 3957 } 3958 3959 auto stream = Stream(json["idString"].str); 3960 stream.gameIDString = json["gameIDString"].str; 3961 stream.gameName = json["gameName"].str; 3962 stream.title = json["title"].str; 3963 stream.startTime = SysTime.fromUnixTime(json["startTimeUnix"].integer); 3964 stream.stopTime = SysTime.fromUnixTime(json["stopTimeUnix"].integer); 3965 stream.maxViewerCount = json["maxViewerCount"].integer; 3966 stream.tags = json["tags"].array 3967 .map!(tag => tag.str) 3968 .array; 3969 return stream; 3970 } 3971 } 3972 3973 /++ 3974 Constructor taking a string (channel) name. 3975 3976 Params: 3977 channelName = Name of the channel. 3978 +/ 3979 this(const string channelName) 3980 { 3981 import std.random : uniform; 3982 3983 this.channelName = channelName; 3984 this.broadcasterName = channelName[1..$]; 3985 this.broadcasterDisplayName = this.broadcasterName; // until we resolve it 3986 this._uniqueID = uniform(1, uint.max); 3987 } 3988 3989 /++ 3990 Accessor to [_uniqueID]. 3991 3992 Returns: 3993 A unique ID, in the form of the value of `_uniqueID`. 3994 +/ 3995 auto uniqueID() const 3996 { 3997 assert((_uniqueID > 0), "Room not properly initialised"); 3998 return _uniqueID; 3999 } 4000 4001 /++ 4002 Name of the channel. 4003 +/ 4004 string channelName; 4005 4006 /++ 4007 The current, ongoing stream. 4008 +/ 4009 Stream stream; 4010 4011 /++ 4012 Account name of the broadcaster. 4013 +/ 4014 string broadcasterName; 4015 4016 /++ 4017 Display name of the broadcaster. 4018 +/ 4019 string broadcasterDisplayName; 4020 4021 /++ 4022 Broadcaster user/account/room ID (not name). 4023 +/ 4024 string id; 4025 4026 /++ 4027 A JSON list of the followers of the channel. 4028 +/ 4029 Follow[string] follows; 4030 4031 /++ 4032 UNIX timestamp of when [follows] was last cached. 4033 +/ 4034 long followsLastCached; 4035 4036 /++ 4037 How many messages to keep in memory, to allow for nuking. 4038 +/ 4039 enum messageMemory = 128; 4040 4041 /++ 4042 The last n messages sent in the channel, used by `nuke`. 4043 +/ 4044 CircularBuffer!(IRCEvent, No.dynamic, messageMemory) lastNMessages; 4045 4046 /++ 4047 Song request history; UNIX timestamps keyed by nickname. 4048 +/ 4049 long[string] songrequestHistory; 4050 4051 /++ 4052 Set when we see a [dialect.defs.IRCEvent.Type.USERSTATE|USERSTATE] 4053 upon joining the channel. 4054 +/ 4055 bool sawUserstate; 4056 } 4057 4058 /++ 4059 All Twitch plugin settings. 4060 +/ 4061 TwitchSettings twitchSettings; 4062 4063 /++ 4064 Array of active bot channels' state. 4065 +/ 4066 Room[string] rooms; 4067 4068 /++ 4069 Custom channel-specific BetterTTV, FrankerFaceZ and 7tv emotes, as 4070 fetched via API calls. 4071 +/ 4072 bool[dstring][string] customEmotesByChannel; 4073 4074 /++ 4075 Custom global BetterTTV, FrankerFaceZ and 7tv emotes, as fetched via API calls. 4076 +/ 4077 bool[dstring] customGlobalEmotes; 4078 4079 /++ 4080 [kameloso.terminal.TerminalToken.bell|TerminalToken.bell] as string, 4081 for use as bell. 4082 +/ 4083 private enum bellString = "" ~ cast(char)(TerminalToken.bell); 4084 4085 /++ 4086 Effective bell after [kameloso.terminal.isTerminal] checks. 4087 +/ 4088 string bell = bellString; 4089 4090 /++ 4091 The Twitch application ID for kameloso. 4092 +/ 4093 enum clientID = "tjyryd2ojnqr8a51ml19kn1yi2n0v1"; 4094 4095 /++ 4096 Authorisation token for the "Authorization: Bearer <token>". 4097 +/ 4098 string authorizationBearer; 4099 4100 /++ 4101 The bot's numeric account/ID. 4102 +/ 4103 string userID; 4104 4105 /++ 4106 How long a Twitch HTTP query usually takes. 4107 4108 It tries its best to self-balance the number based on how long queries 4109 actually take. Start off conservatively. 4110 +/ 4111 long approximateQueryTime = 700; 4112 4113 // QueryConstants 4114 /++ 4115 Constants used when scheduling API queries. 4116 +/ 4117 enum QueryConstants : double 4118 { 4119 /++ 4120 The multiplier of how much the query time should temporarily increase 4121 when it turned out to be a bit short. 4122 +/ 4123 growthMultiplier = 1.1, 4124 4125 /++ 4126 The divisor of how much to wait before retrying a query, after the 4127 timed waited turned out to be a bit short. 4128 +/ 4129 retryTimeDivisor = 3, 4130 4131 /++ 4132 By how many milliseconds to pad measurements of how long a query took 4133 to be on the conservative side. 4134 +/ 4135 measurementPadding = 30, 4136 4137 /++ 4138 The weight to assign the current approximate query time before 4139 making a weighted average based on a new value. This gives the 4140 averaging some inertia. 4141 +/ 4142 averagingWeight = 3, 4143 } 4144 4145 /++ 4146 How many times to retry a Twitch server query. 4147 +/ 4148 enum delegateRetries = 10; 4149 4150 /++ 4151 Associative array of viewer times; seconds keyed by nickname keyed by channel. 4152 +/ 4153 RehashingAA!(string, long)[string] viewerTimesByChannel; 4154 4155 /++ 4156 Whether or not [viewerTimesByChannel] has been modified and there's a 4157 point in saving it to disk. 4158 +/ 4159 bool viewerTimesDirty; 4160 4161 /++ 4162 API keys and tokens, keyed by channel. 4163 +/ 4164 Credentials[string] secretsByChannel; 4165 4166 /++ 4167 The thread ID of the persistent worker thread. 4168 +/ 4169 Tid persistentWorkerTid; 4170 4171 /++ 4172 Associative array of responses from async HTTP queries. 4173 +/ 4174 shared QueryResponse[int] bucket; 4175 4176 @Resource("twitch") 4177 { 4178 /++ 4179 File to save emote counters to. 4180 +/ 4181 string ecountFile = "ecount.json"; 4182 4183 /++ 4184 File to save viewer times to. 4185 +/ 4186 string viewersFile = "viewers.json"; 4187 4188 /++ 4189 File to save API keys and tokens to. 4190 +/ 4191 string secretsFile = "secrets.json"; 4192 4193 /++ 4194 File to save stream history to. 4195 +/ 4196 string streamHistoryFile = "history.json"; 4197 } 4198 4199 /++ 4200 Emote counters associative array; counter longs keyed by emote ID string keyed by channel. 4201 +/ 4202 RehashingAA!(string, long)[string] ecount; 4203 4204 /++ 4205 Whether or not [ecount] has been modified and there's a point in saving it to disk. 4206 +/ 4207 bool ecountDirty; 4208 4209 // isEnabled 4210 /++ 4211 Override 4212 [kameloso.plugins.common.core.IRCPlugin.isEnabled|IRCPlugin.isEnabled] 4213 (effectively overriding [kameloso.plugins.common.core.IRCPluginImpl.isEnabled|IRCPluginImpl.isEnabled]) 4214 and inject a server check, so this plugin only works on Twitch, in addition 4215 to doing nothing when [TwitchSettings.enabled] is false. 4216 4217 Returns: 4218 `true` if this plugin should react to events; `false` if not. 4219 +/ 4220 override public bool isEnabled() const @property pure nothrow @nogc 4221 { 4222 return ( 4223 (state.server.daemon == IRCServer.Daemon.twitch) || 4224 (state.server.daemon == IRCServer.Daemon.unset)) && 4225 (twitchSettings.enabled || 4226 twitchSettings.keygen || 4227 twitchSettings.superKeygen || 4228 twitchSettings.googleKeygen || 4229 twitchSettings.spotifyKeygen); 4230 } 4231 4232 mixin IRCPluginImpl; 4233 }