From f2a4779e97d954a65fbdb637d33f8749a900826e Mon Sep 17 00:00:00 2001 From: Ronny Gunawan <3048897+ronnygunawan@users.noreply.github.com> Date: Wed, 31 Jan 2024 11:55:35 +0700 Subject: [PATCH 1/2] Scaffold VPS data source --- BotNet.Commands/BotNet.Commands.csproj | 2 +- BotNet.Services/BotNet.Services.csproj | 15 +++++------ .../GoogleSheets/FromColumnAttribute.cs | 12 +++++++++ BotNet.Services/VPS/VPSBenchmark.cs | 25 +++++++++++++++++++ BotNet.Services/VPS/VPSBenchmarkDataSource.cs | 14 +++++++++++ BotNet.Tests/BotNet.Tests.csproj | 6 ++--- BotNet/BotNet.csproj | 10 ++++---- 7 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 BotNet.Services/GoogleSheets/FromColumnAttribute.cs create mode 100644 BotNet.Services/VPS/VPSBenchmark.cs create mode 100644 BotNet.Services/VPS/VPSBenchmarkDataSource.cs diff --git a/BotNet.Commands/BotNet.Commands.csproj b/BotNet.Commands/BotNet.Commands.csproj index b5c0657..8e4d4f2 100644 --- a/BotNet.Commands/BotNet.Commands.csproj +++ b/BotNet.Commands/BotNet.Commands.csproj @@ -12,7 +12,7 @@ - + diff --git a/BotNet.Services/BotNet.Services.csproj b/BotNet.Services/BotNet.Services.csproj index a4b611f..3b2a195 100644 --- a/BotNet.Services/BotNet.Services.csproj +++ b/BotNet.Services/BotNet.Services.csproj @@ -43,22 +43,23 @@ - + - + + - - + + - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/BotNet.Services/GoogleSheets/FromColumnAttribute.cs b/BotNet.Services/GoogleSheets/FromColumnAttribute.cs new file mode 100644 index 0000000..1e943fe --- /dev/null +++ b/BotNet.Services/GoogleSheets/FromColumnAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace BotNet.Services.GoogleSheets { + [AttributeUsage(validOn: AttributeTargets.Property, AllowMultiple = false)] + public sealed class FromColumnAttribute : Attribute { + public string Column { get; } + + public FromColumnAttribute(string column) { + Column = column; + } + } +} diff --git a/BotNet.Services/VPS/VPSBenchmark.cs b/BotNet.Services/VPS/VPSBenchmark.cs new file mode 100644 index 0000000..f700109 --- /dev/null +++ b/BotNet.Services/VPS/VPSBenchmark.cs @@ -0,0 +1,25 @@ +using System; +using BotNet.Services.GoogleSheets; + +namespace BotNet.Services.VPS { + public sealed record VPSBenchmark( + [property: FromColumn("A")] string Provider, + [property: FromColumn("B")] string Location, + [property: FromColumn("C")] DateOnly BenchmarkDate, + [property: FromColumn("E")] string? VerdictCons, + [property: FromColumn("F")] decimal IdrMo, + [property: FromColumn("G")] int Core, + [property: FromColumn("H")] int SsdGb, + [property: FromColumn("I")] int RamMb, + [property: FromColumn("J")] int IoMbs, + [property: FromColumn("K")] double? ToCacheFlyMbs, + [property: FromColumn("L")] double? ToHkCnMbs, + [property: FromColumn("M")] double? ToLinodeJpMbs, + [property: FromColumn("N")] double? ToLinodeSgMbs, + [property: FromColumn("O")] double? ToLinodeUkMbs, + [property: FromColumn("P")] double? ToLinodeCaMbs, + [property: FromColumn("R")] double? BzipSec, + [property: FromColumn("S")] double? DlMbs, + [property: FromColumn("T")] double? AvgMbs + ); +} diff --git a/BotNet.Services/VPS/VPSBenchmarkDataSource.cs b/BotNet.Services/VPS/VPSBenchmarkDataSource.cs new file mode 100644 index 0000000..8fab742 --- /dev/null +++ b/BotNet.Services/VPS/VPSBenchmarkDataSource.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotNet.Services.VPS { + public sealed class VPSBenchmarkDataSource { + // Source: https://docs.google.com/spreadsheets/d/14nAIFzIzkQuSxiayhc5tSFWFCWFncrV-GCA3Q5BbS4g/edit#gid=0 + private const string SPREADSHEET_ID = "14nAIFzIzkQuSxiayhc5tSFWFCWFncrV-GCA3Q5BbS4g"; + + + } +} diff --git a/BotNet.Tests/BotNet.Tests.csproj b/BotNet.Tests/BotNet.Tests.csproj index 374aacb..fdb7e5d 100644 --- a/BotNet.Tests/BotNet.Tests.csproj +++ b/BotNet.Tests/BotNet.Tests.csproj @@ -10,9 +10,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/BotNet/BotNet.csproj b/BotNet/BotNet.csproj index 1d8c87e..14bcd55 100644 --- a/BotNet/BotNet.csproj +++ b/BotNet/BotNet.csproj @@ -9,11 +9,11 @@ - - - - - + + + + + From 4fa39454eaaf90f411f5958b729189f83cf710ca Mon Sep 17 00:00:00 2001 From: Ronny Gunawan <3048897+ronnygunawan@users.noreply.github.com> Date: Sat, 17 Feb 2024 13:50:49 +0700 Subject: [PATCH 2/2] Implement SQL --- .../BotUpdate/Message/MessageUpdateHandler.cs | 58 ++++++ .../SQL/SQLCommandHandler.cs | 184 ++++++++++++++++++ BotNet.Commands/BotNet.Commands.csproj | 2 + BotNet.Commands/SQL/SQLCommand.cs | 74 +++++++ BotNet.Services/BotNet.Services.csproj | 1 + .../Pemilu2024/PilpresDataSource.cs | 53 +++++ .../Pemilu2024/ServiceCollectionExtensions.cs | 12 ++ BotNet.Services/Pemilu2024/SirekapClient.cs | 67 +++++++ BotNet.Services/Pemilu2024/Types.cs | 70 +++++++ BotNet.Services/SQL/IScopedDataSource.cs | 8 + BotNet.Services/Sqlite/ScopedDatabase.cs | 63 ++++++ .../Sqlite/ServiceCollectionExtensions.cs | 10 + BotNet/Program.cs | 4 + 13 files changed, 606 insertions(+) create mode 100644 BotNet.CommandHandlers/SQL/SQLCommandHandler.cs create mode 100644 BotNet.Commands/SQL/SQLCommand.cs create mode 100644 BotNet.Services/Pemilu2024/PilpresDataSource.cs create mode 100644 BotNet.Services/Pemilu2024/ServiceCollectionExtensions.cs create mode 100644 BotNet.Services/Pemilu2024/SirekapClient.cs create mode 100644 BotNet.Services/Pemilu2024/Types.cs create mode 100644 BotNet.Services/SQL/IScopedDataSource.cs create mode 100644 BotNet.Services/Sqlite/ScopedDatabase.cs create mode 100644 BotNet.Services/Sqlite/ServiceCollectionExtensions.cs diff --git a/BotNet.CommandHandlers/BotUpdate/Message/MessageUpdateHandler.cs b/BotNet.CommandHandlers/BotUpdate/Message/MessageUpdateHandler.cs index bd40e68..55f5a45 100644 --- a/BotNet.CommandHandlers/BotUpdate/Message/MessageUpdateHandler.cs +++ b/BotNet.CommandHandlers/BotUpdate/Message/MessageUpdateHandler.cs @@ -1,9 +1,12 @@ using BotNet.Commands; using BotNet.Commands.BotUpdate.Message; using BotNet.Commands.CommandPrioritization; +using BotNet.Commands.SQL; using BotNet.Services.BotProfile; using BotNet.Services.SocialLink; using RG.Ninja; +using SqlParser; +using SqlParser.Ast; using Telegram.Bot; using Telegram.Bot.Types.Enums; @@ -157,6 +160,61 @@ out AIFollowUpMessage? aiFollowUpMessage ); await _commandQueue.DispatchAsync(aiFollowUpMessage); + return; + } + + // Handle SQL + if (update.Message is { + ReplyToMessage: null, + Text: { } text + } && text.StartsWith("select", StringComparison.OrdinalIgnoreCase)) { + try { + Sequence ast = new SqlParser.Parser().ParseSql(text); + if (ast.Count > 1) { + // Fire and forget + Task _ = Task.Run(async () => { + try { + await _telegramBotClient.SendTextMessageAsync( + chatId: update.Message.Chat.Id, + text: $"Your SQL contains more than one statement.", + replyToMessageId: update.Message.MessageId, + cancellationToken: cancellationToken + ); + } catch (OperationCanceledException) { + // Terminate gracefully + } + }); + return; + } + if (ast[0] is not Statement.Select selectStatement) { + // Fire and forget + Task _ = Task.Run(async () => { + try { + await _telegramBotClient.SendTextMessageAsync( + chatId: update.Message.Chat.Id, + text: $"Your SQL is not a SELECT statement.", + replyToMessageId: update.Message.MessageId, + cancellationToken: cancellationToken + ); + } catch (OperationCanceledException) { + // Terminate gracefully + } + }); + return; + } + if (SQLCommand.TryCreate( + message: update.Message, + commandPriorityCategorizer: _commandPriorityCategorizer, + sqlCommand: out SQLCommand? sqlCommand + )) { + await _commandQueue.DispatchAsync( + command: sqlCommand + ); + return; + } + } catch { + // Suppress + } } } } diff --git a/BotNet.CommandHandlers/SQL/SQLCommandHandler.cs b/BotNet.CommandHandlers/SQL/SQLCommandHandler.cs new file mode 100644 index 0000000..f80a5ee --- /dev/null +++ b/BotNet.CommandHandlers/SQL/SQLCommandHandler.cs @@ -0,0 +1,184 @@ +using System.Text; +using BotNet.Commands.SQL; +using BotNet.Services.SQL; +using BotNet.Services.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using SqlParser.Ast; +using Telegram.Bot; +using Telegram.Bot.Types.Enums; + +namespace BotNet.CommandHandlers.SQL { + public sealed class SQLCommandHandler( + ITelegramBotClient telegramBotClient, + IServiceProvider serviceProvider + ) : ICommandHandler { + private readonly ITelegramBotClient _telegramBotClient = telegramBotClient; + private readonly IServiceProvider _serviceProvider = serviceProvider; + + public async Task Handle(SQLCommand command, CancellationToken cancellationToken) { + if (command.SelectStatement.Query.Body.AsSelectExpression().Select.From is not { } froms + || froms.Count == 0) { + await _telegramBotClient.SendTextMessageAsync( + chatId: command.Chat.Id, + text: "No FROM clause found.", + replyToMessageId: command.SQLMessageId, + cancellationToken: cancellationToken + ); + return; + } + + // Collect table names from query + HashSet tables = new(); + foreach (TableWithJoins from in froms) { + if (from.Relation != null) { + CollectTableNames(ref tables, from.Relation); + } + + if (from.Joins != null) { + foreach (Join join in from.Joins) { + if (join.Relation != null) { + CollectTableNames(ref tables, join.Relation); + } + } + } + } + + // Create scoped for scoped database + using IServiceScope serviceScope = _serviceProvider.CreateScope(); + + // Load tables into memory + foreach (string table in tables) { + IScopedDataSource? dataSource = serviceScope.ServiceProvider.GetKeyedService(table); + if (dataSource == null) { + await _telegramBotClient.SendTextMessageAsync( + chatId: command.Chat.Id, + text: $$""" + Table '{{table}}' not found. Available tables are: + - pilpres + """, + replyToMessageId: command.SQLMessageId, + cancellationToken: cancellationToken + ); + return; + } + + await dataSource.LoadTableAsync(cancellationToken); + } + + // Execute query + using ScopedDatabase scopedDatabase = serviceScope.ServiceProvider.GetRequiredService(); + StringBuilder resultBuilder = new(); + scopedDatabase.ExecuteReader( + commandText: command.RawStatement, + readAction: (reader) => { + string[] values = new string[reader.FieldCount]; + + // Get column names + for (int i = 0; i < reader.FieldCount; i++) { + values[i] = '"' + reader.GetName(i).Replace("\"", "\"\"") + '"'; + } + resultBuilder.AppendLine(string.Join(',', values)); + + // Get rows + while (reader.Read()) { + for (int i = 0; i < reader.FieldCount; i++) { + if (reader.IsDBNull(i)) { + values[i] = ""; + continue; + } + + Type fieldType = reader.GetFieldType(i); + if (fieldType == typeof(string)) { + values[i] = '"' + reader.GetString(i).Replace("\"", "\"\"") + '"'; + } else if (fieldType == typeof(int)) { + values[i] = reader.GetInt32(i).ToString(); + } else if (fieldType == typeof(long)) { + values[i] = reader.GetInt64(i).ToString(); + } else if (fieldType == typeof(float)) { + values[i] = reader.GetFloat(i).ToString(); + } else if (fieldType == typeof(double)) { + values[i] = reader.GetDouble(i).ToString(); + } else if (fieldType == typeof(decimal)) { + values[i] = reader.GetDecimal(i).ToString(); + } else if (fieldType == typeof(bool)) { + values[i] = reader.GetBoolean(i).ToString(); + } else if (fieldType == typeof(DateTime)) { + values[i] = reader.GetDateTime(i).ToString(); + } else if (fieldType == typeof(byte[])) { + values[i] = BitConverter.ToString(reader.GetFieldValue(i)).Replace("-", ""); + } else { + values[i] = reader[i].ToString(); + } + } + resultBuilder.AppendLine(string.Join(',', values)); + } + } + ); + + // Send result + await _telegramBotClient.SendTextMessageAsync( + chatId: command.Chat.Id, + text: "```csv\n" + resultBuilder.ToString() + "```", + parseMode: ParseMode.MarkdownV2, + replyToMessageId: command.SQLMessageId, + cancellationToken: cancellationToken + ); + + return; + } + + private static void CollectTableNames(ref HashSet tables, TableFactor tableFactor) { + switch (tableFactor) { + case TableFactor.Derived derived: + if (derived.SubQuery.Body.AsSelectExpression().Select.From is { } derivedFroms) { + foreach (TableWithJoins derivedFrom in derivedFroms) { + if (derivedFrom.Relation != null) { + CollectTableNames(ref tables, derivedFrom.Relation); + } + + if (derivedFrom.Joins != null) { + foreach (Join join in derivedFrom.Joins) { + if (join.Relation != null) { + CollectTableNames(ref tables, join.Relation); + } + } + } + } + } + break; + case TableFactor.Function function: + break; + case TableFactor.JsonTable jsonTable: + break; + case TableFactor.NestedJoin nestedJoin: + if (nestedJoin.TableWithJoins != null) { + if (nestedJoin.TableWithJoins.Relation != null) { + CollectTableNames(ref tables, nestedJoin.TableWithJoins.Relation); + } + + if (nestedJoin.TableWithJoins.Joins != null) { + foreach (Join join in nestedJoin.TableWithJoins.Joins) { + if (join.Relation != null) { + CollectTableNames(ref tables, join.Relation); + } + } + } + } + break; + case TableFactor.Pivot pivot: + CollectTableNames(ref tables, pivot.TableFactor); + break; + case TableFactor.Table table: + tables.Add(table.Name.ToString()); + break; + case TableFactor.TableFunction tableFunction: + break; + case TableFactor.UnNest unNest: + break; + case TableFactor.Unpivot unpivot: + tables.Add(unpivot.Name.ToString()); + break; + } + } + } +} diff --git a/BotNet.Commands/BotNet.Commands.csproj b/BotNet.Commands/BotNet.Commands.csproj index 8e4d4f2..f73aa8b 100644 --- a/BotNet.Commands/BotNet.Commands.csproj +++ b/BotNet.Commands/BotNet.Commands.csproj @@ -8,6 +8,8 @@ + + diff --git a/BotNet.Commands/SQL/SQLCommand.cs b/BotNet.Commands/SQL/SQLCommand.cs new file mode 100644 index 0000000..d6d844e --- /dev/null +++ b/BotNet.Commands/SQL/SQLCommand.cs @@ -0,0 +1,74 @@ +using System.Diagnostics.CodeAnalysis; +using BotNet.Commands.BotUpdate.Message; +using BotNet.Commands.ChatAggregate; +using BotNet.Commands.CommandPrioritization; +using SqlParser; +using SqlParser.Ast; + +namespace BotNet.Commands.SQL { + public sealed record SQLCommand : ICommand { + public string RawStatement { get; } + public Statement.Select SelectStatement { get; } + public MessageId SQLMessageId { get; } + public ChatBase Chat { get; } + + private SQLCommand( + string rawStatement, + Statement.Select selectStatement, + MessageId sqlMessageId, + ChatBase chat + ) { + RawStatement = rawStatement; + SelectStatement = selectStatement; + SQLMessageId = sqlMessageId; + Chat = chat; + } + + public static bool TryCreate( + Telegram.Bot.Types.Message message, + CommandPriorityCategorizer commandPriorityCategorizer, + [NotNullWhen(true)] out SQLCommand? sqlCommand + ) { + // Must start with select + if (message.Text is not { } text || !text.StartsWith("select", StringComparison.OrdinalIgnoreCase)) { + sqlCommand = null; + return false; + } + + // Chat must be private or group + if (!ChatBase.TryCreate(message.Chat, commandPriorityCategorizer, out ChatBase? chat)) { + sqlCommand = null; + return false; + } + + // Must be a valid SQL statement + Sequence ast; + try { + ast = new SqlParser.Parser().ParseSql(text); + } catch { + sqlCommand = null; + return false; + } + + // Can only contain one statement + if (ast.Count != 1) { + sqlCommand = null; + return false; + } + + // Must be a SELECT statement + if (ast[0] is not Statement.Select selectStatement) { + sqlCommand = null; + return false; + } + + sqlCommand = new( + rawStatement: text, + selectStatement: selectStatement, + sqlMessageId: new(message.MessageId), + chat: chat + ); + return true; + } + } +} diff --git a/BotNet.Services/BotNet.Services.csproj b/BotNet.Services/BotNet.Services.csproj index 3b2a195..1229dd6 100644 --- a/BotNet.Services/BotNet.Services.csproj +++ b/BotNet.Services/BotNet.Services.csproj @@ -49,6 +49,7 @@ + diff --git a/BotNet.Services/Pemilu2024/PilpresDataSource.cs b/BotNet.Services/Pemilu2024/PilpresDataSource.cs new file mode 100644 index 0000000..1f32805 --- /dev/null +++ b/BotNet.Services/Pemilu2024/PilpresDataSource.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BotNet.Services.SQL; +using BotNet.Services.Sqlite; + +namespace BotNet.Services.Pemilu2024 { + public sealed class PilpresDataSource( + ScopedDatabase scopedDatabase, + SirekapClient sirekapClient + ) : IScopedDataSource { + private const string ANIES = "100025"; + private const string PRABOWO = "100026"; + private const string GANJAR = "100027"; + private readonly ScopedDatabase _scopedDatabase = scopedDatabase; + private readonly SirekapClient _sirekapClient = sirekapClient; + + public async Task LoadTableAsync(CancellationToken cancellationToken) { + _scopedDatabase.ExecuteNonQuery(""" + CREATE TABLE pilpres ( + provinsi VARCHAR(50) PRIMARY KEY, + progress REAL, + anies INTEGER, + prabowo INTEGER, + ganjar INTEGER + ) + """); + + IList listProvinsi = await _sirekapClient.GetPronvisiListAsync(cancellationToken); + Dictionary provinsiByKode = listProvinsi.ToDictionary( + keySelector: provinsi => provinsi.Kode + ); + + ReportPilpres report = await _sirekapClient.GetReportPilpresAsync(cancellationToken); + + foreach ((string kodeWilayah, ReportPilpres.Row row) in report.RowByKodeWilayah.OrderBy(pair => pair.Key)) { + _scopedDatabase.ExecuteNonQuery(""" + INSERT INTO pilpres (provinsi, progress, anies, prabowo, ganjar) + VALUES (@provinsi, @progress, @anies, @prabowo, @ganjar) + """, + [ + ( "@provinsi", provinsiByKode[kodeWilayah].Nama ), + ( "@progress", row.Persen ), + ( "@anies", row.VotesByKodeCalon!.TryGetValue(ANIES, out int anies) ? anies : null), + ( "@prabowo", row.VotesByKodeCalon!.TryGetValue(PRABOWO, out int prabowo) ? prabowo : null), + ( "@ganjar", row.VotesByKodeCalon!.TryGetValue(GANJAR, out int ganjar) ? ganjar : null) + ] + ); + } + } + } +} diff --git a/BotNet.Services/Pemilu2024/ServiceCollectionExtensions.cs b/BotNet.Services/Pemilu2024/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..7b7e91d --- /dev/null +++ b/BotNet.Services/Pemilu2024/ServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using BotNet.Services.SQL; +using Microsoft.Extensions.DependencyInjection; + +namespace BotNet.Services.Pemilu2024 { + public static class ServiceCollectionExtensions { + public static IServiceCollection AddPemilu2024(this IServiceCollection services) { + services.AddTransient(); + services.AddKeyedTransient("pilpres"); + return services; + } + } +} diff --git a/BotNet.Services/Pemilu2024/SirekapClient.cs b/BotNet.Services/Pemilu2024/SirekapClient.cs new file mode 100644 index 0000000..197bd8e --- /dev/null +++ b/BotNet.Services/Pemilu2024/SirekapClient.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using BotNet.Services.Json; + +namespace BotNet.Services.Pemilu2024 { + public sealed class SirekapClient( + HttpClient httpClient + ) { + private static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() { + PropertyNamingPolicy = new SnakeCaseNamingPolicy() + }; + private readonly HttpClient _httpClient = httpClient; + + public async Task> GetPaslonByKodeAsync(CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync>( + requestUri: "https://sirekap-obj-data.kpu.go.id/pemilu/ppwp.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task> GetPartaiByKodeAsync(CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync>( + requestUri: "https://sirekap-obj-data.kpu.go.id/pemilu/partai.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task> GetPronvisiListAsync(CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync>( + requestUri: "https://sirekap-obj-data.kpu.go.id/wilayah/pemilu/ppwp/0.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task> GetDapilDPRListAsync(CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync>( + requestUri: "https://sirekap-obj-data.kpu.go.id/wilayah/pemilu/pdpr/dapil_dpr.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task> GetSubWilayahListAsync(string kodeWilayah, CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync>( + requestUri: $"https://sirekap-obj-data.kpu.go.id/wilayah/pemilu/ppwp/{kodeWilayah}.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task GetReportPilpresAsync(CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync( + requestUri: "https://sirekap-obj-data.kpu.go.id/pemilu/hhcw/ppwp.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + + public async Task GetReportPilpresByWilayahAsync(string kodeWilayah, CancellationToken cancellationToken) { + return await _httpClient.GetFromJsonAsync( + requestUri: $"https://sirekap-obj-data.kpu.go.id/pemilu/hhcw/ppwp/{kodeWilayah}.json", + cancellationToken: cancellationToken + ) ?? throw new JsonException("Unexpected response"); + } + } +} diff --git a/BotNet.Services/Pemilu2024/Types.cs b/BotNet.Services/Pemilu2024/Types.cs new file mode 100644 index 0000000..e88b938 --- /dev/null +++ b/BotNet.Services/Pemilu2024/Types.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace BotNet.Services.Pemilu2024 { + public sealed record Paslon( + [property: JsonPropertyName("ts")] string Timestamp, + string Nama, + string Warna, + int NomorUrut + ); + + public sealed record Partai( + [property: JsonPropertyName("ts")] string Timestamp, + int IdPartai, + int IdPilihan, + bool IsAceh, + string Nama, + string NamaLengkap, + int NomorUrut, + string Warna + ); + + public sealed record Wilayah( + string Nama, + int Id, + string Kode, + int Tingkat + ); + + public sealed record ReportPilpres( + [property: JsonPropertyName("ts")] string Timestamp, + string Psu, + IDictionary Chart, + [property: JsonPropertyName("table")] IDictionary RowByKodeWilayah, + ReportPilpres.Progress Progres + ) { + public sealed record Row { + public string? Psu { get; set; } + public decimal Persen { get; set; } + public bool StatusProgress { get; set; } + + [JsonExtensionData] + public IDictionary? VotesByKodeCalonJson { get; set; } + + [JsonIgnore] + public IDictionary? VotesByKodeCalon { + get { + if (VotesByKodeCalonJson is null) { + return null; + } + + Dictionary votesByKodeCalon = []; + foreach (KeyValuePair kvp in VotesByKodeCalonJson) { + if (kvp.Value.ValueKind == JsonValueKind.Number + && kvp.Value.TryGetInt32(out int votes)) { + votesByKodeCalon[kvp.Key] = votes; + } + } + return votesByKodeCalon; + } + } + } + + public sealed record Progress( + int Total, + int Progres + ); + } +} diff --git a/BotNet.Services/SQL/IScopedDataSource.cs b/BotNet.Services/SQL/IScopedDataSource.cs new file mode 100644 index 0000000..9ca5d60 --- /dev/null +++ b/BotNet.Services/SQL/IScopedDataSource.cs @@ -0,0 +1,8 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace BotNet.Services.SQL { + public interface IScopedDataSource { + Task LoadTableAsync(CancellationToken cancellationToken); + } +} diff --git a/BotNet.Services/Sqlite/ScopedDatabase.cs b/BotNet.Services/Sqlite/ScopedDatabase.cs new file mode 100644 index 0000000..c89dd0d --- /dev/null +++ b/BotNet.Services/Sqlite/ScopedDatabase.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.Data.Sqlite; + +namespace BotNet.Services.Sqlite { + public sealed class ScopedDatabase : IDisposable { + private readonly SqliteConnection _connection; + private bool _disposedValue; + + public ScopedDatabase() { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + public int ExecuteNonQuery(string commandText) { + using SqliteCommand command = _connection.CreateCommand(); + command.CommandText = commandText; + return command.ExecuteNonQuery(); + } + + public int ExecuteNonQuery(string commandText, (string Name, object? Value)[] parameters) { + using SqliteCommand command = _connection.CreateCommand(); + command.CommandText = commandText; + foreach ((string name, object? value) in parameters) { + command.Parameters.AddWithValue(name, value ?? DBNull.Value); + } + return command.ExecuteNonQuery(); + } + + public void ExecuteReader(string commandText, Action readAction) { + using SqliteCommand command = _connection.CreateCommand(); + command.CommandText = commandText; + using SqliteDataReader reader = command.ExecuteReader(); + readAction(reader); + } + + public void ExecuteReader(string commandText, (string Name, object? Value)[] parameters, Action readAction) { + using SqliteCommand command = _connection.CreateCommand(); + command.CommandText = commandText; + foreach ((string name, object? value) in parameters) { + command.Parameters.AddWithValue(name, value ?? DBNull.Value); + } + using SqliteDataReader reader = command.ExecuteReader(); + readAction(reader); + } + + private void Dispose(bool disposing) { + if (!_disposedValue) { + if (disposing) { + // dispose managed state (managed objects) + _connection.Dispose(); + } + + _disposedValue = true; + } + } + + public void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/BotNet.Services/Sqlite/ServiceCollectionExtensions.cs b/BotNet.Services/Sqlite/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0c301f6 --- /dev/null +++ b/BotNet.Services/Sqlite/ServiceCollectionExtensions.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace BotNet.Services.Sqlite { + public static class ServiceCollectionExtensions { + public static IServiceCollection AddSqliteDatabases(this IServiceCollection services) { + services.AddScoped(); + return services; + } + } +} diff --git a/BotNet/Program.cs b/BotNet/Program.cs index 233d33f..2ee58fc 100644 --- a/BotNet/Program.cs +++ b/BotNet/Program.cs @@ -18,11 +18,13 @@ using BotNet.Services.ImageConverter; using BotNet.Services.Meme; using BotNet.Services.OpenAI; +using BotNet.Services.Pemilu2024; using BotNet.Services.Pesto; using BotNet.Services.Piston; using BotNet.Services.Preview; using BotNet.Services.Primbon; using BotNet.Services.ProgrammerHumor; +using BotNet.Services.Sqlite; using BotNet.Services.Stability; using BotNet.Services.ThisXDoesNotExist; using BotNet.Services.Tiktok; @@ -84,6 +86,8 @@ builder.Services.AddCommandHandlers(); builder.Services.AddCommandPriorityCategorizer(); builder.Services.AddBotProfileAccessor(); +builder.Services.AddSqliteDatabases(); +builder.Services.AddPemilu2024(); // MediatR builder.Services.AddMediatR(config => {