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 { public readonly SemaphoreSlim ContextAccess = new(1, 1); public ChannelsContext Channels { get; } = new(); public ConnectionsContext Connections { get; } = new(); public UsersContext Users { get; } = new(); public IEventStorage Events { get; } public ChannelsUsersContext ChannelsUsers { get; } = new(); public Dictionary UserRateLimiters { get; } = new(); public SockChatContext(IEventStorage evtStore) { Events = evtStore; } public void DispatchEvent(IChatEvent eventInfo) { if(eventInfo is MessageCreateEvent mce) { if(mce.IsBroadcast) { Send(new MessageBroadcastS2CPacket(mce.MessageText)); } else if(mce.IsPrivate) { // 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 // this entire routine is garbage, channels should probably in the db if(mce.ChannelName?.StartsWith("@") != true) return; long[] targetIds = mce.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 != mce.SenderId); if(target == null) return; foreach(UserInfo user in users) SendTo(user, new MessageAddS2CPacket( mce.MessageId, DateTimeOffset.Now, mce.SenderId, mce.SenderId == user.UserId ? $"{SockChatUtility.GetUserName(target)} {mce.MessageText}" : mce.MessageText, mce.IsAction, true )); } else { ChannelInfo? channel = Channels.Get(mce.ChannelName, SockChatUtility.SanitiseChannelName); if(channel != null) SendTo(channel, new MessageAddS2CPacket( mce.MessageId, DateTimeOffset.Now, mce.SenderId, mce.MessageText, mce.IsAction, false )); } Events.AddEvent( mce.MessageId, "msg:add", mce.ChannelName, mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms, new { text = mce.MessageText }, (mce.IsBroadcast ? StoredEventFlags.Broadcast : 0) | (mce.IsAction ? StoredEventFlags.Action : 0) | (mce.IsPrivate ? StoredEventFlags.Private : 0) ); return; } } 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)) { HandleDisconnect(user, UserDisconnectReason.TimeOut); Logger.Write($"Timed out {user} (no more connections)."); } } public void SafeUpdate() { ContextAccess.Wait(); try { Update(); } finally { ContextAccess.Release(); } } public ChannelInfo[] GetUserChannels(UserInfo user) { return Channels.GetMany(ChannelsUsers.GetUserChannelNames(user)); } public UserInfo[] GetChannelUsers(ChannelInfo channel) { return Users.GetMany(ChannelsUsers.GetChannelUserIds(channel)); } public void UpdateUser( UserInfo user, string? userName = null, string? nickName = null, Colour? colour = null, UserStatus? status = null, string? statusText = null, int? rank = null, UserPermissions? perms = null, bool? isSuper = null, bool silent = false ) { bool hasChanged = false; string? previousName = null; if(userName != null && !user.UserName.Equals(userName)) { user.UserName = userName; hasChanged = true; } if(nickName != null && !user.NickName.Equals(nickName)) { if(!silent) previousName = string.IsNullOrWhiteSpace(user.NickName) ? user.UserName : user.NickName; user.NickName = nickName; hasChanged = true; } if(colour.HasValue && user.Colour.Equals(colour.Value)) { user.Colour = colour.Value; hasChanged = true; } if(status.HasValue && user.Status != status.Value) { user.Status = status.Value; hasChanged = true; } if(statusText != null && !user.StatusText.Equals(statusText)) { user.StatusText = statusText; hasChanged = true; } if(rank != null && user.Rank != rank) { user.Rank = (int)rank; hasChanged = true; } if(perms.HasValue && user.Permissions != perms) { user.Permissions = perms.Value; hasChanged = true; } if(isSuper.HasValue) { user.IsSuper = isSuper.Value; hasChanged = true; } if(hasChanged) { if(previousName != null) SendToUserChannels(user, new UserUpdateNotificationS2CPacket(previousName, SockChatUtility.GetUserNameWithStatus(user))); SendToUserChannels(user, new UserUpdateS2CPacket( user.UserId, SockChatUtility.GetUserNameWithStatus(user), user.Colour, user.Rank, user.Permissions )); } } 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(StoredEventInfo msg in Events.GetChannelEventLog(channelName)) handler(msg.Type switch { "msg:add" => new MessageAddLogS2CPacket( msg.Id, msg.Created, msg.Sender?.UserId ?? -1, msg.Sender == null ? "ChatBot" : SockChatUtility.GetUserName(msg.Sender), msg.Sender?.Colour ?? Colour.None, msg.Sender?.Rank ?? 0, msg.Sender?.Permissions ?? 0, msg.Data.RootElement.GetProperty("text").GetString() ?? string.Empty, msg.Flags.HasFlag(StoredEventFlags.Action), msg.Flags.HasFlag(StoredEventFlags.Private), msg.Flags.HasFlag(StoredEventFlags.Broadcast), false ), "user:connect" => new UserConnectLogS2CPacket( msg.Id, msg.Created, msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender) ), "user:disconnect" => new UserDisconnectLogS2CPacket( msg.Id, msg.Created, msg.Sender == null ? string.Empty : SockChatUtility.GetUserNameWithStatus(msg.Sender), (UserDisconnectReason)msg.Data.RootElement.GetProperty("reason").GetByte() ), "chan:join" => new UserChannelJoinLogS2CPacket( msg.Id, msg.Created, msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender) ), "chan:leave" => new UserChannelLeaveLogS2CPacket( msg.Id, msg.Created, msg.Sender == null ? string.Empty : SockChatUtility.GetUserName(msg.Sender) ), _ => throw new Exception($"Unsupported backlog type: {msg.Type}"), }); } public void HandleJoin(UserInfo user, ChannelInfo chan, SockChatConnectionInfo conn, int maxMsgLength) { if(!ChannelsUsers.Has(chan, user)) { SendTo(chan, new UserConnectS2CPacket( user.UserId, SockChatUtility.GetUserNameWithStatus(user), user.Colour, user.Rank, user.Permissions )); Events.AddEvent( SharpId.Next(), "user:connect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log ); } conn.Send(new AuthSuccessS2CPacket( user.UserId, SockChatUtility.GetUserNameWithStatus(user), 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.GetUserNameWithStatus(user), 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 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) { SendTo(chan, new UserDisconnectS2CPacket( user.UserId, SockChatUtility.GetUserNameWithStatus(user), reason )); Events.AddEvent( SharpId.Next(), "user:disconnect", chan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, new { reason = (int)reason }, StoredEventFlags.Log ); if(chan.IsTemporary && chan.IsOwner(user)) RemoveChannel(chan); } } 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) { ChannelInfo? oldChan = Channels.Get(ChannelsUsers.GetUserLastChannel(user)); if(oldChan != null) { SendTo(oldChan, new UserChannelLeaveS2CPacket(user.UserId)); Events.AddEvent( SharpId.Next(), "chan:leave", oldChan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log ); } SendTo(chan, new UserChannelJoinS2CPacket( user.UserId, SockChatUtility.GetUserNameWithStatus(user), user.Colour, user.Rank, user.Permissions )); if(oldChan != null) Events.AddEvent( SharpId.Next(), "chan:join", oldChan.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions, null, StoredEventFlags.Log ); SendTo(user, new ContextClearS2CPacket(ContextClearS2CPacket.ClearMode.MessagesUsers)); SendTo(user, new UsersPopulateS2CPacket(GetChannelUsers(chan).Except(new[] { user }).Select( user => new UsersPopulateS2CPacket.ListEntry( user.UserId, SockChatUtility.GetUserNameWithStatus(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(SockChatS2CPacket packet) { string data = packet.Pack(); Connections.WithAuthed(conn => { if(conn is SockChatConnectionInfo scConn) scConn.Send(data); }); } public void SendTo(UserInfo user, SockChatS2CPacket packet) { string data = packet.Pack(); Connections.WithUser(user, conn => { if(conn is SockChatConnectionInfo scConn) scConn.Send(data); }); } public void SendTo(ChannelInfo channel, SockChatS2CPacket packet) { SendTo(channel, packet.Pack()); } public void SendTo(ChannelInfo channel, string packet) { long[] userIds = ChannelsUsers.GetChannelUserIds(channel); foreach(long userId in userIds) Connections.WithUser(userId, conn => { if(conn is SockChatConnectionInfo scConn) scConn.Send(packet); }); } public void SendToUserChannels(UserInfo user, SockChatS2CPacket packet) { ChannelInfo[] chans = GetUserChannels(user); 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)); } } }