1034 lines
44 KiB
C#
1034 lines
44 KiB
C#
using Fleck;
|
|
using SharpChat.Commands;
|
|
using SharpChat.Config;
|
|
using SharpChat.Events;
|
|
using SharpChat.EventStorage;
|
|
using SharpChat.Misuzu;
|
|
using SharpChat.Packet;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace SharpChat {
|
|
public class SockChatServer : IDisposable {
|
|
public const ushort DEFAULT_PORT = 6770;
|
|
public const int DEFAULT_MSG_LENGTH_MAX = 5000;
|
|
public const int DEFAULT_MAX_CONNECTIONS = 5;
|
|
public const int DEFAULT_FLOOD_KICK_LENGTH = 30;
|
|
|
|
public bool IsDisposed { get; private set; }
|
|
|
|
public static ChatUser Bot { get; } = new ChatUser {
|
|
UserId = -1,
|
|
Username = "ChatBot",
|
|
Rank = 0,
|
|
Colour = new ChatColour(),
|
|
};
|
|
|
|
public IWebSocketServer Server { get; }
|
|
public ChatContext Context { get; }
|
|
|
|
private readonly HttpClient HttpClient;
|
|
private readonly MisuzuClient Misuzu;
|
|
|
|
private readonly CachedValue<int> MaxMessageLength;
|
|
private readonly CachedValue<int> MaxConnections;
|
|
private readonly CachedValue<int> FloodKickLength;
|
|
|
|
private IReadOnlyCollection<IChatCommand> Commands { get; } = new IChatCommand[] {
|
|
new AFKCommand(),
|
|
};
|
|
|
|
public List<ChatUserSession> Sessions { get; } = new List<ChatUserSession>();
|
|
private object SessionsAccess { get; } = new object();
|
|
|
|
public ChatUserSession GetSession(IWebSocketConnection conn) {
|
|
lock(SessionsAccess)
|
|
return Sessions.FirstOrDefault(x => x.Connection == conn);
|
|
}
|
|
|
|
private ManualResetEvent Shutdown { get; set; }
|
|
private bool IsShuttingDown = false;
|
|
|
|
private ChatChannel DefaultChannel { get; set; }
|
|
|
|
public SockChatServer(HttpClient httpClient, MisuzuClient msz, IEventStorage evtStore, IConfig config) {
|
|
Logger.Write("Initialising Sock Chat server...");
|
|
|
|
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
|
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
|
|
|
|
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
|
|
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
|
|
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
|
|
|
|
Context = new ChatContext(evtStore);
|
|
|
|
string[] channelNames = config.ReadValue("channels", new[] { "lounge" });
|
|
|
|
foreach(string channelName in channelNames) {
|
|
ChatChannel channelInfo = new(channelName);
|
|
IConfig channelCfg = config.ScopeTo($"channels:{channelName}");
|
|
|
|
string tmp;
|
|
tmp = channelCfg.SafeReadValue("name", string.Empty);
|
|
if(!string.IsNullOrWhiteSpace(tmp))
|
|
channelInfo.Name = tmp;
|
|
|
|
channelInfo.Password = channelCfg.SafeReadValue("password", string.Empty);
|
|
channelInfo.Rank = channelCfg.SafeReadValue("minRank", 0);
|
|
|
|
Context.Channels.Add(channelInfo);
|
|
|
|
DefaultChannel ??= channelInfo;
|
|
}
|
|
|
|
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
|
|
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}");
|
|
}
|
|
|
|
public void Listen(ManualResetEvent mre) {
|
|
Shutdown = mre;
|
|
|
|
Server.Start(sock => {
|
|
if(IsShuttingDown || IsDisposed) {
|
|
sock.Close(1013);
|
|
return;
|
|
}
|
|
|
|
sock.OnOpen = () => OnOpen(sock);
|
|
sock.OnClose = () => OnClose(sock);
|
|
sock.OnError = err => OnError(sock, err);
|
|
sock.OnMessage = msg => OnMessage(sock, msg);
|
|
});
|
|
|
|
Logger.Write("Listening...");
|
|
}
|
|
|
|
private void OnOpen(IWebSocketConnection conn) {
|
|
Logger.Write($"Connection opened from {conn.ConnectionInfo.ClientIpAddress}:{conn.ConnectionInfo.ClientPort}");
|
|
|
|
lock(SessionsAccess) {
|
|
if(!Sessions.Any(x => x.Connection == conn))
|
|
Sessions.Add(new ChatUserSession(conn));
|
|
}
|
|
|
|
Context.Update();
|
|
}
|
|
|
|
private void OnClose(IWebSocketConnection conn) {
|
|
Logger.Write($"Connection closed from {conn.ConnectionInfo.ClientIpAddress}:{conn.ConnectionInfo.ClientPort}");
|
|
|
|
ChatUserSession sess = GetSession(conn);
|
|
|
|
// Remove connection from user
|
|
if(sess?.User != null) {
|
|
// RemoveConnection sets conn.User to null so we must grab a local copy.
|
|
ChatUser user = sess.User;
|
|
|
|
user.RemoveSession(sess);
|
|
|
|
if(!user.HasSessions)
|
|
Context.UserLeave(null, user);
|
|
}
|
|
|
|
// Update context
|
|
Context.Update();
|
|
|
|
// Remove connection from server
|
|
lock(SessionsAccess)
|
|
Sessions.Remove(sess);
|
|
|
|
sess?.Dispose();
|
|
}
|
|
|
|
private void OnError(IWebSocketConnection conn, Exception ex) {
|
|
ChatUserSession sess = GetSession(conn);
|
|
string sessId = sess?.Id ?? new string('0', ChatUserSession.ID_LENGTH);
|
|
Logger.Write($"[{sessId} {conn.ConnectionInfo.ClientIpAddress}] {ex}");
|
|
Context.Update();
|
|
}
|
|
|
|
private readonly object BumpAccess = new();
|
|
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
|
|
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
|
|
|
|
private void OnMessage(IWebSocketConnection conn, string msg) {
|
|
Context.Update();
|
|
|
|
ChatUserSession sess = GetSession(conn);
|
|
|
|
if(sess == null) {
|
|
conn.Close();
|
|
return;
|
|
}
|
|
|
|
if(sess.User is not null && sess.User.HasFloodProtection) {
|
|
sess.User.RateLimiter.AddTimePoint();
|
|
|
|
if(sess.User.RateLimiter.State == ChatRateLimitState.Kick) {
|
|
Task.Run(async () => {
|
|
TimeSpan duration = TimeSpan.FromSeconds(FloodKickLength);
|
|
|
|
await Misuzu.CreateBanAsync(
|
|
sess.User.UserId.ToString(), sess.RemoteAddress.ToString(),
|
|
string.Empty, "::1",
|
|
duration,
|
|
"Kicked from chat for flood protection."
|
|
);
|
|
|
|
Context.BanUser(sess.User, duration, UserDisconnectReason.Flood);
|
|
}).Wait();
|
|
return;
|
|
} else if(sess.User.RateLimiter.State == ChatRateLimitState.Warning)
|
|
sess.User.Send(new FloodWarningPacket());
|
|
}
|
|
|
|
string[] args = msg.Split('\t');
|
|
if(args.Length < 1)
|
|
return;
|
|
|
|
switch(args[0]) {
|
|
case "0":
|
|
if(!int.TryParse(args[1], out int pTime))
|
|
break;
|
|
|
|
sess.BumpPing();
|
|
sess.Send(new PongPacket(sess.LastPing));
|
|
|
|
lock(BumpAccess) {
|
|
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
|
|
(string, string)[] bumpList;
|
|
lock(Context.UsersAccess)
|
|
bumpList = Context.Users
|
|
.Where(u => u.HasSessions && u.Status == ChatUserStatus.Online)
|
|
.Select(u => (u.UserId.ToString(), u.RemoteAddresses.FirstOrDefault()?.ToString() ?? string.Empty))
|
|
.ToArray();
|
|
|
|
if(bumpList.Any())
|
|
Task.Run(async () => {
|
|
await Misuzu.BumpUsersOnlineAsync(bumpList);
|
|
}).Wait();
|
|
|
|
LastBump = DateTimeOffset.UtcNow;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "1":
|
|
if(sess.User != null)
|
|
break;
|
|
|
|
string authMethod = args.ElementAtOrDefault(1);
|
|
if(string.IsNullOrWhiteSpace(authMethod)) {
|
|
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
|
|
sess.Dispose();
|
|
break;
|
|
}
|
|
|
|
string authToken = args.ElementAtOrDefault(2);
|
|
if(string.IsNullOrWhiteSpace(authToken)) {
|
|
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
|
|
sess.Dispose();
|
|
break;
|
|
}
|
|
|
|
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
|
|
string[] tokenParts = authToken.Split(':', 2);
|
|
authMethod = tokenParts[0];
|
|
authToken = tokenParts[1];
|
|
}
|
|
|
|
Task.Run(async () => {
|
|
MisuzuAuthInfo fai;
|
|
string ipAddr = sess.RemoteAddress.ToString();
|
|
|
|
try {
|
|
fai = await Misuzu.AuthVerifyAsync(authMethod, authToken, ipAddr);
|
|
} catch(Exception ex) {
|
|
Logger.Write($"<{sess.Id}> Failed to authenticate: {ex}");
|
|
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
|
|
sess.Dispose();
|
|
#if DEBUG
|
|
throw;
|
|
#else
|
|
return;
|
|
#endif
|
|
}
|
|
|
|
if(!fai.Success) {
|
|
Logger.Debug($"<{sess.Id}> Auth fail: {fai.Reason}");
|
|
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
|
|
sess.Dispose();
|
|
return;
|
|
}
|
|
|
|
MisuzuBanInfo fbi;
|
|
try {
|
|
fbi = await Misuzu.CheckBanAsync(fai.UserId.ToString(), ipAddr);
|
|
} catch(Exception ex) {
|
|
Logger.Write($"<{sess.Id}> Failed auth ban check: {ex}");
|
|
sess.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
|
|
sess.Dispose();
|
|
#if DEBUG
|
|
throw;
|
|
#else
|
|
return;
|
|
#endif
|
|
}
|
|
|
|
if(fbi.IsBanned && !fbi.HasExpired) {
|
|
Logger.Write($"<{sess.Id}> User is banned.");
|
|
sess.Send(new AuthFailPacket(AuthFailReason.Banned, fbi));
|
|
sess.Dispose();
|
|
return;
|
|
}
|
|
|
|
lock(Context.UsersAccess) {
|
|
ChatUser aUser = Context.Users.FirstOrDefault(u => u.UserId == fai.UserId);
|
|
|
|
if(aUser == null)
|
|
aUser = new ChatUser(fai);
|
|
else {
|
|
aUser.ApplyAuth(fai);
|
|
aUser.Channel?.Send(new UserUpdatePacket(aUser));
|
|
}
|
|
|
|
// Enforce a maximum amount of connections per user
|
|
if(aUser.SessionCount >= MaxConnections) {
|
|
sess.Send(new AuthFailPacket(AuthFailReason.MaxSessions));
|
|
sess.Dispose();
|
|
return;
|
|
}
|
|
|
|
// Bumping the ping to prevent upgrading
|
|
sess.BumpPing();
|
|
|
|
aUser.AddSession(sess);
|
|
|
|
sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, $"Welcome to Flashii Chat, {aUser.Username}!"));
|
|
|
|
if(File.Exists("welcome.txt")) {
|
|
IEnumerable<string> lines = File.ReadAllLines("welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x));
|
|
string line = lines.ElementAtOrDefault(RNG.Next(lines.Count()));
|
|
|
|
if(!string.IsNullOrWhiteSpace(line))
|
|
sess.Send(new LegacyCommandResponse(LCR.WELCOME, false, line));
|
|
}
|
|
|
|
Context.HandleJoin(aUser, DefaultChannel, sess, MaxMessageLength);
|
|
}
|
|
}).Wait();
|
|
break;
|
|
|
|
case "2":
|
|
if(args.Length < 3)
|
|
break;
|
|
|
|
ChatUser mUser = sess.User;
|
|
|
|
// No longer concats everything after index 1 with \t, no previous implementation did that either
|
|
string messageText = args.ElementAtOrDefault(2);
|
|
|
|
if(mUser == null || !mUser.Can(ChatUserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText))
|
|
break;
|
|
|
|
#if !DEBUG
|
|
// Extra validation step, not necessary at all but enforces proper formatting in SCv1.
|
|
if (!long.TryParse(args[1], out long mUserId) || mUser.UserId != mUserId)
|
|
break;
|
|
#endif
|
|
ChatChannel mChannel = mUser.CurrentChannel;
|
|
|
|
if(mChannel == null
|
|
|| !mUser.InChannel(mChannel)
|
|
|| (mUser.IsSilenced && !mUser.Can(ChatUserPermissions.SilenceUser)))
|
|
break;
|
|
|
|
if(mUser.Status != ChatUserStatus.Online) {
|
|
mUser.Status = ChatUserStatus.Online;
|
|
mChannel.Send(new UserUpdatePacket(mUser));
|
|
}
|
|
|
|
int maxMsgLength = MaxMessageLength;
|
|
if(messageText.Length > maxMsgLength)
|
|
messageText = messageText[..maxMsgLength];
|
|
|
|
messageText = messageText.Trim();
|
|
|
|
#if DEBUG
|
|
Logger.Write($"<{sess.Id} {mUser.Username}> {messageText}");
|
|
#endif
|
|
|
|
IChatMessage message = null;
|
|
|
|
if(messageText[0] == '/') {
|
|
message = HandleV1Command(messageText, mUser, mChannel, sess);
|
|
|
|
if(message == null)
|
|
break;
|
|
}
|
|
|
|
message ??= new ChatMessage {
|
|
Target = mChannel,
|
|
TargetName = mChannel.TargetName,
|
|
DateTime = DateTimeOffset.UtcNow,
|
|
Sender = mUser,
|
|
Text = messageText,
|
|
};
|
|
|
|
lock(Context.EventsAccess) {
|
|
Context.Events.AddEvent(message);
|
|
mChannel.Send(new ChatMessageAddPacket(message));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
public IChatMessage HandleV1Command(string message, ChatUser user, ChatChannel channel, ChatUserSession sess) {
|
|
string[] parts = message[1..].Split(' ');
|
|
string commandName = parts[0].Replace(".", string.Empty).ToLowerInvariant();
|
|
|
|
for(int i = 1; i < parts.Length; i++)
|
|
parts[i] = parts[i].Replace("<", "<")
|
|
.Replace(">", ">")
|
|
.Replace("\n", " <br/> ");
|
|
|
|
IChatCommand command = null;
|
|
foreach(IChatCommand cmd in Commands)
|
|
if(cmd.IsMatch(commandName)) {
|
|
command = cmd;
|
|
break;
|
|
}
|
|
|
|
if(command != null)
|
|
return command.Dispatch(new ChatCommandContext(parts, user, channel));
|
|
|
|
switch(commandName) {
|
|
case "nick": // sets a temporary nickname
|
|
bool setOthersNick = user.Can(ChatUserPermissions.SetOthersNickname);
|
|
|
|
if(!setOthersNick && !user.Can(ChatUserPermissions.SetOwnNickname)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
ChatUser targetUser = null;
|
|
int offset = 1;
|
|
|
|
if(setOthersNick && parts.Length > 1 && long.TryParse(parts[1], out long targetUserId) && targetUserId > 0) {
|
|
lock(Context.UsersAccess)
|
|
targetUser = Context.Users.FirstOrDefault(u => u.UserId == targetUserId);
|
|
offset = 2;
|
|
}
|
|
|
|
targetUser ??= user;
|
|
|
|
if(parts.Length < offset) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
string nickStr = string.Join('_', parts.Skip(offset))
|
|
.Replace(' ', '_')
|
|
.Replace("\n", string.Empty)
|
|
.Replace("\r", string.Empty)
|
|
.Replace("\f", string.Empty)
|
|
.Replace("\t", string.Empty)
|
|
.Trim();
|
|
|
|
if(nickStr == targetUser.Username)
|
|
nickStr = null;
|
|
else if(nickStr.Length > 15)
|
|
nickStr = nickStr[..15];
|
|
else if(string.IsNullOrEmpty(nickStr))
|
|
nickStr = null;
|
|
|
|
lock(Context.UsersAccess)
|
|
if(!string.IsNullOrWhiteSpace(nickStr) && Context.Users.Any(u => u.NameEquals(nickStr))) {
|
|
user.Send(new LegacyCommandResponse(LCR.NAME_IN_USE, true, nickStr));
|
|
break;
|
|
}
|
|
|
|
string previousName = targetUser == user ? (targetUser.Nickname ?? targetUser.Username) : null;
|
|
targetUser.Nickname = nickStr;
|
|
channel.Send(new UserUpdatePacket(targetUser, previousName));
|
|
break;
|
|
case "whisper": // sends a pm to another user
|
|
case "msg":
|
|
if(parts.Length < 3) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
ChatUser whisperUser;
|
|
string whisperUserStr = parts.ElementAtOrDefault(1);
|
|
lock(Context.UsersAccess)
|
|
whisperUser = Context.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
|
|
|
|
if(whisperUser == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, whisperUserStr));
|
|
break;
|
|
}
|
|
|
|
if(whisperUser == user)
|
|
break;
|
|
|
|
string whisperStr = string.Join(' ', parts.Skip(2));
|
|
|
|
whisperUser.Send(new ChatMessageAddPacket(new ChatMessage {
|
|
DateTime = DateTimeOffset.Now,
|
|
Target = whisperUser,
|
|
TargetName = whisperUser.TargetName,
|
|
Sender = user,
|
|
Text = whisperStr,
|
|
Flags = ChatMessageFlags.Private,
|
|
}));
|
|
user.Send(new ChatMessageAddPacket(new ChatMessage {
|
|
DateTime = DateTimeOffset.Now,
|
|
Target = whisperUser,
|
|
TargetName = whisperUser.TargetName,
|
|
Sender = user,
|
|
Text = $"{whisperUser.DisplayName} {whisperStr}",
|
|
Flags = ChatMessageFlags.Private,
|
|
}));
|
|
break;
|
|
case "action": // describe an action
|
|
case "me":
|
|
if(parts.Length < 2)
|
|
break;
|
|
|
|
string actionMsg = string.Join(' ', parts.Skip(1));
|
|
|
|
return new ChatMessage {
|
|
Target = channel,
|
|
TargetName = channel.TargetName,
|
|
DateTime = DateTimeOffset.UtcNow,
|
|
Sender = user,
|
|
Text = actionMsg,
|
|
Flags = ChatMessageFlags.Action,
|
|
};
|
|
case "who": // gets all online users/online users in a channel if arg
|
|
StringBuilder whoChanSB = new();
|
|
string whoChanStr = parts.Length > 1 && !string.IsNullOrEmpty(parts[1]) ? parts[1] : string.Empty;
|
|
|
|
if(!string.IsNullOrEmpty(whoChanStr)) {
|
|
ChatChannel whoChan;
|
|
lock(Context.ChannelsAccess)
|
|
whoChan = Context.Channels.FirstOrDefault(c => c.NameEquals(whoChanStr));
|
|
|
|
if(whoChan == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
|
|
break;
|
|
}
|
|
|
|
if(whoChan.Rank > user.Rank || (whoChan.HasPassword && !user.Can(ChatUserPermissions.JoinAnyChannel))) {
|
|
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_ERROR, true, whoChanStr));
|
|
break;
|
|
}
|
|
|
|
foreach(ChatUser whoUser in whoChan.GetUsers()) {
|
|
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
|
|
|
|
if(whoUser == user)
|
|
whoChanSB.Append(@" style=""font-weight: bold;""");
|
|
|
|
whoChanSB.Append('>');
|
|
whoChanSB.Append(whoUser.DisplayName);
|
|
whoChanSB.Append("</a>, ");
|
|
}
|
|
|
|
if(whoChanSB.Length > 2)
|
|
whoChanSB.Length -= 2;
|
|
|
|
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_CHANNEL, false, whoChan.Name, whoChanSB));
|
|
} else {
|
|
lock(Context.UsersAccess)
|
|
foreach(ChatUser whoUser in Context.Users) {
|
|
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
|
|
|
|
if(whoUser == user)
|
|
whoChanSB.Append(@" style=""font-weight: bold;""");
|
|
|
|
whoChanSB.Append('>');
|
|
whoChanSB.Append(whoUser.DisplayName);
|
|
whoChanSB.Append("</a>, ");
|
|
}
|
|
|
|
if(whoChanSB.Length > 2)
|
|
whoChanSB.Length -= 2;
|
|
|
|
user.Send(new LegacyCommandResponse(LCR.USERS_LISTING_SERVER, false, whoChanSB));
|
|
}
|
|
break;
|
|
|
|
// double alias for delchan and delmsg
|
|
// if the argument is a number we're deleting a message
|
|
// if the argument is a string we're deleting a channel
|
|
case "delete":
|
|
if(parts.Length < 2) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
if(parts[1].All(char.IsDigit))
|
|
goto case "delmsg";
|
|
goto case "delchan";
|
|
|
|
// anyone can use these
|
|
case "join": // join a channel
|
|
if(parts.Length < 2)
|
|
break;
|
|
|
|
string joinChanStr = parts.ElementAtOrDefault(1);
|
|
ChatChannel joinChan;
|
|
lock(Context.ChannelsAccess)
|
|
joinChan = Context.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr));
|
|
|
|
if(joinChan == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, joinChanStr));
|
|
user.ForceChannel();
|
|
break;
|
|
}
|
|
|
|
Context.SwitchChannel(user, joinChan, string.Join(' ', parts.Skip(2)));
|
|
break;
|
|
case "create": // create a new channel
|
|
if(user.Can(ChatUserPermissions.CreateChannel)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
bool createChanHasHierarchy;
|
|
if(parts.Length < 2 || (createChanHasHierarchy = parts[1].All(char.IsDigit) && parts.Length < 3)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
int createChanHierarchy = 0;
|
|
if(createChanHasHierarchy)
|
|
if(!int.TryParse(parts[1], out createChanHierarchy))
|
|
createChanHierarchy = 0;
|
|
|
|
if(createChanHierarchy > user.Rank) {
|
|
user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
|
|
break;
|
|
}
|
|
|
|
string createChanName = string.Join('_', parts.Skip(createChanHasHierarchy ? 2 : 1));
|
|
|
|
if(!ChatChannel.CheckName(createChanName)) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NAME_INVALID));
|
|
break;
|
|
}
|
|
|
|
lock(Context.ChannelsAccess) {
|
|
if(Context.Channels.Any(c => c.NameEquals(createChanName))) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_ALREADY_EXISTS, true, createChanName));
|
|
break;
|
|
}
|
|
|
|
ChatChannel createChan = new() {
|
|
Name = createChanName,
|
|
IsTemporary = !user.Can(ChatUserPermissions.SetChannelPermanent),
|
|
Rank = createChanHierarchy,
|
|
Owner = user,
|
|
};
|
|
|
|
Context.Channels.Add(createChan);
|
|
lock(Context.UsersAccess) {
|
|
foreach(ChatUser ccu in Context.Users.Where(u => u.Rank >= channel.Rank))
|
|
ccu.Send(new ChannelCreatePacket(channel));
|
|
}
|
|
|
|
Context.SwitchChannel(user, createChan, createChan.Password);
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_CREATED, false, createChan.Name));
|
|
}
|
|
break;
|
|
case "delchan": // delete a channel
|
|
if(parts.Length < 2 || string.IsNullOrWhiteSpace(parts[1])) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
string delChanName = string.Join('_', parts.Skip(1));
|
|
ChatChannel delChan;
|
|
lock(Context.ChannelsAccess)
|
|
delChan = Context.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
|
|
|
|
if(delChan == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, delChanName));
|
|
break;
|
|
}
|
|
|
|
if(!user.Can(ChatUserPermissions.DeleteChannel) && delChan.Owner != user) {
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
|
|
break;
|
|
}
|
|
|
|
lock(Context.ChannelsAccess)
|
|
Context.RemoveChannel(delChan);
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_DELETED, false, delChan.Name));
|
|
break;
|
|
case "password": // set a password on the channel
|
|
case "pwd":
|
|
if(!user.Can(ChatUserPermissions.SetChannelPassword) || channel.Owner != user) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
string chanPass = string.Join(' ', parts.Skip(1)).Trim();
|
|
|
|
if(string.IsNullOrWhiteSpace(chanPass))
|
|
chanPass = string.Empty;
|
|
|
|
lock(Context.ChannelsAccess)
|
|
Context.UpdateChannel(channel, password: chanPass);
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_PASSWORD_CHANGED, false));
|
|
break;
|
|
case "privilege": // sets a minimum hierarchy requirement on the channel
|
|
case "rank":
|
|
case "priv":
|
|
if(!user.Can(ChatUserPermissions.SetChannelHierarchy) || channel.Owner != user) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
if(parts.Length < 2 || !int.TryParse(parts[1], out int chanHierarchy) || chanHierarchy > user.Rank) {
|
|
user.Send(new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
|
|
break;
|
|
}
|
|
|
|
lock(Context.ChannelsAccess)
|
|
Context.UpdateChannel(channel, hierarchy: chanHierarchy);
|
|
user.Send(new LegacyCommandResponse(LCR.CHANNEL_HIERARCHY_CHANGED, false));
|
|
break;
|
|
|
|
case "say": // pretend to be the bot
|
|
if(!user.Can(ChatUserPermissions.Broadcast)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
Context.Send(new LegacyCommandResponse(LCR.BROADCAST, false, string.Join(' ', parts.Skip(1))));
|
|
break;
|
|
case "delmsg": // deletes a message
|
|
bool deleteAnyMessage = user.Can(ChatUserPermissions.DeleteAnyMessage);
|
|
|
|
if(!deleteAnyMessage && !user.Can(ChatUserPermissions.DeleteOwnMessage)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
if(parts.Length < 2 || !parts[1].All(char.IsDigit) || !long.TryParse(parts[1], out long delSeqId)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
lock(Context.EventsAccess) {
|
|
IChatEvent delMsg = Context.Events.GetEvent(delSeqId);
|
|
|
|
if(delMsg == null || delMsg.Sender.Rank > user.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != user.UserId)) {
|
|
user.Send(new LegacyCommandResponse(LCR.MESSAGE_DELETE_ERROR));
|
|
break;
|
|
}
|
|
|
|
Context.Events.RemoveEvent(delMsg);
|
|
}
|
|
break;
|
|
case "kick": // kick a user from the server
|
|
case "ban": // ban a user from the server, this differs from /kick in that it adds all remote address to the ip banlist
|
|
bool isBanning = commandName == "ban";
|
|
|
|
if(!user.Can(isBanning ? ChatUserPermissions.BanUser : ChatUserPermissions.KickUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
string banUserTarget = parts.ElementAtOrDefault(1);
|
|
string banDurationStr = parts.ElementAtOrDefault(2);
|
|
int banReasonIndex = 2;
|
|
ChatUser banUser = null;
|
|
|
|
lock(Context.UsersAccess)
|
|
if(banUserTarget == null || (banUser = Context.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, banUser == null ? "User" : banUserTarget));
|
|
break;
|
|
}
|
|
|
|
if(banUser == user || banUser.Rank >= user.Rank) {
|
|
user.Send(new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.DisplayName));
|
|
break;
|
|
}
|
|
|
|
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
|
|
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
|
|
if(durationSeconds < 0) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
duration = TimeSpan.FromSeconds(durationSeconds);
|
|
++banReasonIndex;
|
|
}
|
|
|
|
if(duration <= TimeSpan.Zero) {
|
|
Context.BanUser(banUser, duration);
|
|
break;
|
|
}
|
|
|
|
string banReason = string.Join(' ', parts.Skip(banReasonIndex));
|
|
|
|
Task.Run(async () => {
|
|
// obviously it makes no sense to only check for one ip address but that's current misuzu limitations
|
|
MisuzuBanInfo fbi = await Misuzu.CheckBanAsync(
|
|
banUser.UserId.ToString(), banUser.RemoteAddresses.First().ToString()
|
|
);
|
|
|
|
if(fbi.IsBanned && !fbi.HasExpired) {
|
|
user.Send(new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.DisplayName));
|
|
return;
|
|
}
|
|
|
|
await Misuzu.CreateBanAsync(
|
|
banUser.UserId.ToString(), banUser.RemoteAddresses.First().ToString(),
|
|
user.UserId.ToString(), sess.RemoteAddress.ToString(),
|
|
duration, banReason
|
|
);
|
|
|
|
Context.BanUser(banUser, duration);
|
|
}).Wait();
|
|
break;
|
|
case "pardon":
|
|
case "unban":
|
|
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
bool unbanUserTargetIsName = true;
|
|
string unbanUserTarget = parts.ElementAtOrDefault(1);
|
|
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
ChatUser unbanUser;
|
|
lock(Context.UsersAccess)
|
|
unbanUser = Context.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
|
|
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) {
|
|
unbanUserTargetIsName = false;
|
|
lock(Context.UsersAccess)
|
|
unbanUser = Context.Users.FirstOrDefault(u => u.UserId == unbanUserId);
|
|
}
|
|
|
|
if(unbanUser != null)
|
|
unbanUserTarget = unbanUser.UserId.ToString();
|
|
|
|
Task.Run(async () => {
|
|
MisuzuBanInfo banInfo = await Misuzu.CheckBanAsync(unbanUserTarget, userIdIsName: unbanUserTargetIsName);
|
|
|
|
if(!banInfo.IsBanned || banInfo.HasExpired) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUserTarget));
|
|
return;
|
|
}
|
|
|
|
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.UserId);
|
|
if(wasBanned)
|
|
user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanUserTarget));
|
|
else
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUserTarget));
|
|
}).Wait();
|
|
break;
|
|
case "pardonip":
|
|
case "unbanip":
|
|
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
string unbanAddrTarget = parts.ElementAtOrDefault(1);
|
|
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress unbanAddr)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
unbanAddrTarget = unbanAddr.ToString();
|
|
|
|
Task.Run(async () => {
|
|
MisuzuBanInfo banInfo = await Misuzu.CheckBanAsync(ipAddr: unbanAddrTarget);
|
|
|
|
if(!banInfo.IsBanned || banInfo.HasExpired) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanAddrTarget));
|
|
return;
|
|
}
|
|
|
|
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.RemoteAddress);
|
|
if(wasBanned)
|
|
user.Send(new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanAddrTarget));
|
|
else
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanAddrTarget));
|
|
}).Wait();
|
|
break;
|
|
case "bans": // gets a list of bans
|
|
case "banned":
|
|
if(!user.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
Task.Run(async () => {
|
|
user.Send(new BanListPacket(
|
|
await Misuzu.GetBanListAsync()
|
|
));
|
|
}).Wait();
|
|
break;
|
|
case "silence": // silence a user
|
|
if(!user.Can(ChatUserPermissions.SilenceUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
string silUserStr = parts.ElementAtOrDefault(1);
|
|
ChatUser silUser;
|
|
|
|
lock(Context.UsersAccess)
|
|
if(parts.Length < 2 || (silUser = Context.Users.FirstOrDefault(u => u.NameEquals(silUserStr))) == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? "User" : silUserStr));
|
|
break;
|
|
}
|
|
|
|
if(silUser == user) {
|
|
user.Send(new LegacyCommandResponse(LCR.SILENCE_SELF));
|
|
break;
|
|
}
|
|
|
|
if(silUser.Rank >= user.Rank) {
|
|
user.Send(new LegacyCommandResponse(LCR.SILENCE_HIERARCHY));
|
|
break;
|
|
}
|
|
|
|
if(silUser.IsSilenced) {
|
|
user.Send(new LegacyCommandResponse(LCR.SILENCE_ALREADY));
|
|
break;
|
|
}
|
|
|
|
DateTimeOffset silenceUntil = DateTimeOffset.MaxValue;
|
|
|
|
if(parts.Length > 2) {
|
|
if(!double.TryParse(parts[2], out double silenceSeconds)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
|
|
break;
|
|
}
|
|
|
|
silenceUntil = DateTimeOffset.UtcNow.AddSeconds(silenceSeconds);
|
|
}
|
|
|
|
silUser.SilencedUntil = silenceUntil;
|
|
silUser.Send(new LegacyCommandResponse(LCR.SILENCED, false));
|
|
user.Send(new LegacyCommandResponse(LCR.TARGET_SILENCED, false, silUser.DisplayName));
|
|
break;
|
|
case "unsilence": // unsilence a user
|
|
if(!user.Can(ChatUserPermissions.SilenceUser)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
string unsilUserStr = parts.ElementAtOrDefault(1);
|
|
ChatUser unsilUser;
|
|
|
|
lock(Context.UsersAccess)
|
|
if(parts.Length < 2 || (unsilUser = Context.Users.FirstOrDefault(u => u.NameEquals(unsilUserStr))) == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? "User" : unsilUserStr));
|
|
break;
|
|
}
|
|
|
|
if(unsilUser.Rank >= user.Rank) {
|
|
user.Send(new LegacyCommandResponse(LCR.UNSILENCE_HIERARCHY));
|
|
break;
|
|
}
|
|
|
|
if(!unsilUser.IsSilenced) {
|
|
user.Send(new LegacyCommandResponse(LCR.NOT_SILENCED));
|
|
break;
|
|
}
|
|
|
|
unsilUser.SilencedUntil = DateTimeOffset.MinValue;
|
|
unsilUser.Send(new LegacyCommandResponse(LCR.UNSILENCED, false));
|
|
user.Send(new LegacyCommandResponse(LCR.TARGET_UNSILENCED, false, unsilUser.DisplayName));
|
|
break;
|
|
case "ip": // gets a user's ip (from all connections in this case)
|
|
case "whois":
|
|
if(!user.Can(ChatUserPermissions.SeeIPAddress)) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, "/ip"));
|
|
break;
|
|
}
|
|
|
|
string ipUserStr = parts.ElementAtOrDefault(1);
|
|
ChatUser ipUser;
|
|
|
|
lock(Context.UsersAccess)
|
|
if(parts.Length < 2 || (ipUser = Context.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == null) {
|
|
user.Send(new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, parts.Length < 2 ? "User" : ipUserStr));
|
|
break;
|
|
}
|
|
|
|
foreach(IPAddress ip in ipUser.RemoteAddresses.Distinct().ToArray())
|
|
user.Send(new LegacyCommandResponse(LCR.IP_ADDRESS, false, ipUser.Username, ip));
|
|
break;
|
|
|
|
case "shutdown":
|
|
case "restart":
|
|
if(user.UserId != 1) {
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{commandName}"));
|
|
break;
|
|
}
|
|
|
|
if(IsShuttingDown)
|
|
break;
|
|
IsShuttingDown = true;
|
|
|
|
if(commandName == "restart")
|
|
lock(SessionsAccess)
|
|
Sessions.ForEach(s => s.PrepareForRestart());
|
|
|
|
Context.Update();
|
|
Shutdown?.Set();
|
|
break;
|
|
|
|
default:
|
|
user.Send(new LegacyCommandResponse(LCR.COMMAND_NOT_FOUND, true, commandName));
|
|
break;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
~SockChatServer() {
|
|
DoDispose();
|
|
}
|
|
|
|
public void Dispose() {
|
|
DoDispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private void DoDispose() {
|
|
if(IsDisposed)
|
|
return;
|
|
IsDisposed = true;
|
|
|
|
lock(SessionsAccess)
|
|
Sessions.ForEach(s => s.Dispose());
|
|
|
|
Server?.Dispose();
|
|
HttpClient?.Dispose();
|
|
}
|
|
}
|
|
}
|