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 => {