Files
2026-07-27 23:58:16 +03:30

8.1 KiB

Prompt — wire the Telegram OTP relay into the .NET API

Paste everything below into a fresh Claude Code session opened at the repo root.


Implement a Development-only Telegram OTP delivery channel on the server, behind the existing ISmsSender seam. It must be opt-in from appsettings — an environment that does not configure it behaves byte-for-byte as it does today.

Context you should read first

  • server/CLAUDE.md → the "External rails go real — config-selected vendor adapters" paragraph and the "Startup wiring" section.
  • server/src/Core/Baya.Application/Contracts/Common/ISmsSender.cs — the contract (SendOtpAsync, SendAsync).
  • server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/KavenegarSmsSender.cs — the shape every real SMS adapter follows. Mirror it.
  • server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.csSmsOptions + SeamProviders.
  • server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs → the RegisterSms method — the config-selected registration pattern.
  • telegram-otp-bot/README.md — the relay's HTTP contract (it already exists and runs; do not modify it).

What the relay already is

A standalone zero-dependency Node service at telegram-otp-bot/ (its own project — not part of client/ or server/). It forwards messages to a fixed list of Telegram chat ids. Contract:

Route Auth Request Success Failure
POST /send_otp X-Api-Key header {"phone":"...","code":"..."} 200 {"ok":true,"delivered":[...],"failed":[...]} 502 when no recipient got it; 503 no recipients configured; 401 bad key; 400 bad body
POST /send X-Api-Key header {"phone":"...","message":"..."} same same
GET /health none 200 {"ok":true}

It is a broadcast, not per-user routing: every configured Telegram recipient receives every code, regardless of which phone requested it. That is exactly why this is Development-only.

Requirements

1. Config surface — opt-in, default off

Add to SmsOptions (SeamOptions.cs) a nested TelegramOptions Telegram { get; set; } = new(); with:

  • BaseUrl — the relay root, e.g. http://127.0.0.1:5010.
  • ApiKeythe shared secret sent as the X-Api-Key header. It is a secret: the committed appsettings*.json carries an empty string or the repo's SET_VIA_USER_SECRETS_OR_ENV placeholder, never a real value. Real value via user-secrets / environment only.
  • TimeoutSeconds — default 10.

Add public const string Telegram = "telegram"; to SeamProviders. Document on the options class, in the SmsOptions XML doc, and in the SeamProviders SMS-gateway group that telegram is a Development convenience channel, not an SMS gateway.

The feature is selected exactly like every other rail:

// server/src/API/Baya.Web.Api/appsettings.Development.json
"Seams": {
  "Sms": {
    "Provider": "telegram",
    "Telegram": {
      "BaseUrl": "http://127.0.0.1:5010",
      "ApiKey": "",                    // real value via user-secrets: Seams:Sms:Telegram:ApiKey
      "TimeoutSeconds": 10
    }
  }
}

Default must stay mock. Do not change the committed Provider value in any shared appsettings.json — document the opt-in instead (see §5). An unconfigured environment must resolve LoggingSmsSender exactly as it does now.

2. The adapter

New TelegramSmsSender : ISmsSender in server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/, following KavenegarSmsSender's shape — primary-constructor injection of HttpClient + IOptions<SeamOptions> + ILogger<T>, System.Text.Json, no new NuGet package.

  • SendOtpAsync(phone, code, ct)POST {BaseUrl}/send_otp with {"phone":…,"code":…}.
  • SendAsync(phone, message, ct)POST {BaseUrl}/send with {"phone":…,"message":…}.
  • Send X-Api-Key: {ApiKey} on every request.
  • Snake-case JSON body keys (phone, code, message) — the relay reads exactly those names.
  • Non-2xx, or a body with ok != true, is a delivery failure — log a warning and throw InvalidOperationException, exactly as KavenegarSmsSender does, so RequestOtpCommand reports a real send failure instead of silently "succeeding". A 502 from the relay means nobody received the code and must not be swallowed.
  • Never log the OTP code. Log the phone tail only — reuse Kavenegar's Tail(phone) helper approach.
  • Guard against an unconfigured ApiKey: throw a clear startup/first-call error naming Seams:Sms:Telegram:ApiKey rather than sending an unauthenticated request that the relay will 401.

3. Registration

In ServiceCollectionExtension.RegisterSms, add a branch before the smsir/ghasedak NotSupportedException:

else if (Is(provider, SeamProviders.Telegram))
{
    services.AddHttpClient(HttpClients.Telegram, c => { /* BaseAddress + Timeout from options */ });
    services.AddSingleton<ISmsSender>(sp => new TelegramSmsSender(...));
}

Add the HttpClients.Telegram named-client constant alongside the existing ones. Follow the file's own Is(...) / BaseOrDefault(...) / Client(sp, name) helpers — do not invent a parallel style.

4. Keep the /dev/last_otp bridge working

Program.cs currently disables the Development OTP-capture decorator whenever a non-mock SMS provider is selected (usingMockSms), because a real gateway must never have the code logged/captured. Telegram is the exception: it is a Development-only channel, and the GET /api/v1/dev/last_otp/{phone} helper and its e2e tests should keep working alongside it.

Widen that condition to allow the capture bridge for mock or telegram (keep it strictly disallowed for kavenegar and any future real gateway), and update the explanatory comment above it to say why — the current comment asserts the bridge is off for every non-mock provider, and that will become wrong. Rename the local to something accurate (e.g. otpCaptureAllowedProvider). The IsDevelopment() guard stays.

5. Docs — same change, non-negotiable

  • server/CLAUDE.md → the "External rails go real" paragraph: add TelegramSmsSender (Sms:Provider=telegram) to the adapter list, flagged Development-only, broadcast, not a gateway, and note that it is the one non-mock SMS provider that keeps the OTP-capture bridge enabled.
  • server/CLAUDE.md → "Startup wiring": update the AddDevelopmentOtpCapture() comment to match the new condition.
  • dev/post-phase/refinement/RUNBOOK.md → a short "OTP over Telegram" subsection: start the relay (cd telegram-otp-bot && npm start), set the same secret on both sides (dotnet user-secrets set "Seams:Sms:Telegram:ApiKey" "<value>"), flip Seams:Sms:Provider to telegram in appsettings.Development.json, log in, read the code in Telegram. Link to telegram-otp-bot/README.md for bot creation and chat-id discovery.

6. Tests

Add unit tests for TelegramSmsSender next to the existing seam tests (Baya.Test.Foundation), using a stubbed HttpMessageHandler — no network:

  • SendOtpAsync posts to /send_otp with the right body and the X-Api-Key header.
  • A 502 / {"ok":false} response throws.
  • The OTP code never appears in the log output.
  • Registration: Provider = "mock" (and an unset provider) still resolves LoggingSmsSender; Provider = "telegram" resolves TelegramSmsSender. This is the regression that matters — the feature must be invisible unless opted in.

Constraints

  • Server-side only. Do not touch client/.
  • No handler changes — RequestOtpCommand depends on ISmsSender and must stay untouched.
  • No new NuGet packages; versions are centrally pinned in Directory.Packages.props regardless.
  • Follow server/CONVENTIONS.md: sealed classes, no unused usings/locals, why-comments only.
  • Never commit the API key or the bot token.
  • Finish with dotnet build Baya.sln (zero new warnings) and dotnet test Baya.sln (all green), and report the actual output.