Retro Bulletin Board Systems on atproto. Web app and TUI.
atbbs.xyz
python
tui
atproto
bbs
1import asyncio
2
3import httpx
4
5from core.models import (
6 AtUri,
7 BBS,
8 Board,
9 News,
10 Site,
11 BBSNotFoundError,
12 NoBBSError,
13 NetworkError,
14)
15from core import lexicon
16from core.constellation import get_news
17from core.records import list_pds_records
18from core.slingshot import get_record, get_records_batch, resolve_identity
19
20
21async def resolve_bbs(client: httpx.AsyncClient, handle: str) -> BBS:
22 """Handle -> fully resolved BBS config."""
23 try:
24 identity = await resolve_identity(client, handle)
25 except httpx.HTTPStatusError:
26 raise BBSNotFoundError(f"Could not resolve handle: {handle}")
27 except httpx.TransportError:
28 raise NetworkError("Could not reach the network.")
29
30 try:
31 site_record = await get_record(client, identity.did, lexicon.SITE, "self")
32 except httpx.HTTPStatusError:
33 raise NoBBSError(f"{handle} isn't running a BBS.")
34 except httpx.TransportError:
35 raise NetworkError("Could not reach the network.")
36
37 sv = site_record.value
38 site_uri = str(AtUri(identity.did, lexicon.SITE, "self"))
39
40 # Fetch boards, news, bans, and hidden posts concurrently
41 board_slugs = sv["boards"]
42 board_tasks = [
43 get_record(client, identity.did, lexicon.BOARD, slug) for slug in board_slugs
44 ]
45 news_task = get_news(client, site_uri)
46 ban_task = list_pds_records(client, identity.pds, identity.did, lexicon.BAN)
47 hidden_task = list_pds_records(client, identity.pds, identity.did, lexicon.HIDE)
48
49 results = await asyncio.gather(
50 *board_tasks, news_task, ban_task, hidden_task, return_exceptions=True
51 )
52 board_records = results[: len(board_slugs)]
53 news_result = results[len(board_slugs)]
54 ban_result = results[len(board_slugs) + 1]
55 hidden_result = results[len(board_slugs) + 2]
56
57 boards = [
58 Board(
59 slug=slug,
60 name=r.value["name"],
61 description=r.value["description"],
62 created_at=r.value["createdAt"],
63 updated_at=r.value.get("updatedAt"),
64 )
65 for slug, r in zip(board_slugs, board_records)
66 if not isinstance(r, BaseException)
67 ]
68
69 # Hydrate news records (only from the sysop's repo)
70 if isinstance(news_result, BaseException):
71 news_records = []
72 else:
73 sysop_news = [r for r in news_result.records if r.did == identity.did]
74 news_records = await get_records_batch(client, sysop_news)
75 news = [
76 News(
77 tid=AtUri.parse(r.uri).rkey,
78 site_uri=r.value["site"],
79 title=r.value["title"],
80 body=r.value["body"],
81 created_at=r.value["createdAt"],
82 )
83 for r in news_records
84 ]
85 news.sort(key=lambda n: n.created_at, reverse=True)
86
87 # Build ban/hidden sets from standalone records
88 banned_dids: set[str] = set()
89 if not isinstance(ban_result, BaseException):
90 banned_dids = {r["value"]["did"] for r in ban_result}
91 hidden_posts: set[str] = set()
92 if not isinstance(hidden_result, BaseException):
93 hidden_posts = {r["value"]["uri"] for r in hidden_result}
94
95 site = Site(
96 name=sv["name"],
97 description=sv["description"],
98 intro=sv["intro"],
99 boards=boards,
100 banned_dids=banned_dids,
101 hidden_posts=hidden_posts,
102 created_at=sv.get("createdAt", ""),
103 updated_at=sv.get("updatedAt"),
104 )
105
106 return BBS(identity=identity, site=site, news=news)