566 lines
24 KiB
C#
566 lines
24 KiB
C#
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<long, RateLimiter> UserRateLimiters { get; } = new();
|
|
|
|
public SockChatContext(IEventStorage evtStore) {
|
|
EventStorage = evtStore;
|
|
Events.Subscribe(evtStore);
|
|
Events.Subscribe(this);
|
|
}
|
|
|
|
public void HandleEvent(ChatEventInfo info) {
|
|
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:add":
|
|
Users.Add(new UserInfo(
|
|
info.SenderId,
|
|
info.SenderName,
|
|
info.SenderColour,
|
|
info.SenderRank,
|
|
info.SenderPerms,
|
|
info.SenderNickName,
|
|
info.Data is UserAddEventData uaData && uaData.IsSuper
|
|
));
|
|
break;
|
|
|
|
case "user:delete":
|
|
UserStatuses.Clear(info.SenderId);
|
|
Users.Remove(info.SenderId);
|
|
break;
|
|
|
|
case "user:connect":
|
|
if(info.Data is not UserConnectEventData ucData || !ucData.Notify)
|
|
break;
|
|
|
|
SendTo(info.ChannelName, new UserConnectS2CPacket(
|
|
info.Id,
|
|
info.Created,
|
|
info.SenderId,
|
|
SockChatUtility.GetUserName(info, userStatusInfo),
|
|
info.SenderColour,
|
|
info.SenderRank,
|
|
info.SenderPerms
|
|
));
|
|
ChannelsUsers.Join(info.ChannelName, info.SenderId);
|
|
break;
|
|
|
|
case "user:disconnect":
|
|
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.OwnerId == info.SenderId)
|
|
Events.Dispatch("chan:delete", chan.Name, info);
|
|
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)
|
|
break;
|
|
|
|
UserInfo? uuUserInfo = Users.Get(info.SenderId);
|
|
if(uuUserInfo is null)
|
|
break;
|
|
|
|
bool uuHasChanged = false;
|
|
string? uuPrevName = null;
|
|
|
|
if(userUpdate.Name != null && !userUpdate.Name.Equals(uuUserInfo.UserName)) {
|
|
uuUserInfo.UserName = userUpdate.Name;
|
|
uuHasChanged = true;
|
|
}
|
|
|
|
if(userUpdate.NickName != null && !userUpdate.NickName.Equals(uuUserInfo.NickName)) {
|
|
if(userUpdate.Notify)
|
|
uuPrevName = string.IsNullOrWhiteSpace(uuUserInfo.NickName) ? uuUserInfo.UserName : uuUserInfo.NickName;
|
|
|
|
uuUserInfo.NickName = userUpdate.NickName;
|
|
uuHasChanged = true;
|
|
}
|
|
|
|
if(userUpdate.Colour.HasValue && userUpdate.Colour != uuUserInfo.Colour.ToMisuzu()) {
|
|
uuUserInfo.Colour = Colour.FromMisuzu(userUpdate.Colour.Value);
|
|
uuHasChanged = true;
|
|
}
|
|
|
|
if(userUpdate.Rank != null && userUpdate.Rank != uuUserInfo.Rank) {
|
|
uuUserInfo.Rank = userUpdate.Rank.Value;
|
|
uuHasChanged = true;
|
|
}
|
|
|
|
if(userUpdate.Perms.HasValue && userUpdate.Perms != uuUserInfo.Permissions) {
|
|
uuUserInfo.Permissions = userUpdate.Perms.Value;
|
|
uuHasChanged = true;
|
|
}
|
|
|
|
if(userUpdate.IsSuper.HasValue && userUpdate.IsSuper != uuUserInfo.IsSuper)
|
|
uuUserInfo.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(uuUserInfo, userStatusInfo)
|
|
));
|
|
|
|
SendToUserChannels(info.SenderId, new UserUpdateS2CPacket(
|
|
uuUserInfo.UserId,
|
|
SockChatUtility.GetUserName(uuUserInfo, userStatusInfo),
|
|
uuUserInfo.Colour,
|
|
uuUserInfo.Rank,
|
|
uuUserInfo.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":
|
|
HandleUserChannelJoin(
|
|
info.ChannelName,
|
|
new UserInfo(info), // kinda stinky
|
|
userStatusInfo
|
|
);
|
|
break;
|
|
|
|
case "chan:leave":
|
|
ChannelsUsers.Leave(info.ChannelName, info.SenderId);
|
|
SendTo(info.ChannelName, new UserChannelLeaveS2CPacket(info.SenderId));
|
|
|
|
ChannelInfo? clChannelInfo = Channels.Get(info.ChannelName);
|
|
if(clChannelInfo?.IsTemporary == true && clChannelInfo.OwnerId == info.SenderId)
|
|
Events.Dispatch("chan:delete", clChannelInfo.Name, info);
|
|
break;
|
|
|
|
case "chan:add":
|
|
if(info.Data is not ChannelAddEventData caData)
|
|
break;
|
|
|
|
ChannelInfo caChannelInfo = new(
|
|
info.ChannelName,
|
|
caData.Password,
|
|
caData.IsTemporary,
|
|
caData.MinRank,
|
|
info.SenderId
|
|
);
|
|
|
|
Channels.Add(caChannelInfo);
|
|
foreach(UserInfo ccu in Users.GetMany(minRank: caChannelInfo.Rank))
|
|
SendTo(ccu, new ChannelCreateS2CPacket(
|
|
caChannelInfo.Name,
|
|
caChannelInfo.HasPassword,
|
|
caChannelInfo.IsTemporary
|
|
));
|
|
break;
|
|
|
|
case "chan:update":
|
|
if(info.Data is not ChannelUpdateEventData cuData)
|
|
break;
|
|
|
|
ChannelInfo? cuChannelInfo = Channels.Get(info.ChannelName);
|
|
if(cuChannelInfo is null)
|
|
break;
|
|
|
|
string cuChannelName = cuChannelInfo.Name;
|
|
|
|
if(!string.IsNullOrEmpty(cuData.Name))
|
|
cuChannelInfo.Name = cuData.Name;
|
|
|
|
if(cuData.MinRank.HasValue)
|
|
cuChannelInfo.Rank = cuData.MinRank.Value;
|
|
|
|
if(cuData.Password != null) // this should probably be hashed
|
|
cuChannelInfo.Password = cuData.Password;
|
|
|
|
if(cuData.IsTemporary.HasValue)
|
|
cuChannelInfo.IsTemporary = cuData.IsTemporary.Value;
|
|
|
|
bool nameChanged = !cuChannelName.Equals(cuChannelInfo.Name, StringComparison.InvariantCultureIgnoreCase);
|
|
if(nameChanged)
|
|
ChannelsUsers.RenameChannel(cuChannelName, cuChannelInfo.Name);
|
|
|
|
// 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: cuChannelInfo.Rank))
|
|
SendTo(user, new ChannelUpdateS2CPacket(
|
|
cuChannelName,
|
|
cuChannelInfo.Name,
|
|
cuChannelInfo.HasPassword,
|
|
cuChannelInfo.IsTemporary
|
|
));
|
|
|
|
if(nameChanged)
|
|
SendTo(cuChannelInfo, new UserChannelForceJoinS2CPacket(cuChannelInfo.Name));
|
|
break;
|
|
|
|
case "chan:delete":
|
|
ChannelInfo? cdTargetChannelInfo = Channels.Get(info.ChannelName);
|
|
ChannelInfo? cdMainChannelInfo = Channels.MainChannel;
|
|
if(cdTargetChannelInfo == null || cdMainChannelInfo == null || cdTargetChannelInfo == Channels.MainChannel)
|
|
break;
|
|
|
|
// Remove channel from the listing
|
|
Channels.Remove(info.ChannelName);
|
|
|
|
// Move all users back to the main channel
|
|
UserInfo[] cdUserInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(info.ChannelName));
|
|
ChannelsUsers.DeleteChannel(cdMainChannelInfo);
|
|
|
|
foreach(UserInfo userInfo in cdUserInfos)
|
|
HandleUserChannelJoin(
|
|
info.ChannelName,
|
|
userInfo,
|
|
UserStatuses.Get(userInfo)
|
|
);
|
|
|
|
// Broadcast deletion of channel
|
|
foreach(UserInfo user in Users.GetMany(minRank: cdTargetChannelInfo.Rank))
|
|
SendTo(user, new ChannelDeleteS2CPacket(cdTargetChannelInfo.Name));
|
|
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 @<Target User> 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;
|
|
}
|
|
}
|
|
|
|
private void HandleUserChannelJoin(string channelName, UserInfo userInfo, UserStatusInfo statusInfo) {
|
|
SendTo(userInfo.UserId, new ClearMessagesAndUsersS2CPacket());
|
|
|
|
UserInfo[] userInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(channelName));
|
|
List<UsersPopulateS2CPacket.ListEntry> userEntries = new();
|
|
foreach(UserInfo memberInfo in userInfos)
|
|
if(memberInfo.UserId != userInfo.UserId)
|
|
userEntries.Add(new(
|
|
memberInfo.UserId,
|
|
SockChatUtility.GetUserName(memberInfo, UserStatuses.Get(memberInfo)),
|
|
memberInfo.Colour,
|
|
memberInfo.Rank,
|
|
memberInfo.Permissions,
|
|
true
|
|
));
|
|
SendTo(userInfo.UserId, new UsersPopulateS2CPacket(userEntries.ToArray()));
|
|
|
|
SendTo(channelName, new UserChannelJoinS2CPacket(
|
|
userInfo.UserId,
|
|
SockChatUtility.GetUserName(userInfo, statusInfo),
|
|
userInfo.Colour,
|
|
userInfo.Rank,
|
|
userInfo.Permissions
|
|
));
|
|
|
|
HandleChannelEventLog(channelName, p => SendTo(userInfo.UserId, p));
|
|
|
|
ChannelsUsers.Join(channelName, userInfo.UserId);
|
|
SendTo(userInfo.UserId, new UserChannelForceJoinS2CPacket(channelName));
|
|
}
|
|
|
|
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:delete", 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 void HandleChannelEventLog(string channelName, Action<ISockChatS2CPacket> 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":
|
|
if(info.Data is UserConnectEventData ucData && ucData.Notify)
|
|
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 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(long userId, ISockChatS2CPacket packet) {
|
|
ChannelInfo[] chans = Channels.GetMany(ChannelsUsers.GetUserChannelNames(userId));
|
|
string data = packet.Pack();
|
|
foreach(ChannelInfo chan in chans)
|
|
SendTo(chan, data);
|
|
}
|
|
}
|
|
}
|