buzzkit 0.1.1__tar.gz → 0.1.3__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.
Files changed (32) hide show
  1. buzzkit-0.1.3/.cargo/config.toml +12 -0
  2. {buzzkit-0.1.1 → buzzkit-0.1.3}/.github/workflows/CI.yml +3 -0
  3. {buzzkit-0.1.1 → buzzkit-0.1.3}/.github/workflows/release.yml +20 -2
  4. {buzzkit-0.1.1 → buzzkit-0.1.3}/.gitignore +3 -0
  5. {buzzkit-0.1.1 → buzzkit-0.1.3}/Cargo.lock +37 -1
  6. {buzzkit-0.1.1 → buzzkit-0.1.3}/Cargo.toml +5 -2
  7. {buzzkit-0.1.1 → buzzkit-0.1.3}/PKG-INFO +29 -1
  8. {buzzkit-0.1.1 → buzzkit-0.1.3}/README.md +28 -0
  9. buzzkit-0.1.3/build-support/vendored-libopus.cmake +11 -0
  10. buzzkit-0.1.3/examples/huddle_echo.py +58 -0
  11. {buzzkit-0.1.1 → buzzkit-0.1.3}/pyproject.toml +1 -1
  12. {buzzkit-0.1.1 → buzzkit-0.1.3}/python/buzzkit/__init__.py +43 -1
  13. buzzkit-0.1.3/python/buzzkit/_native.pyi +102 -0
  14. {buzzkit-0.1.1 → buzzkit-0.1.3}/python/buzzkit/client.py +53 -4
  15. buzzkit-0.1.3/python/buzzkit/huddle.py +368 -0
  16. buzzkit-0.1.3/src/huddle.rs +497 -0
  17. {buzzkit-0.1.1 → buzzkit-0.1.3}/src/lib.rs +153 -5
  18. buzzkit-0.1.3/tests/test_huddle.py +238 -0
  19. buzzkit-0.1.3/tests/test_live_relay.py +112 -0
  20. {buzzkit-0.1.1 → buzzkit-0.1.3}/tests/test_signing.py +51 -0
  21. buzzkit-0.1.1/python/buzzkit/_native.pyi +0 -42
  22. {buzzkit-0.1.1 → buzzkit-0.1.3}/LICENSE +0 -0
  23. {buzzkit-0.1.1 → buzzkit-0.1.3}/LICENSE-APACHE +0 -0
  24. {buzzkit-0.1.1 → buzzkit-0.1.3}/NOTICE +0 -0
  25. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/_shared.py +0 -0
  26. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/claim_invite.py +0 -0
  27. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/list_channels.py +0 -0
  28. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/offline_sign.py +0 -0
  29. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/send_message.py +0 -0
  30. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/set_profile.py +0 -0
  31. {buzzkit-0.1.1 → buzzkit-0.1.3}/examples/subscribe.py +0 -0
  32. {buzzkit-0.1.1 → buzzkit-0.1.3}/python/buzzkit/py.typed +0 -0
@@ -0,0 +1,12 @@
1
+ # The `opus` crate (audiopus_sys) builds a vendored libopus whose CMakeLists
2
+ # declares a pre-3.5 minimum; CMake 4 removed compatibility with those and
3
+ # aborts configure. This env var is CMake's documented escape hatch and is a
4
+ # no-op on older CMake. Applies to local builds, CI, and source installs alike.
5
+ #
6
+ # Deliberately NOT set here: CMAKE_TOOLCHAIN_FILE. cmake-rs skips its own
7
+ # cross-compilation config whenever a toolchain file is present, which breaks
8
+ # cross builds (aarch64 wheels compiled host-arch objects). The lib64 pin in
9
+ # build-support/vendored-libopus.cmake is applied only where needed, by the
10
+ # release workflow (native RPM-family manylinux containers).
11
+ [env]
12
+ CMAKE_POLICY_VERSION_MINIMUM = "3.5"
@@ -29,6 +29,9 @@ jobs:
29
29
  - name: Clippy
