Skip to content

Commit 2c67abd

Browse files
authored
Merge pull request #3 from overlay-social/v0.2.0
0.2.0: social graph, notifications, blocks, geo
2 parents 55d30b8 + 6c071f1 commit 2c67abd

6 files changed

Lines changed: 282 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Changelog
2+
3+
All notable changes to `overlay-social` (Python). Mirrors
4+
`@overlay-social/sdk` (TypeScript) one-to-one.
5+
6+
## 0.2.0 — 2026-06-12
7+
8+
### Added
9+
- `get_friends(subject)` (sync + async) — mutual-consent friendship graph
10+
(`GET /v1/friends/:subject`): `FriendsGraph` with `mutual` / `pending_in` /
11+
`pending_out` (two one-way BRC-3 attestations = an active pair) plus
12+
legacy BAP-era rows (display-only). Safe-empty on error.
13+
- `get_notifications(address, limit=, offset=, mentions=)` (sync + async) —
14+
likes, replies, follows, mentions and friend requests targeting a posting
15+
address. `[]` on error.
16+
- `get_follows(address)` / `get_blocks(address, kind=)` (sync + async).
17+
Blocks are outgoing-only by design (the overlay does not expose
18+
who-blocked-me).
19+
- Geo feed queries: `get_feed(near={"lat":..,"lng":..}, radius_km=..)` and
20+
`get_feed(bbox=(w, s, e, n))`.
21+
- Models: `FriendEntry`, `FriendsGraph`, `NotificationItem`.
22+
23+
### Changed
24+
- `get_topic_root(topic)` uses the real per-topic route
25+
(`GET /v1/topic/:topic/root`) with a `/state` fallback. The per-topic route
26+
does not carry the anchor — use `get_anchor()`/`verify_root()`.
27+
- Identity docs: the live overlay collapses BOUND posting keys/addresses
28+
(key-binding) into their identity root and prefers light self-attested
29+
profiles/handles over legacy ProfileTokens. Same response shapes.
30+
31+
## 0.1.0 — 2026-06-03
32+
33+
- Initial release: sync + async clients — `resolve_identities`,
34+
`list_identities`, `get_identity`, `resolve_handle`, `get_profile`,
35+
`get_feed`, `get_post`, `get_thread`, `get_state`, `get_topic_root`,
36+
`get_anchor`, `verify_root`.

README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,37 @@ Mirrors the overlay's load-bearing role for other apps:
6969
network failure.
7070
- Every request has a hard timeout (default 8s).
7171

72+
## Social graph & notifications (0.2.0)
73+
74+
```python
75+
graph = overlay.get_friends("<identity-root-pubkey>")
76+
graph.mutual # active friendships (both directions attested, BRC-3)
77+
graph.pending_in # incoming requests
78+
graph.pending_out # outgoing requests
79+
80+
notifs = overlay.get_notifications("<posting-address>", limit=50)
81+
# -> [NotificationItem(type='friend_request'|'like'|'reply'|'follow'|'mention', actor=..., ...)]
82+
83+
overlay.get_follows("<address>") # follower/following counts + rows
84+
overlay.get_blocks("<address>") # OUTGOING block/mute list only
85+
overlay.get_feed(near={"lat": 59.94, "lng": 10.76}, radius_km=2) # geo
86+
overlay.get_feed(bbox=(10.5, 59.8, 11.0, 60.1)) # bounding box
87+
overlay.verify_root("tm_social-content") # on-chain anchor vs live state-root
88+
```
89+
90+
All graph/notification reads are best-effort: safe empties on error, so
91+
social UI never bricks on enrichment. Friendship is **mutual consent**
92+
two one-way BRC-3 attestations form an active pair; legacy BAP-era friend
93+
rows are exposed display-only.
94+
7295
## Endpoints used
7396

74-
`GET /state`, `POST /v1/identities/resolve`, `GET /identity/:pubkey`,
75-
`GET /resolve/:handle`, `GET /v1/bio/profile`, `GET /v1/feed`,
76-
`GET /v1/post/:txid`, `GET /v1/thread/:txid`. WhatsOnChain is **never** called.
97+
`GET /state`, `GET /v1/topic/:topic/root`, `POST /v1/identities/resolve`,
98+
`GET /v1/identities`, `GET /identity/:pubkey`, `GET /resolve/:handle`,
99+
`GET /v1/bio/profile`, `GET /v1/feed` (incl. `near`/`bbox`),
100+
`GET /v1/post/:txid`, `GET /v1/thread/:txid`, `GET /v1/friends/:subject`,
101+
`GET /v1/notifications/:address`, `GET /v1/follows/:address`,
102+
`GET /v1/blocks/:address`. WhatsOnChain is **never** called.
77103

