diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 236d2a70..cb96a1f7 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -81,6 +81,7 @@ + @@ -95,6 +96,7 @@ + diff --git a/src/TagzApp.Blazor.Client/Components/Admin/Kick.Config.Ui.razor b/src/TagzApp.Blazor.Client/Components/Admin/Kick.Config.Ui.razor new file mode 100644 index 00000000..4e2296e3 --- /dev/null +++ b/src/TagzApp.Blazor.Client/Components/Admin/Kick.Config.Ui.razor @@ -0,0 +1,94 @@ +@using System.ComponentModel.DataAnnotations +@inject ToastService ToastService +@inject IConfigureTagzApp Config + + + + + + + +
+

Configure the Kick chat provider to aggregate messages from Kick streams.

+

Enter your Kick channel name to start receiving chat messages. The API key is optional and can be used for authenticated requests if needed.

+
+ +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+ + + +
+ +
+ +@code { + + [Parameter, EditorRequired] + public ISocialMediaProvider Provider { get; set; } = null!; + + public (SocialMediaStatus Status, string Message) Health { get; set; } = (SocialMediaStatus.Unknown, string.Empty); + + public ViewModel Model { get; set; } = new(); + + protected override async Task OnParametersSetAsync() + { + + var providerConfiguration = await Provider.GetConfiguration(Config); + + Model = new ViewModel + { + ChannelName = providerConfiguration.GetConfigurationByKey("ChannelName"), + ApiKey = providerConfiguration.GetConfigurationByKey("ApiKey"), + Enabled = string.IsNullOrEmpty(providerConfiguration.GetConfigurationByKey("Enabled")) ? false : bool.Parse(providerConfiguration.GetConfigurationByKey("Enabled")) + }; + + Health = await Provider.GetHealth(); + + await base.OnParametersSetAsync(); + + } + + private async Task SaveConfig() + { + + var providerConfiguration = await Provider.GetConfiguration(Config); + + providerConfiguration.SetConfigurationByKey("ChannelName", Model.ChannelName); + providerConfiguration.SetConfigurationByKey("ApiKey", Model.ApiKey); + providerConfiguration.SetConfigurationByKey("Enabled", Model.Enabled.ToString()); + + await Provider.SaveConfiguration(Config, providerConfiguration); + ToastService.Add($"Saved {providerConfiguration.Name} Configuration", MessageSeverity.Success); + + // get the new health status + Health = await Provider.GetHealth(); + } + + public class ViewModel + { + // add properties for each of the fields you want to edit + + [Required(ErrorMessage = "Channel name is required.")] + public string ChannelName { get; set; } = string.Empty; + + public string ApiKey { get; set; } = string.Empty; + + public bool Enabled { get; set; } + } + +} diff --git a/src/TagzApp.Blazor/Components/Admin/Pages/GenericProvider.razor b/src/TagzApp.Blazor/Components/Admin/Pages/GenericProvider.razor index 02fad04d..9fdd09e6 100644 --- a/src/TagzApp.Blazor/Components/Admin/Pages/GenericProvider.razor +++ b/src/TagzApp.Blazor/Components/Admin/Pages/GenericProvider.razor @@ -3,6 +3,7 @@ @using TagzApp.Providers.Blazot @using TagzApp.Providers.Bluesky @using TagzApp.Providers.TwitchChat +@using TagzApp.Providers.Kick @using TagzApp.Providers.Mastodon @using TagzApp.Blazor.Client.Components.Admin @using TagzApp.Providers.Twitter @@ -28,6 +29,11 @@ break; } + case nameof(KickProvider): + { + + break; + } case nameof(MastodonProvider): { diff --git a/src/TagzApp.Blazor/TagzApp.Blazor.csproj b/src/TagzApp.Blazor/TagzApp.Blazor.csproj index 77b56eef..90316712 100644 --- a/src/TagzApp.Blazor/TagzApp.Blazor.csproj +++ b/src/TagzApp.Blazor/TagzApp.Blazor.csproj @@ -32,6 +32,7 @@ + diff --git a/src/TagzApp.Providers.Kick/ChatClient.cs b/src/TagzApp.Providers.Kick/ChatClient.cs new file mode 100644 index 00000000..588164a5 --- /dev/null +++ b/src/TagzApp.Providers.Kick/ChatClient.cs @@ -0,0 +1,203 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace TagzApp.Providers.Kick; + +public class ChatClient : IChatClient +{ + public const string LOGGER_CATEGORY = "Providers.Kick"; + + private ClientWebSocket? _WebSocket; + private CancellationTokenSource _Shutdown = new(); + private Task? _ReceiveMessagesTask; + private readonly ILogger _Logger; + + public event EventHandler? NewMessage; + + public string ChannelName { get; private set; } + public string ApiKey { get; private set; } + + public bool IsRunning { get; private set; } + public bool IsConnected => _WebSocket?.State == WebSocketState.Open; + + public ChatClient(string channelName, string apiKey, ILogger logger) + { + ChannelName = channelName; + ApiKey = apiKey; + _Logger = logger; + } + + public async void Init() + { + try + { + IsRunning = true; + _WebSocket = new ClientWebSocket(); + + // Kick.com WebSocket endpoint for chat + var uri = new Uri($"wss://ws-us2.pusher.com/app/32cbd69e4b950bf97679?protocol=7&client=js&version=7.6.0&flash=false"); + + await _WebSocket.ConnectAsync(uri, _Shutdown.Token); + + _Logger.LogInformation("Connected to Kick chat WebSocket"); + + // Subscribe to the channel + await SubscribeToChannel(); + + // Start receiving messages + _ReceiveMessagesTask = Task.Run(ReceiveMessages, _Shutdown.Token); + } + catch (Exception ex) + { + _Logger.LogError(ex, "Failed to initialize Kick chat client"); + IsRunning = false; + } + } + + private async Task SubscribeToChannel() + { + if (_WebSocket?.State != WebSocketState.Open) return; + + // Subscribe to the chat channel for the specified channel + var subscribeMessage = new + { + @event = "pusher:subscribe", + data = new { channel = $"chatrooms.{ChannelName}.v2" } + }; + + var messageJson = JsonSerializer.Serialize(subscribeMessage); + var messageBytes = Encoding.UTF8.GetBytes(messageJson); + + await _WebSocket.SendAsync( + new ArraySegment(messageBytes), + WebSocketMessageType.Text, + true, + _Shutdown.Token); + + _Logger.LogInformation("Subscribed to Kick chat channel: {ChannelName}", ChannelName); + } + + private async Task ReceiveMessages() + { + var buffer = new byte[4096]; + + while (_WebSocket?.State == WebSocketState.Open && !_Shutdown.Token.IsCancellationRequested) + { + try + { + var result = await _WebSocket.ReceiveAsync(new ArraySegment(buffer), _Shutdown.Token); + + if (result.MessageType == WebSocketMessageType.Text) + { + var message = Encoding.UTF8.GetString(buffer, 0, result.Count); + await ProcessMessage(message); + } + } + catch (Exception ex) + { + _Logger.LogError(ex, "Error receiving message from Kick chat"); + break; + } + } + } + + private async Task ProcessMessage(string message) + { + try + { + using var jsonDoc = JsonDocument.Parse(message); + var root = jsonDoc.RootElement; + + // Check if this is a chat message event + if (root.TryGetProperty("event", out var eventElement) && + eventElement.GetString() == "App\\Events\\ChatMessageEvent") + { + if (root.TryGetProperty("data", out var dataElement)) + { + var dataString = dataElement.GetString(); + if (!string.IsNullOrEmpty(dataString)) + { + var chatData = JsonSerializer.Deserialize(dataString); + if (chatData != null) + { + await ProcessChatMessage(chatData); + } + } + } + } + } + catch (Exception ex) + { + _Logger.LogDebug(ex, "Failed to process Kick chat message: {Message}", message); + } + } + + private async Task ProcessChatMessage(KickChatMessage chatMessage) + { + var args = new NewMessageEventArgs + { + DisplayName = chatMessage.Sender?.Username ?? "Unknown", + UserName = chatMessage.Sender?.Username ?? "Unknown", + Message = chatMessage.Content ?? string.Empty, + MessageId = chatMessage.Id?.ToString() ?? Guid.NewGuid().ToString(), + Timestamp = chatMessage.CreatedAt, + Badges = [], + Emotes = [] + }; + + NewMessage?.Invoke(this, args); + } + + public void ListenToNewChannel(string channelName) + { + if (ChannelName != channelName) + { + ChannelName = channelName; + + // Reconnect to the new channel + if (IsRunning) + { + Stop(); + Init(); + } + } + } + + public void Stop() + { + try + { + IsRunning = false; + _Shutdown?.Cancel(); + _WebSocket?.CloseAsync(WebSocketCloseStatus.NormalClosure, "Stopping", CancellationToken.None); + } + catch (Exception ex) + { + _Logger.LogError(ex, "Error stopping Kick chat client"); + } + } + + public void Dispose() + { + Stop(); + _WebSocket?.Dispose(); + _Shutdown?.Dispose(); + } + + // Data model for Kick chat messages + private class KickChatMessage + { + public int? Id { get; set; } + public string? Content { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public KickSender? Sender { get; set; } + } + + private class KickSender + { + public string? Username { get; set; } + public string? Slug { get; set; } + } +} diff --git a/src/TagzApp.Providers.Kick/IChatClient.cs b/src/TagzApp.Providers.Kick/IChatClient.cs new file mode 100644 index 00000000..7ef0b30e --- /dev/null +++ b/src/TagzApp.Providers.Kick/IChatClient.cs @@ -0,0 +1,16 @@ +namespace TagzApp.Providers.Kick; + +public interface IChatClient : IDisposable +{ + event EventHandler NewMessage; + + void Init(); + + void Stop(); + + bool IsRunning { get; } + + bool IsConnected { get; } + + void ListenToNewChannel(string channelName); +} diff --git a/src/TagzApp.Providers.Kick/KickConfiguration.cs b/src/TagzApp.Providers.Kick/KickConfiguration.cs new file mode 100644 index 00000000..22254a57 --- /dev/null +++ b/src/TagzApp.Providers.Kick/KickConfiguration.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Serialization; +using TagzApp.Common; + +namespace TagzApp.Providers.Kick; + +public class KickConfiguration : BaseProviderConfiguration +{ + /// + /// The configuration key used to store this configuration in the TagzApp configuration system + /// + protected override string ConfigurationKey => "provider-kick"; + + [JsonPropertyOrder(1)] + public string ChannelName { get; set; } = string.Empty; + + [JsonPropertyOrder(2)] + public string ApiKey { get; set; } = string.Empty; + + public static KickConfiguration Empty => new() + { + ChannelName = string.Empty, + ApiKey = string.Empty + }; + + [JsonIgnore] + public override string Name => "Kick"; + + [JsonIgnore] + public override string Description => "Read all messages from a specified Kick channel"; + + public override bool Enabled { get; set; } + + [JsonIgnore] + public override string[] Keys => ["ChannelName", "ApiKey"]; + + public override string GetConfigurationByKey(string key) + { + return key switch + { + "ChannelName" => ChannelName, + "ApiKey" => ApiKey, + "Enabled" => Enabled.ToString(), + _ => string.Empty + }; + } + + public override void SetConfigurationByKey(string key, string value) + { + switch (key) + { + case "ChannelName": + ChannelName = value; + break; + case "ApiKey": + ApiKey = value; + break; + case "Enabled": + Enabled = bool.Parse(value); + break; + default: + throw new NotImplementedException($"Unable to set value for key '{key}'"); + } + } + + /// + /// Updates this instance with values from another configuration instance + /// + /// The source configuration to copy from + protected override void UpdateFromConfiguration(KickConfiguration source) + { + ChannelName = source.ChannelName; + ApiKey = source.ApiKey; + Enabled = source.Enabled; + } + + /// + /// Public method to update configuration from another instance + /// + /// The source configuration to copy from + public void UpdateFrom(KickConfiguration source) + { + UpdateFromConfiguration(source); + } + + /// + /// Gets the configuration key used by this configuration type + /// + internal new static string GetConfigurationKey() => "provider-kick"; +} diff --git a/src/TagzApp.Providers.Kick/KickProfileRepository.cs b/src/TagzApp.Providers.Kick/KickProfileRepository.cs new file mode 100644 index 00000000..026a7a9d --- /dev/null +++ b/src/TagzApp.Providers.Kick/KickProfileRepository.cs @@ -0,0 +1,66 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace TagzApp.Providers.Kick; + +internal class KickProfileRepository +{ + private static readonly ConcurrentDictionary _ProfilePics = new(); + private readonly HttpClient _HttpClient; + + public KickProfileRepository(IConfiguration configuration, HttpClient client) + { + _HttpClient = client; + } + + public async Task GetProfilePic(string userName) + { + if (_ProfilePics.ContainsKey(userName)) + { + var (profilePic, expiry) = _ProfilePics[userName]; + + if (expiry > DateTime.UtcNow) + { + return profilePic; + } + } + + var profilePicUrl = await GetProfilePicFromKick(userName); + + _ProfilePics.AddOrUpdate(userName, (profilePicUrl, DateTime.UtcNow.AddHours(1)), (key, oldValue) => (profilePicUrl, DateTime.UtcNow.AddHours(1))); + + return profilePicUrl; + } + + private async Task GetProfilePicFromKick(string userName) + { + try + { + // Kick.com API endpoint for user information + var response = await _HttpClient.GetAsync($"https://kick.com/api/v1/users/{userName}"); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(content); + + if (doc.RootElement.TryGetProperty("profile_pic", out var profilePicElement)) + { + var profilePicUrl = profilePicElement.GetString(); + if (!string.IsNullOrEmpty(profilePicUrl)) + { + return profilePicUrl; + } + } + } + } + catch (Exception) + { + // If we can't get the profile pic, return a default + } + + // Return default avatar if we can't get the user's profile pic + return "https://kick.com/images/default-avatar.png"; + } +} diff --git a/src/TagzApp.Providers.Kick/KickProvider.cs b/src/TagzApp.Providers.Kick/KickProvider.cs new file mode 100644 index 00000000..e121e2a1 --- /dev/null +++ b/src/TagzApp.Providers.Kick/KickProvider.cs @@ -0,0 +1,290 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using System.Web; +using TagzApp.Common.Telemetry; +using TagzApp.Common.Configuration; +using TagzApp.Common.Testing; + +namespace TagzApp.Providers.Kick; + +public class KickProvider : ISocialMediaProvider, IDisposable +{ + private bool _DisposedValue; + private IChatClient? _Client; + private readonly IOptionsMonitor _ConfigMonitor; + private readonly IDisposable? _ConfigChangeSubscription; + + public string Id => "KICK"; + public string DisplayName => "Kick"; + internal const string AppSettingsSection = "provider-kick"; + public TimeSpan NewContentRetrievalFrequency => TimeSpan.FromSeconds(1); + public string Description { get; init; } = "Kick is a streaming platform where creators can livestream and chat with their audience."; + public bool Enabled => _ConfigMonitor.CurrentValue.Enabled; + + private SocialMediaStatus _Status = SocialMediaStatus.Unhealthy; + private string _StatusMessage = "Not started"; + + private static readonly ConcurrentQueue _Contents = new(); + private static readonly CancellationTokenSource _CancellationTokenSource = new(); + private readonly ILogger _Logger; + private readonly KickProfileRepository _ProfileRepository; + private readonly ProviderInstrumentation? _Instrumentation; + + public KickProvider(ILogger logger, IConfiguration configuration, HttpClient client, IOptionsMonitor configMonitor, ProviderInstrumentation? instrumentation = null) + { + _ConfigMonitor = configMonitor; + _Logger = logger; + _ProfileRepository = new KickProfileRepository(configuration, client); + _Instrumentation = instrumentation; + + // Subscribe to configuration changes + _ConfigChangeSubscription = _ConfigMonitor.OnChange(async (config, name) => + { + await HandleConfigurationChange(config); + }); + + var currentConfig = _ConfigMonitor.CurrentValue; + if (!string.IsNullOrWhiteSpace(currentConfig.Description)) + { + Description = currentConfig.Description; + } + } + + internal KickProvider(IOptions settings, ILogger logger, IChatClient chatClient) + { + // For static scenarios (testing, development) - create a static options monitor wrapper that returns the settings value + _ConfigMonitor = TagzApp.Common.Testing.OptionsMonitorExtensions.ToStaticMonitor(settings); + _ConfigChangeSubscription = null; // No change subscription for static configurations + _Logger = logger; + _ProfileRepository = null!; // Will be null for testing + ListenForMessages(chatClient); + } + + private async Task HandleConfigurationChange(KickConfiguration newConfig) + { + var previousConfig = _ConfigMonitor.CurrentValue; + + // Handle channel name change + if (_Client != null && _Client.IsRunning && previousConfig.ChannelName != newConfig.ChannelName) + { + _Client.ListenToNewChannel(newConfig.ChannelName); + } + + // Handle enabled state change + if (previousConfig.Enabled != newConfig.Enabled) + { + if (newConfig.Enabled) + { + await StartAsync(); + } + else + { + await StopAsync(); + } + } + } + + private Task ListenForMessages(IChatClient? chatClient = null) + { + var currentConfig = _ConfigMonitor.CurrentValue; + + _Status = SocialMediaStatus.Degraded; + _StatusMessage = "Starting Kick client"; + + _Client = chatClient ?? new ChatClient(currentConfig.ChannelName, currentConfig.ApiKey, _Logger); + + _Client.NewMessage += async (sender, args) => + { + string profileUrl = string.Empty; + try + { + profileUrl = await IdentifyProfilePic(args.UserName); + } + catch (Exception ex) + { + _Logger.LogError(ex, "Failed to identify profile pic for {UserName}", args.UserName); + profileUrl = "about:blank"; + } + + _Contents.Enqueue(new Content + { + Provider = Id, + ProviderId = args.MessageId, + SourceUri = new Uri($"https://kick.com/{_ConfigMonitor.CurrentValue.ChannelName}"), + Author = new Creator + { + ProfileUri = new Uri($"https://kick.com/{args.UserName}"), + ProfileImageUri = new Uri(profileUrl), + DisplayName = args.DisplayName, + UserName = $"@{args.DisplayName}" + }, + Text = HttpUtility.HtmlEncode(args.Message), + Type = ContentType.Chat, + Timestamp = args.Timestamp, + Emotes = args.Emotes + }); + }; + + try + { + _Client.Init(); + } + catch (Exception ex) + { + _Logger.LogError(ex, "Failed to initialize Kick client"); + _Status = SocialMediaStatus.Unhealthy; + _StatusMessage = $"Failed to initialize Kick client: '{ex.Message}'"; + return Task.CompletedTask; + } + + _Status = SocialMediaStatus.Healthy; + _StatusMessage = "OK"; + + return Task.CompletedTask; + } + + private async Task IdentifyProfilePic(string userName) + { + return await _ProfileRepository.GetProfilePic(userName); + } + + public Task> GetContentForHashtag(Hashtag tag, DateTimeOffset since) + { + if (!_Client?.IsRunning ?? true) + { + // mark status as unhealthy and return empty list + _Status = SocialMediaStatus.Unhealthy; + _StatusMessage = "Kick client is not running"; + + return Task.FromResult(Enumerable.Empty()); + } + + if (!_Client.IsConnected) + { + // mark status as unhealthy and return empty list + _Status = SocialMediaStatus.Unhealthy; + _StatusMessage = "Kick client is not connected - check credentials"; + + return Task.FromResult(Enumerable.Empty()); + } + else + { + _Status = SocialMediaStatus.Healthy; + _StatusMessage = "OK"; + } + + var messages = _Contents.ToList(); + if (messages.Count() == 0) return Task.FromResult(Enumerable.Empty()); + + var messageCount = messages.Count(); + for (var i = 0; i < messageCount; i++) + { + _Contents.TryDequeue(out _); + } + + _Status = SocialMediaStatus.Healthy; + _StatusMessage = "OK"; + + if (_Instrumentation is not null) + { + foreach (var username in messages?.Select(x => x.Author?.UserName)!) + { + if (!string.IsNullOrEmpty(username)) + { + _Instrumentation.AddMessage("kick", username); + } + } + } + + messages.ForEach(m => m.HashtagSought = tag.Text); + + return Task.FromResult(messages.AsEnumerable()); + } + + protected virtual void Dispose(bool disposing) + { + if (!_DisposedValue) + { + if (disposing) + { + _ConfigChangeSubscription?.Dispose(); + _Client?.Dispose(); + } + + _DisposedValue = true; + } + } + + ~KickProvider() + { + Dispose(disposing: false); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + public async Task StartAsync() + { + var currentConfig = _ConfigMonitor.CurrentValue; + + if (string.IsNullOrEmpty(currentConfig.ChannelName)) + { + _Status = SocialMediaStatus.Unhealthy; + _StatusMessage = "Kick client is not configured"; + return; + } + + await ListenForMessages(); + } + + public Task<(SocialMediaStatus Status, string Message)> GetHealth() + { + return Task.FromResult((_Status, _StatusMessage)); + } + + public Task StopAsync() + { + _Client?.Stop(); + _Status = SocialMediaStatus.Disabled; + _StatusMessage = "Kick client is stopped"; + + return Task.CompletedTask; + } + + public async Task GetConfiguration(IConfigureTagzApp configure) + { + return await BaseProviderConfiguration.CreateFromConfigurationAsync(configure); + } + + public async Task SaveConfiguration(IConfigureTagzApp configure, IProviderConfiguration providerConfiguration) + { + var kickConfig = (KickConfiguration)providerConfiguration; + await kickConfig.SaveToConfigurationAsync(configure); + + var currentConfig = _ConfigMonitor.CurrentValue; + + // handle channelname change + if (currentConfig.ChannelName != kickConfig.ChannelName) + { + _Client?.ListenToNewChannel(kickConfig.ChannelName); + } + + // Handle enabled state change + if (currentConfig.Enabled != providerConfiguration.Enabled && currentConfig.Enabled) + { + await StopAsync(); + } + else if (currentConfig.Enabled != providerConfiguration.Enabled && !currentConfig.Enabled) + { + await StartAsync(); + } + + // The IOptionsMonitor will automatically pick up the changes from the saved configuration + // No need to manually update since it's reactive + } +} diff --git a/src/TagzApp.Providers.Kick/NewMessageEventArgs.cs b/src/TagzApp.Providers.Kick/NewMessageEventArgs.cs new file mode 100644 index 00000000..16e5d641 --- /dev/null +++ b/src/TagzApp.Providers.Kick/NewMessageEventArgs.cs @@ -0,0 +1,20 @@ +namespace TagzApp.Providers.Kick; + +public class NewMessageEventArgs : EventArgs +{ + public required string DisplayName { get; set; } + + public required string UserName { get; set; } + + public required string Message { get; set; } + + public required string MessageId { get; set; } + + public string[] Badges { get; set; } = []; + + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.MinValue; + + public bool IsWhisper { get; set; } = false; + + public Emote[] Emotes { get; set; } = []; +} diff --git a/src/TagzApp.Providers.Kick/StartKick.cs b/src/TagzApp.Providers.Kick/StartKick.cs new file mode 100644 index 00000000..009dcc81 --- /dev/null +++ b/src/TagzApp.Providers.Kick/StartKick.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TagzApp.Communication.Configuration; +using TagzApp.Communication.Extensions; + +namespace TagzApp.Providers.Kick; + +public class StartKick : IConfigureProvider +{ + private const string _DisplayName = "Kick"; + + public async Task RegisterServices(IServiceCollection services, CancellationToken cancellationToken = default) + { + // Load initial configuration + var initialConfig = await BaseProviderConfiguration.CreateFromConfigurationAsync(ConfigureTagzAppFactory.Current); + + // Configure options for IOptionsMonitor + services.Configure(options => + { + options.UpdateFrom(initialConfig); + }); + + // Add a configuration reload service that can be used to update the options + services.AddSingleton, KickConfigurationSetup>(); + + services.AddHttpClient(new()); + services.AddSingleton(); + + return services; + } +} + +/// +/// Handles configuration setup for KickConfiguration +/// +public class KickConfigurationSetup : IConfigureOptions +{ + public void Configure(KickConfiguration options) + { + // This will be called when the configuration is first accessed + var config = BaseProviderConfiguration + .CreateFromConfigurationAsync(ConfigureTagzAppFactory.Current) + .GetAwaiter() + .GetResult(); + + options.UpdateFrom(config); + } +} diff --git a/src/TagzApp.Providers.Kick/TagzApp.Providers.Kick.csproj b/src/TagzApp.Providers.Kick/TagzApp.Providers.Kick.csproj new file mode 100644 index 00000000..0ab746af --- /dev/null +++ b/src/TagzApp.Providers.Kick/TagzApp.Providers.Kick.csproj @@ -0,0 +1,22 @@ + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/TagzApp.UnitTest/Kick/GivenKickConfiguration.cs b/src/TagzApp.UnitTest/Kick/GivenKickConfiguration.cs new file mode 100644 index 00000000..ea9181af --- /dev/null +++ b/src/TagzApp.UnitTest/Kick/GivenKickConfiguration.cs @@ -0,0 +1,76 @@ +using TagzApp.Providers.Kick; + +namespace TagzApp.UnitTest.Kick; + +public class GivenKickConfiguration +{ + [Fact] + public void ShouldInitializeWithCorrectDefaults() + { + // Act + var config = new KickConfiguration(); + + // Assert + Assert.Equal("Kick", config.Name); + Assert.Equal("Read all messages from a specified Kick channel", config.Description); + Assert.Equal("provider-kick", KickConfiguration.GetConfigurationKey()); + Assert.False(config.Enabled); + Assert.Empty(config.ChannelName); + Assert.Empty(config.ApiKey); + } + + [Fact] + public void ShouldHandleConfigurationByKey() + { + // Arrange + var config = new KickConfiguration(); + + // Act + config.SetConfigurationByKey("ChannelName", "test-channel"); + config.SetConfigurationByKey("ApiKey", "test-api-key"); + config.SetConfigurationByKey("Enabled", "true"); + + // Assert + Assert.Equal("test-channel", config.GetConfigurationByKey("ChannelName")); + Assert.Equal("test-api-key", config.GetConfigurationByKey("ApiKey")); + Assert.Equal("True", config.GetConfigurationByKey("Enabled")); + Assert.True(config.Enabled); + } + + [Fact] + public void ShouldReturnCorrectKeys() + { + // Arrange + var config = new KickConfiguration(); + + // Act + var keys = config.Keys; + + // Assert + Assert.Contains("ChannelName", keys); + Assert.Contains("ApiKey", keys); + Assert.Equal(2, keys.Length); + } + + [Fact] + public void ShouldUpdateFromAnotherConfiguration() + { + // Arrange + var config1 = new KickConfiguration + { + ChannelName = "channel1", + ApiKey = "key1", + Enabled = true + }; + + var config2 = new KickConfiguration(); + + // Act + config2.UpdateFrom(config1); + + // Assert + Assert.Equal("channel1", config2.ChannelName); + Assert.Equal("key1", config2.ApiKey); + Assert.True(config2.Enabled); + } +} diff --git a/src/TagzApp.UnitTest/Kick/GivenKickProvider.cs b/src/TagzApp.UnitTest/Kick/GivenKickProvider.cs new file mode 100644 index 00000000..b7eef375 --- /dev/null +++ b/src/TagzApp.UnitTest/Kick/GivenKickProvider.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using TagzApp.Providers.Kick; + +namespace TagzApp.UnitTest.Kick; + +public class GivenKickProvider +{ + [Fact] + public void ShouldInitializeCorrectly() + { + // Arrange + var mockLogger = new Mock>(); + var mockChatClient = new Mock(); + var config = new KickConfiguration + { + ChannelName = "test-channel", + ApiKey = "test-api-key", + Enabled = true + }; + var options = Options.Create(config); + + // Act + var provider = new KickProvider(options, mockLogger.Object, mockChatClient.Object); + + // Assert + Assert.Equal("KICK", provider.Id); + Assert.Equal("Kick", provider.DisplayName); + Assert.True(provider.Enabled); + Assert.Contains("Kick is a streaming platform", provider.Description); + } + + [Fact] + public void ShouldReturnCorrectNewContentRetrievalFrequency() + { + // Arrange + var mockLogger = new Mock>(); + var mockChatClient = new Mock(); + var config = new KickConfiguration + { + ChannelName = "test-channel", + Enabled = true + }; + var options = Options.Create(config); + + // Act + var provider = new KickProvider(options, mockLogger.Object, mockChatClient.Object); + + // Assert + Assert.Equal(TimeSpan.FromSeconds(1), provider.NewContentRetrievalFrequency); + } + + [Fact] + public async Task ShouldReturnHealthyStatusWhenConnected() + { + // Arrange + var mockLogger = new Mock>(); + var mockChatClient = new Mock(); + mockChatClient.Setup(x => x.IsRunning).Returns(true); + mockChatClient.Setup(x => x.IsConnected).Returns(true); + + var config = new KickConfiguration + { + ChannelName = "test-channel", + Enabled = true + }; + var options = Options.Create(config); + + var provider = new KickProvider(options, mockLogger.Object, mockChatClient.Object); + + // Act + var (status, message) = await provider.GetHealth(); + + // Assert + Assert.Equal(SocialMediaStatus.Healthy, status); + Assert.Equal("OK", message); + } +} diff --git a/src/TagzApp.UnitTest/TagzApp.UnitTest.csproj b/src/TagzApp.UnitTest/TagzApp.UnitTest.csproj index 2da809d9..a80438bd 100644 --- a/src/TagzApp.UnitTest/TagzApp.UnitTest.csproj +++ b/src/TagzApp.UnitTest/TagzApp.UnitTest.csproj @@ -15,6 +15,7 @@ + all @@ -36,6 +37,7 @@ + diff --git a/src/TagzApp.sln b/src/TagzApp.sln index bd5434ca..8bf280a5 100644 --- a/src/TagzApp.sln +++ b/src/TagzApp.sln @@ -61,6 +61,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Components", "TagzA EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Providers.Bluesky", "TagzApp.Providers.Bluesky\TagzApp.Providers.Bluesky.csproj", "{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Providers.Kick", "TagzApp.Providers.Kick\TagzApp.Providers.Kick.csproj", "{3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ZZ. Archive", "ZZ. Archive", "{940E2AF6-DD9B-44E9-A4AD-1DC0CA73964A}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.TwitchRelay", "TagzApp.TwitchRelay\TagzApp.TwitchRelay.csproj", "{A64B6AB5-2F9E-4FBD-A622-49598B904E64}" @@ -331,6 +333,10 @@ Global {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Debug|x86.Build.0 = Debug|Any CPU {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|Any CPU.Build.0 = Release|Any CPU + {3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763}.Release|Any CPU.Build.0 = Release|Any CPU {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|x64.ActiveCfg = Release|Any CPU {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|x64.Build.0 = Release|Any CPU {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|x86.ActiveCfg = Release|Any CPU @@ -432,6 +438,7 @@ Global {D135C491-2B45-49A1-B7CD-B41141475C7C} = {370455D5-6EA6-44C1-B315-B621F7B6A954} {6751D128-B522-4C1A-9B37-5271C0E6C263} = {370455D5-6EA6-44C1-B315-B621F7B6A954} {8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5} = {99E0BB3F-9591-4C7A-A8EE-C54B0C3DE7A5} + {3E1F9B9E-F6E2-4990-BEA0-A239C2E8B763} = {99E0BB3F-9591-4C7A-A8EE-C54B0C3DE7A5} {A64B6AB5-2F9E-4FBD-A622-49598B904E64} = {370455D5-6EA6-44C1-B315-B621F7B6A954} {70CA3D81-8CBB-4C63-9D8D-ADE58CCE5FE8} = {6B73C62C-6CC8-4C85-A24F-FA84489B3137} {290F15DE-B3E8-4FCA-ABF5-C223D611BA6B} = {6B73C62C-6CC8-4C85-A24F-FA84489B3137}