From dd5d89caa8d34ac3bcefa6d05e91af13e5288500 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Tue, 16 Dec 2025 06:53:02 +0100 Subject: [PATCH 1/8] Redesign lightning backend infrastructure Support multiple lightning backends, selected / configured via appsettings.json. --- src/Handlers/UpdateHandler.DirectMessage.cs | 14 +-- src/Handlers/UpdateHandler.cs | 7 +- src/Helper/MessageHelper.Winner.cs | 5 +- src/Program.cs | 22 +++- .../Backends/Backend.AlbyHubService.cs | 0 .../Backend.LnbitsService.cs} | 79 +++++++------- src/Services/Backends/Backend.cs | 102 ++++++++++++++++++ src/Services/PaymentMonitorService.cs | 15 +-- src/Services/RecoveryService.cs | 7 +- src/appsettings.json | 9 +- 10 files changed, 194 insertions(+), 66 deletions(-) create mode 100644 src/Services/Backends/Backend.AlbyHubService.cs rename src/Services/{LnbitsService.cs => Backends/Backend.LnbitsService.cs} (75%) create mode 100644 src/Services/Backends/Backend.cs diff --git a/src/Handlers/UpdateHandler.DirectMessage.cs b/src/Handlers/UpdateHandler.DirectMessage.cs index 1388d2b..11a7df8 100644 --- a/src/Handlers/UpdateHandler.DirectMessage.cs +++ b/src/Handlers/UpdateHandler.DirectMessage.cs @@ -168,7 +168,7 @@ private async Task ProcessPrivatePaymentAsync(ITelegramBotClient botClient, Sess try { // TODO: pass the enum here, not a currency string! - var invoice = await lnbitsService.CreateInvoiceAsync(invoiceAmount, unit, memo, cancellationToken).ConfigureAwait(false); + var invoice = await lightningBackend.CreateInvoiceAsync(invoiceAmount, unit, memo, cancellationToken).ConfigureAwait(false); // Store as pending payment var pending = new PendingPayment { @@ -208,7 +208,7 @@ private async Task ProcessWinnerInvoiceAsync(ITelegramBotClient botClient, Sessi var winnerInfo = session.Winners[winnerUser.UserId]; // Decode and validate the invoice amount - var decodedInvoice = await lnbitsService.DecodeInvoiceAsync(bolt11, cancellationToken); + var decodedInvoice = await lightningBackend.DecodeInvoiceAsync(bolt11, cancellationToken); if (decodedInvoice is null) throw new ArgumentException("Invalid invoice! Please provide a valid Lightning invoice."); @@ -220,7 +220,7 @@ private async Task ProcessWinnerInvoiceAsync(ITelegramBotClient botClient, Sessi try { - var paymentResult = await lnbitsService.PayInvoiceAsync(bolt11!, cancellationToken); + var paymentResult = await lightningBackend.PayInvoiceAsync(bolt11!, cancellationToken); if (paymentResult is not null) { if (session.PayoutCompleted) @@ -342,8 +342,8 @@ private async Task HandleDiagnosisAsync(ITelegramBotClient botClient, CommandMes // Lightning backend Information diagnostics.AppendLine("\n⚡ *Lightning backend status:*"); - diagnostics.AppendLine($"• Node type: *{lnbitsService.ServiceType}*"); - diagnostics.AppendLine($"• Sent requests: *{lnbitsService.SentRequests}*"); + diagnostics.AppendLine($"• Backend type: *{lightningBackend.BackendType}*"); + diagnostics.AppendLine($"• Sent requests: *{lightningBackend.SentRequests}*"); await botClient.SendMessage(command.ChatId, diagnostics.ToString(), @@ -391,7 +391,7 @@ private async Task ProcessRecoveryInvoiceAsync(ITelegramBotClient botClient, Use var expectedSats = lostSats.SatsAmount; // Decode and validate the invoice - var decodedInvoice = await lnbitsService.DecodeInvoiceAsync(bolt11, cancellationToken); + var decodedInvoice = await lightningBackend.DecodeInvoiceAsync(bolt11, cancellationToken); if (decodedInvoice is null) throw new ArgumentException("Invalid Lightning invoice!\n\n" + "Please provide a valid Lightning invoice for your recovery.") @@ -404,7 +404,7 @@ await botClient.SendMessage(user.Id, cancellationToken: cancellationToken); // Attempt to pay the invoice - var paymentResult = await lnbitsService.PayInvoiceAsync(bolt11, cancellationToken); + var paymentResult = await lightningBackend.PayInvoiceAsync(bolt11, cancellationToken); if (paymentResult is not null) { await recoveryService.ClearLostSatsAsync(user.Id); diff --git a/src/Handlers/UpdateHandler.cs b/src/Handlers/UpdateHandler.cs index 700fdf2..5f2bd1e 100644 --- a/src/Handlers/UpdateHandler.cs +++ b/src/Handlers/UpdateHandler.cs @@ -1,6 +1,7 @@ using System.Text; using teamZaps.Configuration; using teamZaps.Services; +using teamZaps.Services.Backends; using teamZaps.Sessions; using teamZaps.Utils; using Telegram.Bot.Types.ReplyMarkups; @@ -9,14 +10,14 @@ namespace teamZaps.Handlers; public partial class UpdateHandler : IUpdateHandler { - public UpdateHandler(ILogger logger, IOptions botBehaviour, IOptions debugSettings, IOptions telegramSettings, IHostEnvironment hostEnvironment, LnbitsService lnbitsService, SessionManager sessionManager, SessionWorkflowService workflowService, RecoveryService recoveryService) + public UpdateHandler(ILogger logger, IOptions botBehaviour, IOptions debugSettings, IOptions telegramSettings, IHostEnvironment hostEnvironment, ILightningBackend lightningBackend, SessionManager sessionManager, SessionWorkflowService workflowService, RecoveryService recoveryService) { this.logger = logger; this.debugSettings = debugSettings.Value; this.botBehaviour = botBehaviour.Value; this.telegramSettings = telegramSettings.Value; this.hostEnvironment = hostEnvironment; - this.lnbitsService = lnbitsService; + this.lightningBackend = lightningBackend; this.sessionManager = sessionManager; this.workflowService = workflowService; this.recoveryService = recoveryService; @@ -229,7 +230,7 @@ private async Task IsUserAdminAsync(ITelegramBotClient botClient, long cha private readonly BotBehaviorOptions botBehaviour; private readonly TelegramSettings telegramSettings; private readonly IHostEnvironment hostEnvironment; - private readonly LnbitsService lnbitsService; + private readonly ILightningBackend lightningBackend; private readonly SessionManager sessionManager; private readonly SessionWorkflowService workflowService; private readonly RecoveryService recoveryService; diff --git a/src/Helper/MessageHelper.Winner.cs b/src/Helper/MessageHelper.Winner.cs index 6602b05..01daa49 100644 --- a/src/Helper/MessageHelper.Winner.cs +++ b/src/Helper/MessageHelper.Winner.cs @@ -3,6 +3,7 @@ using System.Text; using Microsoft.Extensions.Logging; using teamZaps.Services; +using teamZaps.Services.Backends; using teamZaps.Utils; namespace teamZaps.Sessions; @@ -20,7 +21,7 @@ public static async Task SendAsync(SessionState session, ITelegramBotCl session.WinnerMessageId = message.MessageId; return message; } - public static async Task UpdateAsync(SessionState session, PaymentStatus status, LnbitsPaymentResponse? paymentResult, ITelegramBotClient botClient, SessionWorkflowService workflowService, ILogger logger, CancellationToken cancellationToken) + public static async Task UpdateAsync(SessionState session, PaymentStatus status, IPaymentResponse? paymentResult, ITelegramBotClient botClient, SessionWorkflowService workflowService, ILogger logger, CancellationToken cancellationToken) { if (session.WinnerMessageId is null) return; @@ -49,7 +50,7 @@ await botClient.EditMessageText( logger.LogWarning(ex, "Failed to update winner message for session {Session}", session); } } - private static string Build(SessionState session, SessionWorkflowService workflowService, PaymentStatus status, LnbitsPaymentResponse? paymentResult = null) + private static string Build(SessionState session, SessionWorkflowService workflowService, PaymentStatus status, IPaymentResponse? paymentResult = null) { if (session.Winners.Count == 0) throw new InvalidOperationException("No winners available"); diff --git a/src/Program.cs b/src/Program.cs index 44613b5..87b46c5 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,6 +1,7 @@ using teamZaps.Configuration; using teamZaps.Handlers; using teamZaps.Services; +using teamZaps.Services.Backends; using teamZaps.Sessions; namespace teamZaps; @@ -53,7 +54,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { services.Configure(hostContext.Configuration.GetSection(BotBehaviorOptions.SectionName)); services.Configure(hostContext.Configuration.GetSection(TelegramSettings.SectionName)); - services.Configure(hostContext.Configuration.GetSection(LnbitsSettings.SectionName)); + services.Configure(hostContext.Configuration.GetSection("Lightning")); services.Configure(hostContext.Configuration.GetSection(DebugSettings.SectionName)); services.AddHostedService(); @@ -67,8 +68,25 @@ public static IHostBuilder CreateHostBuilder(string[] args) => throw new InvalidOperationException("Telegram bot token is not configured."); return (new TelegramBotClient(settings.BotToken)); }); + + // Register Lightning backend based on configuration + // > Select first configured backend: + var backendType = hostContext.Configuration + .GetSection("Lightning") + .GetChildren() + .Select(c => c.Key) + .FirstOrDefault(); + if (string.IsNullOrWhiteSpace(backendType)) + throw new InvalidOperationException("No lightning backend configured!"); + if (!Common.BackendTypes.TryGetValue(backendType.ToLowerInvariant(), out var lightningBackend)) + throw new NotSupportedException($"Unknown lightning backend '{backendType}' configured!"); + else + { + services.AddSingleton(typeof(ILightningBackend), lightningBackend); + Log.Information($"Using '{backendType}' as lightning backend"); + } + services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Services/Backends/Backend.AlbyHubService.cs b/src/Services/Backends/Backend.AlbyHubService.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/Services/LnbitsService.cs b/src/Services/Backends/Backend.LnbitsService.cs similarity index 75% rename from src/Services/LnbitsService.cs rename to src/Services/Backends/Backend.LnbitsService.cs index d583250..c054115 100644 --- a/src/Services/LnbitsService.cs +++ b/src/Services/Backends/Backend.LnbitsService.cs @@ -4,10 +4,12 @@ using System.Text.Json; using System.Text.Json.Serialization; using teamZaps.Configuration; +using teamZaps.Services.Backends; -namespace teamZaps.Services; +namespace teamZaps.Services.Backends; -public class LnbitsService +[BackendDescription("LNBits")] +public class LnbitsService : ILightningBackend { public LnbitsService(ILogger logger, IOptions settings) { @@ -21,9 +23,6 @@ public LnbitsService(ILogger logger, IOptions set } - #region Properties.Management - public string ServiceType => "LNBits"; - #endregion #region Properties /// /// Total number of requests sent to the LNbits server. @@ -48,32 +47,32 @@ public LnbitsService(ILogger logger, IOptions set return (null); } } - public Task CreateInvoiceAsync(double amount, string unitName, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new + public Task CreateInvoiceAsync(double amount, string unitName, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new { amount = amount, unit = unitName, memo = memo ?? "", @out = false }, cancellationToken); - public Task CreateInvoiceAsync(long amount, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new + public Task CreateInvoiceAsync(long amount, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new { amount = amount, memo = memo ?? "", @out = false }, cancellationToken); - private Task CreateInvoiceAsync(object invoiceRequest, CancellationToken cancellationToken) + private async Task CreateInvoiceAsync(object invoiceRequest, CancellationToken cancellationToken) { try { - return (RequestAsync(HttpMethod.Post, "/api/v1/payments", invoiceRequest, cancellationToken)); + return (await RequestAsync(HttpMethod.Post, "/api/v1/payments", invoiceRequest, cancellationToken)); } catch (Exception ex) { logger.LogError(ex, "Error creating invoice."); - return (Task.FromResult(null)); + return (null); } } - public async Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) + public async Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) { try { @@ -90,7 +89,7 @@ public LnbitsService(ILogger logger, IOptions set } } - public async Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) + public async Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) { try { @@ -112,7 +111,7 @@ public LnbitsService(ILogger logger, IOptions set return (null); } } - public async Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default) + public async Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default) { try { @@ -167,8 +166,7 @@ public class LnbitsWalletDetails [JsonPropertyName("balance")] public long Balance { get; set; } } - -public class LnbitsInvoice +file class LnbitsInvoice : ILightningInvoice { [JsonPropertyName("payment_request")] public string PaymentRequest { get; set; } = string.Empty; @@ -176,8 +174,7 @@ public class LnbitsInvoice [JsonPropertyName("payment_hash")] public string PaymentHash { get; set; } = string.Empty; } - -public class LnbitsPaymentResponse +file class LnbitsPaymentResponse : IPaymentResponse { [JsonPropertyName("payment_hash")] public string PaymentHash { get; set; } = string.Empty; @@ -200,9 +197,33 @@ public class LnbitsPaymentResponse [JsonPropertyName("memo")] public string? Memo { get; set; } } - -public class LnbitsPaymentStatus +file class LnbitsPaymentStatus : IPaymentStatus { + public class LnbitsPaymentDetails + { + public class LnbitsPaymentExtra + { + [JsonPropertyName("fiat_amount")] + public double FiatAmount { get; set; } + [JsonPropertyName("fiat_currency")] + public string? FiatCurrency { get; set; } + [JsonPropertyName("fiat_rate")] + public double FiatRate { get; set; } + } + + + [JsonPropertyName("amount")] + public long Amount { get; set; } + + [JsonPropertyName("extra")] + public LnbitsPaymentExtra? Extra { get; set; } + } + + + public long SatsAmount => Details?.Amount ?? 0; + public double FiatAmount => Details?.Extra?.FiatAmount ?? 0; + public double FiatRate => Details?.Extra?.FiatRate ?? 0; + [JsonPropertyName("paid")] public bool Paid { get; set; } @@ -212,25 +233,7 @@ public class LnbitsPaymentStatus [JsonPropertyName("preimage")] public string? Preimage { get; set; } } -public class LnbitsPaymentDetails -{ - [JsonPropertyName("amount")] - public long Amount { get; set; } - - [JsonPropertyName("extra")] - public LnbitsPaymentExtra? Extra { get; set; } -} -public class LnbitsPaymentExtra -{ - [JsonPropertyName("fiat_amount")] - public double FiatAmount { get; set; } - [JsonPropertyName("fiat_currency")] - public string? FiatCurrency { get; set; } - [JsonPropertyName("fiat_rate")] - public double FiatRate { get; set; } -} - -public class LnbitsDecodedInvoice +file class LnbitsDecodedInvoice : IDecodedInvoice { [JsonPropertyName("amount_msat")] public long Amount { get; set; } diff --git a/src/Services/Backends/Backend.cs b/src/Services/Backends/Backend.cs new file mode 100644 index 0000000..d3a8f99 --- /dev/null +++ b/src/Services/Backends/Backend.cs @@ -0,0 +1,102 @@ +using teamZaps.Utils; + +namespace teamZaps.Services.Backends; + +public static partial class Common +{ + /// + /// Map of available backend types. + /// + public static readonly IReadOnlyDictionary BackendTypes = UtilAssembly + .GetDefinedTypeMap() + .ToDictionary(t => t.Value.BackendType.ToLowerInvariant(), t => t.Key); +} + +/// +/// Descriptor for Lightning wallet backend services. +/// +[AttributeUsage(AttributeTargets.Class)] +public class BackendDescriptionAttribute(string backendType) : Attribute +{ + public string BackendType { get; } = backendType; +} +/// +/// Interface for Lightning wallet backend services. +/// +public interface ILightningBackend +{ + #region Properties + /// + /// Typename of the backend service. + /// + string BackendType => Common.BackendTypes.GetKeyOf(this.GetType()); + + /// + /// Total number of requests sent to the backend. + /// + ulong SentRequests { get; } + #endregion + + + #region Operation.Invoice + /// + /// Create a Lightning invoice. + /// + Task CreateInvoiceAsync(double amount, string currency, string? memo = null, CancellationToken cancellationToken = default); + /// + /// Decode a BOLT11 Lightning invoice to extract payment details. + /// + Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default); + /// + /// Pay a BOLT11 Lightning invoice. + /// + Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default); + /// + /// Check the payment status of an invoice by payment hash. + /// + Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default); + #endregion +} + + +#region Models.CommonData +/// +/// Lightning invoice details (BOLT11 payment request). +/// +public interface ILightningInvoice +{ + string PaymentRequest { get; } + string PaymentHash { get; } +} + +/// +/// Decoded Lightning invoice information. +/// +public interface IDecodedInvoice +{ + long Amount { get; } + string? Description { get; } + string? PaymentHash { get; } +} + +/// +/// Payment response after paying an invoice. +/// +public interface IPaymentResponse +{ + string PaymentHash { get; } + long Amount { get; } + long Fee { get; } +} + +/// +/// Payment status check result. +/// +public interface IPaymentStatus +{ + bool Paid { get; } + long SatsAmount { get; } + double FiatAmount { get; } + double FiatRate { get; } +} +#endregion diff --git a/src/Services/PaymentMonitorService.cs b/src/Services/PaymentMonitorService.cs index dec0097..8c9c57f 100644 --- a/src/Services/PaymentMonitorService.cs +++ b/src/Services/PaymentMonitorService.cs @@ -1,15 +1,16 @@ using System.Diagnostics; using teamZaps.Configuration; using teamZaps.Services; +using teamZaps.Services.Backends; namespace teamZaps.Sessions; public class PaymentMonitorService : BackgroundService { - public PaymentMonitorService(SessionManager sessionManager, LnbitsService lnbitsService, ITelegramBotClient botClient, ILogger logger, SessionWorkflowService workflowService, RecoveryService recoveryService) + public PaymentMonitorService(SessionManager sessionManager, ILightningBackend lightningBackend, ITelegramBotClient botClient, ILogger logger, SessionWorkflowService workflowService, RecoveryService recoveryService) { this.sessionManager = sessionManager; - this.lnbitsService = lnbitsService; + this.lightningBackend = lightningBackend; this.botClient = botClient; this.logger = logger; this.workflowService = workflowService; @@ -42,14 +43,14 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken { try { - var status = await lnbitsService.CheckPaymentStatusAsync(pending.PaymentHash, cancellationToken).ConfigureAwait(false); + var status = await lightningBackend.CheckPaymentStatusAsync(pending.PaymentHash, cancellationToken).ConfigureAwait(false); if (status is null) continue; if (status.Paid && !pending.NotifiedPaid) { #if DEBUG var expectedAmount = ((ITipableAmount)pending).TotalFiatAmount; - var actualAmount = status.Details!.Extra!.FiatAmount; + var actualAmount = status!.FiatAmount; var tolerance = Math.Max(0.01, expectedAmount * 0.01); // Allow 1% tolerance, minimum 1 cent Debug.Assert(Math.Abs(expectedAmount - actualAmount) <= tolerance); Debug.Assert(pending.Currency == BotBehaviorOptions.AcceptedFiatCurrency); @@ -68,10 +69,10 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken PaymentRequest = pending.PaymentRequest, Timestamp = pending.PaidAt ?? DateTimeOffset.UtcNow, Tokens = pending.Tokens, - SatsAmount = status.Details!.Amount, + SatsAmount = status!.SatsAmount, FiatAmount = pending.FiatAmount, TipAmount = pending.TipAmount, - FiatRate = status.Details!.Extra!.FiatRate + FiatRate = status!.FiatRate }; session.PendingPayments.TryRemove(pending.PaymentHash, out _); @@ -112,7 +113,7 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken private readonly SessionManager sessionManager; - private readonly LnbitsService lnbitsService; + private readonly ILightningBackend lightningBackend; private readonly ITelegramBotClient botClient; private readonly ILogger logger; private readonly SessionWorkflowService workflowService; diff --git a/src/Services/RecoveryService.cs b/src/Services/RecoveryService.cs index de2aa31..11a24d7 100644 --- a/src/Services/RecoveryService.cs +++ b/src/Services/RecoveryService.cs @@ -6,6 +6,7 @@ using teamZaps.Sessions; using teamZaps.Utils; using teamZaps.Configuration; +using teamZaps.Services.Backends; namespace teamZaps.Services; @@ -29,10 +30,10 @@ public class RecoveryService : BackgroundService #endregion - public RecoveryService(ILogger logger, LnbitsService lnbitsService, ITelegramBotClient botClient, IOptions debugSettings) + public RecoveryService(ILogger logger, ILightningBackend lightningBackend, ITelegramBotClient botClient, IOptions debugSettings) { this.logger = logger; - this.lnbitsService = lnbitsService; + this.lightningBackend = lightningBackend; this.botClient = botClient; this.debugSettings = debugSettings.Value; @@ -260,7 +261,7 @@ private async Task DeleteRecordAsync(long userId) private readonly ILogger logger; - private readonly LnbitsService lnbitsService; + private readonly ILightningBackend lightningBackend; private readonly ITelegramBotClient botClient; private readonly DebugSettings debugSettings; } diff --git a/src/appsettings.json b/src/appsettings.json index a369f17..2f6b4f1 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -3,10 +3,11 @@ "BotToken": "YOUR_BOT_TOKEN_HERE", "RootUsers": [] }, - "Lnbits": { - "LndhubUrl": "YOUR_LNDHUB_URL_HERE", - "WalletId": "YOUR_WALLET_ID_HERE", - "ApiKey": "YOUR_API_KEY_HERE" + "Lightning": { + "LNBits": { + "LndhubUrl": "YOUR_LNDHUB_URL_HERE", + "ApiKey": "YOUR_API_KEY_HERE" + } }, "Serilog": { "MinimumLevel": { From 0231bbd030990ddc98d5b3cc84fca433c1a477f6 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Fri, 19 Dec 2025 13:03:51 +0100 Subject: [PATCH 2/8] Implement nostr communication --- src/Communication/Nostr.cs | 270 +++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/Communication/Nostr.cs diff --git a/src/Communication/Nostr.cs b/src/Communication/Nostr.cs new file mode 100644 index 0000000..3144c09 --- /dev/null +++ b/src/Communication/Nostr.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBitcoin.Secp256k1; +using NNostr.Client; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace teamZaps.Communication; + +public class NostrWalletConnector : IDisposable +{ + public NostrWalletConnector(ILoggerFactory loggerFactory, string connectionString, string[]? relayUrls) + { + this.logger = loggerFactory.CreateLogger(); + + // Parse NWC connection string: + var connectionUri = new Uri(connectionString); + if (connectionUri.Scheme != "nostr+walletconnect") + throw new InvalidOperationException("Invalid NWC connection string. Must start with 'nostr+walletconnect://'"); + this.walletPubkey = connectionUri.Host; + this.walletPubkeyBytes = Convert.FromHexString(walletPubkey); + + // Extract secret and relay parameters: + var queryParams = ParseQueryString(connectionUri.Query); + var secret = (queryParams.TryGetValue("secret", out var secretValue)) && (secretValue.Count > 0) + ? secretValue[0] + : throw new InvalidOperationException("NWC connection string missing 'secret' parameter"); + this.clientPrivateKey = Context.Instance.CreateECPrivKey(Convert.FromHexString(secret)); + clientPublicKey = Convert.ToHexString(clientPrivateKey.CreateXOnlyPubKey().ToBytes()).ToLower(); + + // Determine relay URLs: + if (relayUrls?.Length > 0) + ; // Use specified relays. + else if ((queryParams.TryGetValue("relay", out var relayValues)) && (relayValues.Count > 0)) + relayUrls = relayValues.ToArray(); + else + throw new InvalidOperationException("NWC connection string missing 'relay' parameter(s)"); + + // Initialize Nostr client: + this.Relays = relayUrls + .Select(r => new Uri(r)) + .ToArray(); + this.nostrClient = new NostrClient(Relays.First()); + } + + + #region Properties + public Uri[] Relays { get; } + public string Pubkey => walletPubkey; + + public ulong SentRequests { get; private set; } + #endregion + + + #region Initialization + public void Dispose() + { + nostrClient.Dispose(); + } + #endregion + #region Operation + public async Task SendNwcRequestAsync(object request, CancellationToken cancellationToken) + where TResult : class + { + try + { + await nostrClient.ConnectAndWaitUntilConnected(cancellationToken); + var requestJson = JsonSerializer.Serialize(request); + var encryptedContent = EncryptNip04(requestJson, walletPubkeyBytes); + + var evt = new NostrEvent + { + Kind = 23194, + Content = encryptedContent, + CreatedAt = DateTimeOffset.UtcNow, + Tags = new List + { + new NostrEventTag { TagIdentifier = "p", Data = new List { walletPubkey } } + } + }; + await evt.ComputeIdAndSignAsync(clientPrivateKey); + + var responseReceived = new TaskCompletionSource(); + var subscriptionId = Guid.NewGuid().ToString(); + + void OnEventsReceived(object? sender, (string subscriptionId, NostrEvent[] events) args) + { + if (args.subscriptionId == subscriptionId) + { + foreach (var responseEvent in args.events) + { + var eTag = responseEvent.GetTaggedData("e"); + if ((responseEvent.Kind == 23195) && (eTag.Length > 0) && (eTag[0] == evt.Id)) + { + responseReceived.TrySetResult(responseEvent); + return; + } + } + } + } + + nostrClient.EventsReceived += OnEventsReceived; + + try + { + await nostrClient.CreateSubscription(subscriptionId, new[] + { + new NostrSubscriptionFilter + { + Kinds = new[] { 23195 }, + Authors = new[] { walletPubkey } + } + }); + + await nostrClient.PublishEvent(evt, cancellationToken); + SentRequests++; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + var responseEvent = await responseReceived.Task.WaitAsync(linkedCts.Token); + var decryptedContent = DecryptNip04(responseEvent!.Content!, walletPubkeyBytes); + var response = JsonSerializer.Deserialize>(decryptedContent); + + if ((response is not null) && (response.Error is not null)) + { + logger.LogError("Received error: {Code} - {Message}", response.Error.Code, response.Error.Message); + return (null); + } + + return (response?.Result); + } + finally + { + nostrClient.EventsReceived -= OnEventsReceived; + await nostrClient.CloseSubscription(subscriptionId); + } + } + catch (OperationCanceledException) + { + logger.LogWarning("Request timed out"); + return (null); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending request"); + return (null); + } + } + #endregion + + + #region Helper + private static Dictionary> ParseQueryString(string query) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(query)) + return (result); + + var queryWithoutPrefix = query.TrimStart('?'); + var pairs = queryWithoutPrefix.Split('&', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) + { + var parts = pair.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0]); + var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + if (!result.ContainsKey(key)) + result[key] = new List(); + result[key].Add(value); + } + + return (result); + } + private string EncryptNip04(string plaintext, byte[] recipientPubkey) + { + var compressedPubkey = new byte[33]; + compressedPubkey[0] = 0x02; + Array.Copy(recipientPubkey, 0, compressedPubkey, 1, 32); + var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); + var sharedSecret = sharedPoint.ToBytes()[1..33]; + + var iv = RandomUtils.GetBytes(16); + + using var aes = Aes.Create(); + aes.Key = sharedSecret; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); + + return ($"{Convert.ToBase64String(ciphertext)}?iv={Convert.ToBase64String(iv)}"); + } + private string DecryptNip04(string encryptedContent, byte[] senderPubkey) + { + var parts = encryptedContent.Split("?iv="); + if (parts.Length != 2) + throw new InvalidOperationException("Invalid encrypted content format"); + + var ciphertext = Convert.FromBase64String(parts[0]); + var iv = Convert.FromBase64String(parts[1]); + + var compressedPubkey = new byte[33]; + compressedPubkey[0] = 0x02; + Array.Copy(senderPubkey, 0, compressedPubkey, 1, 32); + var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); + var sharedSecret = sharedPoint.ToBytes()[1..33]; + + using var aes = Aes.Create(); + aes.Key = sharedSecret; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var plaintext = decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length); + + return (Encoding.UTF8.GetString(plaintext)); + } + #endregion + + + private readonly ILogger logger; + private readonly NostrClient nostrClient; + private readonly string walletPubkey; + private readonly byte[] walletPubkeyBytes; + private readonly ECPrivKey clientPrivateKey; + private readonly string clientPublicKey; +} + + +#region Models +public class NwcRequest +{ + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("params")] + public object Params { get; set; } = new(); +} + +public class NwcResponse +{ + [JsonPropertyName("result_type")] + public string ResultType { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public T? Result { get; set; } + + [JsonPropertyName("error")] + public NwcError? Error { get; set; } +} +public class NwcError +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} +#endregion \ No newline at end of file From 7eca5325124bf68ab3f1b63f6a876afa7ad6cfdd Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Tue, 16 Dec 2025 11:36:36 +0100 Subject: [PATCH 3/8] Implement AlbyHub backend --- src/Common.cs | 110 +++- src/Configuration/AlbyHubSettings.cs | 15 + src/Handlers/UpdateHandler.DirectMessage.cs | 3 +- src/Handlers/UpdateHandler.cs | 2 +- src/Helper/MessageHelper.Winner.cs | 2 +- src/Helper/PaymentParser.cs | 100 ---- src/Program.cs | 6 +- src/README.md | 78 ++- .../Backends/Backend.AlbyHubService.cs | 545 ++++++++++++++++++ .../Backends/Backend.LnbitsService.cs | 11 +- src/Services/Backends/Backend.cs | 9 +- src/Services/PaymentMonitorService.cs | 2 +- src/Services/RecoveryService.cs | 2 +- src/appsettings.json | 5 + src/teamZaps.csproj | 2 + 15 files changed, 762 insertions(+), 130 deletions(-) create mode 100644 src/Configuration/AlbyHubSettings.cs diff --git a/src/Common.cs b/src/Common.cs index c301e2e..4d00a96 100644 --- a/src/Common.cs +++ b/src/Common.cs @@ -1,7 +1,11 @@ +using System.ComponentModel; +using System.Numerics; +using teamZaps.Configuration; using teamZaps.Utils; namespace teamZaps; + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] public class IconAttribute : Attribute { @@ -35,4 +39,108 @@ public bool Equals(string? other) return (false); } -} \ No newline at end of file +} + +public enum PaymentStatus +{ + [Icon("⏳"), Description("*Pay this invoice* to add your contribution to the session!")] + Pending, + [Icon("✅"), Description("*Thank you!* Your payment has been confirmed.")] + Paid, + [Icon("❌"), Description("This invoice has *expired*.")] + Expired +} +public enum PaymentCurrency +{ + [Description("Satoshis"), Currency("丰", "sat", [ "s", "sat", "sats" ])] // Alternative signs: ⓢ ₛ 𝕤 丰 + Sats, + [Description("Euro"), Currency("€", "EUR", [ "eur", "euro" ])] + Euro, + [Description("US Dollar"), Currency("$", "USD", [ "usd" ])] + Dollar, + + [Description("Cent"), Currency("¢", "", [ "c", "cnt", "cent", "cents" ])] + Cent +} + +public interface IFormattableAmount +{ + long SatsAmount { get; } + double FiatAmount { get; } +} +public interface ITipableAmount : IFormattableAmount +{ + double TipAmount { get; } + double TotalFiatAmount => (TipAmount + FiatAmount); +} + + +public static partial class Extensions +{ + #region Constants + private static readonly IReadOnlyDictionary IconMap = UtilEnum.GetCustomAttributes(); + private static readonly IReadOnlyDictionary CurrencyMap = UtilEnum.GetCustomAttributes(); + #endregion + + + public static string GetIcon(this PaymentStatus source) => (IconMap.TryGetValue(source, out var icon) ? icon.Icon : ""); + + public static PaymentCurrency? ToCurrency(this string? source) + { + if (string.IsNullOrEmpty(source)) + // Return default currency: + return (BotBehaviorOptions.AcceptedFiatCurrency); + + return (CurrencyMap.TryGetKeyOf(c => c.Equals(source), out var currency) ? currency : null); + } + public static string ToSymbol(this PaymentCurrency source) => (CurrencyMap.TryGetValue(source, out var attr) ? attr.Symbol : ""); + public static string ToUnitName(this PaymentCurrency source) => (CurrencyMap.TryGetValue(source, out var attr) ? attr.UnitName : ""); + public static IEnumerable GetAbbreviations(this PaymentCurrency source) + { + if (CurrencyMap.TryGetValue(source, out var attr)) + return (attr.Abbreviations.Prepend(attr.Symbol)); + else + return (Enumerable.Empty()); + } + + public static string FormatTip(this byte? source) => FormatTip(source ?? 0); + public static string FormatTip(this byte source) => (source <= 0) ? "🚫 None" : $"{source}%"; + + public static string? FormatTotalFiatAmount(this ITipableAmount source) + { + var amount = $"*{source.TotalFiatAmount.Format()}*"; + var tipAmount = source.TipAmount; + if (tipAmount > 0.01) + amount += $" (inkl. {tipAmount.Format()} tip)"; + return (amount); + } + public static string? FormatAmount(this IFormattableAmount source) + { + var sats = source.SatsAmount.Format(); + string? fiat; + if (source is ITipableAmount tip) + fiat = tip.TotalFiatAmount.Format(); + else + fiat = source.FiatAmount.Format(); + + if ((sats is null) && (fiat is null)) + return (null); + else if ((sats is not null) && (fiat is not null)) + return ($"*{sats}* ({fiat})"); + else + return (sats ?? fiat); + } + public static string? Format(this long source) => Format(source, PaymentCurrency.Sats); + public static string? Format(this double source) => Format(source, BotBehaviorOptions.AcceptedFiatCurrency); + public static string? Format(this INumber source, PaymentCurrency currency) + where T : INumber + { + if (T.Zero.Equals(source)) + return (null); + + if (currency == PaymentCurrency.Sats) + return ($"{source}{currency.ToSymbol()}"); + else + return ($"{source:F2}{currency.ToSymbol()}"); + } +} diff --git a/src/Configuration/AlbyHubSettings.cs b/src/Configuration/AlbyHubSettings.cs new file mode 100644 index 0000000..ff25c61 --- /dev/null +++ b/src/Configuration/AlbyHubSettings.cs @@ -0,0 +1,15 @@ +namespace teamZaps.Configuration; + +public class AlbyHubSettings +{ + public const string SectionName = "AlbyHub"; + + /// + /// Nostr Wallet Connect connection string (nostr+walletconnect://...) + /// + public string ConnectionString { get; set; } = string.Empty; + /// + /// Relay URLs for Nostr communication (optional, overrides connection string relays) + /// + public string[]? RelayUrls { get; set; } +} diff --git a/src/Handlers/UpdateHandler.DirectMessage.cs b/src/Handlers/UpdateHandler.DirectMessage.cs index 11a7df8..465a870 100644 --- a/src/Handlers/UpdateHandler.DirectMessage.cs +++ b/src/Handlers/UpdateHandler.DirectMessage.cs @@ -141,7 +141,6 @@ private async Task ProcessPrivatePaymentAsync(ITelegramBotClient botClient, Sess foreach (var tokenGrp in tokens.GroupBy(t => t.Currency)) { var grpCurrency = tokenGrp.Key; - var unit = grpCurrency.ToUnitName(); var memo = $"{session.ChatTitle}'{user} zapped"; // Ensure invoice to be payed in Euro only @@ -168,7 +167,7 @@ private async Task ProcessPrivatePaymentAsync(ITelegramBotClient botClient, Sess try { // TODO: pass the enum here, not a currency string! - var invoice = await lightningBackend.CreateInvoiceAsync(invoiceAmount, unit, memo, cancellationToken).ConfigureAwait(false); + var invoice = await lightningBackend.CreateInvoiceAsync(invoiceAmount, grpCurrency, memo, cancellationToken).ConfigureAwait(false); // Store as pending payment var pending = new PendingPayment { diff --git a/src/Handlers/UpdateHandler.cs b/src/Handlers/UpdateHandler.cs index 5f2bd1e..981327c 100644 --- a/src/Handlers/UpdateHandler.cs +++ b/src/Handlers/UpdateHandler.cs @@ -1,7 +1,7 @@ using System.Text; using teamZaps.Configuration; using teamZaps.Services; -using teamZaps.Services.Backends; +using teamZaps.Backend; using teamZaps.Sessions; using teamZaps.Utils; using Telegram.Bot.Types.ReplyMarkups; diff --git a/src/Helper/MessageHelper.Winner.cs b/src/Helper/MessageHelper.Winner.cs index 01daa49..cd4f23a 100644 --- a/src/Helper/MessageHelper.Winner.cs +++ b/src/Helper/MessageHelper.Winner.cs @@ -3,7 +3,7 @@ using System.Text; using Microsoft.Extensions.Logging; using teamZaps.Services; -using teamZaps.Services.Backends; +using teamZaps.Backend; using teamZaps.Utils; namespace teamZaps.Sessions; diff --git a/src/Helper/PaymentParser.cs b/src/Helper/PaymentParser.cs index ce5bf72..21c0047 100644 --- a/src/Helper/PaymentParser.cs +++ b/src/Helper/PaymentParser.cs @@ -9,39 +9,6 @@ namespace teamZaps.Sessions; -public enum PaymentStatus -{ - [Icon("⏳"), Description("*Pay this invoice* to add your contribution to the session!")] - Pending, - [Icon("✅"), Description("*Thank you!* Your payment has been confirmed.")] - Paid, - [Icon("❌"), Description("This invoice has *expired*.")] - Expired -} -public enum PaymentCurrency -{ - [Description("Satoshis"), Currency("丰", "sat", [ "s", "sat", "sats" ])] // Alternative signs: ⓢ ₛ 𝕤 丰 - Sats, - [Description("Euro"), Currency("€", "EUR", [ "eur", "euro" ])] - Euro, - [Description("US Dollar"), Currency("$", "USD", [ "usd" ])] - Dollar, - - [Description("Cent"), Currency("¢", "", [ "c", "cnt", "cent", "cents" ])] - Cent -} - -public interface IFormattableAmount -{ - long SatsAmount { get; } - double FiatAmount { get; } -} -public interface ITipableAmount : IFormattableAmount -{ - double TipAmount { get; } - double TotalFiatAmount => (TipAmount + FiatAmount); -} - public record PaymentToken : IFormattableAmount { public required decimal Amount; @@ -129,73 +96,6 @@ public static bool TryParse(string input, out List tokens, out str public static partial class Extensions { - #region Constants - private static readonly IReadOnlyDictionary IconMap = UtilEnum.GetCustomAttributes(); - private static readonly IReadOnlyDictionary CurrencyMap = UtilEnum.GetCustomAttributes(); - #endregion - - - public static string GetIcon(this PaymentStatus source) => (IconMap.TryGetValue(source, out var icon) ? icon.Icon : ""); - - public static PaymentCurrency? ToCurrency(this string? source) - { - if (string.IsNullOrEmpty(source)) - // Return default currency: - return (BotBehaviorOptions.AcceptedFiatCurrency); - - return (CurrencyMap.TryGetKeyOf(c => c.Equals(source), out var currency) ? currency : null); - } - public static string ToSymbol(this PaymentCurrency source) => (CurrencyMap.TryGetValue(source, out var attr) ? attr.Symbol : ""); - public static string ToUnitName(this PaymentCurrency source) => (CurrencyMap.TryGetValue(source, out var attr) ? attr.UnitName : ""); - public static IEnumerable GetAbbreviations(this PaymentCurrency source) - { - if (CurrencyMap.TryGetValue(source, out var attr)) - return (attr.Abbreviations.Prepend(attr.Symbol)); - else - return (Enumerable.Empty()); - } - - public static string FormatTip(this byte? source) => FormatTip(source ?? 0); - public static string FormatTip(this byte source) => (source <= 0) ? "🚫 None" : $"{source}%"; - - public static string? FormatTotalFiatAmount(this ITipableAmount source) - { - var amount = $"*{source.TotalFiatAmount.Format()}*"; - var tipAmount = source.TipAmount; - if (tipAmount > 0.01) - amount += $" (inkl. {tipAmount.Format()} tip)"; - return (amount); - } - public static string? FormatAmount(this IFormattableAmount source) - { - var sats = source.SatsAmount.Format(); - string? fiat; - if (source is ITipableAmount tip) - fiat = tip.TotalFiatAmount.Format(); - else - fiat = source.FiatAmount.Format(); - - if ((sats is null) && (fiat is null)) - return (null); - else if ((sats is not null) && (fiat is not null)) - return ($"*{sats}* ({fiat})"); - else - return (sats ?? fiat); - } - public static string? Format(this long source) => Format(source, PaymentCurrency.Sats); - public static string? Format(this double source) => Format(source, BotBehaviorOptions.AcceptedFiatCurrency); - public static string? Format(this INumber source, PaymentCurrency currency) - where T : INumber - { - if (T.Zero.Equals(source)) - return (null); - - if (currency == PaymentCurrency.Sats) - return ($"{source}{currency.ToSymbol()}"); - else - return ($"{source:F2}{currency.ToSymbol()}"); - } - public static bool IsLightningInvoice(this string source, out string parsedInvoice) { parsedInvoice = source diff --git a/src/Program.cs b/src/Program.cs index 87b46c5..72146ea 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -1,7 +1,7 @@ using teamZaps.Configuration; using teamZaps.Handlers; using teamZaps.Services; -using teamZaps.Services.Backends; +using teamZaps.Backend; using teamZaps.Sessions; namespace teamZaps; @@ -54,8 +54,10 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { services.Configure(hostContext.Configuration.GetSection(BotBehaviorOptions.SectionName)); services.Configure(hostContext.Configuration.GetSection(TelegramSettings.SectionName)); - services.Configure(hostContext.Configuration.GetSection("Lightning")); services.Configure(hostContext.Configuration.GetSection(DebugSettings.SectionName)); + var lightningSection = hostContext.Configuration.GetSection("Lightning"); + services.Configure(lightningSection.GetSection(LnbitsSettings.SectionName)); + services.Configure(lightningSection.GetSection(AlbyHubSettings.SectionName)); services.AddHostedService(); services.AddHostedService(); diff --git a/src/README.md b/src/README.md index cba268b..3eb4f3b 100644 --- a/src/README.md +++ b/src/README.md @@ -84,11 +84,18 @@ Create `appsettings.Development.json`: ```json { "Telegram": { - "BotToken": "YOUR_BOT_TOKEN_FROM_BOTFATHER" + "BotToken": "YOUR_BOT_TOKEN_FROM_BOTFATHER", + "RootUsers": [ 123456789 ] }, - "Lnbits": { - "LndhubUrl": "https://your-lnbits.com/lndhub/ext/", - "ApiKey": "YOUR_LNBITS_API_KEY" + "Lightning": { + "LNBits": { + "LndhubUrl": "YOUR_LNDHUB_URL_HERE", + "ApiKey": "YOUR_API_KEY_HERE" + }, + "AlbyHub": { + "ConnectionString": "YOUR_NWC_CONNECTION_STRING_HERE", + "RelayUrls": [ "YOUR_RELAY_URLS_HERE" ] + } }, "BotBehaviorOptions": { "AllowNonAdminSessionStart": false, @@ -117,6 +124,54 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run ## 🔧 Configuration +### Lightning Backend + +Team Zaps supports multiple Lightning backend implementations through a common `ILightningBackend` interface. **The first backend configured in the `Lightning` section will be selected and used.** + +#### AlbyHub Backend (NWC/NIP-47) + +AlbyHub uses the **Nostr Wallet Connect (NWC)** protocol based on NIP-47 for communication over Nostr relays. This provides a decentralized approach to Lightning wallet integration. + +```json +{ + "Lightning": { + "AlbyHub": { + "ConnectionString": "nostr+walletconnect://PUBKEY?relay=wss://relay.getalby.com/v1&secret=SECRET", + "RelayUrls": [ "wss://relay.getalby.com/v1" ] + } + } +} +``` + +**Configuration:** +- `ConnectionString` - NWC connection URI from AlbyHub wallet settings (format: `nostr+walletconnect://PUBKEY?relay=RELAY_URL&secret=SECRET`) +- `RelayUrls` - Specify relay URLs from connection string + +**How to get the connection string:** +1. Open your AlbyHub wallet +2. Go to Connections → Create new connection +3. Select "Nostr Wallet Connect" +4. Copy the `nostr+walletconnect://...` URI + +#### LNBits Backend (REST API) + +LNBits uses a traditional REST API for Lightning operations. Requires a running LNbits instance. + +```json +{ + "Lightning": { + "LNBits": { + "LndhubUrl": "https://your-lnbits.com/lndhub/ext/", + "ApiKey": "YOUR_LNBITS_API_KEY" + } + } +} +``` + +**Configuration:** +- `LndhubUrl` - LNDhub extension URL (must end with `/lndhub/ext/`) +- `ApiKey` - Invoice/read key from your LNbits wallet + ### Bot Behavior Options The `BotBehaviorOptions` section controls various aspects of bot behavior: @@ -264,14 +319,19 @@ public class RecoveryService : BackgroundService } ``` -### LnbitsService +### Lightning Backend (ILightningBackend) ```csharp -// Lightning Network integration -var invoice = await lnbitsService.CreateInvoiceAsync(amount, "EUR", memo); -var status = await lnbitsService.CheckPaymentStatusAsync(paymentHash); -var result = await lnbitsService.PayInvoiceAsync(bolt11Invoice); +// Abstracted Lightning Network integration +// Automatically uses the first configured backend (AlbyHub or LNBits) +var invoice = await lightningBackend.CreateInvoiceAsync(amount, "EUR", memo); +var status = await lightningBackend.CheckPaymentStatusAsync(paymentHash); +var result = await lightningBackend.PayInvoiceAsync(bolt11Invoice); ``` +**Available Backends:** +- **AlbyHub** - Uses NWC (Nostr Wallet Connect) with NIP-47 protocol over Nostr relays +- **LNBits** - Uses REST API for LNbits instances + ### PaymentParser ```csharp // Advanced payment string parsing with regex diff --git a/src/Services/Backends/Backend.AlbyHubService.cs b/src/Services/Backends/Backend.AlbyHubService.cs index e69de29..64b99e0 100644 --- a/src/Services/Backends/Backend.AlbyHubService.cs +++ b/src/Services/Backends/Backend.AlbyHubService.cs @@ -0,0 +1,545 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using NBitcoin; +using NBitcoin.Secp256k1; +using NNostr.Client; +using teamZaps.Configuration; +using teamZaps.Backend; +using teamZaps.Utils; + +namespace teamZaps.Backend; + +/// +/// AlbyHub Lightning backend implementation. +/// +[BackendDescription("AlbyHub")] +public class AlbyHubService : ILightningBackend, IAsyncDisposable +{ + public AlbyHubService(ILogger logger, IOptions settings) + { + this.logger = logger; + this.settings = settings.Value; + + // Parse NWC connection string: + var connectionUri = new Uri(this.settings.ConnectionString); + if (connectionUri.Scheme != "nostr+walletconnect") + throw new InvalidOperationException("Invalid NWC connection string. Must start with nostr+walletconnect://"); + walletPubkey = connectionUri.Host; + walletPubkeyBytes = Convert.FromHexString(walletPubkey); + + // Extract secret and relay parameters: + var queryParams = ParseQueryString(connectionUri.Query); + secret = (queryParams.TryGetValue("secret", out var secretValue)) && (secretValue.Count > 0) + ? secretValue[0] + : throw new InvalidOperationException("NWC connection string missing 'secret' parameter"); + clientPrivateKey = Context.Instance.CreateECPrivKey(Convert.FromHexString(secret)); + clientPublicKey = Convert.ToHexString(clientPrivateKey.CreateXOnlyPubKey().ToBytes()).ToLower(); + + // Determine relay URLs: + if ((this.settings.RelayUrls is not null) && (this.settings.RelayUrls.Length > 0)) + relays = this.settings.RelayUrls; + else if ((queryParams.TryGetValue("relay", out var relayValues)) && (relayValues.Count > 0)) + relays = relayValues.ToArray(); + else + throw new InvalidOperationException("NWC connection string missing 'relay' parameter(s)"); + + // Initialize Nostr client: + var relayUris = relays + .Select(r => new Uri(r)) + .ToArray(); + nostrClient = new NostrClient(relayUris[0]); + foreach (var relay in relayUris.Skip(1)) + { + } + + logger.LogInformation("AlbyHub initialized with wallet {WalletPubkey} and {RelayCount} relay(s)", walletPubkey[..8] + "...", relays.Length); + } + + + #region Properties + public ulong SentRequests { get; private set; } + #endregion + + + #region Operation.Invoice + public async Task GetBalanceAsync(CancellationToken cancellationToken = default) + { + try + { + var request = new NwcRequest + { + Method = "get_balance", + Params = new { } + }; + + var response = await SendNwcRequestAsync(request, cancellationToken); + if (response is null) + return (null); + + return (response.Balance / 1000); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting wallet balance from AlbyHub"); + return (null); + } + } + public async Task CreateInvoiceAsync(double amount, PaymentCurrency currency, string? memo = null, CancellationToken cancellationToken = default) + { + try + { + var amountMsat = (currency == PaymentCurrency.Sats) + ? (long)(amount * 1000) + : throw new NotSupportedException($"Currency '{currency.GetDescription()}' not supported by AlbyHub"); + var request = new NwcRequest + { + Method = "make_invoice", + Params = new MakeInvoiceParams + { + Amount = amountMsat, + Description = memo ?? "" + } + }; + + var response = await SendNwcRequestAsync(request, cancellationToken); + if (response is null) + return (null); + + return (new AlbyHubInvoice + { + PaymentRequest = response.Invoice, + PaymentHash = response.PaymentHash + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating invoice via AlbyHub"); + return (null); + } + } + public async Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) + { + try + { + var request = new NwcRequest + { + Method = "lookup_invoice", + Params = new LookupInvoiceParams + { + Invoice = bolt11 + } + }; + + var response = await SendNwcRequestAsync(request, cancellationToken); + if (response is null) + return (null); + + return (new AlbyHubDecodedInvoice + { + Amount = response.Amount / 1000, + Description = response.Description, + PaymentHash = response.PaymentHash + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error decoding invoice via AlbyHub: {Invoice}", bolt11); + return (null); + } + } + public async Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) + { + try + { + var request = new NwcRequest + { + Method = "pay_invoice", + Params = new PayInvoiceParams + { + Invoice = bolt11 + } + }; + + var response = await SendNwcRequestAsync(request, cancellationToken); + if (response is null) + return (null); + + return (new AlbyHubPaymentResponse + { + PaymentHash = response.PaymentHash, + Amount = response.Amount / 1000, + Fee = response.FeesPaid / 1000 + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error paying invoice via AlbyHub"); + return (null); + } + } + public async Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default) + { + try + { + var request = new NwcRequest + { + Method = "lookup_invoice", + Params = new LookupInvoiceParams + { + PaymentHash = paymentHash + } + }; + + var response = await SendNwcRequestAsync(request, cancellationToken); + if (response is null) + return (null); + + return (new AlbyHubPaymentStatus + { + Paid = response.Settled, + SatsAmount = response.Amount / 1000, + FiatAmount = 0, + FiatRate = 0 + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking payment status via AlbyHub"); + return (null); + } + } + #endregion + + + #region Helper + private static Dictionary> ParseQueryString(string query) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(query)) + return (result); + + var queryWithoutPrefix = query.TrimStart('?'); + var pairs = queryWithoutPrefix.Split('&', StringSplitOptions.RemoveEmptyEntries); + foreach (var pair in pairs) + { + var parts = pair.Split('=', 2); + var key = Uri.UnescapeDataString(parts[0]); + var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; + if (!result.ContainsKey(key)) + result[key] = new List(); + result[key].Add(value); + } + + return (result); + } + private async Task SendNwcRequestAsync(object request, CancellationToken cancellationToken) where TResult : class + { + try + { + await nostrClient.ConnectAndWaitUntilConnected(cancellationToken); + // Serialize request: + var requestJson = JsonSerializer.Serialize(request); + + // Encrypt content using NIP-04: + var encryptedContent = EncryptNip04(requestJson, walletPubkeyBytes); + + // Create and sign Nostr event: + var evt = new NostrEvent + { + Kind = 23194, + Content = encryptedContent, + CreatedAt = DateTimeOffset.UtcNow, + Tags = new List + { + new NostrEventTag { TagIdentifier = "p", Data = new List { walletPubkey } } + } + }; + await evt.ComputeIdAndSignAsync(clientPrivateKey); + + // Subscribe to response: + var responseReceived = new TaskCompletionSource(); + var subscriptionId = Guid.NewGuid().ToString(); + + void OnEventsReceived(object? sender, (string subscriptionId, NostrEvent[] events) args) + { + if (args.subscriptionId == subscriptionId) + { + foreach (var responseEvent in args.events) + { + var eTag = responseEvent.GetTaggedData("e"); + if ((responseEvent.Kind == 23195) && (eTag.Length > 0) && (eTag[0] == evt.Id)) + { + responseReceived.TrySetResult(responseEvent); + return; + } + } + } + } + + nostrClient.EventsReceived += OnEventsReceived; + + try + { + // Subscribe before sending: + await nostrClient.CreateSubscription(subscriptionId, new[] + { + new NostrSubscriptionFilter + { + Kinds = new[] { 23195 }, + Authors = new[] { walletPubkey } + } + }); + + // Send request: + await nostrClient.PublishEvent(evt, cancellationToken); + logger.LogDebug("Sent NWC request {EventId} to AlbyHub", evt.Id); + + // Wait for response with timeout: + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + var responseEvent = await responseReceived.Task.WaitAsync(linkedCts.Token); + + // Decrypt and parse response: + var decryptedContent = DecryptNip04(responseEvent!.Content!, walletPubkeyBytes); + var response = JsonSerializer.Deserialize>(decryptedContent); + + if ((response is not null) && (response.Error is not null)) + { + logger.LogError("AlbyHub returned error: {Code} - {Message}", response.Error.Code, response.Error.Message); + return (null); + } + + return (response?.Result); + } + finally + { + nostrClient.EventsReceived -= OnEventsReceived; + await nostrClient.CloseSubscription(subscriptionId); + } + } + catch (OperationCanceledException) + { + logger.LogWarning("AlbyHub request timed out"); + return (null); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending request to AlbyHub"); + return (null); + } + } + + private string EncryptNip04(string plaintext, byte[] recipientPubkey) + { + // Compute shared secret (ECDH): + // Nostr uses 32-byte X-only pubkeys, need to convert to 33-byte compressed (0x02 prefix for even Y) + var compressedPubkey = new byte[33]; + compressedPubkey[0] = 0x02; + Array.Copy(recipientPubkey, 0, compressedPubkey, 1, 32); + var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); + var sharedSecret = sharedPoint.ToBytes()[1..33]; + + // Generate IV: + var iv = RandomUtils.GetBytes(16); + + // Encrypt: + using var aes = Aes.Create(); + aes.Key = sharedSecret; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var encryptor = aes.CreateEncryptor(); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); + + // Return base64(ciphertext)?iv=base64(iv): + return ($"{Convert.ToBase64String(ciphertext)}?iv={Convert.ToBase64String(iv)}"); + } + + private string DecryptNip04(string encryptedContent, byte[] senderPubkey) + { + // Parse content: + var parts = encryptedContent.Split("?iv="); + if (parts.Length != 2) + throw new InvalidOperationException("Invalid encrypted content format"); + + var ciphertext = Convert.FromBase64String(parts[0]); + var iv = Convert.FromBase64String(parts[1]); + + // Compute shared secret (ECDH): + // Nostr uses 32-byte X-only pubkeys, need to convert to 33-byte compressed (0x02 prefix for even Y) + var compressedPubkey = new byte[33]; + compressedPubkey[0] = 0x02; + Array.Copy(senderPubkey, 0, compressedPubkey, 1, 32); + var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); + var sharedSecret = sharedPoint.ToBytes()[1..33]; + + // Decrypt: + using var aes = Aes.Create(); + aes.Key = sharedSecret; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var plaintext = decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length); + + return (Encoding.UTF8.GetString(plaintext)); + } + #endregion + + public ValueTask DisposeAsync() + { + nostrClient?.Dispose(); + return ValueTask.CompletedTask; + } + + + private readonly ILogger logger; + private readonly AlbyHubSettings settings; + private readonly NostrClient nostrClient; + private readonly string walletPubkey; + private readonly byte[] walletPubkeyBytes; + private readonly string secret; + private readonly ECPrivKey clientPrivateKey; + private readonly string clientPublicKey; + private readonly string[] relays; +} + +#region NWC Request/Response Models +file class NwcRequest +{ + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + + [JsonPropertyName("params")] + public object Params { get; set; } = new(); +} + +file class NwcResponse +{ + [JsonPropertyName("result_type")] + public string ResultType { get; set; } = string.Empty; + + [JsonPropertyName("result")] + public T? Result { get; set; } + + [JsonPropertyName("error")] + public NwcError? Error { get; set; } +} + +file class NwcError +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} + +file class MakeInvoiceParams +{ + [JsonPropertyName("amount")] + public long Amount { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} + +file class MakeInvoiceResult +{ + [JsonPropertyName("invoice")] + public string Invoice { get; set; } = string.Empty; + + [JsonPropertyName("payment_hash")] + public string PaymentHash { get; set; } = string.Empty; +} + +file class PayInvoiceParams +{ + [JsonPropertyName("invoice")] + public string Invoice { get; set; } = string.Empty; +} + +file class PayInvoiceResult +{ + [JsonPropertyName("preimage")] + public string Preimage { get; set; } = string.Empty; + + [JsonPropertyName("payment_hash")] + public string PaymentHash { get; set; } = string.Empty; + + [JsonPropertyName("amount")] + public long Amount { get; set; } + + [JsonPropertyName("fees_paid")] + public long FeesPaid { get; set; } +} + +file class LookupInvoiceParams +{ + [JsonPropertyName("invoice")] + public string? Invoice { get; set; } + + [JsonPropertyName("payment_hash")] + public string? PaymentHash { get; set; } +} + +file class LookupInvoiceResult +{ + [JsonPropertyName("invoice")] + public string Invoice { get; set; } = string.Empty; + + [JsonPropertyName("payment_hash")] + public string PaymentHash { get; set; } = string.Empty; + + [JsonPropertyName("amount")] + public long Amount { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("settled")] + public bool Settled { get; set; } +} + +file class GetBalanceResult +{ + [JsonPropertyName("balance")] + public long Balance { get; set; } +} +#endregion + +#region Interface Implementations +file class AlbyHubInvoice : ILightningInvoice +{ + public string PaymentRequest { get; set; } = string.Empty; + public string PaymentHash { get; set; } = string.Empty; +} + +file class AlbyHubPaymentResponse : IPaymentResponse +{ + public string PaymentHash { get; set; } = string.Empty; + public long Amount { get; set; } + public long Fee { get; set; } +} + +file class AlbyHubPaymentStatus : IPaymentStatus +{ + public bool Paid { get; set; } + public long SatsAmount { get; set; } + public double FiatAmount { get; set; } + public double FiatRate { get; set; } +} + +file class AlbyHubDecodedInvoice : IDecodedInvoice +{ + public long Amount { get; set; } + public string? Description { get; set; } + public string? PaymentHash { get; set; } +} +#endregion diff --git a/src/Services/Backends/Backend.LnbitsService.cs b/src/Services/Backends/Backend.LnbitsService.cs index c054115..b2caace 100644 --- a/src/Services/Backends/Backend.LnbitsService.cs +++ b/src/Services/Backends/Backend.LnbitsService.cs @@ -4,9 +4,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using teamZaps.Configuration; -using teamZaps.Services.Backends; +using teamZaps.Backend; -namespace teamZaps.Services.Backends; +namespace teamZaps.Backend; [BackendDescription("LNBits")] public class LnbitsService : ILightningBackend @@ -24,9 +24,6 @@ public LnbitsService(ILogger logger, IOptions set #region Properties - /// - /// Total number of requests sent to the LNbits server. - /// public ulong SentRequests { get; private set; } #endregion @@ -47,10 +44,10 @@ public LnbitsService(ILogger logger, IOptions set return (null); } } - public Task CreateInvoiceAsync(double amount, string unitName, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new + public Task CreateInvoiceAsync(double amount, PaymentCurrency currency, string? memo = null, CancellationToken cancellationToken = default) => CreateInvoiceAsync(new { amount = amount, - unit = unitName, + unit = currency.ToUnitName(), memo = memo ?? "", @out = false }, cancellationToken); diff --git a/src/Services/Backends/Backend.cs b/src/Services/Backends/Backend.cs index d3a8f99..7ba2931 100644 --- a/src/Services/Backends/Backend.cs +++ b/src/Services/Backends/Backend.cs @@ -1,6 +1,8 @@ +using System.ComponentModel; using teamZaps.Utils; -namespace teamZaps.Services.Backends; +namespace teamZaps.Backend; + public static partial class Common { @@ -42,7 +44,7 @@ public interface ILightningBackend /// /// Create a Lightning invoice. /// - Task CreateInvoiceAsync(double amount, string currency, string? memo = null, CancellationToken cancellationToken = default); + Task CreateInvoiceAsync(double amount, PaymentCurrency currency, string? memo = null, CancellationToken cancellationToken = default); /// /// Decode a BOLT11 Lightning invoice to extract payment details. /// @@ -58,8 +60,6 @@ public interface ILightningBackend #endregion } - -#region Models.CommonData /// /// Lightning invoice details (BOLT11 payment request). /// @@ -99,4 +99,3 @@ public interface IPaymentStatus double FiatAmount { get; } double FiatRate { get; } } -#endregion diff --git a/src/Services/PaymentMonitorService.cs b/src/Services/PaymentMonitorService.cs index 8c9c57f..f11073d 100644 --- a/src/Services/PaymentMonitorService.cs +++ b/src/Services/PaymentMonitorService.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using teamZaps.Configuration; using teamZaps.Services; -using teamZaps.Services.Backends; +using teamZaps.Backend; namespace teamZaps.Sessions; diff --git a/src/Services/RecoveryService.cs b/src/Services/RecoveryService.cs index 11a24d7..e408fe7 100644 --- a/src/Services/RecoveryService.cs +++ b/src/Services/RecoveryService.cs @@ -6,7 +6,7 @@ using teamZaps.Sessions; using teamZaps.Utils; using teamZaps.Configuration; -using teamZaps.Services.Backends; +using teamZaps.Backend; namespace teamZaps.Services; diff --git a/src/appsettings.json b/src/appsettings.json index 2f6b4f1..0ca506c 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -7,6 +7,11 @@ "LNBits": { "LndhubUrl": "YOUR_LNDHUB_URL_HERE", "ApiKey": "YOUR_API_KEY_HERE" + }, + "AlbyHub": { + "ConnectionString": "YOUR_NWC_CONNECTION_STRING_HERE" + }, + "CoinGecko": { } }, "Serilog": { diff --git a/src/teamZaps.csproj b/src/teamZaps.csproj index 37b806c..6a495e6 100644 --- a/src/teamZaps.csproj +++ b/src/teamZaps.csproj @@ -14,6 +14,8 @@ + + From 4995110005c3e1937850e447293c4ba7f1823068 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Tue, 16 Dec 2025 20:20:14 +0100 Subject: [PATCH 4/8] Implement first exchange rate service --- src/Common.cs | 4 + src/Handlers/UpdateHandler.DirectMessage.cs | 39 +- src/Handlers/UpdateHandler.cs | 8 +- src/Program.cs | 77 +++- src/README.md | 103 ++++- .../Backends/Backend.AlbyHubService.cs | 416 +++--------------- src/Services/Backends/Backend.CoinGecko.cs | 145 ++++++ .../Backends/Backend.LnbitsService.cs | 41 +- src/Services/Backends/Backend.cs | 106 ++++- src/Services/PaymentMonitorService.cs | 22 +- src/Services/RecoveryService.cs | 10 +- src/Sessions/SessionManager.cs | 27 +- src/Sessions/SessionState.cs | 6 +- src/Utils.cs | 9 + src/appsettings.json | 2 +- src/teamZaps.csproj | 1 + 16 files changed, 544 insertions(+), 472 deletions(-) create mode 100644 src/Services/Backends/Backend.CoinGecko.cs diff --git a/src/Common.cs b/src/Common.cs index 4d00a96..ae9268f 100644 --- a/src/Common.cs +++ b/src/Common.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Numerics; +using teamZaps.Backend; using teamZaps.Configuration; using teamZaps.Utils; @@ -54,6 +55,8 @@ public enum PaymentCurrency { [Description("Satoshis"), Currency("丰", "sat", [ "s", "sat", "sats" ])] // Alternative signs: ⓢ ₛ 𝕤 丰 Sats, + [Description("Bitcoin"), Currency("₿", "BTC", [ "btc", "bitcoin" ])] + Bitcoin, [Description("Euro"), Currency("€", "EUR", [ "eur", "euro" ])] Euro, [Description("US Dollar"), Currency("$", "USD", [ "usd" ])] @@ -114,6 +117,7 @@ public static IEnumerable GetAbbreviations(this PaymentCurrency source) amount += $" (inkl. {tipAmount.Format()} tip)"; return (amount); } + public static string FormatFiatRate(this double source) => $"{source:F2} {Common.AcceptedFiatPerBitcoinSymbol}"; public static string? FormatAmount(this IFormattableAmount source) { var sats = source.SatsAmount.Format(); diff --git a/src/Handlers/UpdateHandler.DirectMessage.cs b/src/Handlers/UpdateHandler.DirectMessage.cs index 465a870..b24fd28 100644 --- a/src/Handlers/UpdateHandler.DirectMessage.cs +++ b/src/Handlers/UpdateHandler.DirectMessage.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using teamZaps; using teamZaps.Configuration; using teamZaps.Helper; using teamZaps.Services; @@ -175,10 +176,11 @@ private async Task ProcessPrivatePaymentAsync(ITelegramBotClient botClient, Sess PaymentHash = invoice!.PaymentHash, PaymentRequest = invoice.PaymentRequest, Tokens = tokenGrp.ToArray(), - FiatAmount = grpAmount, + SatsAmount = invoice.SatsAmount, TipAmount = tipAmount, + FiatAmount = grpAmount, Currency = grpCurrency, - CreatedAt = DateTimeOffset.UtcNow + CreatedAt = DateTimeOffset.Now }; session.PendingPayments.TryAdd(invoice.PaymentHash, pending); @@ -207,11 +209,8 @@ private async Task ProcessWinnerInvoiceAsync(ITelegramBotClient botClient, Sessi var winnerInfo = session.Winners[winnerUser.UserId]; // Decode and validate the invoice amount - var decodedInvoice = await lightningBackend.DecodeInvoiceAsync(bolt11, cancellationToken); - if (decodedInvoice is null) - throw new ArgumentException("Invalid invoice! Please provide a valid Lightning invoice."); - - ValidateInvoiceAmount(winnerInfo.SatsAmount, decodedInvoice.Amount); + var invoiceSats = lightningBackend.GetInvoiceAmount(bolt11); + ValidateInvoiceAmount(winnerInfo.SatsAmount, invoiceSats); winnerUser!.SubmittedInvoice = true; @@ -301,7 +300,7 @@ private async Task HandleDiagnosisAsync(ITelegramBotClient botClient, CommandMes diagnostics.AppendLine("\n⚙️ *System status:*"); diagnostics.AppendLine($"• CPU usage: *{Environment.CpuUsage.TotalTime}*"); diagnostics.AppendLine($"• Memory usage: *{GC.GetTotalMemory(false) / 1024 / 1024:N0} MB*"); - diagnostics.AppendLine($"• Uptime: *{DateTimeOffset.UtcNow - Process.GetCurrentProcess().StartTime:dd\\.hh\\:mm\\:ss}*"); + diagnostics.AppendLine($"• Uptime: *{DateTimeOffset.Now - Process.GetCurrentProcess().StartTime:dd\\.hh\\:mm\\:ss}*"); // Bot Information diagnostics.AppendLine("\n🤖 *Bot status:*"); @@ -334,7 +333,7 @@ private async Task HandleDiagnosisAsync(ITelegramBotClient botClient, CommandMes var oldestRecord = allLostSats.OrderBy(r => r.Timestamp).FirstOrDefault(); if (oldestRecord is not null) { - var age = (DateTimeOffset.UtcNow - oldestRecord.Timestamp); + var age = (DateTimeOffset.Now - oldestRecord.Timestamp); diagnostics.AppendLine($"• Oldest record: *{age.TotalDays:N0} days ago*"); } } @@ -344,6 +343,19 @@ private async Task HandleDiagnosisAsync(ITelegramBotClient botClient, CommandMes diagnostics.AppendLine($"• Backend type: *{lightningBackend.BackendType}*"); diagnostics.AppendLine($"• Sent requests: *{lightningBackend.SentRequests}*"); + // Exchange rate backend Information (optional) + diagnostics.AppendLine("\n💱 *Exchange rate backend status:* "); + if (exchangeRateBackend is null) + diagnostics.AppendLine("• Backend: 🚫 *none*"); + else + { + diagnostics.AppendLine($"• Backend type: *{exchangeRateBackend.BackendType}*"); + diagnostics.AppendLineIfNotNull("• Last update: *{0}*", exchangeRateBackend.LastRateUpdate?.ToString("f"), "⚠️ never"); + if (exchangeRateBackend.RatesReliable) + diagnostics.AppendLine($"• Fiat rate: *{exchangeRateBackend.FiatRate!.Value.FormatFiatRate()}*"); + diagnostics.AppendLine($"• Sent requests: *{exchangeRateBackend.SentRequests}*"); + } + await botClient.SendMessage(command.ChatId, diagnostics.ToString(), parseMode: ParseMode.Markdown, @@ -390,13 +402,8 @@ private async Task ProcessRecoveryInvoiceAsync(ITelegramBotClient botClient, Use var expectedSats = lostSats.SatsAmount; // Decode and validate the invoice - var decodedInvoice = await lightningBackend.DecodeInvoiceAsync(bolt11, cancellationToken); - if (decodedInvoice is null) - throw new ArgumentException("Invalid Lightning invoice!\n\n" + - "Please provide a valid Lightning invoice for your recovery.") - .AddLogLevel(LogLevel.Warning); - - ValidateInvoiceAmount(expectedSats, decodedInvoice.Amount); + var invoiceSats = lightningBackend.GetInvoiceAmount(bolt11); + ValidateInvoiceAmount(expectedSats, invoiceSats); await botClient.SendMessage(user.Id, "✅ Recovery invoice received!\n⏳ Processing recovery payment...", diff --git a/src/Handlers/UpdateHandler.cs b/src/Handlers/UpdateHandler.cs index 981327c..a8000c0 100644 --- a/src/Handlers/UpdateHandler.cs +++ b/src/Handlers/UpdateHandler.cs @@ -11,6 +11,10 @@ namespace teamZaps.Handlers; public partial class UpdateHandler : IUpdateHandler { public UpdateHandler(ILogger logger, IOptions botBehaviour, IOptions debugSettings, IOptions telegramSettings, IHostEnvironment hostEnvironment, ILightningBackend lightningBackend, SessionManager sessionManager, SessionWorkflowService workflowService, RecoveryService recoveryService) + : this(logger, botBehaviour, debugSettings, telegramSettings, hostEnvironment, lightningBackend, null, sessionManager, workflowService, recoveryService) + { + } + public UpdateHandler(ILogger logger, IOptions botBehaviour, IOptions debugSettings, IOptions telegramSettings, IHostEnvironment hostEnvironment, ILightningBackend lightningBackend, IExchangeRateBackend? exchangeRateBackend, SessionManager sessionManager, SessionWorkflowService workflowService, RecoveryService recoveryService) { this.logger = logger; this.debugSettings = debugSettings.Value; @@ -18,6 +22,7 @@ public UpdateHandler(ILogger logger, IOptions IsUserAdminAsync(ITelegramBotClient botClient, long cha private readonly TelegramSettings telegramSettings; private readonly IHostEnvironment hostEnvironment; private readonly ILightningBackend lightningBackend; + private readonly IExchangeRateBackend? exchangeRateBackend; private readonly SessionManager sessionManager; private readonly SessionWorkflowService workflowService; private readonly RecoveryService recoveryService; @@ -276,6 +282,6 @@ private static Task Send(this ITelegramBotClient source, long userId, string? ic { if (!string.IsNullOrEmpty(icon)) message = $"{icon} {message}"; - return source.SendMessage(userId, message, cancellationToken: cancellationToken); + return source.SendMessage(userId, message, parseMode: ParseMode.Markdown, cancellationToken: cancellationToken); } } diff --git a/src/Program.cs b/src/Program.cs index 72146ea..f73e36e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -3,6 +3,7 @@ using teamZaps.Services; using teamZaps.Backend; using teamZaps.Sessions; +using teamZaps.Utils; namespace teamZaps; @@ -55,14 +56,21 @@ public static IHostBuilder CreateHostBuilder(string[] args) => services.Configure(hostContext.Configuration.GetSection(BotBehaviorOptions.SectionName)); services.Configure(hostContext.Configuration.GetSection(TelegramSettings.SectionName)); services.Configure(hostContext.Configuration.GetSection(DebugSettings.SectionName)); - var lightningSection = hostContext.Configuration.GetSection("Lightning"); - services.Configure(lightningSection.GetSection(LnbitsSettings.SectionName)); - services.Configure(lightningSection.GetSection(AlbyHubSettings.SectionName)); + var backendsSection = hostContext.Configuration.GetSection("Backends"); + services.Configure(backendsSection.GetSection(LnbitsSettings.SectionName)); + services.Configure(backendsSection.GetSection(AlbyHubSettings.SectionName)); + + // Register HttpClientFactory for backend services: + services.AddHttpClient(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => { var settings = sp.GetRequiredService>().Value; @@ -71,27 +79,56 @@ public static IHostBuilder CreateHostBuilder(string[] args) => return (new TelegramBotClient(settings.BotToken)); }); - // Register Lightning backend based on configuration - // > Select first configured backend: - var backendType = hostContext.Configuration - .GetSection("Lightning") + // Determine backends based on configuration: + // > Select first configured backends. + var configuredBackends = hostContext.Configuration + .GetSection("Backends") .GetChildren() .Select(c => c.Key) - .FirstOrDefault(); - if (string.IsNullOrWhiteSpace(backendType)) + .ToArray(); + + // Inject lightning backend: + var lightningBackend = TryGetBackendType(configuredBackends); + if (lightningBackend is null) throw new InvalidOperationException("No lightning backend configured!"); - if (!Common.BackendTypes.TryGetValue(backendType.ToLowerInvariant(), out var lightningBackend)) - throw new NotSupportedException($"Unknown lightning backend '{backendType}' configured!"); - else + services.AddSingleton(typeof(ILightningBackend), lightningBackend); + Log.Information($"Using '{lightningBackend.Name}' as lightning backend"); + + // Inject exchange rate backend if required: + if (RequiresExchangeRateBackend(lightningBackend)) { - services.AddSingleton(typeof(ILightningBackend), lightningBackend); - Log.Information($"Using '{backendType}' as lightning backend"); + Type? exchangeRateBackend; + if (lightningBackend.IsAssignableTo()) + exchangeRateBackend = lightningBackend; // Use lightning backend also as exchange rate backend. + else + exchangeRateBackend = TryGetBackendType(configuredBackends); + if (exchangeRateBackend is null) + throw new InvalidOperationException("No exchange rate backend configured!"); + services.AddSingleton(typeof(IExchangeRateBackend), exchangeRateBackend); + services.AddHostedService(f => (BackgroundService)f.GetRequiredService()); + Log.Information($"Using '{exchangeRateBackend.Name}' as exchange rate backend"); } - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - + else + Log.Information($"No exchange rate backend required."); }); + + + #region Helper + private static Type? TryGetBackendType(string[] backends) + where T : IBackend + { + foreach (var backend in backends) + { + if (Common.BackendTypes.TryGetValue(backend.ToLowerInvariant(), out var backendType)) + { + if (backendType.ProvidedInterfaces.Any(i => (i == typeof(T)))) + return (backendType.Type); + } + } + return (null); + } + private static bool RequiresExchangeRateBackend(Type type) => type.GetConstructors() + .SelectMany(c => c.GetParameters()) + .Any(p => (p.ParameterType == typeof(IExchangeRateBackend))); + #endregion } diff --git a/src/README.md b/src/README.md index 3eb4f3b..7b30619 100644 --- a/src/README.md +++ b/src/README.md @@ -35,7 +35,13 @@ src/ │ └── UpdateHandler.Session.cs # Group session commands ├── Services/ # Background and integration services │ ├── TelegramBotService.cs # Main bot service lifecycle -│ └── LnbitsService.cs # Lightning Network integration +│ ├── PaymentMonitorService.cs # Background payment monitoring +│ ├── RecoveryService.cs # Lost sats recovery system +│ └── Backends/ # Pluggable backend implementations +│ ├── Backend.cs # Backend interfaces and base types +│ ├── Backend.AlbyHubService.cs # AlbyHub NWC backend +│ ├── Backend.LnbitsService.cs # LNBits REST API backend +│ └── Backend.CoinGecko.cs # CoinGecko exchange rate backend ├── Sessions/ # Core session management │ ├── SessionManager.cs # Session storage and lifecycle │ ├── SessionState.cs # Session and participant models @@ -126,7 +132,7 @@ ASPNETCORE_ENVIRONMENT=Development dotnet run ### Lightning Backend -Team Zaps supports multiple Lightning backend implementations through a common `ILightningBackend` interface. **The first backend configured in the `Lightning` section will be selected and used.** +Team Zaps supports multiple Lightning backend implementations through a common `ILightningBackend` interface. **The first backend configured in the `Backends` section will be selected and used.** #### AlbyHub Backend (NWC/NIP-47) @@ -134,7 +140,7 @@ AlbyHub uses the **Nostr Wallet Connect (NWC)** protocol based on NIP-47 for com ```json { - "Lightning": { + "Backends": { "AlbyHub": { "ConnectionString": "nostr+walletconnect://PUBKEY?relay=wss://relay.getalby.com/v1&secret=SECRET", "RelayUrls": [ "wss://relay.getalby.com/v1" ] @@ -159,7 +165,7 @@ LNBits uses a traditional REST API for Lightning operations. Requires a running ```json { - "Lightning": { + "Backends": { "LNBits": { "LndhubUrl": "https://your-lnbits.com/lndhub/ext/", "ApiKey": "YOUR_LNBITS_API_KEY" @@ -172,6 +178,23 @@ LNBits uses a traditional REST API for Lightning operations. Requires a running - `LndhubUrl` - LNDhub extension URL (must end with `/lndhub/ext/`) - `ApiKey` - Invoice/read key from your LNbits wallet +#### CoinGecko Backend (Exchange Rates) + +CoinGecko provides free BTC exchange rate data for fiat currency support. + +```json +{ + "Backends": { + "CoinGecko": { } + } +} +``` + +**Configuration:** +- No settings required - works out of the box +- Automatically fetches fiat exchange rates +- Used by AlbyHub backend to support fiat currency invoices + ### Bot Behavior Options The `BotBehaviorOptions` section controls various aspects of bot behavior: @@ -279,6 +302,78 @@ Team Zaps employs sophisticated message lifecycle management: ## 🔧 Key Services +### Backend Architecture + +Team Zaps uses a **pluggable backend architecture** that allows different service providers to be swapped without changing application code. Backends implement feature-specific interfaces and are automatically discovered via attributes. + +#### Backend Interface Pattern + +All backends must: +1. **Implement one or more backend interfaces** based on provided features: + - `ILightningBackend` - Lightning wallet operations (create/pay invoices, check status) + - `IExchangeRateBackend` - Cryptocurrency exchange rate lookups + +2. **Decorate the class** with `[BackendDescription("BackendName")]` attribute: + ```csharp + [BackendDescription("AlbyHub")] + public class AlbyHubService : ILightningBackend + { + // Implementation... + } + ``` + +#### Available Backends + +**Lightning Backends:** +- **AlbyHub** - NWC (Nostr Wallet Connect) using NIP-47 protocol + - Implements: `ILightningBackend` + - Configuration: `Backends:AlbyHub` section + - Features: Invoice creation, payment, status checks via Nostr relays + +- **LNBits** - Traditional REST API integration + - Implements: `ILightningBackend` + - Configuration: `Backends:LNBits` section + - Features: Full Lightning operations with fiat currency support + +**Exchange Rate Backends:** +- **CoinGecko** - Free cryptocurrency price data + - Implements: `IExchangeRateBackend` + - Configuration: `Backends:CoinGecko` section (empty config - no keys needed) + - API: CoinGecko public API (no authentication required) + - Features: BTC/USD and BTC/EUR rates with 5-minute caching + - Rate limits: 30 calls/minute on free tier + +#### Backend Selection + +Backends are automatically registered based on configuration: +```json +{ + "Lightning": { + "AlbyHub": { /* config */ }, // ← First backend is selected + "LNBits": { /* config */ } + } +} +``` + +The first configured backend in each category is automatically selected and injected into services. + +#### Adding New Backends + +To add a new backend: + +1. Create `Backend.YourService.cs` in `Services/Backends/` +2. Implement required interface(s) (`ILightningBackend`, `IExchangeRateBackend`, etc.) +3. Add `[BackendDescription("YourService")]` attribute + +**Example - Multi-Feature Backend:** +```csharp +[BackendDescription("SuperWallet")] +public class SuperWalletService : ILightningBackend, IExchangeRateBackend +{ + // Implements both Lightning operations AND exchange rates +} +``` + ### SessionManager ```csharp // Central session storage and participant management diff --git a/src/Services/Backends/Backend.AlbyHubService.cs b/src/Services/Backends/Backend.AlbyHubService.cs index 64b99e0..493d302 100644 --- a/src/Services/Backends/Backend.AlbyHubService.cs +++ b/src/Services/Backends/Backend.AlbyHubService.cs @@ -1,12 +1,10 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; using System.Text.Json.Serialization; using NBitcoin; using NBitcoin.Secp256k1; using NNostr.Client; using teamZaps.Configuration; using teamZaps.Backend; +using teamZaps.Communication; using teamZaps.Utils; namespace teamZaps.Backend; @@ -15,54 +13,30 @@ namespace teamZaps.Backend; /// AlbyHub Lightning backend implementation. /// [BackendDescription("AlbyHub")] -public class AlbyHubService : ILightningBackend, IAsyncDisposable +public class AlbyHubService : ILightningBackend, IDisposable { - public AlbyHubService(ILogger logger, IOptions settings) + public AlbyHubService(ILoggerFactory loggerFactory, IOptions settings, IExchangeRateBackend exchangeRateBackend) { - this.logger = logger; + this.logger = loggerFactory.CreateLogger(); this.settings = settings.Value; + this.exchangeRateBackend = exchangeRateBackend; + this.nostr = new NostrWalletConnector(loggerFactory, settings.Value.ConnectionString, settings.Value.RelayUrls); - // Parse NWC connection string: - var connectionUri = new Uri(this.settings.ConnectionString); - if (connectionUri.Scheme != "nostr+walletconnect") - throw new InvalidOperationException("Invalid NWC connection string. Must start with nostr+walletconnect://"); - walletPubkey = connectionUri.Host; - walletPubkeyBytes = Convert.FromHexString(walletPubkey); - - // Extract secret and relay parameters: - var queryParams = ParseQueryString(connectionUri.Query); - secret = (queryParams.TryGetValue("secret", out var secretValue)) && (secretValue.Count > 0) - ? secretValue[0] - : throw new InvalidOperationException("NWC connection string missing 'secret' parameter"); - clientPrivateKey = Context.Instance.CreateECPrivKey(Convert.FromHexString(secret)); - clientPublicKey = Convert.ToHexString(clientPrivateKey.CreateXOnlyPubKey().ToBytes()).ToLower(); - - // Determine relay URLs: - if ((this.settings.RelayUrls is not null) && (this.settings.RelayUrls.Length > 0)) - relays = this.settings.RelayUrls; - else if ((queryParams.TryGetValue("relay", out var relayValues)) && (relayValues.Count > 0)) - relays = relayValues.ToArray(); - else - throw new InvalidOperationException("NWC connection string missing 'relay' parameter(s)"); - - // Initialize Nostr client: - var relayUris = relays - .Select(r => new Uri(r)) - .ToArray(); - nostrClient = new NostrClient(relayUris[0]); - foreach (var relay in relayUris.Skip(1)) - { - } - - logger.LogInformation("AlbyHub initialized with wallet {WalletPubkey} and {RelayCount} relay(s)", walletPubkey[..8] + "...", relays.Length); + logger.LogInformation("AlbyHub initialized with wallet {WalletPubkey} and {RelayCount} relay(s)", $"{nostr.Pubkey[..8]}...", nostr.Relays.Length); } #region Properties - public ulong SentRequests { get; private set; } + public ulong SentRequests => nostr.SentRequests; #endregion + #region Initialization + public void Dispose() + { + nostr.Dispose(); + } + #endregion #region Operation.Invoice public async Task GetBalanceAsync(CancellationToken cancellationToken = default) { @@ -74,80 +48,49 @@ public AlbyHubService(ILogger logger, IOptions Params = new { } }; - var response = await SendNwcRequestAsync(request, cancellationToken); - if (response is null) - return (null); - - return (response.Balance / 1000); + var response = await nostr.SendNwcRequestAsync(request, cancellationToken); + if (response is not null) + return (response.Balance / 1000); } catch (Exception ex) { - logger.LogError(ex, "Error getting wallet balance from AlbyHub"); - return (null); + logger.LogError(ex, "Error getting wallet balance"); } + return (null); } public async Task CreateInvoiceAsync(double amount, PaymentCurrency currency, string? memo = null, CancellationToken cancellationToken = default) { try { - var amountMsat = (currency == PaymentCurrency.Sats) - ? (long)(amount * 1000) - : throw new NotSupportedException($"Currency '{currency.GetDescription()}' not supported by AlbyHub"); + long amountSat; + if (currency == PaymentCurrency.Sats) + amountSat = (long)(amount); + else + amountSat = exchangeRateBackend.ToSats(amount); + var request = new NwcRequest { Method = "make_invoice", Params = new MakeInvoiceParams { - Amount = amountMsat, + Amount = (amountSat * 1000), Description = memo ?? "" } }; - var response = await SendNwcRequestAsync(request, cancellationToken); - if (response is null) - return (null); - - return (new AlbyHubInvoice - { - PaymentRequest = response.Invoice, - PaymentHash = response.PaymentHash - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Error creating invoice via AlbyHub"); - return (null); - } - } - public async Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) - { - try - { - var request = new NwcRequest - { - Method = "lookup_invoice", - Params = new LookupInvoiceParams - { - Invoice = bolt11 - } - }; - - var response = await SendNwcRequestAsync(request, cancellationToken); - if (response is null) - return (null); - - return (new AlbyHubDecodedInvoice - { - Amount = response.Amount / 1000, - Description = response.Description, - PaymentHash = response.PaymentHash - }); + var response = await nostr.SendNwcRequestAsync(request, cancellationToken); + if (response is not null) + return (new AlbyHubInvoice { + PaymentRequest = response.Invoice, + PaymentHash = response.PaymentHash, + SatsAmount = amountSat + }); } catch (Exception ex) { - logger.LogError(ex, "Error decoding invoice via AlbyHub: {Invoice}", bolt11); - return (null); + logger.LogError(ex, "Error creating invoice"); } + return (null); } public async Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) { @@ -162,22 +105,17 @@ public AlbyHubService(ILogger logger, IOptions } }; - var response = await SendNwcRequestAsync(request, cancellationToken); - if (response is null) - return (null); - - return (new AlbyHubPaymentResponse - { - PaymentHash = response.PaymentHash, - Amount = response.Amount / 1000, - Fee = response.FeesPaid / 1000 - }); + var response = await nostr.SendNwcRequestAsync(request, cancellationToken); + if (response is not null) + return (new AlbyHubPaymentResponse { + PaymentHash = response.PaymentHash + }); } catch (Exception ex) { - logger.LogError(ex, "Error paying invoice via AlbyHub"); - return (null); + logger.LogError(ex, "Error paying invoice"); } + return (null); } public async Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default) { @@ -192,255 +130,34 @@ public AlbyHubService(ILogger logger, IOptions } }; - var response = await SendNwcRequestAsync(request, cancellationToken); - if (response is null) - return (null); - - return (new AlbyHubPaymentStatus - { - Paid = response.Settled, - SatsAmount = response.Amount / 1000, - FiatAmount = 0, - FiatRate = 0 - }); - } - catch (Exception ex) - { - logger.LogError(ex, "Error checking payment status via AlbyHub"); - return (null); - } - } - #endregion - - - #region Helper - private static Dictionary> ParseQueryString(string query) - { - var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(query)) - return (result); - - var queryWithoutPrefix = query.TrimStart('?'); - var pairs = queryWithoutPrefix.Split('&', StringSplitOptions.RemoveEmptyEntries); - foreach (var pair in pairs) - { - var parts = pair.Split('=', 2); - var key = Uri.UnescapeDataString(parts[0]); - var value = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty; - if (!result.ContainsKey(key)) - result[key] = new List(); - result[key].Add(value); - } - - return (result); - } - private async Task SendNwcRequestAsync(object request, CancellationToken cancellationToken) where TResult : class - { - try - { - await nostrClient.ConnectAndWaitUntilConnected(cancellationToken); - // Serialize request: - var requestJson = JsonSerializer.Serialize(request); - - // Encrypt content using NIP-04: - var encryptedContent = EncryptNip04(requestJson, walletPubkeyBytes); - - // Create and sign Nostr event: - var evt = new NostrEvent - { - Kind = 23194, - Content = encryptedContent, - CreatedAt = DateTimeOffset.UtcNow, - Tags = new List - { - new NostrEventTag { TagIdentifier = "p", Data = new List { walletPubkey } } - } - }; - await evt.ComputeIdAndSignAsync(clientPrivateKey); - - // Subscribe to response: - var responseReceived = new TaskCompletionSource(); - var subscriptionId = Guid.NewGuid().ToString(); - - void OnEventsReceived(object? sender, (string subscriptionId, NostrEvent[] events) args) - { - if (args.subscriptionId == subscriptionId) - { - foreach (var responseEvent in args.events) - { - var eTag = responseEvent.GetTaggedData("e"); - if ((responseEvent.Kind == 23195) && (eTag.Length > 0) && (eTag[0] == evt.Id)) - { - responseReceived.TrySetResult(responseEvent); - return; - } - } - } - } - - nostrClient.EventsReceived += OnEventsReceived; - - try + var response = await nostr.SendNwcRequestAsync(request, cancellationToken); + if (response is not null) { - // Subscribe before sending: - await nostrClient.CreateSubscription(subscriptionId, new[] - { - new NostrSubscriptionFilter - { - Kinds = new[] { 23195 }, - Authors = new[] { walletPubkey } - } + var isSettled = ((string.Equals(response.State, "settled", StringComparison.OrdinalIgnoreCase)) || (response.Settled == true)); + return (new AlbyHubPaymentStatus { + Paid = isSettled, + SatsAmount = (response.Amount / 1000), + FiatAmount = 0, + FiatRate = 0 }); - - // Send request: - await nostrClient.PublishEvent(evt, cancellationToken); - logger.LogDebug("Sent NWC request {EventId} to AlbyHub", evt.Id); - - // Wait for response with timeout: - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - var responseEvent = await responseReceived.Task.WaitAsync(linkedCts.Token); - - // Decrypt and parse response: - var decryptedContent = DecryptNip04(responseEvent!.Content!, walletPubkeyBytes); - var response = JsonSerializer.Deserialize>(decryptedContent); - - if ((response is not null) && (response.Error is not null)) - { - logger.LogError("AlbyHub returned error: {Code} - {Message}", response.Error.Code, response.Error.Message); - return (null); - } - - return (response?.Result); } - finally - { - nostrClient.EventsReceived -= OnEventsReceived; - await nostrClient.CloseSubscription(subscriptionId); - } - } - catch (OperationCanceledException) - { - logger.LogWarning("AlbyHub request timed out"); - return (null); } catch (Exception ex) { - logger.LogError(ex, "Error sending request to AlbyHub"); - return (null); + logger.LogError(ex, "Error checking payment status"); } - } - - private string EncryptNip04(string plaintext, byte[] recipientPubkey) - { - // Compute shared secret (ECDH): - // Nostr uses 32-byte X-only pubkeys, need to convert to 33-byte compressed (0x02 prefix for even Y) - var compressedPubkey = new byte[33]; - compressedPubkey[0] = 0x02; - Array.Copy(recipientPubkey, 0, compressedPubkey, 1, 32); - var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); - var sharedSecret = sharedPoint.ToBytes()[1..33]; - - // Generate IV: - var iv = RandomUtils.GetBytes(16); - - // Encrypt: - using var aes = Aes.Create(); - aes.Key = sharedSecret; - aes.IV = iv; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - - using var encryptor = aes.CreateEncryptor(); - var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); - var ciphertext = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length); - - // Return base64(ciphertext)?iv=base64(iv): - return ($"{Convert.ToBase64String(ciphertext)}?iv={Convert.ToBase64String(iv)}"); - } - - private string DecryptNip04(string encryptedContent, byte[] senderPubkey) - { - // Parse content: - var parts = encryptedContent.Split("?iv="); - if (parts.Length != 2) - throw new InvalidOperationException("Invalid encrypted content format"); - - var ciphertext = Convert.FromBase64String(parts[0]); - var iv = Convert.FromBase64String(parts[1]); - - // Compute shared secret (ECDH): - // Nostr uses 32-byte X-only pubkeys, need to convert to 33-byte compressed (0x02 prefix for even Y) - var compressedPubkey = new byte[33]; - compressedPubkey[0] = 0x02; - Array.Copy(senderPubkey, 0, compressedPubkey, 1, 32); - var sharedPoint = ECPubKey.Create(compressedPubkey).GetSharedPubkey(clientPrivateKey); - var sharedSecret = sharedPoint.ToBytes()[1..33]; - - // Decrypt: - using var aes = Aes.Create(); - aes.Key = sharedSecret; - aes.IV = iv; - aes.Mode = CipherMode.CBC; - aes.Padding = PaddingMode.PKCS7; - - using var decryptor = aes.CreateDecryptor(); - var plaintext = decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length); - - return (Encoding.UTF8.GetString(plaintext)); + return (null); } #endregion - public ValueTask DisposeAsync() - { - nostrClient?.Dispose(); - return ValueTask.CompletedTask; - } - private readonly ILogger logger; private readonly AlbyHubSettings settings; - private readonly NostrClient nostrClient; - private readonly string walletPubkey; - private readonly byte[] walletPubkeyBytes; - private readonly string secret; - private readonly ECPrivKey clientPrivateKey; - private readonly string clientPublicKey; - private readonly string[] relays; -} - -#region NWC Request/Response Models -file class NwcRequest -{ - [JsonPropertyName("method")] - public string Method { get; set; } = string.Empty; - - [JsonPropertyName("params")] - public object Params { get; set; } = new(); -} - -file class NwcResponse -{ - [JsonPropertyName("result_type")] - public string ResultType { get; set; } = string.Empty; - - [JsonPropertyName("result")] - public T? Result { get; set; } - - [JsonPropertyName("error")] - public NwcError? Error { get; set; } -} - -file class NwcError -{ - [JsonPropertyName("code")] - public string Code { get; set; } = string.Empty; - - [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + private readonly IExchangeRateBackend exchangeRateBackend; + private readonly NostrWalletConnector nostr; } +#region Models.Nostr file class MakeInvoiceParams { [JsonPropertyName("amount")] @@ -491,6 +208,12 @@ file class LookupInvoiceParams file class LookupInvoiceResult { + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("state")] + public string? State { get; set; } + [JsonPropertyName("invoice")] public string Invoice { get; set; } = string.Empty; @@ -503,8 +226,11 @@ file class LookupInvoiceResult [JsonPropertyName("description")] public string? Description { get; set; } + /// + /// Legacy/non-standard field: NIP-47 uses `state` instead. Kept as a fallback. + /// [JsonPropertyName("settled")] - public bool Settled { get; set; } + public bool? Settled { get; set; } } file class GetBalanceResult @@ -513,12 +239,13 @@ file class GetBalanceResult public long Balance { get; set; } } #endregion - -#region Interface Implementations +#region Models.AlbyHub file class AlbyHubInvoice : ILightningInvoice { public string PaymentRequest { get; set; } = string.Empty; public string PaymentHash { get; set; } = string.Empty; + long? ILightningInvoice.SatsAmount => SatsAmount; + public long SatsAmount { get; set; } } file class AlbyHubPaymentResponse : IPaymentResponse @@ -535,11 +262,4 @@ file class GetBalanceResult public double FiatAmount { get; set; } public double FiatRate { get; set; } } - -file class AlbyHubDecodedInvoice : IDecodedInvoice -{ - public long Amount { get; set; } - public string? Description { get; set; } - public string? PaymentHash { get; set; } -} #endregion diff --git a/src/Services/Backends/Backend.CoinGecko.cs b/src/Services/Backends/Backend.CoinGecko.cs new file mode 100644 index 0000000..3bfced6 --- /dev/null +++ b/src/Services/Backends/Backend.CoinGecko.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Reflection; +using System.Text.Json.Serialization; +using teamZaps.Backend; +using teamZaps.Configuration; +using teamZaps.Sessions; +using teamZaps.Utils; + +namespace teamZaps.Backend; + +/// +/// CoinGecko exchange rate backend. +/// +[BackendDescription("CoinGecko")] +public class CoinGeckoService : BackgroundService, IDisposable, IExchangeRateBackend +{ + #region Constants.Settings + static readonly IReadOnlyDictionary SupportedCurrencies = new Dictionary() + { + { PaymentCurrency.Euro, "eur" }, + { PaymentCurrency.Dollar, "usd" } + }; + static readonly string ApiUrl = $"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies={string.Join(",", SupportedCurrencies.Values)}"; + static readonly TimeSpan UpdatePeriod = TimeSpan.FromMinutes(3); + #endregion + + + public CoinGeckoService(ILogger logger, IHttpClientFactory httpClientFactory, SessionManager sessionManager) + { + this.logger = logger; + this.httpClient = httpClientFactory.CreateClient(); + this.sessionManager = sessionManager; + + InvokeRefreshRates(); + Debug.Assert(forceRefresh is not null); + + sessionManager.OnFirstSessionCreated += OnFirstSessionCreated; + } + + + #region Properties + public ulong SentRequests { get; private set; } + + public IReadOnlyDictionary Rates => rates; + private ConcurrentDictionary rates = new(); + public DateTime? LastRateUpdate { get; private set; } + #endregion + + + #region Events + private void OnFirstSessionCreated(object? sender, EventArgs e) => InvokeRefreshRates(); + #endregion + + + #region Initialization + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Refresh exchanges rates periodically: + using var timer = new PeriodicTimer(UpdatePeriod); + while (!stoppingToken.IsCancellationRequested) + { + if (!sessionManager.ActiveSessions.IsEmpty()) + await RefreshRatesAsync(stoppingToken); + + try + { + using var delay = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, forceRefresh.Token); + await timer.WaitForNextTickAsync(delay.Token); + } + catch (OperationCanceledException) + { + } + } + } + public override void Dispose() + { + sessionManager.OnFirstSessionCreated -= OnFirstSessionCreated; + + base.Dispose(); + } + #endregion + #region Management + public void InvokeRefreshRates() + { + var oldToken = Interlocked.Exchange(ref forceRefresh, new CancellationTokenSource()); + + oldToken?.Cancel(); + } + private async Task RefreshRatesAsync(CancellationToken cancellationToken) + { + try + { + var firstUpdate = (LastRateUpdate is null); + + // Refresh exchange rates: + var response = await httpClient.GetStringAsync(ApiUrl, cancellationToken); + SentRequests++; + var jsonDoc = JsonSerializer.Deserialize(response); + if (!jsonDoc.TryGetProperty("bitcoin", out var bitcoinRates)) + { + logger.LogWarning("Failed to parse exchange rate response"); + return; + } + + // Update cache: + LastRateUpdate = DateTime.Now; + rates.Clear(); + foreach (var currency in SupportedCurrencies) + { + if (bitcoinRates.TryGetProperty(currency.Value, out var rateElement)) + { + var rate = rateElement.GetDouble(); + rates.AddOrUpdate(currency.Key, rate, (key, old) => rate); + } + else + { + if (firstUpdate) + throw new NullReferenceException($"Rate of currency '{currency.Key.GetDescription()}' rate not found in response!"); + else + ; // Assume/hope that currency is temporarily not available. + } + } + + if ((this as IExchangeRateBackend).FiatRate is null) + logger.LogWarning("Refreshed exchange rates, but accepted fiat currency rate was not found!"); + else if (firstUpdate) + { + logger.LogInformation("Refreshed exchange rates"); + logger.LogInformation("Will drop further logs as long as exchange rates are reliable"); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error refreshing exchange rates"); + } + } + #endregion + + + private readonly ILogger logger; + private readonly HttpClient httpClient; + private readonly SessionManager sessionManager; + private CancellationTokenSource forceRefresh; +} diff --git a/src/Services/Backends/Backend.LnbitsService.cs b/src/Services/Backends/Backend.LnbitsService.cs index b2caace..60a31fa 100644 --- a/src/Services/Backends/Backend.LnbitsService.cs +++ b/src/Services/Backends/Backend.LnbitsService.cs @@ -69,22 +69,6 @@ public LnbitsService(ILogger logger, IOptions set return (null); } } - public async Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) - { - try - { - var res = await RequestAsync(HttpMethod.Post, "/api/v1/payments/decode", new { data = bolt11 }, cancellationToken).ConfigureAwait(false); - { - res!.Amount = (res.Amount / 1000); // Convert msat to sat - } - return (res); - } - catch (Exception ex) - { - logger.LogError(ex, "Error decoding invoice: {Invoice}", bolt11); - return null; - } - } public async Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default) { @@ -170,6 +154,8 @@ public class LnbitsWalletDetails [JsonPropertyName("payment_hash")] public string PaymentHash { get; set; } = string.Empty; + + long? ILightningInvoice.SatsAmount => null; } file class LnbitsPaymentResponse : IPaymentResponse { @@ -230,26 +216,3 @@ public class LnbitsPaymentExtra [JsonPropertyName("preimage")] public string? Preimage { get; set; } } -file class LnbitsDecodedInvoice : IDecodedInvoice -{ - [JsonPropertyName("amount_msat")] - public long Amount { get; set; } - - [JsonPropertyName("description")] - public string? Description { get; set; } - - [JsonPropertyName("description_hash")] - public string? DescriptionHash { get; set; } - - [JsonPropertyName("payee")] - public string? Payee { get; set; } - - [JsonPropertyName("payment_hash")] - public string? PaymentHash { get; set; } - - [JsonPropertyName("expiry")] - public long? Expiry { get; set; } - - [JsonPropertyName("min_final_cltv_expiry")] - public long? MinFinalCltvExpiry { get; set; } -} diff --git a/src/Services/Backends/Backend.cs b/src/Services/Backends/Backend.cs index 7ba2931..48840e2 100644 --- a/src/Services/Backends/Backend.cs +++ b/src/Services/Backends/Backend.cs @@ -1,4 +1,6 @@ using System.ComponentModel; +using NLightning.Bolt11.Models; +using teamZaps.Configuration; using teamZaps.Utils; namespace teamZaps.Backend; @@ -6,12 +8,19 @@ namespace teamZaps.Backend; public static partial class Common { + #region Constants /// /// Map of available backend types. /// - public static readonly IReadOnlyDictionary BackendTypes = UtilAssembly + public static readonly IReadOnlyDictionary BackendTypes = UtilAssembly .GetDefinedTypeMap() - .ToDictionary(t => t.Value.BackendType.ToLowerInvariant(), t => t.Key); + .ToDictionary( + t => t.Value.BackendType.ToLowerInvariant(), + t => (t.Key, t.Key.GetInterfaces() + .Where(i => typeof(IBackend).IsAssignableFrom(i)) + .ToArray())); + public static readonly string AcceptedFiatPerBitcoinSymbol = $"{BotBehaviorOptions.AcceptedFiatCurrency.ToSymbol()}/{PaymentCurrency.Bitcoin.ToSymbol()}"; + #endregion } /// @@ -23,33 +32,86 @@ public class BackendDescriptionAttribute(string backendType) : Attribute public string BackendType { get; } = backendType; } /// -/// Interface for Lightning wallet backend services. +/// Interface for backend services. /// -public interface ILightningBackend +public interface IBackend { #region Properties /// /// Typename of the backend service. /// - string BackendType => Common.BackendTypes.GetKeyOf(this.GetType()); + string BackendType => Common.BackendTypes.GetKeyOf(t => (t.Type == this.GetType())); /// /// Total number of requests sent to the backend. /// ulong SentRequests { get; } #endregion +} +/// +/// Interface for exchange-rate backend services. +/// +public interface IExchangeRateBackend : IBackend +{ + #region Constants + static readonly TimeSpan ReliableOffset = TimeSpan.FromMinutes(5); + #endregion + + + /// + /// Timestamp of last successful rate update. + /// + DateTime? LastRateUpdate { get; } + /// + /// Indicates whether the exchange rates are considered reliable (recently updated). + /// + bool RatesReliable => ((LastRateUpdate is not null) && ((DateTime.Now - LastRateUpdate.Value) <= ReliableOffset) && (FiatRate is not null)); + /// + /// Contains BTC exchange rate for fiat currencies. + /// + IReadOnlyDictionary Rates { get; } + /// + /// BTC exchange rate for accepted fiat currency. + /// + double? FiatRate => Rates.TryGetValue(BotBehaviorOptions.AcceptedFiatCurrency); + + + #region Operation + /// + /// Convert fiat amount to sats using the current exchange rate. + /// + /// + long ToSats(double fiatAmount) + { + if (LastRateUpdate is null) + throw new NullReferenceException($"No exchange rates available!"); + var fiatRate = FiatRate; + if (fiatRate is null) + throw new NullReferenceException($"No '{Common.AcceptedFiatPerBitcoinSymbol}' exchange rate available!"); + var amountBtc = (fiatAmount / fiatRate); // Fiat to BTC + var amountSats = (amountBtc * 100_000_000); // BTC to sats + return (Convert.ToInt64(amountSats)); + } + /// + /// Convert fiat amount to msats using the current exchange rate. + /// + /// + long ToMsats(double fiatAmount) => ToSats(fiatAmount * 1000); + #endregion +} +/// +/// Interface for Lightning wallet backend services. +/// +public interface ILightningBackend : IBackend +{ #region Operation.Invoice /// /// Create a Lightning invoice. /// Task CreateInvoiceAsync(double amount, PaymentCurrency currency, string? memo = null, CancellationToken cancellationToken = default); /// - /// Decode a BOLT11 Lightning invoice to extract payment details. - /// - Task DecodeInvoiceAsync(string bolt11, CancellationToken cancellationToken = default); - /// /// Pay a BOLT11 Lightning invoice. /// Task PayInvoiceAsync(string bolt11, CancellationToken cancellationToken = default); @@ -57,6 +119,21 @@ public interface ILightningBackend /// Check the payment status of an invoice by payment hash. /// Task CheckPaymentStatusAsync(string paymentHash, CancellationToken cancellationToken = default); + + /// + /// Decodes a lightning invoice to get the amount in sats. + /// + /// + long GetInvoiceAmount(string bolt11) + { + var sats = Invoice.Decode(bolt11)?.Amount?.Satoshi; + if (sats is null) + throw new NullReferenceException("Failed to decode lightning invoice!\n\n" + + "Please provide a valid bolt11 invoice.") + .AddLogLevel(LogLevel.Warning); + else + return (sats!.Value); + } #endregion } @@ -67,16 +144,7 @@ public interface ILightningInvoice { string PaymentRequest { get; } string PaymentHash { get; } -} - -/// -/// Decoded Lightning invoice information. -/// -public interface IDecodedInvoice -{ - long Amount { get; } - string? Description { get; } - string? PaymentHash { get; } + public long? SatsAmount { get; } } /// diff --git a/src/Services/PaymentMonitorService.cs b/src/Services/PaymentMonitorService.cs index f11073d..e90c605 100644 --- a/src/Services/PaymentMonitorService.cs +++ b/src/Services/PaymentMonitorService.cs @@ -39,7 +39,7 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken { foreach (var session in sessionManager.ActiveSessions) { - foreach (var pending in session.PendingPayments.Values.ToArray()) + foreach (var pending in session.PendingPayments.Values) { try { @@ -49,15 +49,20 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken if (status.Paid && !pending.NotifiedPaid) { #if DEBUG - var expectedAmount = ((ITipableAmount)pending).TotalFiatAmount; - var actualAmount = status!.FiatAmount; - var tolerance = Math.Max(0.01, expectedAmount * 0.01); // Allow 1% tolerance, minimum 1 cent - Debug.Assert(Math.Abs(expectedAmount - actualAmount) <= tolerance); + if (status!.SatsAmount > 0) + Debug.Assert(status.SatsAmount == pending.SatsAmount); + if (status!.FiatAmount > 0) + { + var expectedAmount = ((ITipableAmount)pending).TotalFiatAmount; + var actualAmount = status!.FiatAmount; + var tolerance = Math.Max(0.01, expectedAmount * 0.01); // Allow 1% tolerance, minimum 1 cent + Debug.Assert(Math.Abs(expectedAmount - actualAmount) <= tolerance); + } Debug.Assert(pending.Currency == BotBehaviorOptions.AcceptedFiatCurrency); #endif pending.NotifiedPaid = true; - pending.PaidAt = DateTimeOffset.UtcNow; + pending.PaidAt = DateTimeOffset.Now; // Update the payment message to show paid status await PaymentMessage.UpdateAsync(pending, PaymentStatus.Paid, botClient, logger, cancellationToken); @@ -67,12 +72,11 @@ private async Task CheckPendingPaymentsAsync(CancellationToken cancellationToken User = pending.User, PaymentHash = pending.PaymentHash, PaymentRequest = pending.PaymentRequest, - Timestamp = pending.PaidAt ?? DateTimeOffset.UtcNow, + Timestamp = pending.PaidAt ?? DateTimeOffset.Now, Tokens = pending.Tokens, SatsAmount = status!.SatsAmount, FiatAmount = pending.FiatAmount, - TipAmount = pending.TipAmount, - FiatRate = status!.FiatRate + TipAmount = pending.TipAmount }; session.PendingPayments.TryRemove(pending.PaymentHash, out _); diff --git a/src/Services/RecoveryService.cs b/src/Services/RecoveryService.cs index e408fe7..a32b6fd 100644 --- a/src/Services/RecoveryService.cs +++ b/src/Services/RecoveryService.cs @@ -30,10 +30,9 @@ public class RecoveryService : BackgroundService #endregion - public RecoveryService(ILogger logger, ILightningBackend lightningBackend, ITelegramBotClient botClient, IOptions debugSettings) + public RecoveryService(ILogger logger, ITelegramBotClient botClient, IOptions debugSettings) { this.logger = logger; - this.lightningBackend = lightningBackend; this.botClient = botClient; this.debugSettings = debugSettings.Value; @@ -99,7 +98,7 @@ public async Task RecordLostSatsAsync(ParticipantState participant, string reaso UserId = participant.UserId, UserName = participant.UserName(), SatsAmount = participant.SatsAmount, - Timestamp = DateTimeOffset.UtcNow, + Timestamp = DateTimeOffset.Now, Reason = reason, LastNotified = null // Reset notification timestamp for new payment }; @@ -196,7 +195,7 @@ await botClient.SendMessage(record.UserId, message, logger.LogInformation("Notified user {User} about {SatsAmount} of lost funds", record.DisplayName(), record.SatsAmount.Format()); // Update the record with notification timestamp: - record.LastNotified = DateTimeOffset.UtcNow; + record.LastNotified = DateTimeOffset.Now; await WriteRecordAsync(record); } catch (Exception ex) @@ -261,7 +260,6 @@ private async Task DeleteRecordAsync(long userId) private readonly ILogger logger; - private readonly ILightningBackend lightningBackend; private readonly ITelegramBotClient botClient; private readonly DebugSettings debugSettings; } @@ -279,7 +277,7 @@ public class LostSatsRecord : IUserName #region Properties.Management [JsonIgnore] - public bool NotificationRequired => (LastNotified is null) || ((DateTimeOffset.UtcNow - LastNotified.Value) < RecoveryNotificationPeriod); + public bool NotificationRequired => (LastNotified is null) || ((DateTimeOffset.Now - LastNotified.Value) < RecoveryNotificationPeriod); #endregion #region Properties public required long UserId { get; set; } diff --git a/src/Sessions/SessionManager.cs b/src/Sessions/SessionManager.cs index 3ddf66d..47ccee2 100644 --- a/src/Sessions/SessionManager.cs +++ b/src/Sessions/SessionManager.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using teamZaps.Configuration; using teamZaps.Services; +using teamZaps.Utils; namespace teamZaps.Sessions; @@ -30,6 +31,12 @@ public SessionManager(ILogger logger, IOptions sessions.TryGetValue(chatId, out var session) ? session : null; @@ -86,7 +98,7 @@ public bool RemoveSession(long chatId, bool cancel) lastSummaries[chatId] = new SessionSummary( session.StartedAt, - DateTimeOffset.UtcNow, + DateTimeOffset.Now, session.SatsAmount, session.FiatAmount, session.Participants.Count, @@ -94,6 +106,9 @@ public bool RemoveSession(long chatId, bool cancel) session.WinnerUser?.DisplayName(), session.PayoutCompleted); } + + if (sessions.IsEmpty()) + OnLastSessionRemoved?.Invoke(this, EventArgs.Empty); } return (removed); } diff --git a/src/Sessions/SessionState.cs b/src/Sessions/SessionState.cs index ae4606c..99bcd83 100644 --- a/src/Sessions/SessionState.cs +++ b/src/Sessions/SessionState.cs @@ -101,7 +101,6 @@ public record PaymentRecord() : IUser, ITipableAmount long IFormattableAmount.SatsAmount => this.SatsAmount; public required long SatsAmount; double IFormattableAmount.FiatAmount => this.FiatAmount; - public required double FiatRate; double ITipableAmount.TipAmount => this.TipAmount; public required double TipAmount; public required double FiatAmount; @@ -125,12 +124,13 @@ public class PendingPayment : IUser, ITipableAmount public required PaymentToken[] Tokens { get; init; } public required PaymentCurrency Currency { get; init; } - long IFormattableAmount.SatsAmount => 0; + long IFormattableAmount.SatsAmount => (SatsAmount ?? 0); + public long? SatsAmount { get; init; } public required double TipAmount { get; init; } public required double FiatAmount { get; init; } - public override string ToString() => $"{User}: {this}"; + public override string ToString() => $"{User}: {(this as ITipableAmount).FormatAmount()}"; } public enum SessionPhase diff --git a/src/Utils.cs b/src/Utils.cs index 05bc4ae..483cf99 100644 --- a/src/Utils.cs +++ b/src/Utils.cs @@ -114,6 +114,7 @@ internal static partial class ExtType else return (null); } + public static bool IsAssignableTo(this Type source) => (source?.IsAssignableTo(typeof(T)) == true); } internal static class ExtFieldInfo { @@ -177,6 +178,7 @@ public static bool IsEmpty(this IEnumerable source) { return ((source is null) || (!source.Any())); } + public static IEnumerable WhereNotNull(this IEnumerable source) => source.Where(t => t is not null); } internal static partial class ExtDictionary { @@ -208,6 +210,13 @@ public static T GetKeyOf(this IEnumerable> source, Pred else throw new NotImplementedException($"Failed to obtain key due to conditional missmatch!"); } + public static U? TryGetValue(this IReadOnlyDictionary source, T key) + { + if (source.TryGetValue(key, out var value)) + return (value); + else + return (default); + } } internal static partial class ExtString { diff --git a/src/appsettings.json b/src/appsettings.json index 0ca506c..0c1e12d 100644 --- a/src/appsettings.json +++ b/src/appsettings.json @@ -3,7 +3,7 @@ "BotToken": "YOUR_BOT_TOKEN_HERE", "RootUsers": [] }, - "Lightning": { + "Backends": { "LNBits": { "LndhubUrl": "YOUR_LNDHUB_URL_HERE", "ApiKey": "YOUR_API_KEY_HERE" diff --git a/src/teamZaps.csproj b/src/teamZaps.csproj index 6a495e6..d72cf79 100644 --- a/src/teamZaps.csproj +++ b/src/teamZaps.csproj @@ -15,6 +15,7 @@ + From 1570471172fbba151dda70b87a9338b923452625 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Sat, 20 Dec 2025 20:52:54 +0100 Subject: [PATCH 5/8] Fix start private bot chat --- src/Handlers/UpdateHandler.GroupMessage.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Handlers/UpdateHandler.GroupMessage.cs b/src/Handlers/UpdateHandler.GroupMessage.cs index 8ab3ab4..1c01cfe 100644 --- a/src/Handlers/UpdateHandler.GroupMessage.cs +++ b/src/Handlers/UpdateHandler.GroupMessage.cs @@ -173,14 +173,18 @@ private async Task HandleJoinSessionAsync(ITelegramBotClient botClient, long cha } catch (Exception) { + var botUser = await botClient.GetMe(cancellationToken); var warningMessage = await botClient.SendMessage(chatId, - $"⚠️ {user.ToMarkdownString()}, please start a private chat with me first by clicking @{(await botClient.GetMe(cancellationToken)).Username}", + $"Hey @{user.Username}, we did not meet before ✌️\n" + + "I'm a telegram bot, *helping you* and your friends *to coordinate lightning payments*.\n\n" + + $"ℹ️ Please *start a private chat* to interact with me, by clicking @{botUser.Username}. See you soon 👍", parseMode: ParseMode.Markdown, cancellationToken: cancellationToken); // Mark user as pending session join with message ID for later deletion session.PendingJoins[user.Id] = (chatId, warningMessage.MessageId); + logger.LogInformation("Invited new user {User} to a private bot chat.", user.DisplayName()); return; } From 6115421a6e14436b6449368059e4a136bb7e27f3 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Sat, 20 Dec 2025 21:27:29 +0100 Subject: [PATCH 6/8] Improve pinned messages We should expect that some group chat will not grant the bot _pin messages_ permission. Now we check permissions before pinning messages to avoid errors in case we have none. --- src/Handlers/UpdateHandler.GroupMessage.cs | 5 +++++ src/Helper/MessageHelper.Status.cs | 9 ++++----- src/Services/TelegramBotService.cs | 17 +++++++++++++++++ src/Sessions/SessionState.cs | 1 + 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Handlers/UpdateHandler.GroupMessage.cs b/src/Handlers/UpdateHandler.GroupMessage.cs index 1c01cfe..415287a 100644 --- a/src/Handlers/UpdateHandler.GroupMessage.cs +++ b/src/Handlers/UpdateHandler.GroupMessage.cs @@ -46,7 +46,12 @@ private async Task HandleStartSessionAsync(ITelegramBotClient botClient, Command throw new UnauthorizedAccessException("Only group administrators can start a session."); if (workflowService.TryStartSession(chat, command.From, out var session)) + { + // Check if messages can be pinned + session.BotCanPinMessages = await botClient.BotCanPinMessagesAsync(chat.Id, cancellationToken); + await SessionStatusMessage.SendAsync(session, botClient, workflowService, cancellationToken); + } else throw new InvalidOperationException("A session is already active in this group!") .AddLogLevel(LogLevel.Warning); diff --git a/src/Helper/MessageHelper.Status.cs b/src/Helper/MessageHelper.Status.cs index bba1fb5..fc936e6 100644 --- a/src/Helper/MessageHelper.Status.cs +++ b/src/Helper/MessageHelper.Status.cs @@ -25,7 +25,8 @@ public static async Task SendAsync(SessionState session, ITelegramBotCl session.StatusMessageId = statusMessage.MessageId; - await botClient.PinChatMessage(session.ChatId, statusMessage.MessageId, cancellationToken: cancellationToken); + if (session.BotCanPinMessages) + await botClient.PinChatMessage(session.ChatId, statusMessage.MessageId, cancellationToken: cancellationToken); return (statusMessage); } @@ -60,14 +61,12 @@ await botClient.EditMessageText( logger.LogWarning(ex, "Failed to update pinned status message for session {Session}", session); } - if ((session.Phase.IsClosed()) && (session.StatusMessageId is not null)) - { - // Unpin status message + // Unpin status message + if ((session.BotCanPinMessages) && (session.Phase.IsClosed()) && (session.StatusMessageId is not null)) await botClient.UnpinChatMessage( chatId: session.ChatId, messageId: session.StatusMessageId.Value, cancellationToken: cancellationToken); - } } private static async Task RecreateAsync(SessionState session, ITelegramBotClient botClient, SessionWorkflowService workflowService, ILogger logger, CancellationToken cancellationToken) { diff --git a/src/Services/TelegramBotService.cs b/src/Services/TelegramBotService.cs index e90b255..d49d165 100644 --- a/src/Services/TelegramBotService.cs +++ b/src/Services/TelegramBotService.cs @@ -113,4 +113,21 @@ public static bool TryGetCommand(this Message source, [NotNullWhen(true)] out Co return (command is not null); } + public static async Task BotCanPinMessagesAsync(this ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken = default) + { + try + { + var me = await botClient.GetMe(cancellationToken); + var member = await botClient.GetChatMember(chatId, me.Id, cancellationToken); + if (member is ChatMemberOwner) + return (true); + if (member is ChatMemberAdministrator admin) + return (admin.CanPinMessages); + } + catch (ApiRequestException ex) when (ex.ErrorCode == 400 || ex.ErrorCode == 403) + { + // Chat not found OR bot was kicked/forbidden + } + return (false); + } } \ No newline at end of file diff --git a/src/Sessions/SessionState.cs b/src/Sessions/SessionState.cs index 99bcd83..f51bce1 100644 --- a/src/Sessions/SessionState.cs +++ b/src/Sessions/SessionState.cs @@ -15,6 +15,7 @@ public class SessionState : ITipableAmount public required ParticipantState StartedByUser { get; init; } public required DateTimeOffset StartedAt { get; init; } + public bool BotCanPinMessages { get; set; } public SessionPhase Phase { get; set; } = SessionPhase.AcceptingPayments; From b6dab31804470c8f1ea978918bd5bafc9edb92c8 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Sat, 20 Dec 2025 21:36:09 +0100 Subject: [PATCH 7/8] Minor fixes and improvements - Improved readme - Add MIT license --- LICENSE | 21 ++++++ README.MD | 72 ++++++++++++--------- src/Handlers/UpdateHandler.DirectMessage.cs | 25 +++---- src/README.md | 24 ++++++- 4 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c915d75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SatMeNow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.MD b/README.MD index 00afc7d..ae20ac4 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # Team Zaps 🎯⚡ -> **Split bills with Bitcoin Lightning in Telegram groups!** +**Split bills with Bitcoin Lightning in Telegram groups!** Team Zaps helps groups coordinate Lightning payments for shared expenses. When Bitcoin isn't accepted at your favorite restaurant or bar, use Team Zaps to let everyone pay in sats while one person handles the fiat transaction. @@ -21,35 +21,42 @@ Your team meets up for food and drinks, but the venue doesn't accept Bitcoin? He ## 🚀 Getting Started -### 1. Add Bot to Your Group -1. Add `@TeamZapsBot` to your Telegram group (replace with your actual bot username) -2. Grant the bot admin rights to pin messages +Follow the appropriate path below depending on whether you are a group admin or a regular user. -### 2. Start Using Team Zaps +### Admins: Set up the bot for your group +1. Add the bot to your group: search for `@TeamZapsBot` and invite it. +2. Open `Group Info` → `Administrators` → Promote the bot to an administrator. + - Grant these permissions at minimum: + - **Delete messages** + - Optional but recommended: + - **Pin messages** (if pinned session status is desired) +3. Confirm the bot appears in the admin list. +4. Configure who may start/close sessions in the bot settings (admins only by default). -**In your group chat:** +### Users: Join and use Team Zaps +Once the bot is added and has necessary permissions, users can interact: + +In the group chat: ``` -/startsession - Start a new payment session -/help - View all available commands +/startsession # Admins only (configurable) +/status # Show current session status +/help # Show help and commands ``` -**The bot will guide you through:** -- Joining the session -- Entering the lottery (who's willing to pay fiat) -- Making Lightning payments in private chat -- Drawing the winner and executing the payout - -### 3. Payment Flow - -1. **Join Session** - Click the "🎯 Join" button on the pinned message -2. **Enter Lottery** - Click "🎰 Enter Lottery" if you're willing to pay the bill -3. **Send Payments** - In private chat with the bot, send amounts like: - - `5.99 beer` - - `12.50 pizza + 3.00 tip` - - Multiple lines also work! -4. **Pay Invoices** - Pay the Lightning invoices the bot sends you -5. **Wait for Draw** - When the group closes the session, a winner is drawn -6. **Automatic Payout** - Winner submits Lightning invoice and receives all payments +How to participate: +- Click the pinned session message's **🎯 Join** button to join a session. +- If you're willing to pay the bill in fiat, click **🎰 Enter Lottery**. +- For payments and private info, open a private chat with the bot and send amounts like: + - `5.99 beer` + - `12.50 pizza + 3.00 tip` + - Multiple lines are allowed. + +Payment flow summary: +1. Join session in group +2. Enter lottery (optional) +3. Send payments in private chat +4. Admin closes session → winner is drawn +5. Winner submits Lightning invoice → automatic payout ## ⚡ Payment Examples @@ -71,15 +78,19 @@ The bot accepts various payment formats: ## 🔧 Bot Commands +### Group (use in group chats) | Command | Description | Who can Use | |---------|-------------|-------------| | `/startsession` | Start a new payment session | Admins (configurable) | | `/closesession` | Close payments and draw winner | Admins (configurable) | | `/cancelsession` | Cancel the current session | Admins | | `/status` | View current session status | Anyone | -| `/recover` | Recover lost sats from failed sessions | Anyone (private chat) | -| `/help` | Show help information | Anyone | -| `/diag` | Show diagnostics | Root users | + +### Private (use in direct messages with the bot) +| Command | Description | +|---------|-------------| +| `/recover` | Recover lost sats from failed sessions | +| `/help` | Show help information | ## 💰 Lost and Found Recovery @@ -140,7 +151,10 @@ Want to run your own Team Zaps bot? Check the [developer documentation](src/READ ## 🤝 Contributing -Contributions are welcome! Please read the [developer documentation](src/README.md) for technical details and contribution guidelines. +Contributions are welcome + +Please read the [developer documentation](src/README.md) for technical details and contribution guidelines. +You will find the [repository](https://github.com/SatMeNow/teamZaps) on github. ## 📞 Support diff --git a/src/Handlers/UpdateHandler.DirectMessage.cs b/src/Handlers/UpdateHandler.DirectMessage.cs index b24fd28..fb3e310 100644 --- a/src/Handlers/UpdateHandler.DirectMessage.cs +++ b/src/Handlers/UpdateHandler.DirectMessage.cs @@ -44,18 +44,21 @@ await botClient.SendMessage(command.ChatId, case "/help": await botClient.SendMessage(command.ChatId, "🎯 *Team Zaps Help*\n\n" + - "*Commands:*\n" + - "/startsession - Start a new payment session\n" + - "/closesession - Close payments and start lottery\n" + - "/cancelsession - Cancel session (admin only)\n" + - "/status - View session details\n" + - "/recover - Recovers lost sats from e.g. interrupted sessions\n\n" + - "*How it works:*\n" + - "1️⃣ Join the session using the button on the pinned message\n" + - "2️⃣ Send me payment amounts in *private chat*\n" + + "*Group commands* (use in a group chat):\n" + + "/startsession - Start a new payment session (maybe for admins only)\n" + + "/closesession - Close payments and start lottery (maybe for admins only)\n" + + "/cancelsession - Cancel session (maybe for admins only)\n\n" + + "*Private commands* (use in direct message with the bot):\n" + + "/status - View session details (in group or private)\n" + + "/recover - Recover lost sats from interrupted sessions (private chat)\n" + + "/help - Show this help message\n\n" + + "*How to participate:*\n" + + "1️⃣ Join the session using the button on the status message in the group\n" + + "2️⃣ Send payment amounts here in *private chat*\n" + "3️⃣ Pay the Lightning invoices I send you\n" + - "4️⃣ Join the lottery when payments close!\n\n" + - "💡 *All payments happen in private messages for privacy!*", + "4️⃣ If you opted into the lottery, wait for the draw when the admin closes payments\n\n" + + "💡 *Payments and invoices are handled in private messages for privacy.*\n\n" + + "ℹ️ For *detailed info*, check out the [GitHub Repository](https://github.com/SatMeNow/teamZaps).", parseMode: ParseMode.Markdown, cancellationToken: cancellationToken); break; diff --git a/src/README.md b/src/README.md index 7b30619..0f27b09 100644 --- a/src/README.md +++ b/src/README.md @@ -660,11 +660,33 @@ COPY publish/ . ENTRYPOINT ["dotnet", "teamZaps.dll"] ``` +## ✅ User-facing Commands (quick reference) + +Use the commands below in the appropriate context — group chats or private/direct messages with the bot. + +> Explained commands in this section are only relevant for technical users! + For end-user guidance (screenshots and UX tips) see the user-facing README in the project root: [README.MD](../README.MD) + +### Group commands (use inside the group chat) + +### Private commands (use in a direct/private chat with the bot) +- `/diag` - Show diagnostics (root users only) + + This command returns detailed runtime diagnostics intended for the bot operator (root user) only. It includes: + - Current host environment and process information + - Active sessions and their phases + - Recovery queue status and lost sats summary + - Registered backends and their health status + + Only root user IDs (configured in `appsettings.*.json` under `Telegram:RootUsers`) can run `/diag`. The output may contain sensitive operational details — do not share publicly. + ## 🤝 Contributing +You will find the [repository](https://github.com/SatMeNow/teamZaps) on github. + ### Pull Request Process -1. **Fork & Branch** - Create feature branches from `main` +1. **Fork & Branch** - Create feature branches from `master` 2. **Follow Patterns** - Match existing code style and architecture 3. **Test Thoroughly** - Manual testing at minimum, unit tests preferred 4. **Update Documentation** - Keep this README current From a7d1f3ea6516531af4b48e316b6137f232ebc6d0 Mon Sep 17 00:00:00 2001 From: SatMeNow Date: Sun, 21 Dec 2025 22:18:53 +0100 Subject: [PATCH 8/8] ci: add CI/CD pipeline with Docker and versioning - Add multi-stage Dockerfile for optimized .NET 9 builds - Add GitHub Actions workflow (.github/workflows/deploy.yml) for: - Building and testing on push to master - Automatic semantic versioning based on commit messages - Docker image creation and push to GHCR - GitHub Release creation with binary artifacts - Add docker-compose.yml for easy production deployment - Update developer documentation with CI/CD and versioning guide - Add GitHub repository link to READMEs and bot help command --- .env.example | 35 ++++++++ .github/workflows/deploy.yml | 154 +++++++++++++++++++++++++++++++++++ Dockerfile | 32 ++++++++ docker-compose.yml | 30 +++++++ src/.env.example | 3 - src/README.md | 28 +++++++ 6 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100644 src/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c983439 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ========================================== +# ⚙️ BOT BEHAVIOR & LIMITS +# ========================================== +# Max budget in Euro across all sessions (Optional) +MAX_BUDGET=1000 + +# Max concurrent sessions (Optional) +MAX_PARALLEL_SESSIONS=10 + +# ========================================== +# 🤖 TELEGRAM CONFIGURATION +# ========================================== +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# Root Users (Admins) +# Add up to 3 root user IDs (leave empty if not used) +TELEGRAM_ROOT_USER_ID_1=123456789 +TELEGRAM_ROOT_USER_ID_2= +TELEGRAM_ROOT_USER_ID_3= + +# ========================================== +# ⚡ LIGHTNING BACKEND: ALBY HUB (NWC) +# ========================================== +# Primary backend. Uses Nostr Wallet Connect. +# Format: nostr+walletconnect://PUBKEY?relay=RELAY_URL&secret=SECRET +ALBYHUB_CONNECTION_STRING=your_nwc_connection_string_here + +# ========================================== +# ⚡ LIGHTNING BACKEND: LNBITS (Alternative) +# ========================================== +# To use LNBits instead of AlbyHub: +# 1. Uncomment these lines +# 2. Uncomment the corresponding lines in docker-compose.yml +# LNBITS_URL=https://legend.lnbits.com/lndhub/ext/ +# LNBITS_API_KEY=your_lnbits_admin_key diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f94c3aa --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,154 @@ +name: Build and Deploy + +on: + push: + branches: [ "master" ] + tags: [ "v*" ] + pull_request: + branches: [ "master" ] + workflow_dispatch: + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + # 1. Calculate the next version based on commits + calculate-version: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + outputs: + new_tag: ${{ steps.tag_version.outputs.new_tag }} + changelog: ${{ steps.tag_version.outputs.changelog }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required to calculate version history + + - name: Bump version and push tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch # Default to patch (v1.0.0 -> v1.0.1) if no #major/#minor found + + build-and-test: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Restore dependencies + run: dotnet restore src/teamZaps.csproj + + - name: Build + run: dotnet build src/teamZaps.csproj --no-restore --configuration Release + + # TODO: No tests implemented yet + # - name: Test + # run: dotnet test src/teamZaps.csproj --no-build --verbosity normal + + - name: Publish + run: dotnet publish src/teamZaps.csproj --configuration Release --output ./publish + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: teamZaps-binaries + path: ./publish + + docker-build-push: + needs: [build-and-test, calculate-version] + runs-on: ubuntu-latest + # Only run if we are on master (auto-tagging) OR if a tag was pushed manually + if: | + always() && + (needs.build-and-test.result == 'success') && + (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + # Add the auto-calculated tag if available + type=raw,value=${{ needs.calculate-version.outputs.new_tag }},enable=${{ needs.calculate-version.outputs.new_tag != '' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + create-release: + needs: [build-and-test, docker-build-push, calculate-version] + # Run if we are on master (auto-tagging) OR if a tag was pushed manually + if: | + always() && + (needs.build-and-test.result == 'success') && + (needs.calculate-version.outputs.new_tag != '' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + name: teamZaps-binaries + path: ./teamZaps + + # Determine the tag name to use (either auto-calculated or manual) + - name: Set Tag Name + id: tag_name + run: | + if [ -n "${{ needs.calculate-version.outputs.new_tag }}" ]; then + echo "tag=${{ needs.calculate-version.outputs.new_tag }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Zip Artifacts + run: zip -r teamZaps-${{ steps.tag_name.outputs.tag }}.zip ./teamZaps + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.tag_name.outputs.tag }} + files: teamZaps-${{ steps.tag_name.outputs.tag }}.zip + generate_release_notes: true + body: ${{ needs.calculate-version.outputs.changelog }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1ba0ff1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Base image for runtime +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app + +# SDK image for building +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy csproj and restore dependencies +COPY ["src/teamZaps.csproj", "src/"] +RUN dotnet restore "src/teamZaps.csproj" + +# Copy the rest of the source code +COPY . . +WORKDIR "/src/src" + +# Build the application +RUN dotnet build "teamZaps.csproj" -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish "teamZaps.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . + +# Create directory for data/logs if needed (optional, based on app needs) +# RUN mkdir -p /app/data + +ENTRYPOINT ["dotnet", "teamZaps.dll"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1aaa7cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + teamzaps: + image: ghcr.io/satmenow/teamzaps:latest + container_name: teamzaps + restart: unless-stopped + environment: + - DOTNET_ENVIRONMENT=Production + + # Bot Behavior + - BotBehavior__MaxBudget=${MAX_BUDGET} + - BotBehavior__MaxParallelSessions=${MAX_PARALLEL_SESSIONS} + + # Telegram Settings + - Telegram__BotToken=${TELEGRAM_BOT_TOKEN} + - Telegram__RootUsers__0=${TELEGRAM_ROOT_USER_ID_1} + - Telegram__RootUsers__1=${TELEGRAM_ROOT_USER_ID_2} + - Telegram__RootUsers__2=${TELEGRAM_ROOT_USER_ID_3} + + # Backend: AlbyHub (NWC) + - Backends__AlbyHub__ConnectionString=${ALBYHUB_CONNECTION_STRING} + + # Backend: LNBits (Alternative) + # - Backends__LNBits__LndhubUrl=${LNBITS_URL} + # - Backends__LNBits__ApiKey=${LNBITS_API_KEY} + + volumes: + # Mount appsettings.json if you want to configure it from the host + - ./appsettings.json:/app/appsettings.json + # Mount data directory if the app writes logs or data + - ./data:/app/data diff --git a/src/.env.example b/src/.env.example deleted file mode 100644 index da57a79..0000000 --- a/src/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# Telegram Bot Configuration -# Get your bot token from @BotFather on Telegram -Telegram__BotToken=YOUR_BOT_TOKEN_HERE diff --git a/src/README.md b/src/README.md index 0f27b09..a8bbc45 100644 --- a/src/README.md +++ b/src/README.md @@ -660,6 +660,34 @@ COPY publish/ . ENTRYPOINT ["dotnet", "teamZaps.dll"] ``` +## 🔄 CI/CD & Versioning + +The project uses GitHub Actions for Continuous Integration and Deployment. + +### Automated Pipeline +The pipeline (`.github/workflows/deploy.yml`) automatically runs on: +- Pushes to `master` +- Pull Requests to `master` +- Tag pushes (`v*`) + +**It performs the following steps:** +1. **Build & Test**: Compiles the code and runs tests (if any). +2. **Auto-Tagging**: Calculates the next semantic version based on commit messages. +3. **Docker Build**: Builds a multi-stage Docker image with the new version tag. +4. **Publish**: Pushes the image to GitHub Container Registry (GHCR). +5. **Release**: Creates a GitHub Release with binaries and changelog. + +### Controlling Version Bumps +The versioning system follows [Semantic Versioning](https://semver.org/). You can control the version bump by including specific keywords in your **commit messages** or **PR titles**: + +| Keyword | Effect | Example | +|---------|--------|---------| +| `#major` | Major version bump (X.0.0) | `feat: rewrite core engine #major` | +| `#minor` | Minor version bump (0.X.0) | `feat: add new payment method #minor` | +| (none) | Patch version bump (0.0.X) | `fix: typo in readme` | + +*Default behavior is a **Patch** bump if no keyword is found.* + ## ✅ User-facing Commands (quick reference) Use the commands below in the appropriate context — group chats or private/direct messages with the bot.