Current state of the websocket server for the Hermes client

This commit is contained in:
Tom 2024-06-24 22:21:59 +00:00
commit 95bc073a73
32 changed files with 1676 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
bin/
obj/
appsettings*.json
server.config*.yml
logs/

View File

@ -0,0 +1,13 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:15891",
"sslPort": 44309
}
},
"profiles": {
}
}

136
Quests/QuestManager.cs Normal file
View File

@ -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<short, Quest> _quests;
private IDictionary<long, IList<ChatterQuestProgression>> _progression;
private Database _database;
private ILogger _logger;
private Random _random;
public QuestManager(Database database, ILogger logger)
{
_database = database;
_logger = logger;
_quests = new Dictionary<short, Quest>();
_progression = new Dictionary<long, IList<ChatterQuestProgression>>();
_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<string> emotes)
{
if (!_progression.TryGetValue(chatterId, out IList<ChatterQuestProgression>? 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());
}
}
}
}

34
Requests/BanTTSUser.cs Normal file
View File

@ -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<RequestResult> Grant(IDictionary<string, object> 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);
// }
// }
// }

39
Requests/CreateTTSUser.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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);
}
}
}

View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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());
}
}
}

View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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);
}
}
}

29
Requests/GetChatterIds.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> data)
{
IList<long> ids = new List<long>();
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);
}
}
}

34
Requests/GetEmotes.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> data)
{
IList<EmoteInfo> emotes = new List<EmoteInfo>();
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);
}
}
}

31
Requests/GetTTSUsers.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> data)
{
data["user"] = sender;
IDictionary<long, string> users = new Dictionary<long, string>();
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);
}
}
}

33
Requests/GetTTSVoices.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> data)
{
IList<VoiceDetails> voices = new List<VoiceDetails>();
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);
}
}
}

View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> data)
{
data["user"] = sender;
IList<TTSWordFilter> filters = new List<TTSWordFilter>();
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);
}
}
}

View File

@ -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";
}
}

40
Requests/UpdateTTSUser.cs Normal file
View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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);
}
}
}

View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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);
}
}
}

View File

@ -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<RequestResult> Grant(string sender, IDictionary<string, object> 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);
}
}
}

106
Server.cs Normal file
View File

@ -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<byte>(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<SocketMessage>(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<HeartbeatMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 1)
obj.Data = JsonSerializer.Deserialize<HermesLoginMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 3)
obj.Data = JsonSerializer.Deserialize<RequestMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 5)
obj.Data = JsonSerializer.Deserialize<ErrorMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 6)
obj.Data = JsonSerializer.Deserialize<ChatterMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 7)
obj.Data = JsonSerializer.Deserialize<EmoteDetailsMessage>(obj.Data.ToString(), _options);
else if (obj.OpCode == 8)
obj.Data = JsonSerializer.Deserialize<EmoteUsageMessage>(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}]");
}
}
}

20
ServerConfiguration.cs Normal file
View File

@ -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;
}
}

View File

@ -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<long> _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<long>(1001);
_array = new ChatterMessage[1000];
_index = -1;
_lock = new object();
}
public async Task Execute<T>(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<string, object>() { { "idd", data.Id }, { "name", data.Name } });
}
catch (Exception e)
{
_logger.Error(e, "Failed to add chatter.");
}
}
}
}

View File

@ -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<string> _emotes;
private readonly ILogger _logger;
private readonly object _lock;
public EmoteDetailsHandler(Database database, ILogger logger)
{
_database = database;
_logger = logger;
_emotes = new HashSet<string>(501);
_lock = new object();
}
public async Task Execute<T>(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);
}
}
}
}
}
}
}

View File

@ -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<string> _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<string>(101);
_array = new EmoteUsageMessage[100];
_index = -1;
}
public async Task Execute<T>(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.");
}
}
}

View File

@ -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<T>(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);
}
}
}

View File

@ -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<T>(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
});
}
}
}

View File

@ -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<T>(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<string, object>() { { "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<string, object>() { { "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}]");
}
}
}

View File

@ -0,0 +1,10 @@
using System.Net.WebSockets;
namespace HermesSocketServer.Socket.Handlers
{
public interface ISocketHandler
{
int OpCode { get; }
Task Execute<T>(WebSocketUser sender, T message, HermesSocketManager sockets);
}
}

View File

@ -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<T>(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}].");
}
}
}
}
}

View File

