Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
32 changes: 29 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<identity-root-pubkey>")
graph.mutual # active friendships (both directions attested, BRC-3)
graph.pending_in # incoming requests
graph.pending_out # outgoing requests

notifs = overlay.get_notifications("<posting-address>", limit=50)
# -> [NotificationItem(type='friend_request'|'like'|'reply'|'follow'|'mention', actor=..., ...)]

overlay.get_follows("<address>") # follower/following counts + rows
overlay.get_blocks("<address>") # 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

Expand Down
8 changes: 7 additions & 1 deletion overlay_social/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@
)
from .models import (
FeedResponse,
FriendEntry,
FriendsGraph,
HandleResolution,
IdentityBundle,
IdentityProfile,
NotificationItem,
OverlayState,
PeckRow,
ProfileRow,
Expand All @@ -41,7 +44,7 @@
TopicState,
)

__version__ = "0.1.0"
__version__ = "0.2.0"

__all__ = [
"__version__",
Expand All @@ -53,6 +56,9 @@
"create_async_overlay_client",
"ResolvedIdentity",
"IdentityBundle",
"FriendEntry",
"FriendsGraph",
"NotificationItem",
"IdentityProfile",
"HandleResolution",
"ProfileRow",
Expand Down
141 changes: 141 additions & 0 deletions overlay_social/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@

from .models import (
FeedResponse,
FriendsGraph,
HandleResolution,
IdentityBundle,
NotificationItem,
OverlayState,
PeckRow,
ProfileRow,
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
68 changes: 68 additions & 0 deletions overlay_social/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading