diff --git a/KfChatDotNetBot/Commands/AdminCommands.cs b/KfChatDotNetBot/Commands/AdminCommands.cs index 31bdc89..e05f588 100644 --- a/KfChatDotNetBot/Commands/AdminCommands.cs +++ b/KfChatDotNetBot/Commands/AdminCommands.cs @@ -156,6 +156,74 @@ public class ReconnectKickCommand : ICommand } } +public class NewPartiChannelCommand : ICommand +{ + public List Patterns => [ + new Regex(@"^admin parti add (?\d+) (?\S+) (?\S+) (?true|false)$"), + new Regex(@"^admin parti add (?\d+) (?\S+) (?\S+)$") + + ]; + + public string? HelpText => "Add a Parti channel to the bot's database"; + public UserRight RequiredRight => UserRight.Admin; + public TimeSpan Timeout => TimeSpan.FromSeconds(10); + public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) + { + var autoCapture = false; + if (arguments.TryGetValue("auto_capture", out var argument)) + { + autoCapture = argument.Value == "true"; + } + var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels)).JsonDeserialize>(); + var username = arguments["username"].Value; + channels ??= []; + if (channels.Any(channel => channel.Username == username)) + { + await botInstance.SendChatMessageAsync("Channel is already in the database", true); + return; + } + + var forumId = Convert.ToInt32(arguments["forum_id"].Value); + channels.Add(new PartiChannelModel + { + Username = username, + ForumId = forumId, + AutoCapture = autoCapture, + SocialMedia = arguments["social"].Value + }); + + await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.PartiChannels, channels); + await botInstance.SendChatMessageAsync("Updated list of channels", true); + } +} + +public class RemovePartiChannelCommand : ICommand +{ + public List Patterns => [ + new Regex(@"^admin parti remove (?\S+)$") + ]; + + public string? HelpText => "Remove a Parti channel from the bot's database"; + public UserRight RequiredRight => UserRight.Admin; + public TimeSpan Timeout => TimeSpan.FromSeconds(10); + public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx) + { + var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels)).JsonDeserialize>(); + if (channels == null) throw new Exception("Caught a null when deserializing Parti channels"); + var username = arguments["username"].Value; + var channel = channels.FirstOrDefault(ch => ch.Username == username); + if (channel == null) + { + await botInstance.SendChatMessageAsync("Channel is not in the database", true); + return; + } + channels.Remove(channel); + + await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.PartiChannels, channels); + await botInstance.SendChatMessageAsync("Updated list of channels", true); + } +} + public class AddCourtHearingCommand : ICommand { public List Patterns => [ diff --git a/KfChatDotNetBot/Commands/RestreamCommands.cs b/KfChatDotNetBot/Commands/RestreamCommands.cs index 28bd98e..2f06940 100644 --- a/KfChatDotNetBot/Commands/RestreamCommands.cs +++ b/KfChatDotNetBot/Commands/RestreamCommands.cs @@ -74,25 +74,33 @@ public class SelfPromoCommand : ICommand CancellationToken ctx) { var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize>(); - if (channels == null) + var partiChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels).Result + .JsonDeserialize>(); + if (channels == null || partiChannels == null) { - await botInstance.SendChatMessageAsync("For some reason the list of Kick channels deserialized to null", true); + await botInstance.SendChatMessageAsync("For some reason the list of Kick or Parti channels deserialized to null", true); return; } + var userChannels = channels.Where(ch => ch.ForumId == user.KfId).ToList(); - if (userChannels.Count == 0) + var userPartiChannels = partiChannels.Where(ch => ch.ForumId == user.KfId).ToList(); + + if (userChannels.Count == 0 && userPartiChannels.Count == 0) { await botInstance.SendChatMessageAsync("You have no streams.", true); return; } - - if (userChannels.Count == 1) - { - await botInstance.SendChatMessageAsync($"@{user.KfUsername} is a weirdo who streams. Come check out his channel at https://kick.com/{userChannels[0].ChannelSlug}", true); - return; - } - var streamList = userChannels.Aggregate(string.Empty, (current, stream) => current + $"[br]- https://kick.com/{stream.ChannelSlug}"); + foreach (var stream in userPartiChannels) + { + var url = $"https://parti.com/creator/{stream.SocialMedia}/{stream.Username}/"; + if (stream.SocialMedia == "discord") + { + url += "0"; + } + + streamList += $"[br]- {url}"; + } await botInstance.SendChatMessageAsync( $"@{user.KfUsername} is a weirdo who streams a lot. His channels are at: {streamList}", true); diff --git a/KfChatDotNetBot/Models/BotServicesModels.cs b/KfChatDotNetBot/Models/BotServicesModels.cs index 6ac6a6d..8f9b3e0 100644 --- a/KfChatDotNetBot/Models/BotServicesModels.cs +++ b/KfChatDotNetBot/Models/BotServicesModels.cs @@ -16,4 +16,12 @@ public class CourtHearingModel public required string Description { get; set; } public required DateTimeOffset Time { get; set; } public required string CaseNumber { get; set; } +} + +public class PartiChannelModel +{ + public required string Username { get; set; } + public required int ForumId { get; set; } + public bool AutoCapture { get; set; } = false; + public required string SocialMedia { get; set; } } \ No newline at end of file diff --git a/KfChatDotNetBot/Models/PartiModels.cs b/KfChatDotNetBot/Models/PartiModels.cs new file mode 100644 index 0000000..429afe5 --- /dev/null +++ b/KfChatDotNetBot/Models/PartiModels.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace KfChatDotNetBot.Models; + +public class PartiChannelLiveNotificationModel +{ + [JsonPropertyName("livestream_id")] + public required int LivestreamId { get; set; } + [JsonPropertyName("user_id")] + public required int UserId { get; set; } + [JsonPropertyName("user_name")] + public required string Username { get; set; } + [JsonPropertyName("user_avatar_id")] + public int UserAvatarId { get; set; } + [JsonPropertyName("avatar_link")] + public Uri? AvatarLink { get; set; } + [JsonPropertyName("event_id")] + public required int EventId { get; set; } + [JsonPropertyName("event_title")] + public required string EventTitle { get; set; } + [JsonPropertyName("event_file")] + public Uri? EventFile { get; set; } + [JsonPropertyName("category_name")] + public string? CategoryName { get; set; } + [JsonPropertyName("viewers_count")] + public int? ViewersCount { get; set; } + [JsonPropertyName("social_media")] + public required string SocialMedia { get; set; } + [JsonPropertyName("social_username")] + public required string SocialUsername { get; set; } + [JsonPropertyName("channel_arn")] + public required string ChannelArn { get; set; } + +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index cb7c69a..e0a05ee 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -32,6 +32,7 @@ public class BotServices private BetBolt? _betBolt; private Yeet? _yeet; public AlmanacShill? AlmanacShill; + private Parti? _parti; private Task? _websocketWatchdog; private Task? _howlggGetUserTimer; @@ -293,6 +294,20 @@ public class BotServices _logger.Info("Built the almanac shill task"); } + private async Task BuildParti() + { + var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.Proxy, BuiltIn.Keys.PartiEnabled]); + if (!settings[BuiltIn.Keys.PartiEnabled].ToBoolean()) + { + _logger.Debug("Parti is disabled"); + return; + } + _parti = new Parti(settings[BuiltIn.Keys.YeetProxy].Value, _cancellationToken); + _parti.OnPartiChannelLiveNotification += OnPartiChannelLiveNotification; + await _parti.StartWsClient(); + _logger.Info("Built Parti Websocket connection"); + } + private async Task WebsocketWatchdog() { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); @@ -907,6 +922,45 @@ public class BotServices _ = new YtDlpCapture($"https://kick.com/{channel.ChannelSlug}", _cancellationToken).CaptureAsync(); } } + + private void OnPartiChannelLiveNotification(object sender, PartiChannelLiveNotificationModel data) + { + var settings = SettingsProvider + .GetMultipleValuesAsync([BuiltIn.Keys.PartiChannels, BuiltIn.Keys.CaptureEnabled]).Result; + var channels = settings[BuiltIn.Keys.PartiChannels].JsonDeserialize>(); + if (channels == null) + { + _logger.Error("Caught a null when deserializing Parti channels"); + return; + } + + var channel = channels.FirstOrDefault(ch => ch.Username == data.Username); + if (channel == null) + { + _logger.Debug($"Got a Parti live notification for a channel we don't care about: {data.Username}"); + return; + } + + using var db = new ApplicationDbContext(); + var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId); + if (user == null) + { + _logger.Error($"Caught a null when retrieving forum ID {channel.ForumId}"); + return; + } + + var url = $"https://parti.com/creator/{data.SocialMedia}/{data.Username}/"; + if (data.SocialMedia == "discord") + { + url += "0"; + } + _chatBot.SendChatMessage($"@{user.KfUsername} is live! {data.EventTitle} {url}", true); + if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) + { + _logger.Info($"{channel.Username} is configured to auto capture"); + _ = new YtDlpCapture(url, _cancellationToken).CaptureAsync(); + } + } private void OnStopStreamBroadcast(object sender, KickModels.StopStreamBroadcastEventModel? e) { diff --git a/KfChatDotNetBot/Services/Parti.cs b/KfChatDotNetBot/Services/Parti.cs new file mode 100644 index 0000000..fcfdf33 --- /dev/null +++ b/KfChatDotNetBot/Services/Parti.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; +using KfChatDotNetBot.Models; +using NLog; +using Websocket.Client; + +namespace KfChatDotNetBot.Services; + +public class Parti : IDisposable +{ + private Logger _logger = LogManager.GetCurrentClassLogger(); + private WebsocketClient? _wsClient; + private Uri _wsUri = new("wss://ws-backend.parti.com/"); + // Parti can go a long ass time without sending a message and there's no ping/pong + // So let's just set a really high timeout + private int _reconnectTimeout = 300; + private string? _proxy; + public delegate void OnPartiChannelLiveNotificationEventHandler(object sender, PartiChannelLiveNotificationModel data); + public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo e); + public event OnPartiChannelLiveNotificationEventHandler? OnPartiChannelLiveNotification; + public event OnWsDisconnectionEventHandler? OnWsDisconnection; + private CancellationToken _cancellationToken; + + public Parti(string? proxy = null, CancellationToken cancellationToken = default) + { + _proxy = proxy; + _cancellationToken = cancellationToken; + _logger.Info("Parti WebSocket client created"); + } + + public async Task StartWsClient() + { + _logger.Debug("StartWsClient() called, creating client"); + await CreateWsClient(); + } + + private async Task CreateWsClient() + { + var factory = new Func(() => + { + var clientWs = new ClientWebSocket(); + clientWs.Options.SetRequestHeader("Origin", "https://parti.com"); + clientWs.Options.SetRequestHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0"); + if (_proxy == null) return clientWs; + _logger.Debug($"Using proxy address {_proxy}"); + clientWs.Options.Proxy = new WebProxy(_proxy); + return clientWs; + }); + + var client = new WebsocketClient(_wsUri, factory) + { + ReconnectTimeout = TimeSpan.FromSeconds(_reconnectTimeout), + IsReconnectionEnabled = false + }; + + client.ReconnectionHappened.Subscribe(WsReconnection); + client.MessageReceived.Subscribe(WsMessageReceived); + client.DisconnectionHappened.Subscribe(WsDisconnection); + + _wsClient = client; + + _logger.Debug("Websocket client has been built, about to start"); + await client.Start(); + _logger.Debug("Websocket client started!"); + } + + public bool IsConnected() + { + return _wsClient is { IsRunning: true }; + } + + private void WsDisconnection(DisconnectionInfo disconnectionInfo) + { + _logger.Error($"Client disconnected from Parti (or never successfully connected). Type is {disconnectionInfo.Type}"); + _logger.Error($"Close Status => {disconnectionInfo.CloseStatus}; Close Status Description => {disconnectionInfo.CloseStatusDescription}"); + _logger.Error(disconnectionInfo.Exception); + OnWsDisconnection?.Invoke(this, disconnectionInfo); + } + + private void WsReconnection(ReconnectionInfo reconnectionInfo) + { + _logger.Error($"Websocket connection dropped and reconnected. Reconnection type is {reconnectionInfo.Type}"); + if (reconnectionInfo.Type == ReconnectionType.Initial) + { + _logger.Info("Sending subscribe payload to Parti"); + _wsClient?.Send("{\"subscribe_options\":{\"NewChannelLiveNotify\":{}}}"); + + } + } + + private void WsMessageReceived(ResponseMessage message) + { + if (message.Text == null) + { + _logger.Info("Parti sent a null message"); + return; + } + _logger.Trace($"Received event from Parti: {message.Text}"); + + try + { + var payload = JsonSerializer.Deserialize(message.Text); + if (payload == null) + { + throw new Exception( + "Caught a null when trying to deserialize the Parti livestream notification payload"); + } + OnPartiChannelLiveNotification?.Invoke(this, payload); + } + catch (Exception e) + { + _logger.Error("Failed to handle message from Parti"); + _logger.Error(e); + _logger.Error("--- Payload ---"); + _logger.Error(message.Text); + _logger.Error("--- End of Payload ---"); + } + } + + public void Dispose() + { + _wsClient?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index ea7a489..32b58a9 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -776,6 +776,21 @@ public static class BuiltIn Description = "Path to store the temporary .sh script used to initiate the capture", Default = "/tmp/", ValueType = SettingValueType.Text + }, + new BuiltInSettingsModel + { + Key = Keys.PartiEnabled, + Regex = "(true|false)", + Description = "Whether the Parti stream notification service is enabled", + Default = "true", + ValueType = SettingValueType.Boolean + }, + new BuiltInSettingsModel + { + Key = Keys.PartiChannels, + Description = "JSON of all the Parti channels to listen to", + Default = "[]", + ValueType = SettingValueType.Complex } ]; @@ -866,5 +881,7 @@ public static class BuiltIn public static string CaptureEnabled = "Capture.Enabled"; public static string CaptureYtDlpParentTerminal = "Capture.YtDlp.ParentTerminal"; public static string CaptureYtDlpScriptPath = "Capture.YtDlp.ScriptPath"; + public static string PartiEnabled = "Parti.Enabled"; + public static string PartiChannels = "Parti.Channels"; } } \ No newline at end of file