From 2eae48325a4cd0efa1b38809ba48552b4cd752d4 Mon Sep 17 00:00:00 2001 From: flashwave Date: Fri, 24 May 2024 16:01:11 +0000 Subject: [PATCH] Issue user disconnected and kick/ban as events and restructure table. --- SharpChat.SockChat/Commands/KickBanCommand.cs | 7 +- .../PacketsS2C/ForceDisconnectS2CPacket.cs | 6 +- SharpChat.SockChat/SockChatContext.cs | 123 +++++++++++------- SharpChat.SockChat/SockChatServer.cs | 10 +- SharpChatCommon/ChannelInfo.cs | 4 + SharpChatCommon/EventStorage/IEventStorage.cs | 2 +- .../EventStorage/MariaDBEventStorage.cs | 72 +++++----- .../MariaDBEventStorage_Migrations.cs | 17 +++ .../EventStorage/VirtualEventStorage.cs | 4 +- .../Events/UserKickBanEventData.cs | 26 ++++ SharpChatCommon/UsersContext.cs | 3 +- 11 files changed, 176 insertions(+), 98 deletions(-) create mode 100644 SharpChatCommon/Events/UserKickBanEventData.cs diff --git a/SharpChat.SockChat/Commands/KickBanCommand.cs b/SharpChat.SockChat/Commands/KickBanCommand.cs index 800ff6a..c2418e9 100644 --- a/SharpChat.SockChat/Commands/KickBanCommand.cs +++ b/SharpChat.SockChat/Commands/KickBanCommand.cs @@ -1,4 +1,5 @@ -using SharpChat.Misuzu; +using SharpChat.Events; +using SharpChat.Misuzu; using SharpChat.SockChat.PacketsS2C; using System; using System.Linq; @@ -53,7 +54,7 @@ namespace SharpChat.SockChat.Commands { } if(duration <= TimeSpan.Zero) { - ctx.Chat.BanUser(banUser, duration); + ctx.Chat.Events.Dispatch("user:kickban", banUser, new UserKickBanEventData(UserDisconnectReason.Kicked, TimeSpan.Zero)); return; } @@ -78,7 +79,7 @@ namespace SharpChat.SockChat.Commands { duration, banReason ); - ctx.Chat.BanUser(banUser, duration); + ctx.Chat.Events.Dispatch("user:kickban", banUser, new UserKickBanEventData(UserDisconnectReason.Kicked, duration)); }).Wait(); } } diff --git a/SharpChat.SockChat/PacketsS2C/ForceDisconnectS2CPacket.cs b/SharpChat.SockChat/PacketsS2C/ForceDisconnectS2CPacket.cs index db2ad82..d1589ee 100644 --- a/SharpChat.SockChat/PacketsS2C/ForceDisconnectS2CPacket.cs +++ b/SharpChat.SockChat/PacketsS2C/ForceDisconnectS2CPacket.cs @@ -4,11 +4,9 @@ namespace SharpChat.SockChat.PacketsS2C { public class ForceDisconnectS2CPacket : ISockChatS2CPacket { private readonly long Expires; - public ForceDisconnectS2CPacket() { - } - public ForceDisconnectS2CPacket(DateTimeOffset expires) { - Expires = expires.Year >= 2100 ? -1 : expires.ToUnixTimeSeconds(); + Expires = expires <= DateTimeOffset.UtcNow + ? 0 : (expires.Year >= 2100 ? -1 : expires.ToUnixTimeSeconds()); } public string Pack() { diff --git a/SharpChat.SockChat/SockChatContext.cs b/SharpChat.SockChat/SockChatContext.cs index 8e69c5e..e681295 100644 --- a/SharpChat.SockChat/SockChatContext.cs +++ b/SharpChat.SockChat/SockChatContext.cs @@ -29,6 +29,11 @@ namespace SharpChat { // user status should be stored outside of the UserInfo class so we don't need to do this: UserInfo? userInfo = Users.Get(info.SenderId); + if(!string.IsNullOrWhiteSpace(info.ChannelName)) + ChannelsUsers.SetUserLastChannel(info.SenderId, info.ChannelName); + + // TODO: should user:connect and user:disconnect be channel agnostic? + switch(info.Type) { case "user:connect": SendTo(info.ChannelName, new UserConnectS2CPacket( @@ -43,13 +48,61 @@ namespace SharpChat { break; case "user:disconnect": - SendTo(info.ChannelName, new UserDisconnectS2CPacket( - info.Id, - info.Created, - info.SenderId, - userInfo == null ? SockChatUtility.GetUserName(info) : SockChatUtility.GetUserNameWithStatus(userInfo), - info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave - )); + if(userInfo != null) + UpdateUser(userInfo, status: UserStatus.Offline); + Users.Remove(info.SenderId); + + ChannelInfo[] channels = Channels.GetMany(ChannelsUsers.GetUserChannelNames(info.SenderId)); + ChannelsUsers.DeleteUser(info.SenderId); + + if(channels.Length > 0) { + UserDisconnectS2CPacket udPacket = new( + info.Id, + info.Created, + info.SenderId, + userInfo == null ? SockChatUtility.GetUserName(info) : SockChatUtility.GetUserNameWithStatus(userInfo), + info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave + ); + + foreach(ChannelInfo chan in channels) { + if(chan.IsTemporary && chan.IsOwner(info.SenderId)) + RemoveChannel(chan); + else + SendTo(chan, udPacket); + } + } + break; + + case "user:kickban": + if(info.Data is not UserKickBanEventData userBaka) + break; + + SendTo(info.SenderId, new ForceDisconnectS2CPacket(userBaka.Expires)); + + ConnectionInfo[] conns = Connections.GetUser(info.SenderId); + foreach(ConnectionInfo conn in conns) { + Connections.Remove(conn); + if(conn is SockChatConnectionInfo scConn) + scConn.Close(1000); + + Logger.Write($"<{conn.RemoteEndPoint}> Nuked connection from banned user #{conn.UserId}."); + } + + string bakaChannelName = ChannelsUsers.GetUserLastChannel(info.SenderId); + if(!string.IsNullOrWhiteSpace(bakaChannelName)) + Events.Dispatch(new ChatEventInfo( + SharpId.Next(), + "user:disconnect", + info.Created, + bakaChannelName, + info.SenderId, + info.SenderName, + info.SenderColour, + info.SenderRank, + info.SenderNickName, + info.SenderPerms, + new UserDisconnectEventData(userBaka.Reason) + )); break; case "chan:join": @@ -147,7 +200,12 @@ namespace SharpChat { foreach(UserInfo user in Users.All) if(!Connections.HasUser(user)) { - HandleDisconnect(user, UserDisconnectReason.TimeOut); + Events.Dispatch( + "user:disconnect", + ChannelsUsers.GetUserLastChannel(user), + user, + new UserDisconnectEventData(UserDisconnectReason.TimeOut) + ); Logger.Write($"Timed out {user} (no more connections)."); } } @@ -241,25 +299,6 @@ namespace SharpChat { } } - public void BanUser(UserInfo user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) { - if(duration > TimeSpan.Zero) { - DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration; - SendTo(user, new ForceDisconnectS2CPacket(expires)); - } else - SendTo(user, new ForceDisconnectS2CPacket()); - - ConnectionInfo[] conns = Connections.GetUser(user); - foreach(ConnectionInfo conn in conns) { - Connections.Remove(conn); - if(conn is SockChatConnectionInfo scConn) - scConn.Close(1000); - - Logger.Write($"<{conn.RemoteEndPoint}> Nuked connection from banned user #{conn.UserId}."); - } - - HandleDisconnect(user, reason); - } - public void HandleChannelEventLog(string channelName, Action handler) { foreach(ChatEventInfo info in EventStorage.GetChannelEventLog(channelName)) { switch(info.Type) { @@ -355,21 +394,6 @@ namespace SharpChat { ChannelsUsers.Join(chan.Name, user.UserId); } - public void HandleDisconnect(UserInfo user, UserDisconnectReason reason = UserDisconnectReason.Leave) { - UpdateUser(user, status: UserStatus.Offline); - Users.Remove(user.UserId); - - ChannelInfo[] channels = GetUserChannels(user); - ChannelsUsers.DeleteUser(user); - - foreach(ChannelInfo chan in channels) { - Events.Dispatch("user:disconnect", chan, user, new UserDisconnectEventData(reason)); - - if(chan.IsTemporary && chan.IsOwner(user)) - RemoveChannel(chan); - } - } - public void SwitchChannel(UserInfo user, ChannelInfo chan, string password) { if(ChannelsUsers.IsUserLastChannel(user, chan)) { ForceChannel(user); @@ -434,15 +458,22 @@ namespace SharpChat { } public void SendTo(UserInfo user, ISockChatS2CPacket packet) { - string data = packet.Pack(); - Connections.WithUser(user, conn => { + SendTo(user.UserId, packet.Pack()); + } + + public void SendTo(long userId, ISockChatS2CPacket packet) { + SendTo(userId, packet.Pack()); + } + + public void SendTo(long userId, string packet) { + Connections.WithUser(userId, conn => { if(conn is SockChatConnectionInfo scConn) - scConn.Send(data); + scConn.Send(packet); }); } public void SendTo(ChannelInfo channel, ISockChatS2CPacket packet) { - SendTo(channel, packet.Pack()); + SendTo(channel.Name, packet.Pack()); } public void SendTo(ChannelInfo channel, string packet) { diff --git a/SharpChat.SockChat/SockChatServer.cs b/SharpChat.SockChat/SockChatServer.cs index 05f5da3..94d7d39 100644 --- a/SharpChat.SockChat/SockChatServer.cs +++ b/SharpChat.SockChat/SockChatServer.cs @@ -1,5 +1,6 @@ using Fleck; using SharpChat.Config; +using SharpChat.Events; using SharpChat.EventStorage; using SharpChat.Misuzu; using SharpChat.SockChat.Commands; @@ -156,7 +157,12 @@ namespace SharpChat.SockChat { if(!Context.Connections.HasUser(conn.UserId)) { UserInfo? userInfo = Context.Users.Get(conn.UserId); if(userInfo != null) - Context.HandleDisconnect(userInfo); + Context.Events.Dispatch( + "user:disconnect", + Context.ChannelsUsers.GetUserLastChannel(userInfo), + userInfo, + new UserDisconnectEventData(UserDisconnectReason.Leave) + ); } Context.Update(); @@ -199,7 +205,7 @@ namespace SharpChat.SockChat { if(banDuration == TimeSpan.MinValue) { Context.SendTo(userInfo, new FloodWarningS2CPacket()); } else { - Context.BanUser(userInfo, banDuration, UserDisconnectReason.Flood); + Context.Events.Dispatch("user:kickban", userInfo, new UserKickBanEventData(UserDisconnectReason.Flood, banDuration)); if(banDuration > TimeSpan.Zero) Misuzu.CreateBanAsync( diff --git a/SharpChatCommon/ChannelInfo.cs b/SharpChatCommon/ChannelInfo.cs index aa643d8..fafda1e 100644 --- a/SharpChatCommon/ChannelInfo.cs +++ b/SharpChatCommon/ChannelInfo.cs @@ -26,6 +26,10 @@ OwnerId = ownerId; } + public bool IsOwner(long userId) { + return OwnerId > 0 + && OwnerId == userId; + } public bool IsOwner(UserInfo user) { return OwnerId > 0 && user != null diff --git a/SharpChatCommon/EventStorage/IEventStorage.cs b/SharpChatCommon/EventStorage/IEventStorage.cs index 38d3171..a7d9d80 100644 --- a/SharpChatCommon/EventStorage/IEventStorage.cs +++ b/SharpChatCommon/EventStorage/IEventStorage.cs @@ -4,6 +4,6 @@ using System.Collections.Generic; namespace SharpChat.EventStorage { public interface IEventStorage : IChatEventHandler { ChatEventInfo? GetEvent(long eventId); - IEnumerable GetChannelEventLog(string channelName, int amount = 20, int startAt = 0); + IEnumerable GetChannelEventLog(string channelName, int amount = 20, int after = 0); } } diff --git a/SharpChatCommon/EventStorage/MariaDBEventStorage.cs b/SharpChatCommon/EventStorage/MariaDBEventStorage.cs index 57a0a32..e2f3d4f 100644 --- a/SharpChatCommon/EventStorage/MariaDBEventStorage.cs +++ b/SharpChatCommon/EventStorage/MariaDBEventStorage.cs @@ -33,30 +33,27 @@ namespace SharpChat.EventStorage { } public void HandleEvent(ChatEventInfo info) { - MySqlParameter dataParam = new("data", MySqlDbType.Blob) { - Value = JsonSerializer.SerializeToUtf8Bytes(info.Data, info.Data.GetType(), jsonOpts) - }; - RunCommand( - "INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_data`" - + ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)" - + " VALUES (@id, NOW(), @type, @target, @data" - + ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)", + "INSERT INTO sqc_events (event_id, event_type, event_created, event_channel, event_data" + + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms" + + ") VALUES (@id, @type, FROM_UNIXTIME(@created), @channel, @data" + + ", @senderId, @senderName, @senderColour, @senderRank, @senderNickName, @senderPerms)", new MySqlParameter("id", info.Id), new MySqlParameter("type", info.Type), - new MySqlParameter("target", string.IsNullOrWhiteSpace(info.ChannelName) ? null : info.ChannelName), - dataParam, - new MySqlParameter("sender", info.SenderId < 1 ? null : info.SenderId), - new MySqlParameter("sender_name", info.SenderName), - new MySqlParameter("sender_colour", info.SenderColour.ToMisuzu()), - new MySqlParameter("sender_rank", info.SenderRank), - new MySqlParameter("sender_nick", info.SenderNickName), - new MySqlParameter("sender_perms", info.SenderPerms) + new MySqlParameter("created", info.Created.ToUnixTimeSeconds()), + new MySqlParameter("channel", string.IsNullOrWhiteSpace(info.ChannelName) ? null : info.ChannelName), + new MySqlParameter("data", JsonSerializer.Serialize(info.Data, info.Data.GetType(), jsonOpts)), + new MySqlParameter("senderId", info.SenderId < 1 ? null : info.SenderId), + new MySqlParameter("senderName", info.SenderName), + new MySqlParameter("senderColour", info.SenderColour.ToMisuzu()), + new MySqlParameter("senderRank", info.SenderRank), + new MySqlParameter("senderNickName", string.IsNullOrWhiteSpace(info.SenderNickName) ? null : info.SenderNickName), + new MySqlParameter("senderPerms", info.SenderPerms) ); if(info.Type.Equals("msg:delete") && info.Data is MessageDeleteEventData msgDelete) RunCommand( - "UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL", + "UPDATE IGNORE sqc_events SET event_deleted = NOW() WHERE event_id = @id AND event_deleted IS NULL", new MySqlParameter("id", msgDelete.MessageId) ); } @@ -64,12 +61,10 @@ namespace SharpChat.EventStorage { public ChatEventInfo? GetEvent(long eventId) { try { using MySqlDataReader? reader = RunQuery( - "SELECT `event_id`, `event_type`, `event_data`, `event_target`" - + ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`" - + ", UNIX_TIMESTAMP(`event_created`) AS `event_created`" - + ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`" - + " FROM `sqc_events`" - + " WHERE `event_id` = @id", + "SELECT event_id, event_type, event_channel, event_data" + + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms" + + ", UNIX_TIMESTAMP(event_created) AS event_created, UNIX_TIMESTAMP(event_deleted) AS event_deleted" + + " FROM sqc_events WHERE event_id = @id", new MySqlParameter("id", eventId) ); @@ -87,43 +82,42 @@ namespace SharpChat.EventStorage { } private static ChatEventInfo ReadEvent(MySqlDataReader reader) { - string eventType = Encoding.ASCII.GetString((byte[])reader["event_type"]); + string eventType = reader.GetString("event_type"); ChatEventData eventData = EventDataTypes.ContainsKey(eventType) - ? (ChatEventData)(JsonSerializer.Deserialize((byte[])reader["event_data"], EventDataTypes[eventType]) ?? ChatEventData.EmptyInstance) + ? (ChatEventData)(JsonSerializer.Deserialize(reader.GetString("event_data"), EventDataTypes[eventType]) ?? ChatEventData.EmptyInstance) : ChatEventData.EmptyInstance; return new ChatEventInfo( reader.GetInt64("event_id"), eventType, DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")), - reader.IsDBNull(reader.GetOrdinal("event_target")) ? string.Empty : Encoding.ASCII.GetString((byte[])reader["event_target"]), + reader.GetString("event_channel"), reader.IsDBNull(reader.GetOrdinal("event_sender")) ? -1 : reader.GetInt64("event_sender"), reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"), reader.IsDBNull(reader.GetOrdinal("event_sender_colour")) ? Colour.None : Colour.FromMisuzu(reader.GetInt32("event_sender_colour")), - reader.GetInt32("event_sender_rank"), + reader.IsDBNull(reader.GetOrdinal("event_sender_rank")) ? 0 : reader.GetInt32("event_sender_rank"), reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? null : reader.GetString("event_sender_nick"), - (UserPermissions)reader.GetInt32("event_sender_perms"), + (UserPermissions)(reader.IsDBNull(reader.GetOrdinal("event_sender_perms")) ? 0 : reader.GetInt32("event_sender_perms")), eventData ); } - public IEnumerable GetChannelEventLog(string channelName, int amount = 20, int startAt = 0) { + public IEnumerable GetChannelEventLog(string channelName, int amount = 20, int after = 0) { List events = new(); try { using MySqlDataReader? reader = RunQuery( - "SELECT `event_id`, `event_type`, `event_data`, `event_target`" - + ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`" - + ", UNIX_TIMESTAMP(`event_created`) AS `event_created`" - + ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`" - + " FROM `sqc_events`" - + " WHERE `event_deleted` IS NULL AND (`event_target` = @target OR `event_target` IS NULL)" - + " AND `event_id` > @offset" - + " ORDER BY `event_id` DESC" + "SELECT event_id, event_type, event_channel, event_data" + + ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms" + + ", UNIX_TIMESTAMP(event_created) AS event_created" + + " FROM sqc_events WHERE event_deleted IS NULL" + + " AND (event_channel IS NULL OR event_channel = @channel)" + + " AND event_id > @after" + + " ORDER BY event_id DESC" + " LIMIT @amount", - new MySqlParameter("target", channelName), + new MySqlParameter("channel", channelName), new MySqlParameter("amount", amount), - new MySqlParameter("offset", startAt) + new MySqlParameter("after", after) ); if(reader != null) diff --git a/SharpChatCommon/EventStorage/MariaDBEventStorage_Migrations.cs b/SharpChatCommon/EventStorage/MariaDBEventStorage_Migrations.cs index 4db32bc..5aa2307 100644 --- a/SharpChatCommon/EventStorage/MariaDBEventStorage_Migrations.cs +++ b/SharpChatCommon/EventStorage/MariaDBEventStorage_Migrations.cs @@ -32,6 +32,23 @@ namespace SharpChat.EventStorage { DoMigration("allow_null_target", AllowNullTarget); DoMigration("update_event_type_names", UpdateEventTypeNames); DoMigration("deprecate_event_flags", DeprecateEventFlags); + DoMigration("update_collations_and_use_json_type", UpdateCollationsAndUseJsonType); + } + + private void UpdateCollationsAndUseJsonType() { + RunCommand("UPDATE sqc_events SET event_target = LOWER(event_target)"); + RunCommand("UPDATE sqc_events SET event_sender_nick = NULL WHERE event_sender_nick = ''"); + RunCommand( + "ALTER TABLE `sqc_events` COLLATE='utf8mb4_unicode_520_ci'," + + " CHANGE COLUMN `event_type` `event_type` VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci' AFTER `event_id`," + + " CHANGE COLUMN `event_created` `event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp() AFTER `event_type`," + + " CHANGE COLUMN `event_deleted` `event_deleted` TIMESTAMP NULL DEFAULT NULL AFTER `event_created`," + + " CHANGE COLUMN `event_target` `event_channel` VARCHAR(255) NULL DEFAULT NULL COLLATE 'ascii_general_ci' AFTER `event_deleted`," + + " CHANGE COLUMN `event_sender_name` `event_sender_name` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender`," + + " CHANGE COLUMN `event_sender_nick` `event_sender_nick` VARCHAR(255) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER `event_sender_rank`," + + " CHANGE COLUMN `event_data` `event_data` JSON NOT NULL DEFAULT '{}' AFTER `event_sender_perms`," + + " DROP INDEX `event_target`, ADD INDEX `event_channel` (`event_channel`)" + ); } private void DeprecateEventFlags() { diff --git a/SharpChatCommon/EventStorage/VirtualEventStorage.cs b/SharpChatCommon/EventStorage/VirtualEventStorage.cs index 58f7ac7..b9dc9b8 100644 --- a/SharpChatCommon/EventStorage/VirtualEventStorage.cs +++ b/SharpChatCommon/EventStorage/VirtualEventStorage.cs @@ -17,10 +17,10 @@ namespace SharpChat.EventStorage { return Events.TryGetValue(eventId.ToString(), out ChatEventInfo? evt) ? evt : null; } - public IEnumerable GetChannelEventLog(string channelName, int amount = 20, int startAt = 0) { + public IEnumerable GetChannelEventLog(string channelName, int amount = 20, int after = 0) { IEnumerable subset = Events.Values.Where(ev => ev.ChannelName == channelName); - int start = subset.Count() - startAt - amount; + int start = subset.Count() - after - amount; if(start < 0) { amount += start; start = 0; diff --git a/SharpChatCommon/Events/UserKickBanEventData.cs b/SharpChatCommon/Events/UserKickBanEventData.cs new file mode 100644 index 0000000..17ea4b9 --- /dev/null +++ b/SharpChatCommon/Events/UserKickBanEventData.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.Json.Serialization; + +namespace SharpChat.Events { + [ChatEventDataFor("user:kickban")] + public class UserKickBanEventData : ChatEventData { + [JsonPropertyName("reason")] + public UserDisconnectReason Reason { get; } + + [JsonPropertyName("expires")] + public DateTimeOffset Expires { get; } + + public UserKickBanEventData( + UserDisconnectReason reason, + DateTimeOffset expires + ) { + Reason = reason; + Expires = expires; + } + + public UserKickBanEventData( + UserDisconnectReason reason, + TimeSpan duration + ) : this(reason, DateTimeOffset.UtcNow.Add(duration)) {} + } +} diff --git a/SharpChatCommon/UsersContext.cs b/SharpChatCommon/UsersContext.cs index 17e6179..a69bbf5 100644 --- a/SharpChatCommon/UsersContext.cs +++ b/SharpChatCommon/UsersContext.cs @@ -1,4 +1,5 @@ -using System; +using SharpChat.SockChat; +using System; using System.Collections.Generic; using System.Linq;