Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

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

at master 303 lines 10 kB view raw
1import asyncio 2 3import httpx 4 5import re 6import textwrap 7 8from core.records import hydrate_replies, hydrate_threads 9from core.resolver import resolve_bbs 10from core.util import format_datetime_utc 11 12AMBER = "\033[38;5;208m" 13RESET = "\033[0m" 14DIM = "\033[2m" 15 16LOGO = ( 17 f" {AMBER}▞▀▖{RESET}▌ ▌\r\n" 18 f" {AMBER}▌▙▌{RESET}▛▀▖▛▀▖▞▀▘\r\n" 19 f" {AMBER}▌▀ {RESET}▌ ▌▌ ▌▝▀▖\r\n" 20 f" {AMBER}▝▀ {RESET}▀▀ ▀▀ ▀▀\r\n" 21) 22 23 24LINE_WIDTH = 75 25 26READ_TIMEOUT = 120 # seconds per prompt 27MAX_CONNECTIONS = 20 28 29_connections = asyncio.Semaphore(MAX_CONNECTIONS) 30 31 32def wrap(text: str) -> str: 33 """Wrap text to LINE_WIDTH without breaking indentation.""" 34 out = [] 35 for line in text.split("\r\n"): 36 visible = re.sub(r"\033\[[0-9;]*m", "", line) # Strip ANSI codes 37 if len(visible) <= LINE_WIDTH: 38 out.append(line) 39 else: 40 indent = len(visible) - len(visible.lstrip()) # Preserve indentation 41 wrapped = textwrap.fill( 42 visible, 43 width=LINE_WIDTH, 44 initial_indent="", 45 subsequent_indent=" " * indent, 46 ) 47 out.append(wrapped) 48 return "\r\n".join(out) 49 50 51def strip_iac(data: bytes) -> bytes: 52 """Strip telnet IAC command sequences from raw bytes.""" 53 out = bytearray() 54 i = 0 55 while i < len(data): 56 if data[i] == 0xFF and i + 1 < len(data): 57 cmd = data[i + 1] 58 if cmd == 0xFF: 59 out.append(0xFF) 60 i += 2 61 elif cmd in (0xFB, 0xFC, 0xFD, 0xFE): 62 i += 3 # WILL/WONT/DO/DONT + option 63 elif cmd == 0xFA: 64 i += 2 # sub-negotiation: skip until IAC SE 65 while i < len(data): 66 if data[i] == 0xFF and i + 1 < len(data) and data[i + 1] == 0xF0: 67 i += 2 68 break 69 i += 1 70 else: 71 i += 2 72 else: 73 out.append(data[i]) 74 i += 1 75 return bytes(out) 76 77 78async def write(writer: asyncio.StreamWriter, text: str): 79 writer.write(wrap(text).encode()) 80 await writer.drain() 81 82 83async def prompt( 84 reader: asyncio.streamReader, writer: asyncio.StreamWriter, label: str = "> " 85) -> str: 86 """Handle prompt + inactivity timeouts safely.""" 87 await write(writer, label) 88 try: 89 data = await asyncio.wait_for(reader.readline(), timeout=READ_TIMEOUT) 90 except asyncio.TimeoutError: 91 await write(writer, "\r\n Connection inactive.\r\n") 92 return "" 93 if not data: 94 return "" 95 text = strip_iac(data).decode(errors="ignore").strip() 96 return re.sub(r"[^\x20-\x7e]", "", text) 97 98 99async def show_bbs(writer, bbs): 100 await write(writer, f"\r\n {bbs.site.name}\r\n") 101 await write(writer, f" {bbs.site.description}\r\n") 102 if bbs.site.intro: 103 await write(writer, "\r\n") 104 for line in bbs.site.intro.splitlines(): 105 await write(writer, f" {line}\r\n") 106 await write(writer, "\r\n") 107 if bbs.site.boards: 108 await write(writer, " Boards\r\n") 109 for i, board in enumerate(bbs.site.boards, 1): 110 await write(writer, f" {i}. {board.name}: {board.description}\r\n") 111 await write(writer, "\r\n") 112 113 if bbs.news: 114 await write(writer, f" Latest News: {bbs.news[0].title}\r\n\r\n") 115 116 await write(writer, "[#] open board [n] news [q] quit\r\n") 117 118 119async def show_board(writer, board, threads, has_next): 120 await write(writer, f"\r\n {board.name}\r\n") 121 await write(writer, f" {board.description}\r\n\r\n") 122 123 if not threads: 124 await write(writer, " No threads yet.\r\n") 125 else: 126 for index, thread in enumerate(threads, 1): 127 date = format_datetime_utc(thread.last_activity_at or thread.created_at) 128 await write( 129 writer, 130 f" {index}. {thread.title} · {thread.author.handle} · {date}\r\n", 131 ) 132 133 cmds = ["[#] open thread"] 134 if has_next: 135 cmds.append("[n] next") 136 cmds.extend(["[b] back", "[q] quit"]) 137 await write(writer, f"\r\n{' '.join(cmds)}\r\n") 138 139 140async def show_thread_header(writer, thread): 141 await write(writer, f"\r\n {thread.title}\r\n") 142 await write( 143 writer, 144 f" by {thread.author.handle} · {format_datetime_utc(thread.created_at)}\r\n", 145 ) 146 await write(writer, "\r\n") 147 for line in thread.body.splitlines(): 148 await write(writer, f" {line}\r\n") 149 await write(writer, "\r\n") 150 151 152async def show_replies(writer, replies): 153 for reply in replies: 154 await write( 155 writer, 156 f" {reply.author.handle} · {format_datetime_utc(reply.created_at)}\r\n", 157 ) 158 for line in reply.body.splitlines(): 159 await write(writer, f" {line}\r\n") 160 await write(writer, "\r\n") 161 162 163async def show_thread_prompt(writer, has_more): 164 cmds = ["[b] back"] 165 if has_more: 166 cmds.append("[n] show more") 167 cmds.append("[q] quit") 168 await write(writer, f"{' '.join(cmds)}\r\n") 169 170 171async def show_news(writer, news): 172 await write(writer, "\r\n News:\r\n\r\n") 173 for item in news: 174 await write( 175 writer, f" {item.title} · {format_datetime_utc(item.created_at)}\r\n" 176 ) 177 for line in item.body.splitlines(): 178 await write(writer, f" {line}\r\n") 179 await write(writer, "\r\n") 180 await write(writer, "[b] back [q] quit\r\n") 181 182 183async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): 184 if _connections.locked(): 185 writer.write(b" Server is full. Try again later.\r\n") 186 await writer.drain() 187 writer.close() 188 return 189 async with _connections, httpx.AsyncClient() as client: 190 await write(writer, f"\r\n{LOGO}\r\n") 191 await write( 192 writer, 193 " This is a read-only telnet gateway for AT Protocol BBSes.\r\n Please dial a BBS.\r\n\r\n", 194 ) 195 196 handle = await prompt(reader, writer, "handle> ") 197 if not handle: 198 writer.close() 199 return 200 201 try: 202 bbs = await resolve_bbs(client, handle) 203 except Exception as e: 204 await write(writer, " Could not reach that BBS.\r\n") 205 await write(writer, f"{e}\r\n") 206 writer.close() 207 return 208 209 state = "bbs" 210 board = None 211 threads = [] 212 thread_cursor = None 213 thread = None 214 reply_result = None 215 216 while True: 217 if state == "bbs": 218 await show_bbs(writer, bbs) 219 elif state == "board": 220 await show_board(writer, board, threads, thread_cursor is not None) 221 elif state == "news": 222 await show_news(writer, bbs.news) 223 224 # Thread state renders inline below — prompt only 225 if state == "thread": 226 await show_thread_prompt( 227 writer, reply_result.page < reply_result.total_pages 228 ) 229 230 cmd = await prompt(reader, writer) 231 if not cmd or cmd == "q": 232 break 233 234 if state == "bbs": 235 if cmd == "n" and bbs.news: 236 state = "news" 237 elif cmd.isdigit(): 238 idx = int(cmd) - 1 239 if 0 <= idx < len(bbs.site.boards): 240 board = bbs.site.boards[idx] 241 try: 242 threads, thread_cursor = await hydrate_threads( 243 client, bbs, board 244 ) 245 except Exception: 246 await write(writer, " Could not load threads.\r\n") 247 continue 248 state = "board" 249 250 elif state == "board": 251 if cmd == "b": 252 state = "bbs" 253 elif cmd == "n" and thread_cursor: 254 try: 255 threads, thread_cursor = await hydrate_threads( 256 client, bbs, board, cursor=thread_cursor 257 ) 258 except Exception: 259 await write(writer, " Could not load threads.\r\n") 260 elif cmd.isdigit(): 261 idx = int(cmd) - 1 262 if 0 <= idx < len(threads): 263 thread = threads[idx] 264 await show_thread_header(writer, thread) 265 try: 266 reply_result = await hydrate_replies( 267 client, bbs, thread.uri 268 ) 269 except Exception: 270 await write(writer, " Could not load replies.\r\n") 271 continue 272 await show_replies(writer, reply_result.replies) 273 state = "thread" 274 275 elif state == "thread": 276 if cmd == "b": 277 state = "board" 278 elif cmd == "n" and reply_result.page < reply_result.total_pages: 279 try: 280 reply_result = await hydrate_replies( 281 client, bbs, thread.uri, page=reply_result.page + 1 282 ) 283 await show_replies(writer, reply_result.replies) 284 except Exception: 285 await write(writer, " Could not load replies.\r\n") 286 287 elif state == "news": 288 if cmd == "b": 289 state = "bbs" 290 291 await write(writer, "\r\n Goodbye!\r\n") 292 writer.close() 293 294 295async def main(host: str = "0.0.0.0", port: int = 2323): 296 server = await asyncio.start_server(handle_client, host, port, limit=256) 297 print(f"Telnet BBS gateway listening on {host}:{port}") 298 async with server: 299 await server.serve_forever() 300 301 302if __name__ == "__main__": 303 asyncio.run(main())