diff --git a/Nostra.Client/CommandLineParser.fs b/Nostra.Client/CommandLineParser.fs new file mode 100644 index 0000000..ca642b5 --- /dev/null +++ b/Nostra.Client/CommandLineParser.fs @@ -0,0 +1,127 @@ +namespace Nostra.Client + +open System + +module StdIn = + let readOnce prompt = + Console.Write $"{prompt}: " + Console.ReadLine () + + let read prompt = + fun _ -> readOnce prompt + |> Seq.initInfinite + |> Seq.skipWhile String.IsNullOrWhiteSpace + |> Seq.head + +module CommandLineParser = + let words (str: string) = str.Split (' ', StringSplitOptions.RemoveEmptyEntries) + + type Token = + | User + | AddRelay + | RemoveRelay + | CreateUser + | RemoveUser + | Name + | DisplayName + | About + | Picture + | Nip05 + | Publish + | PublishToChannel + | DirectMessage + | Listen + | Tag + | Alias + | Relay + | Proxy + | Key + | SubscribeAuthor + | UnsubscribeAuthor + | SubscribeChannel + | UnsubscribeChannel + | Value of string + + let tokenize (args : string[]) = + [for x in args do + yield match x with + | "-u" | "--user" -> User + | "--add-relay" -> AddRelay + | "--remove-relay" -> RemoveRelay + | "--create-user" -> CreateUser + | "--remove-user" -> RemoveUser + | "--name" -> Name + | "--display-name" -> DisplayName + | "--about" -> About + | "--nip05" -> Nip05 + | "-p" | "--publish" -> Publish + | "--publish-to-channel" -> PublishToChannel + | "--dm" -> DirectMessage + | "--tag" -> Tag + | "--listen" -> Listen + | "--alias" -> Alias + | "--relay" -> Relay + | "--proxy" -> Proxy + | "--key" -> Key + | "--subscribe-author" -> SubscribeAuthor + | "--unsubscribe-author" -> UnsubscribeAuthor + | "--subscribe-channel" -> SubscribeChannel + | "--unsubscribe-channel" -> UnsubscribeChannel + | _ -> Value x] + + let groupTokens tokens = + let rec groupTokens tokens cur acc = + match tokens with + | [] -> acc + | Value h::t -> groupTokens t cur ((cur, h) :: acc) + | h::t -> groupTokens t h ((h, "") :: acc) + + match tokens with + | [] -> [] + | Value t::_ -> failwith "Invalid command" + | command::t -> + groupTokens t command [ (command, "") ] + + let tryGet key opts = + opts + |> List.filter (fun (k, _) -> k = key) + |> List.map snd + |> List.rev + |> function + | [] -> None + | ""::rest -> Some rest + | values -> Some values + + let parseArgs args = + let tryGetFirst key opts = tryGet key opts |> Option.bind List.tryHead + let orAsk prompt maybeValue = + maybeValue |> Option.defaultWith (fun _ -> StdIn.read prompt) + + let opts = args |> tokenize |> groupTokens + {| + isCreateUser = fun () -> tryGet CreateUser opts |> Option.isSome + getName = fun () -> tryGetFirst Name opts |> orAsk "Name" + getDisplayName = fun () -> tryGetFirst DisplayName opts + getAbout = fun () -> tryGetFirst About opts + getPicture = fun () -> tryGetFirst Picture opts + getNip05 = fun () -> tryGetFirst Nip05 opts + isAddRelay = fun () -> tryGetFirst AddRelay opts |> Option.isSome + isRemoveRelay = fun () -> tryGetFirst RemoveRelay opts |> Option.isSome + getRelaysToAdd = fun () -> tryGet AddRelay opts |> Option.defaultValue [] + getRelaysToRemove = fun () -> tryGet RemoveRelay opts |> Option.defaultValue [] + getProxy = fun () -> tryGetFirst Proxy opts + isSubscribeAuthor = fun () -> tryGetFirst SubscribeAuthor opts |> Option.isSome + isUnsubscribeAuthor = fun () -> tryGetFirst UnsubscribeAuthor opts |> Option.isSome + getSubcribeAuthor = fun () -> tryGet SubscribeAuthor opts |> Option.defaultValue [] + getUnsubcribeAuthor = fun () -> tryGet UnsubscribeAuthor opts |> Option.defaultValue [] + isSubscribeChannel = fun () -> tryGetFirst SubscribeChannel opts |> Option.isSome + isUnsubscribeChannel = fun () -> tryGetFirst UnsubscribeChannel opts |> Option.isSome + getSubcribeChannel = fun () -> tryGet SubscribeChannel opts |> Option.defaultValue [] + getUnsubcribeChannel = fun () -> tryGet UnsubscribeChannel opts |> Option.defaultValue [] + isListen = fun () -> tryGet Listen opts |> Option.isSome + isPublish = fun () -> tryGetFirst Publish opts |> Option.isSome + getMessageToPublish = fun () -> tryGetFirst Publish opts |> orAsk "Note" + isPublishToChannel = fun () -> tryGet PublishToChannel opts |> Option.isSome + getMessageToChannel = fun () -> tryGet PublishToChannel opts + getUserFilePath = fun () -> tryGetFirst User opts |> Option.defaultValue "default-user.json" + |} diff --git a/Nostra.Client/Nostra.Client.fsproj b/Nostra.Client/Nostra.Client.fsproj index 7c66903..65865c7 100644 --- a/Nostra.Client/Nostra.Client.fsproj +++ b/Nostra.Client/Nostra.Client.fsproj @@ -5,6 +5,8 @@ + + diff --git a/Nostra.Client/Program.fs b/Nostra.Client/Program.fs index c391775..48d4faf 100644 --- a/Nostra.Client/Program.fs +++ b/Nostra.Client/Program.fs @@ -1,20 +1,67 @@ -open System -open System.Threading +module Client + +open System +open System.Collections.Generic open Microsoft.FSharp.Control open Nostra open Nostra.Client.Request open Nostra.Client +open Thoth.Json.Net -let displayResponse = function - | Ok (Response.RMEvent (subscriptionId, event)) -> - let (XOnlyPubKey pubKey) = event.PubKey - Console.ForegroundColor <- ConsoleColor.Cyan - Console.WriteLine $"Kind: {event.Kind} - Author: {pubKey.ToBytes() |> Utils.toHex} - {event.CreatedAt}" - Console.ForegroundColor <- enum (-1) - Console.WriteLine (event.Content.Trim()) - Console.ForegroundColor <- ConsoleColor.DarkGray - Console.WriteLine (event.Tags |> List.map (fun (t, vs) -> $"{t}:{vs}")) - Console.WriteLine () +let receivedEvents = HashSet () +let link text url = + $"\027]8;;{url}\a{text}\027]8;;\a" + +let displayResponse (contacts : Map) (addContact: ContactKey -> Metadata -> unit) = function + | Ok (Response.RMEvent ("channelmetadata", event)) -> + let contactKey = Channel event.Id + let metadataResult = Decode.fromString Metadata.Decode.metadata event.Content + match metadataResult with + | Ok metadata -> addContact contactKey metadata + | Error _ -> () + | Ok (Response.RMEvent ("metadata", event)) -> + let contactKey = Author event.PubKey + let metadataResult = Decode.fromString Metadata.Decode.metadata event.Content + match metadataResult with + | Ok metadata -> addContact contactKey metadata + | Error _ -> () + | Ok (Response.RMEvent ("all", event)) -> + let (EventId eventid) = event.Id + + if not (receivedEvents.Contains eventid) then + let contactKey = + if event.Kind = Kind.ChannelMessage then + let eventId = + event.Tags + |> List.choose (function + | "e", [channel; _; "root"] -> Some (EventId.parse channel) + | _ -> None) + |> List.head + |> Result.requiresOk + + EventId.toBytes eventId + else + XOnlyPubKey.toBytes event.PubKey + let maybeContact = contacts |> Map.tryFind contactKey + let author = maybeContact + |> Option.map (fun c -> c.metadata.displayName |> Option.defaultValue c.metadata.name) + |> Option.defaultValue (Utils.toHex contactKey) + let authorNpub = Shareable.encodeNpub event.PubKey + let nevent = Shareable.encodeNevent (event.Id, [], Some event.PubKey, Some event.Kind) + let emoji = match event.Kind with + | Kind.Text -> "📄" + | Kind.ChannelMessage -> "📢" + | _ -> "🥑" + Console.ForegroundColor <- ConsoleColor.Cyan + let eventLink = link emoji $"https://njump.me/{nevent}" + let authorLink= link $"👤 {author}" $"https://njump.me/{authorNpub}" + Console.WriteLine $"{eventLink} {authorLink} 📅 {event.CreatedAt}" + Console.ForegroundColor <- enum (-1) + Console.WriteLine (event.Content.Trim()) + //Console.ForegroundColor <- ConsoleColor.DarkGray + //Console.WriteLine (event.Tags |> List.map (fun (t, vs) -> $"{t}:{vs}")) + Console.WriteLine () + receivedEvents.Add eventid |> ignore | Ok (Response.RMACK(eventId, success, message)) -> Console.ForegroundColor <- ConsoleColor.Green @@ -30,33 +77,177 @@ let displayResponse = function Console.ForegroundColor <- ConsoleColor.Red Console.WriteLine (e.ToString()) -let Main = +let sync = obj +let display (contacts : Map) response = + lock sync (fun () -> + displayResponse contacts response) + +let publish event relays = + let publishedSuccessfullyTo = + relays + |> List.map (fun userRelay -> async { + let! relay = connectToRelay userRelay.uri + relay.publish event + return userRelay.uri + }) + |> List.map Async.Catch + |> Async.Parallel + |> Async.RunSynchronously + |> Array.toList + |> List.map Option.ofChoice + |> List.choose id + + let shareableRelays = publishedSuccessfullyTo |> List.map _.ToString() + let nevent = Shareable.encodeNevent (event.Id, shareableRelays, Some event.PubKey, Some event.Kind ) + Console.WriteLine nevent + +[] +let Main args = + let opts = CommandLineParser.parseArgs args + + let userFilePath = opts.getUserFilePath () + + if opts.isCreateUser() then + let user = User.createUser + (opts.getName()) + (opts.getDisplayName()) + (opts.getAbout()) + (opts.getPicture()) + (opts.getNip05()) + User.save userFilePath user + + if opts.isAddRelay() then + let relays = opts.getRelaysToAdd() |> List.map Uri + let proxy = opts.getProxy() + User.apply userFilePath (User.addRelays relays proxy) + + if opts.isRemoveRelay() then + let relays = opts.getRelaysToRemove() |> List.map Uri + User.apply userFilePath (User.removeRelays relays) + + if opts.isSubscribeAuthor() then + let authors = opts.getSubcribeAuthor() |> List.map Shareable.decodeNpub |> List.lift |> Option.get + User.apply userFilePath (User.subscribeAuthors authors) + + if opts.isUnsubscribeAuthor() then + let authors = opts.getSubcribeAuthor() |> List.map Shareable.decodeNpub |> List.lift |> Option.get + User.apply userFilePath (User.unsubscribeAuthors authors) + + if opts.isSubscribeChannel() then + let channels = opts.getSubcribeChannel() |> List.map Shareable.decodeNote |> List.lift |> Option.get + User.apply userFilePath (User.subscribeChannels channels) + + if opts.isUnsubscribeChannel() then + let channels = opts.getSubcribeChannel() |> List.map Shareable.decodeNote |> List.lift |> Option.get + User.apply userFilePath (User.unsubscribeChannels channels) + + if opts.isPublish() then + let message = opts.getMessageToPublish() + let user = User.load userFilePath + let event = Event.createNote message |> Event.sign user.secret + publish event user.relays + + if opts.isPublishToChannel() then + let channel', message = + match opts.getMessageToChannel() with + | None | Some [] -> (StdIn.read "Channel"), StdIn.read "Message" + | Some [c] -> c, StdIn.read "Message" + | Some (c::msgs) -> c, msgs |> List.head + + let channel = Shareable.decodeNpub channel' |> Option.map (fun pubkey -> EventId (XOnlyPubKey.toBytes pubkey) ) |> Option.get + let user = User.load userFilePath + let event = Event.createChannelMessage channel message |> Event.sign user.secret + publish event user.relays + + if opts.isListen() then + let user = User.load userFilePath + let filter = + Filter.all + |> Filter.since (DateTime.Today.AddDays -40) + |> Filter.limit 100 + + let filterAuthors = + match user.subscribedAuthors with + | [] -> None + | authors -> + filter + |> Filter.notes + |> Filter.authors authors + |> Some + + let filterChannels = + match user.subscribedChannels with + | [] -> None + | channels -> + Some (filter |> Filter.channels channels) + + let knownChannels = + user.contacts + |> List.choose (fun c -> match c.key with + | Channel channel -> Some channel + | _ -> None ) + + let unknownChannels = + user.subscribedChannels + |> List.notInBy (fun (EventId x) (EventId y) -> x = y ) knownChannels + + let filterChannelMetadata = + match unknownChannels with + | [] -> None + | channels -> + Filter.all + |> Filter.channelCreation channels + |> Some + + let knownAuthors = + user.contacts + |> List.choose (fun c -> match c.key with + | Author author -> Some author + | _ -> None ) + + let unknownAuthors = + user.subscribedAuthors + |> List.notInBy XOnlyPubKey.equals knownAuthors - let secret = Key.createNewRandom () + let filterMetadata = + match unknownAuthors with + | [] -> None + | authors -> + Filter.all + |> Filter.metadata + |> Filter.authors authors + |> Some - let uri = Uri "wss://offchain.pub" - //let uri = Uri "wss://relay.primal.net" - //let uri = Uri "wss://nostr.bitcoiner.social" - //let uri = Uri("wss://nostr-pub.wellorder.net") - //let uri = Uri "wss://relay.damus.io" - //let uri = Uri("ws://127.0.0.1:8080/") + let contactMap = + user.contacts + |> List.map (fun c -> + (match c.key with + | Channel e -> EventId.toBytes e + | Author p -> XOnlyPubKey.toBytes p) , c ) + |> Map.ofList - let workflow = async { - let! relay = connectToRelay uri - let filter = Filter.notes - |> Filter.authors ["npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx"] - |> Filter.since (DateTime.Today.AddDays -5) - |> Filter.limit 100 + let addContact contactKey metadata = + User.apply userFilePath (User.addContact contactKey metadata) - let note = Event.createNote "Hello world!" |> Event.sign secret - let delete = Event.createDeleteEvent [note.Id] "Because I can" |> Event.sign secret + let display = display contactMap addContact + let connectSubscribeAndListen uri = async { + let! relay = connectToRelay uri + [filterAuthors; filterChannels] + |> List.choose id + |> relay.subscribe "all" - relay.subscribe "all" [filter] - relay.publish note - relay.publish delete - do! relay.startListening displayResponse - } + filterMetadata + |> Option.iter (fun filter -> relay.subscribe "metadata" [filter]) - use globalCancellationTokenSource = new CancellationTokenSource() - Async.RunSynchronously (workflow, cancellationToken= globalCancellationTokenSource.Token) + filterChannelMetadata + |> Option.iter (fun filter -> relay.subscribe "channelmetadata" [filter]) + do! relay.startListening display + } + user.relays + |> List.map (fun relay -> connectSubscribeAndListen relay.uri) + |> List.map Async.Catch + |> Async.Parallel + |> Async.RunSynchronously + |> ignore + 0 \ No newline at end of file diff --git a/Nostra.Client/README.md b/Nostra.Client/README.md index df38774..bcad444 100644 --- a/Nostra.Client/README.md +++ b/Nostra.Client/README.md @@ -1,59 +1,79 @@ -# Nostra +# Nostra.Client -# How to play with it +A Command line Nostr client based on the F# Nostra library. -### Generate a new key pair: +Using Nostra.Client you can create Nostr users, publish notes, subscribe and follow posts of other +users and public channels and more. -``` -$ dotnet fsi nostracli.fsx genkey +Nostra.Client is heavily "inspired" on [nostr-commander-rs](https://github.com/8go/nostr-commander-rs) -secret: supersecretkey -pubkey: dc04a357c5ef17dd9ca245b7fa24842fc227a5b86a57f5d6a36a8d4443c21014 -``` +# Build from source + + +# Config File -### Listen for all the notes +You don't need to know any of this. This is just for the curious ones. + +The config file looks something like this. If you want to do some quick testing, +you can copy and paste this config file to get going real fast. ``` -$ dotnet fsi nostracli.fsx listen --relay=wss://nostr-pub.wellorder.net - ---------------------------------------------------------------- -Kind: Text - Author: 668ceee55475f595ec0e8ef44c64bf9da8d9dd6008ac54dcd24b2501930b960e -Another day, and 1 BTC now costs 100 million satoshis. Are we too late, or are we still early? ---------------------------------------------------------------- -Kind: Text - Author: 3129509e23d3a6125e1451a5912dbe01099e151726c4766b44e1ecb8c846f506 -Damn… some generous Nostrich just sent me my first tip!! It will be redistributed at my next ☕️ run. Thank you ⚡️ 🙏 🤙 ---------------------------------------------------------------- -Kind: Text - Author: b9a1608d4ad164cb115a1d40ff36efd12b93c097cd2a3bf82a58c32534488893 -For all of the new peeps just joining us, you can search for relays near you using this service: https://nostr.watch/ - -Quite neat 💫 ---------------------------------------------------------------- -Kind: Text - Author: 6f0ec447e0da5ad4b9a3a2aef3e56b24601ca2b46ad7b23381d1941002923274 -We all build during a bear market. I just build up my Bitcoin treasury and there is a purpose to it. We are all alike in some ways, i am exactly where i am supposed to be #nostr - -[771925] ---------------------------------------------------------------- -Kind: Text - Author: ab6e4c12e15cbd17f976ce5b919d1032e37ddb9a57d2491aee2a80d8c4bfa76f -What if i would like to have an address on my own domain? ---------------------------------------------------------------- -Kind: Text - Author: 353781e629477a5ddb2fcf40ead51d2c049f526f4d6161cef28a3ecc75cef5ea -k well first things first, jack owes Elon an undisclosed amount of money for a bet they both made on who was gonna fuck me, but jack won't pay up cause Elon is giving me the money. and I want my money. ---------------------------------------------------------------- -Kind: Text - Author: 67ddca50751581c703c174790588c2cd8b00f80313d0f80a5b9e73d45e48ac20 -I think you need your own domain and a webserver that handles the requests. It's kinda hard to do it on a self-custody wallet. +{ + "secret": "nsec1r4yjuyn3ppypju03rr2pzyynd0w66aaednuj2hflewd3xu0r62fqqvjxkz", + "metadata": { + "name": "Juan", + "display_name": "Juan" + }, + "relays": [ + { + "uri": "wss://nostr-pub.wellorder.net/" + } + ], + "contacts": [ + { + "key": "npub1vxpwwydpmmc4usmxf2zkr8shdjq888g532ydyls3nadxxd9wyx5ssjq5nd", + "metadata": { + "name": "kristapsk", + "picture": "https://avatars.githubusercontent.com/u/4500994", + "about": "Bitcoin dev (Bitcoin Core, JoinMarket, SatSale, etc)\n\nPGP - 70A1 D47D D44F 59DF 8B22 2443 33E4 72FE 870C 7E5D", + "display_name": "Kristaps K.", + "nip05": "kristapsk@kristapsk.lv" + } + }, + { + "key": "npub1nccwjspr3nv7h67xx2qhdh2dzzvpyy55gte2dsu8yl7xd7n74y9qydz7mj", + "metadata": { + "name": "lontivero", + "picture": "https://nostrcheck.me/media/lontivero/avatar.webp?18", + "about": "Bitcoin privacy warrior.", + "display_name": "lontivero", + "nip05": "_@lontivero.github.io" + } + } + ], + "subscribed_authors": [ + "npub1vxpwwydpmmc4usmxf2zkr8shdjq888g532ydyls3nadxxd9wyx5ssjq5nd", + "npub1nccwjspr3nv7h67xx2qhdh2dzzvpyy55gte2dsu8yl7xd7n74y9qydz7mj" + ], + "subscribed_channels": [] +} ``` -### Send Encrypted messages +# Example Usage ``` -$ dotnet fsi nostracli.fsx sendmsg \ - --to=dc04a357c5ef17dd9ca245b7fa24842fc227a5b86a57f5d6a36a8d4443c21014 \ - --secret=65efca3c243e4132afbfc7e30fbc41d8d3698d26d11d816bc24a7787aa57f0dc \ - --relay=wss://nostr-pub.wellorder.net \ - --msg="yeah baby" +$ Nostra.Client --create-user --name "Juan" \ + --display-name "The greatest Juan" --about "Just a random guy for the sake of this documentation" \ + --picture "https://i.imgur.com/lKMdIps.png" \ + --nip05 juan@nostr.example.org \ + --add-relay "wss://relay.primal.net" "wss://nostr.bitcoiner.social" -23f8ec3cf92d67314448844bbc987346755e5e9333cafa551ee87e45f74e9aa4 +$ Nostra.Client --publish "Wow, that was easy!" +$ Nostra.Client --subscribe-author npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s +$ Nostra.Client --subscribe-channel 25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb ``` -![image](https://user-images.githubusercontent.com/127973/212485551-803b3f6a-dee5-49ea-8ae8-9e1164921490.png) +# Usage +``` +``` diff --git a/Nostra.Client/User.fs b/Nostra.Client/User.fs new file mode 100644 index 0000000..d46b0e0 --- /dev/null +++ b/Nostra.Client/User.fs @@ -0,0 +1,247 @@ +namespace Nostra.Client + +open System +open System.IO +open Microsoft.FSharp.Core +open Thoth.Json.Net +open NBitcoin.Secp256k1 +open Nostra + +module List = + let notInBy predicate list2 list1 = + list1 + |> List.filter (fun i1 -> not (List.exists (predicate i1) list2)) + + +type Author = XOnlyPubKey +type Channel = EventId + +type Metadata = { + name : string + displayName : string option + about : string option + picture : string option + nip05 : string option +} +type Relay = { + uri : Uri + proxy : Uri Option +} +type ContactKey = + | Author of XOnlyPubKey + | Channel of Channel + +type Contact = { + key : ContactKey + metadata : Metadata +} +type User = { + secret : ECPrivKey + metadata : Metadata + relays : Relay list + contacts : Contact list + subscribedAuthors : Author list + subscribedChannels : Channel list +} + +module Author = + module Decode = + let author : Decoder = + Decode.string + |> Decode.map Shareable.decodeNpub + |> Decode.andThen (Decode.ofOption "Not a valid bech32 author") + + module Encode = + let author pubkey = + pubkey |> Shareable.encodeNpub |> Encode.string + +module ContactKey = + open Author + module Encode = + let contactKey = function + | Author author -> Encode.author author + | Channel channel -> Encode.eventId channel + + module Decode = + let contactKey : Decoder = + Decode.oneOf [ + Decode.eventId |> Decode.map ContactKey.Channel + Decode.author |> Decode.map ContactKey.Author + ] + +module Metadata = + module Decode = + let metadata : Decoder = + Decode.object (fun get -> { + name = get.Required.Field "name" Decode.string + displayName = get.Optional.Field "display_name" Decode.string + picture = get.Optional.Field "picture" Decode.string + about = get.Optional.Field "about" Decode.string + nip05 = get.Optional.Field "nip05" Decode.string + }) + + module Encode = + let metadata metadata = + let getOptionalField value key = + value + |> Option.map (fun picture -> [ key, Encode.string picture ]) + |> Option.defaultValue [] + + let mandatoryFields = [ + "name", Encode.string metadata.name + ] + let picture = getOptionalField metadata.picture "picture" + let about = getOptionalField metadata.about "about" + let nip05 = getOptionalField metadata.nip05 "nip05" + let displayName = getOptionalField metadata.displayName "display_name" + Encode.object (mandatoryFields @ picture @ about @ displayName @ nip05) + +module Contact = + open Author + open Metadata + open ContactKey + + module Encode = + let contact contact = + Encode.object [ + "key", Encode.contactKey contact.key + "metadata", Encode.metadata contact.metadata + ] + + module Decode = + let contact : Decoder = + Decode.object (fun get -> { + key = get.Required.Field "key" Decode.contactKey + metadata = get.Required.Field "metadata" Decode.metadata + }) + +module User = + open Metadata + + let createUser name displayName about picture nip05 = + let secret = Key.createNewRandom () + { + secret = secret + metadata = { + name = name + displayName = displayName + about = about + picture = picture + nip05 = nip05 + } + relays = [] + contacts = [] + subscribedAuthors = [] + subscribedChannels = [] + } + + module Decode = + open Author + open Contact + + let relay : Decoder = + Decode.object (fun get -> { + uri = get.Required.Field "uri" Decode.uri + proxy = get.Optional.Field "proxy" Decode.uri + }) + + let secret : Decoder = + Decode.string + |> Decode.map Shareable.decodeNsec + |> Decode.andThen (Decode.ofOption "Not a valid bech32 secret") + + let channel : Decoder = + Decode.eventId + + let user : Decoder = + Decode.object (fun get -> { + secret = get.Required.Field "secret" secret + metadata = get.Required.Field "metadata" Decode.metadata + relays = get.Required.Field "relays" (Decode.list relay) + contacts = get.Required.Field "contacts" (Decode.list Decode.contact) + subscribedAuthors = get.Required.Field "subscribed_authors" (Decode.list Decode.author) + subscribedChannels = get.Required.Field "subscribed_channels" (Decode.list channel) + }) + + module Encode = + open Author + open Contact + + let relay relay = + let proxy = match relay.proxy with | Some p -> [ "proxy", Encode.uri p ] | None -> [] + Encode.object ([ + "uri", Encode.uri relay.uri + ] @ proxy) + + let secret secret = + secret |> Shareable.encodeNsec |> Encode.string + let channel = + Encode.eventId + + let user user = + Encode.object [ + "secret", secret user.secret + "metadata", Encode.metadata user.metadata + "relays", Encode.list (List.map relay user.relays) + "contacts", Encode.list (List.map Encode.contact user.contacts) + "subscribed_authors", Encode.list (List.map Encode.author user.subscribedAuthors) + "subscribed_channels", Encode.list (List.map channel user.subscribedChannels) + ] + + let addRelays relays proxy user = + let addedRelays = + relays + |> List.map (fun r -> { + uri = r + proxy = proxy |> Option.map Uri + }) + let existingRelays = + user.relays + |> List.notInBy (fun relay1 relay2 -> relay1.uri = relay2.uri) addedRelays + + { user with relays = existingRelays @ addedRelays } + + let removeRelays relays user = + let relays' = + user.relays + |> List.notInBy (fun relay uri -> uri = relay.uri) relays + { user with relays = relays' } + + let addContact key metadata user = + let contacts = { key = key; metadata = metadata }:: user.contacts + { user with contacts = List.distinctBy (fun c -> c.key) contacts } + + let subscribeAuthors (authors : Author list) user = + let authors' = List.distinctBy XOnlyPubKey.toBytes (user.subscribedAuthors @ authors) + { user with subscribedAuthors = authors' } + + let unsubscribeAuthors (authors : Author list) user = + let authors' = user.subscribedAuthors + |> List.notInBy (fun a1 a2 -> XOnlyPubKey.toBytes a1 = XOnlyPubKey.toBytes a2) authors + { user with subscribedAuthors = authors' } + + let subscribeChannels (channels : Channel list) user = + let channels' = List.distinctBy EventId.toBytes (user.subscribedChannels @ channels) + { user with subscribedChannels = channels' } + + let unsubscribeChannels (channels : Channel list) user = + let channels' = user.subscribedChannels + |> List.notInBy (fun (EventId a1) (EventId a2) -> a1 = a2) channels + { user with subscribedChannels = channels' } + + let save filePath user = + let json = user |> Encode.user |> Encode.toString 2 + File.WriteAllText (filePath, json) + + let load filePath = + File.ReadAllText filePath + |> Decode.fromString Decode.user + |> function + | Ok user -> user + | Error e -> failwith e //"user file is not a valid json" + + let apply filePath action = + filePath + |> load + |> action + |> save filePath \ No newline at end of file diff --git a/Nostra/Client.fs b/Nostra/Client.fs index 37d6bb3..a13d507 100644 --- a/Nostra/Client.fs +++ b/Nostra/Client.fs @@ -4,6 +4,7 @@ open System open System.IO open System.Net.WebSockets open System.Text +open System.Threading open Microsoft.FSharp.Collections open Microsoft.FSharp.Control open System.Buffers @@ -49,7 +50,7 @@ module Client = |> encodeOption "until" filter.Until Encode.unixDateTime |> Encode.object - let singleton: SubscriptionFilter = + let all: SubscriptionFilter = { Ids = [] Kinds = [] Authors = [] @@ -59,30 +60,31 @@ module Client = Events = [] PubKeys = [] } - let notes = { singleton with Kinds = [Kind.Text] } - let metadata = { singleton with Kinds = [Kind.Metadata] } - let contacts = { singleton with Kinds = [Kind.Contacts] } - let encryptedMessages = { singleton with Kinds = [Kind.Encrypted] } + let notes filter = { filter with Kinds = [Kind.Text] } + let metadata filter = { filter with Kinds = [Kind.Metadata] } + let contacts filter = { filter with Kinds = [Kind.Contacts] } + let encryptedMessages filter = { filter with Kinds = [Kind.Encrypted] } + let channelCreation channels filter = { filter with Ids = channels } let events evnts filter = { filter with Events = evnts - |> List.map EventId.parse - |> List.choose Result.toOption |> List.append filter.Events |> List.distinct } let authors authors (filter : SubscriptionFilter) = - let parseAuthor (author : string) = author |> Shareable.decodeNpub |> Option.orElseWith (fun () -> XOnlyPubKey.parse author |> Result.toOption) { filter with Authors = authors - |> List.map parseAuthor - |> List.choose id |> List.append filter.Authors |> List.distinct } + let channels channels filter = + let filter' = events channels filter + { filter' with + Kinds = Kind.ChannelMessage :: filter'.Kinds } + let since dateTime filter = { filter with Since = Some dateTime } @@ -232,7 +234,9 @@ module Client = let pushToRelay = Monad.injectedWith ctx (Communication.sender ()) let receiveLoop onReceiving = Monad.injectedWith ctx (Communication.startReceiving onReceiving) async { - do! ws.ConnectAsync (uri, Async.DefaultCancellationToken) |> Async.AwaitTask + use connectCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(3)) + let cts = CancellationTokenSource.CreateLinkedTokenSource(Async.DefaultCancellationToken, connectCancellationToken.Token) + do! ws.ConnectAsync (uri, cts.Token) |> Async.AwaitTask let subscribe sid filters = pushToRelay (ClientMessage.CMSubscribe (sid, filters)) let publish event = pushToRelay (ClientMessage.CMEvent event) diff --git a/Nostra/nostracli.fsx b/Nostra/nostracli.fsx deleted file mode 100755 index 0b34e0b..0000000 --- a/Nostra/nostracli.fsx +++ /dev/null @@ -1,139 +0,0 @@ -#r "nuget:NBitcoin.Secp256k1" -#r "nuget:Thoth.Json.Net" - -#load "Utils.fs" -#load "Bech32.fs" -#load "Types.fs" -#load "Event.fs" -#load "Client.fs" - -open System -open System.Net.WebSockets -open System.Threading -open Microsoft.FSharp.Collections -open Microsoft.FSharp.Control -open NBitcoin.Secp256k1 -open Nostra -open Nostra.Client -open Nostra.Client.Response - -module CommandLine = - open System.Text.RegularExpressions - - let (|Command|_|) (s: string) = - let r = - new Regex(@"^(?:-{1,2}|\/)(?\w+)[=:]*(?.*)$", RegexOptions.IgnoreCase) - - let m = r.Match(s) - - if m.Success then - Some(m.Groups.["command"].Value.ToLower(), m.Groups.["value"].Value) - else - None - - let parseArgs (args: string seq) = - args - |> Seq.map (function - | Command(switch, value) -> (switch, value) - | flag -> (flag, "")) - |> Seq.skip 1 - |> List.ofSeq - -open System.Threading.Tasks -open Nostra.Monad -open Nostra.Event -open Nostra.Client.Request.Filter.FilterUtils - -let args = fsi.CommandLineArgs -let switchs = CommandLine.parseArgs args - -match switchs with -| ("genkey", _) :: rest -> - let secret = Key.createNewRandom () - let secretBytes = Array.zeroCreate 32 - let pubKeyBytes = secret |> Key.getPubKey |> (fun x -> x.ToBytes()) - secret.WriteToSpan(Span secretBytes) - - let nsec = "nsec" - let npub = "npub" - Console.WriteLine($"secret: hex {Utils.toHex secretBytes}") - Console.WriteLine($" bech32 {Bech32.encode nsec (secretBytes |> Array.toList)}") - Console.WriteLine($"pubkey: hex {Utils.toHex pubKeyBytes}") - Console.WriteLine($" bech32 {Bech32.encode npub (pubKeyBytes |> Array.toList)}") - 0 -| ("listen", _) :: rest -> - let args = Map.ofList rest - - let printEvent (eventResult) = - match eventResult with - | Ok relayMessage -> - match relayMessage with - | RMEvent(id, event) -> - let (XOnlyPubKey pubKey) = event.PubKey - Console.WriteLine "---------------------------------------------------------------" - Console.WriteLine $"Kind: {event.Kind} - Author: {pubKey.ToBytes() |> Utils.toHex}" - Console.WriteLine(event.Content) - | _ -> Console.WriteLine "- other -" - | Error e -> Console.WriteLine(e.ToString()) - - async { - let ws = new ClientWebSocket() - - // "wss://nostr-pub.wellorder.net" - do! ws.ConnectAsync(Uri(args["relay"]), CancellationToken.None) |> Async.AwaitTask - let ctx = Client.Communication.buildContext ws Console.Out - let pushToRelay = injectedWith ctx (Client.Communication.sender ()) - let filter = toFilter (AllNotes(DateTime.UtcNow.AddDays(-1))) - - Client.Request.CMSubscribe("all", [ filter ]) |> pushToRelay - - let receiveLoop = injectedWith ctx (Client.Communication.startReceiving printEvent) - do! receiveLoop - - } - |> Async.RunSynchronously - - 0 -| ("sendmsg", _) :: rest -> - let args = Map.ofList rest - let secret = args["secret"] |> Utils.fromHex |> ECPrivKey.Create - let recipient = args["to"] |> Utils.fromHex |> ECXOnlyPubKey.Create |> XOnlyPubKey - let msg = args["msg"] - let dm = createEncryptedDirectMessage recipient secret msg - let signedDm = sign secret dm - - let ws = new ClientWebSocket() - - ws.ConnectAsync(Uri(args["relay"]), CancellationToken.None) - |> Async.AwaitTask - |> Async.RunSynchronously - - let (EventId id) = signedDm.Id - let ctx = Client.Communication.buildContext ws Console.Out - let pushToRelay = injectedWith ctx (Client.Communication.sender ()) - pushToRelay (Client.Request.CMEvent signedDm) - - Console.WriteLine(id |> Utils.toHex) - Task.Delay(1000) |> Async.AwaitTask |> Async.RunSynchronously - 0 -| _ -> - """ - Usage: dotnet fsi nostrcli.fsx -- command - - Commands: - genkey generates a new private/public key pair. - listen listens for tex notes - --relay from the relay (eg: wss://nostr-pub.wellorder.net) - sendmsg sends encrypted message - --to to the specified public key - --secret using the specified secret key to encrypt - --msg the message - --relay relay to be used - """ - |> Console.WriteLine - - 0 - -//Library.fsx genkey -//secret: 65efca3c243e4132afbfc7e30fbc41d8d3698d26d11d816bc24a7787aa57f0dc -//pubkey: dc04a357c5ef17dd9ca245b7fa24842fc227a5b86a57f5d6a36a8d4443c21014