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