using SharpChat.Events; using SharpChat.EventStorage; using SharpChat.Packet; using System; using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; using System.Net; using System.Threading; namespace SharpChat { public class ChatContext { public record ChannelUserAssoc(long UserId, string ChannelName); public readonly SemaphoreSlim ContextAccess = new(1, 1); public HashSet Channels { get; } = new(); public HashSet Connections { get; } = new(); public HashSet Users { get; } = new(); public IEventStorage Events { get; } public HashSet ChannelUsers { get; } = new(); public Dictionary UserRateLimiters { get; } = new(); public Dictionary UserLastChannel { get; } = new(); public ChatContext(IEventStorage evtStore) { Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore)); } public void DispatchEvent(IChatEvent eventInfo) { if(eventInfo is MessageCreateEvent mce) { if(mce.IsBroadcast) { Send(new LegacyCommandResponse(LCR.BROADCAST, false, 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("@")) return; IEnumerable uids = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1); if(uids.Count() != 2) return; IEnumerable users = Users.Where(u => uids.Any(uid => uid == u.UserId)); ChatUser target = users.FirstOrDefault(u => u.UserId != mce.SenderId); if(target == null) return; foreach(ChatUser user in users) SendTo(user, new ChatMessageAddPacket( mce.MessageId, DateTimeOffset.Now, mce.SenderId, mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText, mce.IsAction, true )); } else { ChatChannel channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName)); SendTo(channel, new ChatMessageAddPacket( 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() { foreach(ChatConnection conn in Connections) if(!conn.IsDisposed && conn.HasTimedOut) { conn.Dispose(); Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}."); } Connections.RemoveWhere(conn => conn.IsDisposed); foreach(ChatUser user in Users) if(!Connections.Any(conn => conn.User == user)) { HandleDisconnect(user, UserDisconnectReason.TimeOut); Logger.Write($"Timed out {user} (no more connections)."); } } public void SafeUpdate() { ContextAccess.Wait(); try { Update(); } finally { ContextAccess.Release(); } } public bool IsInChannel(ChatUser user, ChatChannel channel) { return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name)); } public string[] GetUserChannelNames(ChatUser user) { return ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName).ToArray(); } public ChatChannel[] GetUserChannels(ChatUser user) { string[] names = GetUserChannelNames(user); return Channels.Where(c => names.Any(n => c.NameEquals(n))).ToArray(); } public long[] GetChannelUserIds(ChatChannel channel) { return ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId).ToArray(); } public ChatUser[] GetChannelUsers(ChatChannel channel) { long[] ids = GetChannelUserIds(channel); return Users.Where(u => ids.Contains(u.UserId)).ToArray(); } public void UpdateUser( ChatUser user, string userName = null, string nickName = null, ChatColour? colour = null, ChatUserStatus? status = null, string statusText = null, int? rank = null, ChatUserPermissions? perms = null, bool? isSuper = null, bool silent = false ) { if(user == null) throw new ArgumentNullException(nameof(user)); 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 != 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) SendToUserChannels(user, new UserUpdatePacket(user, previousName)); } public void BanUser(ChatUser user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) { if (duration > TimeSpan.Zero) { DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration; SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Banned, expires)); } else SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Kicked)); foreach(ChatConnection conn in Connections) if(conn.User == user) conn.Dispose(); Connections.RemoveWhere(conn => conn.IsDisposed); HandleDisconnect(user, reason); } public void HandleJoin(ChatUser user, ChatChannel chan, ChatConnection conn, int maxMsgLength) { if(!IsInChannel(user, chan)) { SendTo(chan, new UserConnectPacket(DateTimeOffset.Now, user)); Events.AddEvent("user:connect", user, chan, flags: StoredEventFlags.Log); } conn.Send(new AuthSuccessPacket(user, chan, conn, maxMsgLength)); conn.Send(new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank))); foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name)) conn.Send(new ContextMessagePacket(msg)); conn.Send(new ContextChannelsPacket(Channels.Where(c => c.Rank <= user.Rank))); Users.Add(user); ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name)); UserLastChannel[user.UserId] = chan; } public void HandleDisconnect(ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) { UpdateUser(user, status: ChatUserStatus.Offline); Users.Remove(user); UserLastChannel.Remove(user.UserId); ChatChannel[] channels = GetUserChannels(user); foreach(ChatChannel chan in channels) { ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name)); SendTo(chan, new UserDisconnectPacket(DateTimeOffset.Now, user, reason)); Events.AddEvent("user:disconnect", user, chan, new { reason = (int)reason }, StoredEventFlags.Log); if(chan.IsTemporary && chan.IsOwner(user)) RemoveChannel(chan); } } public void SwitchChannel(ChatUser user, ChatChannel chan, string password) { if(UserLastChannel.TryGetValue(user.UserId, out ChatChannel ulc) && chan == ulc) { ForceChannel(user); return; } if(!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.IsOwner(user)) { if(chan.Rank > user.Rank) { SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name)); ForceChannel(user); return; } if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) { SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name)); ForceChannel(user); return; } } ForceChannelSwitch(user, chan); } public void ForceChannelSwitch(ChatUser user, ChatChannel chan) { if(!Channels.Contains(chan)) return; ChatChannel oldChan = UserLastChannel[user.UserId]; SendTo(oldChan, new UserChannelLeavePacket(user)); Events.AddEvent("chan:leave", user, oldChan, flags: StoredEventFlags.Log); SendTo(chan, new UserChannelJoinPacket(user)); Events.AddEvent("chan:join", user, oldChan, flags: StoredEventFlags.Log); SendTo(user, new ContextClearPacket(chan, ContextClearMode.MessagesUsers)); SendTo(user, new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank))); foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name)) SendTo(user, new ContextMessagePacket(msg)); ForceChannel(user, chan); ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name)); ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name)); UserLastChannel[user.UserId] = chan; if(oldChan.IsTemporary && oldChan.IsOwner(user)) RemoveChannel(oldChan); } public void Send(IServerPacket packet) { if(packet == null) throw new ArgumentNullException(nameof(packet)); foreach(ChatConnection conn in Connections) if(conn.IsAuthed) conn.Send(packet); } public void SendTo(ChatUser user, IServerPacket packet) { if(user == null) throw new ArgumentNullException(nameof(user)); if(packet == null) throw new ArgumentNullException(nameof(packet)); foreach(ChatConnection conn in Connections) if(conn.IsAlive && conn.User == user) conn.Send(packet); } public void SendTo(ChatChannel channel, IServerPacket packet) { if(channel == null) throw new ArgumentNullException(nameof(channel)); if(packet == null) throw new ArgumentNullException(nameof(packet)); // might be faster to grab the users first and then cascade into that SendTo IEnumerable conns = Connections.Where(c => c.IsAuthed && IsInChannel(c.User, channel)); foreach(ChatConnection conn in conns) conn.Send(packet); } public void SendToUserChannels(ChatUser user, IServerPacket packet) { if(user == null) throw new ArgumentNullException(nameof(user)); if(packet == null) throw new ArgumentNullException(nameof(packet)); IEnumerable chans = Channels.Where(c => IsInChannel(user, c)); IEnumerable conns = Connections.Where(conn => conn.IsAuthed && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName)))); foreach(ChatConnection conn in conns) conn.Send(packet); } public IPAddress[] GetRemoteAddresses(ChatUser user) { return Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct().ToArray(); } public void ForceChannel(ChatUser user, ChatChannel chan = null) { if(user == null) throw new ArgumentNullException(nameof(user)); if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan)) throw new ArgumentException("no channel???"); SendTo(user, new UserChannelForceJoinPacket(chan)); } public void UpdateChannel(ChatChannel channel, bool? temporary = null, int? hierarchy = null, string password = null) { if(channel == null) throw new ArgumentNullException(nameof(channel)); if(!Channels.Contains(channel)) throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel)); if(temporary.HasValue) channel.IsTemporary = temporary.Value; if(hierarchy.HasValue) channel.Rank = hierarchy.Value; if(password != null) channel.Password = password; // TODO: Users that no longer have access to the channel/gained access to the channel by the hierarchy change should receive delete and create packets respectively foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) { SendTo(user, new ChannelUpdatePacket(channel.Name, channel)); } } public void RemoveChannel(ChatChannel channel) { if(channel == null || !Channels.Any()) return; ChatChannel defaultChannel = Channels.FirstOrDefault(); 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(ChatUser user in GetChannelUsers(channel)) SwitchChannel(user, defaultChannel, string.Empty); // Broadcast deletion of channel foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) SendTo(user, new ChannelDeletePacket(channel)); } } }