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. + + + + Channel Name: + + + + + API Key (Optional): + + + + + Enabled: + + + + + + Save + + + + + +@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}
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.