From f8f5d0867820e956e858f87a8395ff3d05b86aa0 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 22:06:21 -0400 Subject: [PATCH] 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 | ] as dependencies. - Added tests for the . Refs #892 --- poetry.lock | 125 +++++++++++++- pyproject.toml | 2 + src/infrastructure/clients/__init__.py | 0 src/infrastructure/clients/nostr_client.py | 154 ++++++++++++++++++ .../clients/test_nostr_client.py | 93 +++++++++++ 5 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 src/infrastructure/clients/__init__.py create mode 100644 src/infrastructure/clients/nostr_client.py create mode 100644 tests/infrastructure/clients/test_nostr_client.py diff --git a/poetry.lock b/poetry.lock index e9f10366..d57f63fe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 51f294eb..fceecec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/infrastructure/clients/__init__.py b/src/infrastructure/clients/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/infrastructure/clients/nostr_client.py b/src/infrastructure/clients/nostr_client.py new file mode 100644 index 00000000..a2648645 --- /dev/null +++ b/src/infrastructure/clients/nostr_client.py @@ -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 diff --git a/tests/infrastructure/clients/test_nostr_client.py b/tests/infrastructure/clients/test_nostr_client.py new file mode 100644 index 00000000..792b1f8a --- /dev/null +++ b/tests/infrastructure/clients/test_nostr_client.py @@ -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()