diff --git a/Cargo.lock b/Cargo.lock index e181544..e751eb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,8 +146,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "buzz-core" version = "0.1.0" -source = "git+https://github.com/block/buzz?rev=acfbb1bb6af54cb29cb152496ff43b8285dcb8cf#acfbb1bb6af54cb29cb152496ff43b8285dcb8cf" +source = "git+https://github.com/block/buzz?rev=209536ade6c5ebf7fa82671d7ca0b74f599a40cc#209536ade6c5ebf7fa82671d7ca0b74f599a40cc" dependencies = [ + "base64", "chrono", "hex", "hmac 0.13.0", @@ -167,7 +168,7 @@ dependencies = [ [[package]] name = "buzz-sdk" version = "0.1.0" -source = "git+https://github.com/block/buzz?rev=acfbb1bb6af54cb29cb152496ff43b8285dcb8cf#acfbb1bb6af54cb29cb152496ff43b8285dcb8cf" +source = "git+https://github.com/block/buzz?rev=209536ade6c5ebf7fa82671d7ca0b74f599a40cc#209536ade6c5ebf7fa82671d7ca0b74f599a40cc" dependencies = [ "buzz-core", "nostr", @@ -179,7 +180,7 @@ dependencies = [ [[package]] name = "buzzkit" -version = "0.1.4" +version = "0.2.0" dependencies = [ "base64", "buzz-core", diff --git a/Cargo.toml b/Cargo.toml index d73302f..4d99fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buzzkit" -version = "0.1.4" +version = "0.2.0" edition = "2021" publish = false description = "PyO3 bindings over Block's Buzz zero-I/O crates (buzz-core / buzz-sdk)" @@ -19,8 +19,8 @@ pyo3 = { version = "0.24", features = ["abi3-py312"] } # Event/Keys/EventBuilder types unify across crate boundaries. nostr = { version = "0.44", features = ["nip44", "nip98"] } # Pinned to a specific Buzz commit; bump deliberately to track their kind churn. -buzz-core = { git = "https://github.com/block/buzz", rev = "acfbb1bb6af54cb29cb152496ff43b8285dcb8cf", package = "buzz-core" } -buzz-sdk = { git = "https://github.com/block/buzz", rev = "acfbb1bb6af54cb29cb152496ff43b8285dcb8cf", package = "buzz-sdk" } +buzz-core = { git = "https://github.com/block/buzz", rev = "209536ade6c5ebf7fa82671d7ca0b74f599a40cc", package = "buzz-core" } +buzz-sdk = { git = "https://github.com/block/buzz", rev = "209536ade6c5ebf7fa82671d7ca0b74f599a40cc", package = "buzz-sdk" } uuid = { version = "1", features = ["v4"] } sha2 = "0.10" hex = "0.4" diff --git a/README.md b/README.md index f4937e8..963e383 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,20 @@ claiming. After joining, `set_profile(...)` gives the agent a display name. |---|---| | `generate_keypair()` → `(nsec, npub, hex)` | new identity | | `pubkey_from_secret(secret)` | derive `(npub, hex)` | -| `build_message_event` / `build_profile_event` / `build_auth_event` | build + sign events | +| `build_*_event` (message/reply, reaction, edit, delete, profile, user status, channel, presence…) | build + sign events | +| `compute_auth_tag` / `verify_auth_tag` / `verify_agent_profile` | NIP-OA owner attestation | | `sign_nip98(secret, method, url, body)` | HTTP bridge auth header | | `verify_event(json)` | check id + Schnorr signature | -| `BuzzClient.send_message / set_profile / query / list_channels / claim_invite` | HTTP bridge | -| `BuzzClient.connect / subscribe / subscribe_channel / publish / close` | WebSocket | +| `BuzzClient.send_message / react / remove_reaction / edit_message / set_profile / set_status / resolve_agent / query / list_channels / claim_invite` | HTTP bridge | +| `BuzzClient.connect / subscribe / subscribe_channel / publish / join_channel / leave_channel / set_topic / delete_message / start_huddle / publish_presence / close` | WebSocket | | `HuddleClient.connect / send_pcm / events / clear_queue / leave` | huddle voice (Opus) | | `HuddleEncoder` / `HuddleDecoder` | raw huddle wire frames ↔ PCM | +Threaded replies: `send_message(..., reply_to=)` (add +`reply_root=` for nested replies). Reconnect note: the relay closes with +code **1012** on graceful restart — check `BuzzClient.close_code` in your +reconnect loop and dedupe replayed events by id. + ## Build from source Requires a Rust toolchain and [maturin](https://www.maturin.rs). diff --git a/pyproject.toml b/pyproject.toml index 8a7093c..cef6298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "buzzkit" -version = "0.1.4" +version = "0.2.0" description = "Python bindings + async client for Block's Buzz (Nostr) protocol, backed by Rust buzz-core/buzz-sdk" readme = "README.md" requires-python = ">=3.12" diff --git a/python/buzzkit/__init__.py b/python/buzzkit/__init__.py index 4ce54b8..36b7f35 100644 --- a/python/buzzkit/__init__.py +++ b/python/buzzkit/__init__.py @@ -9,6 +9,8 @@ from __future__ import annotations +from importlib.metadata import version as _version + from ._native import ( HUDDLE_FRAME_SAMPLES, HUDDLE_PROTOCOL_VERSION, @@ -16,30 +18,46 @@ KIND_ADD_MEMBER, KIND_AUTH, KIND_CREATE_CHANNEL, + KIND_DELETE_MESSAGE, + KIND_DELETION, + KIND_EDIT_METADATA, KIND_HTTP_AUTH, KIND_HUDDLE_ENDED, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_LEAVE_CHANNEL, + KIND_MANAGED_AGENT, + KIND_MESSAGE_EDIT, KIND_PRESENCE_UPDATE, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, + KIND_USER_STATUS, HuddleDecoder, HuddleEncoder, build_auth_event, build_create_channel_event, + build_delete_message_event, + build_edit_event, build_huddle_started_event, build_join_channel_event, + build_leave_event, build_message_event, build_presence_event, build_profile_event, + build_reaction_event, + build_remove_reaction_event, + build_set_topic_event, + build_user_status_event, compute_auth_tag, generate_keypair, pubkey_from_secret, sign_nip98, + verify_auth_tag, verify_event, ) +from .agents import verify_agent_profile from .client import BuzzClient from .huddle import ( HuddleAudio, @@ -50,7 +68,7 @@ HuddlePeerLeft, ) -__version__ = "0.1.3" +__version__ = _version("buzzkit") __all__ = [ "HUDDLE_FRAME_SAMPLES", @@ -59,15 +77,22 @@ "KIND_ADD_MEMBER", "KIND_AUTH", "KIND_CREATE_CHANNEL", + "KIND_DELETE_MESSAGE", + "KIND_DELETION", + "KIND_EDIT_METADATA", "KIND_HTTP_AUTH", "KIND_HUDDLE_ENDED", "KIND_HUDDLE_PARTICIPANT_JOINED", "KIND_HUDDLE_PARTICIPANT_LEFT", "KIND_HUDDLE_STARTED", + "KIND_LEAVE_CHANNEL", + "KIND_MANAGED_AGENT", + "KIND_MESSAGE_EDIT", "KIND_PRESENCE_UPDATE", "KIND_REACTION", "KIND_STREAM_MESSAGE", "KIND_STREAM_MESSAGE_V2", + "KIND_USER_STATUS", "BuzzClient", "HuddleAudio", "HuddleClient", @@ -79,14 +104,23 @@ "HuddlePeerLeft", "build_auth_event", "build_create_channel_event", + "build_delete_message_event", + "build_edit_event", "build_huddle_started_event", "build_join_channel_event", + "build_leave_event", "build_message_event", "build_presence_event", "build_profile_event", + "build_reaction_event", + "build_remove_reaction_event", + "build_set_topic_event", + "build_user_status_event", "compute_auth_tag", "generate_keypair", "pubkey_from_secret", "sign_nip98", + "verify_agent_profile", + "verify_auth_tag", "verify_event", ] diff --git a/python/buzzkit/_native.pyi b/python/buzzkit/_native.pyi index 8a8cb13..0e308ea 100644 --- a/python/buzzkit/_native.pyi +++ b/python/buzzkit/_native.pyi @@ -7,9 +7,37 @@ def pubkey_from_secret(secret: str) -> tuple[str, str]: """Return ``(npub, pubkey_hex)`` for a secret (hex or ``nsec…``).""" def build_message_event( - secret: str, channel_id: str, content: str, mentions: list[str] | None = ... + secret: str, + channel_id: str, + content: str, + mentions: list[str] | None = ..., + reply_to: str | None = ..., + reply_root: str | None = ..., ) -> str: - """Build + sign a channel message (kind 9); returns NIP-01 event JSON.""" + """Build + sign a channel message (kind 9); returns NIP-01 event JSON. + + ``reply_to`` threads the message; ``reply_root`` marks a nested reply. + """ + +def build_reaction_event(secret: str, target_event_id: str, emoji: str) -> str: + """Build + sign a reaction (kind 7) to an event.""" + +def build_remove_reaction_event(secret: str, reaction_event_id: str) -> str: + """Build + sign a deletion (kind 5) of one of our own reactions.""" + +def build_edit_event(secret: str, channel_id: str, target_event_id: str, new_content: str) -> str: + """Build + sign a message edit (kind 40003).""" + +def build_delete_message_event( + secret: str, channel_id: str, target_event_id: str, reason: str | None = ... +) -> str: + """Build + sign a message delete tombstone (kind 9005).""" + +def build_set_topic_event(secret: str, channel_id: str, topic: str) -> str: + """Build + sign a channel topic change (kind 9002).""" + +def build_leave_event(secret: str, channel_id: str) -> str: + """Build + sign a channel leave request (kind 9022).""" def build_profile_event( secret: str, @@ -48,6 +76,9 @@ def build_huddle_started_event( def build_presence_event(secret: str, status: str = ...) -> str: """Build + sign a presence event (kind 20001); status online/away/offline.""" +def build_user_status_event(secret: str, text: str, emoji: str | None = ...) -> str: + """Build + sign a NIP-38 user status event (kind 30315, ``d:general``).""" + def build_auth_event( secret: str, challenge: str, relay_url: str, auth_tag: str | None = ... ) -> str: @@ -56,6 +87,9 @@ def build_auth_event( def compute_auth_tag(owner_secret: str, agent_pubkey_hex: str, conditions: str = ...) -> str: """Compute a NIP-OA owner-attestation tag JSON attesting an agent pubkey.""" +def verify_auth_tag(auth_tag_json: str, agent_pubkey_hex: str) -> str: + """Verify a NIP-OA auth tag against an agent pubkey; returns the owner hex.""" + def sign_nip98(secret: str, method: str, url: str, body: bytes | None = ...) -> str: """Return an ``Authorization: Nostr `` header value (NIP-98).""" @@ -85,14 +119,21 @@ class HuddleDecoder: def remove_peer(self, peer_index: int) -> None: """Forget a peer's decoder state (indexes are recycled by the relay).""" +KIND_DELETION: int KIND_REACTION: int KIND_STREAM_MESSAGE: int +KIND_EDIT_METADATA: int +KIND_DELETE_MESSAGE: int +KIND_LEAVE_CHANNEL: int +KIND_MESSAGE_EDIT: int KIND_PRESENCE_UPDATE: int KIND_AUTH: int KIND_HTTP_AUTH: int KIND_STREAM_MESSAGE_V2: int KIND_ADD_MEMBER: int KIND_CREATE_CHANNEL: int +KIND_MANAGED_AGENT: int +KIND_USER_STATUS: int KIND_HUDDLE_STARTED: int KIND_HUDDLE_PARTICIPANT_JOINED: int KIND_HUDDLE_PARTICIPANT_LEFT: int diff --git a/python/buzzkit/agents.py b/python/buzzkit/agents.py new file mode 100644 index 0000000..a5336e3 --- /dev/null +++ b/python/buzzkit/agents.py @@ -0,0 +1,91 @@ +"""NIP-OA agent ownership verification. + +Mirrors the reference algorithm from upstream buzz-cli (#3178): an agent's +kind-0 profile asserts ownership only when it carries *exactly one* `auth` +tag whose Schnorr signature verifies against the claimed owner AND whose +conditions (`kind=…`, `created_at<…`, `created_at>…`) apply to the profile +event itself. + +Pure functions — no I/O. :meth:`BuzzClient.resolve_agent` does the relay side. +""" + +from __future__ import annotations + +import json +import string +from typing import Any + +from . import _native + +_HEX_LOWER = set(string.digits + "abcdef") + +#: Possible return values of :func:`verify_agent_profile`, mirroring upstream. +VERIFICATIONS = ( + "verified", + "missing_auth", + "multiple_auth_tags", + "invalid_auth", + "owner_mismatch", + "condition_mismatch", + "invalid_agent_pubkey", +) + + +def _auth_tags(event: dict[str, Any]) -> list[list]: + tags = event.get("tags") + if not isinstance(tags, list): + return [] + return [t for t in tags if isinstance(t, list) and t and t[0] == "auth"] + + +def _conditions_apply(conditions: str, event: dict[str, Any]) -> bool: + """Do the auth tag's conditions hold for this event? Empty clauses hold.""" + kind = event.get("kind") + created_at = event.get("created_at") + if not isinstance(kind, int) or not isinstance(created_at, int): + return False + for clause in conditions.split("&"): + if clause.startswith("kind="): + ok = clause[len("kind=") :] == str(kind) + elif clause.startswith("created_at<"): + bound = clause[len("created_at<") :] + ok = bound.isdigit() and created_at < int(bound) + elif clause.startswith("created_at>"): + bound = clause[len("created_at>") :] + ok = bound.isdigit() and created_at > int(bound) + else: + ok = clause == "" + if not ok: + return False + return True + + +def verify_agent_profile(profile_event: dict[str, Any], owner_pubkey_hex: str) -> str: + """Verify that an agent's kind-0 profile is attested by ``owner_pubkey_hex``. + + Returns one of :data:`VERIFICATIONS`; only ``"verified"`` asserts + ownership. ``profile_event`` is the parsed NIP-01 event dict. + """ + agent_pubkey = profile_event.get("pubkey") + if ( + not isinstance(agent_pubkey, str) + or len(agent_pubkey) != 64 + or not set(agent_pubkey) <= _HEX_LOWER + ): + return "invalid_agent_pubkey" + tags = _auth_tags(profile_event) + if not tags: + return "missing_auth" + if len(tags) > 1: + return "multiple_auth_tags" + tag = tags[0] + try: + owner = _native.verify_auth_tag(json.dumps(tag), agent_pubkey) + except ValueError as e: + # from_hex accepted 64 lowercase hex that is not a curve point + return "invalid_agent_pubkey" if "invalid agent pubkey" in str(e) else "invalid_auth" + if owner != owner_pubkey_hex: + return "owner_mismatch" + if not _conditions_apply(tag[2], profile_event): + return "condition_mismatch" + return "verified" diff --git a/python/buzzkit/client.py b/python/buzzkit/client.py index f43fda5..5f16963 100644 --- a/python/buzzkit/client.py +++ b/python/buzzkit/client.py @@ -28,6 +28,7 @@ import websockets from . import _native +from .agents import verify_agent_profile logger = logging.getLogger("buzzkit.client") @@ -64,6 +65,11 @@ def __init__(self, relay_url: str, secret: str, *, auth_tag: str | None = None) self._secret = secret self._auth_tag = auth_tag # NIP-OA owner attestation (AUTH + profile) self.npub, self.pubkey_hex = _native.pubkey_from_secret(secret) + #: WebSocket close code from the last disconnect (``None`` while + #: connected or never connected). 1012 means the relay is restarting + #: (graceful drain): reconnect with backoff and dedupe replayed + #: events by id after resubscribing. + self.close_code: int | None = None self._ws: Any = None self._reader: asyncio.Task | None = None self._authed = asyncio.Event() @@ -90,12 +96,57 @@ async def post_event(self, event_json: str) -> dict: return r.json() async def send_message( - self, channel_id: str, content: str, mentions: list[str] | None = None + self, + channel_id: str, + content: str, + mentions: list[str] | None = None, + *, + reply_to: str | None = None, + reply_root: str | None = None, ) -> dict: - """Build + post a channel chat message (kind 9).""" - ev = _native.build_message_event(self._secret, channel_id, content, mentions) + """Build + post a channel chat message (kind 9). + + ``reply_to`` threads the message under that event id; for a nested + reply also pass ``reply_root`` (the thread's root event id). + """ + ev = _native.build_message_event( + self._secret, channel_id, content, mentions, reply_to, reply_root + ) + return await self.post_event(ev) + + async def react(self, target_event_id: str, emoji: str = "👍") -> dict: + """React to an event (kind 7).""" + ev = _native.build_reaction_event(self._secret, target_event_id, emoji) + return await self.post_event(ev) + + async def remove_reaction(self, reaction_event_id: str) -> dict: + """Delete one of our own reactions (kind 5) by its event id.""" + ev = _native.build_remove_reaction_event(self._secret, reaction_event_id) return await self.post_event(ev) + async def edit_message(self, channel_id: str, target_event_id: str, content: str) -> dict: + """Replace one of our own messages (kind 40003).""" + ev = _native.build_edit_event(self._secret, channel_id, target_event_id, content) + return await self.post_event(ev) + + async def delete_message( + self, channel_id: str, target_event_id: str, *, reason: str | None = None + ) -> dict: + """Delete a message (kind 9005 tombstone). Management kind — needs + :meth:`connect` first; ``reason`` is shown room-facing.""" + ev = _native.build_delete_message_event(self._secret, channel_id, target_event_id, reason) + return await self.publish(ev) + + async def set_topic(self, channel_id: str, topic: str) -> dict: + """Set a channel's topic (kind 9002). Needs :meth:`connect` first.""" + ev = _native.build_set_topic_event(self._secret, channel_id, topic) + return await self.publish(ev) + + async def leave_channel(self, channel_id: str) -> dict: + """Leave a channel (kind 9022). Needs :meth:`connect` first.""" + ev = _native.build_leave_event(self._secret, channel_id) + return await self.publish(ev) + async def set_profile( self, display_name: str, @@ -160,10 +211,20 @@ async def start_huddle( return huddle_id async def publish_presence(self, status: str = "online") -> dict: - """Announce presence (kind 20001, ephemeral) over the WebSocket.""" + """Announce presence (kind 20001, ephemeral) over the WebSocket. + + Cadence is the caller's job. Relays at buzz >= v0.5.x hold presence + for a 180 s TTL and expect a heartbeat every 60 s; older relays used + 90 s / 30 s. When the relay version is unknown, 30 s is safe on both. + """ ev = _native.build_presence_event(self._secret, status) return await self.publish(ev) + async def set_status(self, text: str, *, emoji: str | None = None) -> dict: + """Publish a NIP-38 user status (kind 30315). Empty text clears it.""" + ev = _native.build_user_status_event(self._secret, text, emoji) + return await self.post_event(ev) + async def query(self, filters: list[dict]) -> list[dict]: """Run a NIP-01 REQ over the HTTP bridge; returns a list of events.""" url = f"{self._http}/query" @@ -186,6 +247,64 @@ async def list_channels(self) -> list[dict]: out.append({"channel_id": tags.get("d"), "name": tags.get("name"), "event": ev}) return out + async def resolve_agent(self, name: str, owner_pubkey: str | None = None) -> list[dict]: + """Resolve agents named ``name`` from an owner's managed-agent records. + + Mirrors upstream ``buzz users get --name X --owner …``: only kind-30177 + records authored by the owner are consulted (exact name match, + case-insensitive), and each candidate's kind-0 profile must carry + exactly one valid NIP-OA auth tag from that owner — cryptographically + verified — to count as owned. + + ``owner_pubkey`` defaults to this client's own owner: the attesting + pubkey of its NIP-OA auth tag, or its own pubkey without one. Returns + one dict per candidate: ``{pubkey, verification, profile}`` plus + ``owner_pubkey`` when ``verification == "verified"``. + """ + owner = owner_pubkey or self.owner_pubkey_hex + records = await self.query( + [{"kinds": [_native.KIND_MANAGED_AGENT], "authors": [owner], "limit": 1000}] + ) + pubkeys: set[str] = set() + for ev in records: + try: + record_name = json.loads(ev.get("content", ""))["name"] + except (json.JSONDecodeError, TypeError, KeyError): + continue + if not isinstance(record_name, str) or record_name.lower() != name.lower(): + continue + d_tag = next((t[1] for t in ev.get("tags", []) if len(t) >= 2 and t[0] == "d"), "") + if d_tag: + pubkeys.add(d_tag) + if not pubkeys: + return [] + profiles = await self.query([{"kinds": [0], "authors": sorted(pubkeys), "limit": 1000}]) + by_author = {ev["pubkey"]: ev for ev in profiles if "pubkey" in ev} + out = [] + for pk in sorted(pubkeys): + profile = by_author.get(pk) + if profile is None: + verification = "missing_profile" + content: dict = {} + else: + verification = verify_agent_profile(profile, owner) + try: + content = json.loads(profile.get("content", "") or "{}") + except json.JSONDecodeError: + content = {} + entry = {"pubkey": pk, "verification": verification, "profile": content} + if verification == "verified": + entry["owner_pubkey"] = owner + out.append(entry) + return out + + @property + def owner_pubkey_hex(self) -> str: + """This identity's effective owner: its auth-tag attester, else itself.""" + if self._auth_tag is not None: + return json.loads(self._auth_tag)[1] + return self.pubkey_hex + async def claim_invite(self, code_or_url: str) -> dict: """Redeem a relay invite: accept the join-policy (if any), then claim. @@ -231,6 +350,7 @@ async def __aexit__(self, *exc: object) -> None: async def connect(self) -> None: """Open the WebSocket and complete the NIP-42 auth handshake.""" self._authed.clear() + self.close_code = None self._ws = await websockets.connect(self._ws_url, max_size=_MAX_FRAME) self._reader = asyncio.create_task(self._read_loop()) await asyncio.wait_for(self._authed.wait(), timeout=_AUTH_TIMEOUT) @@ -263,8 +383,10 @@ async def _read_loop(self) -> None: await q.put(("closed", msg[2] if len(msg) > 2 else "")) elif typ == "NOTICE": logger.warning("relay NOTICE: %s", msg[1] if len(msg) > 1 else "") - except websockets.ConnectionClosed: - logger.info("buzz websocket closed") + except websockets.ConnectionClosed as e: + close = e.rcvd or e.sent + self.close_code = close.code if close else None + logger.info("buzz websocket closed (code %s)", self.close_code) finally: for q in self._subs.values(): q.put_nowait(("closed", "connection lost")) diff --git a/src/lib.rs b/src/lib.rs index 5908738..6a6def1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ mod huddle; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine as _; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, Tag, ToBech32}; +use nostr::{EventBuilder, EventId, JsonUtil, Keys, Kind, PublicKey, RelayUrl, Tag, ToBech32}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use sha2::{Digest, Sha256}; @@ -19,6 +19,23 @@ fn keys_from_secret(secret: &str) -> PyResult { Keys::parse(secret).map_err(|e| PyValueError::new_err(format!("invalid secret key: {e}"))) } +fn event_id(hex: &str, label: &str) -> PyResult { + EventId::from_hex(hex) + .map_err(|e| PyValueError::new_err(format!("{label} must be an event id hex: {e}"))) +} + +fn channel_uuid(channel_id: &str) -> PyResult { + Uuid::parse_str(channel_id) + .map_err(|e| PyValueError::new_err(format!("channel_id must be a UUID: {e}"))) +} + +fn sign(builder: EventBuilder, keys: &Keys) -> PyResult { + let event = builder + .sign_with_keys(keys) + .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?; + Ok(event.as_json()) +} + fn bech32(v: &T) -> PyResult where ::Err: std::fmt::Display, @@ -48,25 +65,119 @@ fn pubkey_from_secret(secret: &str) -> PyResult<(String, String)> { /// Build and sign a channel chat message (kind 9). Returns the NIP-01 event JSON. /// /// `channel_id` must be a UUID; `mentions` are pubkey hex strings (optional). +/// `reply_to` makes the message a threaded reply to that event id; for a +/// nested reply also pass `reply_root` (the thread's root event id). #[pyfunction] -#[pyo3(signature = (secret, channel_id, content, mentions=None))] +#[pyo3(signature = (secret, channel_id, content, mentions=None, reply_to=None, reply_root=None))] fn build_message_event( secret: &str, channel_id: &str, content: &str, mentions: Option>, + reply_to: Option<&str>, + reply_root: Option<&str>, ) -> PyResult { let keys = keys_from_secret(secret)?; - let cid = Uuid::parse_str(channel_id) - .map_err(|e| PyValueError::new_err(format!("channel_id must be a UUID: {e}")))?; + let cid = channel_uuid(channel_id)?; let mentions = mentions.unwrap_or_default(); let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect(); - let builder = buzz_sdk::build_message(cid, content, None, &mention_refs, false, &[]) - .map_err(|e| PyValueError::new_err(format!("build_message: {e}")))?; - let event = builder - .sign_with_keys(&keys) - .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?; - Ok(event.as_json()) + let thread_ref = match (reply_to, reply_root) { + (None, None) => None, + (None, Some(_)) => { + return Err(PyValueError::new_err("reply_root requires reply_to")); + } + (Some(parent), root) => Some(buzz_sdk::ThreadRef { + root_event_id: event_id(root.unwrap_or(parent), "reply_root")?, + parent_event_id: event_id(parent, "reply_to")?, + }), + }; + let builder = + buzz_sdk::build_message(cid, content, thread_ref.as_ref(), &mention_refs, false, &[]) + .map_err(|e| PyValueError::new_err(format!("build_message: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign a reaction (kind 7) to an event. `emoji` is the reaction +/// content (e.g. "👍"). Returns the NIP-01 event JSON. +#[pyfunction] +fn build_reaction_event(secret: &str, target_event_id: &str, emoji: &str) -> PyResult { + let keys = keys_from_secret(secret)?; + let target = event_id(target_event_id, "target_event_id")?; + let builder = buzz_sdk::build_reaction(target, emoji) + .map_err(|e| PyValueError::new_err(format!("build_reaction: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign a deletion (kind 5) of one of our own reaction events. +#[pyfunction] +fn build_remove_reaction_event(secret: &str, reaction_event_id: &str) -> PyResult { + let keys = keys_from_secret(secret)?; + let target = event_id(reaction_event_id, "reaction_event_id")?; + let builder = buzz_sdk::build_remove_reaction(target) + .map_err(|e| PyValueError::new_err(format!("build_remove_reaction: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign an edit (kind 40003) replacing one of our own channel +/// messages. The relay applies it to `target_event_id` within `channel_id`. +#[pyfunction] +fn build_edit_event( + secret: &str, + channel_id: &str, + target_event_id: &str, + new_content: &str, +) -> PyResult { + let keys = keys_from_secret(secret)?; + let cid = channel_uuid(channel_id)?; + let target = event_id(target_event_id, "target_event_id")?; + let builder = buzz_sdk::build_edit(cid, target, new_content) + .map_err(|e| PyValueError::new_err(format!("build_edit: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign a message delete tombstone (kind 9005). `reason`, when +/// given, becomes the room-facing `public_reason` tag. Management kind — +/// publish over the WebSocket. +#[pyfunction] +#[pyo3(signature = (secret, channel_id, target_event_id, reason=None))] +fn build_delete_message_event( + secret: &str, + channel_id: &str, + target_event_id: &str, + reason: Option<&str>, +) -> PyResult { + let keys = keys_from_secret(secret)?; + let cid = channel_uuid(channel_id)?; + let target = event_id(target_event_id, "target_event_id")?; + let options = buzz_sdk::DeleteMessageOptions { + public_reason: reason, + ..Default::default() + }; + let builder = buzz_sdk::build_delete_message_with_options(cid, target, options) + .map_err(|e| PyValueError::new_err(format!("build_delete_message: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign a channel topic change (kind 9002). Management kind — +/// publish over the WebSocket. +#[pyfunction] +fn build_set_topic_event(secret: &str, channel_id: &str, topic: &str) -> PyResult { + let keys = keys_from_secret(secret)?; + let cid = channel_uuid(channel_id)?; + let builder = buzz_sdk::build_set_topic(cid, topic) + .map_err(|e| PyValueError::new_err(format!("build_set_topic: {e}")))?; + sign(builder, &keys) +} + +/// Build and sign a channel leave request (kind 9022). Management kind — +/// publish over the WebSocket. +#[pyfunction] +fn build_leave_event(secret: &str, channel_id: &str) -> PyResult { + let keys = keys_from_secret(secret)?; + let cid = channel_uuid(channel_id)?; + let builder = buzz_sdk::build_leave(cid) + .map_err(|e| PyValueError::new_err(format!("build_leave: {e}")))?; + sign(builder, &keys) } /// Build and sign a NIP-42 AUTH event (kind 22242) for a relay challenge. @@ -115,6 +226,19 @@ fn compute_auth_tag( .map_err(|e| PyValueError::new_err(format!("compute_auth_tag: {e}"))) } +/// Verify a NIP-OA `auth` tag JSON against an agent pubkey. Returns the +/// attesting OWNER's pubkey hex. Raises `ValueError` on malformed tags, bad +/// signatures, or self-attestation. Signature validity only — whether the +/// tag's conditions apply to a given event is the caller's check. +#[pyfunction] +fn verify_auth_tag(auth_tag_json: &str, agent_pubkey_hex: &str) -> PyResult { + let agent_pubkey = PublicKey::from_hex(agent_pubkey_hex) + .map_err(|e| PyValueError::new_err(format!("invalid agent pubkey: {e}")))?; + let owner = buzz_sdk::nip_oa::verify_auth_tag(auth_tag_json, &agent_pubkey) + .map_err(|e| PyValueError::new_err(format!("verify_auth_tag: {e}")))?; + Ok(owner.to_hex()) +} + /// Sign a NIP-98 HTTP-auth event (kind 27235). Returns the /// `Authorization: Nostr ` header value for the Buzz HTTP bridge. #[pyfunction] @@ -211,6 +335,9 @@ fn build_join_channel_event(secret: &str, channel_id: &str) -> PyResult /// `channel_type` is "stream", "forum", "dm", or "workflow". A `ttl` in /// seconds makes the channel ephemeral (huddles use private/stream/3600). /// Management kinds go over the WebSocket, not the HTTP bridge. +/// +/// `name` is canonicalized: leading `#` and whitespace are stripped, and a +/// name that canonicalizes to empty raises `ValueError`. #[pyfunction] #[pyo3(signature = (secret, channel_id, name, visibility=None, channel_type=None, about=None, ttl=None))] fn build_create_channel_event( @@ -292,13 +419,35 @@ fn build_presence_event(secret: &str, status: &str) -> PyResult { Ok(event.as_json()) } +/// Build and sign a NIP-38 user status event (kind 30315, `d:general`). +/// `text` is the status; `emoji`, when given, becomes an `["emoji", …]` tag. +/// Blank text with no emoji clears the status. Non-ephemeral — post over the +/// HTTP bridge. +#[pyfunction] +#[pyo3(signature = (secret, text, emoji=None))] +fn build_user_status_event(secret: &str, text: &str, emoji: Option<&str>) -> PyResult { + let keys = keys_from_secret(secret)?; + let event = buzz_sdk::build_user_status(text, emoji) + .map_err(|e| PyValueError::new_err(format!("build_user_status: {e}")))? + .sign_with_keys(&keys) + .map_err(|e| PyValueError::new_err(format!("sign: {e}")))?; + Ok(event.as_json()) +} + #[pymodule] fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(generate_keypair, m)?)?; m.add_function(wrap_pyfunction!(pubkey_from_secret, m)?)?; m.add_function(wrap_pyfunction!(build_message_event, m)?)?; + m.add_function(wrap_pyfunction!(build_reaction_event, m)?)?; + m.add_function(wrap_pyfunction!(build_remove_reaction_event, m)?)?; + m.add_function(wrap_pyfunction!(build_edit_event, m)?)?; + m.add_function(wrap_pyfunction!(build_delete_message_event, m)?)?; + m.add_function(wrap_pyfunction!(build_set_topic_event, m)?)?; + m.add_function(wrap_pyfunction!(build_leave_event, m)?)?; m.add_function(wrap_pyfunction!(build_auth_event, m)?)?; m.add_function(wrap_pyfunction!(compute_auth_tag, m)?)?; + m.add_function(wrap_pyfunction!(verify_auth_tag, m)?)?; m.add_function(wrap_pyfunction!(sign_nip98, m)?)?; m.add_function(wrap_pyfunction!(verify_event, m)?)?; m.add_function(wrap_pyfunction!(build_profile_event, m)?)?; @@ -306,16 +455,24 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(build_create_channel_event, m)?)?; m.add_function(wrap_pyfunction!(build_huddle_started_event, m)?)?; m.add_function(wrap_pyfunction!(build_presence_event, m)?)?; + m.add_function(wrap_pyfunction!(build_user_status_event, m)?)?; // Buzz event kinds (subset — mirrors buzz-core/src/kind.rs). + m.add("KIND_DELETION", 5u16)?; m.add("KIND_REACTION", 7u16)?; m.add("KIND_STREAM_MESSAGE", 9u16)?; + m.add("KIND_EDIT_METADATA", 9002u16)?; + m.add("KIND_DELETE_MESSAGE", 9005u16)?; + m.add("KIND_LEAVE_CHANNEL", 9022u16)?; + m.add("KIND_MESSAGE_EDIT", 40003u16)?; m.add("KIND_PRESENCE_UPDATE", 20001u16)?; m.add("KIND_AUTH", 22242u16)?; m.add("KIND_HTTP_AUTH", 27235u16)?; m.add("KIND_STREAM_MESSAGE_V2", 40002u16)?; m.add("KIND_ADD_MEMBER", 9000u16)?; m.add("KIND_CREATE_CHANNEL", 9007u16)?; + m.add("KIND_MANAGED_AGENT", 30177u16)?; + m.add("KIND_USER_STATUS", 30315u16)?; m.add("KIND_HUDDLE_STARTED", 48100u16)?; m.add("KIND_HUDDLE_PARTICIPANT_JOINED", 48101u16)?; m.add("KIND_HUDDLE_PARTICIPANT_LEFT", 48102u16)?; diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..f8c2879 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,98 @@ +"""Offline tests for NIP-OA agent ownership verification and resolution.""" + +from __future__ import annotations + +import json + +import buzzkit +from buzzkit.client import BuzzClient + + +def _profile_event(agent_pk: str, auth_tags: list[list], created_at: int = 100) -> dict: + return { + "pubkey": agent_pk, + "kind": 0, + "created_at": created_at, + "content": '{"display_name":"Honey"}', + "tags": auth_tags, + } + + +def _keys() -> tuple[str, str]: + nsec, _, pk = buzzkit.generate_keypair() + return nsec, pk + + +def test_verified_requires_one_valid_auth_tag_from_owner(): + owner_nsec, owner_pk = _keys() + foreign_nsec, _ = _keys() + _, agent_pk = _keys() + valid = json.loads(buzzkit.compute_auth_tag(owner_nsec, agent_pk, "kind=0")) + foreign = json.loads(buzzkit.compute_auth_tag(foreign_nsec, agent_pk, "kind=0")) + + verify = buzzkit.verify_agent_profile + assert verify(_profile_event(agent_pk, [valid]), owner_pk) == "verified" + assert verify(_profile_event(agent_pk, [foreign]), owner_pk) == "owner_mismatch" + assert verify(_profile_event(agent_pk, []), owner_pk) == "missing_auth" + assert verify(_profile_event(agent_pk, [valid, valid]), owner_pk) == "multiple_auth_tags" + forged = ["auth", owner_pk, "kind=0", "0" * 128] + assert verify(_profile_event(agent_pk, [forged]), owner_pk) == "invalid_auth" + assert verify(_profile_event("not-a-pubkey", [valid]), owner_pk) == "invalid_agent_pubkey" + + +def test_conditions_must_apply_to_the_profile_event(): + owner_nsec, owner_pk = _keys() + _, agent_pk = _keys() + + def status(conditions: str) -> str: + tag = json.loads(buzzkit.compute_auth_tag(owner_nsec, agent_pk, conditions)) + return buzzkit.verify_agent_profile(_profile_event(agent_pk, [tag]), owner_pk) + + assert status("") == "verified" + assert status("kind=0&created_at>99&created_at<101") == "verified" + assert status("kind=9") == "condition_mismatch" + assert status("created_at<100") == "condition_mismatch" + assert status("created_at>100") == "condition_mismatch" + + +def test_owner_pubkey_hex_prefers_auth_tag_owner(): + owner_nsec, owner_pk = _keys() + agent_nsec, agent_pk = _keys() + tag = buzzkit.compute_auth_tag(owner_nsec, agent_pk) + assert BuzzClient("wss://x", agent_nsec, auth_tag=tag).owner_pubkey_hex == owner_pk + assert BuzzClient("wss://x", agent_nsec).owner_pubkey_hex == agent_pk + + +async def test_resolve_agent_scopes_to_owner_records(monkeypatch): + owner_nsec, owner_pk = _keys() + agent_nsec, agent_pk = _keys() + _, missing_pk = _keys() + auth = json.loads(buzzkit.compute_auth_tag(owner_nsec, agent_pk, "kind=0")) + + records = [ + {"content": '{"name":"honey"}', "tags": [["d", agent_pk]]}, + {"content": '{"name":"Honey"}', "tags": [["d", missing_pk]]}, + {"content": '{"name":"Honeybee"}', "tags": [["d", "ignored"]]}, + {"content": "not json", "tags": [["d", "ignored"]]}, + ] + profiles = [_profile_event(agent_pk, [auth])] + + async def fake_query(filters): + kinds = filters[0]["kinds"] + if kinds == [buzzkit.KIND_MANAGED_AGENT]: + assert filters[0]["authors"] == [owner_pk] + return records + assert kinds == [0] + return profiles + + client = BuzzClient("wss://x", agent_nsec) + monkeypatch.setattr(client, "query", fake_query) + result = await client.resolve_agent("Honey", owner_pk) + + by_pk = {r["pubkey"]: r for r in result} + assert set(by_pk) == {agent_pk, missing_pk} + assert by_pk[agent_pk]["verification"] == "verified" + assert by_pk[agent_pk]["owner_pubkey"] == owner_pk + assert by_pk[agent_pk]["profile"]["display_name"] == "Honey" + assert by_pk[missing_pk]["verification"] == "missing_profile" + assert "owner_pubkey" not in by_pk[missing_pk] diff --git a/tests/test_signing.py b/tests/test_signing.py index 457bbe9..9d16d0a 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -36,6 +36,76 @@ def test_tamper_is_detected(): assert buzzkit.verify_event(json.dumps(event)) is False +def test_message_reply_threading(): + nsec, _, _ = buzzkit.generate_keypair() + channel_id = str(uuid.uuid4()) + parent = "a" * 64 + root = "b" * 64 + + direct = json.loads(buzzkit.build_message_event(nsec, channel_id, "re", reply_to=parent)) + assert ["e", parent, "", "reply"] in direct["tags"] + assert not any(t[-1] == "root" for t in direct["tags"]) + + nested = json.loads( + buzzkit.build_message_event(nsec, channel_id, "re", reply_to=parent, reply_root=root) + ) + assert ["e", root, "", "root"] in nested["tags"] + assert ["e", parent, "", "reply"] in nested["tags"] + + with pytest.raises(ValueError): + buzzkit.build_message_event(nsec, channel_id, "re", reply_root=root) + with pytest.raises(ValueError): + buzzkit.build_message_event(nsec, channel_id, "re", reply_to="not-an-event-id") + + +def test_reaction_events(): + nsec, _, _ = buzzkit.generate_keypair() + target = "c" * 64 + reaction = json.loads(buzzkit.build_reaction_event(nsec, target, "👍")) + assert reaction["kind"] == buzzkit.KIND_REACTION == 7 + assert reaction["content"] == "👍" + assert ["e", target] in reaction["tags"] + + removal = json.loads(buzzkit.build_remove_reaction_event(nsec, reaction["id"])) + assert removal["kind"] == buzzkit.KIND_DELETION == 5 + assert ["e", reaction["id"]] in removal["tags"] + + +def test_edit_and_delete_message_events(): + nsec, _, _ = buzzkit.generate_keypair() + channel_id = str(uuid.uuid4()) + target = "d" * 64 + + edit = json.loads(buzzkit.build_edit_event(nsec, channel_id, target, "fixed")) + assert edit["kind"] == buzzkit.KIND_MESSAGE_EDIT == 40003 + assert edit["content"] == "fixed" + assert ["h", channel_id] in edit["tags"] + assert ["e", target] in edit["tags"] + + delete = json.loads(buzzkit.build_delete_message_event(nsec, channel_id, target)) + assert delete["kind"] == buzzkit.KIND_DELETE_MESSAGE == 9005 + assert ["e", target] in delete["tags"] + assert not any(t[0] == "public_reason" for t in delete["tags"]) + + moderated = json.loads( + buzzkit.build_delete_message_event(nsec, channel_id, target, reason="spam") + ) + assert ["public_reason", "spam"] in moderated["tags"] + + +def test_topic_and_leave_events(): + nsec, _, _ = buzzkit.generate_keypair() + channel_id = str(uuid.uuid4()) + + topic = json.loads(buzzkit.build_set_topic_event(nsec, channel_id, "daily standup")) + assert topic["kind"] == buzzkit.KIND_EDIT_METADATA == 9002 + assert ["topic", "daily standup"] in topic["tags"] + + leave = json.loads(buzzkit.build_leave_event(nsec, channel_id)) + assert leave["kind"] == buzzkit.KIND_LEAVE_CHANNEL == 9022 + assert ["h", channel_id] in leave["tags"] + + def test_invalid_channel_id_raises(): nsec, _, _ = buzzkit.generate_keypair() with pytest.raises(ValueError): @@ -116,6 +186,42 @@ def test_create_channel_event(): buzzkit.build_create_channel_event(nsec, channel_id, "x", visibility="bogus") +def test_create_channel_name_is_canonicalized(): + # Since buzz v0.5.x, build_create_channel strips leading '#' (and + # whitespace) and rejects names that canonicalize to empty. + nsec, _, _ = buzzkit.generate_keypair() + event = json.loads(buzzkit.build_create_channel_event(nsec, str(uuid.uuid4()), "###dev")) + tags = {t[0]: t[1] for t in event["tags"]} + assert tags["name"] == "dev" + with pytest.raises(ValueError): + buzzkit.build_create_channel_event(nsec, str(uuid.uuid4()), " ### ") + + +def test_user_status_event(): + nsec, _, _ = buzzkit.generate_keypair() + event = json.loads(buzzkit.build_user_status_event(nsec, "reviewing PRs", emoji="🤖")) + assert event["kind"] == buzzkit.KIND_USER_STATUS == 30315 + assert event["content"] == "reviewing PRs" + assert ["d", "general"] in event["tags"] + assert ["emoji", "🤖"] in event["tags"] + cleared = json.loads(buzzkit.build_user_status_event(nsec, "")) + assert cleared["content"] == "" + assert not any(t[0] == "emoji" for t in cleared["tags"]) + + +def test_verify_auth_tag_roundtrip(): + owner_nsec, _, owner_pk = buzzkit.generate_keypair() + _, _, agent_pk = buzzkit.generate_keypair() + tag = buzzkit.compute_auth_tag(owner_nsec, agent_pk, "kind=0") + assert buzzkit.verify_auth_tag(tag, agent_pk) == owner_pk + # The signature binds the agent pubkey: any other agent must fail. + _, _, other_pk = buzzkit.generate_keypair() + with pytest.raises(ValueError): + buzzkit.verify_auth_tag(tag, other_pk) + with pytest.raises(ValueError): + buzzkit.verify_auth_tag("not json", agent_pk) + + def test_huddle_started_event(): nsec, _, _ = buzzkit.generate_keypair() parent, ephemeral = str(uuid.uuid4()), str(uuid.uuid4())