30
30
  run: cargo clippy --all-targets -- -D warnings
31
31
 
32
+ - name: Rust tests
33
+ run: cargo test
34
+
32
35
  - name: Install (editable) with dev deps
33
36
  run: pip install -e '.[dev]'
34
37
 
@@ -21,7 +21,9 @@ jobs:
21
21
  platform:
22
22
  - { os: ubuntu-latest, target: x86_64 }
23
23
  - { os: ubuntu-latest, target: aarch64 }
24
- - { os: macos-13, target: x86_64 }
24
+ # Intel wheel is cross-compiled from the ARM runner: GitHub retired
25
+ # the macos-13 (Intel) runners — the label queues forever.
26
+ - { os: macos-14, target: x86_64 }
25
27
  - { os: macos-14, target: aarch64 }
26
28
  - { os: windows-latest, target: x64 }
27
29
  steps:
@@ -34,7 +36,23 @@ jobs:
34
36
  with:
35
37
  target: ${{ matrix.platform.target }}
36
38
  args: --release --out dist
37
- manylinux: auto
39
+ # 2_28 (not auto): its cross toolchains are recent enough to compile
40
+ # the vendored libopus NEON intrinsics (manylinux2014's aarch64 gcc
41
+ # chokes on them).
42
+ manylinux: 2_28
43
+ # Static libopus so the wheel is self-contained; cmake for the
44
+ # vendored build when the container doesn't ship it. On RPM-family
45
+ # containers (native x86_64 manylinux) GNUInstallDirs installs to
46
+ # lib64 where audiopus_sys never looks — pin libdir via a toolchain
47
+ # file there. Never set it for the cross containers (Ubuntu-family,
48
+ # libdir is already "lib"): cmake-rs skips its own cross config when
49
+ # a toolchain file is present, producing host-arch objects.
50
+ before-script-linux: |
51
+ export LIBOPUS_STATIC=1
52
+ command -v cmake || yum install -y cmake || dnf install -y cmake || (apt-get update && apt-get install -y cmake)
53
+ if [ -f /etc/redhat-release ] || [ -f /etc/almalinux-release ]; then
54
+ export CMAKE_TOOLCHAIN_FILE="$(pwd)/build-support/vendored-libopus.cmake"
55
+ fi
38
56
  - uses: actions/upload-artifact@v4
39
57
  with:
40
58
  name: wheels-${{ matrix.platform.os }}-${{ matrix.platform.target }}
@@ -6,3 +6,6 @@ __pycache__/
6
6
  *.egg-info/
7
7
  .python-version
8
8
  agent.secret
9
+ /dist
10
+ owner.secret
11
+ /tasks
@@ -27,6 +27,17 @@ version = "0.7.8"
27
27
  source = "registry+https://github.com/rust-lang/crates.io-index"
28
28
  checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
29
29
 
30
+ [[package]]
31
+ name = "audiopus_sys"
32
+ version = "0.2.2"
33
+ source = "registry+https://github.com/rust-lang/crates.io-index"
34
+ checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651"
35
+ dependencies = [
36
+ "cmake",
37
+ "log",
38
+ "pkg-config",
39
+ ]
40
+
30
41
  [[package]]
31
42
  name = "autocfg"
32
43
  version = "1.5.1"
