# 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.cs` — `SmsOptions` + `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`. - `ApiKey` — **the 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: ```jsonc // 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` + `ILogger`, `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`: ```csharp else if (Is(provider, SeamProviders.Telegram)) { services.AddHttpClient(HttpClients.Telegram, c => { /* BaseAddress + Timeout from options */ }); services.AddSingleton(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" ""`), 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.