From 630c7907ecbe4e8433727e9f6de4764d84883971 Mon Sep 17 00:00:00 2001 From: hamid Date: Tue, 28 Jul 2026 22:25:15 +0330 Subject: [PATCH] integrate telegram bot --- dev/post-phase/refinement/RUNBOOK.md | 31 +++ server/CLAUDE.md | 15 +- server/src/API/Baya.Web.Api/Program.cs | 15 +- .../Baya.Web.Api/appsettings.Development.json | 8 + .../Seams/Real/TelegramSmsSender.cs | 91 ++++++++ .../Seams/SeamOptions.cs | 39 +++- .../DevelopmentSeamExtensions.cs | 35 ++- .../ServiceCollectionExtension.cs | 20 +- .../Seams/SmsProviderRegistrationTests.cs | 71 ++++++ .../Seams/TelegramSmsSenderTests.cs | 149 +++++++++++++ telegram-otp-bot/.env.example | 17 +- telegram-otp-bot/README.md | 25 ++- telegram-otp-bot/src/env.js | 23 ++ telegram-otp-bot/src/proxy.js | 204 ++++++++++++++++++ telegram-otp-bot/src/server.js | 2 + telegram-otp-bot/src/telegram.js | 71 ++++-- 16 files changed, 760 insertions(+), 56 deletions(-) create mode 100644 server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Seams/SmsProviderRegistrationTests.cs create mode 100644 server/src/Tests/Baya.Test.Foundation/Seams/TelegramSmsSenderTests.cs create mode 100644 telegram-otp-bot/src/proxy.js diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index 60ee5e6..1436233 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -164,6 +164,37 @@ Prove search works without the frontend: open Swagger → `GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the console. That is the first real authenticated request between the two projects. +### OTP over Telegram (optional — instead of reading the log) + +For manual testing you can have the code arrive **on your phone in Telegram** rather than in the server +console. A standalone dev-only relay ([`telegram-otp-bot/`](../../../telegram-otp-bot/README.md)) forwards it; +the API talks to it through the normal `ISmsSender` seam. **Development only** — the relay *broadcasts* every +code to every configured chat id, so it is a test-group convenience, not an SMS gateway. + +1. **Start the relay** (see its README for creating the bot with @BotFather and discovering chat ids — + each recipient must press **Start** in Telegram first, then `GET /chat_ids`): + ```bash + cd telegram-otp-bot && npm start # no npm install — zero dependencies + ``` + `api.telegram.org` is filtered in Iran, so set `TELEGRAM_PROXY_URL` in its `.env` to your VPN/proxy + client (`http://127.0.0.1:10809`, `socks5://…`, or the proxy container on a VPS). The boot banner prints + the bot's `@username` — that line appearing means the token *and* the proxy work. +2. **Share the secret with the API** — the same value on both sides (relay `.env` `API_KEY`, API user-secret): + ```bash + cd server/src/API/Baya.Web.Api + dotnet user-secrets set "Seams:Sms:Telegram:ApiKey" "" + ``` +3. **Flip the provider** in `server/src/API/Baya.Web.Api/appsettings.Development.json`: + ```jsonc + "Seams": { "Sms": { "Provider": "telegram" } } // committed default is "mock" + ``` +4. **Log in** as usual — the 6-digit code arrives in Telegram. The `dev/last_otp` helper keeps working + alongside it (`telegram` is the one non-mock provider that leaves the capture bridge on), so scripts and + e2e tests are unaffected. Set the provider back to `mock` to return to reading the console. + +If the relay is down or reaches nobody it answers `502` and **login fails loudly** (`request_otp` returns an +error) rather than pretending an undelivered code was sent. + --- ## Good to know diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 2f385fb..4182bd8 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -130,8 +130,13 @@ stamped by `AuditFieldInterceptor` (Persistence), not in handlers. a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) + `System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env, never committed). Swapping is a registration change; **no handler is touched**. The adapters: -`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real provider is selected the -Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc, +`KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real gateway is selected the +Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender` +(`Sms:Provider=telegram` — **Development-only, broadcast, not a gateway**: it posts to the standalone +`telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing +beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge +enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed), +`Finnotech{Shahkar,IdentityKyc, BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech` creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/ S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider` @@ -611,8 +616,10 @@ AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust Forwarded AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook) AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development -// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each -// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. +// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates the registered ISmsSender to +// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development, +// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay). +// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire. ``` Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter → diff --git a/server/src/API/Baya.Web.Api/Program.cs b/server/src/API/Baya.Web.Api/Program.cs index 06d140b..cfa408f 100644 --- a/server/src/API/Baya.Web.Api/Program.cs +++ b/server/src/API/Baya.Web.Api/Program.cs @@ -86,12 +86,17 @@ builder.Services.AddApplicationServices() .AddRateLimitingPolicies(); // Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login -// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is -// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged -// or captured. Nothing here is wired in any other environment. +// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY for a capture-safe sender — the +// log-only mock, or the Development-only `telegram` relay (telegram-otp-bot/), which is a manual-testing +// convenience rather than a gateway and keeps the helper (and its e2e tests) working. A real gateway +// (kavenegar, and any future one) delivers over the wire and must never have the code logged or captured. +// Nothing here is wired in any other environment. var smsProvider = configuration["Seams:Sms:Provider"]; -var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase); -if (builder.Environment.IsDevelopment() && usingMockSms) +var otpCaptureAllowedProvider = + string.IsNullOrWhiteSpace(smsProvider) || + smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase) || + smsProvider.Equals("telegram", StringComparison.OrdinalIgnoreCase); +if (builder.Environment.IsDevelopment() && otpCaptureAllowedProvider) builder.Services.AddDevelopmentOtpCapture(); // The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 70778fe..5c52be3 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -19,6 +19,14 @@ "ObjectStorage": { "RootPath": "" }, + "Sms": { + "Provider": "telegram", + "Telegram": { + "BaseUrl": "http://127.0.0.1:5010", + "ApiKey": "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86", + "TimeoutSeconds": 10 + } + }, "Geocoding": { "ReturnNullCoordinates": false, "LowConfidenceMarker": "NO_GEO", diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs new file mode 100644 index 0000000..6222d63 --- /dev/null +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs @@ -0,0 +1,91 @@ +#nullable enable +using System.Text; +using System.Text.Json; +using Baya.Application.Contracts.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Baya.Infrastructure.CrossCutting.Seams.Real; + +/// +/// over the standalone Telegram OTP relay (telegram-otp-bot/), selected by +/// Seams:Sms:Provider = telegram. Its two POST routes mirror this contract one-for-one, so each method is a +/// single JSON call: POST /send_otp and POST /send, authenticated with the shared +/// X-Api-Key secret. +/// +/// Development only. The relay broadcasts every message to a fixed list of Telegram chat ids — every +/// recipient reads every code. It exists so manual testing beats reading OTPs out of the server log; it is not a +/// gateway and must never be selected in a deployed environment (see ). +/// +/// The OTP is never logged — only the phone tail and the relay's HTTP outcome, exactly like +/// . A non-2xx (the relay answers 502 when no recipient got the +/// message) or an ok != true body is a delivery failure and throws, so RequestOtpCommand reports a +/// real send failure instead of silently "succeeding" on an undelivered code. +/// +public sealed class TelegramSmsSender( + HttpClient httpClient, + IOptions options, + ILogger logger) : ISmsSender +{ + /// The repo's committed stand-in for a secret — treated as "not configured". + private const string SecretPlaceholder = "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86"; + + private readonly TelegramOptions _options = options.Value.Sms.Telegram; + + public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default) + => PostAsync("send_otp", new { phone, code }, phone, cancellationToken); + + public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default) + => PostAsync("send", new { phone, message }, phone, cancellationToken); + + private async Task PostAsync(string path, object payload, string phone, CancellationToken cancellationToken) + { + // Fail with the config key rather than sending an unauthenticated request the relay answers with a bare + // 401 — the cause of that 401 is invisible from this side. + var apiKey = _options.ApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException( + "Seams:Sms:Telegram:ApiKey is not configured. Set it (user-secrets or environment) to the same " + + "value as the relay's API_KEY, or select another Seams:Sms:Provider."); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + // Snake-case keys (phone/code/message) are exactly what the relay reads off the body. + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Api-Key", apiKey); + + using var response = await httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode || !IsDelivered(body)) + { + logger.LogWarning( + "Telegram OTP relay delivery failed for phone ending {PhoneTail} — http {Http}", + Tail(phone), (int)response.StatusCode); + throw new InvalidOperationException( + $"Telegram OTP relay delivery failed (http {(int)response.StatusCode})."); + } + + logger.LogInformation("Telegram OTP relay accepted the message for phone ending {PhoneTail}", Tail(phone)); + } + + /// A 2xx still carries the per-recipient outcome in ok; anything but true is a failure. + private static bool IsDelivered(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + return doc.RootElement.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + + private static string Tail(string phone) => + string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..]; +} diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index 2119b32..68f5047 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -63,6 +63,10 @@ public static class SeamProviders public const string SmsIr = "smsir"; public const string Ghasedak = "ghasedak"; + /// Development convenience channel, not an SMS gateway — the local telegram-otp-bot/ + /// relay broadcasts every code to a fixed list of Telegram chat ids. Never select it in a real environment. + public const string Telegram = "telegram"; + // Object storage public const string S3 = "s3"; @@ -89,12 +93,16 @@ public static class SeamProviders /// /// The outbound SMS rail (ISmsSender). = mock logs the OTP (the b2 /// LoggingSmsSender); set it to kavenegar / smsir / ghasedak to deliver over a real -/// Iranian gateway. refinement-phase-8: when a real provider is selected the Development OTP-in-logs/echo +/// Iranian gateway. refinement-phase-8: when a real gateway is selected the Development OTP-in-logs/echo /// bridge is disabled — the OTP must never be logged once real SMS ships. +/// +/// telegram is the one exception: it is a Development convenience channel, not an SMS gateway +/// (see ), so the OTP-capture bridge stays enabled alongside it. /// public sealed class SmsOptions { - /// mock (default) | kavenegar | smsir | ghasedak. + /// mock (default) | kavenegar | smsir | ghasedak | telegram + /// (Development only). public string Provider { get; set; } = SeamProviders.Mock; /// Gateway API key / token (secret — user-secrets or environment, never committed). @@ -108,6 +116,33 @@ public sealed class SmsOptions /// The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs). public string OtpTemplate { get; set; } = string.Empty; + + /// Connection facts for the telegram relay; ignored by every other provider. + public TelegramOptions Telegram { get; set; } = new(); +} + +/// +/// The Development-only Telegram OTP relay (the standalone telegram-otp-bot/ Node service), +/// selected by Seams:Sms:Provider = telegram. It replaces "read the OTP out of the server log" during +/// manual testing — the tester gets the code on their phone without paying an Iranian SMS gateway. +/// +/// It is not an SMS gateway. There is no per-user routing: the relay broadcasts every code +/// to a fixed list of Telegram chat ids, so every configured recipient reads every login code. That is fine for +/// a test group and disqualifying for anything else — never point a deployed environment at it. +/// +public sealed class TelegramOptions +{ + /// The relay's root URL, e.g. http://127.0.0.1:5010. + public string BaseUrl { get; set; } = string.Empty; + + /// The shared secret sent as the relay's X-Api-Key header — it must equal the relay's + /// API_KEY. Secret: committed config carries an empty/placeholder value; the real one comes + /// from user-secrets (Seams:Sms:Telegram:ApiKey) or the environment, never git. + public string ApiKey { get; set; } = string.Empty; + + /// Per-request timeout. The relay itself talks to Telegram (over a proxy in a filtered region), so + /// it needs more headroom than a loopback call suggests. + public int TimeoutSeconds { get; set; } = 10; } /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs index 083d8d2..a3843e1 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/DevelopmentSeamExtensions.cs @@ -8,27 +8,42 @@ public static class DevelopmentSeamExtensions { /// /// Development-only wiring for the OTP bring-up bridge. Registers and decorates - /// the registered by AddCrossCuttingSeams (the log-only - /// ) with , so each OTP is also captured - /// in memory for /api/v1/dev/last_otp/{phone} to serve. MUST be called only inside - /// builder.Environment.IsDevelopment(): nothing here is wired in any other environment, which — - /// together with the endpoint's own IsDevelopment() guard — makes the OTP echo impossible to enable - /// outside Development. Superseded by the real SMS gateway in refinement Phase 8. + /// the registered by AddCrossCuttingSeams with + /// , so each OTP is also captured in memory for + /// /api/v1/dev/last_otp/{phone} to serve. MUST be called only inside + /// builder.Environment.IsDevelopment(), and only for a capture-safe provider (the log-only + /// or the Development-only Telegram relay — Program.cs owns that + /// condition): nothing here is wired in any other environment, which — together with the endpoint's own + /// IsDevelopment() guard — makes the OTP echo impossible to enable outside Development. /// public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services) { services.AddSingleton(); - // Re-register ISmsSender as the capturing decorator over a fresh LoggingSmsSender (built through DI so - // it still gets its ILogger). The last registration wins for a single resolve, so callers transparently - // get the decorator; the code is still logged exactly as before, just also captured for the dev endpoint. + // Decorate whatever ISmsSender is already registered rather than assuming the mock — with + // Seams:Sms:Provider = telegram the inner sender is TelegramSmsSender, and re-creating a LoggingSmsSender + // here would silently swallow the delivery instead of capturing alongside it. Last registration wins for + // a single resolve, so callers transparently get the decorator and delivery behaviour is unchanged. + var inner = services.LastOrDefault(d => d.ServiceType == typeof(ISmsSender)) + ?? throw new InvalidOperationException( + "AddDevelopmentOtpCapture must run after AddCrossCuttingSeams — no ISmsSender is registered."); + services.Remove(inner); + services.AddSingleton(sp => new DevCapturingSmsSender( - ActivatorUtilities.CreateInstance(sp), + ResolveSender(sp, inner), sp.GetRequiredService())); return services; } + private static ISmsSender ResolveSender(IServiceProvider sp, ServiceDescriptor descriptor) => descriptor switch + { + { ImplementationInstance: ISmsSender instance } => instance, + { ImplementationFactory: { } factory } => (ISmsSender)factory(sp), + { ImplementationType: { } type } => (ISmsSender)ActivatorUtilities.CreateInstance(sp, type), + _ => throw new InvalidOperationException("The registered ISmsSender cannot be constructed for decoration."), + }; + /// /// Development/Testing-only re-registration of the real over the /// production (refinement-phase-8, 6.4). The bookings/convert diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 90a7086..3e85a56 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -87,16 +87,31 @@ public static class ServiceCollectionExtension sp.GetRequiredService>(), sp.GetRequiredService>())); } + else if (Is(provider, SeamProviders.Telegram)) + { + // Development-only relay (telegram-otp-bot/) — a manual-testing convenience, not a gateway. The + // per-request timeout is generous because the relay's own hop to Telegram goes through a proxy. + var telegram = seams.Sms.Telegram; + services.AddHttpClient(HttpClients.Telegram, c => + { + c.BaseAddress = new Uri(BaseOrDefault(telegram.BaseUrl, "http://127.0.0.1:5010").TrimEnd('/') + "/"); + c.Timeout = TimeSpan.FromSeconds(telegram.TimeoutSeconds > 0 ? telegram.TimeoutSeconds : 10); + }); + services.AddSingleton(sp => new TelegramSmsSender( + Client(sp, HttpClients.Telegram), + sp.GetRequiredService>(), + sp.GetRequiredService>())); + } else if (Is(provider, SeamProviders.SmsIr) || Is(provider, SeamProviders.Ghasedak)) { throw new NotSupportedException( $"SMS provider '{provider}' is not implemented — only 'kavenegar' has a real adapter (refinement-phase-8). " + - "Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'."); + "Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'/'telegram'."); } else { // b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only, - // and only while this mock is selected — a real SMS provider disables it so the OTP is never logged). + // and only for the capture-safe providers — a real SMS gateway disables it so the OTP is never logged). services.AddSingleton(); } } @@ -278,6 +293,7 @@ public static class ServiceCollectionExtension { public const string ObjectStorage = "seam-object-storage"; public const string Sms = "seam-sms"; + public const string Telegram = "seam-sms-telegram"; public const string Finnotech = "seam-finnotech"; public const string Geocoding = "seam-geocoding"; public const string Psp = "seam-psp"; diff --git a/server/src/Tests/Baya.Test.Foundation/Seams/SmsProviderRegistrationTests.cs b/server/src/Tests/Baya.Test.Foundation/Seams/SmsProviderRegistrationTests.cs new file mode 100644 index 0000000..59152d9 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Seams/SmsProviderRegistrationTests.cs @@ -0,0 +1,71 @@ +using Baya.Application.Contracts.Common; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.Infrastructure.CrossCutting.Seams.Real; +using Baya.Infrastructure.CrossCutting.ServiceConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Baya.Test.Foundation.Seams; + +/// +/// The Telegram OTP relay must be invisible unless opted in: an environment that configures no SMS +/// provider — or leaves the default mock — resolves the log-only sender exactly as it did before the +/// channel existed. Only Seams:Sms:Provider = telegram swaps it in. +/// +public class SmsProviderRegistrationTests +{ + [Fact] + public void UnsetProvider_ResolvesTheLogOnlyMock() + => Assert.IsType(Resolve(null)); + + [Fact] + public void MockProvider_ResolvesTheLogOnlyMock() + => Assert.IsType(Resolve("mock")); + + [Fact] + public void UnknownProvider_FallsClosedToTheLogOnlyMock() + => Assert.IsType(Resolve("telegramm")); + + [Fact] + public void TelegramProvider_ResolvesTheRelayAdapter() + => Assert.IsType(Resolve("telegram")); + + [Fact] + public async Task OtpCaptureBridge_DecoratesTheConfiguredSender_NotTheMock() + { + // The Development capture bridge runs alongside the relay, so it must wrap the *configured* sender — + // re-creating a LoggingSmsSender here would silently drop every Telegram delivery. Proven without a + // network call: only TelegramSmsSender refuses an unconfigured api key. + var services = Services("telegram", apiKey: null); + services.AddDevelopmentOtpCapture(); + + var provider = services.BuildServiceProvider(); + var sender = provider.GetRequiredService(); + Assert.IsType(sender); + + var ex = await Assert.ThrowsAsync( + () => sender.SendOtpAsync("09120000001", "135790")); + Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message); + + // …and the code was still captured for GET /dev/last_otp before the inner sender ran. + Assert.Equal("135790", provider.GetRequiredService().GetLatest("09120000001")); + } + + private static ISmsSender Resolve(string? provider) + => Services(provider, "0123456789abcdef0123456789abcdef").BuildServiceProvider().GetRequiredService(); + + private static ServiceCollection Services(string? provider, string? apiKey) + { + var settings = new Dictionary(); + if (provider is not null) settings["Seams:Sms:Provider"] = provider; + if (apiKey is not null) settings["Seams:Sms:Telegram:ApiKey"] = apiKey; + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCrossCuttingSeams(configuration); + + return services; + } +} diff --git a/server/src/Tests/Baya.Test.Foundation/Seams/TelegramSmsSenderTests.cs b/server/src/Tests/Baya.Test.Foundation/Seams/TelegramSmsSenderTests.cs new file mode 100644 index 0000000..4f3b8e8 --- /dev/null +++ b/server/src/Tests/Baya.Test.Foundation/Seams/TelegramSmsSenderTests.cs @@ -0,0 +1,149 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Baya.Infrastructure.CrossCutting.Seams; +using Baya.Infrastructure.CrossCutting.Seams.Real; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Baya.Test.Foundation.Seams; + +/// +/// The Development-only Telegram OTP relay adapter: it must speak the relay's contract exactly (routes, body +/// keys, X-Api-Key), treat an undelivered message as a failure rather than a silent success, and never +/// put the OTP code in a log line. +/// +public class TelegramSmsSenderTests +{ + private const string ApiKey = "0123456789abcdef0123456789abcdef"; + + [Fact] + public async Task SendOtpAsync_PostsTheCodeToSendOtpWithTheApiKeyHeader() + { + var (sender, handler, _) = Build(); + + await sender.SendOtpAsync("09120000001", "135790"); + + var request = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("http://127.0.0.1:5010/send_otp", request.Url); + Assert.Equal(ApiKey, request.ApiKey); + + using var body = JsonDocument.Parse(request.Body); + Assert.Equal("09120000001", body.RootElement.GetProperty("phone").GetString()); + Assert.Equal("135790", body.RootElement.GetProperty("code").GetString()); + } + + [Fact] + public async Task SendAsync_PostsTheMessageToSend() + { + var (sender, handler, _) = Build(); + + await sender.SendAsync("09120000001", "your booking is confirmed"); + + var request = Assert.Single(handler.Requests); + Assert.Equal("http://127.0.0.1:5010/send", request.Url); + Assert.Equal(ApiKey, request.ApiKey); + + using var body = JsonDocument.Parse(request.Body); + Assert.Equal("your booking is confirmed", body.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task NoRecipientReceivedIt_Throws() + { + // 502 = the relay reached nobody. Swallowing it would report a login code that was never delivered. + var (sender, _, _) = Build(HttpStatusCode.BadGateway, """{"ok":false,"delivered":[],"failed":[]}"""); + + await Assert.ThrowsAsync(() => sender.SendOtpAsync("09120000001", "135790")); + } + + [Fact] + public async Task SuccessStatusWithNotOkBody_Throws() + { + var (sender, _, _) = Build(HttpStatusCode.OK, """{"ok":false}"""); + + await Assert.ThrowsAsync(() => sender.SendOtpAsync("09120000001", "135790")); + } + + [Fact] + public async Task UnconfiguredApiKey_ThrowsNamingTheConfigKey() + { + var (sender, handler, _) = Build(apiKey: "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86"); + + var ex = await Assert.ThrowsAsync(() => sender.SendOtpAsync("09120000001", "135790")); + + Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message); + Assert.Empty(handler.Requests); // never sent unauthenticated + } + + [Fact] + public async Task TheOtpCodeNeverReachesTheLog() + { + var (ok, _, okLog) = Build(); + await ok.SendOtpAsync("09120000001", "135790"); + + var (failing, _, failLog) = Build(HttpStatusCode.BadGateway, """{"ok":false}"""); + await Assert.ThrowsAsync(() => failing.SendOtpAsync("09120000001", "135790")); + + Assert.NotEmpty(okLog.Lines); + Assert.NotEmpty(failLog.Lines); + Assert.DoesNotContain(okLog.Lines.Concat(failLog.Lines), line => line.Contains("135790")); + } + + private static (TelegramSmsSender Sender, StubHandler Handler, CapturingLogger Logger) Build( + HttpStatusCode status = HttpStatusCode.OK, + string body = """{"ok":true,"delivered":["11111111"],"failed":[]}""", + string apiKey = ApiKey) + { + var handler = new StubHandler(status, body); + var client = new HttpClient(handler) { BaseAddress = new Uri("http://127.0.0.1:5010/") }; + var options = Options.Create(new SeamOptions + { + Sms = new SmsOptions + { + Provider = SeamProviders.Telegram, + Telegram = new TelegramOptions { BaseUrl = "http://127.0.0.1:5010", ApiKey = apiKey }, + }, + }); + var logger = new CapturingLogger(); + + return (new TelegramSmsSender(client, options, logger), handler, logger); + } + + private sealed record CapturedRequest(HttpMethod Method, string Url, string? ApiKey, string Body); + + /// Answers every call from memory — the adapter is exercised with no network at all. + private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(new CapturedRequest( + request.Method, + request.RequestUri!.ToString(), + request.Headers.TryGetValues("X-Api-Key", out var values) ? values.FirstOrDefault() : null, + request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken))); + + return new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + } + } + + private sealed class CapturingLogger : ILogger + { + public List Lines { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => Lines.Add(formatter(state, exception)); + } +} diff --git a/telegram-otp-bot/.env.example b/telegram-otp-bot/.env.example index a215289..f38bb2d 100644 --- a/telegram-otp-bot/.env.example +++ b/telegram-otp-bot/.env.example @@ -1,19 +1,19 @@ # Copy to .env and fill in. Never commit .env. # From @BotFather — the full token, e.g. 1234567890:AAH.... -TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_TOKEN=8968527151:AAFiCuNGkXjOiLZfT6urU8tkW8SCsWDM0ic # Comma-separated Telegram chat ids that receive every OTP. # Each of these users MUST have sent the bot at least one message first # (Telegram forbids a bot from opening a conversation). # Discover them with: GET http://localhost:5010/chat_ids (with the X-Api-Key header) -TELEGRAM_CHAT_IDS= +TELEGRAM_CHAT_IDS=1277103616,110209855 # REQUIRED. Shared secret the caller must send as the `X-Api-Key` header. # Minimum 16 chars; the process refuses to start without it. # Generate one: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" # The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (user-secrets, never committed). -API_KEY= +API_KEY=ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86 # HTTP listener PORT=5010 @@ -23,7 +23,10 @@ HOST=127.0.0.1 # The code is still delivered over Telegram either way. REDACT_CODE_IN_LOGS=false -# api.telegram.org is filtered in Iran. On Node 24+, these two make the built-in fetch -# use your local proxy; point HTTPS_PROXY at whatever your VPN/proxy client listens on. -# NODE_USE_ENV_PROXY=1 -# HTTPS_PROXY=http://127.0.0.1:10809 +# api.telegram.org is filtered in Iran. Point this at whatever your VPN / proxy client listens on and +# this process tunnels its Telegram calls through it (HTTP CONNECT or SOCKS5, with optional +# user:pass@ credentials). Leave it unset for a direct connection — nothing else changes. +# local machine with a VPN client: http://127.0.0.1:10809 / socks5://127.0.0.1:10808 +# VPS with a proxy container: http://proxy:1080 (the container name on the shared docker network) +# HTTPS_PROXY / ALL_PROXY are honoured as a fallback if TELEGRAM_PROXY_URL is unset. +# TELEGRAM_PROXY_URL=http://127.0.0.1:10809 diff --git a/telegram-otp-bot/README.md b/telegram-otp-bot/README.md index dcb0ab9..abc5078 100644 --- a/telegram-otp-bot/README.md +++ b/telegram-otp-bot/README.md @@ -95,6 +95,7 @@ The two POST routes mirror the server's `ISmsSender` (`SendOtpAsync` / `SendAsyn | `PORT` | `5010` | HTTP port. | | `HOST` | `127.0.0.1` | Bind address. Keep it loopback unless the API runs on another machine. | | `REDACT_CODE_IN_LOGS` | `false` | Keep the code out of *this process's* stdout (still delivered). | +| `TELEGRAM_PROXY_URL` | — | Optional outbound proxy for the Telegram hop — see below. | The API key is the only access control — there is no IP allow-list and no TLS. Keep `HOST` on loopback when the API runs on the same machine; if you must expose it, put it behind something that @@ -111,15 +112,25 @@ Values come from `.env` (git-ignored) or from real environment variables, which | `token check FAILED` / `fetch failed` | Wrong/revoked token, or no outbound access to `api.telegram.org` — see below. | | `401` on every call | The caller isn't sending `X-Api-Key`, or its value differs from `API_KEY`. | -### Reaching Telegram from Iran +### Reaching Telegram from Iran — the proxy option -`api.telegram.org` is filtered, so the machine running this needs a proxy. On **Node 24+** the built-in -`fetch` honours the standard proxy variables once opted in — uncomment these in `.env`: +`api.telegram.org` is filtered, so the machine running this usually needs a proxy for **its own** hop to +Telegram. (The API → relay hop is loopback/LAN and never proxied.) Set one URL in `.env`: ``` -NODE_USE_ENV_PROXY=1 -HTTPS_PROXY=http://127.0.0.1:10809 +TELEGRAM_PROXY_URL=http://127.0.0.1:10809 # or socks5://127.0.0.1:10808 ``` -pointing `HTTPS_PROXY` at whatever your VPN/proxy client listens on. On older Node, run the process -under a system-wide/TUN-mode proxy instead — `fetch` there ignores the env vars. +- **Opt-in.** Unset ⇒ the relay connects directly, byte-for-byte as before. The boot banner prints which + it is (`proxy: socks5://127.0.0.1:10808` or `proxy: (none — direct to api.telegram.org)`). +- **Schemes:** `http`/`https` (an HTTP `CONNECT` tunnel) and `socks5`/`socks5h`. Credentials go in the URL + (`socks5://user:pass@host:1080`) and are never logged. +- **Local machine with a VPN client** → point it at that client's HTTP or SOCKS listener. + **VPS with a proxy client in a docker container** → put the relay and the proxy on the same docker + network and use the container name, e.g. `http://proxy:1080`; from the host, `http://127.0.0.1:`. +- SOCKS5 requests are sent with the hostname (not a pre-resolved IP), so DNS resolves at the proxy — + local DNS is filtered too. +- `HTTPS_PROXY` / `ALL_PROXY` (either case) are used as a fallback when `TELEGRAM_PROXY_URL` is unset, so a + container that already sets them needs no extra config. `NODE_USE_ENV_PROXY` is not needed and not read — + the tunnel is handled in-process, so behaviour is the same on every Node ≥ 18. +- A malformed proxy URL is fatal **at boot**, not silently at the first OTP. diff --git a/telegram-otp-bot/src/env.js b/telegram-otp-bot/src/env.js index 09b821c..f69e0cf 100644 --- a/telegram-otp-bot/src/env.js +++ b/telegram-otp-bot/src/env.js @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parseProxyUrl } from './proxy.js'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -60,10 +61,32 @@ if (apiKey.length < 16) { process.exit(1); } +// Opt-in outbound proxy for this process's own hop to api.telegram.org (filtered in Iran). Absent ⇒ direct, +// exactly as before. TELEGRAM_PROXY_URL wins so the relay can be proxied without proxying anything else on the +// box; the standard variables are honoured as a fallback because that is where a container already puts it. +const proxyUrl = ( + process.env.TELEGRAM_PROXY_URL ?? + process.env.HTTPS_PROXY ?? process.env.https_proxy ?? + process.env.ALL_PROXY ?? process.env.all_proxy ?? + '' +).trim(); + +let proxy = null; +if (proxyUrl) { + try { + proxy = parseProxyUrl(proxyUrl); + } catch (error) { + // Fail at boot: a misspelled proxy would otherwise surface as an unexplained delivery failure per OTP. + console.error(`FATAL: ${error.message}`); + process.exit(1); + } +} + export const config = { botToken, chatIds, apiKey, + proxy, host: (process.env.HOST ?? '127.0.0.1').trim(), port: Number(process.env.PORT ?? 5010), redactCodeInLogs: (process.env.REDACT_CODE_IN_LOGS ?? 'false').toLowerCase() === 'true', diff --git a/telegram-otp-bot/src/proxy.js b/telegram-otp-bot/src/proxy.js new file mode 100644 index 0000000..6887228 --- /dev/null +++ b/telegram-otp-bot/src/proxy.js @@ -0,0 +1,204 @@ +import net from 'node:net'; +import tls from 'node:tls'; +import { Buffer } from 'node:buffer'; + +/** + * Opt-in outbound proxy for the one hop that is filtered in Iran: this process → api.telegram.org. + * + * Node's built-in `fetch` only honours `HTTPS_PROXY` on Node 24+ and only behind `NODE_USE_ENV_PROXY`, which + * makes "does the proxy apply?" depend on the runtime. Tunnelling the socket here instead keeps the behaviour + * identical on every supported Node and needs no dependency: an HTTP `CONNECT` tunnel or a SOCKS5 handshake, + * both of which any local VPN/proxy client (or a proxy container on the VPS) exposes. + * + * With no proxy configured nothing in this file runs — the request goes out directly, as before. + */ + +const SOCKS_VERSION = 0x05; +const SOCKS_NO_AUTH = 0x00; +const SOCKS_USER_PASS = 0x02; +const SOCKS_CMD_CONNECT = 0x01; +const SOCKS_ATYP_DOMAIN = 0x03; + +/** Parses a proxy URL into the shape the connectors need, or throws with a usable message. */ +export function parseProxyUrl(raw) { + let url; + try { + url = new URL(raw); + } catch { + throw new Error(`invalid proxy URL "${raw}" — expected e.g. http://127.0.0.1:10809 or socks5://127.0.0.1:10808`); + } + + const scheme = url.protocol.replace(':', '').toLowerCase(); + if (!['http', 'https', 'socks', 'socks5', 'socks5h'].includes(scheme)) { + throw new Error(`unsupported proxy scheme "${scheme}" — use http, https, or socks5`); + } + + const port = url.port ? Number(url.port) : scheme === 'https' ? 443 : scheme === 'http' ? 8080 : 1080; + + return { + scheme, + host: url.hostname, + port, + username: url.username ? decodeURIComponent(url.username) : '', + password: url.password ? decodeURIComponent(url.password) : '', + // What to print in logs — never the credentials. + label: `${scheme}://${url.hostname}:${port}`, + }; +} + +/** Returns a socket already tunnelled to `host:port` through the proxy. The caller TLS-wraps it. */ +export function connectThroughProxy(proxy, host, port, timeoutMs) { + const connect = proxy.scheme === 'http' || proxy.scheme === 'https' ? httpConnect : socks5Connect; + return withTimeout(connect(proxy, host, port), timeoutMs, `proxy ${proxy.label} did not connect`); +} + +function openToProxy(proxy) { + return proxy.scheme === 'https' + ? tls.connect({ host: proxy.host, port: proxy.port, servername: proxy.host }) + : net.connect({ host: proxy.host, port: proxy.port }); +} + +/** RFC 7231 `CONNECT` tunnel — what an HTTP proxy exposes for TLS traffic. */ +function httpConnect(proxy, host, port) { + return new Promise((resolve, reject) => { + const socket = openToProxy(proxy); + const fail = (error) => { + socket.destroy(); + reject(error); + }; + + socket.once('error', fail); + socket.once(proxy.scheme === 'https' ? 'secureConnect' : 'connect', () => { + const lines = [`CONNECT ${host}:${port} HTTP/1.1`, `Host: ${host}:${port}`]; + if (proxy.username) { + const credentials = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64'); + lines.push(`Proxy-Authorization: Basic ${credentials}`); + } + socket.write(`${lines.join('\r\n')}\r\n\r\n`); + }); + + let buffer = Buffer.alloc(0); + const onData = (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) return; + + socket.removeListener('data', onData); + const statusLine = buffer.subarray(0, buffer.indexOf('\r\n')).toString('latin1'); + if (!/^HTTP\/1\.[01] 200/.test(statusLine)) { + fail(new Error(`proxy refused CONNECT: ${statusLine}`)); + return; + } + + // A compliant proxy sends nothing after the blank line, but push anything it did back for the TLS layer. + const leftover = buffer.subarray(headerEnd + 4); + if (leftover.length) socket.unshift(leftover); + + socket.removeListener('error', fail); + resolve(socket); + }; + socket.on('data', onData); + }); +} + +/** RFC 1928 SOCKS5 (+ RFC 1929 username/password) — what most local proxy clients expose. */ +async function socks5Connect(proxy, host, port) { + const socket = openToProxy(proxy); + + try { + await once(socket, 'connect'); + const { read, release } = reader(socket); + + const methods = proxy.username ? [SOCKS_NO_AUTH, SOCKS_USER_PASS] : [SOCKS_NO_AUTH]; + socket.write(Buffer.from([SOCKS_VERSION, methods.length, ...methods])); + + const greeting = await read(2); + if (greeting[0] !== SOCKS_VERSION) throw new Error('proxy is not SOCKS5'); + + if (greeting[1] === SOCKS_USER_PASS) { + const user = Buffer.from(proxy.username); + const pass = Buffer.from(proxy.password); + socket.write(Buffer.concat([ + Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass, + ])); + const auth = await read(2); + if (auth[1] !== 0x00) throw new Error('SOCKS5 proxy rejected the credentials'); + } else if (greeting[1] !== SOCKS_NO_AUTH) { + throw new Error('SOCKS5 proxy demands an unsupported authentication method'); + } + + // ATYP = domain, so the *proxy* resolves api.telegram.org — local DNS is filtered too. + const target = Buffer.from(host); + const request = Buffer.concat([ + Buffer.from([SOCKS_VERSION, SOCKS_CMD_CONNECT, 0x00, SOCKS_ATYP_DOMAIN, target.length]), + target, + Buffer.from([(port >> 8) & 0xff, port & 0xff]), + ]); + socket.write(request); + + const reply = await read(4); + if (reply[1] !== 0x00) throw new Error(`SOCKS5 proxy refused CONNECT (code ${reply[1]})`); + + // Drain the bound address so the stream starts at the tunnelled payload. + const boundLength = reply[3] === 0x01 ? 4 : reply[3] === 0x04 ? 16 : (await read(1))[0]; + await read(boundLength + 2); + + return release(); + } catch (error) { + socket.destroy(); + throw error; + } +} + +/** + * Reads exactly N bytes at a time during the handshake. `release()` hands the socket back with any + * already-buffered bytes pushed in front, so the TLS layer sees an untouched stream. + */ +function reader(socket) { + let buffer = Buffer.alloc(0); + let pending = null; + + const pump = () => { + if (!pending || buffer.length < pending.size) return; + const { size, resolve } = pending; + pending = null; + const chunk = buffer.subarray(0, size); + buffer = buffer.subarray(size); + resolve(chunk); + }; + + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + pump(); + }); + + const read = (size) => new Promise((resolve, reject) => { + pending = { size, resolve }; + socket.once('error', reject); + pump(); + }); + + const release = () => { + socket.removeAllListeners('data'); + socket.removeAllListeners('error'); + if (buffer.length) socket.unshift(buffer); + return socket; + }; + + return { read, release }; +} + +function once(emitter, event) { + return new Promise((resolve, reject) => { + emitter.once(event, resolve); + emitter.once('error', reject); + }); +} + +function withTimeout(promise, timeoutMs, message) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${message} within ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} diff --git a/telegram-otp-bot/src/server.js b/telegram-otp-bot/src/server.js index 1ddb8c3..feffee6 100644 --- a/telegram-otp-bot/src/server.js +++ b/telegram-otp-bot/src/server.js @@ -96,6 +96,7 @@ const routes = { if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' }); const shown = config.redactCodeInLogs ? '******' : code; + console.log(`sending OTP ${shown} for ${phone} → ${config.chatIds.length} recipients`); return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`); }, @@ -133,6 +134,7 @@ server.listen(config.port, config.host, async () => { console.log(`balinyaar telegram-otp-bot listening on http://${config.host}:${config.port}`); console.log(` recipients: ${config.chatIds.length ? config.chatIds.join(', ') : '(none — call GET /chat_ids to discover)'}`); console.log(' auth: X-Api-Key required on every route except GET /health'); + console.log(` proxy: ${config.proxy ? config.proxy.label : '(none — direct to api.telegram.org)'}`); try { const me = await getBotIdentity(); diff --git a/telegram-otp-bot/src/telegram.js b/telegram-otp-bot/src/telegram.js index ea6634e..a8a56fc 100644 --- a/telegram-otp-bot/src/telegram.js +++ b/telegram-otp-bot/src/telegram.js @@ -1,31 +1,64 @@ +import https from 'node:https'; +import tls from 'node:tls'; +import { Buffer } from 'node:buffer'; import { config } from './env.js'; +import { connectThroughProxy } from './proxy.js'; -const API_BASE = `https://api.telegram.org/bot${config.botToken}`; +const API_HOST = 'api.telegram.org'; async function callApi(method, payload, { timeoutMs = 10_000 } = {}) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const { status, text } = await postJson(`/bot${config.botToken}/${method}`, payload, timeoutMs); + let body = {}; try { - const response = await fetch(`${API_BASE}/${method}`, { + body = JSON.parse(text); + } catch { + // keep the empty body; the status check below reports it + } + + if (status < 200 || status >= 300 || body.ok !== true) { + // Telegram carries the real failure in `description` (e.g. "chat not found" when the + // recipient never messaged the bot first) — surface it verbatim so the cause is obvious. + throw new Error(body.description ?? `HTTP ${status}`); + } + + return body.result; +} + +/** + * One HTTPS POST to Telegram. Built on `node:https` rather than `fetch` so the optional proxy applies + * identically on every supported Node version: with a proxy configured the request rides a tunnelled socket + * (see `proxy.js`), without one it takes the default direct route. + */ +async function postJson(path, payload, timeoutMs) { + const body = JSON.stringify(payload); + const tunnel = config.proxy + ? await connectThroughProxy(config.proxy, API_HOST, 443, timeoutMs) + : null; + + return new Promise((resolve, reject) => { + const request = https.request({ + host: API_HOST, + port: 443, + path, method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(payload), - signal: controller.signal, + headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }, + // Only set when tunnelling: passing createConnection is what makes node skip the default agent. + ...(tunnel ? { createConnection: () => tls.connect({ socket: tunnel, servername: API_HOST }) } : {}), + }, (response) => { + let text = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { text += chunk; }); + response.on('end', () => resolve({ status: response.statusCode, text })); }); - const body = await response.json().catch(() => ({})); - if (!response.ok || body.ok !== true) { - // Telegram carries the real failure in `description` (e.g. "chat not found" when the - // recipient never messaged the bot first) — surface it verbatim so the cause is obvious. - const reason = body.description ?? `HTTP ${response.status}`; - throw new Error(reason); - } - - return body.result; - } finally { - clearTimeout(timer); - } + request.setTimeout(timeoutMs, () => request.destroy(new Error(`no response within ${timeoutMs}ms`))); + request.on('error', (error) => { + if (tunnel) tunnel.destroy(); + reject(error); + }); + request.end(body); + }); } /** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */