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 }