mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-05-02 04:22:04 -04:00
Initial commit
This commit is contained in:
285
KfChatDotNetWsClient/ChatClient.cs
Normal file
285
KfChatDotNetWsClient/ChatClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
23
KfChatDotNetWsClient/KfChatDotNetWsClient.csproj
Normal file
23
KfChatDotNetWsClient/KfChatDotNetWsClient.csproj
Normal 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>
|
||||
12
KfChatDotNetWsClient/Models/ChatClientConfigModel.cs
Normal file
12
KfChatDotNetWsClient/Models/ChatClientConfigModel.cs
Normal 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; }
|
||||
}
|
||||
28
KfChatDotNetWsClient/Models/Events/EventHandlers.cs
Normal file
28
KfChatDotNetWsClient/Models/Events/EventHandlers.cs
Normal 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);
|
||||
}
|
||||
12
KfChatDotNetWsClient/Models/Events/MessageModel.cs
Normal file
12
KfChatDotNetWsClient/Models/Events/MessageModel.cs
Normal 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; }
|
||||
}
|
||||
10
KfChatDotNetWsClient/Models/Events/UserModel.cs
Normal file
10
KfChatDotNetWsClient/Models/Events/UserModel.cs
Normal 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; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace KfChatDotNetWsClient.Models.Json;
|
||||
|
||||
public class DeleteMessagesJsonModel
|
||||
{
|
||||
[JsonProperty("delete")]
|
||||
public List<int> MessageIdsToDelete { get; set; }
|
||||
}
|
||||
12
KfChatDotNetWsClient/Models/Json/EditMessageJsonModel.cs
Normal file
12
KfChatDotNetWsClient/Models/Json/EditMessageJsonModel.cs
Normal 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; }
|
||||
}
|
||||
59
KfChatDotNetWsClient/Models/Json/MessagesJsonModel.cs
Normal file
59
KfChatDotNetWsClient/Models/Json/MessagesJsonModel.cs
Normal 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; }
|
||||
}
|
||||
32
KfChatDotNetWsClient/Models/Json/UsersJsonModel.cs
Normal file
32
KfChatDotNetWsClient/Models/Json/UsersJsonModel.cs
Normal 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; }
|
||||
}
|
||||
15
KfChatDotNetWsClient/NLog.config
Normal file
15
KfChatDotNetWsClient/NLog.config
Normal 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>
|
||||
3483
KfChatDotNetWsClient/NLog.xsd
Normal file
3483
KfChatDotNetWsClient/NLog.xsd
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user