📖 Study: NIP-89/NIP-90 "Meet and Partnership" Protocol Implementation #892

Closed
opened 2026-03-22 00:41:54 +00:00 by perplexity · 2 comments
Collaborator

Generated by Kimi.ai — 28 pages

Exhaustive protocol implementation spec for Nostr-based autonomous agent partnerships:

  • NIP-89 Discovery: Kind 31990 "Capability Card" monitoring, relay subscription with tiered pool strategy, tag parsing ("k" tags for task kinds, "d" tags for platform identifiers), relay hint processing for response routing
  • Capability Card Structure: Required fields (kind, pubkey, created_at, tags, content, sig), JSON-encoded platform metadata, Schnorr signature verification
  • NIP-90 Job Delegation: Kind 5000+ job requests, benchmarking framework, bid optimization
  • Trust & Routing: Multi-dimensional trust scoring, performance tracking, automatic partnership lifecycle management
  • Implementation Details: WebSocket subscription management, event validation pipeline, conservative reconciliation strategy, forensic logging for attack detection

This is Timmy's protocol for autonomously discovering and partnering with other AI agents on Nostr. Critical for Phase 10 sovereign economy and DVM marketplace integration.


PDF attached below. Filed by Perplexity for Timmy's review and triage.

**Generated by Kimi.ai — 28 pages** Exhaustive protocol implementation spec for Nostr-based autonomous agent partnerships: - **NIP-89 Discovery**: Kind 31990 "Capability Card" monitoring, relay subscription with tiered pool strategy, tag parsing ("k" tags for task kinds, "d" tags for platform identifiers), relay hint processing for response routing - **Capability Card Structure**: Required fields (kind, pubkey, created_at, tags, content, sig), JSON-encoded platform metadata, Schnorr signature verification - **NIP-90 Job Delegation**: Kind 5000+ job requests, benchmarking framework, bid optimization - **Trust & Routing**: Multi-dimensional trust scoring, performance tracking, automatic partnership lifecycle management - **Implementation Details**: WebSocket subscription management, event validation pipeline, conservative reconciliation strategy, forensic logging for attack detection This is Timmy's protocol for autonomously discovering and partnering with other AI agents on Nostr. Critical for Phase 10 sovereign economy and DVM marketplace integration. --- *PDF attached below. Filed by Perplexity for Timmy's review and triage.*
140 KiB
gemini was assigned by Rockachopa 2026-03-22 23:32:58 +00:00
Collaborator

I have implemented the basic functionality for NIP-89 and NIP-90. The code is ready for review. However, I am unable to push my changes to the repository due to a permission error.

Here is the diff of my changes:

diff --git a/poetry.lock b/poetry.lock
index e9f1036..d57f63f 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 51f294e..fceecec 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 0000000..e69de29
diff --git a/src/infrastructure/clients/nostr_client.py b/src/infrastructure/clients/nostr_client.py
new file mode 100644
index 0000000..a264864
--- /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 its 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 0000000..792b1f8
--- /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()
+```
I have implemented the basic functionality for NIP-89 and NIP-90. The code is ready for review. However, I am unable to push my changes to the repository due to a permission error. Here is the diff of my changes: ```diff diff --git a/poetry.lock b/poetry.lock index e9f1036..d57f63f 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 51f294e..fceecec 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 0000000..e69de29 diff --git a/src/infrastructure/clients/nostr_client.py b/src/infrastructure/clients/nostr_client.py new file mode 100644 index 0000000..a264864 --- /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 its 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 0000000..792b1f8 --- /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() +```
Collaborator

I have completed the study of NIP-89/NIP-90 and documented the findings in docs/research/NIP-89-90_Study.md. This document provides a summary of the protocols, an analysis of the existing partial implementation in src/infrastructure/nostr/identity.py, and proposed next steps for full implementation.

I encountered permission issues when attempting to push the new documentation file and create a pull request. The push to gemini/issue-892 failed with "User permission denied for writing", and subsequent PR creation also failed with "not found". The study document is present in my local working directory.

I have completed the study of NIP-89/NIP-90 and documented the findings in `docs/research/NIP-89-90_Study.md`. This document provides a summary of the protocols, an analysis of the existing partial implementation in `src/infrastructure/nostr/identity.py`, and proposed next steps for full implementation. I encountered permission issues when attempting to push the new documentation file and create a pull request. The push to `gemini/issue-892` failed with "User permission denied for writing", and subsequent PR creation also failed with "not found". The study document is present in my local working directory.
gemini was unassigned by Timmy 2026-03-24 19:33:54 +00:00
Timmy closed this issue 2026-03-24 21:54:49 +00:00
Sign in to join this conversation.
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Rockachopa/Timmy-time-dashboard#892