From 360f9c9cbd172538130707efb10197d3a236a461 Mon Sep 17 00:00:00 2001 From: David Sungaila Date: Thu, 8 Jul 2021 21:42:58 +0200 Subject: [PATCH] Added reply limiter and Twitch client --- Console/Config.ini | 11 +- Console/Program.cs | 3 +- Console/Settings.cs | 19 ++ Core/Core.csproj | 12 +- Core/Models/AccessTokenResponse.cs | 9 +- Core/Models/Twitch/AccessTokenResponse.cs | 43 +++++ Core/Models/Twitch/StreamData.cs | 50 ++++++ Core/Models/Twitch/StreamsResponse.cs | 11 ++ Core/RedditClient.cs | 90 +++++++++- Core/TwitchClient.cs | 205 ++++++++++++++++++++++ README.md | 2 +- 11 files changed, 436 insertions(+), 19 deletions(-) create mode 100644 Core/Models/Twitch/AccessTokenResponse.cs create mode 100644 Core/Models/Twitch/StreamData.cs create mode 100644 Core/Models/Twitch/StreamsResponse.cs create mode 100644 Core/TwitchClient.cs diff --git a/Console/Config.ini b/Console/Config.ini index aa08131..81aeec3 100644 --- a/Console/Config.ini +++ b/Console/Config.ini @@ -12,9 +12,16 @@ Ratelimit=10 ;ignore comments older than this (in seconds) MaxCommentAge=28800 ;maximum replies per thread -CommentLimit=3 +CommentLimit=1 +;delay between replies (in seconds) +RateComment=86400 [UserAgent] ;app name and version sent as User-Agent (uses defaults when empty) ApplicationName= -ApplicationVersion= \ No newline at end of file +ApplicationVersion= + +[Twitch] +;app credentials from https://dev.twitch.tv/console/apps +AppClientId= +AppClientSecret= \ No newline at end of file diff --git a/Console/Program.cs b/Console/Program.cs index 28101cc..a1d6c94 100644 --- a/Console/Program.cs +++ b/Console/Program.cs @@ -26,7 +26,8 @@ public static void Main() Settings.ApplicationVersion, Settings.Ratelimit, Settings.MaxCommentAge, - Settings.CommentLimit); + Settings.CommentLimit, + Settings.RateComment); var tokenSource = new CancellationTokenSource(); var task = client.RunAsync(tokenSource.Token); diff --git a/Console/Settings.cs b/Console/Settings.cs index e21967e..ff2d8dc 100644 --- a/Console/Settings.cs +++ b/Console/Settings.cs @@ -22,6 +22,7 @@ static Settings() Ratelimit = TimeSpan.FromSeconds(int.Parse(iniData["Options"]["Ratelimit"])); MaxCommentAge = TimeSpan.FromSeconds(int.Parse(iniData["Options"]["MaxCommentAge"])); CommentLimit = int.Parse(iniData["Options"]["CommentLimit"]); + RateComment = TimeSpan.FromSeconds(int.Parse(iniData["Options"]["RateComment"])); ApplicationName = iniData["UserAgent"]["ApplicationName"]; ApplicationVersion = iniData["UserAgent"]["ApplicationVersion"]; @@ -57,6 +58,8 @@ private static IList GetFileContent(string value) public static readonly int CommentLimit; + public static readonly TimeSpan RateComment; + public static readonly string ApplicationName; public static readonly string ApplicationVersion; @@ -68,5 +71,21 @@ private static IList GetFileContent(string value) public static readonly IList TriggerPhrases; public static readonly IList Quotes; + + public static class Twitch + { + static Twitch() + { + var iniParser = new FileIniDataParser(); + IniData iniData = iniParser.ReadFile("Config.ini"); + + AppClientId = iniData["Twitch"]["AppClientId"]; + AppClientSecret = iniData["Twitch"]["AppClientSecret"]; + } + + public static readonly string AppClientId; + + public static readonly string AppClientSecret; + } } } \ No newline at end of file diff --git a/Core/Core.csproj b/Core/Core.csproj index f1dce0b..92f63c2 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -7,6 +7,7 @@ true true RedditQuoteBot.snk + true @@ -14,6 +15,7 @@ RedditQuoteBot RedditQuoteBot 1.1.0 + David Sungaila false MIT @@ -39,11 +41,15 @@ true + + + debug + + - + + true true - bin\Release - bin\Release\RedditQuoteBot.xml diff --git a/Core/Models/AccessTokenResponse.cs b/Core/Models/AccessTokenResponse.cs index ba79a4b..38c4612 100644 --- a/Core/Models/AccessTokenResponse.cs +++ b/Core/Models/AccessTokenResponse.cs @@ -4,7 +4,7 @@ namespace RedditQuoteBot.Core.Models { - [DebuggerDisplay("Token = {Token}, ExpiresAt = {ExpiresAt}")] + [DebuggerDisplay("Token = {Token}, ExpiresAtLocal = {ExpiresAtLocal}")] internal class AccessTokenResponse { [JsonPropertyName("access_token")] @@ -22,7 +22,7 @@ public long ExpiresIn set { _expiresIn = value; - ExpiresAt = DateTime.UtcNow.AddSeconds(_expiresIn); + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(_expiresIn); } } @@ -30,7 +30,10 @@ public long ExpiresIn public string? Scope { get; set; } [JsonIgnore] - public DateTime ExpiresAt { get; private set; } = DateTime.MaxValue; + public DateTime ExpiresAtUtc { get; private set; } = DateTime.MaxValue; + + [JsonIgnore] + public DateTime ExpiresAtLocal { get => ExpiresAtUtc.ToLocalTime(); } public override string? ToString() => Token; } diff --git a/Core/Models/Twitch/AccessTokenResponse.cs b/Core/Models/Twitch/AccessTokenResponse.cs new file mode 100644 index 0000000..2617750 --- /dev/null +++ b/Core/Models/Twitch/AccessTokenResponse.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace RedditQuoteBot.Core.Models.Twitch +{ + [DebuggerDisplay("Token = {Token}, ExpiresAtLocal = {ExpiresAtLocal}")] + internal class AccessTokenResponse + { + [JsonPropertyName("access_token")] + public string? Token { get; set; } + + [JsonPropertyName("refresh_token")] + public string? RefreshToken { get; set; } + + [JsonPropertyName("token_type")] + public string? Type { get; set; } + + private long _expiresIn; + + [JsonPropertyName("expires_in")] + public long ExpiresIn + { + get => _expiresIn; + set + { + _expiresIn = value; + ExpiresAtUtc = DateTime.UtcNow.AddSeconds(_expiresIn); + } + } + + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonIgnore] + public DateTime ExpiresAtUtc { get; private set; } = DateTime.MaxValue; + + [JsonIgnore] + public DateTime ExpiresAtLocal { get => ExpiresAtUtc.ToLocalTime(); } + + public override string? ToString() => Token; + } +} \ No newline at end of file diff --git a/Core/Models/Twitch/StreamData.cs b/Core/Models/Twitch/StreamData.cs new file mode 100644 index 0000000..34184d5 --- /dev/null +++ b/Core/Models/Twitch/StreamData.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json.Serialization; + +namespace RedditQuoteBot.Core.Models.Twitch +{ + [DebuggerDisplay("UserLogin = {UserLogin}, Title = {Title}, ViewerCount = {ViewerCount}")] + internal class StreamData + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("user_id")] + public string? UserId { get; set; } + + [JsonPropertyName("user_login")] + public string? UserLogin { get; set; } + + [JsonPropertyName("game_id")] + public string? GameId { get; set; } + + [JsonPropertyName("game_name")] + public string? GameName { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("viewer_count")] + public int ViewerCount { get; set; } + + [JsonPropertyName("started_at")] + public DateTime StartedAt { get; set; } + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("thumbnail_url")] + public string? ThumbnailUrl { get; set; } + + [JsonPropertyName("is_mature")] + public bool IsMature { get; set; } + + [JsonPropertyName("tag_ids")] + public IList? TagIds { get; set; } + } +} \ No newline at end of file diff --git a/Core/Models/Twitch/StreamsResponse.cs b/Core/Models/Twitch/StreamsResponse.cs new file mode 100644 index 0000000..8f8574c --- /dev/null +++ b/Core/Models/Twitch/StreamsResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace RedditQuoteBot.Core.Models.Twitch +{ + internal class StreamsResponse + { + [JsonPropertyName("data")] + public IList? Data { get; set; } + } +} \ No newline at end of file diff --git a/Core/RedditClient.cs b/Core/RedditClient.cs index b95e6be..fa529fa 100644 --- a/Core/RedditClient.cs +++ b/Core/RedditClient.cs @@ -23,6 +23,8 @@ public class RedditClient private readonly TimeSpan _minRatelimit = TimeSpan.FromSeconds(1); + private readonly TimeSpan _minRateComment = TimeSpan.FromMinutes(1); + private AccessTokenResponse? AccessTokenResponse { get; set; } private DateTime LastRequest { get; set; } = DateTime.MinValue; @@ -86,6 +88,11 @@ public class RedditClient /// public int CommentLimit { get; } = 3; + /// + /// The minimum period to wait between replies. + /// + public TimeSpan RateComment { get; } = TimeSpan.FromHours(1); + /// /// The User-Agent used for HTTP requests. /// @@ -102,11 +109,12 @@ public class RedditClient /// The phrases the queried comments are scanned for. /// The quotes that might be posted as comments. /// The reddit user names that will be ignored and not replied to. - /// The application version used for the User-Agent. Defaults to Assembly.GetEntryAssembly().GetName().Name. - /// The application version used for the User-Agent. Defaults to Assembly.GetEntryAssembly().GetName().Version. + /// The application version used for the User-Agent. Defaults to GetType().Assembly.GetName().Name. + /// The application version used for the User-Agent. Defaults to GetType().Assembly.GetName().Version.ToString(). /// The minimum period to wait between HTTP requests. Defaults to 10 seconds. /// The maximum age of comments to consider for replies. Defaults to 8 hours. /// The maximum replies within a single link. + /// The minimum period to wait between replies. Defaults to 1 hour. public RedditClient( string appClientId, string appClientSecret, @@ -120,7 +128,8 @@ public RedditClient( string? applicationVersion = null, TimeSpan? ratelimit = null, TimeSpan? maxCommentAge = null, - int commentLimit = 3) + int commentLimit = 3, + TimeSpan? rateComment = null) { if (string.IsNullOrEmpty(appClientId)) throw new ArgumentException("The app client id cannot be null or empty.", nameof(appClientId)); @@ -161,12 +170,13 @@ public RedditClient( Quotes = quotes; IgnoredUserNames = ignoredUserNames ?? new List(); - ApplicationName = (!string.IsNullOrEmpty(applicationName) ? applicationName : Assembly.GetEntryAssembly().GetName().Name)!; - ApplicationVersion = (!string.IsNullOrEmpty(applicationVersion) ? applicationVersion : Assembly.GetEntryAssembly().GetName().Version.ToString())!; + ApplicationName = (!string.IsNullOrEmpty(applicationName) ? applicationName : GetType().Assembly.GetName().Name)!; + ApplicationVersion = (!string.IsNullOrEmpty(applicationVersion) ? applicationVersion : GetType().Assembly.GetName().Version.ToString())!; Ratelimit = ratelimit ?? Ratelimit; MaxCommentAge = maxCommentAge ?? MaxCommentAge; CommentLimit = Math.Max(commentLimit, 1); + RateComment = rateComment ?? RateComment; UserAgent = $"script:{ApplicationName}:{ApplicationVersion} (by /u/{BotUserName})"; Console.WriteLine($"User-Agent: \"{UserAgent}\""); @@ -220,7 +230,68 @@ private async Task ThrottleRequestsAsync(CancellationToken cancellationToken) { var availableIn = availableAt.Subtract(DateTime.UtcNow); - Console.WriteLine($"Delay for {availableIn} ({availableAt})."); + Console.WriteLine($"Delay for {availableIn} (until {availableAt})."); + await Task.Delay(availableIn, cancellationToken); + } + + LastRequest = DateTime.UtcNow; + } + + private async Task ThrottleReplyAsync(CancellationToken cancellationToken) + { + Console.WriteLine(); + + await CheckAuthentication(cancellationToken); + + ListingResponse? result = null; + + try + { + var response = await _httpClient.GetAsync($"https://oauth.reddit.com/user/{BotUserName}/comments/?limit=1", cancellationToken); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine("Failed to request bot comments."); + Console.WriteLine(response.ReasonPhrase); + throw new InvalidOperationException("Failed to receive listing response."); + } + + var responseContent = await response.Content.ReadAsStreamAsync(); + result = await JsonSerializer.DeserializeAsync(responseContent, null, cancellationToken); + + if (result == null) + throw new InvalidOperationException("Failed to receive listing response."); + } + catch (TaskCanceledException) + { + // task cancellation can be ignored + } + catch (Exception ex) + { + Console.WriteLine("Failed to request bot comments."); + Console.WriteLine(ex); + throw; + } + + if (result?.Data == null) + return; + + // null means the bot has no comments yet (or the request went wrong) + var latestComment = result.Data.Children.Select(child => child.Data!).SingleOrDefault(); + + var ratelimit = TimeSpan.FromTicks(Math.Max(RateComment.Ticks, _minRateComment.Ticks)); + var availableAt = latestComment != null + ? latestComment.CreatedUtc.Add(ratelimit) + : DateTime.MinValue; + + if (DateTime.UtcNow < availableAt) + { + var availableIn = availableAt.Subtract(DateTime.UtcNow); + + if (latestComment != null) + Console.WriteLine($"Latest bot comment was from {latestComment.CreatedLocal}."); + + Console.WriteLine($"Delay for {availableIn} (until {availableAt})."); await Task.Delay(availableIn, cancellationToken); } @@ -229,7 +300,7 @@ private async Task ThrottleRequestsAsync(CancellationToken cancellationToken) private async Task CheckAuthentication(CancellationToken cancellationToken) { - if (AccessTokenResponse == null || string.IsNullOrEmpty(AccessTokenResponse.Token) || AccessTokenResponse.ExpiresAt <= DateTime.UtcNow) + if (AccessTokenResponse == null || string.IsNullOrEmpty(AccessTokenResponse.Token) || AccessTokenResponse.ExpiresAtUtc <= DateTime.UtcNow) await AuthenticateAsync(cancellationToken); } @@ -283,6 +354,7 @@ private async Task> GetCommentsAsync(string subreddit, { await CheckAuthentication(cancellationToken); await ThrottleRequestsAsync(cancellationToken); + await ThrottleReplyAsync(cancellationToken); Console.Write($"Request comments for subreddit /r/{subreddit} ... "); ListingResponse? result = null; @@ -318,7 +390,7 @@ private async Task> GetCommentsAsync(string subreddit, throw; } - if (result.Data == null) + if (result?.Data == null) return new List(); var candidates = result.Data.Children.Select(child => child.Data!).ToList(); @@ -397,7 +469,7 @@ private async Task PostReplyAsync(CommentData comment, string quote, int quoteId try { - response = await _httpClient.PostAsync("https://oauth.reddit.com/api/comment", content); + response = await _httpClient.PostAsync("https://oauth.reddit.com/api/comment", content, cancellationToken); LogReply(comment, quoteId); diff --git a/Core/TwitchClient.cs b/Core/TwitchClient.cs new file mode 100644 index 0000000..4a9a189 --- /dev/null +++ b/Core/TwitchClient.cs @@ -0,0 +1,205 @@ +using RedditQuoteBot.Core.Models.Twitch; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace RedditQuoteBot.Core +{ + /// + /// A HTTP client to query information from Twitch.tv. + /// + public class TwitchClient + { + private readonly HttpClient _httpClient = new HttpClient(); + + private readonly TimeSpan _minRatelimit = TimeSpan.FromSeconds(1); + + private AccessTokenResponse? AccessTokenResponse { get; set; } + + private DateTime LastRequest { get; set; } = DateTime.MinValue; + + /// + /// The app's client ID given by Reddit. + /// + public string AppClientId { get; private set; } + + private string AppClientSecret { get; set; } + + /// + /// The application name used for the User-Agent. + /// + public string ApplicationName { get; } + + /// + /// The application version used for the User-Agent. + /// + public string ApplicationVersion { get; } + + /// + /// The minimum period to wait between HTTP requests. + /// + public TimeSpan Ratelimit { get; } = TimeSpan.FromSeconds(10); + + /// + /// The User-Agent used for HTTP requests. + /// + public string UserAgent { get; } + + /// + /// Creates a new instance of the Twitch.tv HTTP client. + /// + /// The app's client ID given by Twitch.tv. + /// The app's client secret password given by Twitch.tv. + /// The application version used for the User-Agent. Defaults to GetType().Assembly.GetName().Name. + /// The application version used for the User-Agent. Defaults to GetType().Assembly.GetName().Version.ToString(). + /// The minimum period to wait between HTTP requests. Defaults to 10 seconds. + public TwitchClient( + string appClientId, + string appClientSecret, + string? applicationName = null, + string? applicationVersion = null, + TimeSpan? ratelimit = null) + { + if (string.IsNullOrEmpty(appClientId)) + throw new ArgumentException("The app client id cannot be null or empty.", nameof(appClientId)); + + if (string.IsNullOrEmpty(appClientSecret)) + throw new ArgumentException("The app client secret cannot be null or empty.", nameof(appClientSecret)); + + AppClientId = appClientId; + AppClientSecret = appClientSecret; + + ApplicationName = (!string.IsNullOrEmpty(applicationName) ? applicationName : GetType().Assembly.GetName().Name)!; + ApplicationVersion = (!string.IsNullOrEmpty(applicationVersion) ? applicationVersion : GetType().Assembly.GetName().Version.ToString())!; + + Ratelimit = ratelimit ?? Ratelimit; + + UserAgent = $"script:{ApplicationName}:{ApplicationVersion}"; + Console.WriteLine($"User-Agent: \"{UserAgent}\""); + + _httpClient.DefaultRequestHeaders.Add("client-id", AppClientId); + _httpClient.DefaultRequestHeaders.Add( + "User-Agent", + Uri.EscapeDataString(UserAgent)); + } + + private async Task ThrottleRequestsAsync(CancellationToken cancellationToken) + { + Console.WriteLine(); + var ratelimit = TimeSpan.FromTicks(Math.Max(Ratelimit.Ticks, _minRatelimit.Ticks)); + var availableAt = LastRequest.Add(ratelimit); + + if (DateTime.UtcNow < availableAt) + { + var availableIn = availableAt.Subtract(DateTime.UtcNow); + + Console.WriteLine($"Delay for {availableIn} (until {availableAt})."); + await Task.Delay(availableIn, cancellationToken); + } + + LastRequest = DateTime.UtcNow; + } + + private async Task CheckAuthentication(CancellationToken cancellationToken) + { + if (AccessTokenResponse == null || string.IsNullOrEmpty(AccessTokenResponse.Token) || AccessTokenResponse.ExpiresAtUtc <= DateTime.UtcNow) + await AuthenticateAsync(cancellationToken); + } + + private async Task AuthenticateAsync(CancellationToken cancellationToken) + { + await ThrottleRequestsAsync(cancellationToken); + + Console.Write("Request OAuth2 access token (Twitch.tv) with HTTP basic authentication ... "); + + var content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", AppClientId }, + { "client_secret", AppClientSecret }, + { "grant_type", "client_credentials" } + }); + + try + { + var response = await _httpClient.PostAsync("https://id.twitch.tv/oauth2/token", content, cancellationToken); + string responseContent = await response.Content.ReadAsStringAsync(); + AccessTokenResponse = JsonSerializer.Deserialize(responseContent); + + if (AccessTokenResponse?.Token == null) + throw new InvalidOperationException("Failed to receive access token."); + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + AccessTokenResponse.Token); + + Console.WriteLine("succeeded."); + } + catch (TaskCanceledException) + { + // task cancellation can be ignored + } + catch (Exception ex) + { + Console.WriteLine("failed."); + Console.WriteLine(ex); + throw; + } + } + + /// + /// Requests the current viewer count for a given user id. + /// + /// ID of the user who is streaming. + /// The token used to cancel the request. + /// Returns if the user is not streaming. + public async Task GetStreamViewerCount(string userId, CancellationToken cancellationToken) + { + await CheckAuthentication(cancellationToken); + await ThrottleRequestsAsync(cancellationToken); + + Console.Write($"Request stream information (Twitch.tv) for user id {userId} ... "); + StreamsResponse? result; + + try + { + var response = await _httpClient.GetAsync($"https://api.twitch.tv/helix/streams?user_login={userId}", cancellationToken); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine("failed."); + Console.WriteLine(response.ReasonPhrase); + return null; + } + + var responseContent = await response.Content.ReadAsStreamAsync(); + result = await JsonSerializer.DeserializeAsync(responseContent, null, cancellationToken); + + if (result == null) + throw new InvalidOperationException("Failed to receive listing response."); + + Console.WriteLine("succeeded."); + } + catch (TaskCanceledException) + { + // task cancellation can be ignored + return null; + } + catch (Exception ex) + { + Console.WriteLine("failed."); + Console.WriteLine(ex); + throw; + } + + if (result?.Data == null || !result.Data.Any()) + return null; + + return result.Data.Single().ViewerCount; + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 1a0f0d5..360174e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A reddit bot scanning comments for trigger phrases to reply with predefined quotes. -The class library is built on top of **.NET Standard 2.0** and the console app on **.NET Core 3.1**. +The class library is built on top of **.NET Standard 2.0** and the console app on **.NET 5.0**. Screenshot from version 1.0.0