a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

move activity feed out of main.py into ui/ package

ui.py becomes a package: pages.py (HTML templates, unchanged), activity.py
(new — activity-feed data fetching + cache + APIRouter exposing /api/activity),
__init__.py re-exports.

main.py is now composition only — `app.include_router(activity_router)`
replaces ~100 lines of inline data fetching, JSON shaping, TID decoding,
and cache state. main.py: 376 → 260 lines.

no behavior change. /api/activity returns the same shape; cache TTL still 60s.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+155 -119
+3 -119
src/bot/main.py
··· 4 4 import logging 5 5 import time 6 6 from contextlib import asynccontextmanager 7 - from datetime import UTC, datetime 7 + from datetime import datetime 8 8 9 - import httpx 10 9 import logfire 11 10 from fastapi import BackgroundTasks, FastAPI, Request 12 11 from fastapi.responses import HTMLResponse, JSONResponse ··· 21 20 from bot.memory import NamespaceMemory 22 21 from bot.services.notification_poller import NotificationPoller 23 22 from bot.status import bot_status 24 - from bot.ui import home_page, memory_page, status_page 23 + from bot.ui import activity_router, home_page, memory_page, status_page 25 24 26 25 logger = logging.getLogger("bot.main") 27 26 ··· 100 99 except Exception as _e: 101 100 logger.warning(f"logfire fastapi instrumentation failed: {_e}") 102 101 103 - 104 - PHI_DID = "did:plc:65sucjiel52gefhcdcypynsr" 102 + app.include_router(activity_router) 105 103 106 104 107 105 @app.get("/", response_class=HTMLResponse) ··· 228 226 return status_page(cards_html=cards_html) 229 227 230 228 231 - _TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" 232 - 233 - 234 - def _tid_to_iso(tid: str) -> str: 235 - """Decode an AT Protocol TID (base32-sortstring) to ISO8601.""" 236 - try: 237 - n = 0 238 - for ch in tid: 239 - n = n * 32 + _TID_CHARSET.index(ch) 240 - # 64-bit TID: bit 63=0, bits 62..10=timestamp(us), bits 9..0=clockid 241 - us = (n >> 10) & ((1 << 53) - 1) 242 - dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) 243 - return dt.isoformat() 244 - except (ValueError, OSError): 245 - return "" 246 - 247 - 248 - _activity_cache_data: list[dict] | None = None 249 - _activity_cache_expires: float = 0.0 250 - _ACTIVITY_CACHE_TTL = 60 # seconds 251 - 252 229 _graph_cache_data: dict | None = None 253 230 _graph_cache_expires: float = 0.0 254 231 _GRAPH_CACHE_TTL = 60 # seconds 255 - 256 - 257 - @app.get("/api/activity") 258 - async def activity_feed(): 259 - """Recent posts and cosmik cards, merged by time.""" 260 - global _activity_cache_data, _activity_cache_expires 261 - now = time.monotonic() 262 - if _activity_cache_data is not None and now < _activity_cache_expires: 263 - return JSONResponse(_activity_cache_data) 264 - 265 - items: list[dict] = [] 266 - async with httpx.AsyncClient(timeout=10) as client: 267 - posts_coro = client.get( 268 - "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed", 269 - params={ 270 - "actor": PHI_DID, 271 - "filter": "posts_and_author_threads", 272 - "limit": 10, 273 - }, 274 - ) 275 - cards_coro = client.get( 276 - "https://bsky.social/xrpc/com.atproto.repo.listRecords", 277 - params={"repo": PHI_DID, "collection": "network.cosmik.card", "limit": 10}, 278 - ) 279 - posts_resp, cards_resp = await asyncio.gather( 280 - posts_coro, cards_coro, return_exceptions=True 281 - ) 282 - 283 - if isinstance(posts_resp, httpx.Response) and posts_resp.status_code == 200: 284 - for entry in posts_resp.json().get("feed", []): 285 - post = entry.get("post", {}) 286 - record = post.get("record", {}) 287 - uri = post.get("uri", "") 288 - # at://did/app.bsky.feed.post/rkey -> bsky.app link 289 - parts = uri.split("/") 290 - bsky_url = ( 291 - f"https://bsky.app/profile/{PHI_DID}/post/{parts[-1]}" 292 - if len(parts) >= 5 293 - else None 294 - ) 295 - items.append( 296 - { 297 - "type": "post", 298 - "text": record.get("text", ""), 299 - "time": record.get("createdAt", ""), 300 - "uri": uri, 301 - "url": bsky_url, 302 - } 303 - ) 304 - 305 - if isinstance(cards_resp, httpx.Response) and cards_resp.status_code == 200: 306 - for rec in cards_resp.json().get("records", []): 307 - value = rec.get("value", {}) 308 - card_type = value.get("type", "NOTE") 309 - item_type = "url" if card_type == "URL" else "note" 310 - content = value.get("content", {}) 311 - # metadata may be nested under content.metadata (semble lexicon) 312 - meta = content.get("metadata", {}) if isinstance(content, dict) else {} 313 - if item_type == "url": 314 - card_title = content.get("title", "") or meta.get("title", "") 315 - desc = content.get("description", "") or meta.get("description", "") 316 - # skip semble tag metadata ("discussed in context of: ...") 317 - if desc and desc.startswith("discussed in context of:"): 318 - desc = "" 319 - # text is the description (or URL fallback), title is separate 320 - text = desc or (content.get("url", "") if not card_title else "") 321 - else: 322 - card_title = ( 323 - content.get("title", "") if isinstance(content, dict) else "" 324 - ) 325 - text = ( 326 - content.get("text", "") 327 - if isinstance(content, dict) 328 - else str(content) 329 - ) 330 - # derive time from TID rkey (base32-sortstring microseconds) 331 - rkey = rec.get("uri", "").rsplit("/", 1)[-1] 332 - card_time = _tid_to_iso(rkey) 333 - items.append( 334 - { 335 - "type": item_type, 336 - "text": text, 337 - "title": card_title or None, 338 - "time": card_time, 339 - "uri": rec.get("uri", ""), 340 - "url": content.get("url") if item_type == "url" else None, 341 - } 342 - ) 343 - 344 - items.sort(key=lambda x: x.get("time", ""), reverse=True) 345 - _activity_cache_data = items 346 - _activity_cache_expires = now + _ACTIVITY_CACHE_TTL 347 - return JSONResponse(items) 348 232 349 233 350 234 @app.get("/api/memory/graph")
src/bot/ui.py src/bot/ui/pages.py
+11
src/bot/ui/__init__.py
··· 1 + """HTML page templates and JSON data endpoints for phi's web UI.""" 2 + 3 + from bot.ui.activity import router as activity_router 4 + from bot.ui.pages import home_page, memory_page, status_page 5 + 6 + __all__ = [ 7 + "activity_router", 8 + "home_page", 9 + "memory_page", 10 + "status_page", 11 + ]
+141
src/bot/ui/activity.py
··· 1 + """Activity feed: recent posts + cosmik cards merged by time, served as JSON. 2 + 3 + Backs the home page's activity stream. Public-API only (bsky.app + 4 + PDS listRecords) so no auth needed; cached in-process for 60s to keep 5 + the home page from hammering upstream on every render. 6 + """ 7 + 8 + from __future__ import annotations 9 + 10 + import asyncio 11 + import logging 12 + import time 13 + from datetime import UTC, datetime 14 + 15 + import httpx 16 + from fastapi import APIRouter 17 + from fastapi.responses import JSONResponse 18 + 19 + logger = logging.getLogger("bot.ui.activity") 20 + 21 + PHI_DID = "did:plc:65sucjiel52gefhcdcypynsr" 22 + ACTIVITY_CACHE_TTL = 60 # seconds 23 + 24 + _TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" 25 + 26 + _cache_data: list[dict] | None = None 27 + _cache_expires: float = 0.0 28 + 29 + router = APIRouter() 30 + 31 + 32 + def _tid_to_iso(tid: str) -> str: 33 + """Decode an AT Protocol TID (base32-sortstring) to ISO8601.""" 34 + try: 35 + n = 0 36 + for ch in tid: 37 + n = n * 32 + _TID_CHARSET.index(ch) 38 + # 64-bit TID: bit 63=0, bits 62..10=timestamp(us), bits 9..0=clockid 39 + us = (n >> 10) & ((1 << 53) - 1) 40 + dt = datetime.fromtimestamp(us / 1_000_000, tz=UTC) 41 + return dt.isoformat() 42 + except (ValueError, OSError): 43 + return "" 44 + 45 + 46 + def _post_to_item(entry: dict) -> dict: 47 + post = entry.get("post", {}) 48 + record = post.get("record", {}) 49 + uri = post.get("uri", "") 50 + parts = uri.split("/") 51 + bsky_url = ( 52 + f"https://bsky.app/profile/{PHI_DID}/post/{parts[-1]}" 53 + if len(parts) >= 5 54 + else None 55 + ) 56 + return { 57 + "type": "post", 58 + "text": record.get("text", ""), 59 + "time": record.get("createdAt", ""), 60 + "uri": uri, 61 + "url": bsky_url, 62 + } 63 + 64 + 65 + def _card_to_item(rec: dict) -> dict: 66 + value = rec.get("value", {}) 67 + card_type = value.get("type", "NOTE") 68 + item_type = "url" if card_type == "URL" else "note" 69 + content = value.get("content", {}) 70 + # metadata may be nested under content.metadata (semble lexicon) 71 + meta = content.get("metadata", {}) if isinstance(content, dict) else {} 72 + 73 + if item_type == "url": 74 + card_title = content.get("title", "") or meta.get("title", "") 75 + desc = content.get("description", "") or meta.get("description", "") 76 + # skip semble tag metadata ("discussed in context of: ...") 77 + if desc and desc.startswith("discussed in context of:"): 78 + desc = "" 79 + text = desc or (content.get("url", "") if not card_title else "") 80 + else: 81 + card_title = content.get("title", "") if isinstance(content, dict) else "" 82 + text = content.get("text", "") if isinstance(content, dict) else str(content) 83 + 84 + rkey = rec.get("uri", "").rsplit("/", 1)[-1] 85 + return { 86 + "type": item_type, 87 + "text": text, 88 + "title": card_title or None, 89 + "time": _tid_to_iso(rkey), 90 + "uri": rec.get("uri", ""), 91 + "url": content.get("url") if item_type == "url" else None, 92 + } 93 + 94 + 95 + async def _fetch_items() -> list[dict]: 96 + items: list[dict] = [] 97 + async with httpx.AsyncClient(timeout=10) as client: 98 + posts_resp, cards_resp = await asyncio.gather( 99 + client.get( 100 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed", 101 + params={ 102 + "actor": PHI_DID, 103 + "filter": "posts_and_author_threads", 104 + "limit": 10, 105 + }, 106 + ), 107 + client.get( 108 + "https://bsky.social/xrpc/com.atproto.repo.listRecords", 109 + params={ 110 + "repo": PHI_DID, 111 + "collection": "network.cosmik.card", 112 + "limit": 10, 113 + }, 114 + ), 115 + return_exceptions=True, 116 + ) 117 + 118 + if isinstance(posts_resp, httpx.Response) and posts_resp.status_code == 200: 119 + for entry in posts_resp.json().get("feed", []): 120 + items.append(_post_to_item(entry)) 121 + 122 + if isinstance(cards_resp, httpx.Response) and cards_resp.status_code == 200: 123 + for rec in cards_resp.json().get("records", []): 124 + items.append(_card_to_item(rec)) 125 + 126 + items.sort(key=lambda x: x.get("time", ""), reverse=True) 127 + return items 128 + 129 + 130 + @router.get("/api/activity") 131 + async def activity_feed(): 132 + """Recent posts and cosmik cards, merged by time. 60s cache.""" 133 + global _cache_data, _cache_expires 134 + now = time.monotonic() 135 + if _cache_data is not None and now < _cache_expires: 136 + return JSONResponse(_cache_data) 137 + 138 + items = await _fetch_items() 139 + _cache_data = items 140 + _cache_expires = now + ACTIVITY_CACHE_TTL 141 + return JSONResponse(items)