Skip to content

Commit f5daca5

Browse files
author
Neo
committed
[P2-10] gateway: social follow-graph routes (server-add for the visible Social tab)
No follow storage existed. Added runtime/social/follows.py (social_follows table: follower, followee, created_at) + four routes: POST /social/follow · POST /social/unfollow (follower = X-Wallet-Address) GET /social/{address}/followers · GET /social/{address}/following Postcondition: tests/test_social_follows.py 3 passed (store roundtrip, self-follow ignored, live route follow->followers); seam clean; full suite green.
1 parent 12df56c commit f5daca5

3 files changed

Lines changed: 165 additions & 0 deletions

File tree

gateway/server.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,49 @@ async def handle_auth_verify(self, request: web.Request) -> web.Response:
712712
"expires_at": expires_at,
713713
})
714714

715+
# ─── Social follow graph (P2-10) ──────────────────────────────────────
716+
717+
def _follow_store(self):
718+
from runtime.social.follows import FollowStore
719+
return FollowStore(self.react_loop.memory.db)
720+
721+
async def handle_social_follow(self, request: web.Request) -> web.Response:
722+
"""POST /social/follow — {address}. Follower = X-Wallet-Address."""
723+
follower = request.headers.get("X-Wallet-Address", "").strip()
724+
try:
725+
body = await request.json()
726+
except json.JSONDecodeError:
727+
return web.json_response({"error": "invalid JSON"}, status=400)
728+
followee = str(body.get("address", "")).strip()
729+
if not follower or not followee:
730+
return web.json_response({"error": "address required"}, status=400)
731+
await self._follow_store().follow(follower, followee)
732+
return web.json_response({"success": True, "following": followee})
733+
734+
async def handle_social_unfollow(self, request: web.Request) -> web.Response:
735+
follower = request.headers.get("X-Wallet-Address", "").strip()
736+
try:
737+
body = await request.json()
738+
except json.JSONDecodeError:
739+
return web.json_response({"error": "invalid JSON"}, status=400)
740+
followee = str(body.get("address", "")).strip()
741+
if not follower or not followee:
742+
return web.json_response({"error": "address required"}, status=400)
743+
await self._follow_store().unfollow(follower, followee)
744+
return web.json_response({"success": True, "unfollowed": followee})
745+
746+
async def handle_social_followers(self, request: web.Request) -> web.Response:
747+
address = request.match_info.get("address", "").strip()
748+
followers = await self._follow_store().followers(address)
749+
return web.json_response({"address": address, "followers": followers,
750+
"count": len(followers)})
751+
752+
async def handle_social_following(self, request: web.Request) -> web.Response:
753+
address = request.match_info.get("address", "").strip()
754+
following = await self._follow_store().following(address)
755+
return web.json_response({"address": address, "following": following,
756+
"count": len(following)})
757+
715758
# ─── Sign in with Apple (P1-8) ────────────────────────────────────────
716759

717760
async def handle_apple_auth(self, request: web.Request) -> web.Response:
@@ -1786,6 +1829,11 @@ def create_app(self) -> web.Application:
17861829
app.router.add_get("/social/trending", self.handle_social_trending)
17871830
app.router.add_get("/social/actor/{wallet}", self.handle_social_actor)
17881831
app.router.add_get("/social/stats", self.handle_social_stats)
1832+
# Follow graph (P2-10)
1833+
app.router.add_post("/social/follow", self.handle_social_follow)
1834+
app.router.add_post("/social/unfollow", self.handle_social_unfollow)
1835+
app.router.add_get("/social/{address}/followers", self.handle_social_followers)
1836+
app.router.add_get("/social/{address}/following", self.handle_social_following)
17891837

17901838
# ── A2A commerce ──────────────────────────────────────────────
17911839
app.router.add_get("/a2a/services", self.handle_a2a_services)

