Added Parti stream integration

This commit is contained in:
barelyprofessional
2025-07-09 23:31:49 -05:00
parent 7171acacfd
commit d22138a9f9
7 changed files with 325 additions and 10 deletions

View File

@@ -156,6 +156,74 @@ public class ReconnectKickCommand : ICommand
}
}
public class NewPartiChannelCommand : ICommand
{
public List<Regex> Patterns => [
new Regex(@"^admin parti add (?<forum_id>\d+) (?<social>\S+) (?<username>\S+) (?<auto_capture>true|false)$"),
new Regex(@"^admin parti add (?<forum_id>\d+) (?<social>\S+) (?<username>\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<List<PartiChannelModel>>();
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<Regex> Patterns => [
new Regex(@"^admin parti remove (?<username>\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<List<PartiChannelModel>>();
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<Regex> Patterns => [

View File

@@ -74,25 +74,33 @@ public class SelfPromoCommand : ICommand
CancellationToken ctx)
{
var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
if (channels == null)
var partiChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels).Result
.JsonDeserialize<List<PartiChannelModel>>();
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);

View File

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

View File

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

View File

@@ -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<List<PartiChannelModel>>();
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)
{

View File

@@ -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<ClientWebSocket>(() =>
{
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<PartiChannelLiveNotificationModel>(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);
}
}

View File

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