feat: Implement NIP-89 and NIP-90 for Nostr agent partnerships

This commit introduces a new NostrClient for interacting with the Nostr
network. The client implements the basic functionality for NIP-89
(discovery of agent capabilities) and NIP-90 (job delegation).

The following changes are included:

- A new  class in
  that can connect to relays, subscribe to events, and publish events.
- Implementation of  (NIP-89) to discover agent
  capability cards.
- Implementation of  (NIP-90) to create and publish
  job requests.
- Added  and usage: websockets [--version | <uri>] as dependencies.
- Added tests for the .

Refs #892
This commit is contained in:
Alexander Whitestone
2026-03-23 22:06:21 -04:00
parent 0b4ed1b756
commit f8f5d08678
5 changed files with 368 additions and 6 deletions

125
poetry.lock generated
View File

@@ -752,10 +752,9 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
name = "charset-normalizer"
version = "3.4.4"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = true
optional = false
python-versions = ">=3.7"
groups = ["main"]
markers = "extra == \"voice\" or extra == \"research\""
files = [
{file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"},
{file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"},
@@ -942,6 +941,67 @@ prompt-toolkit = ">=3.0.36"
[package.extras]
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
[[package]]
name = "coincurve"
version = "21.0.0"
description = "Safest and fastest Python library for secp256k1 elliptic curve operations"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "coincurve-21.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:986727bba6cf0c5670990358dc6af9a54f8d3e257979b992a9dbd50dd82fa0dc"},
{file = "coincurve-21.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c1c584059de61ed16c658e7eae87ee488e81438897dae8fabeec55ef408af474"},
{file = "coincurve-21.0.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4210b35c922b2b36c987a48c0b110ab20e490a2d6a92464ca654cb09e739fcc"},
{file = "coincurve-21.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf67332cc647ef52ef371679c76000f096843ae266ae6df5e81906eb6463186b"},
{file = "coincurve-21.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997607a952913c6a4bebe86815f458e77a42467b7a75353ccdc16c3336726880"},
{file = "coincurve-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cfdd0938f284fb147aa1723a69f8794273ec673b10856b6e6f5f63fcc99d0c2e"},
{file = "coincurve-21.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:88c1e3f6df2f2fbe18152c789a18659ee0429dc604fc77530370c9442395f681"},
{file = "coincurve-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:530b58ed570895612ef510e28df5e8a33204b03baefb5c986e22811fa09622ef"},
{file = "coincurve-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:f920af756a98edd738c0cfa431e81e3109aeec6ffd6dffb5ed4f5b5a37aacba8"},
{file = "coincurve-21.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:070e060d0d57b496e68e48b39d5e3245681376d122827cb8e09f33669ff8cf1b"},
{file = "coincurve-21.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:65ec42cab9c60d587fb6275c71f0ebc580625c377a894c4818fb2a2b583a184b"},
{file = "coincurve-21.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5828cd08eab928db899238874d1aab12fa1236f30fe095a3b7e26a5fc81df0a3"},
{file = "coincurve-21.0.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54de1cac75182de9f71ce41415faafcaf788303e21cbd0188064e268d61625e5"},
{file = "coincurve-21.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07cda058d9394bea30d57a92fdc18ee3ca6b5bc8ef776a479a2ffec917105836"},
{file = "coincurve-21.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9070804d7c71badfe4f0bf19b728cfe7c70c12e733938ead6b1db37920b745c0"},
{file = "coincurve-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:669ab5db393637824b226de058bb7ea0cb9a0236e1842d7b22f74d4a8a1f1ff1"},
{file = "coincurve-21.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3bcd538af097b3914ec3cb654262e72e224f95f2e9c1eb7fbd75d843ae4e528e"},
{file = "coincurve-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45b6a5e6b5536e1f46f729829d99ce1f8f847308d339e8880fe7fa1646935c10"},
{file = "coincurve-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:87597cf30dfc05fa74218810776efacf8816813ab9fa6ea1490f94e9f8b15e77"},
{file = "coincurve-21.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:b992d1b1dac85d7f542d9acbcf245667438839484d7f2b032fd032256bcd778e"},
{file = "coincurve-21.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f60ad56113f08e8c540bb89f4f35f44d434311433195ffff22893ccfa335070c"},
{file = "coincurve-21.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1cb1cd19fb0be22e68ecb60ad950b41f18b9b02eebeffaac9391dc31f74f08f2"},
{file = "coincurve-21.0.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05d7e255a697b3475d7ae7640d3bdef3d5bc98ce9ce08dd387f780696606c33b"},
{file = "coincurve-21.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a366c314df7217e3357bb8c7d2cda540b0bce180705f7a0ce2d1d9e28f62ad4"},
{file = "coincurve-21.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b04778b75339c6e46deb9ae3bcfc2250fbe48d1324153e4310fc4996e135715"},
{file = "coincurve-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8efcbdcd50cc219989a2662e6c6552f455efc000a15dd6ab3ebf4f9b187f41a3"},
{file = "coincurve-21.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6df44b4e3b7acdc1453ade52a52e3f8a5b53ecdd5a06bd200f1ec4b4e250f7d9"},
{file = "coincurve-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bcc0831f07cb75b91c35c13b1362e7b9dc76c376b27d01ff577bec52005e22a8"},
{file = "coincurve-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5dd7b66b83b143f3ad3861a68fc0279167a0bae44fe3931547400b7a200e90b1"},
{file = "coincurve-21.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:78dbe439e8cb22389956a4f2f2312813b4bd0531a0b691d4f8e868c7b366555d"},
{file = "coincurve-21.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9df5ceb5de603b9caf270629996710cf5ed1d43346887bc3895a11258644b65b"},
{file = "coincurve-21.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:154467858d23c48f9e5ab380433bc2625027b50617400e2984cc16f5799ab601"},
{file = "coincurve-21.0.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f57f07c44d14d939bed289cdeaba4acb986bba9f729a796b6a341eab1661eedc"},
{file = "coincurve-21.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fb03e3a388a93d31ed56a442bdec7983ea404490e21e12af76fb1dbf097082a"},
{file = "coincurve-21.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d09ba4fd9d26b00b06645fcd768c5ad44832a1fa847ebe8fb44970d3204c3cb7"},
{file = "coincurve-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1a1e7ee73bc1b3bcf14c7b0d1f44e6485785d3b53ef7b16173c36d3cefa57f93"},
{file = "coincurve-21.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ad05952b6edc593a874df61f1bc79db99d716ec48ba4302d699e14a419fe6f51"},
{file = "coincurve-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d2bf350ced38b73db9efa1ff8fd16a67a1cb35abb2dda50d89661b531f03fd3"},
{file = "coincurve-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:54d9500c56d5499375e579c3917472ffcf804c3584dd79052a79974280985c74"},
{file = "coincurve-21.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:773917f075ec4b94a7a742637d303a3a082616a115c36568eb6c873a8d950d18"},
{file = "coincurve-21.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bb82ba677fc7600a3bf200edc98f4f9604c317b18c7b3f0a10784b42686e3a53"},
{file = "coincurve-21.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5001de8324c35eee95f34e011a5c3b4e7d9ae9ca4a862a93b2c89b3f467f511b"},
{file = "coincurve-21.0.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4d0bb5340bcac695731bef51c3e0126f252453e2d1ae7fa1486d90eff978bf6"},
{file = "coincurve-21.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a9b49789ff86f3cf86cfc8ff8c6c43bac2607720ec638e8ba471fa7e8765bd2"},
{file = "coincurve-21.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b85b49e192d2ca1a906a7b978bacb55d4dcb297cc2900fbbd9b9180d50878779"},
{file = "coincurve-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ad6445f0bb61b3a4404d87a857ddb2a74a642cd4d00810237641aab4d6b1a42f"},
{file = "coincurve-21.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:d3f017f1491491f3f2c49e5d2d3a471a872d75117bfcb804d1167061c94bd347"},
{file = "coincurve-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:500e5e38cd4cbc4ea8a5c631ce843b1d52ef19ac41128568214d150f75f1f387"},
{file = "coincurve-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:ef81ca24511a808ad0ebdb8fdaf9c5c87f12f935b3d117acccc6520ad671bcce"},
{file = "coincurve-21.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:6ec8e859464116a3c90168cd2bd7439527d4b4b5e328b42e3c8e0475f9b0bf71"},
{file = "coincurve-21.0.0.tar.gz", hash = "sha256:8b37ce4265a82bebf0e796e21a769e56fdbf8420411ccbe3fafee4ed75b6a6e5"},
]
[[package]]
name = "colorama"
version = "0.4.6"
@@ -3930,6 +3990,30 @@ dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pyt
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
[[package]]
name = "pynostr"
version = "0.7.0"
description = "Python Library for nostr."
optional = false
python-versions = ">3.7.0"
groups = ["main"]
files = [
{file = "pynostr-0.7.0-py3-none-any.whl", hash = "sha256:9407a64f08f29ec230ff6c5c55404fe6ad77fef1eacf409d03cfd5508ca61834"},
{file = "pynostr-0.7.0.tar.gz", hash = "sha256:05566e18ae0ba467ba1ac6b29d82c271e4ba618ff176df5e56d544c3dee042ba"},
]
[package.dependencies]
coincurve = ">=1.8.0"
cryptography = ">=37.0.4"
requests = "*"
rich = "*"
tlv8 = "*"
tornado = "*"
typer = "*"
[package.extras]
websocket-client = ["websocket-client (>=1.3.3)"]
[[package]]
name = "pyobjc"
version = "12.1"
@@ -8016,10 +8100,9 @@ files = [
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = true
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "extra == \"voice\" or extra == \"research\""
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
@@ -8828,6 +8911,17 @@ docs = ["sphinx", "sphinx-autobuild", "sphinx-llms-txt-link", "sphinx-no-pragma"
lint = ["doc8", "mypy", "pydoclint", "ruff"]
test = ["coverage", "fake.py", "pytest", "pytest-codeblock", "pytest-cov", "pytest-ordering", "tox"]
[[package]]
name = "tlv8"
version = "0.10.0"
description = "Python module to handle type-length-value (TLV) encoded data 8-bit type, 8-bit length, and N-byte value as described within the Apple HomeKit Accessory Protocol Specification Non-Commercial Version Release R2."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "tlv8-0.10.0.tar.gz", hash = "sha256:7930a590267b809952272ac2a27ee81b99ec5191fa2eba08050e0daee4262684"},
]
[[package]]
name = "tokenizers"
version = "0.22.2"
@@ -8934,6 +9028,26 @@ typing-extensions = ">=4.10.0"
opt-einsum = ["opt-einsum (>=3.3)"]
optree = ["optree (>=0.13.0)"]
[[package]]
name = "tornado"
version = "6.5.5"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
]
[[package]]
name = "tqdm"
version = "4.67.3"
@@ -9205,7 +9319,6 @@ files = [
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
markers = {main = "extra == \"voice\" or extra == \"research\" or extra == \"dev\""}
[package.dependencies]
pysocks = {version = ">=1.5.6,<1.5.7 || >1.5.7,<2.0", optional = true, markers = "extra == \"socks\""}
@@ -9720,4 +9833,4 @@ voice = ["openai-whisper", "piper-tts", "pyttsx3", "sounddevice"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<4"
content-hash = "5af3028474051032bef12182eaa5ef55950cbaeca21d1793f878d54c03994eb0"
content-hash = "bca84c65e590e038a4b8bbd582ce8efa041f678b3adad47139d13c04690c5940"

View File

@@ -63,6 +63,8 @@ pytest-randomly = { version = ">=3.16.0", optional = true }
pytest-xdist = { version = ">=3.5.0", optional = true }
anthropic = "^0.86.0"
opencv-python = "^4.13.0.92"
websockets = ">=12.0"
pynostr = "*"
[tool.poetry.extras]
telegram = ["python-telegram-bot"]

View File

View File

@@ -0,0 +1,154 @@
# TODO: This code should be moved to the timmy-nostr repository once it's available.
# See ADR-024 for more details.
import json
import logging
from typing import Any
import websockets
from pynostr.event import Event
from pynostr.key import PrivateKey
logger = logging.getLogger(__name__)
class NostrClient:
"""
A client for interacting with the Nostr network.
"""
def __init__(self, relays: list[str], private_key_hex: str | None = None):
self.relays = relays
self._connections: dict[str, websockets.WebSocketClientProtocol] = {}
if private_key_hex:
self.private_key = PrivateKey.from_hex(private_key_hex)
self.public_key = self.private_key.public_key
else:
self.private_key = None
self.public_key = None
async def connect(self):
"""
Connect to all the relays.
"""
for relay in self.relays:
try:
conn = await websockets.connect(relay)
self._connections[relay] = conn
logger.info(f"Connected to Nostr relay: {relay}")
except Exception as e:
logger.error(f"Failed to connect to Nostr relay {relay}: {e}")
async def disconnect(self):
"""
Disconnect from all the relays.
"""
for relay, conn in self._connections.items():
try:
await conn.close()
logger.info(f"Disconnected from Nostr relay: {relay}")
except Exception as e:
logger.error(f"Failed to disconnect from Nostr relay {relay}: {e}")
self._connections = {}
async def subscribe_for_events(
self,
subscription_id: str,
filters: list[dict[str, Any]],
unsubscribe_on_eose: bool = True,
):
"""
Subscribe to events from the Nostr network.
"""
for relay, conn in self._connections.items():
try:
request = ["REQ", subscription_id]
request.extend(filters)
await conn.send(json.dumps(request))
logger.info(f"Subscribed to events on {relay} with sub_id: {subscription_id}")
async for message in conn:
message_json = json.loads(message)
message_type = message_json[0]
if message_type == "EVENT":
yield message_json[2]
elif message_type == "EOSE":
logger.info(f"End of stored events for sub_id: {subscription_id} on {relay}")
if unsubscribe_on_eose:
await self.unsubscribe(subscription_id, relay)
break
except Exception as e:
logger.error(f"Failed to subscribe to events on {relay}: {e}")
async def unsubscribe(self, subscription_id: str, relay: str):
"""
Unsubscribe from events.
"""
if relay not in self._connections:
logger.warning(f"Not connected to relay: {relay}")
return
conn = self._connections[relay]
try:
request = ["CLOSE", subscription_id]
await conn.send(json.dumps(request))
logger.info(f"Unsubscribed from sub_id: {subscription_id} on {relay}")
except Exception as e:
logger.error(f"Failed to unsubscribe from {relay}: {e}")
async def publish_event(self, event: Event):
"""
Publish an event to all connected relays.
"""
for relay, conn in self._connections.items():
try:
request = ["EVENT", event.to_dict()]
await conn.send(json.dumps(request))
logger.info(f"Published event {event.id} to {relay}")
except Exception as e:
logger.error(f"Failed to publish event to {relay}: {e}")
# NIP-89 Implementation
async def find_capability_cards(self, kinds: list[int] | None = None):
"""
Find capability cards (Kind 31990) for other agents.
"""
# Kind 31990 is for "Handler recommendations" which is a precursor to NIP-89
# NIP-89 is for "Application-specific data" which is a more general purpose
# kind. The issue description says "Kind 31990 'Capability Card' monitoring"
# which is a bit of a mix of concepts. I will use Kind 31990 as the issue
# description says.
filters = [{"kinds": [31990]}]
if kinds:
filters[0]["#k"] = [str(k) for k in kinds]
sub_id = "capability-card-finder"
async for event in self.subscribe_for_events(sub_id, filters):
yield event
# NIP-90 Implementation
async def create_job_request(
self,
kind: int,
content: str,
tags: list[list[str]] | None = None,
) -> Event:
"""
Create and publish a job request (Kind 5000-5999).
"""
if not self.private_key:
raise Exception("Cannot create job request without a private key.")
if not 5000 <= kind <= 5999:
raise ValueError("Job request kind must be between 5000 and 5999.")
event = Event(
pubkey=self.public_key.hex(),
kind=kind,
content=content,
tags=tags or [],
)
event.sign(self.private_key.hex())
await self.publish_event(event)
return event

View File

@@ -0,0 +1,93 @@
import json
import pytest
import websockets
from pynostr.key import PrivateKey
from src.infrastructure.clients.nostr_client import NostrClient
@pytest.mark.asyncio
async def test_nostr_client_connect_disconnect():
# Using a public mock relay for testing
relays = ["wss://relay.damus.io"]
client = NostrClient(relays)
await client.connect()
assert len(client._connections) == 1
for relay in relays:
assert relay in client._connections
assert client._connections[relay].state == websockets.protocol.State.OPEN
await client.disconnect()
assert len(client._connections) == 0
@pytest.mark.asyncio
async def test_find_capability_cards():
relays = ["wss://relay.damus.io"]
client = NostrClient(relays)
await client.connect()
# Create a dummy capability card event
# In a real scenario, this would be published by another agent
dummy_event = {
"id": "faked_id",
"pubkey": "faked_pubkey",
"created_at": 1678886400,
"kind": 31990,
"tags": [
["d", "test-platform"],
["k", "5000"]
],
"content": json.dumps({
"name": "Test Agent",
"about": "An agent for testing purposes"
}),
"sig": "faked_sig"
}
async def event_generator():
yield dummy_event
# Mock the subscribe_for_events method to return the dummy event
async def mock_subscribe_for_events(subscription_id, filters, unsubscribe_on_eose=True):
async for event in event_generator():
yield event
client.subscribe_for_events = mock_subscribe_for_events
async for event in client.find_capability_cards():
assert event["kind"] == 31990
await client.disconnect()
@pytest.mark.asyncio
async def test_create_job_request():
private_key = PrivateKey()
relays = ["wss://relay.damus.io"]
client = NostrClient(relays, private_key.hex())
await client.connect()
# Mock the publish_event method
published_events = []
async def mock_publish_event(event):
published_events.append(event)
client.publish_event = mock_publish_event
kind = 5001
content = "Test job request"
tags = [["d", "test-job"]]
event = await client.create_job_request(kind, content, tags)
assert event.kind == kind
assert event.content == content
assert event.tags == tags
assert event.pubkey == private_key.public_key.hex()
assert event.verify()
assert len(published_events) == 1
assert published_events[0] == event
await client.disconnect()