integrate telegram bot
This commit is contained in:
@@ -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" "<the relay's API_KEY>"
|
||||
```
|
||||
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
|
||||
|
||||
+11
-4
@@ -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 →
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+91
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISmsSender"/> over the standalone <b>Telegram OTP relay</b> (<c>telegram-otp-bot/</c>), selected by
|
||||
/// <c>Seams:Sms:Provider = telegram</c>. Its two POST routes mirror this contract one-for-one, so each method is a
|
||||
/// single JSON call: <c>POST /send_otp</c> and <c>POST /send</c>, authenticated with the shared
|
||||
/// <c>X-Api-Key</c> secret.
|
||||
///
|
||||
/// <para><b>Development only.</b> 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 <see cref="TelegramOptions"/>).</para>
|
||||
///
|
||||
/// <para><b>The OTP is never logged</b> — only the phone tail and the relay's HTTP outcome, exactly like
|
||||
/// <see cref="KavenegarSmsSender"/>. A non-2xx (the relay answers <c>502</c> when <i>no</i> recipient got the
|
||||
/// message) or an <c>ok != true</c> body is a delivery failure and throws, so <c>RequestOtpCommand</c> reports a
|
||||
/// real send failure instead of silently "succeeding" on an undelivered code.</para>
|
||||
/// </summary>
|
||||
public sealed class TelegramSmsSender(
|
||||
HttpClient httpClient,
|
||||
IOptions<SeamOptions> options,
|
||||
ILogger<TelegramSmsSender> logger) : ISmsSender
|
||||
{
|
||||
/// <summary>The repo's committed stand-in for a secret — treated as "not configured".</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>A 2xx still carries the per-recipient outcome in <c>ok</c>; anything but <c>true</c> is a failure.</summary>
|
||||
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)..];
|
||||
}
|
||||
@@ -63,6 +63,10 @@ public static class SeamProviders
|
||||
public const string SmsIr = "smsir";
|
||||
public const string Ghasedak = "ghasedak";
|
||||
|
||||
/// <summary><b>Development convenience channel, not an SMS gateway</b> — the local <c>telegram-otp-bot/</c>
|
||||
/// relay broadcasts every code to a fixed list of Telegram chat ids. Never select it in a real environment.</summary>
|
||||
public const string Telegram = "telegram";
|
||||
|
||||
// Object storage
|
||||
public const string S3 = "s3";
|
||||
|
||||
@@ -89,12 +93,16 @@ public static class SeamProviders
|
||||
/// <summary>
|
||||
/// The outbound SMS rail (<c>ISmsSender</c>). <see cref="Provider"/> = <c>mock</c> logs the OTP (the b2
|
||||
/// <c>LoggingSmsSender</c>); set it to <c>kavenegar</c> / <c>smsir</c> / <c>ghasedak</c> to deliver over a real
|
||||
/// Iranian gateway. <b>refinement-phase-8:</b> when a real provider is selected the Development OTP-in-logs/echo
|
||||
/// Iranian gateway. <b>refinement-phase-8:</b> 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.
|
||||
///
|
||||
/// <para><c>telegram</c> is the one exception: it is a <b>Development convenience channel, not an SMS gateway</b>
|
||||
/// (see <see cref="TelegramOptions"/>), so the OTP-capture bridge stays enabled alongside it.</para>
|
||||
/// </summary>
|
||||
public sealed class SmsOptions
|
||||
{
|
||||
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c>.</summary>
|
||||
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c> | <c>telegram</c>
|
||||
/// (Development only).</summary>
|
||||
public string Provider { get; set; } = SeamProviders.Mock;
|
||||
|
||||
/// <summary>Gateway API key / token (secret — user-secrets or environment, never committed).</summary>
|
||||
@@ -108,6 +116,33 @@ public sealed class SmsOptions
|
||||
|
||||
/// <summary>The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).</summary>
|
||||
public string OtpTemplate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Connection facts for the <c>telegram</c> relay; ignored by every other provider.</summary>
|
||||
public TelegramOptions Telegram { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <b>Development-only</b> Telegram OTP relay (the standalone <c>telegram-otp-bot/</c> Node service),
|
||||
/// selected by <c>Seams:Sms:Provider = telegram</c>. 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.
|
||||
///
|
||||
/// <para><b>It is not an SMS gateway.</b> There is no per-user routing: the relay <i>broadcasts</i> 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.</para>
|
||||
/// </summary>
|
||||
public sealed class TelegramOptions
|
||||
{
|
||||
/// <summary>The relay's root URL, e.g. <c>http://127.0.0.1:5010</c>.</summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The shared secret sent as the relay's <c>X-Api-Key</c> header — it must equal the relay's
|
||||
/// <c>API_KEY</c>. <b>Secret:</b> committed config carries an empty/placeholder value; the real one comes
|
||||
/// from user-secrets (<c>Seams:Sms:Telegram:ApiKey</c>) or the environment, never git.</summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+25
-10
@@ -8,27 +8,42 @@ public static class DevelopmentSeamExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Development-only wiring for the OTP bring-up bridge. Registers <see cref="DevOtpStore"/> and decorates
|
||||
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> (the log-only
|
||||
/// <see cref="LoggingSmsSender"/>) with <see cref="DevCapturingSmsSender"/>, so each OTP is also captured
|
||||
/// in memory for <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
|
||||
/// <c>builder.Environment.IsDevelopment()</c>: nothing here is wired in any other environment, which —
|
||||
/// together with the endpoint's own <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable
|
||||
/// outside Development. Superseded by the real SMS gateway in refinement Phase 8.
|
||||
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> with
|
||||
/// <see cref="DevCapturingSmsSender"/>, so each OTP is also captured in memory for
|
||||
/// <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
|
||||
/// <c>builder.Environment.IsDevelopment()</c>, and only for a capture-safe provider (the log-only
|
||||
/// <see cref="LoggingSmsSender"/> or the Development-only Telegram relay — <c>Program.cs</c> owns that
|
||||
/// condition): nothing here is wired in any other environment, which — together with the endpoint's own
|
||||
/// <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable outside Development.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<DevOtpStore>();
|
||||
|
||||
// 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<ISmsSender>(sp => new DevCapturingSmsSender(
|
||||
ActivatorUtilities.CreateInstance<LoggingSmsSender>(sp),
|
||||
ResolveSender(sp, inner),
|
||||
sp.GetRequiredService<DevOtpStore>()));
|
||||
|
||||
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."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Development/Testing-only re-registration of the real <see cref="MockPaymentCaptureSimulator"/> over the
|
||||
/// production <see cref="DisabledPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). The <c>bookings/convert</c>
|
||||
|
||||
+18
-2
@@ -87,16 +87,31 @@ public static class ServiceCollectionExtension
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
|
||||
sp.GetRequiredService<ILogger<KavenegarSmsSender>>()));
|
||||
}
|
||||
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<ISmsSender>(sp => new TelegramSmsSender(
|
||||
Client(sp, HttpClients.Telegram),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
|
||||
sp.GetRequiredService<ILogger<TelegramSmsSender>>()));
|
||||
}
|
||||
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<ISmsSender, LoggingSmsSender>();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The Telegram OTP relay must be <b>invisible unless opted in</b>: an environment that configures no SMS
|
||||
/// provider — or leaves the default <c>mock</c> — resolves the log-only sender exactly as it did before the
|
||||
/// channel existed. Only <c>Seams:Sms:Provider = telegram</c> swaps it in.
|
||||
/// </summary>
|
||||
public class SmsProviderRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void UnsetProvider_ResolvesTheLogOnlyMock()
|
||||
=> Assert.IsType<LoggingSmsSender>(Resolve(null));
|
||||
|
||||
[Fact]
|
||||
public void MockProvider_ResolvesTheLogOnlyMock()
|
||||
=> Assert.IsType<LoggingSmsSender>(Resolve("mock"));
|
||||
|
||||
[Fact]
|
||||
public void UnknownProvider_FallsClosedToTheLogOnlyMock()
|
||||
=> Assert.IsType<LoggingSmsSender>(Resolve("telegramm"));
|
||||
|
||||
[Fact]
|
||||
public void TelegramProvider_ResolvesTheRelayAdapter()
|
||||
=> Assert.IsType<TelegramSmsSender>(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<ISmsSender>();
|
||||
Assert.IsType<DevCapturingSmsSender>(sender);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => 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<DevOtpStore>().GetLatest("09120000001"));
|
||||
}
|
||||
|
||||
private static ISmsSender Resolve(string? provider)
|
||||
=> Services(provider, "0123456789abcdef0123456789abcdef").BuildServiceProvider().GetRequiredService<ISmsSender>();
|
||||
|
||||
private static ServiceCollection Services(string? provider, string? apiKey)
|
||||
{
|
||||
var settings = new Dictionary<string, string?>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The Development-only Telegram OTP relay adapter: it must speak the relay's contract exactly (routes, body
|
||||
/// keys, <c>X-Api-Key</c>), treat an undelivered message as a failure rather than a silent success, and never
|
||||
/// put the OTP code in a log line.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuccessStatusWithNotOkBody_Throws()
|
||||
{
|
||||
var (sender, _, _) = Build(HttpStatusCode.OK, """{"ok":false}""");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnconfiguredApiKey_ThrowsNamingTheConfigKey()
|
||||
{
|
||||
var (sender, handler, _) = Build(apiKey: "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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);
|
||||
|
||||
/// <summary>Answers every call from memory — the adapter is exercised with no network at all.</summary>
|
||||
private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||
{
|
||||
public List<CapturedRequest> Requests { get; } = [];
|
||||
|
||||
protected override async Task<HttpResponseMessage> 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<TelegramSmsSender>
|
||||
{
|
||||
public List<string> Lines { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> Lines.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:<published-port>`.
|
||||
- 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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user