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.cs—SmsOptions+SeamProviders.server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs→ theRegisterSmsmethod — 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 theX-Api-Keyheader. It is a secret: the committedappsettings*.jsoncarries an empty string or the repo'sSET_VIA_USER_SECRETS_OR_ENVplaceholder, never a real value. Real value via user-secrets / environment only.TimeoutSeconds— default10.
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_otpwith{"phone":…,"code":…}.SendAsync(phone, message, ct)→POST {BaseUrl}/sendwith{"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 throwInvalidOperationException, exactly asKavenegarSmsSenderdoes, soRequestOtpCommandreports a real send failure instead of silently "succeeding". A502from 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 namingSeams:Sms:Telegram:ApiKeyrather 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: addTelegramSmsSender(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 theAddDevelopmentOtpCapture()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>"), flipSeams:Sms:Providertotelegraminappsettings.Development.json, log in, read the code in Telegram. Link totelegram-otp-bot/README.mdfor 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:
SendOtpAsyncposts to/send_otpwith the right body and theX-Api-Keyheader.- A
502/{"ok":false}response throws. - The OTP code never appears in the log output.
- Registration:
Provider = "mock"(and an unset provider) still resolvesLoggingSmsSender;Provider = "telegram"resolvesTelegramSmsSender. 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 —
RequestOtpCommanddepends onISmsSenderand must stay untouched. - No new NuGet packages; versions are centrally pinned in
Directory.Packages.propsregardless. - Follow
server/CONVENTIONS.md:sealedclasses, 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) anddotnet test Baya.sln(all green), and report the actual output.