78104
## License
79105

overlay_social/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@
2828
)
2929
from .models import (
3030
FeedResponse,
31+
FriendEntry,
32+
FriendsGraph,
3133
HandleResolution,
3234
IdentityBundle,
3335
IdentityProfile,
36+
NotificationItem,
3437
OverlayState,
3538
PeckRow,
3639
ProfileRow,
@@ -41,7 +44,7 @@
4144
TopicState,
4245
)
4346

44-
__version__ = "0.1.0"
47+
__version__ = "0.2.0"
4548

4649
__all__ = [
4750
"__version__",
@@ -53,6 +56,9 @@
5356
"create_async_overlay_client",
5457
"ResolvedIdentity",
5558
"IdentityBundle",
59+
"FriendEntry",
60+
"FriendsGraph",
61+
"NotificationItem",
5662
"IdentityProfile",
5763
"HandleResolution",
5864
"ProfileRow",

overlay_social/client.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222

2323
from .models import (
2424
FeedResponse,
25+
FriendsGraph,
2526
HandleResolution,
2627
IdentityBundle,
28+
NotificationItem,
2729
OverlayState,
2830
PeckRow,
2931
ProfileRow,
@@ -64,6 +66,15 @@ def put(k: str, v: Any) -> None:
6466
put("author", params.get("author"))
6567
put("order", params.get("order", "desc"))
6668
put("before", params.get("before"))
69+
# Geo: near={"lat":..,"lng":..} (+ radius_km) → haversine; bbox=(w,s,e,n).
70+
near = params.get("near")
71+
if near:
72+
lat = near.get("lat") if isinstance(near, dict) else near[0]
73+
lng = near.get("lng") if isinstance(near, dict) else near[1]
74+
put("near", f"{lat},{lng}")
75+
put("radius_km", params.get("radius_km"))
76+
elif params.get("bbox"):
77+
put("bbox", ",".join(str(x) for x in params["bbox"]))
6778
return out
6879

6980

@@ -245,6 +256,64 @@ def get_profile(
245256
raise OverlayError(f"overlay {resp.status_code} on /v1/bio/profile")
246257
return _interp_profile(resp.json())
247258

259+
# social graph
260+
def get_friends(self, subject: str) -> FriendsGraph:
261+
"""GET /v1/friends/:subject — mutual-consent friendship graph for an
262+
identity ROOT pubkey. Returns an EMPTY graph on any error."""
263+
if not subject:
264+
return FriendsGraph()
265+
try:
266+
data = self._get_json_or_null(f"/v1/friends/{subject}")
267+
return FriendsGraph.from_dict(data) if isinstance(data, dict) else FriendsGraph()
268+
except Exception:
269+
return FriendsGraph()
270+
271+
def get_notifications(
272+
self, address: str, *, limit: int = 50, offset: int = 0, mentions: bool = True
273+
) -> list[NotificationItem]:
274+
"""GET /v1/notifications/:address — like/reply/follow/mention/
275+
friend_request targeting a POSTING address, newest first. [] on error."""
276+
if not address:
277+
return []
278+
try:
279+
params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
280+
if not mentions:
281+
params["mentions"] = "0"
282+
r = self._client.get(f"{self.base_url}/v1/notifications/{address}", params=params)
283+
if r.status_code // 100 != 2:
284+
return []
285+
data = r.json() if r.content else {}
286+
return [NotificationItem.from_dict(x) for x in (data.get("data") or []) if isinstance(x, dict)]
287+
except Exception:
288+
return []
289+
290+
def get_follows(self, address: str) -> dict[str, Any]:
291+
"""GET /v1/follows/:address — follower/following counts + rows.
292+
Safe-empty dict on error."""
293+
empty: dict[str, Any] = {"followers": 0, "following": 0, "data": {"followers": [], "following": []}}
294+
if not address:
295+
return empty
296+
try:
297+
data = self._get_json_or_null(f"/v1/follows/{address}")
298+
return data if isinstance(data, dict) else empty
299+
except Exception:
300+
return empty
301+
302+
def get_blocks(self, address: str, *, kind: str | None = None) -> list[dict[str, Any]]:
303+
"""GET /v1/blocks/:address — OUTGOING block/mute list (the overlay
304+
deliberately does not expose who-blocked-me). [] on error."""
305+
if not address:
306+
return []
307+
try:
308+
params = {"kind": kind} if kind else None
309+
r = self._client.get(f"{self.base_url}/v1/blocks/{address}", params=params)
310+
if r.status_code // 100 != 2:
311+
return []
312+
data = r.json() if r.content else {}
313+
return list(data.get("data") or [])
314+
except Exception:
315+
return []
316+
248317
# feed / posts
249318
def get_feed(self, **params: Any) -> FeedResponse:
250319
resp = self._client.get(f"{self.base_url}/v1/feed", params=_feed_query(params))
@@ -267,8 +336,17 @@ def get_state(self) -> OverlayState:
267336
return OverlayState.from_dict(self._get_json("/state"))
268337

269338
def get_topic_root(self, topic: str) -> TopicState | None:
339+
"""GET /v1/topic/:topic/root (cheap, 30s server cache; no anchor —
340+
use get_anchor/verify_root for on-chain status). Falls back to a
341+
find over /state for older overlays."""
270342
if not topic:
271343
return None
344+
try:
345+
data = self._get_json_or_null(f"/v1/topic/{topic}/root")
346+
if isinstance(data, dict) and data.get("stateRoot"):
347+
return TopicState.from_dict(data)
348+
except Exception:
349+
pass
272350
try:
273351
for t in self.get_state().topics:
274352
if t.topic == topic:
@@ -395,6 +473,61 @@ async def get_profile(
395473
raise OverlayError(f"overlay {resp.status_code} on /v1/bio/profile")
396474
return _interp_profile(resp.json())
397475

476+
async def get_friends(self, subject: str) -> FriendsGraph:
477+
"""GET /v1/friends/:subject — mutual-consent friendship graph for an
478+
identity ROOT pubkey. Returns an EMPTY graph on any error."""
479+
if not subject:
480+
return FriendsGraph()
481+
try:
482+
data = await self._get_json_or_null(f"/v1/friends/{subject}")
483+
return FriendsGraph.from_dict(data) if isinstance(data, dict) else FriendsGraph()
484+
except Exception:
485+
return FriendsGraph()
486+
487+
async def get_notifications(
488+
self, address: str, *, limit: int = 50, offset: int = 0, mentions: bool = True
489+
) -> list[NotificationItem]:
490+
"""GET /v1/notifications/:address — like/reply/follow/mention/
491+
friend_request targeting a POSTING address, newest first. [] on error."""
492+
if not address:
493+
return []
494+
try:
495+
params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
496+
if not mentions:
497+
params["mentions"] = "0"
498+
r = await self._client.get(f"{self.base_url}/v1/notifications/{address}", params=params)
499+
if r.status_code // 100 != 2:
500+
return []
501+
data = r.json() if r.content else {}
502+
return [NotificationItem.from_dict(x) for x in (data.get("data") or []) if isinstance(x, dict)]
503+
except Exception:
504+
return []
505+
506+
async def get_follows(self, address: str) -> dict[str, Any]:
507+
"""GET /v1/follows/:address — follower/following counts + rows."""
508+
empty: dict[str, Any] = {"followers": 0, "following": 0, "data": {"followers": [], "following": []}}
509+
if not address:
510+
return empty
511+
try:
512+
data = await self._get_json_or_null(f"/v1/follows/{address}")
513+
return data if isinstance(data, dict) else empty
514+
except Exception:
515+
return empty
516+
517+
async def get_blocks(self, address: str, *, kind: str | None = None) -> list[dict[str, Any]]:
518+
"""GET /v1/blocks/:address — OUTGOING block/mute list. [] on error."""
519+
if not address:
520+
return []
521+
try:
522+
params = {"kind": kind} if kind else None
523+
r = await self._client.get(f"{self.base_url}/v1/blocks/{address}", params=params)
524+
if r.status_code // 100 != 2:
525+
return []
526+
data = r.json() if r.content else {}
527+
return list(data.get("data") or [])
528+
except Exception:
529+
return []
530+
398531
async def get_feed(self, **params: Any) -> FeedResponse:
399532
resp = await self._client.get(f"{self.base_url}/v1/feed", params=_feed_query(params))
400533
if resp.status_code // 100 != 2:
@@ -415,8 +548,16 @@ async def get_state(self) -> OverlayState:
415548
return OverlayState.from_dict(await self._get_json("/state"))
416549

417550
async def get_topic_root(self, topic: str) -> TopicState | None:
551+
"""GET /v1/topic/:topic/root (cheap; no anchor — use get_anchor/
552+
verify_root). Falls back to a find over /state for older overlays."""
418553
if not topic:
419554
return None
555+
try:
556+
data = await self._get_json_or_null(f"/v1/topic/{topic}/root")
557+
if isinstance(data, dict) and data.get("stateRoot"):
558+
return TopicState.from_dict(data)
559+
except Exception:
560+
pass
420561
try:
421562
state = await self.get_state()
422563
except OverlayError:

overlay_social/models.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,71 @@ def from_dict(cls, d: dict[str, Any]) -> "FeedResponse":
257257
class ThreadResponse:
258258
post: PeckRow | None = None
259259
replies: list[PeckRow] = field(default_factory=list)
260+
261+
262+
@dataclass(frozen=True)
263+
class FriendEntry:
264+
"""One side of the friend graph, hydrated with the peer's canonical handle."""
265+
266+
peer: str # peer identity ROOT pubkey (66-hex compressed)
267+
handle: str | None = None
268+
269+
@classmethod
270+
def from_dict(cls, d: dict[str, Any]) -> "FriendEntry":
271+
return cls(peer=d.get("peer", ""), handle=d.get("handle"))
272+
273+
274+
@dataclass(frozen=True)
275+
class FriendsGraph:
276+
"""GET /v1/friends/:subject — mutual-consent friendship layer.
277+
278+
``mutual`` = both directions attested + unrevoked (two one-way BRC-3
279+
records form a pair). ``pending_in``/``pending_out`` are one-way.
280+
``legacy_outgoing``/``legacy_incoming`` mirror the BAP-era Bitcoin Schema
281+
rows (display-only; never count toward mutual).
282+
"""
283+
284+
mutual: list[FriendEntry] = field(default_factory=list)
285+
pending_in: list[FriendEntry] = field(default_factory=list)
286+
pending_out: list[FriendEntry] = field(default_factory=list)
287+
legacy_outgoing: list[dict[str, Any]] = field(default_factory=list)
288+
legacy_incoming: list[dict[str, Any]] = field(default_factory=list)
289+
290+
@classmethod
291+
def from_dict(cls, d: dict[str, Any]) -> "FriendsGraph":
292+
light = d.get("light") or {}
293+
data = d.get("data") or {}
294+
return cls(
295+
mutual=[FriendEntry.from_dict(x) for x in (light.get("mutual") or [])],
296+
pending_in=[FriendEntry.from_dict(x) for x in (light.get("pending_in") or [])],
297+
pending_out=[FriendEntry.from_dict(x) for x in (light.get("pending_out") or [])],
298+
legacy_outgoing=list(data.get("outgoing") or []),
299+
legacy_incoming=list(data.get("incoming") or []),
300+
)
301+
302+
303+
@dataclass(frozen=True)
304+
class NotificationItem:
305+
"""One row from GET /v1/notifications/:address.
306+
307+
``actor`` is an address/username for like/reply/follow/mention and an
308+
identity ROOT pubkey for friend_request — resolve via resolve_identities
309+
(bound posting keys collapse to their identity).
310+
"""
311+
312+
type: str
313+
actor: str
314+
target_txid: str | None = None
315+
subject_txid: str | None = None
316+
timestamp: str | None = None
317+
318+
@classmethod
319+
def from_dict(cls, d: dict[str, Any]) -> "NotificationItem":
320+
ts = d.get("timestamp")
321+
return cls(
322+
type=d.get("type", ""),
323+
actor=d.get("actor", ""),
324+
target_txid=d.get("target_txid"),
325+
subject_txid=d.get("subject_txid"),
326+
timestamp=str(ts) if ts is not None else None,
327+
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "overlay-social"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Read-only Python client for overlay.peck.to — identity resolution, profiles, feed and overlay state over the canonical BSV/BRC-100 social overlay."
99
readme = "README.md"
1010
requires-python = ">=3.10"

0 commit comments

Comments
 (0)