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 }