import ssl
client
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
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
= PrivateKey()
private_key = private_key.public_key
public_key
# load client with no keys
= Client(ssl_options={'cert_reqs': ssl.CERT_NONE})
client
# load client with public key only
= Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, public_key_hex=public_key.hex())
client assert client.public_key.hex() == public_key.hex()
assert client.private_key is None
# load client with public key and private key
= Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, public_key_hex=public_key.hex(), private_key_hex=private_key.hex())
client assert client.public_key.hex() == public_key.hex()
assert client.private_key.hex() == private_key.hex()
# load client with private key only
= Client(ssl_options={'cert_reqs': ssl.CERT_NONE}, private_key_hex=private_key.hex())
client 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(private_key_hex=private_key.hex(), relay_urls=relay_urls_1)
client 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.
Client.disconnect
Client.disconnect ()
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(private_key_hex=private_key.hex(), relay_urls=relay_urls_1,
client ={'cert_reqs': ssl.CERT_NONE})
ssl_optionswith 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
Client.get_notices_from_relay
Client.get_notices_from_relay ()
calls the _notice_handler method on all notices from relays
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.
='ws://127.0.0.1:6969'
url= Client(private_key_hex=private_key.hex(), relay_urls=[url],
client ={'cert_reqs': ssl.CERT_NONE})
ssl_options
= PublicKey.from_npub('npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m').hex()
jacks_pubkey
= Filter(
a_filter =[jacks_pubkey],
authors=10
limit
)with client:
= str(uuid.uuid4())
subscription_id =a_filter, subscription_id=subscription_id)
client.publish_subscription(filtersfor 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…
Client.insert_event_to_database
Client.insert_event_to_database (event_msg:nostr.message_pool.EventMessa ge)
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(private_key_hex=private_key.hex(),
client ={'cert_reqs': ssl.CERT_NONE},
ssl_options='test', relay_urls=['wss://relay.damus.io'])
db_namewith 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}')
f'DELETE FROM events')
con.execute(with client:
= str(uuid.uuid4())
subscription_id =a_filter, subscription_id=subscription_id)
client.publish_subscription(filters 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:
= pd.read_sql('select * from events', con)
df 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.
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
Client.check_event_pubkey
Client.check_event_pubkey (event:nostr.event.Event)
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(private_key_hex=private_key.hex(),
client ={'cert_reqs': ssl.CERT_NONE},
ssl_options='test', relay_urls=[url])
db_name
= Event(public_key=jacks_pubkey, content='this is also a test')
bad_event
def error_on_invalid():
with client:
=bad_event)
client.publish_event(event
test_fail(error_on_invalid)
2023-01-13 19:46:09,749 - websocket - WARNING - websocket connected
And now publishing a good event
= Event(public_key=client.public_key.hex(), content='this is also a test', created_at=int(time.time()))
good_event assert good_event.created_at == int(time.time())
# publishing events commented out for sake of others
with client:
=good_event) client.publish_event(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.
Client.event_encrypted_message
Client.event_encrypted_message (recipient_hex:str, message:str)
Client.event_channel_metadata
Client.event_channel_metadata ()
Client.event_channel_mute_user
Client.event_channel_mute_user ()
Client.event_channel_hide_message
Client.event_channel_hide_message ()
Client.event_channel_message
Client.event_channel_message ()
Client.event_channel_metadata
Client.event_channel_metadata ()
Client.event_channel
Client.event_channel ()
Client.event_reaction
Client.event_reaction ()
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
Client.event_recommended_relay
Client.event_recommended_relay (relay_list)
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
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
Client.filter_events_recommended_relays
Client.filter_events_recommended_relays (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
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 ='python-nostr-testacct',
client.event_metadata(name='i am a robot - dont mind me',
about='https://cdn-icons-png.flaticon.com/256/7603/7603393.png')
picturewith 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(private_key_hex=private_key.hex(),
client ={'cert_reqs': ssl.CERT_NONE},
ssl_options='test', relay_urls=['wss://relay.damus.io'])
db_name
with client:
= client.filter_events_recommended_relays(authors='c80b5248fbe8f392bc3ba45091fb4e6e2b5872387601bf90f53992366b30d720')
new_filter = str(uuid.uuid4())
subscription_id =new_filter, subscription_id=subscription_id)
client.publish_subscription(filters2)
time.sleep(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"
]
}
]
}
]
}}
'select * from events',con=client.db_conn) pd.read_sql(
id | pubkey | created_at | kind | tags | content | sig | subscription_id | url |
---|