using SharpChat.Events; using SharpChat.EventStorage; using SharpChat.SockChat; using SharpChat.SockChat.PacketsS2C; using System; using System.Collections.Generic; using System.Linq; using System.Threading; namespace SharpChat { public class SockChatContext : IChatEventHandler { public readonly SemaphoreSlim ContextAccess = new(1, 1); public IEventStorage EventStorage { get; } public ChatEventDispatcher Events { get; } = new(); public ConnectionsContext Connections { get; } = new(); public ChannelsContext Channels { get; } = new(); public UsersContext Users { get; } = new(); public UserStatusContext UserStatuses { get; } = new(); public ChannelsUsersContext ChannelsUsers { get; } = new(); public Dictionary UserRateLimiters { get; } = new(); public SockChatContext(IEventStorage evtStore) { EventStorage = evtStore; Events.Subscribe(evtStore); Events.Subscribe(this); } public void HandleEvent(ChatEventInfo info) { UserStatusInfo userStatusInfo = UserStatuses.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:add": Users.Add(new UserInfo( info.SenderId, info.SenderName, info.SenderColour, info.SenderRank, info.SenderPerms, info.SenderNickName, info.Data is UserAddEventData uaData && uaData.IsSuper )); break; case "user:delete": UserStatuses.Clear(info.SenderId); Users.Remove(info.SenderId); break; case "user:connect": if(info.Data is not UserConnectEventData ucData || !ucData.Notify) break; SendTo(info.ChannelName, new UserConnectS2CPacket( info.Id, info.Created, info.SenderId, SockChatUtility.GetUserName(info, userStatusInfo), info.SenderColour, info.SenderRank, info.SenderPerms )); ChannelsUsers.Join(info.ChannelName, info.SenderId); break; case "user:disconnect": 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, SockChatUtility.GetUserName(info, userStatusInfo), info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave ); foreach(ChannelInfo chan in channels) { if(chan.IsTemporary && chan.OwnerId == info.SenderId) Events.Dispatch("chan:delete", chan.Name, info); else SendTo(chan, udPacket); } } break; case "user:status": if(info.Data is not UserStatusUpdateEventData userStatusUpdate) break; if(userStatusInfo.Status == userStatusUpdate.Status && userStatusInfo.Text.Equals(userStatusUpdate.Text)) break; userStatusInfo = UserStatuses.Set( info.SenderId, userStatusUpdate.Status, userStatusUpdate.Text ?? string.Empty ); SendToUserChannels(info.SenderId, new UserUpdateS2CPacket( info.SenderId, SockChatUtility.GetUserName(info, userStatusInfo), info.SenderColour, info.SenderRank, info.SenderPerms )); break; case "user:update": if(info.Data is not UserUpdateEventData userUpdate) break; UserInfo? uuUserInfo = Users.Get(info.SenderId); if(uuUserInfo is null) break; bool uuHasChanged = false; string? uuPrevName = null; if(userUpdate.Name != null && !userUpdate.Name.Equals(uuUserInfo.UserName)) { uuUserInfo.UserName = userUpdate.Name; uuHasChanged = true; } if(userUpdate.NickName != null && !userUpdate.NickName.Equals(uuUserInfo.NickName)) { if(userUpdate.Notify) uuPrevName = string.IsNullOrWhiteSpace(uuUserInfo.NickName) ? uuUserInfo.UserName : uuUserInfo.NickName; uuUserInfo.NickName = userUpdate.NickName; uuHasChanged = true; } if(userUpdate.Colour.HasValue && userUpdate.Colour != uuUserInfo.Colour.ToMisuzu()) { uuUserInfo.Colour = Colour.FromMisuzu(userUpdate.Colour.Value); uuHasChanged = true; } if(userUpdate.Rank != null && userUpdate.Rank != uuUserInfo.Rank) { uuUserInfo.Rank = userUpdate.Rank.Value; uuHasChanged = true; } if(userUpdate.Perms.HasValue && userUpdate.Perms != uuUserInfo.Permissions) { uuUserInfo.Permissions = userUpdate.Perms.Value; uuHasChanged = true; } if(userUpdate.IsSuper.HasValue && userUpdate.IsSuper != uuUserInfo.IsSuper) uuUserInfo.IsSuper = userUpdate.IsSuper.Value; if(uuHasChanged) { if(uuPrevName != null) SendToUserChannels(info.SenderId, new UserNickChangeS2CPacket( info.Id, info.Created, string.IsNullOrWhiteSpace(info.SenderNickName) ? uuPrevName : $"~{info.SenderNickName}", SockChatUtility.GetUserName(uuUserInfo, userStatusInfo) )); SendToUserChannels(info.SenderId, new UserUpdateS2CPacket( uuUserInfo.UserId, SockChatUtility.GetUserName(uuUserInfo, userStatusInfo), uuUserInfo.Colour, uuUserInfo.Rank, uuUserInfo.Permissions )); } 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": HandleUserChannelJoin( info.ChannelName, new UserInfo(info), // kinda stinky userStatusInfo ); break; case "chan:leave": ChannelsUsers.Leave(info.ChannelName, info.SenderId); SendTo(info.ChannelName, new UserChannelLeaveS2CPacket(info.SenderId)); ChannelInfo? clChannelInfo = Channels.Get(info.ChannelName); if(clChannelInfo?.IsTemporary == true && clChannelInfo.OwnerId == info.SenderId) Events.Dispatch("chan:delete", clChannelInfo.Name, info); break; case "chan:add": if(info.Data is not ChannelAddEventData caData) break; ChannelInfo caChannelInfo = new( info.ChannelName, caData.Password, caData.IsTemporary, caData.MinRank, info.SenderId ); Channels.Add(caChannelInfo); foreach(UserInfo ccu in Users.GetMany(minRank: caChannelInfo.Rank)) SendTo(ccu, new ChannelCreateS2CPacket( caChannelInfo.Name, caChannelInfo.HasPassword, caChannelInfo.IsTemporary )); break; case "chan:update": if(info.Data is not ChannelUpdateEventData cuData) break; ChannelInfo? cuChannelInfo = Channels.Get(info.ChannelName); if(cuChannelInfo is null) break; string cuChannelName = cuChannelInfo.Name; if(!string.IsNullOrEmpty(cuData.Name)) cuChannelInfo.Name = cuData.Name; if(cuData.MinRank.HasValue) cuChannelInfo.Rank = cuData.MinRank.Value; if(cuData.Password != null) // this should probably be hashed cuChannelInfo.Password = cuData.Password; if(cuData.IsTemporary.HasValue) cuChannelInfo.IsTemporary = cuData.IsTemporary.Value; bool nameChanged = !cuChannelName.Equals(cuChannelInfo.Name, StringComparison.InvariantCultureIgnoreCase); if(nameChanged) ChannelsUsers.RenameChannel(cuChannelName, cuChannelInfo.Name); // TODO: Users that no longer have access to the channel/gained access to the channel by the rank change should receive delete and create packets respectively // the server currently doesn't keep track of what channels a user is already aware of so can't really simulate this yet. foreach(UserInfo user in Users.GetMany(minRank: cuChannelInfo.Rank)) SendTo(user, new ChannelUpdateS2CPacket( cuChannelName, cuChannelInfo.Name, cuChannelInfo.HasPassword, cuChannelInfo.IsTemporary )); if(nameChanged) SendTo(cuChannelInfo, new UserChannelForceJoinS2CPacket(cuChannelInfo.Name)); break; case "chan:delete": ChannelInfo? cdTargetChannelInfo = Channels.Get(info.ChannelName); ChannelInfo? cdMainChannelInfo = Channels.MainChannel; if(cdTargetChannelInfo == null || cdMainChannelInfo == null || cdTargetChannelInfo == Channels.MainChannel) break; // Remove channel from the listing Channels.Remove(info.ChannelName); // Move all users back to the main channel UserInfo[] cdUserInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(info.ChannelName)); ChannelsUsers.DeleteChannel(cdMainChannelInfo); foreach(UserInfo userInfo in cdUserInfos) HandleUserChannelJoin( info.ChannelName, userInfo, UserStatuses.Get(userInfo) ); // Broadcast deletion of channel foreach(UserInfo user in Users.GetMany(minRank: cdTargetChannelInfo.Rank)) SendTo(user, new ChannelDeleteS2CPacket(cdTargetChannelInfo.Name)); break; case "msg:delete": if(info.Data is not MessageDeleteEventData msgDelete) break; MessageDeleteS2CPacket msgDelPacket = new(msgDelete.MessageId); if(info.IsBroadcast) { Send(msgDelPacket); } else if(info.ChannelName.StartsWith('@')) { long[] targetIds = info.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1).ToArray(); if(targetIds.Length != 2) return; UserInfo[] users = Users.GetMany(targetIds); UserInfo? target = users.FirstOrDefault(u => u.UserId != info.SenderId); if(target == null) return; foreach(UserInfo user in users) SendTo(user, msgDelPacket); } else { SendTo(info.ChannelName, msgDelPacket); } break; case "msg:add": if(info.Data is not MessageAddEventData msgAdd) break; if(info.IsBroadcast) { Send(new MessageBroadcastS2CPacket(info.Id, info.Created, msgAdd.Text)); } else if(info.ChannelName.StartsWith('@')) { // The channel name returned by GetDMChannelName should not be exposed to the user, instead @ should be displayed // e.g. nook sees @Arysil and Arysil sees @nook long[] targetIds = info.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1).ToArray(); if(targetIds.Length != 2) return; UserInfo[] users = Users.GetMany(targetIds); UserInfo? target = users.FirstOrDefault(u => u.UserId != info.SenderId); if(target == null) return; foreach(UserInfo user in users) SendTo(user, new MessageAddS2CPacket( info.Id, DateTimeOffset.Now, info.SenderId, info.SenderId == user.UserId ? $"{SockChatUtility.GetUserName(target)} {msgAdd.Text}" : msgAdd.Text, msgAdd.IsAction, true )); } else { ChannelInfo? channel = Channels.Get(info.ChannelName, SockChatUtility.SanitiseChannelName); if(channel != null) SendTo(channel, new MessageAddS2CPacket( info.Id, DateTimeOffset.Now, info.SenderId, msgAdd.Text, msgAdd.IsAction, false )); } break; } } private void HandleUserChannelJoin(string channelName, UserInfo userInfo, UserStatusInfo statusInfo) { SendTo(userInfo.UserId, new ClearMessagesAndUsersS2CPacket()); UserInfo[] userInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(channelName)); List userEntries = new(); foreach(UserInfo memberInfo in userInfos) if(memberInfo.UserId != userInfo.UserId) userEntries.Add(new( memberInfo.UserId, SockChatUtility.GetUserName(memberInfo, UserStatuses.Get(memberInfo)), memberInfo.Colour, memberInfo.Rank, memberInfo.Permissions, true )); SendTo(userInfo.UserId, new UsersPopulateS2CPacket(userEntries.ToArray())); SendTo(channelName, new UserChannelJoinS2CPacket( userInfo.UserId, SockChatUtility.GetUserName(userInfo, statusInfo), userInfo.Colour, userInfo.Rank, userInfo.Permissions )); HandleChannelEventLog(channelName, p => SendTo(userInfo.UserId, p)); ChannelsUsers.Join(channelName, userInfo.UserId); SendTo(userInfo.UserId, new UserChannelForceJoinS2CPacket(channelName)); } public void Update() { ConnectionInfo[] timedOut = Connections.GetTimedOut(); foreach(ConnectionInfo conn in timedOut) { Connections.Remove(conn); if(conn is SockChatConnectionInfo scConn) scConn.Close(1002); Logger.Write($"<{conn.RemoteEndPoint}> Nuked timed out connection from user #{conn.UserId}."); } foreach(UserInfo user in Users.All) if(!Connections.HasUser(user)) { Events.Dispatch("user:delete", user); Events.Dispatch( "user:disconnect", ChannelsUsers.GetUserLastChannel(user), user, new UserDisconnectEventData(UserDisconnectReason.TimeOut) ); Logger.Write($"Timed out {user} (no more connections)."); } } public void SafeUpdate() { ContextAccess.Wait(); try { Update(); } finally { ContextAccess.Release(); } } public void HandleChannelEventLog(string channelName, Action handler) { foreach(ChatEventInfo info in EventStorage.GetChannelEventLog(channelName)) { switch(info.Type) { case "msg:add": if(info.Data is not MessageAddEventData msgAdd) break; handler(new MessageAddLogS2CPacket( info.Id, info.Created, info.SenderId, info.SenderName, info.SenderColour, info.SenderRank, info.SenderPerms, msgAdd.Text, msgAdd.IsAction, info.ChannelName.StartsWith('@'), info.IsBroadcast, false )); break; case "user:connect": if(info.Data is UserConnectEventData ucData && ucData.Notify) handler(new UserConnectLogS2CPacket( info.Id, info.Created, SockChatUtility.GetUserName(info) )); break; case "user:disconnect": handler(new UserDisconnectLogS2CPacket( info.Id, info.Created, SockChatUtility.GetUserName(info), info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave )); break; case "user:update": if(info.Data is UserUpdateEventData userUpdate && userUpdate.Notify) handler(new UserNickChangeLogS2CPacket( info.Id, info.Created, info.SenderNickName == null ? info.SenderName : $"~{info.SenderNickName}", userUpdate.NickName == null ? info.SenderName : $"~{userUpdate.NickName}" )); break; case "chan:join": handler(new UserChannelJoinLogS2CPacket( info.Id, info.Created, SockChatUtility.GetUserName(info) )); break; case "chan:leave": handler(new UserChannelLeaveLogS2CPacket( info.Id, info.Created, SockChatUtility.GetUserName(info) )); break; } } } public void Send(ISockChatS2CPacket packet) { string data = packet.Pack(); Connections.WithAuthed(conn => { if(conn is SockChatConnectionInfo scConn) scConn.Send(data); }); } public void SendTo(UserInfo user, ISockChatS2CPacket packet) { 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(packet); }); } public void SendTo(ChannelInfo channel, ISockChatS2CPacket packet) { SendTo(channel.Name, packet.Pack()); } public void SendTo(ChannelInfo channel, string packet) { SendTo(channel.Name, packet); } public void SendTo(string channelName, ISockChatS2CPacket packet) { SendTo(channelName, packet.Pack()); } public void SendTo(string channelName, string packet) { long[] userIds = ChannelsUsers.GetChannelUserIds(channelName); foreach(long userId in userIds) Connections.WithUser(userId, conn => { if(conn is SockChatConnectionInfo scConn) scConn.Send(packet); }); } public void SendToUserChannels(long userId, ISockChatS2CPacket packet) { ChannelInfo[] chans = Channels.GetMany(ChannelsUsers.GetUserChannelNames(userId)); string data = packet.Pack(); foreach(ChannelInfo chan in chans) SendTo(chan, data); } } }