Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="9.4.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.44.0" />
<PackageVersion Include="Moq" Version="4.20.70" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.6.146" />
<PackageVersion Include="Npgsql" Version="9.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
Expand All @@ -95,6 +96,7 @@
<PackageVersion Include="PSC.Blazor.Components.MarkdownEditor" Version="8.0.5" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.31" />
<PackageVersion Include="System.Net.Http.Json" Version="9.0.8" />
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
<PackageVersion Include="xunit" Version="2.8.1" />
<PackageVersion Include="xunit.analyzers" Version="1.14.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.1" />
Expand Down
94 changes: 94 additions & 0 deletions src/TagzApp.Blazor.Client/Components/Admin/Kick.Config.Ui.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
@using System.ComponentModel.DataAnnotations
@inject ToastService ToastService
@inject IConfigureTagzApp Config

<UiProviderConfig ProviderName="Kick" Health="@Health" ProviderIconCssClass="bi-chat-dots">

<EditForm Model="Model" OnValidSubmit="SaveConfig">
<AntiforgeryToken />
<ValidationSummary />

<div class="alert-info" style="padding: 4px; margin-bottom: 10px;">
<p>Configure the Kick chat provider to aggregate messages from Kick streams.</p>
<p>Enter your Kick channel name to start receiving chat messages. The API key is optional and can be used for authenticated requests if needed.</p>
</div>

<dl>
<dt><label for="ChannelName">Channel Name:</label></dt>
<dd>
<InputText id="ChannelName" name="ChannelName" @bind-Value="Model.ChannelName" placeholder="Your Kick channel name" />
<ValidationMessage For="() => Model.ChannelName" class="text-danger" />
</dd>
<dt><label for="ApiKey">API Key (Optional):</label></dt>
<dd>
<InputText id="ApiKey" type="password" name="ApiKey" @bind-Value="Model.ApiKey" placeholder="Optional API key" />
<ValidationMessage For="() => Model.ApiKey" class="text-danger" />
</dd>
<dt><label for="Enabled">Enabled:</label></dt>
<dd>
<InputCheckbox id="Enabled" name="Enabled" @bind-Value="Model.Enabled" />
</dd>
</dl>

<button type="submit" class="btn btn-primary">Save</button>

</EditForm>

</UiProviderConfig>

@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; }
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,11 @@
<Bluesky_Config_Ui Provider="@Provider" />
break;
}
case nameof(KickProvider):
{
<Kick_Config_Ui Provider="@Provider" />
break;
}
case nameof(MastodonProvider):
{
<Mastodon_Config_Ui Provider="@Provider" />
Expand Down
1 change: 1 addition & 0 deletions src/TagzApp.Blazor/TagzApp.Blazor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<ProjectReference Include="..\TagzApp.Providers.AzureQueue\TagzApp.Providers.AzureQueue.csproj" />
<ProjectReference Include="..\TagzApp.Providers.Blazot\TagzApp.Providers.Blazot.csproj" />
<ProjectReference Include="..\TagzApp.Providers.Bluesky\TagzApp.Providers.Bluesky.csproj" />
<ProjectReference Include="..\TagzApp.Providers.Kick\TagzApp.Providers.Kick.csproj" />
<ProjectReference Include="..\TagzApp.Providers.Mastodon\TagzApp.Providers.Mastodon.csproj" />
<ProjectReference Include="..\TagzApp.Providers.TwitchChat\TagzApp.Providers.TwitchChat.csproj" />
<ProjectReference Include="..\TagzApp.Providers.Twitter\TagzApp.Providers.Twitter.csproj" />
Expand Down
203 changes: 203 additions & 0 deletions src/TagzApp.Providers.Kick/ChatClient.cs
Original file line number Diff line number Diff line change
@@ -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<NewMessageEventArgs>? 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<byte>(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<byte>(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<KickChatMessage>(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; }
}
}
16 changes: 16 additions & 0 deletions src/TagzApp.Providers.Kick/IChatClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace TagzApp.Providers.Kick;

public interface IChatClient : IDisposable
{
event EventHandler<NewMessageEventArgs> NewMessage;

void Init();

void Stop();

bool IsRunning { get; }

bool IsConnected { get; }

void ListenToNewChannel(string channelName);
}
Loading
Loading