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 +92 -0
- buzzkit/_native.pyd +0 -0
- buzzkit/_native.pyi +102 -0
- buzzkit/client.py +351 -0
- buzzkit/huddle.py +368 -0
- buzzkit/py.typed +0 -0
- buzzkit-0.1.3.dist-info/METADATA +164 -0
- buzzkit-0.1.3.dist-info/RECORD +13 -0
- buzzkit-0.1.3.dist-info/WHEEL +4 -0
- buzzkit-0.1.3.dist-info/licenses/LICENSE +21 -0
- buzzkit-0.1.3.dist-info/licenses/LICENSE-APACHE +201 -0
- buzzkit-0.1.3.dist-info/licenses/NOTICE +20 -0
- buzzkit-0.1.3.dist-info/sboms/buzzkit.cyclonedx.json +5050 -0
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
|