diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ce9c4d5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to `overlay-social` (Python). Mirrors +`@overlay-social/sdk` (TypeScript) one-to-one. + +## 0.2.0 — 2026-06-12 + +### Added +- `get_friends(subject)` (sync + async) — mutual-consent friendship graph + (`GET /v1/friends/:subject`): `FriendsGraph` with `mutual` / `pending_in` / + `pending_out` (two one-way BRC-3 attestations = an active pair) plus + legacy BAP-era rows (display-only). Safe-empty on error. +- `get_notifications(address, limit=, offset=, mentions=)` (sync + async) — + likes, replies, follows, mentions and friend requests targeting a posting + address. `[]` on error. +- `get_follows(address)` / `get_blocks(address, kind=)` (sync + async). + Blocks are outgoing-only by design (the overlay does not expose + who-blocked-me). +- Geo feed queries: `get_feed(near={"lat":..,"lng":..}, radius_km=..)` and + `get_feed(bbox=(w, s, e, n))`. +- Models: `FriendEntry`, `FriendsGraph`, `NotificationItem`. + +### Changed +- `get_topic_root(topic)` uses the real per-topic route + (`GET /v1/topic/:topic/root`) with a `/state` fallback. The per-topic route + does not carry the anchor — use `get_anchor()`/`verify_root()`. +- Identity docs: the live overlay collapses BOUND posting keys/addresses + (key-binding) into their identity root and prefers light self-attested + profiles/handles over legacy ProfileTokens. Same response shapes. + +## 0.1.0 — 2026-06-03 + +- Initial release: sync + async clients — `resolve_identities`, + `list_identities`, `get_identity`, `resolve_handle`, `get_profile`, + `get_feed`, `get_post`, `get_thread`, `get_state`, `get_topic_root`, + `get_anchor`, `verify_root`. diff --git a/README.md b/README.md index 166ebab..80df88c 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,37 @@ Mirrors the overlay's load-bearing role for other apps: network failure. - Every request has a hard timeout (default 8s). +## Social graph & notifications (0.2.0) + +```python +graph = overlay.get_friends("") +graph.mutual # active friendships (both directions attested, BRC-3) +graph.pending_in # incoming requests +graph.pending_out # outgoing requests + +notifs = overlay.get_notifications("", limit=50) +# -> [NotificationItem(type='friend_request'|'like'|'reply'|'follow'|'mention', actor=..., ...)] + +overlay.get_follows("
") # follower/following counts + rows +overlay.get_blocks("
") # OUTGOING block/mute list only +overlay.get_feed(near={"lat": 59.94, "lng": 10.76}, radius_km=2) # geo +overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box +overlay.verify_root("tm_social-content") # on-chain anchor vs live state-root +``` + +All graph/notification reads are best-effort: safe empties on error, so +social UI never bricks on enrichment. Friendship is **mutual consent** — +two one-way BRC-3 attestations form an active pair; legacy BAP-era friend +rows are exposed display-only. + ## Endpoints used -`GET /state`, `POST /v1/identities/resolve`, `GET /identity/:pubkey`, -`GET /resolve/:handle`, `GET /v1/bio/profile`, `GET /v1/feed`, -`GET /v1/post/:txid`, `GET /v1/thread/:txid`. WhatsOnChain is **never** called. +`GET /state`, `GET /v1/topic/:topic/root`, `POST /v1/identities/resolve`, +`GET /v1/identities`, `GET /identity/:pubkey`, `GET /resolve/:handle`, +`GET /v1/bio/profile`, `GET /v1/feed` (incl. `near`/`bbox`), +`GET /v1/post/:txid`, `GET /v1/thread/:txid`, `GET /v1/friends/:subject`, +`GET /v1/notifications/:address`, `GET /v1/follows/:address`, +`GET /v1/blocks/:address`. WhatsOnChain is **never** called. ## License diff --git a/overlay_social/__init__.py b/overlay_social/__init__.py index 03dfedf..74b016e 100644 --- a/overlay_social/__init__.py +++ b/overlay_social/__init__.py @@ -28,9 +28,12 @@ ) from .models import ( FeedResponse, + FriendEntry, + FriendsGraph, HandleResolution, IdentityBundle, IdentityProfile, + NotificationItem, OverlayState, PeckRow, ProfileRow, @@ -41,7 +44,7 @@ TopicState, ) -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = [ "__version__", @@ -53,6 +56,9 @@ "create_async_overlay_client", "ResolvedIdentity", "IdentityBundle", + "FriendEntry", + "FriendsGraph", + "NotificationItem", "IdentityProfile", "HandleResolution", "ProfileRow", diff --git a/overlay_social/client.py b/overlay_social/client.py index c397e2f..456e74a 100644 --- a/overlay_social/client.py +++ b/overlay_social/client.py @@ -22,8 +22,10 @@ from .models import ( FeedResponse, + FriendsGraph, HandleResolution, IdentityBundle, + NotificationItem, OverlayState, PeckRow, ProfileRow, @@ -64,6 +66,15 @@ def put(k: str, v: Any) -> None: put("author", params.get("author")) put("order", params.get("order", "desc")) put("before", params.get("before")) + # Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine; bbox=(w,s,e,n). + near = params.get("near") + if near: + lat = near.get("lat") if isinstance(near, dict) else near[0] + lng = near.get("lng") if isinstance(near, dict) else near[1] + put("near", f"{lat},{lng}") + put("radius_km", params.get("radius_km")) + elif params.get("bbox"): + put("bbox", ",".join(str(x) for x in params["bbox"])) return out @@ -245,6 +256,64 @@ def get_profile( raise OverlayError(f"overlay {resp.status_code} on /v1/bio/profile") return _interp_profile(resp.json()) + # social graph + def get_friends(self, subject: str) -> FriendsGraph: + """GET /v1/friends/:subject — mutual-consent friendship graph for an + identity ROOT pubkey. Returns an EMPTY graph on any error.""" + if not subject: + return FriendsGraph() + try: + data = self._get_json_or_null(f"/v1/friends/{subject}") + return FriendsGraph.from_dict(data) if isinstance(data, dict) else FriendsGraph() + except Exception: + return FriendsGraph() + + def get_notifications( + self, address: str, *, limit: int = 50, offset: int = 0, mentions: bool = True + ) -> list[NotificationItem]: + """GET /v1/notifications/:address — like/reply/follow/mention/ + friend_request targeting a POSTING address, newest first. [] on error.""" + if not address: + return [] + try: + params: dict[str, str] = {"limit": str(limit), "offset": str(offset)} + if not mentions: + params["mentions"] = "0" + r = self._client.get(f"{self.base_url}/v1/notifications/{address}", params=params) + if r.status_code // 100 != 2: + return [] + data = r.json() if r.content else {} + return [NotificationItem.from_dict(x) for x in (data.get("data") or []) if isinstance(x, dict)] + except Exception: + return [] + + def get_follows(self, address: str) -> dict[str, Any]: + """GET /v1/follows/:address — follower/following counts + rows. + Safe-empty dict on error.""" + empty: dict[str, Any] = {"followers": 0, "following": 0, "data": {"followers": [], "following": []}} + if not address: + return empty + try: + data = self._get_json_or_null(f"/v1/follows/{address}") + return data if isinstance(data, dict) else empty + except Exception: + return empty + + def get_blocks(self, address: str, *, kind: str | None = None) -> list[dict[str, Any]]: + """GET /v1/blocks/:address — OUTGOING block/mute list (the overlay + deliberately does not expose who-blocked-me). [] on error.""" + if not address: + return [] + try: + params = {"kind": kind} if kind else None + r = self._client.get(f"{self.base_url}/v1/blocks/{address}", params=params) + if r.status_code // 100 != 2: + return [] + data = r.json() if r.content else {} + return list(data.get("data") or []) + except Exception: + return [] + # feed / posts def get_feed(self, **params: Any) -> FeedResponse: resp = self._client.get(f"{self.base_url}/v1/feed", params=_feed_query(params)) @@ -267,8 +336,17 @@ def get_state(self) -> OverlayState: return OverlayState.from_dict(self._get_json("/state")) def get_topic_root(self, topic: str) -> TopicState | None: + """GET /v1/topic/:topic/root (cheap, 30s server cache; no anchor — + use get_anchor/verify_root for on-chain status). Falls back to a + find over /state for older overlays.""" if not topic: return None + try: + data = self._get_json_or_null(f"/v1/topic/{topic}/root") + if isinstance(data, dict) and data.get("stateRoot"): + return TopicState.from_dict(data) + except Exception: + pass try: for t in self.get_state().topics: if t.topic == topic: @@ -395,6 +473,61 @@ async def get_profile( raise OverlayError(f"overlay {resp.status_code} on /v1/bio/profile") return _interp_profile(resp.json()) + async def get_friends(self, subject: str) -> FriendsGraph: + """GET /v1/friends/:subject — mutual-consent friendship graph for an + identity ROOT pubkey. Returns an EMPTY graph on any error.""" + if not subject: + return FriendsGraph() + try: + data = await self._get_json_or_null(f"/v1/friends/{subject}") + return FriendsGraph.from_dict(data) if isinstance(data, dict) else FriendsGraph() + except Exception: + return FriendsGraph() + + async def get_notifications( + self, address: str, *, limit: int = 50, offset: int = 0, mentions: bool = True + ) -> list[NotificationItem]: + """GET /v1/notifications/:address — like/reply/follow/mention/ + friend_request targeting a POSTING address, newest first. [] on error.""" + if not address: + return [] + try: + params: dict[str, str] = {"limit": str(limit), "offset": str(offset)} + if not mentions: + params["mentions"] = "0" + r = await self._client.get(f"{self.base_url}/v1/notifications/{address}", params=params) + if r.status_code // 100 != 2: + return [] + data = r.json() if r.content else {} + return [NotificationItem.from_dict(x) for x in (data.get("data") or []) if isinstance(x, dict)] + except Exception: + return [] + + async def get_follows(self, address: str) -> dict[str, Any]: + """GET /v1/follows/:address — follower/following counts + rows.""" + empty: dict[str, Any] = {"followers": 0, "following": 0, "data": {"followers": [], "following": []}} + if not address: + return empty + try: + data = await self._get_json_or_null(f"/v1/follows/{address}") + return data if isinstance(data, dict) else empty + except Exception: + return empty + + async def get_blocks(self, address: str, *, kind: str | None = None) -> list[dict[str, Any]]: + """GET /v1/blocks/:address — OUTGOING block/mute list. [] on error.""" + if not address: + return [] + try: + params = {"kind": kind} if kind else None + r = await self._client.get(f"{self.base_url}/v1/blocks/{address}", params=params) + if r.status_code // 100 != 2: + return [] + data = r.json() if r.content else {} + return list(data.get("data") or []) + except Exception: + return [] + async def get_feed(self, **params: Any) -> FeedResponse: resp = await self._client.get(f"{self.base_url}/v1/feed", params=_feed_query(params)) if resp.status_code // 100 != 2: @@ -415,8 +548,16 @@ async def get_state(self) -> OverlayState: return OverlayState.from_dict(await self._get_json("/state")) async def get_topic_root(self, topic: str) -> TopicState | None: + """GET /v1/topic/:topic/root (cheap; no anchor — use get_anchor/ + verify_root). Falls back to a find over /state for older overlays.""" if not topic: return None + try: + data = await self._get_json_or_null(f"/v1/topic/{topic}/root") + if isinstance(data, dict) and data.get("stateRoot"): + return TopicState.from_dict(data) + except Exception: + pass try: state = await self.get_state() except OverlayError: diff --git a/overlay_social/models.py b/overlay_social/models.py index 4bf98df..aff920b 100644 --- a/overlay_social/models.py +++ b/overlay_social/models.py @@ -257,3 +257,71 @@ def from_dict(cls, d: dict[str, Any]) -> "FeedResponse": class ThreadResponse: post: PeckRow | None = None replies: list[PeckRow] = field(default_factory=list) + + +@dataclass(frozen=True) +class FriendEntry: + """One side of the friend graph, hydrated with the peer's canonical handle.""" + + peer: str # peer identity ROOT pubkey (66-hex compressed) + handle: str | None = None + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "FriendEntry": + return cls(peer=d.get("peer", ""), handle=d.get("handle")) + + +@dataclass(frozen=True) +class FriendsGraph: + """GET /v1/friends/:subject — mutual-consent friendship layer. + + ``mutual`` = both directions attested + unrevoked (two one-way BRC-3 + records form a pair). ``pending_in``/``pending_out`` are one-way. + ``legacy_outgoing``/``legacy_incoming`` mirror the BAP-era Bitcoin Schema + rows (display-only; never count toward mutual). + """ + + mutual: list[FriendEntry] = field(default_factory=list) + pending_in: list[FriendEntry] = field(default_factory=list) + pending_out: list[FriendEntry] = field(default_factory=list) + legacy_outgoing: list[dict[str, Any]] = field(default_factory=list) + legacy_incoming: list[dict[str, Any]] = field(default_factory=list) + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "FriendsGraph": + light = d.get("light") or {} + data = d.get("data") or {} + return cls( + mutual=[FriendEntry.from_dict(x) for x in (light.get("mutual") or [])], + pending_in=[FriendEntry.from_dict(x) for x in (light.get("pending_in") or [])], + pending_out=[FriendEntry.from_dict(x) for x in (light.get("pending_out") or [])], + legacy_outgoing=list(data.get("outgoing") or []), + legacy_incoming=list(data.get("incoming") or []), + ) + + +@dataclass(frozen=True) +class NotificationItem: + """One row from GET /v1/notifications/:address. + + ``actor`` is an address/username for like/reply/follow/mention and an + identity ROOT pubkey for friend_request — resolve via resolve_identities + (bound posting keys collapse to their identity). + """ + + type: str + actor: str + target_txid: str | None = None + subject_txid: str | None = None + timestamp: str | None = None + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> "NotificationItem": + ts = d.get("timestamp") + return cls( + type=d.get("type", ""), + actor=d.get("actor", ""), + target_txid=d.get("target_txid"), + subject_txid=d.get("subject_txid"), + timestamp=str(ts) if ts is not None else None, + ) diff --git a/pyproject.toml b/pyproject.toml index faf2cd9..dd5e23e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "overlay-social" -version = "0.1.0" +version = "0.2.0" description = "Read-only Python client for overlay.peck.to — identity resolution, profiles, feed and overlay state over the canonical BSV/BRC-100 social overlay." readme = "README.md" requires-python = ">=3.10"