client

basic client operations not specific to the rebroadcastr client

This notebook will implement and test a barebones client that can easily do the following - manage connections - manage private keys - publish event subscriptions (requests) - retrieve events and save to db - publish events

Client Initialization

the basic client initializes with the following - a relay manager from python-nostr - set of relays that will be used for reading and writing (as of now all relays are used for both by default) - a message pool that acts as an incoming queue for messages from the relays - a user account - provided with a private key - able to publish new events - or with only a public key - only able to read and republish existing events - a sqlite database named by the publickey and located on the system user data directory by default - by default events are saved into the database as they are read out of the message queue from the message pool


source

Client

 Client (public_key_hex:str=None, private_key_hex:str=None,
         db_name:str='nostr-data', relay_urls:list=None,
         ssl_options:dict={}, first_response_only:bool=True)

A basic framework for common operations that a nostr client will need to execute.

Args: public_key_hex (str, optional): public key to initiate client private_key_hex (str, optional): private key to log in with public key. Defaults to None, in which case client is effectively read only relay_urls (list, optional): provide a list of relay urls. Defaults to None, in which case a default list will be used. ssl_options (dict, optional): ssl options for websocket connection Defaults to empty dict allow_duplicates (bool, optional): whether or not to allow duplicate event ids into the queue from multiple relays. This isn’t fully working yet. Defaults to False.

Test setting account by private key or public key and handles as expected - note that some uses like the rebroadcastr client don’t need a private key since we aren’t creating new events

import ssl
private_key = PrivateKey()
public_key = private_key.public_key

# load client with no keys
client = Client(ssl_options={'cert_reqs': ssl.CERT_NONE})

# load client with public key only
client = Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, public_key_hex=public_key.hex())
assert client.public_key.hex() == public_key.hex()
assert client.private_key is None

# load client with public key and private key
client = Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, public_key_hex=public_key.hex(), private_key_hex=private_key.hex())
assert client.public_key.hex() == public_key.hex()
assert client.private_key.hex() == private_key.hex()

# load client with private key only
client = Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, private_key_hex=private_key.hex())
assert client.public_key.hex() == public_key.hex()
assert client.private_key.hex() == private_key.hex()

test adding and removing relays while disconnected

relay_urls_1 = [
                'wss://nostr-2.zebedee.cloud',
                'wss://rsslay.fiatjaf.com',
                'wss://nostr-relay.wlvs.space',
                'wss://nostr.orangepill.dev',
                'wss://nostr.oxtr.dev'
            ]
relay_urls_2 = [
                'wss://relay.damus.io',
                'wss://brb.io',
                'wss://nostr-2.zebedee.cloud',
                'wss://rsslay.fiatjaf.com',
            ]
client = Client(private_key_hex=private_key.hex(), relay_urls=relay_urls_1)
assert set(relay_urls_1) == set(client.relay_manager.relays.keys())
client.set_relays(relay_urls_2)
assert set(relay_urls_2) == set(client.relay_manager.relays.keys())

Adding Connection Methods

the connection methods provide an easy, pythonic way to open and close connections with the relay manager in order to perform operations with the client.

The connections can be opened and closed by calling Client.connect() and Client.disconnect respectively. The client also provides a context manager that can be used as:

with Client() as client:
    while True:
        pass

The above code effectively runs the client until it is forced to exit for some interal reason at which point the connections are automatically closed.


source

Client.disconnect

 Client.disconnect ()

source

Client.connect

 Client.connect ()
relay_urls_1 = [
                'wss://rsslay.fiatjaf.com',
                'wss://nostr-relay.wlvs.space',
                'wss://nostr.orangepill.dev',
                'wss://nostr.oxtr.dev'
            ]
relay_urls_2 = [
                'wss://relay.damus.io',
                'wss://brb.io',
                'wss://rsslay.fiatjaf.com',
            ]
client = Client(private_key_hex=private_key.hex(), relay_urls=relay_urls_1,
                ssl_options={'cert_reqs': ssl.CERT_NONE})
with client:

    # these are not hard asserts because some relays wont connect
    print(client.relay_manager.connection_statuses)
    client.set_relays(relay_urls_2)
    print(client.relay_manager.connection_statuses)
