buzzkit 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,3 +6,5 @@ __pycache__/
6
6
  *.egg-info/
7
7
  .python-version
8
8
  agent.secret
9
+ /dist
10
+ owner.secret
@@ -168,7 +168,7 @@ dependencies = [
168
168
 
169
169
  [[package]]
170
170
  name = "buzzkit"
171
- version = "0.1.0"
171
+ version = "0.1.2"
172
172
  dependencies = [
173
173
  "base64",
174
174
  "buzz-core",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buzzkit"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  edition = "2021"
5
5
  publish = false
6
6
  description = "PyO3 bindings over Block's Buzz zero-I/O crates (buzz-core / buzz-sdk)"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buzzkit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,6 +1,6 @@
1
1
  """Redeem a relay invite so the agent becomes a community member.
2
2
 
3
- BUZZ_RELAY_URL=wss://... python examples/claim_invite.py <invite_url_or_code>
3
+ BUZZ_RELAY_URL=wss://... python examples/claim_invite.py <invite_url_or_code>
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """List channels (kind 39000 metadata) visible to the agent.
2
2
 
3
- BUZZ_RELAY_URL=wss://... python examples/list_channels.py
3
+ BUZZ_RELAY_URL=wss://... python examples/list_channels.py
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Offline proof — no relay. keygen -> sign a Buzz message -> verify -> tamper check.
2
2
 
3
- python examples/offline_sign.py
3
+ python examples/offline_sign.py
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Post a message to a channel via the HTTP bridge.
2
2
 
3
- BUZZ_RELAY_URL=wss://... python examples/send_message.py <channel-uuid> ["message"]
3
+ BUZZ_RELAY_URL=wss://... python examples/send_message.py <channel-uuid> ["message"]
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Publish the agent's profile (kind 0) so it shows a display name in Buzz.
2
2
 
3
- BUZZ_RELAY_URL=wss://... python examples/set_profile.py ["Display Name"]
3
+ BUZZ_RELAY_URL=wss://... python examples/set_profile.py ["Display Name"]
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -1,6 +1,6 @@
1
1
  """Real-time inbound: subscribe to a channel over the WebSocket and print messages.
2
2
 
3
- BUZZ_RELAY_URL=wss://... python examples/subscribe.py <channel-uuid>
3
+ BUZZ_RELAY_URL=wss://... python examples/subscribe.py <channel-uuid>
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "buzzkit"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python bindings + async client for Block's Buzz (Nostr) protocol, backed by Rust buzz-core/buzz-sdk"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -10,6 +10,7 @@ This is an independent, unofficial project — not affiliated with Block, Inc.
10
10
  from __future__ import annotations
11
11
 
12
12
  from ._native import (
13
+ KIND_ADD_MEMBER,
13
14
  KIND_AUTH,
14
15
  KIND_HTTP_AUTH,
15
16
  KIND_PRESENCE_UPDATE,
@@ -17,8 +18,11 @@ from ._native import (
17
18
  KIND_STREAM_MESSAGE,
18
19
  KIND_STREAM_MESSAGE_V2,
19
20
  build_auth_event,
21
+ build_join_channel_event,
20
22
  build_message_event,
23
+ build_presence_event,
21
24
  build_profile_event,
25
+ compute_auth_tag,
22
26
  generate_keypair,
23
27
  pubkey_from_secret,
24
28
  sign_nip98,
@@ -26,9 +30,10 @@ from ._native import (
26
30
  )
27
31
  from .client import BuzzClient
28
32
 
29
- __version__ = "0.1.0"
33
+ __version__ = "0.1.2"
30
34
 
31
35
  __all__ = [
36
+ "KIND_ADD_MEMBER",
32
37
  "KIND_AUTH",
33
38
  "KIND_HTTP_AUTH",
34
39
  "KIND_PRESENCE_UPDATE",
@@ -37,8 +42,11 @@ __all__ = [
37
42
  "KIND_STREAM_MESSAGE_V2",
38
43
  "BuzzClient",
39
44
  "build_auth_event",
45
+ "build_join_channel_event",
40
46
  "build_message_event",
47
+ "build_presence_event",
41
48
  "build_profile_event",
49
+ "compute_auth_tag",
42
50
  "generate_keypair",
43
51
  "pubkey_from_secret",
44
52
  "sign_nip98",
@@ -21,9 +21,20 @@ def build_profile_event(
21
21
  ) -> str:
22
22
  """Build + sign a profile event (kind 0); returns NIP-01 event JSON."""
23
23
 
24
- def build_auth_event(secret: str, challenge: str, relay_url: str) -> str:
24
+ def build_join_channel_event(secret: str, channel_id: str) -> str:
25
+ """Build + sign a NIP-29 channel self-join event (kind 9000, role=bot)."""
26
+
27
+ def build_presence_event(secret: str, status: str = ...) -> str:
28
+ """Build + sign a presence event (kind 20001); status online/away/offline."""
29
+
30
+ def build_auth_event(
31
+ secret: str, challenge: str, relay_url: str, auth_tag: str | None = ...
32
+ ) -> str:
25
33
  """Build + sign a NIP-42 AUTH event (kind 22242); returns event JSON."""
26
34
 
35
+ def compute_auth_tag(owner_secret: str, agent_pubkey_hex: str, conditions: str = ...) -> str:
36
+ """Compute a NIP-OA owner-attestation tag JSON attesting an agent pubkey."""
37
+
27
38
  def sign_nip98(secret: str, method: str, url: str, body: bytes | None = ...) -> str:
28
39
  """Return an ``Authorization: Nostr <base64>`` header value (NIP-98)."""
29
40
 
@@ -36,3 +47,4 @@ KIND_PRESENCE_UPDATE: int
36
47
  KIND_AUTH: int
37
48
  KIND_HTTP_AUTH: int
38
49
  KIND_STREAM_MESSAGE_V2: int
50
+ KIND_ADD_MEMBER: int
@@ -108,6 +108,22 @@ class BuzzClient:
108
108
  )
109
109
  return await self.post_event(ev)
110
110
 
111
+ async def join_channel(self, channel_id: str) -> dict:
112
+ """Self-add as a bot member of a channel (NIP-29 kind 9000).
113
+
114
+ Published over the WebSocket — NIP-29 management events are not accepted
115
+ on the HTTP bridge — so :meth:`connect` must be called first. Required
116
+ for the identity's messages to reach other channel members and for it to
117
+ appear in mention autocomplete.
118
+ """
119
+ ev = _native.build_join_channel_event(self._secret, channel_id)
120
+ return await self.publish(ev)
121
+
122
+ async def publish_presence(self, status: str = "online") -> dict:
123
+ """Announce presence (kind 20001, ephemeral) over the WebSocket."""
124
+ ev = _native.build_presence_event(self._secret, status)
125
+ return await self.publish(ev)
126
+
111
127
  async def query(self, filters: list[dict]) -> list[dict]:
112
128
  """Run a NIP-01 REQ over the HTTP bridge; returns a list of events."""
113
129
  url = f"{self._http}/query"
@@ -214,7 +230,9 @@ class BuzzClient:
214
230
  q.put_nowait(("closed", "connection lost"))
215
231
 
216
232
  async def _on_auth_challenge(self, challenge: str) -> None:
217
- auth_ev = json.loads(_native.build_auth_event(self._secret, challenge, self._ws_url))
233
+ auth_ev = json.loads(
234
+ _native.build_auth_event(self._secret, challenge, self._ws_url, self._auth_tag)
235
+ )
218
236
  self._auth_event_id = auth_ev["id"]
219
237
  await self._ws.send(json.dumps(["AUTH", auth_ev]))
220
238
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  use base64::engine::general_purpose::STANDARD as B64;
9
9
  use base64::Engine as _;
10
- use nostr::{EventBuilder, JsonUtil, Keys, Kind, RelayUrl, Tag, ToBech32};
10
+ use nostr::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, Tag, ToBech32};
11
11
  use pyo3::exceptions::PyValueError;
12
12
  use pyo3::prelude::*;
13
13
  use sha2::{Digest, Sha256};
@@ -69,17 +69,50 @@ fn build_message_event(
69
69
 
70
70
  /// Build and sign a NIP-42 AUTH event (kind 22242) for a relay challenge.
71
71
  /// Returns the event JSON to send back as `["AUTH", <event>]`.
72
+ ///
73
+ /// `auth_tag` is an optional NIP-OA owner-attestation tag JSON
74
+ /// (`["auth", <owner>, <conditions>, <sig>]`) — inject it so the relay records
75
+ /// the agent's owner.
72
76
  #[pyfunction]
73
- fn build_auth_event(secret: &str, challenge: &str, relay_url: &str) -> PyResult<String> {
77
+ #[pyo3(signature = (secret, challenge, relay_url, auth_tag=None))]
78
+ fn build_auth_event(
79
+ secret: &str,
80
+ challenge: &str,
81
+ relay_url: &str,
82
+ auth_tag: Option<&str>,
83
+ ) -> PyResult<String> {
74
84
  let keys = keys_from_secret(secret)?;
75
85
  let url = RelayUrl::parse(relay_url)
76
86
  .map_err(|e| PyValueError::new_err(format!("invalid relay url: {e}")))?;
77
- let event = EventBuilder::auth(challenge, url)
87
+ let mut builder = EventBuilder::auth(challenge, url);
88
+ if let Some(tag_json) = auth_tag {
89
+ let tag = buzz_sdk::nip_oa::parse_auth_tag(tag_json)
90
+ .map_err(|e| PyValueError::new_err(format!("invalid auth_tag: {e}")))?;
91
+ builder = builder.tags([tag]);
92
+ }
93
+ let event = builder
78
94
  .sign_with_keys(&keys)
79
95
  .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?;
80
96
  Ok(event.as_json())
81
97
  }
82
98
 
99
+ /// Compute a NIP-OA owner-attestation tag: the OWNER signs an attestation over
100
+ /// the agent's pubkey. Returns the `["auth", <owner>, <conditions>, <sig>]` tag
101
+ /// JSON. `owner_secret` must differ from the agent (no self-attestation).
102
+ #[pyfunction]
103
+ #[pyo3(signature = (owner_secret, agent_pubkey_hex, conditions=""))]
104
+ fn compute_auth_tag(
105
+ owner_secret: &str,
106
+ agent_pubkey_hex: &str,
107
+ conditions: &str,
108
+ ) -> PyResult<String> {
109
+ let owner_keys = keys_from_secret(owner_secret)?;
110
+ let agent_pubkey = PublicKey::from_hex(agent_pubkey_hex)
111
+ .map_err(|e| PyValueError::new_err(format!("invalid agent pubkey: {e}")))?;
112
+ buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions)
113
+ .map_err(|e| PyValueError::new_err(format!("compute_auth_tag: {e}")))
114
+ }
115
+
83
116
  /// Sign a NIP-98 HTTP-auth event (kind 27235). Returns the
84
117
  /// `Authorization: Nostr <base64>` header value for the Buzz HTTP bridge.
85
118
  #[pyfunction]
@@ -140,15 +173,51 @@ fn build_profile_event(
140
173
  Ok(event.as_json())
141
174
  }
142
175
 
176
+ /// Build and sign a NIP-29 channel self-join event (kind 9000, role=bot).
177
+ /// The signer adds itself (`p` = own pubkey) to `channel_id`'s member list.
178
+ #[pyfunction]
179
+ fn build_join_channel_event(secret: &str, channel_id: &str) -> PyResult<String> {
180
+ let keys = keys_from_secret(secret)?;
181
+ let cid = Uuid::parse_str(channel_id)
182
+ .map_err(|e| PyValueError::new_err(format!("channel_id must be a UUID: {e}")))?;
183
+ let pubkey_hex = keys.public_key().to_hex();
184
+ // The `p` tag references the signer itself (self-join), so we must opt into
185
+ // self-tagging — nostr's EventBuilder strips author-matching `p` tags by
186
+ // default, which the relay would then reject as "missing p tag".
187
+ let builder = buzz_sdk::build_add_member(cid, &pubkey_hex, Some(buzz_sdk::MemberRole::Bot))
188
+ .map_err(|e| PyValueError::new_err(format!("build_add_member: {e}")))?
189
+ .allow_self_tagging();
190
+ let event = builder
191
+ .sign_with_keys(&keys)
192
+ .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?;
193
+ Ok(event.as_json())
194
+ }
195
+
196
+ /// Build and sign a presence event (kind 20001). `status` is "online",
197
+ /// "away", or "offline". Ephemeral — publish over the WebSocket.
198
+ #[pyfunction]
199
+ #[pyo3(signature = (secret, status="online"))]
200
+ fn build_presence_event(secret: &str, status: &str) -> PyResult<String> {
201
+ let keys = keys_from_secret(secret)?;
202
+ let event = buzz_sdk::build_presence_update(status)
203
+ .map_err(|e| PyValueError::new_err(format!("build_presence: {e}")))?
204
+ .sign_with_keys(&keys)
205
+ .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?;
206
+ Ok(event.as_json())
207
+ }
208
+
143
209
  #[pymodule]
144
210
  fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
145
211
  m.add_function(wrap_pyfunction!(generate_keypair, m)?)?;
146
212
  m.add_function(wrap_pyfunction!(pubkey_from_secret, m)?)?;
147
213
  m.add_function(wrap_pyfunction!(build_message_event, m)?)?;
148
214
  m.add_function(wrap_pyfunction!(build_auth_event, m)?)?;
215
+ m.add_function(wrap_pyfunction!(compute_auth_tag, m)?)?;
149
216
  m.add_function(wrap_pyfunction!(sign_nip98, m)?)?;
150
217
  m.add_function(wrap_pyfunction!(verify_event, m)?)?;
151
218
  m.add_function(wrap_pyfunction!(build_profile_event, m)?)?;
219
+ m.add_function(wrap_pyfunction!(build_join_channel_event, m)?)?;
220
+ m.add_function(wrap_pyfunction!(build_presence_event, m)?)?;
152
221
 
153
222
  // Buzz event kinds (subset — mirrors buzz-core/src/kind.rs).
154
223
  m.add("KIND_REACTION", 7u16)?;
@@ -157,5 +226,6 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
157
226
  m.add("KIND_AUTH", 22242u16)?;
158
227
  m.add("KIND_HTTP_AUTH", 27235u16)?;
159
228
  m.add("KIND_STREAM_MESSAGE_V2", 40002u16)?;
229
+ m.add("KIND_ADD_MEMBER", 9000u16)?;
160
230
  Ok(())
161
231
  }
@@ -59,8 +59,39 @@ def test_nip98_header_shape():
59
59
 
60
60
  def test_auth_event_is_kind_22242():
61
61
  nsec, _, _ = buzzkit.generate_keypair()
62
- event = json.loads(
63
- buzzkit.build_auth_event(nsec, "challenge-123", "wss://relay.example")
64
- )
62
+ event = json.loads(buzzkit.build_auth_event(nsec, "challenge-123", "wss://relay.example"))
65
63
  assert event["kind"] == buzzkit.KIND_AUTH == 22242
66
64
  assert ["challenge", "challenge-123"] in event["tags"]
65
+
66
+
67
+ def test_join_channel_event_keeps_self_p_tag():
68
+ nsec, _, pk_hex = buzzkit.generate_keypair()
69
+ event = json.loads(buzzkit.build_join_channel_event(nsec, str(uuid.uuid4())))
70
+ assert event["kind"] == buzzkit.KIND_ADD_MEMBER == 9000
71
+ # The `p` tag references the signer itself; it must survive nostr's self-tag
72
+ # stripping (allow_self_tagging) or the relay rejects the join as "missing p tag".
73
+ assert ["p", pk_hex] in event["tags"]
74
+ assert ["role", "bot"] in event["tags"]
75
+ assert any(t[0] == "h" for t in event["tags"])
76
+
77
+
78
+ def test_compute_auth_tag():
79
+ owner_nsec, _, owner_pk = buzzkit.generate_keypair()
80
+ _, _, agent_pk = buzzkit.generate_keypair()
81
+ tag = json.loads(buzzkit.compute_auth_tag(owner_nsec, agent_pk))
82
+ assert tag[0] == "auth"
83
+ assert tag[1] == owner_pk # owner pubkey
84
+ assert len(tag) == 4 # ["auth", owner, conditions, sig]
85
+
86
+
87
+ def test_compute_auth_tag_rejects_self():
88
+ nsec, _, pk_hex = buzzkit.generate_keypair()
89
+ with pytest.raises(ValueError):
90
+ buzzkit.compute_auth_tag(nsec, pk_hex) # owner == agent
91
+
92
+
93
+ def test_presence_event():
94
+ nsec, _, _ = buzzkit.generate_keypair()
95
+ event = json.loads(buzzkit.build_presence_event(nsec, "online"))
96
+ assert event["kind"] == buzzkit.KIND_PRESENCE_UPDATE == 20001
97
+ assert event["content"] == "online" or ["status", "online"] in event["tags"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes