diff --git a/SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs b/SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs index 4199060..e9369ee 100644 --- a/SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs +++ b/SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs @@ -1,5 +1,6 @@ using SharpChat.Auth; using SharpChat.Bans; +using SharpChat.Channels; using SharpChat.Configuration; using SharpChat.SockChat.S2CPackets; @@ -8,14 +9,10 @@ namespace SharpChat.C2SPacketHandlers; public class AuthC2SPacketHandler( AuthClient authClient, BansClient bansClient, - Channel defaultChannel, + ChannelsContext channelsCtx, CachedValue<int> maxMsgLength, CachedValue<int> maxConns ) : C2SPacketHandler { - private readonly Channel DefaultChannel = defaultChannel ?? throw new ArgumentNullException(nameof(defaultChannel)); - private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength)); - private readonly CachedValue<int> MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns)); - public bool IsMatch(C2SPacketHandlerContext ctx) { return ctx.CheckPacketId("1"); } @@ -75,7 +72,7 @@ public class AuthC2SPacketHandler( ); // Enforce a maximum amount of connections per user - if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) { + if(ctx.Chat.Connections.Count(conn => conn.User == user) >= maxConns) { await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions)); ctx.Connection.Dispose(); return; @@ -93,7 +90,7 @@ public class AuthC2SPacketHandler( await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line)); } - await ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength); + await ctx.Chat.HandleJoin(user, channelsCtx.DefaultChannel, ctx.Connection, maxMsgLength); } finally { ctx.Chat.ContextAccess.Release(); } diff --git a/SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs b/SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs index 234cde1..d757ecb 100644 --- a/SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs +++ b/SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.Configuration; using SharpChat.Events; using SharpChat.Snowflake; diff --git a/SharpChat/ClientCommandContext.cs b/SharpChat/ClientCommandContext.cs index d53a9ba..3d58037 100644 --- a/SharpChat/ClientCommandContext.cs +++ b/SharpChat/ClientCommandContext.cs @@ -1,4 +1,6 @@ -namespace SharpChat; +using SharpChat.Channels; + +namespace SharpChat; public class ClientCommandContext { public string Name { get; } diff --git a/SharpChat/ClientCommands/CreateChannelClientCommand.cs b/SharpChat/ClientCommands/CreateChannelClientCommand.cs index 03de911..d13cdba 100644 --- a/SharpChat/ClientCommands/CreateChannelClientCommand.cs +++ b/SharpChat/ClientCommands/CreateChannelClientCommand.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.SockChat.S2CPackets; namespace SharpChat.ClientCommands; @@ -35,28 +36,23 @@ public class CreateChannelClientCommand : ClientCommand { string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0)); - if(!Channel.CheckName(createChanName)) { + try { + Channel channel = ctx.Chat.Channels.CreateChannel( + createChanName, + temporary: !ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPermanent), + rank: createChanHierarchy, + ownerId: ctx.User.UserId + ); + + foreach(User ccu in ctx.Chat.Users.Where(u => u.Rank >= ctx.Channel.Rank)) + await ctx.Chat.SendTo(ccu, new ChannelCreateS2CPacket(channel.Name, channel.HasPassword, channel.IsTemporary)); + + await ctx.Chat.SwitchChannel(ctx.User, channel, channel.Password); + await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_CREATED, false, channel.Name)); + } catch(ChannelNameFormatException) { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NAME_INVALID)); - return; - } - - if(ctx.Chat.Channels.Any(c => c.NameEquals(createChanName))) { + } catch(ChannelExistsException) { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_ALREADY_EXISTS, true, createChanName)); - return; } - - Channel createChan = new( - createChanName, - isTemporary: !ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPermanent), - rank: createChanHierarchy, - ownerId: ctx.User.UserId - ); - - ctx.Chat.Channels.Add(createChan); - foreach(User ccu in ctx.Chat.Users.Where(u => u.Rank >= ctx.Channel.Rank)) - await ctx.Chat.SendTo(ccu, new ChannelCreateS2CPacket(createChan.Name, createChan.HasPassword, createChan.IsTemporary)); - - await ctx.Chat.SwitchChannel(ctx.User, createChan, createChan.Password); - await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_CREATED, false, createChan.Name)); } } diff --git a/SharpChat/ClientCommands/DeleteChannelClientCommand.cs b/SharpChat/ClientCommands/DeleteChannelClientCommand.cs index 68f2c83..3c7aed4 100644 --- a/SharpChat/ClientCommands/DeleteChannelClientCommand.cs +++ b/SharpChat/ClientCommands/DeleteChannelClientCommand.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.SockChat.S2CPackets; namespace SharpChat.ClientCommands; @@ -19,7 +20,7 @@ public class DeleteChannelClientCommand : ClientCommand { } string delChanName = string.Join('_', ctx.Args); - Channel? delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName)); + Channel? delChan = ctx.Chat.Channels.GetChannel(delChanName); if(delChan == null) { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, delChanName)); diff --git a/SharpChat/ClientCommands/JoinChannelClientCommand.cs b/SharpChat/ClientCommands/JoinChannelClientCommand.cs index d2b9364..3a05b13 100644 --- a/SharpChat/ClientCommands/JoinChannelClientCommand.cs +++ b/SharpChat/ClientCommands/JoinChannelClientCommand.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.SockChat.S2CPackets; namespace SharpChat.ClientCommands; @@ -10,7 +11,7 @@ public class JoinChannelClientCommand : ClientCommand { public async Task Dispatch(ClientCommandContext ctx) { long msgId = ctx.Chat.RandomSnowflake.Next(); string joinChanStr = ctx.Args.FirstOrDefault() ?? "Channel"; - Channel? joinChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr)); + Channel? joinChan = ctx.Chat.Channels.GetChannel(joinChanStr); if(joinChan is null) { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, joinChanStr)); diff --git a/SharpChat/ClientCommands/RankChannelClientCommand.cs b/SharpChat/ClientCommands/RankChannelClientCommand.cs index fa98c88..5f51644 100644 --- a/SharpChat/ClientCommands/RankChannelClientCommand.cs +++ b/SharpChat/ClientCommands/RankChannelClientCommand.cs @@ -22,7 +22,7 @@ public class RankChannelClientCommand : ClientCommand { return; } - await ctx.Chat.UpdateChannel(ctx.Channel, hierarchy: chanHierarchy); + await ctx.Chat.UpdateChannel(ctx.Channel, rank: chanHierarchy); await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_HIERARCHY_CHANGED, false)); } } diff --git a/SharpChat/ClientCommands/WhoClientCommand.cs b/SharpChat/ClientCommands/WhoClientCommand.cs index c60f6dd..d037ce9 100644 --- a/SharpChat/ClientCommands/WhoClientCommand.cs +++ b/SharpChat/ClientCommands/WhoClientCommand.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.SockChat.S2CPackets; using System.Text; @@ -30,7 +31,7 @@ public class WhoClientCommand : ClientCommand { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_SERVER, false, whoChanSB)); } else { - Channel? whoChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(whoChanStr)); + Channel? whoChan = ctx.Chat.Channels.GetChannel(whoChanStr); if(whoChan is null) { await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, whoChanStr)); diff --git a/SharpChat/Context.cs b/SharpChat/Context.cs index 3354cb9..423a7f8 100644 --- a/SharpChat/Context.cs +++ b/SharpChat/Context.cs @@ -1,3 +1,4 @@ +using SharpChat.Channels; using SharpChat.Events; using SharpChat.Messages; using SharpChat.Snowflake; @@ -15,7 +16,7 @@ public class Context { public SnowflakeGenerator SnowflakeGenerator { get; } = new(); public RandomSnowflake RandomSnowflake { get; } - public HashSet<Channel> Channels { get; } = []; + public ChannelsContext Channels { get; } = new(); public HashSet<Connection> Connections { get; } = []; public HashSet<User> Users { get; } = []; public MessageStorage Messages { get; } @@ -59,7 +60,7 @@ public class Context { true )); } else { - Channel? channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName)); + Channel? channel = Channels.GetChannel(mce.ChannelName); if(channel is not null) await SendTo(channel, new ChatMessageAddS2CPacket( mce.MessageId, @@ -118,8 +119,7 @@ public class Context { } public Channel[] GetUserChannels(User user) { - string[] names = GetUserChannelNames(user); - return [.. Channels.Where(c => names.Any(n => c.NameEquals(n)))]; + return [.. Channels.GetChannels(GetUserChannelNames(user))]; } public string[] GetChannelUserIds(Channel channel) { @@ -241,7 +241,7 @@ public class Context { await conn.Send(new ContextMessageS2CPacket(msg)); await conn.Send(new ContextChannelsS2CPacket( - Channels.Where(c => c.Rank <= user.Rank) + Channels.GetChannels(user.Rank) .Select(c => new ContextChannelsS2CPacket.Entry(c.Name, c.HasPassword, c.IsTemporary)) )); @@ -294,9 +294,6 @@ public class Context { } public async Task ForceChannelSwitch(User user, Channel chan) { - if(!Channels.Any(c => c.NameEquals(chan.Name))) - return; - Channel oldChan = UserLastChannel[user.UserId]; long leaveId = RandomSnowflake.Next(); @@ -354,7 +351,7 @@ public class Context { } public async Task SendToUserChannels(User user, S2CPacket packet) { - IEnumerable<Channel> chans = Channels.Where(c => IsInChannel(user, c)); + IEnumerable<Channel> chans = Channels.GetChannels(c => IsInChannel(user, c)); IEnumerable<Connection> conns = Connections.Where(conn => conn.IsAlive && conn.User is not null && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName)))); foreach(Connection conn in conns) await conn.Send(packet); @@ -373,18 +370,18 @@ public class Context { await SendTo(user, new UserChannelForceJoinS2CPacket(chan.Name)); } - public async Task UpdateChannel(Channel channel, bool? temporary = null, int? hierarchy = null, string? password = null) { - if(!Channels.Any(c => c.NameEquals(channel.Name))) - 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; + public async Task UpdateChannel( + Channel channel, + bool? temporary = null, + int? rank = null, + string? password = null + ) { + Channels.UpdateChannel( + channel, + temporary: temporary, + rank: rank, + 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(User user in Users.Where(u => u.Rank >= channel.Rank)) @@ -392,20 +389,13 @@ public class Context { } public async Task RemoveChannel(Channel channel) { - if(channel == null || Channels.Count < 1) - return; - - Channel? defaultChannel = Channels.FirstOrDefault(); - if(defaultChannel is null) - return; - // Remove channel from the listing - Channels.Remove(channel); + Channels.RemoveChannel(channel.Name); // 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(User user in GetChannelUsers(channel)) - await SwitchChannel(user, defaultChannel, string.Empty); + await SwitchChannel(user, Channels.DefaultChannel, string.Empty); // Broadcast deletion of channel foreach(User user in Users.Where(u => u.Rank >= channel.Rank)) diff --git a/SharpChat/SockChatServer.cs b/SharpChat/SockChatServer.cs index 11b75ae..3e9b6de 100644 --- a/SharpChat/SockChatServer.cs +++ b/SharpChat/SockChatServer.cs @@ -2,6 +2,7 @@ using Fleck; using SharpChat.Auth; using SharpChat.Bans; using SharpChat.C2SPacketHandlers; +using SharpChat.Channels; using SharpChat.ClientCommands; using SharpChat.Configuration; using SharpChat.Messages; @@ -35,8 +36,6 @@ public class SockChatServer : IDisposable { private static readonly string[] DEFAULT_CHANNELS = ["lounge"]; - private Channel DefaultChannel { get; set; } - public SockChatServer( AuthClient authClient, BansClient bansClient, @@ -63,20 +62,14 @@ public class SockChatServer : IDisposable { if(string.IsNullOrWhiteSpace(name)) name = channelName; - Channel channelInfo = new( + Context.Channels.CreateChannel( name, channelCfg.SafeReadValue("password", string.Empty)!, rank: channelCfg.SafeReadValue("minRank", 0) ); - - Context.Channels.Add(channelInfo); - DefaultChannel ??= channelInfo; } - if(DefaultChannel is null) - throw new Exception("The default channel could not be determined."); - - GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections)); + GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, Context.Channels, MaxMessageLength, MaxConnections)); AuthedHandlers.AddRange([ new PingC2SPacketHandler(authClient), diff --git a/SharpChat/Channel.cs b/SharpChatCommon/Channels/Channel.cs similarity index 68% rename from SharpChat/Channel.cs rename to SharpChatCommon/Channels/Channel.cs index cfdd371..56bf40a 100644 --- a/SharpChat/Channel.cs +++ b/SharpChatCommon/Channels/Channel.cs @@ -1,4 +1,4 @@ -namespace SharpChat; +namespace SharpChat.Channels; public class Channel( string name, @@ -7,15 +7,18 @@ public class Channel( int rank = 0, string ownerId = "" ) { - public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name)); - public string Password { get; set; } = password ?? string.Empty; - public bool IsTemporary { get; set; } = isTemporary; - public int Rank { get; set; } = rank; - public string OwnerId { get; set; } = ownerId; + public string Name { get; internal set; } = name; + public string Password { get; internal set; } = password ?? string.Empty; + public bool IsTemporary { get; internal set; } = isTemporary; + public int Rank { get; internal set; } = rank; + public string OwnerId { get; internal set; } = ownerId; public bool HasPassword => !string.IsNullOrWhiteSpace(Password); + public bool IsPublic + => !HasPassword && Rank < 1; + public bool NameEquals(string name) { return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase); } diff --git a/SharpChatCommon/Channels/ChannelExistsException.cs b/SharpChatCommon/Channels/ChannelExistsException.cs new file mode 100644 index 0000000..751b2ba --- /dev/null +++ b/SharpChatCommon/Channels/ChannelExistsException.cs @@ -0,0 +1,4 @@ +namespace SharpChat.Channels; + +public class ChannelExistsException(string argName) + : ArgumentException("A channel with that name already exists.", argName) {} diff --git a/SharpChatCommon/Channels/ChannelIsDefaultException.cs b/SharpChatCommon/Channels/ChannelIsDefaultException.cs new file mode 100644 index 0000000..6a6294d --- /dev/null +++ b/SharpChatCommon/Channels/ChannelIsDefaultException.cs @@ -0,0 +1,4 @@ +namespace SharpChat.Channels; + +public class ChannelIsDefaultException(string argName) + : ArgumentException("You cannot delete the default channel.", argName) {} diff --git a/SharpChatCommon/Channels/ChannelNameFormatException.cs b/SharpChatCommon/Channels/ChannelNameFormatException.cs new file mode 100644 index 0000000..f29cae9 --- /dev/null +++ b/SharpChatCommon/Channels/ChannelNameFormatException.cs @@ -0,0 +1,4 @@ +namespace SharpChat.Channels; + +public class ChannelNameFormatException(string argName) + : ArgumentException("Channel name contains unsupported characters.", argName) { } diff --git a/SharpChatCommon/Channels/ChannelNotFoundException.cs b/SharpChatCommon/Channels/ChannelNotFoundException.cs new file mode 100644 index 0000000..0c8a818 --- /dev/null +++ b/SharpChatCommon/Channels/ChannelNotFoundException.cs @@ -0,0 +1,4 @@ +namespace SharpChat.Channels; + +public class ChannelNotFoundException(string argName) + : ArgumentException("No channel with that name exists.", argName) {} diff --git a/SharpChatCommon/Channels/ChannelsContext.cs b/SharpChatCommon/Channels/ChannelsContext.cs new file mode 100644 index 0000000..ac0b7c6 --- /dev/null +++ b/SharpChatCommon/Channels/ChannelsContext.cs @@ -0,0 +1,126 @@ +namespace SharpChat.Channels; + +public class ChannelsContext { + private readonly List<Channel> Channels = []; + + public int Count => Channels.Count; + + private Channel? DefaultChannelValue; + public Channel DefaultChannel { + get { + if(DefaultChannelValue is not null) { + if(Channels.Contains(DefaultChannelValue)) + return DefaultChannelValue; + + DefaultChannelValue = null; + } + + return GetChannel(c => c.IsPublic && !c.IsTemporary) ?? throw new NoDefaultChannelException(); + } + + set => DefaultChannelValue = value; + } + + public bool ChannelExists(Func<Channel, bool> predicate) { + return Channels.Any(predicate); + } + + public bool ChannelExists(string name) { + return ChannelExists(c => c.NameEquals(name)); + } + + public Channel? GetChannel(Func<Channel, bool> predicate) { + return Channels.FirstOrDefault(predicate); + } + + public Channel? GetChannel(string name) { + return GetChannel(c => c.NameEquals(name)); + } + + public IEnumerable<Channel> GetChannels(Func<Channel, bool> predicate) { + return Channels.Where(predicate); + } + + public IEnumerable<Channel> GetChannels(IEnumerable<string> names) { + return GetChannels(c => names.Any(n => c.NameEquals(n))); + } + + public IEnumerable<Channel> GetChannels(int maxRank) { + return GetChannels(c => c.Rank <= maxRank); + } + + public Channel CreateChannel( + string name, + string password = "", + bool temporary = false, + int rank = 0, + string ownerId = "" + ) { + if(!Channel.CheckName(name)) + throw new ChannelNameFormatException(nameof(name)); + if(ChannelExists(name)) + throw new ChannelExistsException(nameof(name)); + + Channel channel = new( + name, + password ?? string.Empty, + temporary, + rank, + ownerId ?? string.Empty + ); + + Channels.Add(channel); + + return channel; + } + + public Channel UpdateChannel( + Channel channel, + string? name = null, + string? password = null, + bool? temporary = null, + int? rank = null, + string? ownerId = null + ) => UpdateChannel(channel.Name, name, password, temporary, rank, ownerId); + + public Channel UpdateChannel( + string currentName, + string? name = null, + string? password = null, + bool? temporary = null, + int? rank = null, + string? ownerId = null + ) { + Channel channel = GetChannel(currentName) ?? throw new ChannelNotFoundException(nameof(currentName)); + + if(name is not null && currentName != name) { + if(!Channel.CheckName(name)) + throw new ChannelNameFormatException(nameof(name)); + if(ChannelExists(name)) + throw new ChannelExistsException(nameof(name)); + } + + if(name is not null) + channel.Name = name; + if(password is not null) + channel.Password = password; + if(temporary.HasValue) + channel.IsTemporary = temporary.Value; + if(rank.HasValue) + channel.Rank = rank.Value; + if(ownerId is not null) + channel.OwnerId = ownerId; + + return channel; + } + + public void RemoveChannel(string name) { + Channel channel = GetChannel(name) ?? throw new ChannelNotFoundException(nameof(name)); + + Channel defaultChannel = DefaultChannel; + if(channel == defaultChannel || defaultChannel.NameEquals(channel.Name)) + throw new ChannelIsDefaultException(nameof(name)); + + Channels.Remove(channel); + } +} diff --git a/SharpChatCommon/Channels/NoDefaultChannelException.cs b/SharpChatCommon/Channels/NoDefaultChannelException.cs new file mode 100644 index 0000000..d50513f --- /dev/null +++ b/SharpChatCommon/Channels/NoDefaultChannelException.cs @@ -0,0 +1,4 @@ +namespace SharpChat.Channels; + +public class NoDefaultChannelException() + : NullReferenceException("A public, non-temporary default channel could not be determined.") {}