Initial commit

This commit is contained in:
barelyprofessional
2024-03-25 20:11:49 +08:00
commit 9f92fc8e27
62 changed files with 17831 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
using System.Net;
using System.Net.WebSockets;
using KfChatDotNetWsClient.Models;
using KfChatDotNetWsClient.Models.Events;
using KfChatDotNetWsClient.Models.Json;
using Newtonsoft.Json;
using NLog;
using Websocket.Client;
// It's a fucking lie. You must use conditional access or you WILL get NullReferenceErrors if an event is not in use
// ReSharper disable ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
namespace KfChatDotNetWsClient;
public class ChatClient
{
public event EventHandlers.OnMessagesEventHandler OnMessages;
public event EventHandlers.OnUsersPartedEventHandler OnUsersParted;
public event EventHandlers.OnUsersJoinedEventHandler OnUsersJoined;
public event EventHandlers.OnWsReconnectEventHandler OnWsReconnect;
public event EventHandlers.OnDeleteMessagesEventHandler OnDeleteMessages;
public event EventHandlers.OnWsDisconnectionEventHandler OnWsDisconnection;
public event EventHandlers.OnFailedToJoinRoom OnFailedToJoinRoom;
public event EventHandlers.OnUnknownCommand OnUnknownCommand;
private WebsocketClient _wsClient;
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
private ChatClientConfigModel _config;
public ChatClient(ChatClientConfigModel config)
{
_config = config;
}
public void UpdateConfig(ChatClientConfigModel config)
{
_config = config;
}
public void UpdateToken(string newToken)
{
_config.XfSessionToken = newToken;
}
public async Task StartWsClient()
{
_wsClient = await CreateWsClient();
}
public void Disconnect()
{
_wsClient.Stop(WebSocketCloseStatus.NormalClosure, "Closing websocket").Wait();
}
private async Task<WebsocketClient> CreateWsClient()
{
var factory = new Func<ClientWebSocket>(() =>
{
var clientWs = new ClientWebSocket();
// Guest mode
if (_config.XfSessionToken == null)
{
return clientWs;
}
var cookieContainer = new CookieContainer();
cookieContainer.Add(new Cookie("xf_session", _config.XfSessionToken, "/", _config.CookieDomain));
clientWs.Options.Cookies = cookieContainer;
if (_config.Proxy != null)
{
clientWs.Options.Proxy = new WebProxy(_config.Proxy);
}
return clientWs;
});
var client = new WebsocketClient(_config.WsUri, factory)
{
ReconnectTimeout = TimeSpan.FromSeconds(_config.ReconnectTimeout)
};
client.ReconnectionHappened.Subscribe(WsReconnection);
client.MessageReceived.Subscribe(WsMessageReceived);
client.DisconnectionHappened.Subscribe(WsDisconnection);
_logger.Debug("Websocket client has been built, about to start");
await client.Start();
_logger.Debug("Websocket client started!");
return client;
}
public bool IsConnected()
{
return _wsClient is { IsRunning: true };
}
private void WsDisconnection(DisconnectionInfo disconnectionInfo)
{
_logger.Error($"Client disconnected from the chat (or never successfully connected). Type is {disconnectionInfo.Type}");
_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.Error("Not firing the reconnection event as this is the initial event");
return;
}
OnWsReconnect?.Invoke(this, reconnectionInfo);
}
private void WsMessageReceived(ResponseMessage message)
{
if (message.Text == null)
{
_logger.Info("Websocket message was null, ignoring packet");
return;
}
if (message.Text.StartsWith("You cannot join this room"))
{
_logger.Debug("Got a message saying we failed to join the room");
OnFailedToJoinRoom?.Invoke(this, message.Text);
return;
}
if (message.Text.StartsWith("Unknown command"))
{
_logger.Debug("Unknown command received");
OnUnknownCommand?.Invoke(this, message.Text);
return;
}
Dictionary<string, object> packetType = new Dictionary<string, object>();
try
{
packetType = JsonConvert.DeserializeObject<Dictionary<string, object>>(message.Text)!;
}
catch (Exception e)
{
_logger.Error("Failed to parse packet");
_logger.Error(e);
_logger.Error($"Packet contents: {message.Text}");
return;
}
_logger.Debug($"Received packet from KF: {string.Join(',', packetType.Keys)}");
// Message(s) received
if (packetType.ContainsKey("messages"))
{
_logger.Debug("Looks like it's a chat message");
WsChatMessagesReceived(message);
return;
}
// User(s) joined
if (packetType.ContainsKey("users"))
{
_logger.Debug("Looks like this is a user(s) joined packet");
WsChatUsersJoined(message);
return;
}
// User(s) parted
if (packetType.ContainsKey("user"))
{
_logger.Debug("Looks like this is a user(s) parted packet");
WsChatUsersParted(message);
return;
}
if (packetType.ContainsKey("delete"))
{
_logger.Debug($"Looks like this is a message deletion packet");
WsDeleteMessagesReceived(message);
return;
}
_logger.Info($"Received packet this was not handled: {message.Text}");
}
public void JoinRoom(int roomId)
{
_logger.Debug($"Joining {roomId}");
_wsClient.Send($"/join {roomId}");
}
public void SendMessage(string message)
{
_logger.Debug($"Sending '{message}'");
_wsClient.Send(message);
}
public void DeleteMessage(int messageId)
{
_logger.Debug($"Deleting {messageId}");
_wsClient.Send($"/delete {messageId}");
}
public void EditMessage(int messageId, string newMessage)
{
// Explicitly set formatting to none as it must be inline (Newtonsoft will do this by default but just wanting to be explicit)
var payload = JsonConvert.SerializeObject(new EditMessageJsonModel {Id = messageId, Message = newMessage},
Formatting.None);
_logger.Debug($"Editing {messageId} with '{newMessage}'");
_wsClient.Send($"/edit {payload}");
}
private void WsDeleteMessagesReceived(ResponseMessage message)
{
var data = JsonConvert.DeserializeObject<DeleteMessagesJsonModel>(message.Text);
_logger.Debug($"Received delete packet for messages: {string.Join(',', data.MessageIdsToDelete)}");
OnDeleteMessages?.Invoke(this, data.MessageIdsToDelete);
}
private void WsChatMessagesReceived(ResponseMessage message)
{
var data = JsonConvert.DeserializeObject<MessagesJsonModel>(message.Text);
var messages = new List<MessageModel>();
foreach (var chatMessage in data.Messages)
{
var model = new MessageModel
{
Author = new UserModel
{
Id = chatMessage.Author.Id,
Username = chatMessage.Author.Username,
AvatarUrl = chatMessage.Author.AvatarUrl,
// It isn't sent on chat messages
LastActivity = null
},
Message = chatMessage.Message,
MessageId = chatMessage.MessageId,
MessageRaw = chatMessage.MessageRaw,
RoomId = chatMessage.RoomId
};
if(chatMessage.MessageEditDate == 0)
{
model.MessageEditDate = null;
}
else
{
model.MessageEditDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageEditDate);
}
model.MessageDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageDate);
messages.Add(model);
}
_logger.Debug($"Received {messages.Count} chat messages");
if (messages.Count == 1)
{
_logger.Debug($"{JsonConvert.SerializeObject(messages[0], Formatting.Indented)}");
}
OnMessages?.Invoke(this, messages, data);
}
private void WsChatUsersJoined(ResponseMessage message)
{
var data = JsonConvert.DeserializeObject<UsersJsonModel>(message.Text);
var users = new List<UserModel>();
foreach (var user in data.Users.Keys)
{
users.Add(new UserModel
{
Id = int.Parse(user),
Username = data.Users[user].Username,
AvatarUrl = data.Users[user].AvatarUrl,
LastActivity = DateTimeOffset.FromUnixTimeSeconds(data.Users[user].LastActivity)
});
}
var usersJoined= data.Users.Select(user => int.Parse(user.Key)).ToList();
_logger.Debug($"Following users have joined: {string.Join(',', usersJoined)}");
OnUsersJoined?.Invoke(this, users, data);
}
private void WsChatUsersParted(ResponseMessage message)
{
// {"user":{"1337":false}}
var data = JsonConvert.DeserializeObject<Dictionary<string, Dictionary<string, bool>>>(message.Text);
var usersParted = data!["user"].Select(user => int.Parse(user.Key)).ToList();
_logger.Debug($"Following users have parted: {string.Join(',', usersParted)}");
OnUsersParted?.Invoke(this, usersParted);
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>default</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.2.8" />
<PackageReference Include="Websocket.Client" Version="5.1.1" />
</ItemGroup>
<ItemGroup>
<None Remove="NLog.config" />
<Content Include="NLog.config">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace KfChatDotNetWsClient.Models;
public class ChatClientConfigModel
{
// XF session token. Sent as a cookie to auth the user
public string? XfSessionToken { get; set; }
// Currently wss://kiwifarms.net/chat.ws
public Uri WsUri { get; set; }
public int ReconnectTimeout { get; set; } = 30;
public string CookieDomain { get; set; } = "kiwifarms.net";
public string? Proxy { get; set; }
}

View File

@@ -0,0 +1,28 @@
using KfChatDotNetWsClient.Models.Json;
using Websocket.Client;
namespace KfChatDotNetWsClient.Models.Events;
public class EventHandlers
{
public delegate void OnMessagesEventHandler(object sender, List<MessageModel> messages,
MessagesJsonModel jsonPayload);
// When a user first joins the chat, this event will fire with the entire user list (which may be massive)
// But when users join in the course of a regular chat, it'll be one at a time
public delegate void OnUsersJoinedEventHandler(object sender, List<UserModel> users, UsersJsonModel jsonPayload);
// Usually only one user parts at a time, but theoretically the model could support more than one at a time
public delegate void OnUsersPartedEventHandler(object sender, List<int> userIds);
public delegate void OnWsReconnectEventHandler(object sender, ReconnectionInfo reconnectionInfo);
// Usually only one is sent at a time but it is a list hence the pluralization
public delegate void OnDeleteMessagesEventHandler(object sender, List<int> messageIds);
public delegate void OnWsDisconnectionEventHandler(object sender, DisconnectionInfo disconnectionInfo);
public delegate void OnFailedToJoinRoom(object sender, string message);
public delegate void OnUnknownCommand(object sender, string message);
}

View File

@@ -0,0 +1,12 @@
namespace KfChatDotNetWsClient.Models.Events;
public class MessageModel
{
public UserModel Author { get; set; }
public string Message { get; set; }
public int MessageId { get; set; }
public DateTimeOffset? MessageEditDate { get; set; }
public DateTimeOffset MessageDate { get; set; }
public string MessageRaw { get; set; }
public int RoomId { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace KfChatDotNetWsClient.Models.Events;
public class UserModel
{
public int Id { get; set; }
public string Username { get; set; }
public Uri AvatarUrl { get; set; }
// Unset if it's related to a chat message
public DateTimeOffset? LastActivity { get; set; }
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace KfChatDotNetWsClient.Models.Json;
public class DeleteMessagesJsonModel
{
[JsonProperty("delete")]
public List<int> MessageIdsToDelete { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Newtonsoft.Json;
namespace KfChatDotNetWsClient.Models.Json;
public class EditMessageJsonModel
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
}

View File

@@ -0,0 +1,59 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace KfChatDotNetWsClient.Models.Json;
// {
// "messages": [
// {
// "author": {
// "id": 110635,
// "username": "felted",
// "avatar_url": "https://kiwifarms.net/data/avatars/m/110/110635.jpg?1657300618"
// },
// "message": "Nigger.",
// "message_id": 4390866,
// "message_edit_date": 0,
// "message_date": 1657317093,
// "message_raw": "Nigger.",
// "room_id": 10
// }
// ]
// }
// message_raw contains the original bbcode for the message
// message is the HTML-version the web client renders with emotes transformed into images, etc.
public class MessagesJsonModel
{
public class AuthorModel
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("username")]
public string Username { get; set; }
[JsonProperty("avatar_url")]
public Uri AvatarUrl { get; set; }
}
public class MessageModel
{
[JsonProperty("author")]
public AuthorModel Author { get; set; }
[JsonProperty("message")]
public string Message { get; set; }
[JsonProperty("message_id")]
public int MessageId { get; set; }
[JsonProperty("message_edit_date")]
public int MessageEditDate { get; set; }
[JsonProperty("message_date")]
public int MessageDate { get; set; }
[JsonProperty("message_raw")]
public string MessageRaw { get; set; }
[JsonProperty("room_id")]
public int RoomId { get; set; }
}
[JsonProperty("messages")]
public List<MessageModel> Messages { get; set; }
}

View File

@@ -0,0 +1,32 @@
using Newtonsoft.Json;
namespace KfChatDotNetWsClient.Models.Json;
// {
// "users": {
// "1337": {
// "id": 1337,
// "username": "Example User",
// "avatar_url": "https://kiwifarms.net/data/avatars/m/13/1337.jpg?1648885311",
// "last_activity": 1657316000
// }
// }
// }
public class UsersJsonModel
{
public class UserModel
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("username")]
public string Username { get; set; }
[JsonProperty("avatar_url")]
public Uri AvatarUrl { get; set; }
[JsonProperty("last_activity")]
public int LastActivity { get; set; }
}
[JsonProperty("users")]
public Dictionary<string, UserModel> Users { get; set; }
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
autoReload="true"
throwExceptions="false"
internalLogLevel="Off" internalLogFile="~/nlog-internal.log">
<targets>
<target xsi:type="Console" name="console"/>
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="console" />
</rules>
</nlog>

File diff suppressed because it is too large Load Diff