using System.Net.Http.Headers; using System.Text.Json; using RelayServer.Models; using RelayServer.Services.Crypto; using RelayServer.Services.Data; using RelayServer.Services.Rtc; using WebSocketSharp; using WebSocketSharp.Server; using ErrorEventArgs = WebSocketSharp.ErrorEventArgs; using RelayShared.Services; namespace RelayServer.Services.Chat; /// /// Handles all WebSocket traffic: authentication, key registration, channel management, /// encrypted chat relay, message editing/deletion, typing indicators, and edit history. /// public class ChatSocketBehavior : WebSocketBehavior { public static ClientKeyService? ClientKeyService { get; set; } public static PermissionService? PermissionService { get; set; } public static string? ServerPublicKey { get; set; } public static string? ServerPrivateKey { get; set; } public static string? ChannelDbKey { get; set; } public static ChannelCryptoService? ChannelCryptoService { get; set; } public static SurrealDb.Net.SurrealDbClient? Db { get; set; } protected override void OnMessage(MessageEventArgs e) { var msg = e.Data; try { using var doc = JsonDocument.Parse(msg); var root = doc.RootElement; if (root.TryGetProperty("Action", out var actionProp)) { var action = (WsAction)actionProp.GetInt32(); var control = JsonSerializer.Deserialize(msg)!; DispatchControl(action, control); return; } if (root.TryGetProperty("Type", out var typeProp)) { var type = (SignalType)typeProp.GetInt32(); switch (type) { case SignalType.EncryptedSignal: HandleEncryptedRtcSignal(msg); return; case SignalType.ClientEncryptedChat: HandleEncryptedChatMessage(msg); return; case SignalType.ClientEditMessage: HandleEditMessage(msg); return; case SignalType.ClientDeleteMessage: HandleDeleteMessage(msg); return; } } Console.WriteLine($"Unrecognised WS message session={ID}: {msg[..Math.Min(120, msg.Length)]}"); } catch (Exception ex) { Console.WriteLine($"WS message error session={ID}: {ex.Message}"); } } private void DispatchControl(WsAction action, WsControlMessage c) { switch (action) { case WsAction.Authenticate: HandleAuthenticate(c); break; case WsAction.RegisterKey: HandleRegisterKey(c); break; case WsAction.GetServerKey: HandleGetServerKey(); break; case WsAction.GetChannels: HandleGetChannels(); break; case WsAction.GetHistory: HandleGetHistory(c); break; case WsAction.RtcJoin: HandleRtcJoinChannel(c); break; case WsAction.RtcLeave: HandleRtcLeaveChannel(c); break; case WsAction.SendTyping: HandleTyping(c); break; case WsAction.GetEditHistory: HandleGetEditHistory(c); break; case WsAction.CreateChannel: HandleCreateChannel(c); break; case WsAction.DeleteChannel: HandleDeleteChannel(c); break; default: Console.WriteLine($"Unknown WsAction {action} session={ID}"); break; } } private async void HandleAuthenticate(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.Token)) { Console.WriteLine("Invalid Authenticate payload."); return; } try { using var core = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:1337") }; core.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); core.DefaultRequestHeaders.Add("User-Agent", "RelayServer"); var resp = await core.PostAsJsonAsync("/server/verify/user", new AuthUserVerify { Username = c.Username, Token = c.Token }); Console.WriteLine($"Auth [{c.Username}]: {await resp.Content.ReadAsStringAsync()}"); } catch (Exception ex) { Console.WriteLine($"Auth failed for {c.Username}: {ex.Message}"); } Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Authenticated, Detail = c.Username })); } private void HandleRegisterKey(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.PublicKey)) { Console.WriteLine("Invalid RegisterKey payload."); return; } if (ClientKeyService is null) { Console.WriteLine("ClientKeyService null."); return; } RegisterOrUpdateClientKeySync(c.Username, c.PublicKey); ConnectedClientService.Register(ID, c.Username); Console.WriteLine($"Key registered: {c.Username} (session={ID})"); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.KeyRegistered, Detail = c.Username })); } private void HandleGetServerKey() { if (string.IsNullOrWhiteSpace(ServerPublicKey)) { Console.WriteLine("Server public key not initialised."); return; } Send(JsonSerializer.Serialize(new ServerPublicKeyMessage { Type = SignalType.ServerPublicKey, PublicKey = ServerPublicKey })); } private void HandleGetChannels() { if (Db is null) { Console.WriteLine("Db null."); return; } // Resolve the requesting user so we can compute per-user CanPost for each channel. var username = ConnectedClientService.GetUsernameForSession(ID) ?? string.Empty; var channels = BuildChannelListForUser(username); Send(JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels })); } private void HandleGetHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId)) { Console.WriteLine("Invalid GetHistory payload."); return; } if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return; var targetClient = GetClientPublicKeyByUsernameSync(c.Username); if (targetClient is null) { Console.WriteLine($"No public key for history user {c.Username}"); return; } var messages = GetChannelMessagesSync() .Where(m => m.ChannelId == c.ChannelId) .OrderBy(m => m.CreatedAt) .ToList(); Console.WriteLine($"Sending {messages.Count} history messages to {c.Username}"); foreach (var dbMsg in messages) { var msgId = GetRecordId(dbMsg.Id); if (dbMsg.IsDeleted) { Send(JsonSerializer.Serialize(new SocketEncryptedMessage { Type = SignalType.EncryptedChat, MessageId = msgId, SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId), RecipientUsername = c.Username, ChannelId = c.ChannelId, IsDeleted = true })); continue; } string plainText; try { plainText = ChannelCryptoService.Decrypt(dbMsg.CipherText, dbMsg.Nonce, dbMsg.Tag, ChannelDbKey); } catch (Exception ex) { Console.WriteLine($"History decrypt failed {dbMsg.Id}: {ex.Message}"); continue; } var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); Send(JsonSerializer.Serialize(new SocketEncryptedMessage { Type = SignalType.EncryptedChat, MessageId = msgId, SenderUsername = ExtractUsernameFromUserId(dbMsg.SenderUserId), RecipientUsername = c.Username, ChannelId = c.ChannelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, IsEdited = dbMsg.EditedAt.HasValue })); } } private void HandleRtcJoinChannel(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.Username) || string.IsNullOrWhiteSpace(c.ChannelId)) { Console.WriteLine("Invalid RtcJoin payload."); return; } if (PermissionService is not null && !PermissionService.CanSpeakAsync(c.Username, c.ChannelId).GetAwaiter().GetResult()) { Console.WriteLine($"RTC join denied (no Speak): user={c.Username}, channel={c.ChannelId}"); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You don't have permission to speak in this channel." })); return; } RtcChannelPresenceService.SetUser(ID, c.Username); RtcChannelPresenceService.JoinChannel(ID, c.ChannelId); Console.WriteLine($"RTC join: session={ID}, user={c.Username}, channel={c.ChannelId}"); } private void HandleRtcLeaveChannel(WsControlMessage c) { if (!string.IsNullOrWhiteSpace(c.ChannelId) && RtcChannelPresenceService.IsInChannel(ID, c.ChannelId)) RtcChannelPresenceService.LeaveChannel(ID); Console.WriteLine($"RTC leave: session={ID}, user={c.Username}"); } private void HandleTyping(WsControlMessage c) { var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername) || string.IsNullOrWhiteSpace(c.ChannelId)) return; var json = JsonSerializer.Serialize(new SocketTypingEvent { Type = SignalType.TypingIndicator, Username = senderUsername, ChannelId = c.ChannelId }); foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); if (string.Equals(rawUsername, senderUsername, StringComparison.OrdinalIgnoreCase)) continue; foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername)) Sessions.SendTo(json, sid); } } private void HandleGetEditHistory(WsControlMessage c) { if (string.IsNullOrWhiteSpace(c.MessageId) || string.IsNullOrWhiteSpace(c.Username)) return; if (!EnsureCoreReady() || ChannelCryptoService is null || string.IsNullOrWhiteSpace(ChannelDbKey)) return; var targetClient = GetClientPublicKeyByUsernameSync(c.Username); if (targetClient is null) return; var edits = GetChannelMessageEditsSync(c.MessageId) .OrderBy(e => e.EditedAt) .ToList(); var entries = new List(); foreach (var edit in edits) { string plainText; try { plainText = ChannelCryptoService.Decrypt(edit.CipherText, edit.Nonce, edit.Tag, ChannelDbKey); } catch (Exception ex) { Console.WriteLine($"Edit history decrypt failed: {ex.Message}"); continue; } var encrypted = E2EeHelper.EncryptForRecipient(plainText, targetClient.PublicKey); entries.Add(new SocketEditHistoryEntry { CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, EditedAt = edit.EditedAt }); } Send(JsonSerializer.Serialize(new SocketEditHistoryResponse { Type = SignalType.EditHistory, MessageId = c.MessageId, Entries = entries })); } private void HandleCreateChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(username)) return; if (PermissionService is null || !PermissionService.CanManageChannelsAsync(username).GetAwaiter().GetResult()) { Console.WriteLine($"CreateChannel denied for {username}: insufficient permissions."); Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." })); return; } if (string.IsNullOrWhiteSpace(c.ChannelName)) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Channel name is required." })); return; } var type = (ChannelType)c.ChannelType; Task.Run(async () => await Db!.Create("channels", new Channels { Name = c.ChannelName, Type = type, Group = c.ChannelGroup ?? string.Empty, CreatedAt = DateTime.UtcNow })).GetAwaiter().GetResult(); Console.WriteLine($"Channel created: {c.ChannelName} ({type}) by {username}"); BroadcastChannelList(); } private void HandleDeleteChannel(WsControlMessage c) { var username = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(username)) return; if (PermissionService is null || !PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult()) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "Permission denied." })); return; } if (string.IsNullOrWhiteSpace(c.ChannelId)) return; var all = GetChannelsSync(); var target = all.FirstOrDefault(ch => GetRecordId(ch.Id) == c.ChannelId); if (target is null) return; target.IsDeleted = true; Task.Run(async () => await Db!.Merge(target)) .GetAwaiter().GetResult(); Console.WriteLine($"Channel deleted: {target.Name} by {username}"); BroadcastChannelList(); } private void HandleEncryptedRtcSignal(string msg) { SocketRtcSignalMessage? payload; try { payload = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse RTC signal."); return; } if (payload is null || string.IsNullOrWhiteSpace(payload.ChannelId)) return; string plainText; try { plainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"RTC decrypt failed: {ex.Message}"); return; } foreach (var sid in RtcChannelPresenceService.GetSessionsInChannel(payload.ChannelId)) { if (sid == ID) continue; var uname = RtcChannelPresenceService.GetUsernameForSession(sid); if (string.IsNullOrWhiteSpace(uname)) continue; var key = GetClientPublicKeyByUsernameSync(uname); if (key is null) continue; var enc = E2EeHelper.EncryptForRecipient(plainText, key.PublicKey); Sessions.SendTo(JsonSerializer.Serialize(new SocketRtcSignalMessage { Type = SignalType.EncryptedSignal, SenderUsername = payload.SenderUsername, ChannelId = payload.ChannelId, CipherText = enc.CipherText, Nonce = enc.Nonce, Tag = enc.Tag, EncryptedKey = enc.EncryptedKey }), sid); } } private void HandleEncryptedChatMessage(string msg) { SocketEncryptedMessage? payload; try { payload = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse chat payload."); return; } if (payload is null || payload.Type != SignalType.ClientEncryptedChat) return; if (!EnsureCoreReady() || !EnsureCryptoReady()) return; // Permission check. var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; if (PermissionService is not null && !PermissionService.CanSendMessagesAsync(senderUsername, payload.ChannelId).GetAwaiter().GetResult()) { Send(JsonSerializer.Serialize(new WsEventMessage { Event = WsEvent.Error, Detail = "You cannot send messages in this channel." })); return; } string plainText; try { plainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = payload.CipherText, Nonce = payload.Nonce, Tag = payload.Tag, EncryptedKey = payload.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"Chat decrypt failed: {ex.Message}"); return; } Console.WriteLine($"Decrypted chat from {payload.SenderUsername}"); string messageId; try { var dbEnc = ChannelCryptoService!.Encrypt(plainText, ChannelDbKey); var saved = CreateChannelMessageSync(new ChannelMessages { ChannelId = payload.ChannelId, SenderUserId = $"users:{payload.SenderUsername.ToLower()}", CipherText = dbEnc.cipherText, Nonce = dbEnc.nonce, Tag = dbEnc.tag, CreatedAt = DateTime.UtcNow }); messageId = GetRecordId(saved.Id); Console.WriteLine($"Message saved: {messageId}"); } catch (Exception ex) { Console.WriteLine($"Save failed: {ex.Message}"); return; } DeliverToServerMembers(plainText, payload.SenderUsername, payload.ChannelId, messageId, SignalType.EncryptedChat, isEdited: false); MirrorAttachmentIfNeeded(plainText, payload.SenderUsername, payload.ChannelId); } private void MirrorAttachmentIfNeeded(string plainText, string senderUsername, string originChannelId) { ChatMessageContent? content; try { content = JsonSerializer.Deserialize(plainText); } catch { return; } if (content is null || string.IsNullOrWhiteSpace(content.AttachmentBase64)) return; // The user wants images, zips, docs — but not gifs (and links/text aren't attachments anyway). var mime = content.AttachmentMimeType ?? string.Empty; if (mime.Equals("image/gif", StringComparison.OrdinalIgnoreCase)) return; var origin = GetChannelsSync().FirstOrDefault(ch => GetRecordId(ch.Id) == originChannelId); if (origin?.LinkedFileChannelId is null) return; var fileChannelId = origin.LinkedFileChannelId; if (originChannelId == fileChannelId) return; var mirror = new ChatMessageContent { Text = $"📎 Shared from #{origin.Name} by {senderUsername}", AttachmentBase64 = content.AttachmentBase64, AttachmentMimeType = content.AttachmentMimeType, AttachmentFileName = content.AttachmentFileName }; var mirrorPlain = JsonSerializer.Serialize(mirror); string mirrorId; try { var dbEnc = ChannelCryptoService!.Encrypt(mirrorPlain, ChannelDbKey); var saved = CreateChannelMessageSync(new ChannelMessages { ChannelId = fileChannelId, SenderUserId = $"users:{senderUsername.ToLower()}", CipherText = dbEnc.cipherText, Nonce = dbEnc.nonce, Tag = dbEnc.tag, CreatedAt = DateTime.UtcNow }); mirrorId = GetRecordId(saved.Id); } catch (Exception ex) { Console.WriteLine($"File mirror save failed: {ex.Message}"); return; } DeliverToServerMembers(mirrorPlain, senderUsername, fileChannelId, mirrorId, SignalType.EncryptedChat, isEdited: false); Console.WriteLine($"Mirrored attachment from {originChannelId} to file channel {fileChannelId}"); } private void HandleEditMessage(string msg) { SocketEncryptedMessage? request; try { request = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse edit request."); return; } if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return; if (!EnsureCoreReady() || !EnsureCryptoReady()) return; var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; var existing = GetChannelMessageByIdSync(request.MessageId); if (existing is null) { Console.WriteLine($"Edit: message {request.MessageId} not found."); return; } if (!string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine($"Edit denied: {senderUsername} does not own {request.MessageId}."); return; } string newPlainText; try { newPlainText = E2EeHelper.DecryptForRecipient( new EncryptedPayload { CipherText = request.CipherText, Nonce = request.Nonce, Tag = request.Tag, EncryptedKey = request.EncryptedKey }, ServerPrivateKey); } catch (Exception ex) { Console.WriteLine($"Edit decrypt failed: {ex.Message}"); return; } try { CreateChannelMessageEditSync(new ChannelMessageEdits { MessageId = request.MessageId, CipherText = existing.CipherText, Nonce = existing.Nonce, Tag = existing.Tag, EditedAt = existing.EditedAt ?? existing.CreatedAt }); } catch (Exception ex) { Console.WriteLine($"Edit history save failed: {ex.Message}"); } try { var dbEnc = ChannelCryptoService!.Encrypt(newPlainText, ChannelDbKey); existing.CipherText = dbEnc.cipherText; existing.Nonce = dbEnc.nonce; existing.Tag = dbEnc.tag; existing.EditedAt = DateTime.UtcNow; UpdateChannelMessageSync(existing); Console.WriteLine($"Message {request.MessageId} edited by {senderUsername}."); } catch (Exception ex) { Console.WriteLine($"Edit DB update failed: {ex.Message}"); return; } DeliverToServerMembers(newPlainText, senderUsername, request.ChannelId, request.MessageId, SignalType.MessageEdited, isEdited: true); } private void HandleDeleteMessage(string msg) { SocketEncryptedMessage? request; try { request = JsonSerializer.Deserialize(msg); } catch { Console.WriteLine("Failed to parse delete request."); return; } if (request is null || string.IsNullOrWhiteSpace(request.MessageId)) return; if (!EnsureCoreReady()) return; var senderUsername = ConnectedClientService.GetUsernameForSession(ID); if (string.IsNullOrWhiteSpace(senderUsername)) return; var existing = GetChannelMessageByIdSync(request.MessageId); if (existing is null) return; bool isOwner = string.Equals(ExtractUsernameFromUserId(existing.SenderUserId), senderUsername, StringComparison.OrdinalIgnoreCase); bool canManage = PermissionService?.CanManageMessagesAsync(senderUsername, request.ChannelId).GetAwaiter().GetResult() ?? false; if (!isOwner && !canManage) { Console.WriteLine($"Delete denied: {senderUsername} does not own {request.MessageId}."); return; } try { existing.IsDeleted = true; UpdateChannelMessageSync(existing); Console.WriteLine($"Message {request.MessageId} deleted by {senderUsername}."); } catch (Exception ex) { Console.WriteLine($"Delete DB update failed: {ex.Message}"); return; } var deletedEvent = JsonSerializer.Serialize(new SocketMessageDeletedEvent { Type = SignalType.MessageDeleted, MessageId = request.MessageId, ChannelId = request.ChannelId }); foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); foreach (var sid in ConnectedClientService.GetSessionsForUser(rawUsername)) Sessions.SendTo(deletedEvent, sid); } } private void DeliverToServerMembers( string plainText, string senderUsername, string channelId, string messageId, SignalType signalType, bool isEdited) { foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername); if (sessionIds.Count == 0) continue; var properUsername = sessionIds .Select(ConnectedClientService.GetUsernameForSession) .FirstOrDefault(u => u is not null) ?? rawUsername; var clientKey = GetClientPublicKeyByUsernameSync(properUsername); if (clientKey is null) { Console.WriteLine($"No public key for {properUsername}, skipping."); continue; } var encrypted = E2EeHelper.EncryptForRecipient(plainText, clientKey.PublicKey); var json = JsonSerializer.Serialize(new SocketEncryptedMessage { Type = signalType, MessageId = messageId, SenderUsername = senderUsername, RecipientUsername = properUsername, ChannelId = channelId, CipherText = encrypted.CipherText, Nonce = encrypted.Nonce, Tag = encrypted.Tag, EncryptedKey = encrypted.EncryptedKey, IsEdited = isEdited }); foreach (var sid in sessionIds) Sessions.SendTo(json, sid); } } private void BroadcastChannelList() { foreach (var member in GetServerMembersSync()) { var rawUsername = ExtractUsernameFromUserId(member.UserId); var sessionIds = ConnectedClientService.GetSessionsForUser(rawUsername); if (sessionIds.Count == 0) continue; var properUsername = sessionIds .Select(ConnectedClientService.GetUsernameForSession) .FirstOrDefault(u => u is not null) ?? rawUsername; var channels = BuildChannelListForUser(properUsername); var json = JsonSerializer.Serialize(new SocketChannelList { Type = SignalType.ChannelList, Channels = channels }); foreach (var sid in sessionIds) Sessions.SendTo(json, sid); } } private List BuildChannelListForUser(string username) { var rawChannels = GetChannelsSync() .Where(c => !c.IsDeleted) .OrderBy(c => c.CreatedAt) .ToList(); var items = new List(); foreach (var c in rawChannels) { var channelId = GetRecordId(c.Id); // "Visibility" — drop channels this user is not allowed to see. if (PermissionService is not null && !PermissionService.CanViewChannelAsync(username, channelId).GetAwaiter().GetResult()) continue; bool canPost = PermissionService is null || PermissionService.CanSendMessagesAsync(username, channelId).GetAwaiter().GetResult(); bool canManage = PermissionService is not null && (PermissionService.CanDeleteChannelAsync(username).GetAwaiter().GetResult() || PermissionService.CanEditChannelAsync(username).GetAwaiter().GetResult()); items.Add(new ChannelItem { ChannelId = channelId, Name = c.Name, Type = c.Type, Group = c.Group, IsReadOnly = c.IsReadOnly, CanPost = canPost, CanManage = canManage, CreatedAt = c.CreatedAt }); } return items; } protected override void OnClose(CloseEventArgs e) { ConnectedClientService.Unregister(ID); RtcChannelPresenceService.RemoveSession(ID); Console.WriteLine($"WS closed: session={ID}, code={e.Code}"); base.OnClose(e); } protected override void OnError(ErrorEventArgs e) { Console.WriteLine($"WS error: session={ID}, message={e.Message}"); base.OnError(e); } private void RegisterOrUpdateClientKeySync(string username, string publicKey) => Task.Run(async () => await ClientKeyService!.RegisterOrUpdateKeyAsync(username, publicKey)).GetAwaiter().GetResult(); private List GetChannelsSync() => Task.Run(async () => await Db!.Select("channels")).GetAwaiter().GetResult().ToList(); private ClientPublicKeys? GetClientPublicKeyByUsernameSync(string username) => Task.Run(async () => await ClientKeyService!.GetByUsernameAsync(username)).GetAwaiter().GetResult(); private List GetChannelMessagesSync() => Task.Run(async () => await Db!.Select("channel_messages")).GetAwaiter().GetResult().ToList(); private ChannelMessages? GetChannelMessageByIdSync(string messageId) => GetChannelMessagesSync().FirstOrDefault(m => GetRecordId(m.Id) == messageId); private ChannelMessages CreateChannelMessageSync(ChannelMessages message) => Task.Run(async () => await Db!.Create("channel_messages", message)).GetAwaiter().GetResult(); private void UpdateChannelMessageSync(ChannelMessages message) => Task.Run(async () => await Db!.Merge(message)).GetAwaiter().GetResult(); private void CreateChannelMessageEditSync(ChannelMessageEdits edit) => Task.Run(async () => await Db!.Create("channel_message_edits", edit)).GetAwaiter().GetResult(); private List GetChannelMessageEditsSync(string messageId) { var all = Task.Run(async () => await Db!.Select("channel_message_edits")) .GetAwaiter().GetResult().ToList(); return all.Where(e => e.MessageId == messageId).ToList(); } private List GetServerMembersSync() => Task.Run(async () => await Db!.Select("server_members")).GetAwaiter().GetResult().ToList(); private static string ExtractUsernameFromUserId(string senderUserId) { if (string.IsNullOrWhiteSpace(senderUserId)) return "Unknown"; var parts = senderUserId.Split(':', 2); return parts.Length == 2 ? parts[1] : senderUserId; } private static string GetRecordId(object? id) { if (id is null) return string.Empty; var json = JsonSerializer.Serialize(id); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; return $"{root.GetProperty("Table").GetString()}:{root.GetProperty("Id").GetString()}"; } private bool EnsureCoreReady() { if (ClientKeyService is null || Db is null) { Console.WriteLine("Core services null."); return false; } return true; } private bool EnsureCryptoReady() { if (string.IsNullOrWhiteSpace(ServerPrivateKey) || string.IsNullOrWhiteSpace(ChannelDbKey) || ChannelCryptoService is null) { Console.WriteLine("Crypto keys null."); return false; } return true; } }