@@ -168,13 +179,14 @@ dependencies = [
168
179
 
169
180
  [[package]]
170
181
  name = "buzzkit"
171
- version = "0.1.1"
182
+ version = "0.1.3"
172
183
  dependencies = [
173
184
  "base64",
174
185
  "buzz-core",
175
186
  "buzz-sdk",
176
187
  "hex",
177
188
  "nostr",
189
+ "opus",
178
190
  "pyo3",
179
191
  "sha2 0.10.9",
180
192
  "uuid",
@@ -265,6 +277,15 @@ dependencies = [
265
277
  "zeroize",
266
278
  ]
267
279
 
280
+ [[package]]
281
+ name = "cmake"
282
+ version = "0.1.58"
283
+ source = "registry+https://github.com/rust-lang/crates.io-index"
284
+ checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
285
+ dependencies = [
286
+ "cc",
287
+ ]
288
+
268
289
  [[package]]
269
290
  name = "cmov"
270
291
  version = "0.5.4"
@@ -748,6 +769,15 @@ version = "0.3.1"
748
769
  source = "registry+https://github.com/rust-lang/crates.io-index"
749
770
  checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
750
771
 
772
+ [[package]]
773
+ name = "opus"
774
+ version = "0.3.1"
775
+ source = "registry+https://github.com/rust-lang/crates.io-index"
776
+ checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c"
777
+ dependencies = [
778
+ "audiopus_sys",
779
+ ]
780
+
751
781
  [[package]]
752
782
  name = "password-hash"
753
783
  version = "0.5.0"
@@ -781,6 +811,12 @@ version = "0.2.17"
781
811
  source = "registry+https://github.com/rust-lang/crates.io-index"
782
812
  checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
783
813
 
814
+ [[package]]
815
+ name = "pkg-config"
816
+ version = "0.3.33"
817
+ source = "registry+https://github.com/rust-lang/crates.io-index"
818
+ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
819
+
784
820
  [[package]]
785
821
  name = "poly1305"
786
822
  version = "0.8.0"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "buzzkit"
3
- version = "0.1.1"
3
+ version = "0.1.3"
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)"
@@ -13,7 +13,9 @@ name = "_native"
13
13
  crate-type = ["cdylib"]
14
14
 
15
15
  [dependencies]
16
- pyo3 = { version = "0.24", features = ["extension-module", "abi3-py312"] }
16
+ # extension-module is enabled by maturin via pyproject.toml, not here, so
17
+ # plain `cargo build` / `cargo test` can link against libpython.
18
+ pyo3 = { version = "0.24", features = ["abi3-py312"] }
17
19
  # Must match the `nostr` version buzz-core/buzz-sdk resolve to so the
18
20
  # Event/Keys/EventBuilder types unify across crate boundaries.
19
21
  nostr = { version = "0.44", features = ["nip44", "nip98"] }
@@ -24,3 +26,4 @@ uuid = { version = "1", features = ["v4"] }
24
26
  sha2 = "0.10"
25
27
  hex = "0.4"
26
28
  base64 = "0.22"
29
+ opus = "0.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: buzzkit
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -86,6 +86,32 @@ async def main():
86
86
  asyncio.run(main())
87
87
  ```
88
88
 
89
+ ### Huddle audio (voice)
90
+
91
+ Buzz huddles are ephemeral voice channels; audio is Opus (48 kHz mono, 20 ms
92
+ frames) over a dedicated WebSocket. `HuddleClient` handles the handshake,
93
+ Opus encode/decode (in Rust), and real-time outbound pacing — you deal in raw
94
+ PCM (s16le mono 48 kHz):
95
+
96
+ ```python
97
+ from buzzkit import BuzzClient, HuddleAudio, HuddleClient
98
+
99
+ # Huddles announce themselves as kind 48100 on their parent channel:
100
+ async with BuzzClient(relay_url, nsec) as bz:
101
+ async for ev in bz.subscribe_channel(parent_id, kinds=[buzzkit.KIND_HUDDLE_STARTED]):
102
+ huddle_id = json.loads(ev["content"])["ephemeral_channel_id"]
103
+ break
104
+
105
+ async with HuddleClient(relay_url, nsec, huddle_id, parent_channel_id=parent_id) as h:
106
+ h.send_pcm(pcm_s16le_48k) # queued, paced at 50 frames/s
107
+ async for ev in h.events():
108
+ if isinstance(ev, HuddleAudio): # decoded remote audio
109
+ print(ev.pubkey, len(ev.pcm))
110
+ ```
111
+
112
+ Being a member of the parent channel is enough — the relay auto-adds you to
113
+ the ephemeral huddle when `parent_channel_id` is given.
114
+
89
115
  ## Joining a community (relay onboarding)
90
116
 
91
117
  Hosted Buzz communities are **closed relays**: an identity must be a relay member
@@ -114,6 +140,8 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
114
140
  | `verify_event(json)` | check id + Schnorr signature |
115
141
  | `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
116
142
  | `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
143
+ | `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
144
+ | `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |
117
145
 
118
146
  ## Build from source
119
147
 
@@ -53,6 +53,32 @@ async def main():
53
53
  asyncio.run(main())
54
54
  ```
55
55
 
56
+ ### Huddle audio (voice)
57
+
58
+ Buzz huddles are ephemeral voice channels; audio is Opus (48 kHz mono, 20 ms
59
+ frames) over a dedicated WebSocket. `HuddleClient` handles the handshake,
60
+ Opus encode/decode (in Rust), and real-time outbound pacing — you deal in raw
61
+ PCM (s16le mono 48 kHz):
62
+
63
+ ```python
64
+ from buzzkit import BuzzClient, HuddleAudio, HuddleClient
65
+
66
+ # Huddles announce themselves as kind 48100 on their parent channel:
67
+ async with BuzzClient(relay_url, nsec) as bz:
68
+ async for ev in bz.subscribe_channel(parent_id, kinds=[buzzkit.KIND_HUDDLE_STARTED]):
69
+ huddle_id = json.loads(ev["content"])["ephemeral_channel_id"]
70
+ break
71
+
72
+ async with HuddleClient(relay_url, nsec, huddle_id, parent_channel_id=parent_id) as h:
73
+ h.send_pcm(pcm_s16le_48k) # queued, paced at 50 frames/s
74
+ async for ev in h.events():
75
+ if isinstance(ev, HuddleAudio): # decoded remote audio
76
+ print(ev.pubkey, len(ev.pcm))
77
+ ```
78
+
79
+ Being a member of the parent channel is enough — the relay auto-adds you to
80
+ the ephemeral huddle when `parent_channel_id` is given.
81
+
56
82
  ## Joining a community (relay onboarding)
57
83
 
58
84
  Hosted Buzz communities are **closed relays**: an identity must be a relay member
@@ -81,6 +107,8 @@ claiming. After joining, `set_profile(...)` gives the agent a display name.
81
107
  | `verify_event(json)` | check id + Schnorr signature |
82
108
  | `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge |
83
109
  | `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket |
110
+ | `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) |
111
+ | `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM |
84
112
 
85
113
  ## Build from source
86
114
 
@@ -0,0 +1,11 @@
1
+ # Toolchain file for building the vendored libopus (audiopus_sys) via cmake-rs.
2
+ #
3
+ # audiopus_sys's build script adds only `<out>/lib` to the linker search path,
4
+ # but GNUInstallDirs installs to `lib64` on RPM-family hosts (manylinux,
5
+ # Fedora, RHEL) — the link then fails with `unable to find library -lopus`.
6
+ # Pinning the classic layout keeps the library where the build script looks.
7
+ #
8
+ # Applied by the release workflow ONLY on native RPM-family containers.
9
+ # Do not export CMAKE_TOOLCHAIN_FILE globally: cmake-rs skips its own
10
+ # cross-compilation config when a toolchain file is present.
11
+ set(CMAKE_INSTALL_LIBDIR "lib" CACHE PATH "installation libdir pinned for audiopus_sys" FORCE)
@@ -0,0 +1,58 @@
1
+ """Echo agent for Buzz huddles — repeats back everything it hears.
2
+
3
+ Joins a huddle and echoes remote audio with a short delay. Useful for
4
+ verifying the audio path end-to-end (desktop → relay → agent → relay →
5
+ desktop) without any AI provider.
6
+
7
+ BUZZ_RELAY_URL=wss://... python examples/huddle_echo.py <parent_channel_id>
8
+
9
+ Watches the parent channel for huddle announcements (kind 48100) and joins
10
+ each one; pass a second argument to join a known huddle directly:
11
+
12
+ python examples/huddle_echo.py <parent_channel_id> <huddle_channel_id>
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import sys
20
+ import time
21
+
22
+ from _shared import agent_secret, relay_url
23
+ from buzzkit import KIND_HUDDLE_STARTED, BuzzClient, HuddleAudio, HuddleClient
24
+
25
+
26
+ async def echo_in_huddle(relay: str, secret: str, huddle_id: str, parent_id: str) -> None:
27
+ async with HuddleClient(relay, secret, huddle_id, parent_channel_id=parent_id) as huddle:
28
+ print(f"joined huddle {huddle_id} — echoing (peers: {len(huddle.peers) - 1})")
29
+ async for ev in huddle.events():
30
+ if isinstance(ev, HuddleAudio):
31
+ huddle.send_pcm(ev.pcm)
32
+ else:
33
+ print(f" {type(ev).__name__}: {ev.pubkey[:12]}…")
34
+ print(f"huddle {huddle_id} ended")
35
+
36
+
37
+ async def main() -> None:
38
+ if len(sys.argv) < 2:
39
+ raise SystemExit("usage: huddle_echo.py <parent_channel_id> [huddle_channel_id]")
40
+ parent_id = sys.argv[1]
41
+ relay, secret = relay_url(), agent_secret()
42
+
43
+ if len(sys.argv) > 2:
44
+ await echo_in_huddle(relay, secret, sys.argv[2], parent_id)
45
+ return
46
+
47
+ print(f"watching {parent_id} for huddles…")
48
+ async with BuzzClient(relay, secret) as bz:
49
+ live = {"kinds": [KIND_HUDDLE_STARTED], "#h": [parent_id], "since": int(time.time())}
50
+ async for event in bz.subscribe([live]):
51
+ huddle_id = json.loads(event["content"]).get("ephemeral_channel_id")
52
+ if huddle_id:
53
+ await echo_in_huddle(relay, secret, huddle_id, parent_id)
54
+ print(f"watching {parent_id} for the next huddle…")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ asyncio.run(main())
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "buzzkit"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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,39 +10,81 @@ 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
+ HUDDLE_FRAME_SAMPLES,
14
+ HUDDLE_PROTOCOL_VERSION,
15
+ HUDDLE_SAMPLE_RATE,
13
16
  KIND_ADD_MEMBER,
14
17
  KIND_AUTH,
18
+ KIND_CREATE_CHANNEL,
15
19
  KIND_HTTP_AUTH,
20
+ KIND_HUDDLE_ENDED,
21
+ KIND_HUDDLE_PARTICIPANT_JOINED,
22
+ KIND_HUDDLE_PARTICIPANT_LEFT,
23
+ KIND_HUDDLE_STARTED,
16
24
  KIND_PRESENCE_UPDATE,
17
25
  KIND_REACTION,
18
26
  KIND_STREAM_MESSAGE,
19
27
  KIND_STREAM_MESSAGE_V2,
28
+ HuddleDecoder,
29
+ HuddleEncoder,
20
30
  build_auth_event,
31
+ build_create_channel_event,
32
+ build_huddle_started_event,
21
33
  build_join_channel_event,
22
34
  build_message_event,
35
+ build_presence_event,
23
36
  build_profile_event,
37
+ compute_auth_tag,
24
38
  generate_keypair,
25
39
  pubkey_from_secret,
26
40
  sign_nip98,
27
41
  verify_event,
28
42
  )
29
43
  from .client import BuzzClient
44
+ from .huddle import (
45
+ HuddleAudio,
46
+ HuddleClient,
47
+ HuddleError,
48
+ HuddleEvent,
49
+ HuddlePeerJoined,
50
+ HuddlePeerLeft,
51
+ )
30
52
 
31
- __version__ = "0.1.1"
53
+ __version__ = "0.1.3"
32
54
 
33
55
  __all__ = [
56
+ "HUDDLE_FRAME_SAMPLES",
57
+ "HUDDLE_PROTOCOL_VERSION",
58
+ "HUDDLE_SAMPLE_RATE",
34
59
  "KIND_ADD_MEMBER",
35
60
  "KIND_AUTH",
61
+ "KIND_CREATE_CHANNEL",
36
62
  "KIND_HTTP_AUTH",
63
+ "KIND_HUDDLE_ENDED",
64
+ "KIND_HUDDLE_PARTICIPANT_JOINED",
65
+ "KIND_HUDDLE_PARTICIPANT_LEFT",
66
+ "KIND_HUDDLE_STARTED",
37
67
  "KIND_PRESENCE_UPDATE",
38
68
  "KIND_REACTION",
39
69
  "KIND_STREAM_MESSAGE",
40
70
  "KIND_STREAM_MESSAGE_V2",
41
71
  "BuzzClient",
72
+ "HuddleAudio",
73
+ "HuddleClient",
74
+ "HuddleDecoder",
75
+ "HuddleEncoder",
76
+ "HuddleError",
77
+ "HuddleEvent",
78
+ "HuddlePeerJoined",
79
+ "HuddlePeerLeft",
42
80
  "build_auth_event",
81
+ "build_create_channel_event",
82
+ "build_huddle_started_event",
43
83
  "build_join_channel_event",
44
84
  "build_message_event",
85
+ "build_presence_event",
45
86
  "build_profile_event",
87
+ "compute_auth_tag",
46
88
  "generate_keypair",
47
89
  "pubkey_from_secret",
48
90
  "sign_nip98",
@@ -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
@@ -20,6 +20,7 @@ import asyncio
20
20
  import contextlib
21
21
  import json
22
22
  import logging
23
+ import uuid
23
24
  from collections.abc import AsyncIterator
24
25
  from typing import Any
25
26
 
@@ -61,7 +62,7 @@ class BuzzClient:
61
62
  self._http = _to_http(relay_url)
62
63
  self._ws_url = _to_ws(relay_url)
63
64
  self._secret = secret
64
- self._auth_tag = auth_tag # reserved for NIP-OA owner attestation
65
+ self._auth_tag = auth_tag # NIP-OA owner attestation (AUTH + profile)
65
66
  self.npub, self.pubkey_hex = _native.pubkey_from_secret(secret)
66
67
  self._ws: Any = None
67
68
  self._reader: asyncio.Task | None = None
@@ -96,15 +97,26 @@ class BuzzClient:
96
97
  return await self.post_event(ev)
97
98
 
98
99
  async def set_profile(
99
- self, display_name: str, *, about: str | None = None, picture: str | None = None
100
+ self,
101
+ display_name: str,
102
+ *,
103
+ about: str | None = None,
104
+ picture: str | None = None,
105
+ auth_tag: str | None = None,
100
106
  ) -> dict:
101
- """Publish this identity's profile (kind 0) so it shows a name in Buzz."""
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
+ """
102
113
  ev = _native.build_profile_event(
103
114
  self._secret,
104
115
  display_name=display_name,
105
116
  name=display_name,
106
117
  about=about,
107
118
  picture=picture,
119
+ auth_tag=auth_tag if auth_tag is not None else self._auth_tag,
108
120
  )
109
121
  return await self.post_event(ev)
110
122
 
@@ -119,6 +131,41 @@ class BuzzClient:
119
131
  ev = _native.build_join_channel_event(self._secret, channel_id)
120
132
  return await self.publish(ev)
121
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
+
122
169
  async def query(self, filters: list[dict]) -> list[dict]:
123
170
  """Run a NIP-01 REQ over the HTTP bridge; returns a list of events."""
124
171
  url = f"{self._http}/query"
@@ -225,7 +272,9 @@ class BuzzClient:
225
272
  q.put_nowait(("closed", "connection lost"))
226
273
 
227
274
  async def _on_auth_challenge(self, challenge: str) -> None:
228
- auth_ev = json.loads(_native.build_auth_event(self._secret, challenge, self._ws_url))
275
+ auth_ev = json.loads(
276
+ _native.build_auth_event(self._secret, challenge, self._ws_url, self._auth_tag)
277
+ )
229
278
  self._auth_event_id = auth_ev["id"]
230
279
  await self._ws.send(json.dumps(["AUTH", auth_ev]))
231
280