@ -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<WebSocketUser, ISocketHandler>
{
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<ISocketHandler>("hermes-heartbeat"));
Add(1, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-hermeslogin"));
Add(3, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-request"));
Add(5, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-error"));
Add(6, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-chatter"));
Add(7, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-emotedetails"));
Add(8, _serviceProvider.GetRequiredKeyedService<ISocketHandler>("hermes-emoteusage"));
}
protected override async Task Execute<T>(WebSocketUser sender, ISocketHandler handler, T value)
{
await handler.Execute(sender, value, _sockets);
}
}
}

96
Socket/SocketManager.cs Normal file
View File

@ -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<WebSocketUser> _sockets;
private System.Timers.Timer _timer;
private ILogger _logger;
public HermesSocketManager(ILogger logger)
{
_sockets = new List<WebSocketUser>();
_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<WebSocketUser> GetAllSockets()
{
return _sockets.AsReadOnly();
}
public IEnumerable<WebSocketUser> 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)
{
}
}
}
}

113
Socket/WebSocketUser.cs Normal file
View File

@ -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<Data>(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<byte>(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<WebSocketReceiveResult?> Receive(ArraySegment<byte> 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<Data>(int opcode, Data data)
{
return new SocketMessage()
{
OpCode = opcode,
Data = data
};
}
}
}

35
SocketServer.csproj Normal file
View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\CommonSocketLibrary\CommonSocketLibrary.csproj" />
<ProjectReference Include="..\HermesSocketLibrary\HermesSocketLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MathParser.org-mXparser" Version="6.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.2" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2-dev-00338" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.1-dev-10391" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.1-dev-00972" />
<PackageReference Include="Serilog.Sinks.RollingFile" Version="3.3.1-dev-00771" />
<PackageReference Include="Serilog.Sinks.Trace" Version="4.0.0" />
<PackageReference Include="YamlDotNet" Version="15.1.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>HermesSocketServer</RootNamespace>
</PropertyGroup>
</Project>

119
Startup.cs Normal file
View File

@ -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<ServerConfiguration>(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<ForwardedHeadersOptions>(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<ServerConfiguration>(configuration);
s.AddSingleton<Database>();
// Socket message handlers
s.AddSingleton<Serilog.ILogger>(logger);
s.AddKeyedSingleton<ISocketHandler, HeartbeatHandler>("hermes-heartbeat");
s.AddKeyedSingleton<ISocketHandler, HermesLoginHandler>("hermes-hermeslogin");
s.AddKeyedSingleton<ISocketHandler, RequestHandler>("hermes-request");
s.AddKeyedSingleton<ISocketHandler, ErrorHandler>("hermes-error");
s.AddKeyedSingleton<ISocketHandler, ChatterHandler>("hermes-chatter");
s.AddKeyedSingleton<ISocketHandler, EmoteDetailsHandler>("hermes-emotedetails");
s.AddKeyedSingleton<ISocketHandler, EmoteUsageHandler>("hermes-emoteusage");
// Request handlers
s.AddKeyedSingleton<IRequest, GetTTSUsers>("BanTTSUser");
s.AddKeyedSingleton<IRequest, GetTTSUsers>("GetTTSUsers");
s.AddKeyedSingleton<IRequest, GetTTSVoices>("GetTTSVoices");
s.AddKeyedSingleton<IRequest, GetTTSWordFilters>("GetTTSWordFilters");
s.AddKeyedSingleton<IRequest, CreateTTSUser>("CreateTTSUser");
s.AddKeyedSingleton<IRequest, CreateTTSVoice>("CreateTTSVoice");
s.AddKeyedSingleton<IRequest, DeleteTTSVoice>("DeleteTTSVoice");
s.AddKeyedSingleton<IRequest, UpdateTTSUser>("UpdateTTSUser");
s.AddKeyedSingleton<IRequest, UpdateTTSVoice>("UpdateTTSVoice");
s.AddKeyedSingleton<IRequest, GetChatterIds>("GetChatterIds");
s.AddKeyedSingleton<IRequest, GetEmotes>("GetEmotes");
s.AddKeyedSingleton<IRequest, UpdateTTSVoiceState>("UpdateTTSVoiceState");
s.AddSingleton<HermesSocketManager>();
s.AddSingleton<SocketHandlerManager>();
s.AddSingleton<RequestManager, ServerRequestManager>();
s.AddSingleton<JsonSerializerOptions>(new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
s.AddSingleton<Server>();
var app = builder.Build();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseWebSockets(new WebSocketOptions()
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
var options = app.Services.GetRequiredService<JsonSerializerOptions>();
var server = app.Services.GetRequiredService<Server>();
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();

129
db/Database.cs Normal file
View File

@ -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<string, object>? values, Action<NpgsqlDataReader> 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<NpgsqlCommand> action, Action<NpgsqlDataReader> 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<int> Execute(string sql, IDictionary<string, object>? 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<int> Execute(string sql, Action<NpgsqlCommand> 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<object?> ExecuteScalar(string sql, IDictionary<string, object>? 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<object?> ExecuteScalar(string sql, Action<NpgsqlCommand> action)
{
using (var connection = await _source.OpenConnectionAsync())
{
using (var command = new NpgsqlCommand(sql, connection))
{
action(command);
await command.PrepareAsync();
return await command.ExecuteScalarAsync();
}
}
}
}
}