feat: zap2boost
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
This commit is contained in:
parent
fb3559eb84
commit
d1cb619316
|
@ -2,4 +2,6 @@ bin/
|
|||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
/_ReSharper.Caches/
|
||||
appsettings.json
|
||||
data/
|
|
@ -0,0 +1,13 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/projectSettingsUpdater.xml
|
||||
/.idea.NostrServices.iml
|
||||
/contentModel.xml
|
||||
/modules.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
|
@ -0,0 +1,13 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,35 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace NostrServices.Client;
|
||||
|
||||
public class NostrServicesClient
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public NostrServicesClient(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
_client.BaseAddress = new Uri("https://nostr.api.v0l.io");
|
||||
}
|
||||
|
||||
public async Task<NostrProfile?> Profile(string id)
|
||||
{
|
||||
var rsp = await _client.GetAsync($"/api/v1/export/profile/{id}");
|
||||
if (rsp.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await rsp.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<NostrProfile>(json);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public class NostrProfile
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonProperty("lud16")]
|
||||
public string? LightningAddress { get; init; }
|
||||
}
|
||||
}
|
|
@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FASTERlay", "FASTERlay\FAST
|
|||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicTests", "BasicTests\BasicTests.csproj", "{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PayForReactions", "PayForReactions\PayForReactions.csproj", "{18CFBD86-576C-4185-82E4-A04866E2ED22}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrServices.Client", "NostrServices.Client\NostrServices.Client.csproj", "{17CA7036-95BC-4851-BAB1-419996EA2761}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
@ -37,5 +41,13 @@ Global
|
|||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{17CA7036-95BC-4851-BAB1-419996EA2761}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{17CA7036-95BC-4851-BAB1-419996EA2761}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{17CA7036-95BC-4851-BAB1-419996EA2761}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{17CA7036-95BC-4851-BAB1-419996EA2761}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
@ -59,9 +59,8 @@ public class OpenGraphController : Controller
|
|||
}
|
||||
}
|
||||
|
||||
if ((NostrIdentifierParser.TryParse(id, out var nid) && nid != default) ||
|
||||
(nid = NostrBareIdentifier.Parse(id)) != default ||
|
||||
(nid = await TryParseNip05(id, cts)) != default)
|
||||
var nid = await Extensions.TryParseIdentifier(id);
|
||||
if (nid != default)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -221,37 +220,6 @@ public class OpenGraphController : Controller
|
|||
}
|
||||
|
||||
record HeadElement(string Element, List<KeyValuePair<string, string>> Attributes);
|
||||
|
||||
private async Task<NostrIdentifier?> TryParseNip05(string id, CancellationToken cts)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (id.Contains("@"))
|
||||
{
|
||||
var idSplit = id.Split("@");
|
||||
var url = new Uri($"https://{idSplit[1]}/.well-known/nostr.json?name={Uri.EscapeDataString(idSplit[0])}");
|
||||
var json = await _httpClient.GetStringAsync(url, cts);
|
||||
var parsed = JsonConvert.DeserializeObject<NostrJson>(json);
|
||||
var match = parsed?.Names?.FirstOrDefault(a => a.Key.Equals(idSplit[0], StringComparison.CurrentCultureIgnoreCase));
|
||||
if (match.HasValue && !string.IsNullOrEmpty(match.Value.Value))
|
||||
{
|
||||
return new NostrProfileIdentifier(match.Value.Value.ToLower(), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Failed to parse nostr address {id} {msg}", id, ex.Message);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
class NostrJson
|
||||
{
|
||||
[JsonProperty("names")]
|
||||
public Dictionary<string, string>? Names { get; init; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
[ProtoContract]
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
using System.Net.Http.Headers;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace PayForReactions;
|
||||
|
||||
public class AlbyApi
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public AlbyApi(HttpClient client, Config config)
|
||||
{
|
||||
_client = client;
|
||||
_client.BaseAddress = new("https://api.getalby.com");
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(config.Alby.Type, config.Alby.Token);
|
||||
}
|
||||
|
||||
public async Task<long> GetBalance()
|
||||
{
|
||||
var json = await _client.GetStringAsync("/balance");
|
||||
var obj = JsonConvert.DeserializeObject<BalanceResponse>(json);
|
||||
return (long?)obj?.Balance ?? 0L;
|
||||
}
|
||||
|
||||
public async Task<bool> PayInvoice(string pr)
|
||||
{
|
||||
var rsp = await _client.PostAsync("/payments/bolt11", new StringContent(JsonConvert.SerializeObject(new
|
||||
{
|
||||
invoice = pr
|
||||
}), new MediaTypeHeaderValue("application/json")));
|
||||
|
||||
return rsp.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
class BalanceResponse
|
||||
{
|
||||
[JsonProperty("balance")]
|
||||
public decimal Balance { get; init; }
|
||||
|
||||
[JsonProperty("currency")]
|
||||
public string Currency { get; init; } = "BTC";
|
||||
|
||||
[JsonProperty("unit")]
|
||||
public string Unit { get; init; } = "sat";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
namespace PayForReactions;
|
||||
|
||||
public class Config
|
||||
{
|
||||
public OAuthToken Alby { get; init; } = null!;
|
||||
|
||||
public string PrivateKey { get; init; } = null!;
|
||||
|
||||
public string Target { get; init; } = null!;
|
||||
}
|
||||
|
||||
public class OAuthToken
|
||||
{
|
||||
public string Token { get; init; } = null!;
|
||||
public string Type { get; init; } = "Bearer";
|
||||
public string RefreshToken { get; init; } = null!;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["PayForReactions/PayForReactions.csproj", "PayForReactions/"]
|
||||
RUN dotnet restore "PayForReactions/PayForReactions.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/PayForReactions"
|
||||
RUN dotnet build "PayForReactions.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "PayForReactions.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "PayForReactions.dll"]
|
|
@ -0,0 +1,187 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Utils;
|
||||
|
||||
public enum LNURLErrorCode
|
||||
{
|
||||
ServiceUnavailable = 1,
|
||||
InvalidLNURL = 2,
|
||||
}
|
||||
|
||||
public class LNURLError : Exception
|
||||
{
|
||||
public LNURLErrorCode Code { get; private set; }
|
||||
|
||||
public LNURLError(LNURLErrorCode code, string message) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
}
|
||||
|
||||
public class Lnurl
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public Lnurl(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public static Uri? ParseLnUrl(string lnurl)
|
||||
{
|
||||
var emailRegex =
|
||||
new Regex(
|
||||
"^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$");
|
||||
|
||||
lnurl = lnurl.ToLower().Trim();
|
||||
|
||||
if (lnurl.StartsWith("lnurlp:"))
|
||||
{
|
||||
return new Uri(lnurl.Replace("lnurlp://", "https://"));
|
||||
}
|
||||
|
||||
if (lnurl.StartsWith("lnurl"))
|
||||
{
|
||||
Bech32.Decode(lnurl, out var hrp, out var decoded);
|
||||
var decodedString = Encoding.UTF8.GetString(decoded!);
|
||||
if (!decodedString.StartsWith("http"))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
return new Uri(decodedString);
|
||||
}
|
||||
|
||||
if (emailRegex.IsMatch(lnurl))
|
||||
{
|
||||
var parts = lnurl.Split('@');
|
||||
return new Uri($"https://{parts[1]}/.well-known/lnurlp/{parts[0]}");
|
||||
}
|
||||
|
||||
if (lnurl.StartsWith("https:"))
|
||||
{
|
||||
return new Uri(lnurl);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task<LNURLService?> LoadAsync(string lnurl)
|
||||
{
|
||||
var url = ParseLnUrl(lnurl);
|
||||
using var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonConvert.DeserializeObject<LNURLService>(json)!;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
public async Task<LNURLInvoice> GetInvoiceAsync(LNURLService service, int amount, string? comment = null, NostrEvent? zap = null)
|
||||
{
|
||||
if (service == null || string.IsNullOrWhiteSpace(service.Callback))
|
||||
{
|
||||
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
|
||||
}
|
||||
|
||||
var query = new Dictionary<string, string>
|
||||
{
|
||||
["amount"] = (amount * 1000).ToString()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(comment) && service.CommentAllowed.HasValue)
|
||||
{
|
||||
query["comment"] = comment;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(service.NostrPubkey) && zap != null)
|
||||
{
|
||||
query["nostr"] = JsonConvert.SerializeObject(zap, NostrSerializer.Settings);
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(service.Callback)
|
||||
{
|
||||
Query = string.Join('&', query.Select(a => $"{a.Key}={Uri.EscapeDataString(a.Value)}"))
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(builder.Uri);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonConvert.DeserializeObject<LNURLInvoice>(json);
|
||||
if (data?.Status == "ERROR")
|
||||
{
|
||||
throw new Exception(data.Reason);
|
||||
}
|
||||
|
||||
return data!;
|
||||
}
|
||||
|
||||
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, $"Failed to fetch invoice ({response.ReasonPhrase})");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class LNURLService
|
||||
{
|
||||
[JsonProperty("tag")]
|
||||
public string? Tag { get; init; }
|
||||
|
||||
[JsonProperty("nostrPubkey")]
|
||||
public string? NostrPubkey { get; init; }
|
||||
|
||||
[JsonProperty("minSendable")]
|
||||
public int? MinSendable { get; init; }
|
||||
|
||||
[JsonProperty("maxSnedable")]
|
||||
public int? MaxSendable { get; init; }
|
||||
|
||||
[JsonProperty("metadata")]
|
||||
public string Metadata { get; init; } = null!;
|
||||
|
||||
[JsonProperty("callback")]
|
||||
public string Callback { get; init; } = null!;
|
||||
|
||||
[JsonProperty("commentAllowed")]
|
||||
public int? CommentAllowed { get; init; }
|
||||
}
|
||||
|
||||
public class LNURLStatus
|
||||
{
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; } = null!;
|
||||
|
||||
[JsonProperty("reason")]
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public class LNURLInvoice : LNURLStatus
|
||||
{
|
||||
[JsonProperty("pr")]
|
||||
public string Pr { get; set; } = null!;
|
||||
|
||||
[JsonProperty("successAction")]
|
||||
public LNURLSuccessAction? SuccessAction { get; set; }
|
||||
}
|
||||
|
||||
public class LNURLSuccessAction
|
||||
{
|
||||
[JsonProperty("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[JsonProperty("url")]
|
||||
public string? Url { get; set; }
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
using FASTER.core;
|
||||
|
||||
namespace PayForReactions;
|
||||
|
||||
public class NostrStore : IDisposable
|
||||
{
|
||||
private readonly ILogger<NostrStore> _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly FasterKVSettings<byte[], byte[]> _storeSettings;
|
||||
private readonly FasterKV<byte[], byte[]> _store;
|
||||
|
||||
private readonly Task _checkPointer;
|
||||
|
||||
public NostrStore(string dir, ILogger<NostrStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_storeSettings = new FasterKVSettings<byte[], byte[]>(Path.Combine(dir, "main"), false, _logger);
|
||||
_store = new(_storeSettings);
|
||||
var version = _store.GetLatestCheckpointVersion();
|
||||
if (version != -1)
|
||||
{
|
||||
_store.Recover();
|
||||
}
|
||||
|
||||
_checkPointer = Task.Factory.StartNew(async () =>
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(10_000);
|
||||
await _store.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
public FasterKV<byte[], byte[]> MainStore => _store;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_checkPointer.ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
_store.Dispose();
|
||||
_storeSettings.Dispose();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.FASTER.Core" Version="2.6.1" />
|
||||
<PackageReference Include="Nostr.Client" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
|
||||
<ProjectReference Include="..\NostrServices.Client\NostrServices.Client.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
|
@ -0,0 +1,38 @@
|
|||
using NostrRelay;
|
||||
using NostrServices.Client;
|
||||
using PayForReactions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var config = builder.Configuration.GetSection("Config").Get<Config>()!;
|
||||
builder.Services.AddSingleton(config);
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddTransient<Lnurl>();
|
||||
builder.Services.AddTransient<AlbyApi>();
|
||||
builder.Services.AddTransient<ZapperRelay>();
|
||||
builder.Services.AddTransient<NostrServicesClient>();
|
||||
builder.Services.AddSingleton<NostrStore>(svc =>
|
||||
{
|
||||
var logger = svc.GetRequiredService<ILogger<NostrStore>>();
|
||||
return new NostrStore("./data", logger);
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
|
||||
host.UseWebSockets();
|
||||
host.MapRelay<ZapperRelay>("/", new RelayDocument()
|
||||
{
|
||||
Name = "wss://reactions.v0l.io",
|
||||
Description = "Zapping server for Kieran's paid reactions",
|
||||
SupportedNips = [1],
|
||||
Software = "PayForReactions (https://git.v0l.io/Kieran/NostrServices)",
|
||||
Limitation = new()
|
||||
{
|
||||
RestrictedWrites = true,
|
||||
AuthRequired = false,
|
||||
PaymentRequired = false
|
||||
}
|
||||
});
|
||||
|
||||
host.Run();
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"PayForReactions": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"environmentVariables": {
|
||||
"DOTNET_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
using FASTER.core;
|
||||
using Nostr.Client.Keys;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Requests;
|
||||
using NostrRelay;
|
||||
using NostrServices.Client;
|
||||
using EventSession =
|
||||
FASTER.core.ClientSession<byte[], byte[], byte[], byte[], FASTER.core.Empty, FASTER.core.SimpleFunctions<byte[], byte[]>>;
|
||||
|
||||
namespace PayForReactions;
|
||||
|
||||
public class ZapperRelay : INostrRelay, IDisposable
|
||||
{
|
||||
private readonly Config _config;
|
||||
private readonly Lnurl _lnurl;
|
||||
private readonly AlbyApi _albyApi;
|
||||
private readonly NostrServicesClient _nostrServices;
|
||||
private readonly EventSession _session;
|
||||
|
||||
public ZapperRelay(Lnurl lnurl, AlbyApi albyApi, NostrServicesClient nostrServices, Config config, NostrStore store)
|
||||
{
|
||||
_lnurl = lnurl;
|
||||
_albyApi = albyApi;
|
||||
_nostrServices = nostrServices;
|
||||
_config = config;
|
||||
_session = store.MainStore.For(new SimpleFunctions<byte[], byte[]>()).NewSession<SimpleFunctions<byte[], byte[]>>();
|
||||
}
|
||||
|
||||
public ValueTask<bool> AcceptConnection(NostrClientContext context)
|
||||
{
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<NostrEvent> HandleRequest(NostrClientContext context, NostrRequest req)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
public async ValueTask<HandleEventResponse> HandleEvent(NostrClientContext context, NostrEvent ev)
|
||||
{
|
||||
var target = NostrPublicKey.FromBech32(_config.Target).Hex;
|
||||
if (ev.Pubkey == target)
|
||||
{
|
||||
var id = Convert.FromHexString(ev.Id!);
|
||||
var obj = NostrBuf.Encode(ev);
|
||||
(await _session.UpsertAsync(ref id, ref obj)).Complete();
|
||||
return new(true, "");
|
||||
}
|
||||
|
||||
var amount = MapKindToAmount(ev.Kind);
|
||||
if (amount != default)
|
||||
{
|
||||
var eTag = ev.Tags?.FirstOrDefault(a => a.TagIdentifier == "e");
|
||||
var pTag = ev.Tags?.FirstOrDefault(a => a.TagIdentifier == "p" && a.AdditionalData[0] == target);
|
||||
if (pTag == default || eTag == default)
|
||||
{
|
||||
return new(false, $"blocked: must be a reaction to {target}");
|
||||
}
|
||||
|
||||
var idBytes = Convert.FromHexString(eTag.AdditionalData[0]);
|
||||
var refEventResult = (await _session.ReadAsync(ref idBytes)).Complete();
|
||||
if (refEventResult.status.NotFound)
|
||||
{
|
||||
return new(false, "blocked: parent event not found");
|
||||
}
|
||||
|
||||
var seenId = Convert.FromHexString(ev.Id!);
|
||||
var seenIdResult = (await _session.ReadAsync(ref seenId)).Complete();
|
||||
if (seenIdResult.status.Found)
|
||||
{
|
||||
return new(false, "blocked: already zapped this one, how dare you!");
|
||||
}
|
||||
|
||||
var refEvent = NostrBuf.Decode(refEventResult.output);
|
||||
if (refEvent == default)
|
||||
{
|
||||
return new(false, "blocked: parent event not found");
|
||||
}
|
||||
|
||||
if (refEvent.Pubkey != target)
|
||||
{
|
||||
return new(false, $"blocked: parent event not posted by {target}");
|
||||
}
|
||||
|
||||
var sender = ev.Pubkey!;
|
||||
var senderProfile = await _nostrServices.Profile(NostrPublicKey.FromHex(sender).Bech32);
|
||||
if (senderProfile == default)
|
||||
{
|
||||
return new(false, "blocked: couldn't find your profile anon!");
|
||||
}
|
||||
|
||||
var parsedTarget = Lnurl.ParseLnUrl(senderProfile.LightningAddress ?? "");
|
||||
if (parsedTarget == default)
|
||||
{
|
||||
return new(false,
|
||||
$"blocked: so sad... couldn't send a zap because you don't have a lightning address in your profile {senderProfile.Name}!");
|
||||
}
|
||||
|
||||
var svc = await _lnurl.LoadAsync(parsedTarget.ToString());
|
||||
if (svc == default)
|
||||
{
|
||||
return new(false, "blocked: wallet is down, no zap for you!");
|
||||
}
|
||||
|
||||
var key = NostrPrivateKey.FromBech32(_config.PrivateKey);
|
||||
var myPubkey = key.DerivePublicKey().Hex;
|
||||
var zap = new NostrEvent
|
||||
{
|
||||
Kind = NostrKind.ZapRequest,
|
||||
Content = "Thanks for your interaction!",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Pubkey = myPubkey,
|
||||
Tags = new NostrEventTags(
|
||||
new NostrEventTag("e", ev.Id!),
|
||||
new NostrEventTag("p", sender),
|
||||
new NostrEventTag("amount", (amount * 1000).ToString()!)
|
||||
)
|
||||
};
|
||||
|
||||
var zapSigned = zap.Sign(key);
|
||||
var invoice = await _lnurl.GetInvoiceAsync(svc, 5, "Thanks for your interaction!", zapSigned);
|
||||
if (string.IsNullOrEmpty(invoice.Pr))
|
||||
{
|
||||
return new(false, $"blocked: failed to get invoice from {parsedTarget}");
|
||||
}
|
||||
|
||||
if (!await _albyApi.PayInvoice(invoice.Pr))
|
||||
{
|
||||
return new(false, "blocked: failed to pay invoice!");
|
||||
}
|
||||
|
||||
var seenEvent = NostrBuf.Encode(ev);
|
||||
(await _session.UpsertAsync(ref seenId, ref seenEvent)).Complete();
|
||||
|
||||
return new(true, "Zapped!");
|
||||
}
|
||||
|
||||
return new(false, "blocked: kind not accepted, no zap for you!");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
private int? MapKindToAmount(NostrKind k)
|
||||
{
|
||||
switch (k)
|
||||
{
|
||||
case NostrKind.Reaction: return 5;
|
||||
case NostrKind.GenericRepost: return 10;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue