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) { // user status should be stored outside of the UserInfo class so we don't need to do this: UserInfo? userInfo = Users.Get(info.SenderId); 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:connect": SendTo(info.ChannelName, new UserConnectS2CPacket( info.Id, info.Created, info.SenderId, SockChatUtility.GetUserName(info, userStatusInfo), info.SenderColour, info.SenderRank, info.SenderPerms )); break; case "user:disconnect": if(userInfo != null) Events.Dispatch("user:status", userInfo, new UserStatusUpdateEventData(UserStatus.Offline)); UserStatuses.Clear(info.SenderId); 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, SockChatUtility.GetUserName(info, userStatusInfo), 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: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 || userInfo is null) break; bool uuHasChanged = false; string? uuPrevName = null; if(userUpdate.Name != null && !userUpdate.Name.Equals(userInfo.UserName)) { userInfo.UserName = userUpdate.Name; uuHasChanged = true; } if(userUpdate.NickName != null && !userUpdate.NickName.Equals(userInfo.NickName)) { if(userUpdate.Notify) uuPrevName = string.IsNullOrWhiteSpace(userInfo.NickName) ? userInfo.UserName : userInfo.NickName; userInfo.NickName = userUpdate.NickName; uuHasChanged = true; } if(userUpdate.Colour.HasValue && userUpdate.Colour != userInfo.Colour.ToMisuzu()) { userInfo.Colour = Colour.FromMisuzu(userUpdate.Colour.Value); uuHasChanged = true; } if(userUpdate.Rank != null && userUpdate.Rank != userInfo.Rank) { userInfo.Rank = userUpdate.Rank.Value; uuHasChanged = true; } if(userUpdate.Perms.HasValue && userUpdate.Perms != userInfo.Permissions) { userInfo.Permissions = userUpdate.Perms.Value; uuHasChanged = true; } if(userUpdate.IsSuper.HasValue && userUpdate.IsSuper != userInfo.IsSuper) userInfo.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(userInfo, userStatusInfo) )); SendToUserChannels(info.SenderId, new UserUpdateS2CPacket( userInfo.UserId, SockChatUtility.GetUserName(userInfo, userStatusInfo), userInfo.Colour, userInfo.Rank, userInfo.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": SendTo(info.ChannelName, new UserChannelJoinS2CPacket( info.SenderId, SockChatUtility.GetUserName(info, userStatusInfo), info.SenderColour, info.SenderRank, info.SenderPerms )); break; case "chan:leave": SendTo(info.ChannelName, new UserChannelLeaveS2CPacket(info.SenderId)); 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; } } 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: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 UserInfo[] GetChannelUsers(ChannelInfo channel) { return Users.GetMany(ChannelsUsers.GetChannelUserIds(channel)); } 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": 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 HandleJoin(UserInfo user, ChannelInfo chan, SockChatConnectionInfo conn, int maxMsgLength) { if(!ChannelsUsers.Has(chan, user)) Events.Dispatch("user:connect", chan, user); UserStatusInfo statusInfo = UserStatuses.Get(user); conn.Send(new AuthSuccessS2CPacket( user.UserId, SockChatUtility.GetUserName(user, statusInfo), user.Colour, user.Rank, user.Permissions, chan.Name, maxMsgLength )); conn.Send(new UsersPopulateS2CPacket(GetChannelUsers(chan).Except(new[] { user }).Select( user => new UsersPopulateS2CPacket.ListEntry( user.UserId, SockChatUtility.GetUserName(user, statusInfo), user.Colour, user.Rank, user.Permissions, true ) ).OrderByDescending(user => user.Rank).ToArray())); HandleChannelEventLog(chan.Name, p => conn.Send(p)); conn.Send(new ChannelsPopulateS2CPacket(Channels.GetMany(isPublic: true, minRank: user.Rank).Select( channel => new ChannelsPopulateS2CPacket.ListEntry(channel.Name, channel.HasPassword, channel.IsTemporary) ).ToArray())); if(Users.Get(userId: user.UserId) == null) Users.Add(user); ChannelsUsers.Join(chan.Name, user.UserId); } public void SwitchChannel(UserInfo user, ChannelInfo chan, string password) { if(ChannelsUsers.IsUserLastChannel(user, chan)) { ForceChannel(user); return; } if(!user.Permissions.HasFlag(UserPermissions.JoinAnyChannel) && chan.IsOwner(user)) { if(chan.Rank > user.Rank) { SendTo(user, new ChannelRankTooLowErrorS2CPacket(chan.Name)); ForceChannel(user); return; } if(!string.IsNullOrEmpty(chan.Password) && chan.Password.Equals(password)) { SendTo(user, new ChannelPasswordWrongErrorS2CPacket(chan.Name)); ForceChannel(user); return; } } ForceChannelSwitch(user, chan); } public void ForceChannelSwitch(UserInfo user, ChannelInfo chan) { DateTimeOffset now = DateTimeOffset.UtcNow; ChannelInfo? oldChan = Channels.Get(ChannelsUsers.GetUserLastChannel(user)); if(oldChan != null) Events.Dispatch("chan:leave", now, oldChan, user); Events.Dispatch("chan:join", now, chan, user); SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.ClearMode.MessagesUsers)); SendTo(user, new UsersPopulateS2CPacket(GetChannelUsers(chan).Except(new[] { user }).Select( user => new UsersPopulateS2CPacket.ListEntry( user.UserId, SockChatUtility.GetUserName(user, UserStatuses.Get(user)), user.Colour, user.Rank, user.Permissions, true ) ).OrderByDescending(u => u.Rank).ToArray())); HandleChannelEventLog(chan.Name, p => SendTo(user, p)); ForceChannel(user, chan); if(oldChan != null) ChannelsUsers.Leave(oldChan, user); ChannelsUsers.Join(chan, user); if(oldChan != null && oldChan.IsTemporary && oldChan.IsOwner(user)) RemoveChannel(oldChan); } 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(UserInfo user, ISockChatS2CPacket packet) { SendToUserChannels(user.UserId, 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); } public void ForceChannel(UserInfo user, ChannelInfo? chan = null) { chan ??= Channels.Get(ChannelsUsers.GetUserLastChannel(user)); if(chan != null) SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name)); } public void UpdateChannel( ChannelInfo channel, bool? temporary = null, int? minRank = null, string? password = null ) { string prevName = channel.Name; if(temporary.HasValue) channel.IsTemporary = temporary.Value; if(minRank.HasValue) channel.Rank = minRank.Value; if(password != null) channel.Password = password; // 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: channel.Rank)) SendTo(user, new ChannelUpdateS2CPacket(prevName, channel.Name, channel.HasPassword, channel.IsTemporary)); } public void RemoveChannel(ChannelInfo channel) { if(channel == null || Channels.PublicCount > 1) return; ChannelInfo? defaultChannel = Channels.MainChannel; if(defaultChannel == null) return; // Remove channel from the listing Channels.Remove(channel); // Move all users back to the main channel // TODO: Replace this with a kick. SCv2 supports being in 0 channels, SCv1 should force the user back to DefaultChannel. foreach(UserInfo user in GetChannelUsers(channel)) SwitchChannel(user, defaultChannel, string.Empty); // Broadcast deletion of channel foreach(UserInfo user in Users.GetMany(minRank: channel.Rank)) SendTo(user, new ChannelDeleteS2CPacket(channel.Name)); } } }