commit 95bc073a73b8dffb24dacd5b0b33051fb01a8e99 Author: Tom Date: Mon Jun 24 22:21:59 2024 +0000 Current state of the websocket server for the Hermes client diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69f3b1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +appsettings*.json +server.config*.yml +logs/ \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..77bb8bc --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:15891", + "sslPort": 44309 + } + }, + "profiles": { + } +} diff --git a/Quests/QuestManager.cs b/Quests/QuestManager.cs new file mode 100644 index 0000000..9f9a347 --- /dev/null +++ b/Quests/QuestManager.cs @@ -0,0 +1,136 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Quests; +using HermesSocketLibrary.Quests.Tasks; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Quests +{ + public class QuestManager + { + private IDictionary _quests; + private IDictionary> _progression; + private Database _database; + private ILogger _logger; + private Random _random; + + public QuestManager(Database database, ILogger logger) + { + _database = database; + _logger = logger; + + _quests = new Dictionary(); + _progression = new Dictionary>(); + _random = new Random(); + } + + public async Task AddNewDailyQuests(DateOnly date, int count) + { + var midnight = date.ToDateTime(TimeOnly.MinValue); + var quests = _quests.Values.Select(q => q.StartTime.Date == midnight); + for (var i = 0; i < count; i++) + { + IQuestTask task; + var questType = _random.Next(2); + if (questType == 0) + { + task = new MessageQuestTask(_random.Next(21) + 10); + } + else + { + task = new EmoteMessageQuestTask(_random.Next(11) + 5); + } + var temp = new DailyQuest(-1, task, date); + + string sql = "INSERT INTO \"Quest\" (type, target, start, end) VALUES (@type, @target, @start, @end)"; + await _database.Execute(sql, c => + { + c.Parameters.AddWithValue("@type", temp.Type); + c.Parameters.AddWithValue("@target", temp.Task.Target); + c.Parameters.AddWithValue("@start", temp.StartTime); + c.Parameters.AddWithValue("@end", temp.EndTime); + }); + + string sql2 = "SELECT id FROM \"Quest\" WHERE type = @type AND start = @start"; + int? questId = (int?)await _database.ExecuteScalar(sql, c => + { + c.Parameters.AddWithValue("@type", temp.Type); + c.Parameters.AddWithValue("@start", temp.StartTime); + }); + + var quest = new DailyQuest((short)questId.Value, task, date); + _quests.Add(quest.Id, quest); + } + } + + public async Task AddNewWeeklyQuests(DateOnly date, int count) + { + for (var i = 0; i < count; i++) + { + IQuestTask task; + var questType = _random.Next(2); + if (questType == 0) + { + task = new MessageQuestTask(_random.Next(21) + 10); + } + else + { + task = new EmoteMessageQuestTask(_random.Next(11) + 5); + } + var temp = new WeeklyQuest(-1, task, date); + + string sql = "INSERT INTO \"Quest\" (type, target, start, end) VALUES (@type, @target, @start, @end)"; + await _database.Execute(sql, c => + { + c.Parameters.AddWithValue("@type", temp.Type); + c.Parameters.AddWithValue("@target", temp.Task.Target); + c.Parameters.AddWithValue("@start", temp.StartTime); + c.Parameters.AddWithValue("@end", temp.EndTime); + }); + + string sql2 = "SELECT id FROM \"Quest\" WHERE type = @type AND start = @start"; + int? questId = (int?)await _database.ExecuteScalar(sql, c => + { + c.Parameters.AddWithValue("@type", temp.Type); + c.Parameters.AddWithValue("@start", temp.StartTime); + }); + + var quest = new WeeklyQuest((short)questId.Value, task, date); + _quests.Add(quest.Id, quest); + } + } + + public void Process(long chatterId, string message, HashSet emotes) + { + if (!_progression.TryGetValue(chatterId, out IList? progressions) || progressions == null || !progressions.Any()) + return; + + foreach (var progression in progressions) + { + if (!progression.Quest.IsOngoing()) + continue; + + _logger.Information($"Quest {progression.Quest.Task.Name} [id: {progression.Quest.Id}][progression: {progression.Counter}/{progression.Quest.Task.Target}]"); + progression.Process(message, emotes); + } + } + + public async Task UpdateQuests(DateOnly date, int dailies, int weeklies) + { + var dateMidnight = date.ToDateTime(TimeOnly.MinValue); + var dquests = _quests.Values.Select(q => q.StartTime.Date == dateMidnight); + var monday = date.AddDays((date.DayOfWeek - DayOfWeek.Monday + 7) % 7); + var mondayMidnight = monday.ToDateTime(TimeOnly.MinValue); + var wquests = _quests.Values.Select(q => q.StartTime >= mondayMidnight); + + if (dquests.Count() < dailies) + { + await AddNewDailyQuests(date, dailies - dquests.Count()); + } + + if (wquests.Count() < weeklies) + { + await AddNewWeeklyQuests(monday, dailies - wquests.Count()); + } + } + } +} \ No newline at end of file diff --git a/Requests/BanTTSUser.cs b/Requests/BanTTSUser.cs new file mode 100644 index 0000000..7be5743 --- /dev/null +++ b/Requests/BanTTSUser.cs @@ -0,0 +1,34 @@ +// using System.Text.Json; +// using HermesSocketLibrary.db; +// using HermesSocketLibrary.Requests; + +// namespace HermesSocketServer.Requests +// { +// public class BanTTSUser : IRequest +// { +// public string Name => "ban_tts_user"; +// private Database _database; +// private ILogger _logger; + +// public BanTTSUser(Database database, ILogger logger) +// { +// _database = database; +// _logger = logger; +// } + +// public async Task Grant(IDictionary data) +// { +// // if (long.TryParse(data["user"].ToString(), out long user)) +// // data["user"] = user; +// // if (data["broadcaster"] is JsonElement b) +// // data["broadcaster"] = b.ToString(); +// // if (data["voice"] is JsonElement v) +// // data["voice"] = v.ToString(); + +// // string sql = "UPDATE \"TtsChatVoice\" (\"broadcasterId\", \"chatterId\", \"ttsVoiceId\") VALUES (@broadcaster, @user, @voice)"; +// // var result = await _database.Execute(sql, data); +// // _logger.Information($"Selected a tts voice for {data["user"]} in channel {data["broadcaster"]}: {data["voice"]}"); +// return new RequestResult(1 == 1, null); +// } +// } +// } \ No newline at end of file diff --git a/Requests/CreateTTSUser.cs b/Requests/CreateTTSUser.cs new file mode 100644 index 0000000..51f9540 --- /dev/null +++ b/Requests/CreateTTSUser.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class CreateTTSUser : IRequest + { + public string Name => "create_tts_user"; + private Database _database; + private ILogger _logger; + + public CreateTTSUser(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + if (long.TryParse(data["chatter"].ToString(), out long chatter)) + data["chatter"] = chatter; + if (data["voice"] is JsonElement v) + data["voice"] = v.ToString(); + data["user"] = sender; + + var check = await _database.ExecuteScalar("SELECT state FROM \"TtsVoiceState\" WHERE \"userId\" = @user AND \"ttsVoiceId\" = @voice", data) ?? false; + if (check is not bool state || !state){ + return new RequestResult(false, null); + } + + string sql = "INSERT INTO \"TtsChatVoice\" (\"userId\", \"chatterId\", \"ttsVoiceId\") VALUES (@user, @chatter, @voice)"; + var result = await _database.Execute(sql, data); + _logger.Information($"Selected a tts voice for {data["chatter"]} in channel {data["user"]}: {data["voice"]}"); + return new RequestResult(result == 1, null); + } + } +} \ No newline at end of file diff --git a/Requests/CreateTTSVoice.cs b/Requests/CreateTTSVoice.cs new file mode 100644 index 0000000..9f54e29 --- /dev/null +++ b/Requests/CreateTTSVoice.cs @@ -0,0 +1,46 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class CreateTTSVoice : IRequest + { + public string Name => "create_tts_voice"; + private Database _database; + private ILogger _logger; + private Random _random; + + public CreateTTSVoice(Database database, ILogger logger) + { + _database = database; + _logger = logger; + _random = new Random(); + } + + + public async Task Grant(string sender, IDictionary data) + { + string id = RandomString(25); + data.Add("idd", id); + + if (data["voice"] is JsonElement v) + data["voice"] = v.ToString(); + + string sql = "INSERT INTO \"TtsVoice\" (id, name) VALUES (@idd, @voice)"; + var result = await _database.Execute(sql, data); + _logger.Information($"Added a new voice: {data["voice"]} (id: {data["idd"]})"); + + data.Remove("idd"); + return new RequestResult(result == 1, id); + } + + private string RandomString(int length) + { + const string chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[_random.Next(s.Length)]).ToArray()); + } + } +} \ No newline at end of file diff --git a/Requests/DeleteTTSVoice.cs b/Requests/DeleteTTSVoice.cs new file mode 100644 index 0000000..a61f259 --- /dev/null +++ b/Requests/DeleteTTSVoice.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class DeleteTTSVoice : IRequest + { + public string Name => "delete_tts_voice"; + private Database _database; + private ILogger _logger; + + public DeleteTTSVoice(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + if (data["voice"] is JsonElement v) + data["voice"] = v.ToString(); + + string sql = "DELETE FROM \"TtsVoice\" WHERE id = @voice"; + var result = await _database.Execute(sql, data); + _logger.Information($"Deleted a voice by id: {data["voice"]}"); + return new RequestResult(result == 1, null); + } + } +} \ No newline at end of file diff --git a/Requests/GetChatterIds.cs b/Requests/GetChatterIds.cs new file mode 100644 index 0000000..7329f44 --- /dev/null +++ b/Requests/GetChatterIds.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class GetChatterIds : IRequest + { + public string Name => "get_chatter_ids"; + private Database _database; + private ILogger _logger; + + public GetChatterIds(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + IList ids = new List(); + string sql = $"SELECT id FROM \"Chatter\""; + await _database.Execute(sql, data, (r) => ids.Add(r.GetInt64(0))); + _logger.Information($"Fetched all chatters."); + return new RequestResult(true, ids, notifyClientsOnAccount: false); + } + } +} \ No newline at end of file diff --git a/Requests/GetEmotes.cs b/Requests/GetEmotes.cs new file mode 100644 index 0000000..056014c --- /dev/null +++ b/Requests/GetEmotes.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using HermesSocketLibrary.Requests.Messages; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class GetEmotes : IRequest + { + public string Name => "get_emotes"; + private Database _database; + private ILogger _logger; + + public GetEmotes(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + IList emotes = new List(); + string sql = $"SELECT id, name FROM \"Emote\""; + await _database.Execute(sql, data, (r) => emotes.Add(new EmoteInfo() + { + Id = r.GetString(0), + Name = r.GetString(1) + })); + _logger.Information($"Fetched all emotes."); + return new RequestResult(true, emotes, notifyClientsOnAccount: false); + } + } +} \ No newline at end of file diff --git a/Requests/GetTTSUsers.cs b/Requests/GetTTSUsers.cs new file mode 100644 index 0000000..66ba217 --- /dev/null +++ b/Requests/GetTTSUsers.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class GetTTSUsers : IRequest + { + public string Name => "get_tts_users"; + private readonly Database _database; + private readonly ILogger _logger; + + public GetTTSUsers(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + data["user"] = sender; + + IDictionary users = new Dictionary(); + string sql = $"SELECT \"ttsVoiceId\", \"chatterId\" FROM \"TtsChatVoice\" WHERE \"userId\" = @user"; + await _database.Execute(sql, data, (r) => users.Add(r.GetInt64(1), r.GetString(0))); + _logger.Information($"Fetched all chatters' selected tts voice for channel {data["user"]}."); + return new RequestResult(true, users, notifyClientsOnAccount: false); + } + } +} \ No newline at end of file diff --git a/Requests/GetTTSVoices.cs b/Requests/GetTTSVoices.cs new file mode 100644 index 0000000..e9bdc8e --- /dev/null +++ b/Requests/GetTTSVoices.cs @@ -0,0 +1,33 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using HermesSocketLibrary.Requests.Messages; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class GetTTSVoices : IRequest + { + public string Name => "get_tts_voices"; + private Database _database; + private ILogger _logger; + + public GetTTSVoices(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + IList voices = new List(); + string sql = "SELECT id, name FROM \"TtsVoice\""; + await _database.Execute(sql, data, (r) => voices.Add(new VoiceDetails() + { + Id = r.GetString(0), + Name = r.GetString(1) + })); + _logger.Information("Fetched all TTS voices."); + return new RequestResult(true, voices, notifyClientsOnAccount: false); + } + } +} \ No newline at end of file diff --git a/Requests/GetTTSWordFilters.cs b/Requests/GetTTSWordFilters.cs new file mode 100644 index 0000000..076df4f --- /dev/null +++ b/Requests/GetTTSWordFilters.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using HermesSocketLibrary.Requests.Messages; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class GetTTSWordFilters : IRequest + { + public string Name => "get_tts_word_filters"; + private readonly Database _database; + private readonly ILogger _logger; + + public GetTTSWordFilters(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + data["user"] = sender; + + IList filters = new List(); + string sql = $"SELECT id, search, replace FROM \"TtsWordFilter\" WHERE \"userId\" = @user"; + await _database.Execute(sql, data, (r) => filters.Add(new TTSWordFilter() + { + Id = r.GetString(0), + Search = r.GetString(1), + Replace = r.GetString(2) + })); + return new RequestResult(true, filters, notifyClientsOnAccount: false); + } + } +} \ No newline at end of file diff --git a/Requests/ServerRequestManager.cs b/Requests/ServerRequestManager.cs new file mode 100644 index 0000000..c8c21e8 --- /dev/null +++ b/Requests/ServerRequestManager.cs @@ -0,0 +1,14 @@ +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer +{ + public class ServerRequestManager : RequestManager + { + public ServerRequestManager(IServiceProvider serviceProvider, ILogger logger) : base(serviceProvider, logger) + { + } + + protected override string AssemblyName => "SocketServer"; + } +} \ No newline at end of file diff --git a/Requests/UpdateTTSUser.cs b/Requests/UpdateTTSUser.cs new file mode 100644 index 0000000..9cabdb4 --- /dev/null +++ b/Requests/UpdateTTSUser.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class UpdateTTSUser : IRequest + { + public string Name => "update_tts_user"; + private readonly Database _database; + private readonly ILogger _logger; + + public UpdateTTSUser(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + if (long.TryParse(data["chatter"].ToString(), out long chatterId)) + data["chatter"] = chatterId; + if (data["voice"] is JsonElement v) + data["voice"] = v.ToString(); + data["user"] = sender; + + var check = await _database.ExecuteScalar("SELECT state FROM \"TtsVoiceState\" WHERE \"userId\" = @user AND \"ttsVoiceId\" = @voice", data) ?? false; + if (check is not bool state || !state) + { + return new RequestResult(false, null); + } + + string sql = "UPDATE \"TtsChatVoice\" SET \"ttsVoiceId\" = @voice WHERE \"userId\" = @user AND \"chatterId\" = @chatter"; + var result = await _database.Execute(sql, data); + _logger.Information($"Updated {data["chatter"]}'s selected tts voice to {data["voice"]} in channel {data["user"]}."); + return new RequestResult(result == 1, null); + } + } +} \ No newline at end of file diff --git a/Requests/UpdateTTSVoice.cs b/Requests/UpdateTTSVoice.cs new file mode 100644 index 0000000..162157c --- /dev/null +++ b/Requests/UpdateTTSVoice.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class UpdateTTSVoice : IRequest + { + public string Name => "update_tts_voice"; + private Database _database; + private ILogger _logger; + + public UpdateTTSVoice(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + if (data["voice"] is JsonElement v) + data["voice"] = v.ToString(); + if (data["voiceid"] is JsonElement id) + data["voiceid"] = id.ToString(); + + string sql = "UPDATE \"TtsVoice\" SET name = @voice WHERE id = @voiceid"; + var result = await _database.Execute(sql, data); + _logger.Information($"Updated voice {data["voiceid"]}'s name to {data["voice"]}."); + return new RequestResult(result == 1, null); + } + } +} \ No newline at end of file diff --git a/Requests/UpdateTTSVoiceState.cs b/Requests/UpdateTTSVoiceState.cs new file mode 100644 index 0000000..1b0d3e5 --- /dev/null +++ b/Requests/UpdateTTSVoiceState.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Requests +{ + public class UpdateTTSVoiceState : IRequest + { + public string Name => "update_tts_voice_state"; + private Database _database; + private ILogger _logger; + + public UpdateTTSVoiceState(Database database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task Grant(string sender, IDictionary data) + { + if (data["voice"] is JsonElement voice) + data["voice"] = voice.ToString(); + if (data["state"] is JsonElement state) + data["state"] = state.ToString() == "True"; + data["user"] = sender; + + //string sql = "UPDATE \"TtsVoiceState\" SET state = @state WHERE \"userId\" = @user AND \"ttsVoiceId\" = @voice"; + string sql = "INSERT INTO \"TtsVoiceState\" (\"userId\", \"ttsVoiceId\", state) VALUES (@user, @voice, @state) ON CONFLICT (\"userId\", \"ttsVoiceId\") DO UPDATE SET state = @state"; + var result = await _database.Execute(sql, data); + _logger.Information($"Updated voice {data["voice"]}'s state (from {data["user"]}) to {data["state"]}."); + return new RequestResult(result == 1, null); + } + } +} \ No newline at end of file diff --git a/Server.cs b/Server.cs new file mode 100644 index 0000000..ee52229 --- /dev/null +++ b/Server.cs @@ -0,0 +1,106 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using HermesSocketLibrary.Socket.Data; +using HermesSocketServer.Socket; +using ILogger = Serilog.ILogger; + +namespace HermesSocketLibrary +{ + public class Server + { + private readonly HermesSocketManager _sockets; + private readonly SocketHandlerManager _handlers; + private readonly JsonSerializerOptions _options; + private readonly ILogger _logger; + + + public Server( + HermesSocketManager sockets, + SocketHandlerManager handlers, + JsonSerializerOptions options, + ILogger logger + ) + { + _sockets = sockets; + _handlers = handlers; + _options = options; + _logger = logger; + } + + + public async Task Handle(WebSocketUser socket, HttpContext context) + { + _logger.Information($"Socket connected [ip: {socket.IPAddress}]"); + _sockets.Add(socket); + var buffer = new byte[1024 * 8]; + + while (socket.State == WebSocketState.Open) + { + try + { + var result = await socket.Receive(new ArraySegment(buffer)); + if (result == null || result.MessageType == WebSocketMessageType.Close || !socket.Connected) + break; + + string message = Encoding.UTF8.GetString(buffer, 0, result.Count).TrimEnd('\0'); + var obj = JsonSerializer.Deserialize(message, _options); + if (obj == null || !obj.OpCode.HasValue) + continue; + + if (obj.OpCode != 0) + _logger.Information("Message: " + message); + + /** + * 0: Heartbeat + * 1: Login RX + * 2: Login Ack TX + * 3: Request RX + * 4: Request Ack TX + * 5: Error RX/TX + */ + if (obj.Data == null) + { + await socket.Send(5, new ErrorMessage("Received no data in the message.")); + continue; + } + else if (obj.OpCode == 0) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 1) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 3) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 5) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 6) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 7) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else if (obj.OpCode == 8) + obj.Data = JsonSerializer.Deserialize(obj.Data.ToString(), _options); + else + { + await socket.Send(5, new ErrorMessage("Received an invalid message: " + message)); + continue; + } + await _handlers.Execute(socket, obj.OpCode.Value, obj.Data); + } + catch (Exception e) + { + _logger.Error(e, "Error trying to process a socket message"); + } + } + + try + { + if (socket.Connected) + await socket.Close(socket.CloseStatus ?? WebSocketCloseStatus.NormalClosure, socket.CloseStatusDescription, CancellationToken.None); + } + catch (Exception) + { + _sockets.Remove(socket); + } + _logger.Information($"Client disconnected [username: {socket.Name}][id: {socket.Id}][ip: {socket.IPAddress}]"); + } + } +} \ No newline at end of file diff --git a/ServerConfiguration.cs b/ServerConfiguration.cs new file mode 100644 index 0000000..ac0e81c --- /dev/null +++ b/ServerConfiguration.cs @@ -0,0 +1,20 @@ +namespace HermesSocketServer +{ + public class ServerConfiguration + { + public string Environment; + public WebsocketServerConfiguration WebsocketServer; + public DatabaseConfiguration Database; + } + + public class WebsocketServerConfiguration + { + public string Host; + public string Port; + } + + public class DatabaseConfiguration + { + public string ConnectionString; + } +} \ No newline at end of file diff --git a/Socket/Handlers/ChatterHandler.cs b/Socket/Handlers/ChatterHandler.cs new file mode 100644 index 0000000..20a3682 --- /dev/null +++ b/Socket/Handlers/ChatterHandler.cs @@ -0,0 +1,57 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class ChatterHandler : ISocketHandler + { + public int OpCode { get; } = 6; + private readonly Database _database; + private readonly HashSet _chatters; + private readonly ChatterMessage[] _array; + private readonly ILogger _logger; + + private readonly object _lock; + private int _index; + + public ChatterHandler(Database database, ILogger logger) + { + _database = database; + _logger = logger; + _chatters = new HashSet(1001); + _array = new ChatterMessage[1000]; + _index = -1; + _lock = new object(); + } + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not ChatterMessage data) + return; + + lock (_lock) + { + if (_chatters.Contains(data.Id)) + return; + + _chatters.Add(data.Id); + + if (_index == _array.Length - 1) + _index = -1; + + _array[++_index] = data; + } + + try + { + string sql = "INSERT INTO \"Chatter\" (id, name) VALUES (@idd, @name)"; + await _database.Execute(sql, new Dictionary() { { "idd", data.Id }, { "name", data.Name } }); + } + catch (Exception e) + { + _logger.Error(e, "Failed to add chatter."); + } + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/EmoteDetailsHandler.cs b/Socket/Handlers/EmoteDetailsHandler.cs new file mode 100644 index 0000000..45231df --- /dev/null +++ b/Socket/Handlers/EmoteDetailsHandler.cs @@ -0,0 +1,77 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Socket.Data; +using Npgsql; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class EmoteDetailsHandler : ISocketHandler + { + public int OpCode { get; } = 7; + private readonly Database _database; + private readonly HashSet _emotes; + private readonly ILogger _logger; + private readonly object _lock; + + public EmoteDetailsHandler(Database database, ILogger logger) + { + _database = database; + _logger = logger; + _emotes = new HashSet(501); + _lock = new object(); + } + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not EmoteDetailsMessage data) + return; + + if (data.Emotes == null) + return; + + if (!data.Emotes.Any()) + return; + + lock (_lock) + { + foreach (var entry in data.Emotes) + { + if (_emotes.Contains(entry.Key)) + { + _emotes.Remove(entry.Key); + continue; + } + + _emotes.Add(entry.Key); + } + } + + int rows = 0; + string sql = "INSERT INTO \"Emote\" (id, name) VALUES (@idd, @name)"; + using (var connection = await _database.DataSource.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + foreach (var entry in data.Emotes) + { + command.Parameters.Clear(); + command.Parameters.AddWithValue("idd", entry.Key); + command.Parameters.AddWithValue("name", entry.Value); + + await command.PrepareAsync(); + try + { + rows += await command.ExecuteNonQueryAsync(); + } + catch (Exception e) + { + _logger.Error(e, "Failed to add emote detail: " + entry.Key + " -> " + entry.Value); + } + } + } + } + + + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/EmoteUsageHandler.cs b/Socket/Handlers/EmoteUsageHandler.cs new file mode 100644 index 0000000..0d53a94 --- /dev/null +++ b/Socket/Handlers/EmoteUsageHandler.cs @@ -0,0 +1,75 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Socket.Data; +using Npgsql; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class EmoteUsageHandler : ISocketHandler + { + public int OpCode { get; } = 8; + + private readonly Database _database; + private readonly HashSet _history; + private readonly EmoteUsageMessage[] _array; + private readonly ILogger _logger; + + private int _index; + + public EmoteUsageHandler(Database database, ILogger logger) + { + _database = database; + _logger = logger; + _history = new HashSet(101); + _array = new EmoteUsageMessage[100]; + _index = -1; + } + + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not EmoteUsageMessage data) + return; + + lock (_logger) + { + if (_history.Contains(data.MessageId)) + { + return; + } + _history.Add(data.MessageId); + + if (_index >= _array.Length - 1) + _index = -1; + + _index = (_index + 1) % _array.Length; + if (_array[_index] != null) + _history.Remove(data.MessageId); + + _array[_index] = data; + } + + int rows = 0; + string sql = "INSERT INTO \"EmoteUsageHistory\" (timestamp, \"broadcasterId\", \"emoteId\", \"chatterId\") VALUES (@time, @broadcaster, @emote, @chatter)"; + using (var connection = await _database.DataSource.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + foreach (var entry in data.Emotes) + { + command.Parameters.Clear(); + command.Parameters.AddWithValue("time", data.DateTime); + command.Parameters.AddWithValue("broadcaster", data.BroadcasterId); + command.Parameters.AddWithValue("emote", entry); + command.Parameters.AddWithValue("chatter", data.ChatterId); + + await command.PrepareAsync(); + rows += await command.ExecuteNonQueryAsync(); + } + } + } + + _logger.Information($"Tracked {rows} emote(s) to history."); + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/ErrorHandler.cs b/Socket/Handlers/ErrorHandler.cs new file mode 100644 index 0000000..d930757 --- /dev/null +++ b/Socket/Handlers/ErrorHandler.cs @@ -0,0 +1,29 @@ +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class ErrorHandler : ISocketHandler + { + public int OpCode { get; } = 0; + + private ILogger _logger; + + public ErrorHandler(ILogger logger) + { + _logger = logger; + } + + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not ErrorMessage data) + return; + + if (data.Exception == null) + _logger.Error(data.Message); + else + _logger.Error(data.Exception, data.Message); + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/HeartbeatHandler.cs b/Socket/Handlers/HeartbeatHandler.cs new file mode 100644 index 0000000..91ccabe --- /dev/null +++ b/Socket/Handlers/HeartbeatHandler.cs @@ -0,0 +1,33 @@ +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class HeartbeatHandler : ISocketHandler + { + public int OpCode { get; } = 0; + + private ILogger _logger; + + public HeartbeatHandler(ILogger logger) + { + _logger = logger; + } + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not HeartbeatMessage data) + return; + + sender.LastHeartbeatReceived = DateTime.UtcNow; + _logger.Verbose($"Received heartbeat from socket [ip: {sender.IPAddress}]."); + + if (data.Respond) + await sender.Send(0, new HeartbeatMessage() + { + DateTime = DateTime.UtcNow, + Respond = false + }); + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/HermesLoginHandler.cs b/Socket/Handlers/HermesLoginHandler.cs new file mode 100644 index 0000000..f930b83 --- /dev/null +++ b/Socket/Handlers/HermesLoginHandler.cs @@ -0,0 +1,83 @@ +using HermesSocketLibrary.db; +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class HermesLoginHandler : ISocketHandler + { + public int OpCode { get; } = 1; + + private readonly Database _database; + private readonly HermesSocketManager _sockets; + private readonly ILogger _logger; + private readonly object _lock; + + public HermesLoginHandler(Database database, HermesSocketManager sockets, ILogger logger) + { + _database = database; + _sockets = sockets; + _logger = logger; + _lock = new object(); + } + + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (message is not HermesLoginMessage data || data == null || data.ApiKey == null) + return; + if (sender.Id != null) + return; + + string sql = "select \"userId\" from \"ApiKey\" where id = @key"; + var result = await _database.ExecuteScalar(sql, new Dictionary() { { "key", data.ApiKey } }); + string? userId = result?.ToString(); + + if (userId == null) + return; + + var recipients = _sockets.GetSockets(userId).ToList(); + + lock (_lock) + { + if (sender.Id != null) + return; + + sender.Id = userId; + } + + string sql2 = "select \"name\" from \"User\" where id = @user"; + var result2 = await _database.ExecuteScalar(sql2, new Dictionary() { { "user", userId } }); + string? name = result2?.ToString(); + + if (string.IsNullOrEmpty(name)) + return; + + sender.Name = name; + + await sender.Send(2, new LoginAckMessage() + { + UserId = userId + }); + + var ack = new LoginAckMessage() + { + AnotherClient = true, + UserId = userId + }; + + foreach (var socket in recipients) + { + try + { + await socket.Send(2, ack); + } + catch (Exception) + { + } + } + + _logger.Information($"Hermes client logged in [name: {name}][id: {userId}][ip: {sender.IPAddress}]"); + } + } +} \ No newline at end of file diff --git a/Socket/Handlers/ISocketHandler.cs b/Socket/Handlers/ISocketHandler.cs new file mode 100644 index 0000000..349694d --- /dev/null +++ b/Socket/Handlers/ISocketHandler.cs @@ -0,0 +1,10 @@ +using System.Net.WebSockets; + +namespace HermesSocketServer.Socket.Handlers +{ + public interface ISocketHandler + { + int OpCode { get; } + Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets); + } +} \ No newline at end of file diff --git a/Socket/Handlers/RequestHandler.cs b/Socket/Handlers/RequestHandler.cs new file mode 100644 index 0000000..177985d --- /dev/null +++ b/Socket/Handlers/RequestHandler.cs @@ -0,0 +1,71 @@ +using HermesSocketLibrary.Requests; +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket.Handlers +{ + public class RequestHandler : ISocketHandler + { + public int OpCode { get; } = 3; + private readonly RequestManager _requests; + private readonly HermesSocketManager _sockets; + private readonly ILogger _logger; + + public RequestHandler(RequestManager requests, HermesSocketManager sockets, ILogger logger) + { + _requests = requests; + _sockets = sockets; + _logger = logger; + } + + + public async Task Execute(WebSocketUser sender, T message, HermesSocketManager sockets) + { + if (sender.Id == null) + return; + if (message is not RequestMessage data) + return; + + RequestResult? result = null; + _logger.Debug("Executing request handler: " + data.Type); + try + { + result = await _requests.Grant(sender.Id, data); + } + catch (Exception e) + { + _logger.Error(e, $"Failed to grant a request of type '{data.Type}'."); + } + + if (result == null || !result.Success) + return; + + var ack = new RequestAckMessage() + { + Request = data, + Data = result.Result, + Nounce = data.Nounce + }; + + if (!result.NotifyClientsOnAccount) + { + await sender.Send(4, ack); + return; + } + + var recipients = _sockets.GetSockets(sender.Id); + foreach (var socket in recipients) + { + try + { + _logger.Verbose($"Sending {data.Type} to socket [ip: {socket.IPAddress}]."); + await socket.Send(4, ack); + } + catch (Exception) + { + _logger.Warning($"Failed to send {data.Type} to socket [ip: {socket.IPAddress}]."); + } + } + } + } +} \ No newline at end of file diff --git a/Socket/SocketHandlerManager.cs b/Socket/SocketHandlerManager.cs new file mode 100644 index 0000000..672826e --- /dev/null +++ b/Socket/SocketHandlerManager.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using CommonSocketLibrary.Abstract; +using HermesSocketServer.Socket.Handlers; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket +{ + public class SocketHandlerManager : HandlerManager + { + private readonly HermesSocketManager _sockets; + private readonly IServiceProvider _serviceProvider; + + + public SocketHandlerManager(HermesSocketManager sockets, IServiceProvider serviceProvider, ILogger logger) + : base(logger) + { + _sockets = sockets; + _serviceProvider = serviceProvider; + + Add(0, _serviceProvider.GetRequiredKeyedService("hermes-heartbeat")); + Add(1, _serviceProvider.GetRequiredKeyedService("hermes-hermeslogin")); + Add(3, _serviceProvider.GetRequiredKeyedService("hermes-request")); + Add(5, _serviceProvider.GetRequiredKeyedService("hermes-error")); + Add(6, _serviceProvider.GetRequiredKeyedService("hermes-chatter")); + Add(7, _serviceProvider.GetRequiredKeyedService("hermes-emotedetails")); + Add(8, _serviceProvider.GetRequiredKeyedService("hermes-emoteusage")); + } + + protected override async Task Execute(WebSocketUser sender, ISocketHandler handler, T value) + { + await handler.Execute(sender, value, _sockets); + } + } +} \ No newline at end of file diff --git a/Socket/SocketManager.cs b/Socket/SocketManager.cs new file mode 100644 index 0000000..3b17222 --- /dev/null +++ b/Socket/SocketManager.cs @@ -0,0 +1,96 @@ +using System.Net.WebSockets; +using System.Timers; +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket +{ + public class HermesSocketManager + { + private IList _sockets; + private System.Timers.Timer _timer; + private ILogger _logger; + + + public HermesSocketManager(ILogger logger) + { + _sockets = new List(); + _timer = new System.Timers.Timer(TimeSpan.FromSeconds(1)); + _timer.AutoReset = true; + _timer.Elapsed += async (sender, e) => await HandleHeartbeats(e); + _timer.Enabled = true; + _logger = logger; + } + + + public void Add(WebSocketUser socket) + { + _sockets.Add(socket); + } + + public IList GetAllSockets() + { + return _sockets.AsReadOnly(); + } + + public IEnumerable GetSockets(string userId) + { + foreach (var socket in _sockets) + { + if (socket.Id == userId) + yield return socket; + } + } + + public bool Remove(WebSocketUser socket) + { + return _sockets.Remove(socket); + } + + private async Task HandleHeartbeats(ElapsedEventArgs e) + { + try + { + var signalTime = e.SignalTime.ToUniversalTime(); + for (var i = 0; i < _sockets.Count; i++) + { + var socket = _sockets[i]; + if (!socket.Connected) + { + _sockets.RemoveAt(i--); + } + else if (signalTime - socket.LastHeartbeatReceived > TimeSpan.FromSeconds(30)) + { + if (socket.LastHeartbeatReceived > socket.LastHearbeatSent) + { + try + { + socket.LastHearbeatSent = DateTime.UtcNow; + await socket.Send(0, new HeartbeatMessage() { DateTime = socket.LastHearbeatSent }); + } + catch (Exception) + { + _logger.Warning($"Failed to send the heartbeat to socket [ip: {socket.IPAddress}]."); + await socket.Close(WebSocketCloseStatus.NormalClosure, "Failed to send a heartbeat message.", CancellationToken.None); + } + finally + { + if (!socket.Connected) + _sockets.RemoveAt(i--); + } + } + else if (signalTime - socket.LastHeartbeatReceived > TimeSpan.FromSeconds(120)) + { + _logger.Debug($"Closing socket [ip: {socket.IPAddress}] for not responding for 2 minutes."); + await socket.Close(WebSocketCloseStatus.NormalClosure, "No heartbeat received.", CancellationToken.None); + _sockets.RemoveAt(i--); + } + } + } + } + catch (Exception) + { + } + } + } +} \ No newline at end of file diff --git a/Socket/WebSocketUser.cs b/Socket/WebSocketUser.cs new file mode 100644 index 0000000..c1bc5f5 --- /dev/null +++ b/Socket/WebSocketUser.cs @@ -0,0 +1,113 @@ +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using HermesSocketLibrary.Socket.Data; +using ILogger = Serilog.ILogger; + +namespace HermesSocketServer.Socket +{ + public class WebSocketUser + { + private readonly WebSocket _socket; + private readonly JsonSerializerOptions _options; + private readonly ILogger _logger; + + private readonly IPAddress? _ipAddress; + private CancellationTokenSource _cts; + private bool _connected; + + public WebSocketCloseStatus? CloseStatus { get => _socket.CloseStatus; } + public string? CloseStatusDescription { get => _socket.CloseStatusDescription; } + public WebSocketState State { get => _socket.State; } + public IPAddress? IPAddress { get => _ipAddress; } + public bool Connected { get => _connected; } + public string? Id { get; set; } + public string? Name { get; set; } + public DateTime LastHeartbeatReceived { get; set; } + public DateTime LastHearbeatSent { get; set; } + public CancellationToken Token { get => _cts.Token; } + + + public WebSocketUser(WebSocket socket, IPAddress? ipAddress, JsonSerializerOptions options, ILogger logger) + { + _socket = socket; + _ipAddress = ipAddress; + _options = options; + _connected = true; + _logger = logger; + _cts = new CancellationTokenSource(); + LastHeartbeatReceived = DateTime.UtcNow; + } + + + public async Task Close(WebSocketCloseStatus status, string? message, CancellationToken token) + { + try + { + await _socket.CloseAsync(status, message ?? CloseStatusDescription, token); + } + catch (WebSocketException wse) when (wse.Message.StartsWith("The WebSocket is in an invalid state ")) + { + } + catch (OperationCanceledException) + { + } + catch (Exception e) + { + _logger.Error(e, "Failed to close socket."); + } + finally + { + _connected = false; + await _cts.CancelAsync(); + _cts = new CancellationTokenSource(); + } + } + + public async Task Send(int opcode, Data data) + { + var message = GenerateMessage(opcode, data); + var content = JsonSerializer.Serialize(message, _options); + + var bytes = Encoding.UTF8.GetBytes(content); + var array = new ArraySegment(bytes); + var total = bytes.Length; + var current = 0; + + while (current < total) + { + var size = Encoding.UTF8.GetBytes(content.Substring(current), array); + await _socket.SendAsync(array, WebSocketMessageType.Text, current + size >= total, Token); + current += size; + } + + _logger.Verbose($"TX #{opcode}: {content}"); + } + + public async Task Receive(ArraySegment bytes) + { + try + { + return await _socket.ReceiveAsync(bytes, Token); + } + catch (WebSocketException wse) when (wse.Message.StartsWith("The remote party ")) + { + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to receive a web socket message."); + } + return null; + } + + private SocketMessage GenerateMessage(int opcode, Data data) + { + return new SocketMessage() + { + OpCode = opcode, + Data = data + }; + } + } +} \ No newline at end of file diff --git a/SocketServer.csproj b/SocketServer.csproj new file mode 100644 index 0000000..cd851aa --- /dev/null +++ b/SocketServer.csproj @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + net8.0 + enable + enable + HermesSocketServer + + + diff --git a/Startup.cs b/Startup.cs new file mode 100644 index 0000000..af8a441 --- /dev/null +++ b/Startup.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Text.Json; +using HermesSocketLibrary; +using HermesSocketLibrary.db; +using HermesSocketLibrary.Requests; +using HermesSocketServer; +using HermesSocketServer.Requests; +using HermesSocketServer.Socket; +using HermesSocketServer.Socket.Handlers; +using Microsoft.AspNetCore.HttpOverrides; +using Serilog; +using Serilog.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + + +var deserializer = new DeserializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .Build(); + +var configFileName = "server.config.yml"; +if (File.Exists("server.config." + Environment.GetEnvironmentVariable("TTS_ENV").ToLower() + ".yml")) + configFileName = "server.config." + Environment.GetEnvironmentVariable("TTS_ENV").ToLower() + ".yml"; +var configContent = File.ReadAllText(configFileName); +var configuration = deserializer.Deserialize(configContent); + +if (configuration.Environment.ToUpper() != "QA" && configuration.Environment.ToUpper() != "PROD") + throw new Exception("Invalid environment set."); + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; +}); + +builder.WebHost.UseUrls($"http://{configuration.WebsocketServer.Host}:{configuration.WebsocketServer.Port}"); +var logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.FromLogContext() + .WriteTo.File("logs/log.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 7) + .WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Information) + .CreateLogger(); + +builder.Host.UseSerilog(logger); +builder.Logging.AddSerilog(logger); +var s = builder.Services; + +s.AddSerilog(); + +s.AddSingleton(configuration); +s.AddSingleton(); + +// Socket message handlers +s.AddSingleton(logger); +s.AddKeyedSingleton("hermes-heartbeat"); +s.AddKeyedSingleton("hermes-hermeslogin"); +s.AddKeyedSingleton("hermes-request"); +s.AddKeyedSingleton("hermes-error"); +s.AddKeyedSingleton("hermes-chatter"); +s.AddKeyedSingleton("hermes-emotedetails"); +s.AddKeyedSingleton("hermes-emoteusage"); + +// Request handlers +s.AddKeyedSingleton("BanTTSUser"); +s.AddKeyedSingleton("GetTTSUsers"); +s.AddKeyedSingleton("GetTTSVoices"); +s.AddKeyedSingleton("GetTTSWordFilters"); +s.AddKeyedSingleton("CreateTTSUser"); +s.AddKeyedSingleton("CreateTTSVoice"); +s.AddKeyedSingleton("DeleteTTSVoice"); +s.AddKeyedSingleton("UpdateTTSUser"); +s.AddKeyedSingleton("UpdateTTSVoice"); +s.AddKeyedSingleton("GetChatterIds"); +s.AddKeyedSingleton("GetEmotes"); +s.AddKeyedSingleton("UpdateTTSVoiceState"); + +s.AddSingleton(); +s.AddSingleton(); +s.AddSingleton(); +s.AddSingleton(new JsonSerializerOptions() +{ + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower +}); +s.AddSingleton(); + +var app = builder.Build(); +app.UseForwardedHeaders(); +app.UseSerilogRequestLogging(); +app.UseWebSockets(new WebSocketOptions() +{ + KeepAliveInterval = TimeSpan.FromSeconds(30) +}); + +var options = app.Services.GetRequiredService(); +var server = app.Services.GetRequiredService(); + +app.Use(async (HttpContext context, RequestDelegate next) => +{ + if (context.Request.Path != "/") + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (context.WebSockets.IsWebSocketRequest) + { + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await server.Handle(new WebSocketUser(webSocket, IPAddress.Parse(context.Request.Headers["X-Forwarded-For"].ToString()), options, logger), context); + } + else + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } +}); + +await app.RunAsync(); \ No newline at end of file diff --git a/db/Database.cs b/db/Database.cs new file mode 100644 index 0000000..401ffc2 --- /dev/null +++ b/db/Database.cs @@ -0,0 +1,129 @@ +using HermesSocketServer; +using Npgsql; + +namespace HermesSocketLibrary.db +{ + public class Database + { + private NpgsqlDataSource _source; + private ServerConfiguration _configuration; + + public NpgsqlDataSource DataSource { get => _source; } + + + public Database(ServerConfiguration configuration) + { + NpgsqlDataSourceBuilder builder = new NpgsqlDataSourceBuilder(configuration.Database.ConnectionString); + _source = builder.Build(); + } + + public async Task Execute(string sql, IDictionary? values, Action reading) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + if (values != null) + { + foreach (var entry in values) + command.Parameters.AddWithValue(entry.Key, entry.Value); + } + await command.PrepareAsync(); + + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + reading(reader); + } + } + } + } + } + + public async Task Execute(string sql, Action action, Action reading) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + action(command); + await command.PrepareAsync(); + + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + reading(reader); + } + } + } + } + } + + public async Task Execute(string sql, IDictionary? values) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + if (values != null) + { + foreach (var entry in values) + command.Parameters.AddWithValue(entry.Key, entry.Value); + } + await command.PrepareAsync(); + + return await command.ExecuteNonQueryAsync(); + } + } + } + + public async Task Execute(string sql, Action action) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + action(command); + await command.PrepareAsync(); + + return await command.ExecuteNonQueryAsync(); + } + } + } + + public async Task ExecuteScalar(string sql, IDictionary? values = null) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + if (values != null) + { + foreach (var entry in values) + command.Parameters.AddWithValue(entry.Key, entry.Value); + } + + await command.PrepareAsync(); + + return await command.ExecuteScalarAsync(); + } + } + } + + public async Task ExecuteScalar(string sql, Action action) + { + using (var connection = await _source.OpenConnectionAsync()) + { + using (var command = new NpgsqlCommand(sql, connection)) + { + action(command); + await command.PrepareAsync(); + + return await command.ExecuteScalarAsync(); + } + } + } + } +} \ No newline at end of file