integrate telegram bot
This commit is contained in:
+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";
|
||||
|
||||
Reference in New Issue
Block a user