runtime/social/follows.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""P2-10: minimal social follow-graph storage.
2+
3+
Backs GET /social/{address}/followers|following and POST /social/follow|unfollow.
4+
Async sqlite, matching the feed_engine DB style.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import time
11+
from typing import Any
12+
13+
logger = logging.getLogger(__name__)
14+
15+
_CREATE_TABLE = """
16+
CREATE TABLE IF NOT EXISTS social_follows (
17+
follower TEXT,
18+
followee TEXT,
19+
created_at REAL,
20+
PRIMARY KEY (follower, followee)
21+
)
22+
"""
23+
24+
25+
class FollowStore:
26+
def __init__(self, db: Any) -> None:
27+
self._db = db
28+
self._ready = False
29+
30+
async def _ensure(self) -> None:
31+
if not self._ready:
32+
await self._db.execute(_CREATE_TABLE)
33+
self._ready = True
34+
35+
async def follow(self, follower: str, followee: str) -> None:
36+
await self._ensure()
37+
if not follower or not followee or follower == followee:
38+
return
39+
await self._db.execute(
40+
"INSERT OR IGNORE INTO social_follows (follower, followee, created_at) "
41+
"VALUES (?, ?, ?)",
42+
(follower, followee, time.time()),
43+
)
44+
45+
async def unfollow(self, follower: str, followee: str) -> None:
46+
await self._ensure()
47+
await self._db.execute(
48+
"DELETE FROM social_follows WHERE follower = ? AND followee = ?",
49+
(follower, followee),
50+
)
51+
52+
async def followers(self, address: str) -> list[str]:
53+
await self._ensure()
54+
rows = await self._db.fetchall(
55+
"SELECT follower FROM social_follows WHERE followee = ?", (address,))
56+
return [r[0] for r in rows]
57+
58+
async def following(self, address: str) -> list[str]:
59+
await self._ensure()
60+
rows = await self._db.fetchall(
61+
"SELECT followee FROM social_follows WHERE follower = ?", (address,))
62+
return [r[0] for r in rows]

tests/test_social_follows.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""P2-10: social follow-graph store + routes."""
2+
3+
import pytest
4+
5+
from runtime.db.database import Database
6+
from runtime.social.follows import FollowStore
7+
8+
9+
@pytest.fixture
10+
def db(tmp_path):
11+
return Database({"database": {"path": str(tmp_path / "follows.db")}})
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_follow_unfollow_roundtrip(db):
16+
store = FollowStore(db)
17+
await store.follow("0xa", "0xb")
18+
await store.follow("0xc", "0xb")
19+
assert set(await store.followers("0xb")) == {"0xa", "0xc"}
20+
assert await store.following("0xa") == ["0xb"]
21+
await store.unfollow("0xa", "0xb")
22+
assert await store.followers("0xb") == ["0xc"]
23+
24+
25+
@pytest.mark.asyncio
26+
async def test_self_follow_ignored(db):
27+
store = FollowStore(db)
28+
await store.follow("0xa", "0xa")
29+
assert await store.followers("0xa") == []
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_routes(aiohttp_client, tmp_path):
34+
from tests.test_gateway import _build_mock_server
35+
config = {
36+
"platform": "0pnMatrx", "memory_dir": str(tmp_path / "m"),
37+
"workspace": str(tmp_path), "timezone": "UTC",
38+
"model": {"provider": "ollama", "providers": {}},
39+
"agents": {"neo": {"enabled": True}},
40+
"gateway": {"api_key": "", "rate_limit_rpm": 60, "rate_limit_burst": 10},
41+
"security": {},
42+
}
43+
server = _build_mock_server(config)
44+
server._app_attest = None
45+
server._security_backend = "noop"
46+
server.react_loop.memory.db = Database({"database": {"path": str(tmp_path / "g.db")}})
47+
client = await aiohttp_client(server.create_app())
48+
49+
r = await client.post("/social/follow", json={"address": "0xbob"},
50+
headers={"X-Wallet-Address": "0xalice"})
51+
assert r.status == 200 and (await r.json())["success"] is True
52+
53+
r = await client.get("/social/0xbob/followers")
54+
body = await r.json()
55+
assert body["followers"] == ["0xalice"] and body["count"] == 1

0 commit comments

Comments
 (0)