1 /++
2     Functions for accessing the Twitch API. For internal use.
3 
4     See_Also:
5         [kameloso.plugins.twitch.base],
6         [kameloso.plugins.twitch.keygen],
7         [kameloso.plugins.common.core],
8         [kameloso.plugins.common.misc]
9 
10     Copyright: [JR](https://github.com/zorael)
11     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
12 
13     Authors:
14         [JR](https://github.com/zorael)
15  +/
16 module kameloso.plugins.twitch.api;
17 
18 version(TwitchSupport):
19 version(WithTwitchPlugin):
20 
21 private:
22 
23 import kameloso.plugins.twitch.base;
24 import kameloso.plugins.twitch.common;
25 
26 import arsd.http2 : HttpVerb;
27 import dialect.defs;
28 import lu.common : Next;
29 import std.traits : isSomeFunction;
30 import std.typecons : Flag, No, Yes;
31 import core.thread : Fiber;
32 
33 package:
34 
35 
36 // QueryResponse
37 /++
38     Embodies a response from a query to the Twitch servers. A string paired with
39     a millisecond count of how long the query took, and some metadata about the request.
40 
41     This is used instead of a [std.typecons.Tuple] because it doesn't apparently
42     work with `shared`.
43  +/
44 struct QueryResponse
45 {
46     /// Response body, may be several lines.
47     string str;
48 
49     /// How long the query took, from issue to response.
50     long msecs;
51 
52     /// The HTTP response code received.
53     uint code;
54 
55     /// The message of any exception thrown while querying.
56     string error;
57 }
58 
59 
60 // retryDelegate
61 /++
62     Retries a passed delegate until it no longer throws or until the hardcoded
63     number of retries
64     ([kameloso.plugins.twitch.base.TwitchPlugin.delegateRetries|TwitchPlugin.delegateRetries])
65     is reached.
66 
67     Params:
68         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
69         dg = Delegate to call.
70 
71     Returns:
72         Whatever the passed delegate returns.
73  +/
74 auto retryDelegate(Dg)(TwitchPlugin plugin, Dg dg)
75 {
76     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
77     {
78         try
79         {
80             if (i > 0)
81             {
82                 import kameloso.plugins.common.delayawait : delay;
83                 import core.time : seconds;
84 
85                 static immutable retryDelay = 3.seconds;
86                 delay(plugin, retryDelay, Yes.yield);
87             }
88             return dg();
89         }
90         catch (MissingBroadcasterTokenException e)
91         {
92             // This is never a transient error
93             throw e;
94         }
95         catch (Exception e)
96         {
97             // Retry until we reach the retry limit, then print if we should, before rethrowing
98             if (i < TwitchPlugin.delegateRetries-1) continue;
99 
100             version(PrintStacktraces)
101             {
102                 if (!plugin.state.settings.headless)
103                 {
104                     printRetryDelegateException(e);
105                 }
106             }
107             throw e;
108         }
109     }
110 
111     assert(0, "Unreachable");
112 }
113 
114 
115 // printRetryDelegateException
116 /++
117     Prints out details about exceptions passed from [retryDelegate].
118     [retryDelegate] itself rethrows them when we return, so no need to do that here.
119 
120     Gated behind version `PrintStacktraces`.
121 
122     Params:
123         e = The exception to print.
124  +/
125 version(PrintStacktraces)
126 void printRetryDelegateException(/*const*/ Exception e)
127 {
128     import kameloso.common : logger;
129     import std.json : JSONException, parseJSON;
130     import std.stdio : stdout, writeln;
131 
132     if (auto twitchQueryException = cast(TwitchQueryException)e)
133     {
134         logger.trace(twitchQueryException);
135 
136         try
137         {
138             writeln(parseJSON(twitchQueryException.responseBody).toPrettyString);
139         }
140         catch (JSONException _)
141         {
142             writeln(twitchQueryException.responseBody);
143         }
144 
145         stdout.flush();
146         //throw twitchQueryException;
147     }
148     else if (auto emptyDataJSONException = cast(EmptyDataJSONException)e)
149     {
150         // Must be before TwitchJSONException below
151         logger.trace(emptyDataJSONException);
152         //throw emptyDataJSONException;
153     }
154     else if (auto twitchJSONException = cast(TwitchJSONException)e)
155     {
156         // UnexpectedJSONException and ErrorJSONException
157         logger.trace(twitchJSONException);
158         writeln(twitchJSONException.json.toPrettyString);
159         stdout.flush();
160         //throw twitchJSONException;
161     }
162     else /*if (auto plainException = cast(Exception)e)*/
163     {
164         logger.trace(e);
165         //throw e;
166     }
167 }
168 
169 
170 // persistentQuerier
171 /++
172     Persistent worker issuing Twitch API queries based on the concurrency messages
173     sent to it.
174 
175     Example:
176     ---
177     spawn(&persistentQuerier, plugin.bucket, caBundleFile);
178     ---
179 
180     Params:
181         bucket = The shared associative array to put the results in, response
182             values keyed by a unique numerical ID.
183         caBundleFile = Path to a `cacert.pem` SSL certificate bundle.
184  +/
185 void persistentQuerier(
186     shared QueryResponse[int] bucket,
187     const string caBundleFile)
188 {
189     import kameloso.thread : ThreadMessage;
190     import std.concurrency : OwnerTerminated, receive;
191     import std.variant : Variant;
192 
193     version(Posix)
194     {
195         import kameloso.thread : setThreadName;
196         setThreadName("twitchworker");
197     }
198 
199     bool halt;
200 
201     void invokeSendHTTPRequestImpl(
202         const int id,
203         const string url,
204         const string authToken,
205         /*const*/ HttpVerb verb,
206         immutable(ubyte)[] body_,
207         const string contentType) scope
208     {
209         version(BenchmarkHTTPRequests)
210         {
211             import std.datetime.systime : Clock;
212             immutable pre = Clock.currTime;
213         }
214 
215         immutable response = sendHTTPRequestImpl(
216             url,
217             authToken,
218             caBundleFile,
219             verb,
220             cast(ubyte[])body_,
221             contentType);
222 
223         synchronized //()
224         {
225             bucket[id] = response;  // empty str if code >= 400
226         }
227 
228         version(BenchmarkHTTPRequests)
229         {
230             import std.stdio : stdout, writefln;
231             immutable post = Clock.currTime;
232             writefln("%s (%s)", post-pre, url);
233             stdout.flush();
234         }
235     }
236 
237     void sendWithBody(
238         int id,
239         string url,
240         string authToken,
241         HttpVerb verb,
242         immutable(ubyte)[] body_,
243         string contentType) scope
244     {
245         invokeSendHTTPRequestImpl(
246             id,
247             url,
248             authToken,
249             verb,
250             body_,
251             contentType);
252     }
253 
254     void sendWithoutBody(
255         int id,
256         string url,
257         string authToken) scope
258     {
259         // Shorthand
260         invokeSendHTTPRequestImpl(
261             id,
262             url,
263             authToken,
264             HttpVerb.GET,
265             cast(immutable(ubyte)[])null,
266             string.init);
267     }
268 
269     void onMessage(ThreadMessage message) scope
270     {
271         halt = (message.type == ThreadMessage.Type.teardown);
272     }
273 
274     void onOwnerTerminated(OwnerTerminated _) scope
275     {
276         halt = true;
277     }
278 
279     while (!halt)
280     {
281         receive(
282             &sendWithBody,
283             &sendWithoutBody,
284             &onMessage,
285             &onOwnerTerminated,
286             (Variant v) scope
287             {
288                 import std.stdio : stdout, writeln;
289                 writeln("Twitch worker received unknown Variant: ", v);
290                 stdout.flush();
291             }
292         );
293     }
294 }
295 
296 
297 // sendHTTPRequest
298 /++
299     Wraps [sendHTTPRequestImpl] by proxying calls to it via the
300     [persistentQuerier] subthread.
301 
302     Once the query returns, the response body is checked to see whether or not
303     an error occurred. If so, it throws an exception with a descriptive message.
304 
305     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
306 
307     Example:
308     ---
309     immutable QueryResponse = sendHTTPRequest(plugin, "https://id.twitch.tv/oauth2/validate", __FUNCTION__, "OAuth 30letteroauthstring");
310     ---
311 
312     Params:
313         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
314         url = The URL to query.
315         caller = Name of the calling function.
316         authorisationHeader = Authorisation HTTP header to pass.
317         verb = What [arsd.http2.HttpVerb|HttpVerb] to use in the request.
318         body_ = Request body to send in case of verbs like `POST` and `PATCH`.
319         contentType = "Content-Type" HTTP header to pass.
320         id = Numerical ID to use instead of generating a new one.
321         recursing = Whether or not this is a recursive call and another one should
322             not be attempted.
323 
324     Returns:
325         The [QueryResponse] that was discovered while monitoring the `bucket`
326         as having been received from the server.
327 
328     Throws:
329         [TwitchQueryException] if there were unrecoverable errors.
330  +/
331 QueryResponse sendHTTPRequest(
332     TwitchPlugin plugin,
333     const string url,
334     const string caller = __FUNCTION__,
335     const string authorisationHeader = string.init,
336     /*const*/ HttpVerb verb = HttpVerb.GET,
337     /*const*/ ubyte[] body_ = null,
338     const string contentType = string.init,
339     int id = -1,
340     const Flag!"recursing" recursing = No.recursing)
341 in (Fiber.getThis, "Tried to call `sendHTTPRequest` from outside a Fiber")
342 in (url.length, "Tried to send an HTTP request without a URL")
343 {
344     import kameloso.plugins.common.delayawait : delay;
345     import kameloso.thread : ThreadMessage;
346     import std.concurrency : prioritySend, send;
347     import std.datetime.systime : Clock, SysTime;
348     import core.time : msecs;
349 
350     if (plugin.state.settings.trace)
351     {
352         import kameloso.common : logger;
353         enum pattern = "%s: <i>%s<t> (%s)";
354         logger.tracef(pattern, verb, url, caller);
355     }
356 
357     plugin.state.mainThread.prioritySend(ThreadMessage.shortenReceiveTimeout());
358 
359     immutable pre = Clock.currTime;
360     if (id == -1) id = getUniqueNumericalID(plugin.bucket);
361 
362     plugin.persistentWorkerTid.send(
363         id,
364         url,
365         authorisationHeader,
366         verb,
367         body_.idup,
368         contentType);
369 
370     delay(plugin, plugin.approximateQueryTime.msecs, Yes.yield);
371     immutable response = waitForQueryResponse(plugin, id);
372 
373     scope(exit)
374     {
375         synchronized //()
376         {
377             // Always remove, otherwise there'll be stale entries
378             plugin.bucket.remove(id);
379         }
380     }
381 
382     immutable post = Clock.currTime;
383     immutable diff = (post - pre);
384     immutable msecs_ = diff.total!"msecs";
385     averageApproximateQueryTime(plugin, msecs_);
386 
387     if (response.code == 2)
388     {
389         throw new TwitchQueryException(
390             response.error,
391             response.str,
392             response.error,
393             response.code);
394     }
395     else if (response.code == 0) //(!response.str.length)
396     {
397         throw new EmptyResponseException("Empty response");
398     }
399     else if ((response.code >= 500) && !recursing)
400     {
401         return sendHTTPRequest(
402             plugin,
403             url,
404             caller,
405             authorisationHeader,
406             verb,
407             body_,
408             contentType,
409             id,
410             Yes.recursing);
411     }
412     else if (response.code >= 400)
413     {
414         import std.format : format;
415         import std.json : JSONException;
416 
417         try
418         {
419             import lu.string : unquoted;
420             import std.json : parseJSON;
421             import std.string : chomp;
422 
423             // {"error":"Unauthorized","status":401,"message":"Must provide a valid Client-ID or OAuth token"}
424             /*
425             {
426                 "error": "Unauthorized",
427                 "message": "Client ID and OAuth token do not match",
428                 "status": 401
429             }
430             {
431                 "error": "Unknown Emote Set",
432                 "error_code": 70441,
433                 "status": "Not Found",
434                 "status_code": 404
435             }
436             {
437                 "message": "user not found"
438             }
439             */
440             immutable json = parseJSON(response.str);
441             long code = response.code;
442             string status;
443             string message;
444 
445             if (immutable statusCodeJSON = "status_code" in json)
446             {
447                 code = (*statusCodeJSON).integer;
448                 status = json["status"].str;
449                 message = json["error"].str;
450             }
451             else if (immutable statusJSON = "status" in json)
452             {
453                 import std.json : JSONException;
454 
455                 try
456                 {
457                     code = (*statusJSON).integer;
458                     status = json["error"].str;
459                     message = json["message"].str;
460                 }
461                 catch (JSONException _)
462                 {
463                     status = "Error";
464                     message = json["message"].str;
465                 }
466             }
467             else if (immutable messageJSON = "message" in json)
468             {
469                 status = "Error";
470                 message = (*messageJSON).str;
471             }
472             else
473             {
474                 status = "Error";
475                 message = "An unspecified error occured";
476 
477                 version(PrintStacktraces)
478                 {
479                     if (!plugin.state.settings.headless)
480                     {
481                         import std.stdio : stdout, writeln;
482 
483                         writeln(json.toPrettyString);
484                         stdout.flush();
485                     }
486                 }
487             }
488 
489             enum pattern = "%3d %s: %s";
490             immutable exceptionMessage = pattern.format(
491                 code,
492                 status.chomp.unquoted,
493                 message.chomp.unquoted);
494 
495             throw new ErrorJSONException(exceptionMessage, json);
496         }
497         catch (JSONException e)
498         {
499             throw new TwitchQueryException(
500                 e.msg,
501                 response.str,
502                 response.error,
503                 response.code,
504                 e.file,
505                 e.line);
506         }
507     }
508 
509     return response;
510 }
511 
512 
513 // sendHTTPRequestImpl
514 /++
515     Sends a HTTP request of the passed verb to the passed URL, and returns the response.
516 
517     Params:
518         url = URL address to look up.
519         authHeader = Authorisation token HTTP header to pass.
520         caBundleFile = Path to a `cacert.pem` SSL certificate bundle.
521         verb = What [arsd.http2.HttpVerb|HttpVerb] to use in the request.
522         body_ = Request body to send in case of verbs like `POST` and `PATCH`.
523         contentType = "Content-Type" HTTP header to use.
524 
525     Returns:
526         A [QueryResponse] of the response from the server.
527  +/
528 auto sendHTTPRequestImpl(
529     const string url,
530     const string authHeader,
531     const string caBundleFile,
532     /*const*/ HttpVerb verb = HttpVerb.GET,
533     /*const*/ ubyte[] body_ = null,
534     /*const*/ string contentType = string.init)
535 {
536     import kameloso.constants : KamelosoInfo, Timeout;
537     import arsd.http2 : HttpClient, Uri;
538     import std.algorithm.comparison : among;
539     import std.datetime.systime : Clock;
540     import core.time : seconds;
541 
542     static HttpClient client;
543     static string[] headers;
544 
545     if (!client)
546     {
547         import kameloso.constants : KamelosoInfo;
548 
549         client = new HttpClient;
550         client.useHttp11 = true;
551         client.keepAlive = true;
552         client.acceptGzip = false;
553         client.defaultTimeout = Timeout.httpGET.seconds;
554         client.userAgent = "kameloso/" ~ cast(string)KamelosoInfo.version_;
555         headers = [ "Client-ID: " ~ TwitchPlugin.clientID ];
556         if (caBundleFile.length) client.setClientCertificate(caBundleFile, caBundleFile);
557     }
558 
559     client.authorization = authHeader;
560 
561     QueryResponse response;
562     auto pre = Clock.currTime;
563     auto req = client.request(Uri(url), verb, body_, contentType);
564     // The Twitch Client-ID header leaks into Google and Spotify requests. Worth dealing with?
565     req.requestParameters.headers = headers;
566     auto res = req.waitForCompletion();
567 
568     if (res.code.among!(301, 302, 307, 308) && res.location.length)
569     {
570         // Moved
571         foreach (immutable i; 0..5)
572         {
573             pre = Clock.currTime;
574             req = client.request(Uri(res.location), verb, body_, contentType);
575             req.requestParameters.headers = headers;
576             res = req.waitForCompletion();
577 
578             if (!res.code.among!(301, 302, 307, 308) || !res.location.length) break;
579         }
580     }
581 
582     response.code = res.code;
583     response.error = res.codeText;
584     response.str = res.contentText;
585     immutable post = Clock.currTime;
586     immutable delta = (post - pre);
587     response.msecs = delta.total!"msecs";
588     return response;
589 }
590 
591 
592 // getTwitchData
593 /++
594     By following a passed URL, queries Twitch servers for an entity (user or channel).
595 
596     Params:
597         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
598         url = The URL to follow.
599         caller = Name of the calling function.
600 
601     Returns:
602         A singular user or channel regardless of how many were asked for in the URL.
603         If nothing was found, an exception is thrown instead.
604 
605     Throws:
606         [EmptyDataJSONException] if the `"data"` field is empty for some reason.
607 
608         [UnexpectedJSONException] on unexpected JSON.
609 
610         [TwitchQueryException] on other JSON errors.
611  +/
612 auto getTwitchData(
613     TwitchPlugin plugin,
614     const string url,
615     const string caller = __FUNCTION__)
616 in (Fiber.getThis, "Tried to call `getTwitchData` from outside a Fiber")
617 {
618     import std.json : JSONException, JSONType, parseJSON;
619 
620     // Request here outside try-catch to let exceptions fall through
621     immutable response = sendHTTPRequest(plugin, url, caller, plugin.authorizationBearer);
622 
623     try
624     {
625         immutable responseJSON = parseJSON(response.str);
626 
627         if (responseJSON.type != JSONType.object)
628         {
629             enum message = "`getTwitchData` query response JSON is not JSONType.object";
630             throw new UnexpectedJSONException(message, responseJSON);
631         }
632         else if (immutable dataJSON = "data" in responseJSON)
633         {
634             if (dataJSON.array.length == 1)
635             {
636                 return dataJSON.array[0];
637             }
638             else if (!dataJSON.array.length)
639             {
640                 enum message = "`getTwitchData` query response JSON has empty \"data\"";
641                 throw new EmptyDataJSONException(message, responseJSON);
642             }
643             else
644             {
645                 enum message = "`getTwitchData` query response JSON \"data\" value is not a 1-length array";
646                 throw new UnexpectedJSONException(message, *dataJSON);
647             }
648         }
649         else
650         {
651             enum message = "`getTwitchData` query response JSON does not contain a \"data\" element";
652             throw new UnexpectedJSONException(message, responseJSON);
653         }
654     }
655     catch (JSONException e)
656     {
657         throw new TwitchQueryException(
658             e.msg,
659             response.str,
660             response.error,
661             response.code,
662             e.file,
663             e.line);
664     }
665 }
666 
667 
668 // getChatters
669 /++
670     Get the JSON representation of everyone currently in a broadcaster's channel.
671 
672     It is not updated in realtime, so it doesn't make sense to call this often.
673 
674     Params:
675         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
676         broadcaster = The broadcaster to look up chatters for.
677         caller = Name of the calling function.
678 
679     Returns:
680         A [std.json.JSONValue|JSONValue] with "`chatters`" and "`chatter_count`" keys.
681         If nothing was found, an exception is thrown instead.
682 
683     Throws:
684         [UnexpectedJSONException] on unexpected JSON.
685  +/
686 auto getChatters(
687     TwitchPlugin plugin,
688     const string broadcaster,
689     const string caller = __FUNCTION__)
690 in (Fiber.getThis, "Tried to call `getChatters` from outside a Fiber")
691 in (broadcaster.length, "Tried to get chatters with an empty broadcaster string")
692 {
693     import std.conv : text;
694     import std.json : JSONType, parseJSON;
695 
696     immutable chattersURL = text("https://tmi.twitch.tv/group/user/", broadcaster, "/chatters");
697 
698     auto getChattersDg()
699     {
700         immutable response = sendHTTPRequest(plugin, chattersURL, caller, plugin.authorizationBearer);
701         immutable responseJSON = parseJSON(response.str);
702 
703         /*
704         {
705             "_links": {},
706             "chatter_count": 93,
707             "chatters": {
708                 "broadcaster": [
709                     "streamernick"
710                 ],
711                 "vips": [],
712                 "moderators": [
713                     "somemod"
714                 ],
715                 "staff": [],
716                 "admins": [],
717                 "global_mods": [],
718                 "viewers": [
719                     "abc",
720                     "def",
721                     "ghi"
722                 ]
723             }
724         }
725         */
726 
727         if (responseJSON.type != JSONType.object)
728         {
729             enum message = "`getChatters` response JSON is not JSONType.object";
730             throw new UnexpectedJSONException(message, responseJSON);
731         }
732 
733         immutable chattersJSON = "chatters" in responseJSON;
734         if (!chattersJSON)
735         {
736             // For some reason we received an object that didn't contain chatters
737             enum message = "`getChatters` \"chatters\" JSON is not JSONType.object";
738             throw new UnexpectedJSONException(message, *chattersJSON);
739         }
740 
741         // Don't return `chattersJSON`, as we would lose "chatter_count".
742         return responseJSON;
743     }
744 
745     return retryDelegate(plugin, &getChattersDg);
746 }
747 
748 
749 // getValidation
750 /++
751     Validates an access key, retrieving information about it.
752 
753     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
754 
755     Params:
756         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
757         authToken = Authorisation token to validate.
758         async = Whether or not the validation should be done asynchronously, using Fibers.
759         caller = Name of the calling function.
760 
761     Returns:
762         A [std.json.JSONValue|JSONValue] with the validation information JSON of the
763         current authorisation header/client ID pair.
764 
765     Throws:
766         [UnexpectedJSONException] on unexpected JSON received.
767 
768         [TwitchQueryException] on other failure.
769  +/
770 auto getValidation(
771     TwitchPlugin plugin,
772     /*const*/ string authToken,
773     const Flag!"async" async,
774     const string caller = __FUNCTION__)
775 in ((!async || Fiber.getThis), "Tried to call asynchronous `getValidation` from outside a Fiber")
776 in (authToken.length, "Tried to validate an empty Twitch authorisation token")
777 {
778     import lu.string : beginsWith;
779     import std.json : JSONType, parseJSON;
780 
781     enum url = "https://id.twitch.tv/oauth2/validate";
782 
783     // Validation needs an "Authorization: OAuth xxx" header, as opposed to the
784     // "Authorization: Bearer xxx" used everywhere else.
785     authToken = plugin.state.bot.pass.beginsWith("oauth:") ?
786         authToken[6..$] :
787         authToken;
788     immutable authorizationHeader = "OAuth " ~ authToken;
789 
790     auto getValidationDg()
791     {
792         QueryResponse response;
793 
794         if (async)
795         {
796             response = sendHTTPRequest(plugin, url, caller, authorizationHeader);
797         }
798         else
799         {
800             if (plugin.state.settings.trace)
801             {
802                 import kameloso.common : logger;
803                 enum pattern = "GET: <i>%s<t> (%s)";
804                 logger.tracef(pattern, url, __FUNCTION__);
805             }
806 
807             response = sendHTTPRequestImpl(
808                 url,
809                 authorizationHeader,
810                 plugin.state.connSettings.caBundleFile);
811 
812             // Copy/paste error handling...
813             if (response.code == 2)
814             {
815                 throw new TwitchQueryException(
816                     response.error,
817                     response.str,
818                     response.error,
819                     response.code);
820             }
821             else if (response.code == 0) //(!response.str.length)
822             {
823                 throw new TwitchQueryException(
824                     "Empty response",
825                     response.str,
826                     response.error,
827                     response.code);
828             }
829             else if (response.code >= 400)
830             {
831                 import std.format : format;
832                 import std.json : JSONException;
833 
834                 try
835                 {
836                     import lu.string : unquoted;
837                     import std.json : parseJSON;
838                     import std.string : chomp;
839 
840                     // {"error":"Unauthorized","status":401,"message":"Must provide a valid Client-ID or OAuth token"}
841                     /*
842                     {
843                         "error": "Unauthorized",
844                         "message": "Client ID and OAuth token do not match",
845                         "status": 401
846                     }
847                     */
848                     immutable errorJSON = parseJSON(response.str);
849                     enum pattern = "%3d %s: %s";
850 
851                     immutable message = pattern.format(
852                         errorJSON["status"].integer,
853                         errorJSON["error"].str.unquoted,
854                         errorJSON["message"].str.chomp.unquoted);
855 
856                     throw new TwitchQueryException(message, response.str, response.error, response.code);
857                 }
858                 catch (JSONException e)
859                 {
860                     throw new TwitchQueryException(
861                         e.msg,
862                         response.str,
863                         response.error,
864                         response.code);
865                 }
866             }
867         }
868 
869         immutable validationJSON = parseJSON(response.str);
870 
871         if ((validationJSON.type != JSONType.object) || ("client_id" !in validationJSON))
872         {
873             enum message = "Failed to validate Twitch authorisation token; unknown JSON";
874             throw new UnexpectedJSONException(message, validationJSON);
875         }
876 
877         return validationJSON;
878     }
879 
880     return retryDelegate(plugin, &getValidationDg);
881 }
882 
883 
884 // getFollows
885 /++
886     Fetches a list of all follows of the passed channel and caches them in
887     the channel's entry in [kameloso.plugins.twitch.base.TwitchPlugin.rooms|TwitchPlugin.rooms].
888 
889     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
890 
891     Params:
892         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
893         id = The string identifier for the channel.
894 
895     Returns:
896         An associative array of [std.json.JSONValue|JSONValue]s keyed by nickname string,
897         containing follows.
898  +/
899 auto getFollows(TwitchPlugin plugin, const string id)
900 in (Fiber.getThis, "Tried to call `getFollows` from outside a Fiber")
901 in (id.length, "Tried to get follows with an empty ID string")
902 {
903     immutable url = "https://api.twitch.tv/helix/users/follows?first=100&to_id=" ~ id;
904 
905     auto getFollowsDg()
906     {
907         const entitiesArrayJSON = getMultipleTwitchData(plugin, url);
908         Follow[string] allFollows;
909 
910         foreach (entityJSON; entitiesArrayJSON)
911         {
912             immutable key = entityJSON["from_id"].str;
913             allFollows[key] = Follow.fromJSON(entityJSON);
914         }
915 
916         return allFollows;
917     }
918 
919     return retryDelegate(plugin, &getFollowsDg);
920 }
921 
922 
923 // getMultipleTwitchData
924 /++
925     By following a passed URL, queries Twitch servers for an array of entities
926     (such as users or channels).
927 
928     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
929 
930     Params:
931         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
932         url = The URL to follow.
933         caller = Name of the calling function.
934 
935     Returns:
936         A [std.json.JSONValue|JSONValue] of type `array` containing all returned
937         entities, over all paginated queries.
938  +/
939 auto getMultipleTwitchData(
940     TwitchPlugin plugin,
941     const string url,
942     const string caller = __FUNCTION__)
943 in (Fiber.getThis, "Tried to call `getMultipleTwitchData` from outside a Fiber")
944 {
945     import std.json : JSONValue, parseJSON;
946 
947     JSONValue allEntitiesJSON;
948     allEntitiesJSON = null;
949     allEntitiesJSON.array = null;
950     string after;
951 
952     do
953     {
954         immutable paginatedURL = after.length ?
955             (url ~ "&after=" ~ after) :
956             url;
957         immutable response = sendHTTPRequest(plugin, paginatedURL, caller, plugin.authorizationBearer);
958         immutable responseJSON = parseJSON(response.str);
959         immutable dataJSON = "data" in responseJSON;
960 
961         if (!dataJSON)
962         {
963             enum message = "No data in JSON response";
964             throw new UnexpectedJSONException(message, *dataJSON);
965         }
966 
967         foreach (thisResponseJSON; dataJSON.array)
968         {
969             allEntitiesJSON.array ~= thisResponseJSON;
970         }
971 
972         immutable cursor = "cursor" in responseJSON["pagination"];
973 
974         after = cursor ?
975             cursor.str :
976             string.init;
977     }
978     while (after.length);
979 
980     return allEntitiesJSON.array;
981 }
982 
983 
984 // averageApproximateQueryTime
985 /++
986     Given a query time measurement, calculate a new approximate query time based on
987     the weighted averages of the old value and said new measurement.
988 
989     The old value is given a weight of
990     [kameloso.plugins.twitch.base.TwitchPlugin.QueryConstants.averagingWeight|averagingWeight]
991     and the new measurement a weight of 1. Additionally the measurement is padded by
992     [kameloso.plugins.twitch.base.TwitchPlugin.QueryConstants.measurementPadding|measurementPadding]
993     to be on the safe side.
994 
995     Params:
996         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
997         responseMsecs = The new measurement of how many milliseconds the last
998             query took to complete.
999  +/
1000 void averageApproximateQueryTime(TwitchPlugin plugin, const long responseMsecs)
1001 {
1002     import std.algorithm.comparison : min;
1003 
1004     enum maxDeltaToResponse = 5000;
1005 
1006     immutable current = plugin.approximateQueryTime;
1007     alias weight = TwitchPlugin.QueryConstants.averagingWeight;
1008     alias padding = TwitchPlugin.QueryConstants.measurementPadding;
1009     immutable responseAdjusted = cast(long)min(responseMsecs, (current + maxDeltaToResponse));
1010     immutable average = ((weight * current) + (responseAdjusted + padding)) / (weight + 1);
1011 
1012     version(BenchmarkHTTPRequests)
1013     {
1014         import std.stdio : writefln;
1015         enum pattern = "time:%s | response: %d~%d (+%d) | new average:%s";
1016         writefln(
1017             pattern,
1018             current,
1019             responseMsecs,
1020             responseAdjusted,
1021             cast(long)padding,
1022             average);
1023     }
1024 
1025     plugin.approximateQueryTime = cast(long)average;
1026 }
1027 
1028 
1029 // waitForQueryResponse
1030 /++
1031     Common code to wait for a query response.
1032 
1033     Merely spins and monitors the shared `bucket` associative array for when a
1034     response has arrived, and then returns it.
1035 
1036     Times out after a hardcoded [kameloso.constants.Timeout.httpGET|Timeout.httpGET]
1037     if nothing was received.
1038 
1039     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1040 
1041     Example:
1042     ---
1043     immutable id = getUniqueNumericalID(plugin.bucket);
1044     immutable url = "https://api.twitch.tv/helix/users?login=zorael";
1045     plugin.persistentWorkerTid.send(id, url, plugin.authorizationBearer);
1046 
1047     delay(plugin, plugin.approximateQueryTime.msecs, Yes.yield);
1048     immutable response = waitForQueryResponse(plugin, id, url);
1049     // response.str is the response body
1050     ---
1051 
1052     Params:
1053         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1054         id = Numerical ID to use as key when storing the response in the bucket AA.
1055         leaveTimingAlone = Whether or not to adjust the approximate query time.
1056             Enabled by default but can be disabled if the caller wants to do it.
1057 
1058     Returns:
1059         A [QueryResponse] as constructed by other parts of the program.
1060  +/
1061 auto waitForQueryResponse(TwitchPlugin plugin, const int id)
1062 in (Fiber.getThis, "Tried to call `waitForQueryResponse` from outside a Fiber")
1063 {
1064     import kameloso.constants : Timeout;
1065     import kameloso.plugins.common.delayawait : delay;
1066     import std.datetime.systime : Clock;
1067     import core.time : msecs;
1068 
1069     version(BenchmarkHTTPRequests)
1070     {
1071         import std.stdio : writefln;
1072         uint misses;
1073     }
1074 
1075     immutable startTime = Clock.currTime.toUnixTime;
1076     shared QueryResponse* response;
1077     double accumulatingTime = plugin.approximateQueryTime;
1078 
1079     while (true)
1080     {
1081         response = id in plugin.bucket;
1082 
1083         if (!response || (*response == QueryResponse.init))
1084         {
1085             immutable now = Clock.currTime.toUnixTime;
1086 
1087             if ((now - startTime) >= Timeout.httpGET)
1088             {
1089                 response = new shared QueryResponse;
1090                 return *response;
1091             }
1092 
1093             version(BenchmarkHTTPRequests)
1094             {
1095                 ++misses;
1096                 immutable oldAccumulatingTime = accumulatingTime;
1097             }
1098 
1099             // Miss; fired too early, there is no response available yet
1100             alias QC = TwitchPlugin.QueryConstants;
1101             accumulatingTime *= QC.growthMultiplier;
1102             immutable briefWait = cast(long)(accumulatingTime / QC.retryTimeDivisor);
1103 
1104             version(BenchmarkHTTPRequests)
1105             {
1106                 enum pattern = "MISS %d! elapsed: %s | old: %d --> new: %d | wait: %d";
1107                 writefln(
1108                     pattern,
1109                     misses,
1110                     (now-startTime),
1111                     cast(long)oldAccumulatingTime,
1112                     cast(long)accumulatingTime,
1113                     cast(long)briefWait);
1114             }
1115 
1116             delay(plugin, briefWait.msecs, Yes.yield);
1117             continue;
1118         }
1119 
1120         version(BenchmarkHTTPRequests)
1121         {
1122             enum pattern = "HIT! elapsed: %s | response: %s | misses: %d";
1123             immutable now = Clock.currTime.toUnixTime;
1124             writefln(pattern, (now-startTime), response.msecs, misses);
1125         }
1126 
1127         // Make the new approximate query time a weighted average
1128         averageApproximateQueryTime(plugin, response.msecs);
1129         plugin.bucket.remove(id);
1130         return *response;
1131     }
1132 }
1133 
1134 
1135 // getTwitchUser
1136 /++
1137     Fetches information about a Twitch user and returns it in the form of a
1138     Voldemort struct with nickname, display name and account ID (as string) members.
1139 
1140     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1141 
1142     Params:
1143         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1144         givenName = Name of user to look up.
1145         givenIDString = ID of user to look up, if no `givenName` given.
1146         searchByDisplayName = Whether or not to also attempt to look up `givenName`
1147             as a display name.
1148 
1149     Returns:
1150         Voldemort aggregate struct with `nickname`, `displayName` and `idString` members.
1151  +/
1152 auto getTwitchUser(
1153     TwitchPlugin plugin,
1154     const string givenName,
1155     const string givenIDString,
1156     const Flag!"searchByDisplayName" searchByDisplayName = No.searchByDisplayName)
1157 in (Fiber.getThis, "Tried to call `getTwitchUser` from outside a Fiber")
1158 in ((givenName.length || givenIDString.length),
1159     "Tried to get Twitch user without supplying a name nor an ID")
1160 {
1161     import std.conv : to;
1162     import std.json : JSONType;
1163 
1164     static struct User
1165     {
1166         string idString;
1167         string nickname;
1168         string displayName;
1169     }
1170 
1171     User user;
1172 
1173     if (const stored = givenName in plugin.state.users)
1174     {
1175         // Stored user
1176         user.idString = stored.id.to!string;
1177         user.nickname = stored.nickname;
1178         user.displayName = stored.displayName;
1179         return user;
1180     }
1181 
1182     // No such luck
1183     if (searchByDisplayName)
1184     {
1185         foreach (const stored; plugin.state.users)
1186         {
1187             if (stored.displayName == givenName)
1188             {
1189                 // Found user by displayName
1190                 user.idString = stored.id.to!string;
1191                 user.nickname = stored.nickname;
1192                 user.displayName = stored.displayName;
1193                 return user;
1194             }
1195         }
1196     }
1197 
1198     // None on record, look up
1199     immutable userURL = givenName ?
1200         ("https://api.twitch.tv/helix/users?login=" ~ givenName) :
1201         ("https://api.twitch.tv/helix/users?id=" ~ givenIDString);
1202 
1203     auto getTwitchUserDg()
1204     {
1205         immutable userJSON = getTwitchData(plugin, userURL);
1206 
1207         if ((userJSON.type != JSONType.object) || ("id" !in userJSON))
1208         {
1209             // No such user
1210             return user; //User.init;
1211         }
1212 
1213         user.idString = userJSON["id"].str;
1214         user.nickname = userJSON["login"].str;
1215         user.displayName = userJSON["display_name"].str;
1216         return user;
1217     }
1218 
1219     return retryDelegate(plugin, &getTwitchUserDg);
1220 }
1221 
1222 
1223 // getTwitchGame
1224 /++
1225     Fetches information about a game; its numerical ID and full name.
1226 
1227     If `id` is passed, then it takes priority over `name`.
1228 
1229     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1230 
1231     Params:
1232         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1233         name = Name of game to look up.
1234         id = ID of game to look up.
1235 
1236     Returns:
1237         Voldemort aggregate struct with `id` and `name` members.
1238  +/
1239 auto getTwitchGame(TwitchPlugin plugin, const string name, const string id)
1240 in (Fiber.getThis, "Tried to call `getTwitchGame` from outside a Fiber")
1241 in ((name.length || id.length), "Tried to call `getTwitchGame` with no game name nor game ID")
1242 {
1243     static struct Game
1244     {
1245         string id;
1246         string name;
1247     }
1248 
1249     immutable gameURL = id.length ?
1250         ("https://api.twitch.tv/helix/games?id=" ~ id) :
1251         ("https://api.twitch.tv/helix/games?name=" ~ name);
1252 
1253     auto getTwitchGameDg()
1254     {
1255         immutable gameJSON = getTwitchData(plugin, gameURL);
1256 
1257         /*
1258         {
1259             "id": "512953",
1260             "name": "Elden Ring",
1261             "box_art_url": "https://static-cdn.jtvnw.net/ttv-boxart/512953_IGDB-{width}x{height}.jpg"
1262         }
1263         */
1264 
1265         return Game(gameJSON["id"].str, gameJSON["name"].str);
1266     }
1267 
1268     return retryDelegate(plugin, &getTwitchGameDg);
1269 }
1270 
1271 
1272 // getUniqueNumericalID
1273 /++
1274     Generates a unique numerical ID for use as key in the passed associative array bucket.
1275 
1276     Params:
1277         bucket = Shared associative array of responses from async HTTP queries.
1278 
1279     Returns:
1280         A unique integer for use as bucket key.
1281  +/
1282 auto getUniqueNumericalID(shared QueryResponse[int] bucket)
1283 {
1284     import std.random : uniform;
1285 
1286     int id = uniform(0, int.max);
1287 
1288     synchronized //()
1289     {
1290         while (id in bucket)
1291         {
1292             id = uniform(0, int.max);
1293         }
1294 
1295         bucket[id] = QueryResponse.init;  // reserve it
1296     }
1297 
1298     return id;
1299 }
1300 
1301 
1302 // modifyChannel
1303 /++
1304     Modifies a channel's title or currently played game.
1305 
1306     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1307 
1308     Params:
1309         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1310         channelName = Name of channel to modify.
1311         title = Optional channel title to set.
1312         gameID = Optional game ID to set the channel as playing.
1313         caller = Name of the calling function.
1314  +/
1315 void modifyChannel(
1316     TwitchPlugin plugin,
1317     const string channelName,
1318     const string title,
1319     const string gameID,
1320     const string caller = __FUNCTION__)
1321 in (Fiber.getThis, "Tried to call `modifyChannel` from outside a Fiber")
1322 in (channelName.length, "Tried to modify a channel with an empty channel name string")
1323 in ((title.length || gameID.length), "Tried to modify a channel with no title nor game ID supplied")
1324 {
1325     import std.array : Appender;
1326 
1327     const room = channelName in plugin.rooms;
1328     assert(room, "Tried to modify a channel for which there existed no room");
1329 
1330     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
1331     immutable url = "https://api.twitch.tv/helix/channels?broadcaster_id=" ~ room.id;
1332 
1333     Appender!(char[]) sink;
1334     sink.reserve(128);
1335 
1336     sink.put('{');
1337 
1338     if (title.length)
1339     {
1340         sink.put(`"title":"`);
1341         sink.put(title);
1342         sink.put('"');
1343         if (gameID.length) sink.put(',');
1344     }
1345 
1346     if (gameID.length)
1347     {
1348         sink.put(`"game_id":"`);
1349         sink.put(gameID);
1350         sink.put('"');
1351     }
1352 
1353     sink.put('}');
1354 
1355     void modifyChannelDg()
1356     {
1357         cast(void)sendHTTPRequest(
1358             plugin,
1359             url,
1360             caller,
1361             authorizationBearer,
1362             HttpVerb.PATCH,
1363             cast(ubyte[])sink.data,
1364             "application/json");
1365     }
1366 
1367     return retryDelegate(plugin, &modifyChannelDg);
1368 }
1369 
1370 
1371 // getChannel
1372 /++
1373     Fetches information about a channel; its title, what game is being played,
1374     the channel tags, etc.
1375 
1376     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1377 
1378     Params:
1379         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1380         channelName = Name of channel to fetch information about.
1381  +/
1382 auto getChannel(
1383     TwitchPlugin plugin,
1384     const string channelName)
1385 in (Fiber.getThis, "Tried to call `getChannel` from outside a Fiber")
1386 in (channelName.length, "Tried to fetch a channel with an empty channel name string")
1387 {
1388     import std.algorithm.iteration : map;
1389     import std.array : array;
1390     import std.json : parseJSON;
1391 
1392     const room = channelName in plugin.rooms;
1393     assert(room, "Tried to look up a channel for which there existed no room");
1394 
1395     immutable url = "https://api.twitch.tv/helix/channels?broadcaster_id=" ~ room.id;
1396 
1397     static struct Channel
1398     {
1399         string gameIDString;
1400         string gameName;
1401         string[] tags;
1402         string title;
1403     }
1404 
1405     auto getChannelDg()
1406     {
1407         immutable gameDataJSON = getTwitchData(plugin, url);
1408 
1409         /+
1410         {
1411             "data": [
1412                 {
1413                     "broadcaster_id": "22216721",
1414                     "broadcaster_language": "en",
1415                     "broadcaster_login": "zorael",
1416                     "broadcaster_name": "zorael",
1417                     "delay": 0,
1418                     "game_id": "",
1419                     "game_name": "",
1420                     "tags": [],
1421                     "title": "bleph"
1422                 }
1423             ]
1424         }
1425          +/
1426 
1427         Channel channel;
1428         channel.gameIDString = gameDataJSON["game_id"].str;
1429         channel.gameName = gameDataJSON["game_name"].str;
1430         channel.tags = gameDataJSON["tags"].array
1431             .map!(tagJSON => tagJSON.str)
1432             .array;
1433         channel.title = gameDataJSON["title"].str;
1434         return channel;
1435     }
1436 
1437     return retryDelegate(plugin, &getChannelDg);
1438 }
1439 
1440 
1441 // getBroadcasterAuthorisation
1442 /++
1443     Returns a broadcaster-level "Bearer" authorisation token for a channel,
1444     where such exist.
1445 
1446     Params:
1447         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1448         channelName = Name of channel to return token for.
1449 
1450     Returns:
1451         A "Bearer" OAuth token string for use in HTTP headers.
1452 
1453     Throws:
1454         [MissingBroadcasterTokenException] if there were no broadcaster API token
1455         for the supplied channel in the secrets storage.
1456  +/
1457 auto getBroadcasterAuthorisation(TwitchPlugin plugin, const string channelName)
1458 in (channelName.length, "Tried to get broadcaster authorisation with an empty channel name string")
1459 {
1460     static string[string] authorizationByChannel;
1461 
1462     auto authorizationBearer = channelName in authorizationByChannel;
1463 
1464     if (!authorizationBearer)
1465     {
1466         if (const creds = channelName in plugin.secretsByChannel)
1467         {
1468             if (creds.broadcasterKey.length)
1469             {
1470                 authorizationByChannel[channelName] = "Bearer " ~ creds.broadcasterKey;
1471                 authorizationBearer = channelName in authorizationByChannel;
1472             }
1473         }
1474     }
1475 
1476     if (!authorizationBearer)
1477     {
1478         enum message = "Missing broadcaster key";
1479         throw new MissingBroadcasterTokenException(
1480             message,
1481             channelName,
1482             __FILE__);
1483     }
1484 
1485     return *authorizationBearer;
1486 }
1487 
1488 
1489 // startCommercial
1490 /++
1491     Starts a commercial in the specified channel.
1492 
1493     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1494 
1495     Params:
1496         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1497         channelName = Name of channel to run commercials for.
1498         lengthString = Length to play the commercial for, as a string.
1499         caller = Name of the calling function.
1500  +/
1501 void startCommercial(
1502     TwitchPlugin plugin,
1503     const string channelName,
1504     const string lengthString,
1505     const string caller = __FUNCTION__)
1506 in (Fiber.getThis, "Tried to call `startCommercial` from outside a Fiber")
1507 in (channelName.length, "Tried to start a commercial with an empty channel name string")
1508 {
1509     import std.format : format;
1510 
1511     const room = channelName in plugin.rooms;
1512     assert(room, "Tried to look up start commerical in a channel for which there existed no room");
1513 
1514     enum url = "https://api.twitch.tv/helix/channels/commercial";
1515     enum pattern = `
1516 {
1517     "broadcaster_id": "%s",
1518     "length": %s
1519 }`;
1520 
1521     immutable body_ = pattern.format(room.id, lengthString);
1522     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
1523 
1524     void startCommercialDg()
1525     {
1526         cast(void)sendHTTPRequest(
1527             plugin,
1528             url,
1529             caller,
1530             authorizationBearer,
1531             HttpVerb.POST,
1532             cast(ubyte[])body_,
1533             "application/json");
1534     }
1535 
1536     return retryDelegate(plugin, &startCommercialDg);
1537 }
1538 
1539 
1540 // getPolls
1541 /++
1542     Fetches information about polls in the specified channel. If an ID string is
1543     supplied, it will be included in the query, otherwise all `"ACTIVE"` polls
1544     are included in the returned JSON.
1545 
1546     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1547 
1548     Params:
1549         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1550         channelName = Name of channel to fetch polls for.
1551         idString = ID of a specific poll to get.
1552         caller = Name of the calling function.
1553 
1554     Returns:
1555         An arary of [std.json.JSONValue|JSONValue]s with all the matched polls.
1556  +/
1557 auto getPolls(
1558     TwitchPlugin plugin,
1559     const string channelName,
1560     const string idString = string.init,
1561     const string caller = __FUNCTION__)
1562 in (Fiber.getThis, "Tried to call `getPolls` from outside a Fiber")
1563 in (channelName.length, "Tried to get polls with an empty channel name string")
1564 {
1565     import std.json : JSONType, JSONValue, parseJSON;
1566 
1567     const room = channelName in plugin.rooms;
1568     assert(room, "Tried to get polls of a channel for which there existed no room");
1569 
1570     enum baseURL = "https://api.twitch.tv/helix/polls?broadcaster_id=";
1571     string url = baseURL ~ room.id;  // mutable;
1572     if (idString.length) url ~= "&id=" ~ idString;
1573 
1574     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
1575 
1576     auto getPollsDg()
1577     {
1578         JSONValue allPollsJSON;
1579         allPollsJSON = null;
1580         allPollsJSON.array = null;
1581 
1582         string after;
1583         uint retry;
1584 
1585         inner:
1586         do
1587         {
1588             immutable paginatedURL = after.length ?
1589                 (url ~ "&after=" ~ after) :
1590                 url;
1591             immutable response = sendHTTPRequest(
1592                 plugin,
1593                 paginatedURL,
1594                 caller,
1595                 authorizationBearer,
1596                 HttpVerb.GET,
1597                 cast(ubyte[])null,
1598                 "application/json");
1599             immutable responseJSON = parseJSON(response.str);
1600 
1601             if ((responseJSON.type != JSONType.object) || ("data" !in responseJSON))
1602             {
1603                 // Invalid response in some way
1604                 if (++retry < TwitchPlugin.delegateRetries) continue inner;
1605                 enum message = "`getPolls` response has unexpected JSON";
1606                 throw new UnexpectedJSONException(message, responseJSON);
1607             }
1608 
1609             retry = 0;
1610 
1611             /*
1612             {
1613                 "data": [
1614                     {
1615                     "id": "ed961efd-8a3f-4cf5-a9d0-e616c590cd2a",
1616                     "broadcaster_id": "55696719",
1617                     "broadcaster_name": "TwitchDev",
1618                     "broadcaster_login": "twitchdev",
1619                     "title": "Heads or Tails?",
1620                     "choices": [
1621                         {
1622                         "id": "4c123012-1351-4f33-84b7-43856e7a0f47",
1623                         "title": "Heads",
1624                         "votes": 0,
1625                         "channel_points_votes": 0,
1626                         "bits_votes": 0
1627                         },
1628                         {
1629                         "id": "279087e3-54a7-467e-bcd0-c1393fcea4f0",
1630                         "title": "Tails",
1631                         "votes": 0,
1632                         "channel_points_votes": 0,
1633                         "bits_votes": 0
1634                         }
1635                     ],
1636                     "bits_voting_enabled": false,
1637                     "bits_per_vote": 0,
1638                     "channel_points_voting_enabled": false,
1639                     "channel_points_per_vote": 0,
1640                     "status": "ACTIVE",
1641                     "duration": 1800,
1642                     "started_at": "2021-03-19T06:08:33.871278372Z"
1643                     }
1644                 ],
1645                 "pagination": {}
1646             }
1647             */
1648 
1649             foreach (const pollJSON; responseJSON["data"].array)
1650             {
1651                 if (pollJSON["status"].str != "ACTIVE") continue;
1652                 allPollsJSON.array ~= pollJSON;
1653             }
1654 
1655             after = responseJSON["after"].str;
1656         }
1657         while (after.length);
1658 
1659         return allPollsJSON.array;
1660     }
1661 
1662     return retryDelegate(plugin, &getPollsDg);
1663 }
1664 
1665 
1666 // createPoll
1667 /++
1668     Creates a Twitch poll in the specified channel.
1669 
1670     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1671 
1672     Params:
1673         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1674         channelName = Name of channel to create the poll in.
1675         title = Poll title.
1676         durationString = How long the poll should run for in seconds (as a string).
1677         choices = A string array of poll choices.
1678         caller = Name of the calling function.
1679 
1680     Returns:
1681         An array of [std.json.JSONValue|JSONValue]s with
1682         the response returned when creating the poll. On failure, an empty
1683         [std.json.JSONValue|JSONValue] is instead returned.
1684 
1685     Throws:
1686         [UnexpectedJSONException] on unexpected JSON.
1687  +/
1688 auto createPoll(
1689     TwitchPlugin plugin,
1690     const string channelName,
1691     const string title,
1692     const string durationString,
1693     const string[] choices,
1694     const string caller = __FUNCTION__)
1695 in (Fiber.getThis, "Tried to call `createPoll` from outside a Fiber")
1696 in (channelName.length, "Tried to create a poll with an empty channel name string")
1697 {
1698     import std.array : Appender, replace;
1699     import std.format : format;
1700     import std.json : JSONType, parseJSON;
1701 
1702     const room = channelName in plugin.rooms;
1703     assert(room, "Tried to create a poll in a channel for which there existed no room");
1704 
1705     enum url = "https://api.twitch.tv/helix/polls";
1706     enum pattern = `
1707 {
1708     "broadcaster_id": "%s",
1709     "title": "%s",
1710     "choices":[
1711 %s
1712     ],
1713     "duration": %s
1714 }`;
1715 
1716     Appender!(char[]) sink;
1717     sink.reserve(256);
1718 
1719     foreach (immutable i, immutable choice; choices)
1720     {
1721         if (i > 0) sink.put(',');
1722         sink.put(`{"title":"`);
1723         sink.put(choice.replace(`"`, `\"`));
1724         sink.put(`"}`);
1725     }
1726 
1727     immutable escapedTitle = title.replace(`"`, `\"`);
1728     immutable body_ = pattern.format(room.id, escapedTitle, sink.data, durationString);
1729     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
1730 
1731     auto createPollDg()
1732     {
1733         immutable response = sendHTTPRequest(
1734             plugin,
1735             url,
1736             caller,
1737             authorizationBearer,
1738             HttpVerb.POST,
1739             cast(ubyte[])body_,
1740             "application/json");
1741         immutable responseJSON = parseJSON(response.str);
1742 
1743         /*
1744         {
1745             "data": [
1746                 {
1747                 "id": "ed961efd-8a3f-4cf5-a9d0-e616c590cd2a",
1748                 "broadcaster_id": "141981764",
1749                 "broadcaster_name": "TwitchDev",
1750                 "broadcaster_login": "twitchdev",
1751                 "title": "Heads or Tails?",
1752                 "choices": [
1753                     {
1754                     "id": "4c123012-1351-4f33-84b7-43856e7a0f47",
1755                     "title": "Heads",
1756                     "votes": 0,
1757                     "channel_points_votes": 0,
1758                     "bits_votes": 0
1759                     },
1760                     {
1761                     "id": "279087e3-54a7-467e-bcd0-c1393fcea4f0",
1762                     "title": "Tails",
1763                     "votes": 0,
1764                     "channel_points_votes": 0,
1765                     "bits_votes": 0
1766                     }
1767                 ],
1768                 "bits_voting_enabled": false,
1769                 "bits_per_vote": 0,
1770                 "channel_points_voting_enabled": true,
1771                 "channel_points_per_vote": 100,
1772                 "status": "ACTIVE",
1773                 "duration": 1800,
1774                 "started_at": "2021-03-19T06:08:33.871278372Z"
1775                 }
1776             ]
1777         }
1778         */
1779 
1780         if ((responseJSON.type != JSONType.object) || ("data" !in responseJSON))
1781         {
1782             // Invalid response in some way
1783             enum message = "`createPoll` response has unexpected JSON";
1784             throw new UnexpectedJSONException(message, responseJSON);
1785         }
1786 
1787         return responseJSON["data"].array;
1788     }
1789 
1790     return retryDelegate(plugin, &createPollDg);
1791 }
1792 
1793 
1794 // endPoll
1795 /++
1796     Ends a Twitch poll, putting it in either a `"TERMINATED"` or `"ARCHIVED"` state.
1797 
1798     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
1799 
1800     Params:
1801         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1802         channelName = Name of channel whose poll to end.
1803         voteID = ID of the specific vote to end.
1804         terminate = If set, ends the poll by putting it in a `"TERMINATED"` state.
1805             If unset, ends it in an `"ARCHIVED"` way.
1806         caller = Name of the calling function.
1807 
1808     Returns:
1809         The [std.json.JSONValue|JSONValue] of the first response returned when ending the poll.
1810 
1811     Throws:
1812         [UnexpectedJSONException] on unexpected JSON.
1813  +/
1814 auto endPoll(
1815     TwitchPlugin plugin,
1816     const string channelName,
1817     const string voteID,
1818     const Flag!"terminate" terminate,
1819     const string caller = __FUNCTION__)
1820 in (Fiber.getThis, "Tried to call `endPoll` from outside a Fiber")
1821 in (channelName.length, "Tried to end a poll with an empty channel name string")
1822 {
1823     import std.format : format;
1824     import std.json : JSONType, parseJSON;
1825 
1826     const room = channelName in plugin.rooms;
1827     assert(room, "Tried to end a poll in a channel for which there existed no room");
1828 
1829     enum url = "https://api.twitch.tv/helix/polls";
1830     enum pattern = `
1831 {
1832     "broadcaster_id": "%s",
1833     "id": "%s",
1834     "status": "%s"
1835 }`;
1836 
1837     immutable status = terminate ? "TERMINATED" : "ARCHIVED";
1838     immutable body_ = pattern.format(room.id, voteID, status);
1839     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
1840 
1841     auto endPollDg()
1842     {
1843         immutable response = sendHTTPRequest(
1844             plugin,
1845             url,
1846             caller,
1847             authorizationBearer,
1848             HttpVerb.PATCH,
1849             cast(ubyte[])body_,
1850             "application/json");
1851         immutable responseJSON = parseJSON(response.str);
1852 
1853         /*
1854         {
1855             "data": [
1856                 {
1857                 "id": "ed961efd-8a3f-4cf5-a9d0-e616c590cd2a",
1858                 "broadcaster_id": "141981764",
1859                 "broadcaster_name": "TwitchDev",
1860                 "broadcaster_login": "twitchdev",
1861                 "title": "Heads or Tails?",
1862                 "choices": [
1863                     {
1864                     "id": "4c123012-1351-4f33-84b7-43856e7a0f47",
1865                     "title": "Heads",
1866                     "votes": 0,
1867                     "channel_points_votes": 0,
1868                     "bits_votes": 0
1869                     },
1870                     {
1871                     "id": "279087e3-54a7-467e-bcd0-c1393fcea4f0",
1872                     "title": "Tails",
1873                     "votes": 0,
1874                     "channel_points_votes": 0,
1875                     "bits_votes": 0
1876                     }
1877                 ],
1878                 "bits_voting_enabled": false,
1879                 "bits_per_vote": 0,
1880                 "channel_points_voting_enabled": true,
1881                 "channel_points_per_vote": 100,
1882                 "status": "TERMINATED",
1883                 "duration": 1800,
1884                 "started_at": "2021-03-19T06:08:33.871278372Z",
1885                 "ended_at": "2021-03-19T06:11:26.746889614Z"
1886                 }
1887             ]
1888         }
1889         */
1890 
1891         if ((responseJSON.type != JSONType.object) || ("data" !in responseJSON))
1892         {
1893             // Invalid response in some way
1894             enum message = "`endPoll` response has unexpected JSON";
1895             throw new UnexpectedJSONException(message, responseJSON);
1896         }
1897 
1898         return responseJSON["data"].array[0];
1899     }
1900 
1901     return retryDelegate(plugin, &endPollDg);
1902 }
1903 
1904 
1905 // getBotList
1906 /++
1907     Fetches a list of known (online) bots from TwitchInsights.net.
1908 
1909     With this we don't have to keep a static list of known bots to exclude when
1910     counting chatters.
1911 
1912     Params:
1913         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
1914 
1915     Returns:
1916         A `string[]` array of online bot account names.
1917 
1918     Throws:
1919         [TwitchQueryException] on unexpected JSON.
1920 
1921     See_Also:
1922         https://twitchinsights.net/bots
1923  +/
1924 auto getBotList(TwitchPlugin plugin, const string caller = __FUNCTION__)
1925 {
1926     import std.algorithm.searching : endsWith;
1927     import std.array : Appender;
1928     import std.json : JSONType, parseJSON;
1929 
1930     auto getBotListDg()
1931     {
1932         enum url = "https://api.twitchinsights.net/v1/bots/online";
1933         immutable response = sendHTTPRequest(plugin, url, caller);
1934         immutable responseJSON = parseJSON(response.str);
1935 
1936         /*
1937         {
1938             "_total": 78,
1939             "bots": [
1940                 [
1941                     "commanderroot",
1942                     55158,
1943                     1664543800
1944                 ],
1945                 [
1946                     "alexisthenexis",
1947                     54928,
1948                     1664543800
1949                 ],
1950                 [
1951                     "anna_banana_10",
1952                     54636,
1953                     1664543800
1954                 ],
1955                 [
1956                     "sophiafox21",
1957                     54587,
1958                     1664543800
1959                 ]
1960             ]
1961         }
1962         */
1963 
1964         if ((responseJSON.type != JSONType.object) || ("bots" !in responseJSON))
1965         {
1966             // Invalid response in some way, retry until we reach the limit
1967             enum message = "`getBotList` response has unexpected JSON";
1968             throw new UnexpectedJSONException(message, responseJSON);
1969         }
1970 
1971         Appender!(string[]) sink;
1972         sink.reserve(responseJSON["_total"].integer);
1973 
1974         foreach (const botEntryJSON; responseJSON["bots"].array)
1975         {
1976             /*
1977             [
1978                 "commanderroot",
1979                 55158,
1980                 1664543800
1981             ]
1982             */
1983 
1984             immutable botAccountName = botEntryJSON.array[0].str;
1985 
1986             if (!botAccountName.endsWith("bot"))
1987             {
1988                 // Only add bots whose names don't end with "bot", since we automatically filter those
1989                 sink.put(botAccountName);
1990             }
1991         }
1992 
1993         return sink.data;
1994     }
1995 
1996     return retryDelegate(plugin, &getBotListDg);
1997 }
1998 
1999 
2000 // getStream
2001 /++
2002     Fetches information about an ongoing stream.
2003 
2004     Params:
2005         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2006         loginName = Account name of user whose stream to fetch information of.
2007 
2008     Returns:
2009         A [kameloso.plugins.twitch.base.TwitchPlugin.Room.Stream|Room.Stream]
2010         populated with all (relevant) information.
2011  +/
2012 auto getStream(TwitchPlugin plugin, const string loginName)
2013 in (loginName.length, "Tried to get a stream with an empty login name string")
2014 {
2015     import std.algorithm.iteration : map;
2016     import std.array : array;
2017     import std.datetime.systime : SysTime;
2018 
2019     immutable streamURL = "https://api.twitch.tv/helix/streams?user_login=" ~ loginName;
2020 
2021     auto getStreamDg()
2022     {
2023         try
2024         {
2025             immutable streamJSON = getTwitchData(plugin, streamURL);
2026 
2027             /*
2028             {
2029                 "data": [
2030                     {
2031                         "game_id": "506415",
2032                         "game_name": "Sekiro: Shadows Die Twice",
2033                         "id": "47686742845",
2034                         "is_mature": false,
2035                         "language": "en",
2036                         "started_at": "2022-12-26T16:47:58Z",
2037                         "tag_ids": [
2038                             "6ea6bca4-4712-4ab9-a906-e3336a9d8039"
2039                         ],
2040                         "tags": [
2041                             "darksouls",
2042                             "voiceactor",
2043                             "challengerunner",
2044                             "chill",
2045                             "rpg",
2046                             "survival",
2047                             "creativeprofanity",
2048                             "simlish",
2049                             "English"
2050                         ],
2051                         "thumbnail_url": "https:\/\/static-cdn.jtvnw.net\/previews-ttv\/live_user_lobosjr-{width}x{height}.jpg",
2052                         "title": "it's been so long! | fresh run",
2053                         "type": "live",
2054                         "user_id": "28640725",
2055                         "user_login": "lobosjr",
2056                         "user_name": "LobosJr",
2057                         "viewer_count": 2341
2058                     }
2059                 ],
2060                 "pagination": {
2061                     "cursor": "eyJiIjp7IkN1cnNvciI6ImV5SnpJam95TXpReExqUTBOelV3T1RZMk9URXdORFFzSW1RaU9tWmhiSE5sTENKMElqcDBjblZsZlE9PSJ9LCJhIjp7IkN1cnNvciI6IiJ9fQ"
2062                 }
2063             }
2064             */
2065             /*
2066             {
2067                 "data": [],
2068                 "pagination": {}
2069             }
2070             */
2071 
2072             auto stream = TwitchPlugin.Room.Stream(streamJSON["id"].str);
2073             stream.live = true;
2074             stream.userIDString = streamJSON["user_id"].str;
2075             stream.userLogin = streamJSON["user_login"].str;
2076             stream.userDisplayName = streamJSON["user_name"].str;
2077             stream.gameIDString = streamJSON["game_id"].str;
2078             stream.gameName = streamJSON["game_name"].str;
2079             stream.title = streamJSON["title"].str;
2080             stream.startTime = SysTime.fromISOExtString(streamJSON["started_at"].str);
2081             stream.viewerCount = streamJSON["viewer_count"].integer;
2082             stream.tags = streamJSON["tags"].array
2083                 .map!(tag => tag.str)
2084                 .array;
2085             return stream;
2086         }
2087         catch (EmptyDataJSONException _)
2088         {
2089             // Stream is down
2090             return TwitchPlugin.Room.Stream.init;
2091         }
2092         catch (Exception e)
2093         {
2094             throw e;
2095         }
2096     }
2097 
2098     return retryDelegate(plugin, &getStreamDg);
2099 }
2100 
2101 
2102 // getBTTVEmotes
2103 /++
2104     Fetches BetterTTV emotes for a given channel.
2105 
2106     Params:
2107         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2108         emoteMap = Reference to the `bool[dstring]` associative array to store
2109             the fetched emotes in.
2110         idString = Twitch user/channel ID in string form.
2111         caller = Name of the calling function.
2112 
2113     See_Also:
2114         https://betterttv.com
2115  +/
2116 void getBTTVEmotes(
2117     TwitchPlugin plugin,
2118     ref bool[dstring] emoteMap,
2119     const string idString,
2120     const string caller = __FUNCTION__)
2121 in (Fiber.getThis, "Tried to call `getBTTVEmotes` from outside a Fiber")
2122 in (idString.length, "Tried to get BTTV emotes with an empty ID string")
2123 {
2124     import std.conv : to;
2125     import std.json : JSONType, parseJSON;
2126 
2127     immutable url = "https://api.betterttv.net/3/cached/users/twitch/" ~ idString;
2128 
2129     auto getBTTVEmotesDg()
2130     {
2131         try
2132         {
2133             immutable response = sendHTTPRequest(plugin, url, caller);
2134             immutable responseJSON = parseJSON(response.str);
2135 
2136             /+
2137             {
2138                 "avatar": "https:\/\/static-cdn.jtvnw.net\/jtv_user_pictures\/lobosjr-profile_image-b5e3a6c3556aed54-300x300.png",
2139                 "bots": [
2140                     "lobotjr",
2141                     "dumj01"
2142                 ],
2143                 "channelEmotes": [
2144                     {
2145                         "animated": false,
2146                         "code": "FeelsDennyMan",
2147                         "id": "58a9cde206e70d0465b2b47e",
2148                         "imageType": "png",
2149                         "userId": "5575430f9cd396156bd1430c"
2150                     },
2151                     {
2152                         "animated": true,
2153                         "code": "lobosSHAKE",
2154                         "id": "5b007dc718b2f46a14d40242",
2155                         "imageType": "gif",
2156                         "userId": "5575430f9cd396156bd1430c"
2157                     }
2158                 ],
2159                 "id": "5575430f9cd396156bd1430c",
2160                 "sharedEmotes": [
2161                     {
2162                         "animated": true,
2163                         "code": "(ditto)",
2164                         "id": "554da1a289d53f2d12781907",
2165                         "imageType": "gif",
2166                         "user": {
2167                             "displayName": "NightDev",
2168                             "id": "5561169bd6b9d206222a8c19",
2169                             "name": "nightdev",
2170                             "providerId": "29045896"
2171                         }
2172                     },
2173                     {
2174                         "animated": true,
2175                         "code": "WolfPls",
2176                         "height": 28,
2177                         "id": "55fdff6e7a4f04b172c506c0",
2178                         "imageType": "gif",
2179                         "user": {
2180                             "displayName": "bearzly",
2181                             "id": "5573551240fa91166bb18c67",
2182                             "name": "bearzly",
2183                             "providerId": "23239904"
2184                         },
2185                         "width": 21
2186                     }
2187                 ]
2188             }
2189              +/
2190 
2191             if (responseJSON.type != JSONType.object)
2192             {
2193                 enum message = "`getBTTVEmotes` response has unexpected JSON " ~
2194                     "(response is wrong type)";
2195                 throw new UnexpectedJSONException(message, responseJSON);
2196             }
2197 
2198             immutable channelEmotesJSON = "channelEmotes" in responseJSON;
2199             immutable sharedEmotesJSON = "sharedEmotes" in responseJSON;
2200 
2201             foreach (const emoteJSON; channelEmotesJSON.array)
2202             {
2203                 immutable emote = emoteJSON["code"].str.to!dstring;
2204                 emoteMap[emote] = true;
2205             }
2206 
2207             foreach (const emoteJSON; sharedEmotesJSON.array)
2208             {
2209                 immutable emote = emoteJSON["code"].str.to!dstring;
2210                 emoteMap[emote] = true;
2211             }
2212 
2213             // All done
2214         }
2215         catch (ErrorJSONException e)
2216         {
2217             if (e.json.type == JSONType.object)
2218             {
2219                 const messageJSON = "message" in e.json;
2220 
2221                 if (messageJSON && (messageJSON.str == "user not found"))
2222                 {
2223                     // Benign
2224                     return;
2225                 }
2226                 // Drop down
2227             }
2228             throw e;
2229         }
2230         catch (Exception e)
2231         {
2232             throw e;
2233         }
2234     }
2235 
2236     return retryDelegate(plugin, &getBTTVEmotesDg);
2237 }
2238 
2239 
2240 // getBTTVGlobalEmotes
2241 /++
2242     Fetches globalBetterTTV emotes.
2243 
2244     Params:
2245         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2246         emoteMap = Reference to the `bool[dstring]` associative array to store
2247             the fetched emotes in.
2248         caller = Name of the calling function.
2249 
2250     See_Also:
2251         https://betterttv.com/emotes/global
2252  +/
2253 void getBTTVGlobalEmotes(
2254     TwitchPlugin plugin,
2255     ref bool[dstring] emoteMap,
2256     const string caller = __FUNCTION__)
2257 in (Fiber.getThis, "Tried to call `getBTTVGlobalEmotes` from outside a Fiber")
2258 {
2259     import std.conv : to;
2260     import std.json : parseJSON;
2261 
2262     void getBTTVGlobalEmotesDg()
2263     {
2264         enum url = "https://api.betterttv.net/3/cached/emotes/global";
2265 
2266         immutable response = sendHTTPRequest(plugin, url, caller);
2267         immutable responseJSON = parseJSON(response.str);
2268 
2269         /+
2270         [
2271             {
2272                 "animated": false,
2273                 "code": ":tf:",
2274                 "id": "54fa8f1401e468494b85b537",
2275                 "imageType": "png",
2276                 "userId": "5561169bd6b9d206222a8c19"
2277             },
2278             {
2279                 "animated": false,
2280                 "code": "CiGrip",
2281                 "id": "54fa8fce01e468494b85b53c",
2282                 "imageType": "png",
2283                 "userId": "5561169bd6b9d206222a8c19"
2284             }
2285         ]
2286          +/
2287 
2288         foreach (immutable emoteJSON; responseJSON.array)
2289         {
2290             immutable emote = emoteJSON["code"].str.to!dstring;
2291             emoteMap[emote] = true;
2292         }
2293 
2294         // All done
2295     }
2296 
2297     return retryDelegate(plugin, &getBTTVGlobalEmotesDg);
2298 }
2299 
2300 
2301 // getFFZEmotes
2302 /++
2303     Fetches FrankerFaceZ emotes for a given channel.
2304 
2305     Params:
2306         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2307         emoteMap = Reference to the `bool[dstring]` associative array to store
2308             the fetched emotes in.
2309         idString = Twitch user/channel ID in string form.
2310         caller = Name of the calling function.
2311 
2312     See_Also:
2313         https://www.frankerfacez.com
2314  +/
2315 void getFFZEmotes(
2316     TwitchPlugin plugin,
2317     ref bool[dstring] emoteMap,
2318     const string idString,
2319     const string caller = __FUNCTION__)
2320 in (Fiber.getThis, "Tried to call `getFFZEmotes` from outside a Fiber")
2321 in (idString.length, "Tried to get FFZ emotes with an empty ID string")
2322 {
2323     import std.conv : to;
2324     import std.json : JSONType, parseJSON;
2325 
2326     immutable url = "https://api.frankerfacez.com/v1/room/id/" ~ idString;
2327 
2328     auto getFFZEmotesDg()
2329     {
2330         try
2331         {
2332             immutable response = sendHTTPRequest(plugin, url, caller);
2333             immutable responseJSON = parseJSON(response.str);
2334 
2335             /+
2336             {
2337                 "room": {
2338                     "_id": 366358,
2339                     "css": null,
2340                     "display_name": "GinoMachino",
2341                     "id": "ginomachino",
2342                     "is_group": false,
2343                     "mod_urls": null,
2344                     "moderator_badge": null,
2345                     "set": 366370,
2346                     "twitch_id": 148651829,
2347                     "user_badge_ids": {
2348                         "2": [
2349                             188355608
2350                         ]
2351                     },
2352                     "user_badges": {
2353                         "2": [
2354                             "machinobot"
2355                         ]
2356                     },
2357                     "vip_badge": null,
2358                     "youtube_id": null
2359                 },
2360                 "sets": {
2361                     "366370": {
2362                         "_type": 1,
2363                         "css": null,
2364                         "emoticons": [
2365                             {
2366                                 "created_at": "2016-11-02T14:52:50.395Z",
2367                                 "css": null,
2368                                 "height": 32,
2369                                 "hidden": false,
2370                                 "id": 139407,
2371                                 "last_updated": "2016-11-08T21:26:39.377Z",
2372                                 "margins": null,
2373                                 "modifier": false,
2374                                 "name": "LULW",
2375                                 "offset": null,
2376                                 "owner": {
2377                                     "_id": 53544,
2378                                     "display_name": "Ian678",
2379                                     "name": "ian678"
2380                                 },
2381                                 "public": true,
2382                                 "status": 1,
2383                                 "urls": {
2384                                     "1": "\/\/cdn.frankerfacez.com\/emote\/139407\/1",
2385                                     "2": "\/\/cdn.frankerfacez.com\/emote\/139407\/2",
2386                                     "4": "\/\/cdn.frankerfacez.com\/emote\/139407\/4"
2387                                 },
2388                                 "usage_count": 148783,
2389                                 "width": 28
2390                             },
2391                             {
2392                                 "created_at": "2018-11-12T16:03:21.331Z",
2393                                 "css": null,
2394                                 "height": 23,
2395                                 "hidden": false,
2396                                 "id": 295554,
2397                                 "last_updated": "2018-11-15T08:31:33.401Z",
2398                                 "margins": null,
2399                                 "modifier": false,
2400                                 "name": "WhiteKnight",
2401                                 "offset": null,
2402                                 "owner": {
2403                                     "_id": 333730,
2404                                     "display_name": "cccclone",
2405                                     "name": "cccclone"
2406                                 },
2407                                 "public": true,
2408                                 "status": 1,
2409                                 "urls": {
2410                                     "1": "\/\/cdn.frankerfacez.com\/emote\/295554\/1",
2411                                     "2": "\/\/cdn.frankerfacez.com\/emote\/295554\/2",
2412                                     "4": "\/\/cdn.frankerfacez.com\/emote\/295554\/4"
2413                                 },
2414                                 "usage_count": 35,
2415                                 "width": 20
2416                             }
2417                         ],
2418                         "icon": null,
2419                         "id": 366370,
2420                         "title": "Channel: GinoMachino"
2421                     }
2422                 }
2423             }
2424              +/
2425 
2426             if (responseJSON.type == JSONType.object)
2427             {
2428                 if (immutable setsJSON = "sets" in responseJSON)
2429                 {
2430                     foreach (immutable setJSON; (*setsJSON).object)
2431                     {
2432                         if (immutable emoticonsArrayJSON = "emoticons" in setJSON)
2433                         {
2434                             foreach (immutable emoteJSON; (*emoticonsArrayJSON).array)
2435                             {
2436                                 immutable emote = emoteJSON["name"].str.to!dstring;
2437                                 emoteMap[emote] = true;
2438                             }
2439 
2440                             // All done
2441                             return;
2442                         }
2443                     }
2444                 }
2445             }
2446 
2447             // Invalid response in some way
2448             enum message = "`getFFZEmotes` response has unexpected JSON";
2449             throw new UnexpectedJSONException(message, responseJSON);
2450         }
2451         catch (ErrorJSONException e)
2452         {
2453             // Likely 404
2454             const messageJSON = "message" in e.json;
2455 
2456             if (messageJSON && (messageJSON.str == "No such room"))
2457             {
2458                 // Benign
2459                 return;
2460             }
2461             throw e;
2462         }
2463         catch (Exception e)
2464         {
2465             throw e;
2466         }
2467     }
2468 
2469     return retryDelegate(plugin, &getFFZEmotesDg);
2470 }
2471 
2472 
2473 // get7tvEmotes
2474 /++
2475     Fetches 7tv emotes for a given channel.
2476 
2477     Params:
2478         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2479         emoteMap = Reference to the `bool[dstring]` associative array to store
2480             the fetched emotes in.
2481         idString = Twitch user/channel ID in string form.
2482         caller = Name of the calling function.
2483 
2484     See_Also:
2485         https://7tv.app
2486  +/
2487 void get7tvEmotes(
2488     TwitchPlugin plugin,
2489     ref bool[dstring] emoteMap,
2490     const string idString,
2491     const string caller = __FUNCTION__)
2492 in (Fiber.getThis, "Tried to call `get7tvEmotes` from outside a Fiber")
2493 in (idString.length, "Tried to get 7tv emotes with an empty ID string")
2494 {
2495     import std.conv : to;
2496     import std.json : JSONType, parseJSON;
2497 
2498     immutable url = "https://api.7tv.app/v2/users/" ~ idString ~ "/emotes";
2499 
2500     auto get7tvEmotesDg()
2501     {
2502         try
2503         {
2504             immutable response = sendHTTPRequest(plugin, url, caller);
2505             immutable responseJSON = parseJSON(response.str);
2506 
2507             /+
2508             [
2509                 {
2510                     "animated": false,
2511                     "code": ":tf:",
2512                     "id": "54fa8f1401e468494b85b537",
2513                     "imageType": "png",
2514                     "userId": "5561169bd6b9d206222a8c19"
2515                 },
2516                 {
2517                     "animated": false,
2518                     "code": "CiGrip",
2519                     "id": "54fa8fce01e468494b85b53c",
2520                     "imageType": "png",
2521                     "userId": "5561169bd6b9d206222a8c19"
2522                 }
2523             ]
2524              +/
2525 
2526             if (responseJSON.type == JSONType.array)
2527             {
2528                 foreach (immutable emoteJSON; responseJSON.array)
2529                 {
2530                     immutable emote = emoteJSON["name"].str.to!dstring;
2531                     emoteMap[emote] = true;
2532                 }
2533 
2534                 // All done
2535                 return;
2536             }
2537 
2538             // Invalid response in some way
2539             enum message = "`get7tvEmotes` response has unexpected JSON " ~
2540                 "(response is not object nor array)";
2541             throw new UnexpectedJSONException(message, responseJSON);
2542         }
2543         catch (ErrorJSONException e)
2544         {
2545             if (const errorJSON = "error" in e.json)
2546             {
2547                 if ((errorJSON.str == "No Items Found") ||
2548                     (errorJSON.str == "Unknown Emote Set"))
2549                 {
2550                     // Benign
2551                     return;
2552                 }
2553             }
2554             throw e;
2555         }
2556         catch (Exception e)
2557         {
2558             throw e;
2559         }
2560     }
2561 
2562     return retryDelegate(plugin, &get7tvEmotesDg);
2563 }
2564 
2565 
2566 // get7tvGlobalEmotes
2567 /++
2568     Fetches 7tv emotes.
2569 
2570     Params:
2571         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2572         emoteMap = Reference to the `bool[dstring]` associative array to store
2573             the fetched emotes in.
2574         caller = Name of the calling function.
2575 
2576     See_Also:
2577         https://7tv.app
2578  +/
2579 void get7tvGlobalEmotes(
2580     TwitchPlugin plugin,
2581     ref bool[dstring] emoteMap,
2582     const string caller = __FUNCTION__)
2583 in (Fiber.getThis, "Tried to call `get7tvGlobalEmotes` from outside a Fiber")
2584 {
2585     import std.conv : to;
2586     import std.json : parseJSON;
2587 
2588     void get7tvGlobalEmotesDg()
2589     {
2590         enum url = "https://api.7tv.app/v2/emotes/global";
2591 
2592         immutable response = sendHTTPRequest(plugin, url, caller);
2593         immutable responseJSON = parseJSON(response.str);
2594 
2595         /+
2596         [
2597             {
2598                 "height": [],
2599                 "id": "60421fe677137b000de9e683",
2600                 "mime": "image\/webp",
2601                 "name": "reckH",
2602                 "owner": {},
2603                 "status": 3,
2604                 "tags": [],
2605                 "urls": [],
2606                 "visibility": 2,
2607                 "visibility_simple": [],
2608                 "width": []
2609             },
2610             [...]
2611         ]
2612          +/
2613 
2614         foreach (const emoteJSON; responseJSON.array)
2615         {
2616             immutable emote = emoteJSON["name"].str.to!dstring;
2617             emoteMap[emote] = true;
2618         }
2619 
2620         // All done
2621     }
2622 
2623     return retryDelegate(plugin, &get7tvGlobalEmotesDg);
2624 }
2625 
2626 
2627 // getSubscribers
2628 /++
2629     Fetches a list of all subscribers of the specified channel. A broadcaster-level
2630     access token is required.
2631 
2632     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
2633 
2634     Params:
2635         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2636         channelName = Name of channel to fetch subscribers of.
2637         caller = Name of the calling function.
2638 
2639     Returns:
2640         An array of Voldemort subscribers.
2641  +/
2642 version(none)
2643 auto getSubscribers(
2644     TwitchPlugin plugin,
2645     const string channelName,
2646     const string caller = __FUNCTION__)
2647 in (Fiber.getThis, "Tried to call `getSubscribers` from outside a Fiber")
2648 in (channelName.length, "Tried to get subscribers with an empty channel name string")
2649 {
2650     import std.array : Appender;
2651     import std.format : format;
2652     import std.json : JSONType, parseJSON;
2653 
2654     const room = channelName in plugin.rooms;
2655     assert(room, "Tried to get subscribers of a channel for which there existed no room");
2656 
2657     immutable authorizationBearer = getBroadcasterAuthorisation(plugin, channelName);
2658 
2659     auto getSubscribersDg()
2660     {
2661         static struct User
2662         {
2663             string id;
2664             string name;
2665             string displayName;
2666         }
2667 
2668         static struct Subscription
2669         {
2670             User user;
2671             User gifter;
2672             bool wasGift;
2673         }
2674 
2675         enum url = "https://api.twitch.tv/helix/subscribers";
2676         enum initialPattern = `
2677 {
2678     "broadcaster_id": "%s",
2679     "first": "100"
2680 }`;
2681 
2682     enum subsequentPattern = `
2683 {
2684     "broadcaster_id": "%s",
2685     "after": "%s",
2686 }`;
2687 
2688         Appender!(Subscription[]) subs;
2689         string after;
2690         uint retry;
2691 
2692         inner:
2693         do
2694         {
2695             immutable body_ = after.length ?
2696                 subsequentPattern.format(room.id, after) :
2697                 initialPattern.format(room.id);
2698             immutable response = sendHTTPRequest(
2699                 plugin,
2700                 url,
2701                 caller,
2702                 authorizationBearer,
2703                 HttpVerb.GET,
2704                 cast(ubyte[])body_,
2705                 "application/json");
2706             immutable responseJSON = parseJSON(response.str);
2707 
2708             /*
2709             {
2710                 "data": [
2711                     {
2712                         "broadcaster_id": "141981764",
2713                         "broadcaster_login": "twitchdev",
2714                         "broadcaster_name": "TwitchDev",
2715                         "gifter_id": "12826",
2716                         "gifter_login": "twitch",
2717                         "gifter_name": "Twitch",
2718                         "is_gift": true,
2719                         "tier": "1000",
2720                         "plan_name": "Channel Subscription (twitchdev)",
2721                         "user_id": "527115020",
2722                         "user_name": "twitchgaming",
2723                         "user_login": "twitchgaming"
2724                     },
2725                 ],
2726                 "pagination": {
2727                     "cursor": "xxxx"
2728                 },
2729                 "total": 13,
2730                 "points": 13
2731             }
2732             */
2733 
2734             if ((responseJSON.type != JSONType.object) || ("data" !in responseJSON))
2735             {
2736                 // Invalid response in some way
2737                 if (++retry < TwitchPlugin.delegateRetries) continue inner;
2738                 enum message = "`getSubscribers` response has unexpected JSON";
2739                 throw new UnexpectedJSONException(message, responseJSON);
2740             }
2741 
2742             retry = 0;
2743 
2744             if (!subs.capacity)
2745             {
2746                 subs.reserve(responseJSON["total"].integer);
2747             }
2748 
2749             foreach (immutable subJSON; responseJSON["data"].array)
2750             {
2751                 Subscription sub;
2752                 sub.user.id = subJSON["user_id"].str;
2753                 sub.user.name = subJSON["user_login"].str;
2754                 sub.user.displayName = subJSON["user_name"].str;
2755                 sub.wasGift = subJSON["is_gift"].boolean;
2756                 sub.gifter.id = subJSON["gifter_id"].str;
2757                 sub.gifter.name = subJSON["gifter_login"].str;
2758                 sub.gifter.displayName = subJSON["gifter_name"].str;
2759                 subs.put(sub);
2760             }
2761 
2762             immutable paginationJSON = "pagination" in responseJSON;
2763             if (!paginationJSON) break;
2764 
2765             immutable cursorJSON = "cursor" in *paginationJSON;
2766             if (!cursorJSON) break;
2767 
2768             after = cursorJSON.str;
2769         }
2770         while (after.length);
2771 
2772         return subs;
2773     }
2774 
2775     return retryDelegate(plugin, &getSubscribersDg);
2776 }
2777 
2778 
2779 // createShoutout
2780 /++
2781     Prepares a `Shoutout` Voldemort struct with information needed to compose a shoutout.
2782 
2783     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
2784 
2785     Params:
2786         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
2787         login = Login name of other streamer to prepare a shoutout for.
2788 
2789     Returns:
2790         Voldemort `Shoutout` struct.
2791  +/
2792 auto createShoutout(
2793     TwitchPlugin plugin,
2794     const string login)
2795 in (Fiber.getThis, "Tried to call `createShoutout` from outside a Fiber")
2796 in (login.length, "Tried to create a shoutout with an empty login name string")
2797 {
2798     import std.json : JSONType;
2799 
2800     static struct Shoutout
2801     {
2802         enum State
2803         {
2804             success,
2805             noSuchUser,
2806             noChannel,
2807             otherError,
2808         }
2809 
2810         State state;
2811         string displayName;
2812         string gameName;
2813     }
2814 
2815     auto shoutoutDg()
2816     {
2817         Shoutout shoutout;
2818 
2819         try
2820         {
2821             immutable userURL = "https://api.twitch.tv/helix/users?login=" ~ login;
2822             immutable userJSON = getTwitchData(plugin, userURL);
2823             immutable id = userJSON["id"].str;
2824             //immutable login = userJSON["login"].str;
2825             immutable channelURL = "https://api.twitch.tv/helix/channels?broadcaster_id=" ~ id;
2826             immutable channelJSON = getTwitchData(plugin, channelURL);
2827 
2828             shoutout.state = Shoutout.State.success;
2829             shoutout.displayName = channelJSON["broadcaster_name"].str;
2830             shoutout.gameName = channelJSON["game_name"].str;
2831             return shoutout;
2832         }
2833         catch (ErrorJSONException e)
2834         {
2835             if ((e.json["status"].integer = 400) &&
2836                 (e.json["error"].str == "Bad Request") &&
2837                 (e.json["message"].str == "Invalid username(s), email(s), or ID(s). Bad Identifiers."))
2838             {
2839                 shoutout.state = Shoutout.State.noSuchUser;
2840                 return shoutout;
2841             }
2842 
2843             shoutout.state = Shoutout.State.otherError;
2844             return shoutout;
2845         }
2846         catch (EmptyDataJSONException _)
2847         {
2848             shoutout.state = Shoutout.State.noSuchUser;
2849             return shoutout;
2850         }
2851         catch (Exception e)
2852         {
2853             throw e;
2854         }
2855     }
2856 
2857     return retryDelegate(plugin, &shoutoutDg);
2858 }