assert not any(client.relay_manager.connection_statuses.values())
2023-01-14 08:45:02,009 - websocket - WARNING - websocket connected
2023-01-14 08:45:02,197 - websocket - ERROR - Handshake status 500 Internal Server Error - goodbye
2023-01-14 08:45:02,397 - websocket - WARNING - websocket connected
/Users/ryanarmstrong/python/nostrfastr/nostrfastr/nostr.py:172: UserWarning: wss://nostr-relay.wlvs.space is not connected... removing relay.
  warnings.warn(
/Users/ryanarmstrong/python/nostrfastr/nostrfastr/nostr.py:172: UserWarning: wss://nostr.oxtr.dev is not connected... removing relay.
  warnings.warn(
{'wss://nostr.orangepill.dev': True, 'wss://rsslay.fiatjaf.com': True}
2023-01-14 08:45:04,056 - websocket - WARNING - websocket connected
2023-01-14 08:45:04,377 - websocket - WARNING - websocket connected
{'wss://rsslay.fiatjaf.com': True, 'wss://brb.io': True, 'wss://relay.damus.io': True}

Make sure that our context manager appropriately raises errors and closes connections

from fastcore.test import test_fail
2023-01-14 08:45:07,057 - websocket - ERROR - Handshake status 503 Service Unavailable - goodbye
def error_in_context():
    with client:
        raise Error()
test_fail(error_in_context)
2023-01-14 08:45:07,606 - websocket - WARNING - websocket connected
2023-01-14 08:45:07,902 - websocket - WARNING - websocket connected
2023-01-14 08:45:08,183 - websocket - WARNING - websocket connected
assert not any(client.relay_manager.connection_statuses.values())

Publishing Subscriptions

Requests to the relays are most easily understood as subscriptions since each request will continue to receive events to the message pool until the websocket connection is closed or the subscription passes an until criteria. NIP-01 clearly outlines the components of a subscription. Subscriptions are then executed as follows: - the relay runs a query for past events that meet the filter criteria - the events are returned to the client honoring any limit arguments specified - the relay may return an end of stored events (EOSE) notice as described in NIP-15 to inform the client that any further events received are newly published events - the relay will continue to return events that meet the subscription filter criteria until one of 3 things happen: - a new subscription is sent with the same subscription_id and overwrites the existing subscription - the client sends a CLOSE message to the relay to close the subscription - the websocket between the client and the relay is closed

Subscription filters can be created from the Filter class in python-nostr with the arguments as shown below


source

Client.get_notices_from_relay

 Client.get_notices_from_relay ()

calls the _notice_handler method on all notices from relays


source

Client.publish_subscription

 Client.publish_subscription
                              (filters:Union[nostr.filter.Filter,nostr.fil
                              ter.Filters], subscription_id:str='724a1d59-
                              f5bc-452b-8859-61eb6f5da7c2')

publishes a request from a subscription id and a set of filters. Filters can be defined using the request_by_custom_filter method or from a list of preset filters (as of yet to be created):

Args: request_filters (Filters): list of filters for a subscription subscription_id (str): subscription id to be sent to relay. defaults to a random guid

below we make and publish a simple subscription that requests posts from Jack Dorsey’s pubkey with a limit of 10. This will initially only return 10 events and then would continue to return new posts from Jack if we left the connection open.

we can see that python-nostr has also added the subscription to a dictionary of subscriptions for each relay in the relay manager.

url='ws://127.0.0.1:6969'
client = Client(private_key_hex=private_key.hex(), relay_urls=[url],
                ssl_options={'cert_reqs': ssl.CERT_NONE})

jacks_pubkey = PublicKey.from_npub('npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m').hex()

a_filter = Filter(
    authors=[jacks_pubkey],
    limit=10
)
with client:
    subscription_id = str(uuid.uuid4())
    client.publish_subscription(filters=a_filter, subscription_id=subscription_id)
    for relay in client.relay_manager:
        assert subscription_id in relay.subscriptions.keys()
2023-01-14 08:45:12,102 - websocket - WARNING - websocket connected

Retrieving Events

As a result, the same request likely does not need to be made twice as long as the connection is still open. New events for any subscriptions can be retrieved using the get_events method.

The message_pool also contains notices (effectively plain english errors from the relay) and end of subscription notices, which let you know that the relay is done sending information at the moment (but may resume if more events come in). These different types of events will be handled next. First…


source

Client.insert_event_to_database

 Client.insert_event_to_database
                                  (event_msg:nostr.message_pool.EventMessa
                                  ge)

source

Client.get_events_pool

 Client.get_events_pool ()

calls the _event_handler method on all events from relays

let’s publish the same subscription and get the events. We will clear the events data table of our test database.

client = Client(private_key_hex=private_key.hex(),
                ssl_options={'cert_reqs': ssl.CERT_NONE},
                db_name='test', relay_urls=['wss://relay.damus.io'])
with client.db_conn as con:
    if client.db_name != 'test':
        raise ValueError(f'should not be TRUNCATING a non test database - current database is {client.db_name}')
    con.execute(f'DELETE FROM events')
with client:
    subscription_id = str(uuid.uuid4())
    client.publish_subscription(filters=a_filter, subscription_id=subscription_id)
    client.get_events_pool()
2023-01-14 08:45:18,128 - websocket - WARNING - websocket connected

See what events we got.

Note: there could be more than 10 events received. In testing I got 20 events because two different relays had a different set of events for Jack.

with client.db_conn as con:
    df = pd.read_sql('select * from events', con)
df.info()
<class 'pandas.core.frame.DataFrame'>
Index: 0 entries
Data columns (total 9 columns):
 #   Column           Non-Null Count  Dtype 
---  ------           --------------  ----- 
 0   id               0 non-null      object
 1   pubkey           0 non-null      object
 2   created_at       0 non-null      object
 3   kind             0 non-null      object
 4   tags             0 non-null      object
 5   content          0 non-null      object
 6   sig              0 non-null      object
 7   subscription_id  0 non-null      object
 8   url              0 non-null      object
dtypes: object(9)
memory usage: 0.0+ bytes

End Of Stored Events Notice

Make sure we have a method to print the end of stored message notice.


source

Client.get_eose_from_relay

 Client.get_eose_from_relay ()

calls the _eose_handler end of subsribtion events from relays

Publishing Events

Make a method that can publish events


source

Client.check_event_pubkey

 Client.check_event_pubkey (event:nostr.event.Event)

source

Client.publish_event

 Client.publish_event (event:nostr.event.Event)

publish an event and immediately checks for a notice from the relay in case of an invalid event

Args: event (Event): description

We will try to publish an event with the wrong public key and assert that it will fail.

client = Client(private_key_hex=private_key.hex(),
                ssl_options={'cert_reqs': ssl.CERT_NONE},
                db_name='test', relay_urls=[url])

bad_event = Event(public_key=jacks_pubkey, content='this is also a test')

def error_on_invalid():
    with client:
        client.publish_event(event=bad_event)

test_fail(error_on_invalid)
2023-01-13 19:46:09,749 - websocket - WARNING - websocket connected

And now publishing a good event

good_event = Event(public_key=client.public_key.hex(), content='this is also a test', created_at=int(time.time()))
assert good_event.created_at == int(time.time())

# publishing events commented out for sake of others
with client:
    client.publish_event(event=good_event)
2023-01-13 19:46:11,881 - websocket - WARNING - websocket connected

Special Methods for Common Filters and Events

below are some methods to help create common filters and events used in the nostr protocol

See the nostr nips page for a full list of event types and specifications. These events will continue to be built out over time.


source

Client.event_encrypted_message

 Client.event_encrypted_message (recipient_hex:str, message:str)

source

Client.event_channel_metadata

 Client.event_channel_metadata ()

source

Client.event_channel_mute_user

 Client.event_channel_mute_user ()

source

Client.event_channel_hide_message

 Client.event_channel_hide_message ()

source

Client.event_channel_message

 Client.event_channel_message ()

source

Client.event_channel_metadata

 Client.event_channel_metadata ()

source

Client.event_channel

 Client.event_channel ()

source

Client.event_reaction

 Client.event_reaction ()

source

Client.event_deletion

 Client.event_deletion (event_ids:Union[str,list], reason:str)

event to delete a single event by id

Args: event_ids (str|list): event id as string or list of event ids reason (str): a reason for deletion provided by the user


source

Client.event_text_note

 Client.event_text_note (text:str)

create a text not event

Args: text (str): text for nostr note to be published


source

Client.event_metadata

 Client.event_metadata (name:str=None, about:str=None, picture:str=None)

summary

Args: self (Client): description name (str, optional): profile name. Defaults to None. about (str, optional): profile about me. Defaults to None. picture (str, optional): url to profile picture. Defaults to None.

Returns: Event: Event to publish for a metadata update


source

Client.filter_events_authors

 Client.filter_events_authors (authors:Union[str,list])

build a filter from authors

Args: authors (Union[str,list]): an author or a list of authors to request

Returns: Filter: A filter object to use with a subscription


source

Client.filter_events_by_id

 Client.filter_events_by_id (ids:Union[str,list])

build a filter from event ids

Args: ids (Union[str,list]): an event id or a list of event ids to request

Returns: Filter: A filter object to use with a subscription

Let’s try to do a metadata update

metadata_update = \
    client.event_metadata(name='python-nostr-testacct',
                          about='i am a robot - dont mind me',
                          picture='https://cdn-icons-png.flaticon.com/256/7603/7603393.png')
with client:
    # publishing events commented out for sake of others
    client.publish_event(metadata_update)
    pass
print(metadata_update.to_json_object())
2023-01-13 19:46:15,915 - websocket - WARNING - websocket connected
{'id': '28420f2a726bcc46ba24660e167c276ee1e6231d9b70bab68e8d1d60315dfbee', 'pubkey': 'c5597eb728296cf5a6d32aae47237e6a6b5b4ab0fce254ecbadb63aa51dc9a52', 'created_at': 1673667975, 'kind': <EventKind.SET_METADATA: 0>, 'tags': [], 'content': '{"name": "python-nostr-testacct", "about": "i am a robot - dont mind me", "picture": "https://cdn-icons-png.flaticon.com/256/7603/7603393.png"}', 'sig': '07cdad3ac25019e88483ede586762c54eb045f1f7d73b25320c160b6685ff21de8070c4b284b79946d7712094bfb199152b82c7c851a6ee555c4ac011d2ea49a'}
client = Client(private_key_hex=private_key.hex(),
                ssl_options={'cert_reqs': ssl.CERT_NONE},
                db_name='test', relay_urls=['wss://relay.damus.io'])

with client:
    new_filter = client.filter_events_recommended_relays(authors='c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720')
    subscription_id = str(uuid.uuid4())
    client.publish_subscription(filters=new_filter, subscription_id=subscription_id)
    time.sleep(2)
    print(client.relay_manager.relays)
    client.get_events_pool()
2023-01-13 19:55:56,805 - websocket - WARNING - websocket connected
{'wss://relay.damus.io': {
  "url": "wss://relay.damus.io",
  "policy": {
    "read": true,
    "write": true
  },
  "subscriptions": [
    {
      "id": "2ebf6fc4-d414-4eac-9401-aca91fae086f",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "478279db-770a-4a01-9497-a6b51de8fba7",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "244b13de-f8c0-4285-bcaa-2645b51cf31d",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "46b6147a-b0f4-40c3-ac39-b92399d33926",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "1d3eecd2-c1f8-4ee4-a8fe-c09d4202ff04",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "125adb08-a146-4069-82e1-452d443052ef",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "14599812-338b-4e4e-b538-21693423b5fa",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "994a764c-8bd4-4c23-8a81-79351bf40da7",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "42f466d7-d182-4fc4-b045-ea4fd2927b9c",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "73b05f9a-0939-419a-b1b8-6c9187573bee",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "93712053-bc4d-48da-9b19-9d11c024962e",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "1d874191-1e1b-4fd6-9eb9-72bd36e820e3",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "ab59d40d-fbc4-4b27-954f-98bc03b9d627",
      "filters": [
        {
          "authors": [
            "82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"
          ],
          "limit": 10
        }
      ]
    },
    {
      "id": "70e45963-6109-4ae9-8f72-69808dad1885",
      "filters": [
        {
          "kinds": [
            2
          ],
          "authors": [
            "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720"
          ]
        }
      ]
    },
    {
      "id": "511a38b8-d5e7-4d8f-a8c8-bbaae4c038c2",
      "filters": [
        {
          "kinds": [
            2
          ],
          "authors": [
            "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720"
          ]
        }
      ]
    },
    {
      "id": "c7590d75-6b51-44e8-a1c9-fe168c4dd075",
      "filters": [
        {
          "kinds": [
            2
          ],
          "authors": [
            "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720"
          ]
        }
      ]
    },
    {
      "id": "7a1f2fd0-9306-463d-8f29-2f7e1e3e2e68",
      "filters": [
        {
          "kinds": [
            2
          ],
          "authors": [
            "c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720"
          ]
        }
      ]
    },
    {
      "id": "68db2083-562e-4a55-a9f2-c61895431776",
      "filters": [
        {
          "kinds": [
            2
          ],
          "authors": [
            "cc80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720"
          ]
        }
      ]
    }
  ]
}}
pd.read_sql('select * from events',con=client.db_conn)
id pubkey created_at kind tags content sig subscription_id url