Retro Bulletin Board Systems on atproto. Web app and TUI. atbbs.xyz
python tui atproto bbs

core: add AtUri datatype

+77 -42
+3 -2
core/filters.py
··· 1 - from core.models import Record 1 + from core import lexicon 2 + from core.models import AtUri, Record 2 3 3 4 4 5 def filter_moderated( ··· 8 9 return [ 9 10 r 10 11 for r in records 11 - if r.uri.split("/")[2] not in banned_dids and r.uri not in hidden_posts 12 + if AtUri.parse(r.uri).did not in banned_dids and r.uri not in hidden_posts 12 13 ]
+29
core/models.py
··· 1 1 from dataclasses import dataclass 2 2 3 3 4 + class 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 + 4 33 # errors 5 34 6 35
+19 -15
core/records.py
··· 8 8 from core import lexicon 9 9 from core.constellation import get_replies, get_threads 10 10 from core.filters import filter_moderated 11 - from core.models import BBS, Board, Reply, Thread 11 + from core.models import AtUri, BBS, Board, Reply, Thread 12 12 from core.slingshot import get_records_batch, resolve_identities_batch 13 13 from core.util import now_iso 14 14 ··· 20 20 cursor: str | None = None, 21 21 ) -> tuple[list[Thread], str | None]: 22 22 """Fetch and hydrate threads for a board.""" 23 - board_uri = f"at://{bbs.identity.did}/{lexicon.BOARD}/{board.slug}" 23 + board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, board.slug)) 24 24 backlinks = await get_threads(client, board_uri, cursor=cursor) 25 25 records = await get_records_batch(client, backlinks.records) 26 26 records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) 27 27 28 - dids = [r.uri.split("/")[2] for r in records] 28 + parsed = {r.uri: AtUri.parse(r.uri) for r in records} 29 + dids = [p.did for p in parsed.values()] 29 30 authors = await resolve_identities_batch(client, dids) 30 31 31 32 threads = [ ··· 35 36 title=r.value["title"], 36 37 body=r.value["body"], 37 38 created_at=r.value["createdAt"], 38 - author=authors[r.uri.split("/")[2]], 39 + author=authors[parsed[r.uri].did], 39 40 updated_at=r.value.get("updatedAt"), 40 41 attachments=r.value.get("attachments"), 41 42 ) 42 43 for r in records 43 - if r.uri.split("/")[2] in authors 44 + if parsed[r.uri].did in authors 44 45 ] 45 46 threads.sort(key=lambda t: t.created_at, reverse=True) 46 47 return threads, backlinks.cursor ··· 57 58 records = await get_records_batch(client, backlinks.records) 58 59 records = filter_moderated(records, bbs.site.banned_dids, bbs.site.hidden_posts) 59 60 60 - dids = [r.uri.split("/")[2] for r in records] 61 + parsed = {r.uri: AtUri.parse(r.uri) for r in records} 62 + dids = [p.did for p in parsed.values()] 61 63 authors = await resolve_identities_batch(client, dids) 62 64 63 65 replies = [ ··· 66 68 subject_uri=r.value["subject"], 67 69 body=r.value["body"], 68 70 created_at=r.value["createdAt"], 69 - author=authors[r.uri.split("/")[2]], 71 + author=authors[parsed[r.uri].did], 70 72 updated_at=r.value.get("updatedAt"), 71 73 attachments=r.value.get("attachments"), 72 74 quote=r.value.get("quote"), 73 75 ) 74 76 for r in records 75 - if r.uri.split("/")[2] in authors 77 + if parsed[r.uri].did in authors 76 78 ] 77 79 replies.sort(key=lambda t: t.created_at) 78 80 return replies, backlinks.cursor ··· 326 328 thread_uri = tr["uri"] 327 329 thread_title = tr["value"].get("title", "") 328 330 board_uri = tr["value"].get("board", "") 329 - bbs_did = board_uri.split("/")[2] if len(board_uri.split("/")) > 2 else did 331 + bbs_did = AtUri.parse(board_uri).did if board_uri else did 330 332 try: 331 333 bbs_authors = await resolve_identities_batch(client, [bbs_did]) 332 334 bbs_handle = bbs_authors[bbs_did].handle if bbs_did in bbs_authors else "" ··· 336 338 try: 337 339 backlinks = await get_replies(client, thread_uri, limit=50) 338 340 records = await get_records_batch(client, backlinks.records) 339 - records = [r for r in records if r.uri.split("/")[2] != did] 341 + parsed = {r.uri: AtUri.parse(r.uri) for r in records} 342 + records = [r for r in records if parsed[r.uri].did != did] 340 343 if not records: 341 344 continue 342 345 343 - dids = [r.uri.split("/")[2] for r in records] 346 + dids = [parsed[r.uri].did for r in records] 344 347 authors = await resolve_identities_batch(client, dids) 345 348 346 349 for r in records: 347 - author_did = r.uri.split("/")[2] 350 + author_did = parsed[r.uri].did 348 351 if author_did not in authors: 349 352 continue 350 353 all_items.append( ··· 386 389 continue 387 390 388 391 records = await get_records_batch(client, backlinks.records) 389 - records = [r for r in records if r.uri.split("/")[2] != did] 392 + parsed = {r.uri: AtUri.parse(r.uri) for r in records} 393 + records = [r for r in records if parsed[r.uri].did != did] 390 394 if not records: 391 395 continue 392 396 393 - dids = [r.uri.split("/")[2] for r in records] 397 + dids = [parsed[r.uri].did for r in records] 394 398 authors = await resolve_identities_batch(client, dids) 395 399 396 400 for r in records: 397 - author_did = r.uri.split("/")[2] 401 + author_did = parsed[r.uri].did 398 402 if author_did not in authors: 399 403 continue 400 404 all_items.append(
+3 -2
core/resolver.py
··· 3 3 import httpx 4 4 5 5 from core.models import ( 6 + AtUri, 6 7 BBS, 7 8 Board, 8 9 News, ··· 35 36 raise NetworkError("Could not reach the network.") 36 37 37 38 sv = site_record.value 38 - site_uri = f"at://{identity.did}/{lexicon.SITE}/self" 39 + site_uri = str(AtUri(identity.did, lexicon.SITE, "self")) 39 40 40 41 # Fetch boards and news backlinks concurrently 41 42 board_slugs = sv["boards"] ··· 69 70 news_records = await get_records_batch(client, sysop_news) 70 71 news = [ 71 72 News( 72 - tid=r.uri.split("/")[-1], 73 + tid=AtUri.parse(r.uri).rkey, 73 74 site_uri=r.value["site"], 74 75 title=r.value["title"], 75 76 body=r.value["body"],
+4 -4
tui/screens/activity.py
··· 66 66 from core.resolver import resolve_bbs 67 67 from core import lexicon 68 68 from core.slingshot import get_record, resolve_identity 69 - from core.models import Thread 69 + from core.models import AtUri, Thread 70 70 71 - parts = item["thread_uri"].split("/") 72 - thread_did = parts[2] 73 - thread_tid = parts[-1] 71 + thread = AtUri.parse(item["thread_uri"]) 72 + thread_did = thread.did 73 + thread_tid = thread.rkey 74 74 handle = item.get("bbs_handle") or self.app.user_session.get("handle", "") 75 75 76 76 client = self.app.http_client
+2 -1
tui/screens/compose.py
··· 8 8 from pathlib import Path 9 9 10 10 from core import lexicon 11 + from core.models import AtUri 11 12 from core.records import create_thread_record, create_reply_record, upload_blob 12 13 from tui.util import require_session 13 14 ··· 84 85 self.notify("Title and body cannot be empty.", severity="error") 85 86 return 86 87 87 - board_uri = f"at://{self.bbs.identity.did}/{lexicon.BOARD}/{self.board.slug}" 88 + board_uri = str(AtUri(self.bbs.identity.did, lexicon.BOARD, self.board.slug)) 88 89 89 90 # Handle file attachment 90 91 attachments = []
+2 -2
tui/screens/thread.py
··· 6 6 from textual.widgets import Button, Footer 7 7 8 8 from core import lexicon 9 - from core.models import BBS, Thread 9 + from core.models import AtUri, BBS, Thread 10 10 from tui.fetchers import delete_record, fetch_replies 11 11 from tui.util import require_session 12 12 from tui.widgets.post import Post ··· 29 29 self._replies_map: dict[str, object] = {} 30 30 31 31 def compose(self) -> ComposeResult: 32 - board_slug = self.thread.board_uri.split("/")[-1] 32 + board_slug = AtUri.parse(self.thread.board_uri).rkey 33 33 board_name = next( 34 34 (b.name for b in self.bbs.site.boards if b.slug == board_slug), board_slug 35 35 )
+2 -1
tui/widgets/post.py
··· 4 4 from textual.widget import Widget 5 5 from textual.widgets import Static 6 6 7 + from core.models import AtUri 7 8 from tui.util import format_datetime 8 9 9 10 ··· 102 103 @property 103 104 def rkey(self) -> str | None: 104 105 if self.record_uri: 105 - return self.record_uri.split("/")[-1] 106 + return AtUri.parse(self.record_uri).rkey 106 107 return None 107 108 108 109 def compose(self) -> ComposeResult:
+5 -4
web/routes.py
··· 4 4 from quart import Blueprint, current_app, render_template, request 5 5 6 6 from core.models import ( 7 + AtUri, 7 8 BBSNotFoundError, 8 9 NetworkError, 9 10 NoBBSError, ··· 180 181 { 181 182 "uri": t.uri, 182 183 "did": t.author.did, 183 - "rkey": t.uri.split("/")[-1], 184 + "rkey": AtUri.parse(t.uri).rkey, 184 185 "handle": t.author.handle, 185 186 "title": t.title, 186 187 "body": t.body, ··· 227 228 attachments=thread_record.value.get("attachments"), 228 229 ) 229 230 230 - board_slug = thread_obj.board_uri.split("/")[-1] 231 + board_slug = AtUri.parse(thread_obj.board_uri).rkey 231 232 current_board = next((b for b in bbs.site.boards if b.slug == board_slug), None) 232 233 233 234 return await render_template( ··· 262 263 return {"replies": [], "cursor": None} 263 264 264 265 # Build a minimal Thread object for hydrate_replies 265 - thread_uri = f"at://{did}/{lexicon.THREAD}/{tid}" 266 + thread_uri = str(AtUri(did, lexicon.THREAD, tid)) 266 267 from core.models import MiniDoc 267 268 268 269 dummy_thread = Thread( ··· 286 287 { 287 288 "uri": r.uri, 288 289 "did": r.author.did, 289 - "rkey": r.uri.split("/")[-1], 290 + "rkey": AtUri.parse(r.uri).rkey, 290 291 "handle": r.author.handle, 291 292 "pds_url": r.author.pds or "", 292 293 "body": r.body,
+5 -9
web/routes_sysop.py
··· 3 3 from quart import Blueprint, current_app, redirect, render_template, request 4 4 5 5 from core import lexicon 6 + from core.models import AtUri 6 7 from core.util import now_iso 7 8 from web.helpers import get_user 8 9 from web.routes_write import _authed_pds_post, authed_delete_record ··· 72 73 # Delete news records (via Constellation backlinks) 73 74 from core.constellation import get_news 74 75 75 - site_uri = f"at://{user['did']}/{lexicon.SITE}/self" 76 + site_uri = str(AtUri(user["did"], lexicon.SITE, "self")) 76 77 try: 77 78 backlinks = await get_news(client, site_uri) 78 79 for ref in backlinks.records: ··· 184 185 hidden_posts = [] 185 186 if bbs.site.hidden_posts: 186 187 hidden_dids = list( 187 - { 188 - uri.split("/")[2] 189 - for uri in bbs.site.hidden_posts 190 - if len(uri.split("/")) > 2 191 - } 188 + {AtUri.parse(uri).did for uri in bbs.site.hidden_posts} 192 189 ) 193 190 hidden_authors = await resolve_identities_batch(client, hidden_dids) 194 191 195 192 for uri in bbs.site.hidden_posts: 196 - parts = uri.split("/") 197 - did = parts[2] if len(parts) > 2 else "?" 193 + did = AtUri.parse(uri).did 198 194 handle = hidden_authors[did].handle if did in hidden_authors else did 199 195 200 196 try: ··· 355 351 if not title or not body: 356 352 return redirect(f"/bbs/{handle}") 357 353 358 - site_uri = f"at://{user['did']}/{lexicon.SITE}/self" 354 + site_uri = str(AtUri(user["did"], lexicon.SITE, "self")) 359 355 now = now_iso() 360 356 361 357 await _authed_pds_post(
+3 -2
web/routes_write.py
··· 3 3 from quart import Blueprint, current_app, redirect, request 4 4 5 5 from core import lexicon 6 + from core.models import AtUri 6 7 from core.records import upload_blob 7 8 from core.util import now_iso 8 9 from web.helpers import get_user, session_updater ··· 57 58 if bbs.site.is_banned(user["did"]): 58 59 return redirect(f"/bbs/{handle}/board/{slug}") 59 60 60 - board_uri = f"at://{bbs.identity.did}/{lexicon.BOARD}/{slug}" 61 + board_uri = str(AtUri(bbs.identity.did, lexicon.BOARD, slug)) 61 62 62 63 # Handle file attachments 63 64 attachments = [] ··· 116 117 if not body: 117 118 return redirect(f"/bbs/{handle}/thread/{did}/{tid}") 118 119 119 - thread_uri = f"at://{did}/{lexicon.THREAD}/{tid}" 120 + thread_uri = str(AtUri(did, lexicon.THREAD, tid)) 120 121 121 122 # Handle file attachments 122 123 client = current_app.http_client