1 /++ 2 Functions for generating a Twitch API key. 3 4 See_Also: 5 [kameloso.plugins.twitch.base], 6 [kameloso.plugins.twitch.api] 7 8 Copyright: [JR](https://github.com/zorael) 9 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 10 11 Authors: 12 [JR](https://github.com/zorael) 13 +/ 14 module kameloso.plugins.twitch.keygen; 15 16 version(TwitchSupport): 17 version(WithTwitchPlugin): 18 19 private: 20 21 import kameloso.plugins.twitch.base; 22 import kameloso.plugins.twitch.common; 23 import kameloso.common : logger; 24 import kameloso.logger : LogLevel; 25 import kameloso.terminal.colours.tags : expandTags; 26 import std.typecons : Flag, No, Yes; 27 28 package: 29 30 31 // requestTwitchKey 32 /++ 33 Start the captive key generation routine at the earliest possible moment. 34 Invoked by [kameloso.plugins.twitch.base.start|start] during early connect. 35 36 Params: 37 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 38 +/ 39 void requestTwitchKey(TwitchPlugin plugin) 40 { 41 import kameloso.thread : ThreadMessage; 42 import std.concurrency : prioritySend; 43 import std.datetime.systime : Clock; 44 import std.process : Pid, ProcessException, wait; 45 import std.stdio : stdout, writeln; 46 47 scope(exit) if (plugin.state.settings.flush) stdout.flush(); 48 49 logger.trace(); 50 logger.info("== Twitch authorisation key generation mode =="); 51 enum attemptToOpenMessage = ` 52 Attempting to open a Twitch login page in your default web browser. Follow the 53 instructions and log in to authorise the use of this program with your <w>BOT</> account. 54 55 <l>Then paste the address of the page you are redirected to afterwards here.</> 56 57 * The redirected address should start with <i>http://localhost</>. 58 * It will probably say "<l>this site can't be reached</>" or "<l>unable to connect</>". 59 * <l>The key generated will be one for the account you are currently logged in as in your browser.</> 60 If you are logged into your main Twitch account and you want the bot to use a 61 separate account, you will have to log out and log in as that first, before 62 attempting this. Use an incognito/private window. 63 * If you are running local web server on port <i>80</>, you may have to temporarily 64 disable it for this to work. 65 `; 66 writeln(attemptToOpenMessage.expandTags(LogLevel.off)); 67 if (plugin.state.settings.flush) stdout.flush(); 68 69 static immutable scopes = 70 [ 71 // New Twitch API 72 // -------------------------- 73 //"analytics:read:extension", 74 //"analytics:read:games", 75 //"bits:read", 76 //"channel:edit:commercial", 77 //"channel:manage:broadcast", 78 //"channel:manage:extensions" 79 //"channel:manage:polls", 80 //"channel:manage:predictions", 81 //"channel:manage:redemptions", 82 //"channel:manage:schedule", 83 //"channel:manage:videos", 84 //"channel:read:editors", 85 //"channel:read:goals", 86 //"channel:read:hype_train", 87 //"channel:read:polls", 88 //"channel:read:predictions", 89 //"channel:read:redemptions", 90 //"channel:read:stream_key", 91 //"channel:read:subscriptions", 92 //"clips:edit", 93 //"moderation:read", 94 //"moderator:manage:banned_users", 95 //"moderator:read:blocked_terms", 96 //"moderator:manage:blocked_terms", 97 //"moderator:manage:automod", 98 //"moderator:read:automod_settings", 99 //"moderator:manage:automod_settings", 100 //"moderator:read:chat_settings", 101 //"moderator:manage:chat_settings", 102 //"user:edit", 103 //"user:edit:follows", 104 //"user:manage:blocked_users", 105 //"user:read:blocked_users", 106 //"user:read:broadcast", 107 //"user:read:email", 108 //"user:read:follows", 109 //"user:read:subscriptions" 110 //"user:edit:broadcast", // removed/undocumented? implied user:read:broadcast 111 112 // Twitch APIv5 113 // -------------------------- 114 //"channel_check_subscription", // removed/undocumented? 115 //"channel_subscriptions", 116 //"channel_commercial", 117 //"channel_editor", 118 //"channel_feed_edit", // removed/undocumented? 119 //"channel_feed_read", // removed/undocumented? 120 //"user_follows_edit", 121 //"channel_read", 122 //"channel_stream", // removed/undocumented? 123 //"collections_edit", // removed/undocumented? 124 //"communities_edit", // removed/undocumented? 125 //"communities_moderate", // removed/undocumented? 126 //"openid", // removed/undocumented? 127 //"user_read", 128 //"user_blocks_read", 129 //"user_blocks_edit", 130 //"user_subscriptions", // removed/undocumented? 131 //"viewing_activity_read", // removed/undocumented? 132 133 // Chat and PubSub 134 // -------------------------- 135 "channel:moderate", 136 "chat:edit", 137 "chat:read", 138 "whispers:edit", 139 "whispers:read", 140 ]; 141 142 Pid browser; 143 scope(exit) if (browser !is null) wait(browser); 144 145 enum authNode = "https://id.twitch.tv/oauth2/authorize"; 146 immutable url = buildAuthNodeURL(authNode, scopes); 147 148 if (plugin.state.settings.force) 149 { 150 logger.warning("Forcing; not automatically opening browser."); 151 printManualURL(url); 152 if (plugin.state.settings.flush) stdout.flush(); 153 } 154 else 155 { 156 try 157 { 158 import kameloso.platform : openInBrowser; 159 openInBrowser(url); 160 } 161 catch (ProcessException _) 162 { 163 // Probably we got some platform wrong and command was not found 164 logger.warning("Error: could not automatically open browser."); 165 printManualURL(url); 166 if (plugin.state.settings.flush) stdout.flush(); 167 } 168 catch (Exception _) 169 { 170 logger.warning("Error: no graphical environment detected"); 171 printManualURL(url); 172 if (plugin.state.settings.flush) stdout.flush(); 173 } 174 } 175 176 plugin.state.bot.pass = readURLAndParseKey(plugin, authNode); 177 if (*plugin.state.abort) return; 178 179 writeln(); 180 logger.info("Validating..."); 181 182 immutable expiry = getTokenExpiry(plugin, plugin.state.bot.pass); 183 if (*plugin.state.abort) return; 184 185 immutable delta = (expiry - Clock.currTime); 186 immutable numDays = delta.total!"days"; 187 188 enum isValidPattern = "Your key is valid for another <l>%d</> days."; 189 logger.infof(isValidPattern, numDays); 190 logger.trace(); 191 192 plugin.state.updates |= typeof(plugin.state.updates).bot; 193 plugin.state.mainThread.prioritySend(ThreadMessage.save()); 194 } 195 196 197 // requestTwitchSuperKey 198 /++ 199 Start the captive key generation routine at the earliest possible moment, 200 which is at plugin [kameloso.plugins.twitch.base.start|start]. 201 202 Params: 203 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 204 +/ 205 void requestTwitchSuperKey(TwitchPlugin plugin) 206 { 207 import std.process : Pid, ProcessException, wait; 208 import std.stdio : stdout, writeln; 209 import std.datetime.systime : Clock; 210 211 scope(exit) if (plugin.state.settings.flush) stdout.flush(); 212 213 logger.trace(); 214 logger.info("== Twitch authorisation super key generation mode =="); 215 enum message = ` 216 To access certain Twitch functionality like changing channel settings 217 (what game is currently being played, etc), the program needs an authorisation 218 key that corresponds to the owner of that channel. 219 220 In the instructions that follow, it is essential that you are logged into the 221 <w>STREAMER</> account in your browser. 222 223 You also need to supply the channel for which it all relates. 224 (Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.) 225 `; 226 writeln(message.expandTags(LogLevel.off)); 227 228 immutable channel = readNamedString("<l>Enter your <i>#channel<l>:</> ", 229 0L, *plugin.state.abort); 230 if (*plugin.state.abort) return; 231 232 enum attemptToOpenMessage = ` 233 -------------------------------------------------------------------------------- 234 235 Attempting to open a Twitch login page in your default web browser. Follow the 236 instructions and log in to authorise the use of this program with your <w>STREAMER</> account. 237 238 <l>Then paste the address of the page you are redirected to afterwards here.</> 239 240 * The redirected address should start with <i>http://localhost</>. 241 * It will probably say "<l>this site can't be reached</>" or "<l>unable to connect</>". 242 * <l>The key generated will be one for the account you are currently logged in as in your browser.</> 243 You should be logged into your main Twitch account for this key. 244 * If you are running local web server on port <i>80</>, you may have to temporarily 245 disable it for this to work. 246 `; 247 writeln(attemptToOpenMessage.expandTags(LogLevel.off)); 248 if (plugin.state.settings.flush) stdout.flush(); 249 250 static immutable scopes = 251 [ 252 // New Twitch API 253 // -------------------------- 254 //"analytics:read:extension", 255 //"analytics:read:games", 256 //"bits:read", 257 "channel:edit:commercial", 258 "channel:manage:broadcast", 259 //"channel:manage:extensions" 260 "channel:manage:polls", 261 "channel:manage:predictions", 262 //"channel:manage:redemptions", 263 //"channel:manage:schedule", 264 //"channel:manage:videos", 265 "channel:read:editors", 266 "channel:read:goals", 267 "channel:read:hype_train", 268 "channel:read:polls", 269 //"channel:read:predictions", 270 //"channel:read:redemptions", 271 //"channel:read:stream_key", 272 "channel:read:subscriptions", 273 //"clips:edit", 274 "moderation:read", 275 "moderator:manage:banned_users", 276 "moderator:read:blocked_terms", 277 "moderator:manage:blocked_terms", 278 "moderator:manage:automod", 279 "moderator:read:automod_settings", 280 "moderator:manage:automod_settings", 281 "moderator:read:chat_settings", 282 "moderator:manage:chat_settings", 283 //"user:edit", 284 //"user:edit:follows", 285 //"user:manage:blocked_users", 286 //"user:read:blocked_users", 287 //"user:read:broadcast", 288 //"user:read:email", 289 //"user:read:follows", 290 //"user:read:subscriptions" 291 //"user:edit:broadcast", // removed/undocumented? implied user:read:broadcast 292 293 // Twitch APIv5 294 // -------------------------- 295 //"channel_check_subscription", // removed/undocumented? 296 //"channel_subscriptions", 297 //"channel_commercial", 298 //"channel_editor", 299 //"channel_feed_edit", // removed/undocumented? 300 //"channel_feed_read", // removed/undocumented? 301 //"user_follows_edit", 302 //"channel_read", 303 //"channel_stream", // removed/undocumented? 304 //"collections_edit", // removed/undocumented? 305 //"communities_edit", // removed/undocumented? 306 //"communities_moderate", // removed/undocumented? 307 //"openid", // removed/undocumented? 308 //"user_read", 309 //"user_blocks_read", 310 //"user_blocks_edit", 311 //"user_subscriptions", // removed/undocumented? 312 //"viewing_activity_read", // removed/undocumented? 313 314 // Chat and PubSub 315 // -------------------------- 316 //"channel:moderate", 317 //"chat:edit", 318 //"chat:read", 319 //"whispers:edit", 320 //"whispers:read", 321 ]; 322 323 Pid browser; 324 scope(exit) if (browser !is null) wait(browser); 325 326 enum authNode = "https://id.twitch.tv/oauth2/authorize"; 327 immutable url = buildAuthNodeURL(authNode, scopes); 328 329 if (plugin.state.settings.force) 330 { 331 logger.warning("Forcing; not automatically opening browser."); 332 printManualURL(url); 333 if (plugin.state.settings.flush) stdout.flush(); 334 } 335 else 336 { 337 try 338 { 339 import kameloso.platform : openInBrowser; 340 openInBrowser(url); 341 } 342 catch (ProcessException _) 343 { 344 // Probably we got some platform wrong and command was not found 345 logger.warning("Error: could not automatically open browser."); 346 printManualURL(url); 347 if (plugin.state.settings.flush) stdout.flush(); 348 } 349 } 350 351 Credentials creds; 352 creds.broadcasterKey = readURLAndParseKey(plugin, authNode); 353 354 if (*plugin.state.abort) return; 355 356 if (auto storedCreds = channel in plugin.secretsByChannel) 357 { 358 import lu.meld : MeldingStrategy, meldInto; 359 creds.meldInto!(MeldingStrategy.aggressive)(*storedCreds); 360 } 361 else 362 { 363 plugin.secretsByChannel[channel] = creds; 364 } 365 366 writeln(); 367 logger.info("Validating..."); 368 369 immutable expiry = getTokenExpiry(plugin, creds.broadcasterKey); 370 if (*plugin.state.abort) return; 371 372 immutable delta = (expiry - Clock.currTime); 373 immutable numDays = delta.total!"days"; 374 375 enum isValidPattern = "Your key is valid for another <l>%d</> days."; 376 logger.infof(isValidPattern, numDays); 377 logger.trace(); 378 379 saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile); 380 } 381 382 383 // readURLAndParseKey 384 /++ 385 Reads a URL from standard in and parses an OAuth key from it. 386 387 Params: 388 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 389 authNode = Authentication node URL, to detect whether the wrong link was pasted. 390 391 Returns: 392 An OAuth token key parsed from a pasted URL string. 393 +/ 394 private auto readURLAndParseKey(TwitchPlugin plugin, const string authNode) 395 { 396 import lu.string : contains, nom, stripped; 397 import std.stdio : readln, stdin, stdout, write, writeln; 398 399 string key; 400 401 while (!key.length) 402 { 403 scope(exit) if (plugin.state.settings.flush) stdout.flush(); 404 405 enum pasteMessage = "<l>Paste the address of empty the page you were redirected to here (empty line exits):</> 406 407 > "; 408 write(pasteMessage.expandTags(LogLevel.off)); 409 stdout.flush(); 410 411 stdin.flush(); 412 immutable readURL = readln().stripped; 413 414 if (!readURL.length || *plugin.state.abort) 415 { 416 writeln(); 417 logger.warning("Aborting."); 418 logger.trace(); 419 *plugin.state.abort = true; 420 return string.init; 421 } 422 423 if (readURL.length == 30) 424 { 425 // As is 426 key = readURL; 427 } 428 else if (!readURL.contains("access_token=")) 429 { 430 import lu.string : beginsWith; 431 432 writeln(); 433 434 if (readURL.beginsWith(authNode)) 435 { 436 enum wrongPageMessage = "Not that page; the empty page you're " ~ 437 "lead to after clicking <l>Authorize</>."; 438 logger.error(wrongPageMessage); 439 } 440 else 441 { 442 logger.error("Could not make sense of URL. Try copying again or file a bug."); 443 } 444 445 writeln(); 446 continue; 447 } 448 449 string slice = readURL; // mutable 450 slice.nom("access_token="); 451 key = slice.nom('&'); 452 453 if (key.length != 30L) 454 { 455 writeln(); 456 logger.error("Invalid key length!"); 457 writeln(); 458 key = string.init; // reset it so the while loop repeats 459 } 460 } 461 462 return key; 463 } 464 465 466 // buildAuthNodeURL 467 /++ 468 Constructs an authorisation node URL with the passed scopes. 469 470 Params: 471 authNode = Base authorisation node URL. 472 scopes = OAuth scope string array. 473 474 Returns: 475 A URL string. 476 +/ 477 private auto buildAuthNodeURL(const string authNode, const string[] scopes) 478 { 479 import std.array : join; 480 import std.conv : text; 481 482 return text( 483 authNode, 484 "?response_type=token", 485 "&client_id=", TwitchPlugin.clientID, 486 "&redirect_uri=http://localhost", 487 "&scope=", scopes.join('+'), 488 "&force_verify=true", 489 "&state=kameloso"); 490 } 491 492 493 // getTokenExpiry 494 /++ 495 Validates an authorisation token and returns a [std.datetime.systime.SysTime|SysTime] 496 of when it expires. 497 498 Params: 499 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 500 authToken = Authorisation token to validate and check expiry of. 501 502 Returns: 503 A [std.datetime.systime.SysTime|SysTime] of when the passed token expires. 504 +/ 505 auto getTokenExpiry(TwitchPlugin plugin, const string authToken) 506 { 507 import kameloso.plugins.twitch.api : getValidation; 508 import std.datetime.systime : Clock, SysTime; 509 510 foreach (immutable i; 0..TwitchPlugin.delegateRetries) 511 { 512 try 513 { 514 immutable validationJSON = getValidation(plugin, authToken, No.async); 515 immutable expiresIn = validationJSON["expires_in"].integer; 516 immutable expiresWhen = SysTime.fromUnixTime(Clock.currTime.toUnixTime + expiresIn); 517 return expiresWhen; 518 } 519 catch (Exception e) 520 { 521 // Retry until we reach the retry limit, then rethrow 522 if (i < TwitchPlugin.delegateRetries-1) continue; 523 throw e; 524 } 525 } 526 527 assert(0, "Unreachable"); 528 }