Retro Bulletin Board Systems on atproto. Web app and TUI.
atbbs.xyz
python
tui
atproto
bbs
1from dataclasses import dataclass
2
3
4class AtUri:
5 """Parsed AT URI with did, collection, and rkey fields."""
6
7 __slots__ = ("did", "collection", "rkey")
8
9 def __init__(self, did: str, collection: str, rkey: str):
10 self.did = did
11 self.collection = collection
12 self.rkey = rkey
13
14 @classmethod
15 def parse(cls, uri: str) -> "AtUri":
16 parts = uri.split("/")
17 return cls(parts[2], parts[3], parts[4])
18
19 def __str__(self) -> str:
20 return f"at://{self.did}/{self.collection}/{self.rkey}"
21
22 def __eq__(self, other):
23 if isinstance(other, AtUri):
24 return str(self) == str(other)
25 if isinstance(other, str):
26 return str(self) == other
27 return NotImplemented
28
29 def __hash__(self):
30 return hash(str(self))
31
32
33# errors
34
35
36class BBSNotFoundError(Exception):
37 """Handle could not be resolved."""
38
39
40class NoBBSError(Exception):
41 """Handle resolved but has no site record."""
42
43
44class NetworkError(Exception):
45 """Slingshot or Constellation is unreachable."""
46
47
48class AuthError(Exception):
49 """Session expired and token refresh failed."""
50
51
52# microcosm response types
53
54
55@dataclass
56class MiniDoc:
57 did: str
58 handle: str
59 pds: str | None = None
60 signing_key: str | None = None
61
62
63@dataclass
64class BacklinkRef:
65 """A single backlink from Constellation (record locator, no content)."""
66
67 did: str
68 collection: str
69 rkey: str
70
71 @property
72 def uri(self) -> str:
73 return f"at://{self.did}/{self.collection}/{self.rkey}"
74
75
76@dataclass
77class BacklinksResponse:
78 """Full Constellation getBacklinks response."""
79
80 total: int
81 records: list[BacklinkRef]
82 cursor: str | None = None
83
84
85@dataclass
86class Record:
87 """A single record from Slingshot getRecord."""
88
89 uri: str
90 cid: str
91 value: dict
92
93
94# lexicons
95
96
97@dataclass
98class Board:
99 """xyz.atboards.board — a subforum defined by the sysop."""
100
101 slug: str
102 name: str
103 description: str
104 created_at: str
105 updated_at: str | None = None
106
107
108@dataclass
109class News:
110 """xyz.atboards.news — a sysop announcement."""
111
112 tid: str
113 site_uri: str
114 title: str
115 body: str
116 created_at: str
117
118
119@dataclass
120class Site:
121 """xyz.atboards.site/self — the BBS front door."""
122
123 name: str
124 description: str
125 intro: str
126 boards: list[Board]
127 banned_dids: set[str]
128 hidden_posts: set[str]
129 created_at: str
130 updated_at: str | None = None
131
132 def is_banned(self, did: str) -> bool:
133 return did in self.banned_dids
134
135
136@dataclass
137class Thread:
138 """xyz.atboards.thread — a user's thread on a board."""
139
140 uri: str
141 board_uri: str
142 title: str
143 body: str
144 created_at: str
145 author: MiniDoc
146 updated_at: str | None = None
147 attachments: list[dict] | None = None
148
149
150@dataclass
151class Reply:
152 """xyz.atboards.reply — a user's reply to a thread."""
153
154 uri: str
155 subject_uri: str
156 body: str
157 created_at: str
158 author: MiniDoc
159 updated_at: str | None = None
160 attachments: list[dict] | None = None
161 quote: str | None = None
162
163
164@dataclass
165class BBS:
166 """Fully resolved BBS: resolve_bbs(handle)."""
167
168 identity: MiniDoc
169 site: Site
170 news: list[News]