buzzkit 0.1.3__cp312-abi3-win_amd64.whl

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.
buzzkit/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """buzzkit — Python bindings + async client for Block's Buzz (Nostr) protocol.
2
+
3
+ Low-level event building/signing/verification is done in Rust (``buzz-core`` /
4
+ ``buzz-sdk``); all network I/O is pure Python. Use the module-level functions for
5
+ one-off event work, or :class:`BuzzClient` for a managed async relay connection.
6
+
7
+ This is an independent, unofficial project — not affiliated with Block, Inc.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ._native import (
13
+ HUDDLE_FRAME_SAMPLES,
14
+ HUDDLE_PROTOCOL_VERSION,
15
+ HUDDLE_SAMPLE_RATE,
16
+ KIND_ADD_MEMBER,
17
+ KIND_AUTH,
18
+ KIND_CREATE_CHANNEL,
19
+ KIND_HTTP_AUTH,
20
+ KIND_HUDDLE_ENDED,
21
+ KIND_HUDDLE_PARTICIPANT_JOINED,
22
+ KIND_HUDDLE_PARTICIPANT_LEFT,
23
+ KIND_HUDDLE_STARTED,
24
+ KIND_PRESENCE_UPDATE,
25
+ KIND_REACTION,
26
+ KIND_STREAM_MESSAGE,
27
+ KIND_STREAM_MESSAGE_V2,
28
+ HuddleDecoder,
29
+ HuddleEncoder,
30
+ build_auth_event,
31
+ build_create_channel_event,
32
+ build_huddle_started_event,
33
+ build_join_channel_event,
34
+ build_message_event,
35
+ build_presence_event,
36
+ build_profile_event,
37
+ compute_auth_tag,
38
+ generate_keypair,
39
+ pubkey_from_secret,
40
+ sign_nip98,
41
+ verify_event,
42
+ )
43
+ from .client import BuzzClient
44
+ from .huddle import (
45
+ HuddleAudio,
46
+ HuddleClient,
47
+ HuddleError,
48
+ HuddleEvent,
49
+ HuddlePeerJoined,
50
+ HuddlePeerLeft,
51
+ )
52
+
53
+ __version__ = "0.1.3"
54
+
55
+ __all__ = [
56
+ "HUDDLE_FRAME_SAMPLES",
57
+ "HUDDLE_PROTOCOL_VERSION",
58
+ "HUDDLE_SAMPLE_RATE",
59
+ "KIND_ADD_MEMBER",
60
+ "KIND_AUTH",
61
+ "KIND_CREATE_CHANNEL",
62
+ "KIND_HTTP_AUTH",
63
+ "KIND_HUDDLE_ENDED",
64
+ "KIND_HUDDLE_PARTICIPANT_JOINED",
65
+ "KIND_HUDDLE_PARTICIPANT_LEFT",
66
+ "KIND_HUDDLE_STARTED",
67
+ "KIND_PRESENCE_UPDATE",
68
+ "KIND_REACTION",
69
+ "KIND_STREAM_MESSAGE",
70
+ "KIND_STREAM_MESSAGE_V2",
71
+ "BuzzClient",
72
+ "HuddleAudio",
73
+ "HuddleClient",
74
+ "HuddleDecoder",
75
+ "HuddleEncoder",
76
+ "HuddleError",
77
+ "HuddleEvent",
78
+ "HuddlePeerJoined",
79
+ "HuddlePeerLeft",
80
+ "build_auth_event",
81
+ "build_create_channel_event",
82
+ "build_huddle_started_event",
83
+ "build_join_channel_event",
84
+ "build_message_event",
85
+ "build_presence_event",
86
+ "build_profile_event",
87
+ "compute_auth_tag",
88
+ "generate_keypair",
89
+ "pubkey_from_secret",
90
+ "sign_nip98",
91
+ "verify_event",
92
+ ]
buzzkit/_native.pyd ADDED
Binary file
buzzkit/_native.pyi ADDED
@@ -0,0 +1,102 @@
1
+ """Type stubs for the Rust extension module (buzzkit._native)."""
2
+
3
+ def generate_keypair() -> tuple[str, str, str]:
4
+ """Return ``(nsec, npub, pubkey_hex)`` for a fresh keypair."""
5
+
6
+ def pubkey_from_secret(secret: str) -> tuple[str, str]:
7
+ """Return ``(npub, pubkey_hex)`` for a secret (hex or ``nsec…``)."""
8
+
9
+ def build_message_event(
10
+ secret: str, channel_id: str, content: str, mentions: list[str] | None = ...
11
+ ) -> str:
12
+ """Build + sign a channel message (kind 9); returns NIP-01 event JSON."""
13
+
14
+ def build_profile_event(
15
+ secret: str,
16
+ display_name: str | None = ...,
17
+ name: str | None = ...,
18
+ about: str | None = ...,
19
+ picture: str | None = ...,
20
+ nip05: str | None = ...,
21
+ auth_tag: str | None = ...,
22
+ ) -> str:
23
+ """Build + sign a profile event (kind 0); returns NIP-01 event JSON.
24
+
25
+ ``auth_tag`` embeds a NIP-OA owner attestation so the Buzz desktop shows
26
+ the identity as "managed by <owner>".
27
+ """
28
+
29
+ def build_join_channel_event(secret: str, channel_id: str) -> str:
30
+ """Build + sign a NIP-29 channel self-join event (kind 9000, role=bot)."""
31
+
32
+ def build_create_channel_event(
33
+ secret: str,
34
+ channel_id: str,
35
+ name: str,
36
+ visibility: str | None = ...,
37
+ channel_type: str | None = ...,
38
+ about: str | None = ...,
39
+ ttl: int | None = ...,
40
+ ) -> str:
41
+ """Build + sign a NIP-29 create-channel event (kind 9007)."""
42
+
43
+ def build_huddle_started_event(
44
+ secret: str, parent_channel_id: str, ephemeral_channel_id: str
45
+ ) -> str:
46
+ """Build + sign a huddle-started advisory (kind 48100) for the parent channel."""
47
+
48
+ def build_presence_event(secret: str, status: str = ...) -> str:
49
+ """Build + sign a presence event (kind 20001); status online/away/offline."""
50
+
51
+ def build_auth_event(
52
+ secret: str, challenge: str, relay_url: str, auth_tag: str | None = ...
53
+ ) -> str:
54
+ """Build + sign a NIP-42 AUTH event (kind 22242); returns event JSON."""
55
+
56
+ def compute_auth_tag(owner_secret: str, agent_pubkey_hex: str, conditions: str = ...) -> str:
57
+ """Compute a NIP-OA owner-attestation tag JSON attesting an agent pubkey."""
58
+
59
+ def sign_nip98(secret: str, method: str, url: str, body: bytes | None = ...) -> str:
60
+ """Return an ``Authorization: Nostr <base64>`` header value (NIP-98)."""
61
+
62
+ def verify_event(event_json: str) -> bool:
63
+ """Verify an event's id + Schnorr signature."""
64
+
65
+ class HuddleEncoder:
66
+ """Stateful huddle audio encoder: s16le mono 48 kHz PCM in, v2 wire frames out."""
67
+
68
+ def __init__(self, bitrate: int = 32000, dtx: bool = True) -> None: ...
69
+ def encode(self, pcm: bytes) -> list[bytes]:
70
+ """Feed PCM; returns complete 20 ms wire frames (partials are buffered)."""
71
+
72
+ def flush(self) -> bytes | None:
73
+ """Zero-pad and emit the buffered partial frame, if any."""
74
+
75
+ def discard(self) -> None:
76
+ """Drop buffered PCM without emitting it (barge-in)."""
77
+
78
+ class HuddleDecoder:
79
+ """Stateful huddle audio decoder for relay frames ([peer_index][header][opus])."""
80
+
81
+ def __init__(self) -> None: ...
82
+ def decode(self, frame: bytes) -> tuple[int, int, int, int, bool, bytes]:
83
+ """Return ``(peer_index, seq, ts_48k, level_dbov, is_dtx, pcm_s16le_48k)``."""
84
+
85
+ def remove_peer(self, peer_index: int) -> None:
86
+ """Forget a peer's decoder state (indexes are recycled by the relay)."""
87
+
88
+ KIND_REACTION: int
89
+ KIND_STREAM_MESSAGE: int
90
+ KIND_PRESENCE_UPDATE: int
91
+ KIND_AUTH: int
92
+ KIND_HTTP_AUTH: int
93
+ KIND_STREAM_MESSAGE_V2: int
94
+ KIND_ADD_MEMBER: int
95
+ KIND_CREATE_CHANNEL: int
96
+ KIND_HUDDLE_STARTED: int
97
+ KIND_HUDDLE_PARTICIPANT_JOINED: int
98
+ KIND_HUDDLE_PARTICIPANT_LEFT: int
99
+ KIND_HUDDLE_ENDED: int
100
+ HUDDLE_PROTOCOL_VERSION: int
101
+ HUDDLE_SAMPLE_RATE: int
102
+ HUDDLE_FRAME_SAMPLES: int
buzzkit/client.py ADDED
@@ -0,0 +1,351 @@
1
+ """Async client for a Buzz relay: HTTP bridge + authenticated WebSocket.
2
+
3
+ HTTP methods (:meth:`BuzzClient.post_event`, :meth:`send_message`,
4
+ :meth:`query`, :meth:`claim_invite`, …) are one-shot and need no connection.
5
+ WebSocket methods (:meth:`subscribe`, :meth:`publish`) require :meth:`connect`
6
+ first — or use the client as an async context manager::
7
+
8
+ async with BuzzClient(relay_url, nsec) as bz:
9
+ async for event in bz.subscribe_channel(channel_id):
10
+ ...
11
+
12
+ Reconnection is intentionally left to the caller (e.g. RoomKit's SourceProvider
13
+ already owns reconnect/health); on a dropped socket, active subscriptions receive
14
+ a final ``closed`` and their iterators stop.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import contextlib
21
+ import json
22
+ import logging
23
+ import uuid
24
+ from collections.abc import AsyncIterator
25
+ from typing import Any
26
+
27
+ import httpx
28
+ import websockets
29
+
30
+ from . import _native
31
+
32
+ logger = logging.getLogger("buzzkit.client")
33
+
34
+ _AUTH_TIMEOUT = 20.0
35
+ _OK_TIMEOUT = 20.0
36
+ _MAX_FRAME = 1 << 20 # 1 MiB — matches the relay's max frame size
37
+
38
+
39
+ def _to_http(relay_url: str) -> str:
40
+ u = relay_url.rstrip("/")
41
+ if u.startswith("wss://"):
42
+ return "https://" + u[len("wss://") :]
43
+ if u.startswith("ws://"):
44
+ return "http://" + u[len("ws://") :]
45
+ return u
46
+
47
+
48
+ def _to_ws(relay_url: str) -> str:
49
+ u = relay_url.rstrip("/")
50
+ if u.startswith("https://"):
51
+ return "wss://" + u[len("https://") :]
52
+ if u.startswith("http://"):
53
+ return "ws://" + u[len("http://") :]
54
+ return u
55
+
56
+
57
+ class BuzzClient:
58
+ """A Buzz relay client, keyed by a single Nostr secret (agent identity)."""
59
+
60
+ def __init__(self, relay_url: str, secret: str, *, auth_tag: str | None = None) -> None:
61
+ self.relay_url = relay_url
62
+ self._http = _to_http(relay_url)
63
+ self._ws_url = _to_ws(relay_url)
64
+ self._secret = secret
65
+ self._auth_tag = auth_tag # NIP-OA owner attestation (AUTH + profile)
66
+ self.npub, self.pubkey_hex = _native.pubkey_from_secret(secret)
67
+ self._ws: Any = None
68
+ self._reader: asyncio.Task | None = None
69
+ self._authed = asyncio.Event()
70
+ self._auth_event_id: str | None = None
71
+ self._subs: dict[str, asyncio.Queue] = {}
72
+ self._pending_ok: dict[str, asyncio.Future] = {}
73
+
74
+ # ------------------------------------------------------------------ HTTP
75
+
76
+ def _nip98(self, method: str, url: str, body: bytes | None) -> str:
77
+ return _native.sign_nip98(self._secret, method, url, body)
78
+
79
+ async def post_event(self, event_json: str) -> dict:
80
+ """POST a signed event to the HTTP bridge (non-ephemeral kinds only)."""
81
+ url = f"{self._http}/events"
82
+ body = event_json.encode("utf-8")
83
+ headers = {
84
+ "Authorization": self._nip98("POST", url, body),
85
+ "Content-Type": "application/json",
86
+ }
87
+ async with httpx.AsyncClient(timeout=20.0) as c:
88
+ r = await c.post(url, content=body, headers=headers)
89
+ r.raise_for_status()
90
+ return r.json()
91
+
92
+ async def send_message(
93
+ self, channel_id: str, content: str, mentions: list[str] | None = None
94
+ ) -> dict:
95
+ """Build + post a channel chat message (kind 9)."""
96
+ ev = _native.build_message_event(self._secret, channel_id, content, mentions)
97
+ return await self.post_event(ev)
98
+
99
+ async def set_profile(
100
+ self,
101
+ display_name: str,
102
+ *,
103
+ about: str | None = None,
104
+ picture: str | None = None,
105
+ auth_tag: str | None = None,
106
+ ) -> dict:
107
+ """Publish this identity's profile (kind 0) so it shows a name in Buzz.
108
+
109
+ ``auth_tag`` defaults to the client's NIP-OA owner attestation, so a
110
+ client constructed with one automatically shows as "managed by
111
+ <owner>" in the Buzz desktop (which reads the tag from kind 0).
112
+ """
113
+ ev = _native.build_profile_event(
114
+ self._secret,
115
+ display_name=display_name,
116
+ name=display_name,
117
+ about=about,
118
+ picture=picture,
119
+ auth_tag=auth_tag if auth_tag is not None else self._auth_tag,
120
+ )
121
+ return await self.post_event(ev)
122
+
123
+ async def join_channel(self, channel_id: str) -> dict:
124
+ """Self-add as a bot member of a channel (NIP-29 kind 9000).
125
+
126
+ Published over the WebSocket — NIP-29 management events are not accepted
127
+ on the HTTP bridge — so :meth:`connect` must be called first. Required
128
+ for the identity's messages to reach other channel members and for it to
129
+ appear in mention autocomplete.
130
+ """
131
+ ev = _native.build_join_channel_event(self._secret, channel_id)
132
+ return await self.publish(ev)
133
+
134
+ async def start_huddle(
135
+ self, parent_channel_id: str, *, name: str | None = None, ttl: int = 3600
136
+ ) -> str:
137
+ """Start a huddle in a channel; returns the ephemeral huddle channel id.
138
+
139
+ Creates a private ephemeral channel (kind 9007, over the WebSocket —
140
+ :meth:`connect` first) and posts the kind-48100 announcement to the
141
+ parent channel. Join the audio with
142
+ ``HuddleClient(..., huddle_id, parent_channel_id=parent_channel_id)``.
143
+ """
144
+ huddle_id = str(uuid.uuid4())
145
+ create_ev = _native.build_create_channel_event(
146
+ self._secret,
147
+ huddle_id,
148
+ name or f"huddle-{huddle_id[:8]}",
149
+ visibility="private",
150
+ channel_type="stream",
151
+ ttl=ttl,
152
+ )
153
+ result = await self.publish(create_ev)
154
+ if not result["accepted"]:
155
+ raise RuntimeError(f"huddle channel rejected: {result['message']}")
156
+ started_ev = _native.build_huddle_started_event(
157
+ self._secret, parent_channel_id, huddle_id
158
+ )
159
+ result = await self.publish(started_ev)
160
+ if not result["accepted"]:
161
+ raise RuntimeError(f"huddle announcement rejected: {result['message']}")
162
+ return huddle_id
163
+
164
+ async def publish_presence(self, status: str = "online") -> dict:
165
+ """Announce presence (kind 20001, ephemeral) over the WebSocket."""
166
+ ev = _native.build_presence_event(self._secret, status)
167
+ return await self.publish(ev)
168
+
169
+ async def query(self, filters: list[dict]) -> list[dict]:
170
+ """Run a NIP-01 REQ over the HTTP bridge; returns a list of events."""
171
+ url = f"{self._http}/query"
172
+ body = json.dumps(filters).encode("utf-8")
173
+ headers = {
174
+ "Authorization": self._nip98("POST", url, body),
175
+ "Content-Type": "application/json",
176
+ }
177
+ async with httpx.AsyncClient(timeout=20.0) as c:
178
+ r = await c.post(url, content=body, headers=headers)
179
+ r.raise_for_status()
180
+ data = r.json()
181
+ return data if isinstance(data, list) else []
182
+
183
+ async def list_channels(self) -> list[dict]:
184
+ """List channel metadata (kind 39000) as ``{channel_id, name, event}``."""
185
+ out = []
186
+ for ev in await self.query([{"kinds": [39000], "limit": 500}]):
187
+ tags = {t[0]: t[1] for t in ev.get("tags", []) if len(t) >= 2}
188
+ out.append({"channel_id": tags.get("d"), "name": tags.get("name"), "event": ev})
189
+ return out
190
+
191
+ async def claim_invite(self, code_or_url: str) -> dict:
192
+ """Redeem a relay invite: accept the join-policy (if any), then claim.
193
+
194
+ Accepts a full ``.../invite/<code>`` URL or a bare code.
195
+ """
196
+ code = code_or_url.rsplit("/invite/", 1)[-1].strip().strip("/")
197
+ async with httpx.AsyncClient(timeout=20.0) as c:
198
+ policy = (await c.get(f"{self._http}/api/join-policy")).json().get("policy")
199
+ receipt = None
200
+ if policy:
201
+ rp = await c.post(
202
+ f"{self._http}/api/invites/accept-policy",
203
+ json={
204
+ "code": code,
205
+ "policy_version": policy["version"],
206
+ "age_confirmed": True,
207
+ },
208
+ )
209
+ rp.raise_for_status()
210
+ receipt = rp.json()["receipt"]
211
+ payload: dict = {"code": code}
212
+ if receipt:
213
+ payload["policy_receipt"] = receipt
214
+ url = f"{self._http}/api/invites/claim"
215
+ body = json.dumps(payload).encode("utf-8")
216
+ headers = {
217
+ "Authorization": self._nip98("POST", url, body),
218
+ "Content-Type": "application/json",
219
+ }
220
+ rc = await c.post(url, content=body, headers=headers)
221
+ rc.raise_for_status()
222
+ return rc.json()
223
+
224
+ # ------------------------------------------------------------- WebSocket
225
+
226
+ async def __aenter__(self) -> BuzzClient:
227
+ await self.connect()
228
+ return self
229
+
230
+ async def __aexit__(self, *exc: object) -> None:
231
+ await self.close()
232
+
233
+ async def connect(self) -> None:
234
+ """Open the WebSocket and complete the NIP-42 auth handshake."""
235
+ self._authed.clear()
236
+ self._ws = await websockets.connect(self._ws_url, max_size=_MAX_FRAME)
237
+ self._reader = asyncio.create_task(self._read_loop())
238
+ await asyncio.wait_for(self._authed.wait(), timeout=_AUTH_TIMEOUT)
239
+
240
+ async def _read_loop(self) -> None:
241
+ try:
242
+ async for raw in self._ws:
243
+ try:
244
+ msg = json.loads(raw)
245
+ except json.JSONDecodeError:
246
+ continue
247
+ if not msg:
248
+ continue
249
+ typ = msg[0]
250
+ if typ == "EVENT":
251
+ q = self._subs.get(msg[1])
252
+ if q is not None:
253
+ await q.put(("event", msg[2]))
254
+ elif typ == "OK":
255
+ self._on_ok(msg)
256
+ elif typ == "AUTH":
257
+ await self._on_auth_challenge(msg[1])
258
+ elif typ == "EOSE":
259
+ q = self._subs.get(msg[1])
260
+ if q is not None:
261
+ await q.put(("eose", None))
262
+ elif typ == "CLOSED":
263
+ q = self._subs.get(msg[1])
264
+ if q is not None:
265
+ await q.put(("closed", msg[2] if len(msg) > 2 else ""))
266
+ elif typ == "NOTICE":
267
+ logger.warning("relay NOTICE: %s", msg[1] if len(msg) > 1 else "")
268
+ except websockets.ConnectionClosed:
269
+ logger.info("buzz websocket closed")
270
+ finally:
271
+ for q in self._subs.values():
272
+ q.put_nowait(("closed", "connection lost"))
273
+
274
+ async def _on_auth_challenge(self, challenge: str) -> None:
275
+ auth_ev = json.loads(
276
+ _native.build_auth_event(self._secret, challenge, self._ws_url, self._auth_tag)
277
+ )
278
+ self._auth_event_id = auth_ev["id"]
279
+ await self._ws.send(json.dumps(["AUTH", auth_ev]))
280
+
281
+ def _on_ok(self, msg: list) -> None:
282
+ event_id = msg[1]
283
+ accepted = bool(msg[2]) if len(msg) > 2 else False
284
+ message = msg[3] if len(msg) > 3 else ""
285
+ if event_id == self._auth_event_id:
286
+ if accepted:
287
+ self._authed.set()
288
+ else:
289
+ logger.error("NIP-42 auth rejected: %s", message)
290
+ return
291
+ fut = self._pending_ok.pop(event_id, None)
292
+ if fut is not None and not fut.done():
293
+ fut.set_result((accepted, message))
294
+
295
+ async def publish(self, event_json: str) -> dict:
296
+ """Publish a signed event over the WebSocket and await the relay OK.
297
+
298
+ Use this for ephemeral kinds (20000–29999) which the HTTP bridge rejects.
299
+ """
300
+ if self._ws is None:
301
+ raise RuntimeError("call connect() before publish()")
302
+ ev = json.loads(event_json)
303
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
304
+ self._pending_ok[ev["id"]] = fut
305
+ await self._ws.send(json.dumps(["EVENT", ev]))
306
+ accepted, message = await asyncio.wait_for(fut, timeout=_OK_TIMEOUT)
307
+ return {"accepted": accepted, "event_id": ev["id"], "message": message}
308
+
309
+ async def subscribe(
310
+ self, filters: list[dict], *, sub_id: str = "sub", close_on_eose: bool = False
311
+ ) -> AsyncIterator[dict]:
312
+ """Subscribe with NIP-01 filters and async-iterate matching events."""
313
+ if self._ws is None:
314
+ raise RuntimeError("call connect() before subscribe()")
315
+ q: asyncio.Queue = asyncio.Queue()
316
+ self._subs[sub_id] = q
317
+ await self._ws.send(json.dumps(["REQ", sub_id, *filters]))
318
+ try:
319
+ while True:
320
+ kind, payload = await q.get()
321
+ if kind == "event":
322
+ yield payload
323
+ elif kind == "eose":
324
+ if close_on_eose:
325
+ break
326
+ elif kind == "closed":
327
+ break
328
+ finally:
329
+ self._subs.pop(sub_id, None)
330
+ # the socket may already be gone
331
+ with contextlib.suppress(Exception):
332
+ await self._ws.send(json.dumps(["CLOSE", sub_id]))
333
+
334
+ async def subscribe_channel(
335
+ self, channel_id: str, *, kinds: list[int] | None = None, **kw: Any
336
+ ) -> AsyncIterator[dict]:
337
+ """Subscribe to a channel's messages (default kind 9) via its h-tag."""
338
+ f = {"kinds": kinds or [_native.KIND_STREAM_MESSAGE], "#h": [channel_id]}
339
+ async for ev in self.subscribe([f], **kw):
340
+ yield ev
341
+
342
+ async def close(self) -> None:
343
+ """Cancel the reader task and close the WebSocket."""
344
+ if self._reader is not None:
345
+ self._reader.cancel()
346
+ with contextlib.suppress(asyncio.CancelledError):
347
+ await self._reader
348
+ self._reader = None
349
+ if self._ws is not None:
350
+ await self._ws.close()
351
+ self._ws = None