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 }