# 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<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`:

```csharp
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.
