sharp-chat/SharpChat/ChatContext.cs

312 lines
12 KiB
C#

using Fleck;
using SharpChat.Events;
using SharpChat.EventStorage;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
namespace SharpChat {
public class ChatContext {
public record ChannelUserAssoc(long UserId, string ChannelName);
public HashSet<ChatChannel> Channels { get; } = new();
public readonly object ChannelsAccess = new();
public HashSet<ChatConnection> Connections { get; } = new();
public readonly object ConnectionsAccess = new();
public HashSet<ChatUser> Users { get; } = new();
public readonly object UsersAccess = new();
public IEventStorage Events { get; }
public readonly object EventsAccess = new();
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = new();
public readonly object ChannelUsersAccess = new();
public ChatContext(IEventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
}
public void Update() {
lock(ConnectionsAccess) {
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);
lock(UsersAccess)
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 ChatConnection GetConnection(IWebSocketConnection sock) {
return Connections.FirstOrDefault(s => s.Socket == sock);
}
public bool IsInChannel(ChatUser user, ChatChannel channel) {
lock(ChannelUsersAccess)
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
}
public string[] GetUserChannelNames(ChatUser user) {
lock(ChannelUsersAccess)
return ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName).ToArray();
}
public ChatChannel[] GetUserChannels(ChatUser user) {
string[] names = GetUserChannelNames(user);
lock(ChannelsAccess)
return Channels.Where(c => names.Any(n => c.NameEquals(n))).ToArray();
}
public long[] GetChannelUserIds(ChatChannel channel) {
lock(ChannelUsersAccess)
return ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId).ToArray();
}
public ChatUser[] GetChannelUsers(ChatChannel channel) {
long[] ids = GetChannelUserIds(channel);
lock(UsersAccess)
return Users.Where(u => ids.Contains(u.UserId)).ToArray();
}
public void BanUser(ChatUser user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if(duration > TimeSpan.Zero)
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Banned, DateTimeOffset.Now + duration));
else
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
lock(ConnectionsAccess) {
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) {
lock(EventsAccess) {
if(!IsInChannel(user, chan)) {
SendTo(chan, new UserConnectPacket(DateTimeOffset.Now, user));
Events.AddEvent(new UserConnectEvent(DateTimeOffset.Now, user, chan));
}
conn.Send(new AuthSuccessPacket(user, chan, conn, maxMsgLength));
conn.Send(new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
foreach(IChatEvent msg in Events.GetChannelEventLog(chan.Name))
conn.Send(new ContextMessagePacket(msg));
lock(ChannelsAccess)
conn.Send(new ContextChannelsPacket(Channels.Where(c => c.Rank <= user.Rank)));
lock(UsersAccess)
Users.Add(user);
lock(ChannelUsersAccess) {
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
user.CurrentChannel = chan;
}
}
}
public void HandleDisconnect(ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
user.Status = ChatUserStatus.Offline;
lock(EventsAccess) {
lock(UsersAccess)
Users.Remove(user);
lock(ChannelUsersAccess) {
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(new UserDisconnectEvent(DateTimeOffset.Now, user, chan, reason));
if(chan.IsTemporary && chan.Owner == user)
lock(ChannelsAccess)
RemoveChannel(chan);
}
}
}
}
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
if(user.CurrentChannel == chan) {
ForceChannel(user);
return;
}
if(!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.Owner != user) {
if(chan.Rank > user.Rank) {
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
ForceChannel(user);
return;
}
if(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) {
lock(ChannelsAccess)
if(!Channels.Contains(chan))
return;
ChatChannel oldChan = user.CurrentChannel;
lock(EventsAccess) {
SendTo(oldChan, new UserChannelLeavePacket(user));
Events.AddEvent(new UserChannelLeaveEvent(DateTimeOffset.Now, user, oldChan));
SendTo(chan, new UserChannelJoinPacket(user));
Events.AddEvent(new UserChannelJoinEvent(DateTimeOffset.Now, user, chan));
SendTo(user, new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
SendTo(user, new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
foreach(IChatEvent msg in Events.GetChannelEventLog(chan.Name))
SendTo(user, new ContextMessagePacket(msg));
ForceChannel(user, chan);
lock(ChannelUsersAccess) {
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
user.CurrentChannel = chan;
}
}
if(oldChan.IsTemporary && oldChan.Owner == user)
lock(ChannelsAccess)
RemoveChannel(oldChan);
}
public void Send(IServerPacket packet) {
if(packet == null)
throw new ArgumentNullException(nameof(packet));
lock(ConnectionsAccess)
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));
lock(ConnectionsAccess)
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
lock(ConnectionsAccess) {
IEnumerable<ChatConnection> conns = Connections.Where(c => c.IsAuthed && IsInChannel(c.User, channel));
foreach(ChatConnection conn in conns)
conn.Send(packet);
}
}
public IPAddress[] GetRemoteAddresses(ChatUser user) {
lock(ConnectionsAccess)
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));
SendTo(user, new UserChannelForceJoinPacket(chan ?? user.CurrentChannel));
}
public void UpdateChannel(ChatChannel channel, string name = null, 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));
string prevName = channel.Name;
int prevHierarchy = channel.Rank;
bool nameUpdated = !string.IsNullOrWhiteSpace(name) && name != prevName;
if(nameUpdated) {
if(!ChatChannel.CheckName(name))
throw new ArgumentException("Name contains invalid characters.", nameof(name));
channel.Name = name;
}
if(temporary.HasValue)
channel.IsTemporary = temporary.Value;
if(hierarchy.HasValue)
channel.Rank = hierarchy.Value;
if(password != null)
channel.Password = password;
// 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
lock(UsersAccess)
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) {
SendTo(user, new ChannelUpdatePacket(prevName, channel));
if(nameUpdated)
ForceChannel(user);
}
}
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
lock(UsersAccess)
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank))
SendTo(user, new ChannelDeletePacket(channel));
}
}
}