Files
KfChatDotNet/KfChatDotNetWsClient/ChatClient.cs
barelyprofessional b40fdf1a7c Ignore null MOTDs
2026-03-19 13:34:03 -05:00

501 lines
17 KiB
C#

using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Unicode;
using KfChatDotNetWsClient.Models;
using KfChatDotNetWsClient.Models.Events;
using KfChatDotNetWsClient.Models.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;
public event EventHandlers.OnPermissionsEventHandler? OnPermissions;
public event EventHandlers.OnSystemMessage? OnSystemMessage;
public event EventHandlers.OnMotdEventHandler? OnMotd;
public event EventHandlers.OnWhisperEventHandler? OnWhisper;
private WebsocketClient? _wsClient;
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
private ChatClientConfigModel _config;
public DateTime LastPacketReceived = DateTime.UtcNow;
public ChatClient(ChatClientConfigModel config)
{
_config = config;
}
public void UpdateConfig(ChatClientConfigModel config)
{
_config = config;
}
public void UpdateCookies(Dictionary<string, string> cookies)
{
_config.Cookies = cookies;
}
public async Task StartWsClient()
{
await CreateWsClient();
}
public void Disconnect()
{
if (_wsClient == null) throw new WebSocketNotInitializedException();
_wsClient.Stop(WebSocketCloseStatus.NormalClosure, "Closing websocket").Wait();
}
// Bot is inconsistent with what methods are async and not but I don't want to change the Disconnect() method to be
// async as then it'll fuck up anyone not waiting for it. This is why there's an explicit async method for this but
// none for reconnect.
public async Task DisconnectAsync()
{
if (_wsClient == null) throw new WebSocketNotInitializedException();
await _wsClient.Stop(WebSocketCloseStatus.NormalClosure, "Closing websocket");
}
public async Task ReconnectAsync()
{
if (_wsClient == null) throw new WebSocketNotInitializedException();
await _wsClient.Reconnect();
}
private async Task CreateWsClient()
{
var factory = new Func<ClientWebSocket>(() =>
{
var clientWs = new ClientWebSocket();
if (_config.Proxy != null)
{
clientWs.Options.Proxy = new WebProxy(_config.Proxy);
}
// Guest mode
if (_config.Cookies.Keys.Count == 0)
{
return clientWs;
}
var cookieContainer = new CookieContainer();
foreach (var key in _config.Cookies.Keys)
{
cookieContainer.Add(new Cookie(key, _config.Cookies[key], "/", _config.CookieDomain));
}
clientWs.Options.Cookies = cookieContainer;
return clientWs;
});
var client = new WebsocketClient(_config.WsUri, factory)
{
ReconnectTimeout = TimeSpan.FromSeconds(_config.ReconnectTimeout)
};
_wsClient = client;
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!");
}
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($"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}");
OnWsReconnect?.Invoke(this, reconnectionInfo);
}
private void WsMessageReceived(ResponseMessage message)
{
LastPacketReceived = DateTime.UtcNow;
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;
try
{
packetType = JsonSerializer.Deserialize<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("permissions"))
{
_logger.Debug("Looks like we got permissions");
WsPermissions(message);
return;
}
if (packetType.ContainsKey("system"))
{
_logger.Debug("Looks like a system message");
WsSystemMessage(message);
return;
}
if (packetType.ContainsKey("delete"))
{
_logger.Debug($"Looks like this is a message deletion packet");
WsDeleteMessagesReceived(message);
return;
}
if (packetType.ContainsKey("whisper"))
{
_logger.Debug("Looks like this is a whisper packet");
WsWhisper(message);
return;
}
if (packetType.ContainsKey("motd"))
{
_logger.Debug("Looks like this is an MOTD packet");
WsMotd(message);
return;
}
_logger.Info($"Received packet this was not handled: {message.Text}");
}
public void JoinRoom(int roomId)
{
_logger.Debug($"Joining {roomId}");
if (_wsClient == null) throw new WebSocketNotInitializedException();
_wsClient.Send($"/join {roomId}");
}
public void SendMessage(string message)
{
_logger.Debug($"Sending '{message}'");
if (_wsClient == null) throw new WebSocketNotInitializedException();
_wsClient.Send(message);
}
public async Task SendMessageInstantAsync(string message)
{
_logger.Debug($"Sending '{message}', bypassing the queue");
if (_wsClient == null) throw new WebSocketNotInitializedException();
await _wsClient.SendInstant(message);
}
public void DeleteMessage(int messageId)
{
_logger.Debug($"Deleting {messageId}");
if (_wsClient == null) throw new WebSocketNotInitializedException();
_wsClient.Send($"/delete {messageId}");
}
public async Task DeleteMessageAsync(string messageUuid)
{
_logger.Debug($"Deleting {messageUuid}");
if (_wsClient == null) throw new WebSocketNotInitializedException();
await _wsClient.SendInstant($"/delete {messageUuid}");
}
public void EditMessage(string messageUuid, string newMessage)
{
var payload = JsonSerializer.Serialize(new EditMessageJsonModel {Uuid = messageUuid, Message = newMessage});
_logger.Debug($"Editing {messageUuid} with '{newMessage}'");
if (_wsClient == null) throw new WebSocketNotInitializedException();
_wsClient.Send($"/edit {payload}");
}
public async Task EditMessageAsync(string messageUuid, string newMessage)
{
var settings = new TextEncoderSettings();
settings.AllowRange(UnicodeRanges.All);
var options = new JsonSerializerOptions()
{
Encoder = JavaScriptEncoder.Create(settings)
};
var payload = JsonSerializer.Serialize(new EditMessageJsonModel {Uuid = messageUuid, Message = newMessage}, options);
_logger.Debug($"Editing {messageUuid} with '{newMessage}'");
if (_wsClient == null) throw new WebSocketNotInitializedException();
var msg = $"/edit {payload}";
var length = Encoding.UTF8.GetByteCount(msg);
if (length > 2048)
{
_logger.Error($"Edit message is too long at {length} bytes");
}
await _wsClient.SendInstant($"/edit {payload}");
}
public async Task SetMotd(string messageUuid)
{
_logger.Debug($"Setting {messageUuid} as the MOTD");
if (_wsClient == null) throw new WebSocketNotInitializedException();
await _wsClient.SendInstant($"/motd {messageUuid}");
}
private void WsDeleteMessagesReceived(ResponseMessage message)
{
var data = JsonSerializer.Deserialize<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 = JsonSerializer.Deserialize<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,
MessageUuid = chatMessage.MessageUuid,
MessageRaw = chatMessage.MessageRaw,
RoomId = chatMessage.RoomId,
MessageRawHtmlDecoded = WebUtility.HtmlDecode(chatMessage.MessageRaw),
MessageDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageDate)
};
if (chatMessage.MessageEditDate == 0)
{
model.MessageEditDate = null;
}
else
{
model.MessageEditDate = DateTimeOffset.FromUnixTimeSeconds(chatMessage.MessageEditDate);
}
messages.Add(model);
}
_logger.Debug($"Received {messages.Count} chat messages");
if (messages.Count == 1)
{
_logger.Debug($"{JsonSerializer.Serialize(messages[0])}");
}
try
{
OnMessages?.Invoke(this, messages, data);
}
catch (Exception e)
{
_logger.Error("Our handler for chat messages threw an exception");
_logger.Error(e);
}
}
private void WsChatUsersJoined(ResponseMessage message)
{
var data = JsonSerializer.Deserialize<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 = JsonSerializer.Deserialize<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);
}
private void WsPermissions(ResponseMessage message)
{
var data = JsonSerializer.Deserialize<JsonElement>(message.Text!);
var permissions = data.GetProperty("permissions").Deserialize<PermissionsJsonModel>();
try
{
OnPermissions?.Invoke(this, permissions!);
}
catch (Exception e)
{
_logger.Error("Caught error when invoking OnPermissions");
_logger.Error(e);
}
}
private void WsSystemMessage(ResponseMessage message)
{
var msg = JsonSerializer.Deserialize<JsonElement>(message.Text!).GetProperty("system").GetString();
try
{
OnSystemMessage?.Invoke(this, msg!);
}
catch (Exception e)
{
_logger.Error("Caught error when invoking OnSystemMessage");
_logger.Error(e);
}
}
private void WsWhisper(ResponseMessage message)
{
var data = JsonSerializer.Deserialize<JsonElement>(message.Text!).GetProperty("whisper")
.Deserialize<WhisperJsonModel>();
var model = new WhisperModel
{
Author = new UserModel
{
Id = data!.Author.Id,
Username = data.Author.Username,
AvatarUrl = data.Author.AvatarUrl
},
Recipient = new UserModel
{
Id = data.Recipient.Id,
Username = data.Recipient.Username,
AvatarUrl = data.Recipient.AvatarUrl
},
Message = data.Message,
MessageRaw = data.MessageRaw,
MessageRawHtmlDecoded = WebUtility.HtmlDecode(data.MessageRaw),
MessageDate = DateTimeOffset.FromUnixTimeSeconds(data.MessageDate)
};
try
{
OnWhisper?.Invoke(this, model);
}
catch (Exception e)
{
_logger.Error("WS handler for whisper threw an exception when invoking OnWhisper");
_logger.Error(e);
}
}
private void WsMotd(ResponseMessage message)
{
var motdElement = JsonSerializer.Deserialize<JsonElement>(message.Text!).GetProperty("motd");
//.Deserialize<MessagesJsonModel.MessageModel>();
if (motdElement.ValueKind == JsonValueKind.Null) return;
var msg = motdElement.Deserialize<MessagesJsonModel.MessageModel>();
var model = new MessageModel
{
Author = new UserModel
{
Id = msg!.Author.Id,
Username = msg.Author.Username,
AvatarUrl = msg.Author.AvatarUrl,
// It isn't sent on chat messages
LastActivity = null
},
Message = msg.Message,
MessageUuid = msg.MessageUuid,
MessageRaw = msg.MessageRaw,
RoomId = msg.RoomId,
MessageRawHtmlDecoded = WebUtility.HtmlDecode(msg.MessageRaw),
MessageDate = DateTimeOffset.FromUnixTimeSeconds(msg.MessageDate)
};
if (msg.MessageEditDate == 0)
{
model.MessageEditDate = null;
}
else
{
model.MessageEditDate = DateTimeOffset.FromUnixTimeSeconds(msg.MessageEditDate);
}
try
{
OnMotd?.Invoke(this, model);
}
catch (Exception e)
{
_logger.Error("The handler for MOTD messages threw an exception");
_logger.Error(e);
}
}
}
public class WebSocketNotInitializedException : Exception;