Retro Bulletin Board Systems on atproto. Web app and TUI.
lazy mirror of alyraffauf/atbbs
atbbs.xyz
forums
python
tui
atproto
bbs
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())