new framework

This commit is contained in:
2026-05-12 22:36:19 +02:00
parent d92768dd23
commit 9d52a2e2c6
29 changed files with 1240 additions and 400 deletions

View File

@@ -1,8 +1,8 @@
GIT GIT
remote: https://git.teletype.hu/tools/rubbs.git remote: https://git.teletype.hu/tools/rubbs.git
revision: be21826eccc8eb2930b7a83a97b8ed9672219a9c revision: 9ae939a213d4cf45140f77ac3baacfa061dde2fb
specs: specs:
bbs (0.3.0) bbs (0.4.0)
artii (~> 2.1) artii (~> 2.1)
GEM GEM

103
README.md
View File

@@ -1,23 +1,26 @@
# Teletype BBS Server # Teletype BBS Server
Telnet-accessible community BBS server built on the [rubbs](https://git.teletype.hu/tools/rubbs) gem. ANSI-rendered retro terminal interface with a message board, wiki integration, and a game catalog. Telnet-accessible Synchronet-style community BBS server built on the
[rubbs](https://git.teletype.hu/tools/rubbs) gem. Full-screen ANSI interface
with a top menubar, stacked modal windows, message boards, multi-user chat,
wiki integration, and a game catalog.
## Running ## Running
**Docker Compose (recommended):** **Docker Compose:**
```bash ```bash
docker compose up --build docker compose up --build
``` ```
**Directly (requires Ruby 3.x):** **Directly (Ruby 3.x):**
```bash ```bash
bundle install bundle install
ruby bbs.rb ruby bbs.rb
``` ```
Connect with: Connect:
```bash ```bash
telnet localhost 2323 telnet localhost 2323
@@ -25,39 +28,91 @@ telnet localhost 2323
## Configuration ## Configuration
Copy `env-example` to `.env` and fill in the values: Copy `env-example` to `.env`:
| Variable | Default | Description | | Variable | Default | Description |
|---|---|---| |---|---|---|
| `WEBAPP_WIKIJS_TOKEN` | — | Bearer token for Wiki.js API (optional) | | `BBS_PORT` | `2323` | TCP port to listen on |
| `MESSAGES_PATH` | `data/messages.dat` | Path to the messages CSV file | | `BBS_SYSOPS` | empty | Comma-separated list of usernames with sysop access |
| `WEBAPP_WIKIJS_TOKEN` | — | Bearer token for the Wiki.js GraphQL API |
| `BOARDS_PATH` | `data/boards` | Directory of per-board CSV files |
| `MESSAGES_PATH` | `data/messages.dat` | Legacy single-board file; auto-migrated into the General board on first run |
| `LAST_CALLERS_PATH` | `data/last_callers.csv` | Login log CSV |
| `PROFILE_PATH` | `data/profiles.csv` | User profile CSV |
| `BBS_DESKTOP_ART` | `data/art` | Path to a single ANSI art file (`.ans`, `.ansi`, `.asc`, `.nfo`, `.txt`) **or** a directory containing them — a random one is shown per session. CP437 / SAUCE-aware |
## Menu ## Interface
| Option | Description | Connect, type a name at the prompt, then a full-screen Synchronet-style shell
opens.
### Keys
| Key | Action |
|---|---| |---|---|
| Message Board | View the last 30 messages | | `Alt+letter` | Open the matching top menu |
| New Message | Post a message (max 200 chars) | | `F10` | Open the leftmost menu |
| Blog Posts | Browse blog entries from Wiki.js | | `Tab` / `Shift-Tab` | Move focus inside a window |
| HowTo Guides | Browse how-to articles from Wiki.js | | `Enter` | Activate the focused button / submit input |
| Game Catalog | Browse the Teletype game catalog | | `Esc` | Close the top window / menu |
| Online Users | List of currently connected users | | `F1` | Help |
| System Info | Server stats | | `F2` | Message Boards |
| `F3` | Quick post |
| `F4` | Chat |
### Menus
| Menu | Items |
|---|---|
| **File** | Bulletin · System Info · Exit |
| **Messages** | Boards… · New Post… |
| **Files** | Blog Posts · HowTo Guides · Game Catalog |
| **Users** | Online · Last Callers · Profile… |
| **Chat** | Open chat |
| **Sysop** | Console (sysops only) |
| **Help** | About · Keys |
### Features
- **Multi-board messaging** — separate sub-boards (General / Tech / Off-Topic / Sysop), each in its own CSV file under `data/boards/`.
- **Multi-line composer** — `TextArea` widget, up to 1000 chars per post.
- **Live chat** — broadcast hub, 1 Hz polling, history replay on join.
- **Wiki split-view** — list of titles on the left, selected page rendered on the right.
- **Game card** — selected game's story + external links.
- **Last callers** + **online users** + **bulletin on login** (last callers, message count).
- **Persistent user profile** — signature / location / homepage / notes per username.
- **Sysop console** — stats + broadcast (only for users in `BBS_SYSOPS`).
- **xterm mouse** — click menus, buttons, list items; wheel-scroll lists.
- **Idle disconnect** at 10 minutes.
- **Configurable desktop art** — drop `.ans` / `.ansi` / `.asc` / `.nfo` / `.txt` files into `data/art/` (or wherever `BBS_DESKTOP_ART` points). Classic CP437 BBS art is auto-decoded; SAUCE metadata is stripped. With a directory, a different file is shown per session.
## Project structure ## Project structure
``` ```
bbs.rb Entry point — BBS configuration and flow definition bbs.rb entry point — services + Application
lib/ lib/
online_users.rb Thread-safe connected-users map repository/
message_board.rb CSV-backed message store online_users_repository.rb thread-safe connected-user map
wiki.rb Wiki.js GraphQL client board_repository.rb multi-board CSV store
catalog.rb Games API client wiki_repository.rb Wiki.js GraphQL client
display.rb ANSI rendering helpers and content handlers catalog_repository.rb games API client
last_callers_repository.rb login log
profile_repository.rb per-user persistent profile
service/
online_service.rb, board_service.rb, wiki_service.rb,
games_service.rb, chat_service.rb, last_callers_service.rb,
profile_service.rb
model/ value objects (MessageModel, GameModel, …)
ui/windows/ per-window builders (messages, wiki,
games, users, chat, bulletin, profile,
sysop, system info)
data/ data/
messages.dat Message board records (auto-created) boards/<id>.csv per-board messages (auto-created)
last_callers.csv login log
profiles.csv persistent user profiles
``` ```
## Data ## Data
Messages are stored in `data/messages.dat` (plain CSV, auto-created on first post). The `data/` directory is mounted as a Docker volume so records survive container restarts. All persisted state lives under `data/` which is mounted as a volume in Docker
so it survives container restarts.

337
bbs.rb
View File

@@ -2,231 +2,168 @@
require 'bbs' require 'bbs'
require 'time' require 'time'
require_relative 'lib/repository/online_users_repository' require_relative 'lib/repository/online_users_repository'
require_relative 'lib/repository/message_board_repository' require_relative 'lib/repository/board_repository'
require_relative 'lib/repository/wiki_repository' require_relative 'lib/repository/wiki_repository'
require_relative 'lib/repository/catalog_repository' require_relative 'lib/repository/catalog_repository'
require_relative 'lib/repository/last_callers_repository'
require_relative 'lib/repository/profile_repository'
require_relative 'lib/service/online_service' require_relative 'lib/service/online_service'
require_relative 'lib/service/message_service' require_relative 'lib/service/board_service'
require_relative 'lib/service/profile_service'
require_relative 'lib/service/wiki_service' require_relative 'lib/service/wiki_service'
require_relative 'lib/service/games_service' require_relative 'lib/service/games_service'
require_relative 'lib/tui/helpers' require_relative 'lib/service/chat_service'
require_relative 'lib/service/last_callers_service'
require_relative 'lib/ui/windows/system_info_window'
require_relative 'lib/ui/windows/messages_window'
require_relative 'lib/ui/windows/wiki_window'
require_relative 'lib/ui/windows/games_window'
require_relative 'lib/ui/windows/users_window'
require_relative 'lib/ui/windows/chat_window'
require_relative 'lib/ui/windows/bulletin_window'
require_relative 'lib/ui/windows/profile_window'
require_relative 'lib/ui/windows/sysop_window'
ONLINE = OnlineService.new(OnlineUsersRepository.new) ONLINE = OnlineService.new(OnlineUsersRepository.new)
MESSAGES = MessageService.new(MessageBoardRepository.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat'))) BOARDS = BoardService.new(BoardRepository.new(
ENV.fetch('BOARDS_PATH', 'data/boards'),
legacy_path: ENV.fetch('MESSAGES_PATH', 'data/messages.dat')))
WIKI = WikiService.new(WikiRepository.new(token: ENV['WEBAPP_WIKIJS_TOKEN'])) WIKI = WikiService.new(WikiRepository.new(token: ENV['WEBAPP_WIKIJS_TOKEN']))
GAMES = GamesService.new(CatalogRepository.new) GAMES = GamesService.new(CatalogRepository.new)
CHAT = ChatService.new
LAST_CALLERS = LastCallersService.new(LastCallersRepository.new(
ENV.fetch('LAST_CALLERS_PATH', 'data/last_callers.csv')))
PROFILE = ProfileService.new(ProfileRepository.new(
ENV.fetch('PROFILE_PATH', 'data/profiles.csv')))
MENU_ITEMS = [ STARTED_AT = Time.now
'Messages', 'Post Message', 'Blog Posts', SYSOPS = ENV.fetch('BBS_SYSOPS', '').split(',').map { |s| s.strip.downcase }.reject(&:empty?)
'HowTo Guides', 'Game Catalog', 'Online Users', DESKTOP_ART = ENV.fetch('BBS_DESKTOP_ART', 'data/art')
'System Info', 'Exit'
].freeze
LEFT_WIDTH = 21 SERVICES = {
CONTENT_X = 23 online: ONLINE,
CONTENT_Y = 2 boards: BOARDS,
wiki: WIKI,
THEME = { games: GAMES,
bg: "\e[0;40m", chat: CHAT,
normal: "\e[0;33m", last_callers: LAST_CALLERS,
bright: "\e[1;33m", profile: PROFILE,
border: "\e[0;33m", uptime: STARTED_AT,
selected: "\e[0;30;43m",
header: "\e[1;33m",
label: "\e[2;33m",
status: "\e[0;30;43m",
input: "\e[1;33m",
success: "\e[1;32m",
error: "\e[1;31m",
reset: "\e[0m",
}.freeze }.freeze
MAIN_TUI = BBS::TUI.define do MAIN_APP = BBS::Application.define do
helpers { include TUIHelpers } theme BBS::Theme.synchronet
helpers do
def sysop?
name = (context[:username] || '').downcase
SYSOPS.include?(name)
end
end
menubar do
menu '&File' do
item '&Bulletin' do UI::Windows.bulletin(self, services: SERVICES) end
item '&System Info' do UI::Windows.system_info(self, services: SERVICES) end
separator
item 'E&xit', shortcut: 'Alt-X' do :halt end
end
menu '&Messages' do
item '&Boards…', shortcut: 'F2' do UI::Windows.messages(self, services: SERVICES) end
item '&New Post…', shortcut: 'F3' do
board = SERVICES[:boards].boards.first
UI::Windows.compose_message(self, services: SERVICES, board: board)
end
end
menu '&Files' do
item '&Blog Posts' do UI::Windows.wiki(self, services: SERVICES, category: 'blog', title: 'Blog Posts') end
item '&HowTo Guides' do UI::Windows.wiki(self, services: SERVICES, category: 'howto', title: 'HowTo Guides') end
item '&Game Catalog' do UI::Windows.games(self, services: SERVICES) end
end
menu '&Users' do
item '&Online' do UI::Windows.online_users(self, services: SERVICES) end
item '&Last Callers' do UI::Windows.last_callers(self, services: SERVICES) end
item '&Profile…' do UI::Windows.profile(self, services: SERVICES) end
end
menu '&Chat' do
item '&Open chat', shortcut: 'F4' do UI::Windows.chat(self, services: SERVICES) end
end
menu 'S&ysop' do
item '&Console', shortcut: 'F10' do
name = (context[:username] || '').downcase
if SYSOPS.include?(name)
UI::Windows.sysop_console(self, services: SERVICES)
else
BBS::Dialogs.message(self, 'Sysop access denied.', title: ' Sysop ')
end
end
end
menu '&Help' do
item '&About' do
BBS::Dialogs.message(self,
'Teletype BBS — telnet community board powered by rubbs. ' \
'Use the top menu (or Alt+letter), Tab to move focus, Esc to close windows.',
title: ' About ', width: 64)
end
item '&Keys' do
BBS::Dialogs.message(self,
'Alt+letter — open menu · Tab/Shift-Tab — move focus · Enter — activate · ' \
'Esc — close window · F1 Help · F2 Boards · F3 Post · F4 Chat · F10 Sysop',
title: ' Keys ', width: 70)
end
end
end
status_hint('F1', 'Help') { BBS::Dialogs.message(self, 'Top menu: Alt+letter — Windows: Tab/Shift-Tab — Close: Esc') }
status_hint('F2', 'Boards') { UI::Windows.messages(self, services: SERVICES) }
status_hint('F3', 'Post') {
board = SERVICES[:boards].boards.first
UI::Windows.compose_message(self, services: SERVICES, board: board)
}
status_hint('F4', 'Chat') { UI::Windows.chat(self, services: SERVICES) }
status_hint('F10', 'Menu') { menubar.open(0) }
status_hint('Alt-X', 'Exit') { :halt }
init do init do
@menu_selection = 0 art = if File.directory?(DESKTOP_ART)
@page_title = 'TELETYPE BBS' BBS::Widgets::AnsiArt.random(DESKTOP_ART)
@items = [] elsif File.file?(DESKTOP_ART)
@item_selection = 0 BBS::Widgets::AnsiArt.from_file(DESKTOP_ART)
@scroll = 0 else
@detail = [] BBS::Widgets::AnsiArt.new(lines: BBS::Widgets::AnsiArt.default_lines)
@page_metadata = +''
@input = +''
@status = +''
@previous_page = nil
@modal = nil
end end
art.layout(1, 1, cols, rows - 2)
chrome { render_chrome } add(art)
page :idle do
enter { @page_title = 'TELETYPE BBS'; @modal = nil }
render do
text 'Navigate the menu with ↑↓, select with Enter.',
x: CONTENT_X, y: CONTENT_Y + content_height / 2, style: THEME[:normal]
if @modal == :sysinfo
float title: ' SYSTEM INFO ', width: 56, height: 10,
style: THEME[:border], background: :black do |fx, fy, fw, _|
[
"Online users #{ONLINE.count}",
"Messages #{MESSAGES.count}",
"Wiki https://wiki.teletypegames.org",
"Games API https://teletypegames.org",
"Platform #{RUBY_PLATFORM}",
"Ruby #{RUBY_VERSION}",
].each_with_index do |row, index|
text row[0, fw], x: fx, y: fy + index, style: THEME[:normal]
end end
text 'ESC close', x: fx, y: fy + 7, style: THEME[:label]
end
elsif @modal == :new_msg
float title: ' POST MESSAGE ', width: 62, height: 9,
style: THEME[:border], background: :black do |fx, fy, fw, _|
text 'Type your message and press Enter. Esc cancels.',
x: fx, y: fy, style: THEME[:normal]
text "#{themed_color(:bright, @username)}: #{themed_color(:input, @input)}_",
x: fx, y: fy + 2
text @status, x: fx, y: fy + 4, style: THEME[:error] unless @status.empty?
text "#{@input.length}/200 ESC cancel",
x: fx, y: fy + 6, style: THEME[:label]
end
end
end
nav :menu, size: MENU_ITEMS.size
key(:enter) do
if @modal == :new_msg
case MESSAGES.post(@username, @input)
when :empty then @status = 'Message cannot be empty.'
when :ok then @modal = nil
end
elsif @modal.nil?
case MENU_ITEMS[@menu_selection]
when 'Messages' then go :messages
when 'Post Message' then @modal = :new_msg; @input = +''; @status = +''
when 'Blog Posts' then go :blog
when 'HowTo Guides' then go :howto
when 'Game Catalog' then go :games
when 'Online Users' then go :online
when 'System Info' then @modal = :sysinfo
when 'Exit' then :halt
end
end
end
key(:backspace) { @input.chop! if @modal == :new_msg && !@input.empty? }
key('q') { @modal ? @modal = nil : :halt }
key(:escape) { @modal ? @modal = nil : :halt }
printable { |ch| @input << ch if @modal == :new_msg && @input.length < 200 }
end
page :messages do
enter do
@page_title = 'MESSAGES'
@items = MESSAGES.recent(30)
@scroll = 0
end
render { render_messages }
nav :scroll, content: -> { @items }, window: -> { content_height }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :blog do
enter do
@page_title = 'BLOG'
@items = WIKI.list('blog')
@item_selection = 0
@scroll = 0
end
render { render_wiki_list }
nav :list, window: -> { content_height - 3 }
key(:enter) do
next unless (wiki_page = @items[@item_selection])
@detail = wordwrap(WIKI.content(wiki_page.id), content_width)
@page_title = wiki_page.title
@page_metadata = "#{format_date(wiki_page.created_at)} " \
"#{WIKI.page_url(wiki_page.locale, wiki_page.path)}"
@previous_page = :blog
@scroll = 0
go :page_view
end
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :howto do
enter do
@page_title = 'HOWTO GUIDES'
@items = WIKI.list('howto')
@item_selection = 0
@scroll = 0
end
render { render_wiki_list }
nav :list, window: -> { content_height - 3 }
key(:enter) do
next unless (wiki_page = @items[@item_selection])
@detail = wordwrap(WIKI.content(wiki_page.id), content_width)
@page_title = wiki_page.title
@page_metadata = "#{format_date(wiki_page.created_at)} " \
"#{WIKI.page_url(wiki_page.locale, wiki_page.path)}"
@previous_page = :howto
@scroll = 0
go :page_view
end
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :page_view do
render { render_page_view }
nav :scroll, content: -> { @detail }, window: -> { content_height - 2 }
key('q') { @scroll = 0; go(@previous_page || :idle) }
key(:escape) { @scroll = 0; go(@previous_page || :idle) }
end
page :games do
enter do
@page_title = 'GAME CATALOG'
@items = GAMES.all
@item_selection = 0
end
render { render_game_card }
nav :cycle
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
page :online do
enter do
@page_title = 'ONLINE USERS'
@items = ONLINE.user_list
end
render { render_online_users }
key('r') { reload }
key('q') { go :idle }
key(:escape) { go :idle }
end
start :idle
end end
BBS.configure do |c| BBS.configure do |c|
c.on_session_end = ->(session) { ONLINE.remove(session.session_id) } c.mouse = true
c.idle_seconds = 600
c.on_session_end = lambda do |session|
ONLINE.remove(session.session_id)
CHAT.unsubscribe(session.session_id)
end
c.flow = BBS::Flow.define do c.flow = BBS::Flow.define do
big_banner 'TELETYPE BBS', style: :success big_banner 'TELETYPE BBS', style: :success
ask :username, prompt: 'Name (blank for Anonymous)', ask :username, prompt: 'Name (blank for Anonymous)',
transform: ->(value) { value.strip.empty? ? 'Anonymous' : value.strip[0...20] } transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] }
call { |ctx| ONLINE.add(ctx[:session_id], ctx[:username]) } call do |ctx|
tui MAIN_TUI ONLINE.add(ctx[:session_id], ctx[:username])
CHAT.subscribe(ctx[:session_id])
LAST_CALLERS.record(ctx[:username], ctx[:session_id])
end
app MAIN_APP
say 'Goodbye!', style: :muted say 'Goodbye!', style: :muted
end end
end end

11
data/art/cybr.ansi Normal file
View File

@@ -0,0 +1,11 @@
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░ █▀▀ █▄█ █▄▄ █▀▀ █▀█ █▀█ █▀█ █▀█ █▀▀ █▄ █ █▀█ █▄ █ █░
░ █▄▄ █ █▄█ ██▄ █▀▄ █▀ █▄█ █▀ █▄▄ █ ▀█ █▄█ █ ▀█ ░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
// JACK IN. DROP CARRIER. STAY A WHILE.
// online users: live
// message log: active
// carrier: 2400 baud

13
data/art/synchronet.ansi Normal file
View File

@@ -0,0 +1,13 @@
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀██
██ ♦ ♦ ♦ T E L E T Y P E B B S ♦ ♦ ♦ ██
██▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄██
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
┌──────────────────────────┐
│ retro telnet community │
│ node up since 2024  │
└──────────────────────────┘
>> press F10 for the menu, Esc to close windows

20
data/art/teletype.ansi Normal file
View File

@@ -0,0 +1,20 @@
 ╔══════════════════════════════════════════════════════════════╗
 ║ ║
 ║ ██╗████████╗███████╗██╗ ███████╗████████╗██╗ ██╗ ║
 ║ ╚═╝╚══██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝ ║
 ║ ██╗ ██║ █████╗ ██║ █████╗ ██║ ╚████╔╝  ║
 ║ ██║ ██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝  ║
 ║ ██║ ██║ ███████╗███████╗███████╗ ██║ ██║  ║
 ║ ╚═╝ ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝  ║
 ║ ║
 ║ · · · B · B · S · · · ║
 ║ ║
 ║ ── an open call to old friends, est. 2024 ── ║
 ║ ║
 ╚══════════════════════════════════════════════════════════════╝
F10 open the top menu F1 help
F2 message boards F4 live chat
Alt-X exit Esc close window

8
data/boards/general.csv Normal file
View File

@@ -0,0 +1,8 @@
04-28 20:52,Tasi,hello
04-30 08:29,Tasi,fsafd
04-30 08:39,1,3rfdf
05-11 20:03,Anonymous,fdsdsds
05-11 20:08,Anonymous,dsadsa
05-11 20:10,Anonymous,dsadsa
05-12 18:24,l,fdsadsa
05-12 18:35,Anonymous,kmlknml
1 04-28 20:52 Tasi hello
2 04-30 08:29 Tasi fsafd
3 04-30 08:39 1 3rfdf
4 05-11 20:03 Anonymous fdsdsds
5 05-11 20:08 Anonymous dsadsa
6 05-11 20:10 Anonymous dsadsa
7 05-12 18:24 l fdsadsa
8 05-12 18:35 Anonymous kmlknml

33
data/last_callers.csv Normal file
View File

@@ -0,0 +1,33 @@
2026-05-12T20:20:51+00:00,Anonymous,e77f7a460d7654f4
2026-05-12T22:27:17+02:00,Viewer,f5f8bbf84638ea60
2026-05-12T22:28:22+02:00,Viewer,f48020e07e93c8a3
2026-05-12T22:29:26+02:00,U1,c75947c5260f2c54
2026-05-12T22:29:26+02:00,U2,2c112b1914aef3cd
2026-05-12T22:29:27+02:00,U3,3d4a80e19da61eb2
2026-05-12T22:29:27+02:00,U4,0dd454311ec38b09
2026-05-12T22:29:28+02:00,U5,e27ba4422e79801c
2026-05-12T22:29:29+02:00,U6,f069d85c8d3ea877
2026-05-12T22:29:29+02:00,U7,5d988b9d6eab7a9d
2026-05-12T22:29:30+02:00,U8,1f136cd1951ea877
2026-05-12T22:29:56+02:00,U1,bfe6dba0fdad5af9
2026-05-12T22:29:58+02:00,U2,6348d0dfdf708bf0
2026-05-12T22:29:59+02:00,U3,03f12f1bcd3be8d2
2026-05-12T22:30:01+02:00,U4,589e5062e957fb68
2026-05-12T22:30:02+02:00,U5,ed799355445e6d0c
2026-05-12T22:30:04+02:00,U6,a8361d060218f425
2026-05-12T22:30:05+02:00,U7,6bdeff9bd5710204
2026-05-12T22:30:07+02:00,U8,39eb19df6e0f07fb
2026-05-12T22:30:08+02:00,U9,31c72bef9e926b0d
2026-05-12T22:30:10+02:00,U10,b08e0b92c7462227
2026-05-12T20:31:16+00:00,Anonymous,06f53a51c2b7c59e
2026-05-12T22:33:25+02:00,Tester,e017950dedfea152
2026-05-12T22:33:26+02:00,Tester,8d3a7070a652e5d1
2026-05-12T22:34:19+02:00,Tester,bec5cf5e93ca56de
2026-05-12T22:35:06+02:00,Tester,c1208d6af2c0a9b8
2026-05-12T22:35:11+02:00,Tester,02ec7074106211a8
2026-05-12T22:36:03+02:00,Tester,8abcffd0380e1518
2026-05-12T22:36:36+02:00,Tester,8bb755ffbeb2b2d4
2026-05-12T22:38:18+02:00,Tester,2f7cd3777479424c
2026-05-12T22:38:19+02:00,Tester,d8c26f8f4ee89aba
2026-05-12T22:38:21+02:00,Tester,99491903edd101ed
2026-05-12T22:38:21+02:00,Tester,3a22507ba98e63a6
1 2026-05-12T20:20:51+00:00 Anonymous e77f7a460d7654f4
2 2026-05-12T22:27:17+02:00 Viewer f5f8bbf84638ea60
3 2026-05-12T22:28:22+02:00 Viewer f48020e07e93c8a3
4 2026-05-12T22:29:26+02:00 U1 c75947c5260f2c54
5 2026-05-12T22:29:26+02:00 U2 2c112b1914aef3cd
6 2026-05-12T22:29:27+02:00 U3 3d4a80e19da61eb2
7 2026-05-12T22:29:27+02:00 U4 0dd454311ec38b09
8 2026-05-12T22:29:28+02:00 U5 e27ba4422e79801c
9 2026-05-12T22:29:29+02:00 U6 f069d85c8d3ea877
10 2026-05-12T22:29:29+02:00 U7 5d988b9d6eab7a9d
11 2026-05-12T22:29:30+02:00 U8 1f136cd1951ea877
12 2026-05-12T22:29:56+02:00 U1 bfe6dba0fdad5af9
13 2026-05-12T22:29:58+02:00 U2 6348d0dfdf708bf0
14 2026-05-12T22:29:59+02:00 U3 03f12f1bcd3be8d2
15 2026-05-12T22:30:01+02:00 U4 589e5062e957fb68
16 2026-05-12T22:30:02+02:00 U5 ed799355445e6d0c
17 2026-05-12T22:30:04+02:00 U6 a8361d060218f425
18 2026-05-12T22:30:05+02:00 U7 6bdeff9bd5710204
19 2026-05-12T22:30:07+02:00 U8 39eb19df6e0f07fb
20 2026-05-12T22:30:08+02:00 U9 31c72bef9e926b0d
21 2026-05-12T22:30:10+02:00 U10 b08e0b92c7462227
22 2026-05-12T20:31:16+00:00 Anonymous 06f53a51c2b7c59e
23 2026-05-12T22:33:25+02:00 Tester e017950dedfea152
24 2026-05-12T22:33:26+02:00 Tester 8d3a7070a652e5d1
25 2026-05-12T22:34:19+02:00 Tester bec5cf5e93ca56de
26 2026-05-12T22:35:06+02:00 Tester c1208d6af2c0a9b8
27 2026-05-12T22:35:11+02:00 Tester 02ec7074106211a8
28 2026-05-12T22:36:03+02:00 Tester 8abcffd0380e1518
29 2026-05-12T22:36:36+02:00 Tester 8bb755ffbeb2b2d4
30 2026-05-12T22:38:18+02:00 Tester 2f7cd3777479424c
31 2026-05-12T22:38:19+02:00 Tester d8c26f8f4ee89aba
32 2026-05-12T22:38:21+02:00 Tester 99491903edd101ed
33 2026-05-12T22:38:21+02:00 Tester 3a22507ba98e63a6

1
data/profiles.csv Normal file
View File

@@ -0,0 +1 @@
session_id,timestamp,username,signature,location,homepage,notes
1 session_id timestamp username signature location homepage notes

View File

@@ -1,2 +1,10 @@
WEBAPP_WIKIJS_TOKEN= WEBAPP_WIKIJS_TOKEN=
BOARDS_PATH=data/boards
MESSAGES_PATH=data/messages.dat MESSAGES_PATH=data/messages.dat
LAST_CALLERS_PATH=data/last_callers.csv
PROFILE_PATH=data/profiles.csv
BBS_SYSOPS=
BBS_PORT=2323
# BBS_DESKTOP_ART — single .ans/.ansi/.txt file (path) or a directory of them
# (a random one is shown per session). Defaults to data/art/.
BBS_DESKTOP_ART=data/art

View File

@@ -0,0 +1,83 @@
# frozen_string_literal: true
require 'csv'
require 'time'
require 'fileutils'
require_relative '../model/message_model'
# Multi-board store. Each board ("general", "tech", "offtopic"…) maps to one
# CSV file inside +base_dir+. Lazy-loaded the first time a board is touched.
class BoardRepository
BoardMeta = Struct.new(:id, :title, :description)
DEFAULT_BOARDS = [
BoardMeta.new('general', 'General', 'Open chat, anything goes'),
BoardMeta.new('tech', 'Tech', 'Hardware, software, code'),
BoardMeta.new('offtopic', 'Off-Topic','Random topics, jokes'),
BoardMeta.new('sysop', 'Sysop', 'Notes from the operator'),
].freeze
attr_reader :boards
def initialize(base_dir, boards: DEFAULT_BOARDS, legacy_path: nil)
@base_dir = base_dir
@boards = boards
@cache = {}
@mu = Mutex.new
FileUtils.mkdir_p(@base_dir)
migrate_legacy!(legacy_path) if legacy_path
end
def append(board_id, username, text)
msg = MessageModel.new(Time.now.strftime('%m-%d %H:%M'), username, text)
@mu.synchronize do
load_board(board_id) << msg
CSV.open(path_for(board_id), 'a') { |csv| csv << [msg.timestamp, msg.username, msg.text] }
end
msg
end
def last(board_id, n = 30)
@mu.synchronize { load_board(board_id).last(n) }
end
def count(board_id)
@mu.synchronize { load_board(board_id).size }
end
def total_count
@mu.synchronize { @boards.sum { |b| load_board(b.id).size } }
end
private
def migrate_legacy!(legacy_path)
return unless File.exist?(legacy_path)
target = path_for('general')
return if File.exist?(target) # already migrated or general has its own data
rows = []
CSV.foreach(legacy_path) { |row| rows << row }
return if rows.empty?
CSV.open(target, 'w') { |csv| rows.each { |r| csv << r } }
warn "BoardRepository: migrated #{rows.size} legacy messages " \
"from #{legacy_path} to #{target}"
rescue StandardError => e
warn "BoardRepository: legacy migration failed: #{e}"
end
def path_for(id)
File.join(@base_dir, "#{id}.csv")
end
def load_board(id)
return @cache[id] if @cache.key?(id)
list = []
if File.exist?(path_for(id))
CSV.foreach(path_for(id)) { |row| list << MessageModel.new(*row) }
end
@cache[id] = list
rescue StandardError => e
warn "BoardRepository load error (#{id}): #{e}"
@cache[id] = []
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'csv'
require 'time'
require 'fileutils'
# Append-only log of the last N logins. Stored as CSV so it survives restarts.
class LastCallersRepository
Caller = Struct.new(:timestamp, :username, :session_id)
def initialize(path, limit: 50)
@path = path
@limit = limit
@mu = Mutex.new
@entries = load
end
def record(username, session_id)
entry = Caller.new(Time.now.iso8601, username, session_id)
@mu.synchronize do
@entries << entry
@entries.shift while @entries.size > @limit
FileUtils.mkdir_p(File.dirname(@path))
CSV.open(@path, 'a') { |csv| csv << [entry.timestamp, entry.username, entry.session_id] }
end
entry
end
def recent(n = 10)
@mu.synchronize { @entries.last(n).reverse }
end
private
def load
return [] unless File.exist?(@path)
CSV.read(@path).map { |row| Caller.new(*row) }
rescue StandardError
[]
end
end

View File

@@ -1,42 +0,0 @@
# frozen_string_literal: true
require 'csv'
require 'time'
require 'fileutils'
require_relative '../model/message_model'
class MessageBoardRepository
def initialize(path)
@path = path
@messages = []
@mu = Mutex.new
load_csv
end
def append(username, text)
msg = MessageModel.new(Time.now.strftime('%m-%d %H:%M'), username, text)
@mu.synchronize do
@messages << msg
FileUtils.mkdir_p(File.dirname(@path))
CSV.open(@path, 'a') { |csv| csv << [msg.timestamp, msg.username, msg.text] }
end
msg
end
def last(n = 30)
@mu.synchronize { @messages.last(n) }
end
def count
@mu.synchronize { @messages.size }
end
private
def load_csv
return unless File.exist?(@path)
CSV.foreach(@path) { |row| @messages << MessageModel.new(*row) }
rescue => e
warn "MessageBoardRepository load error: #{e}"
end
end

View File

@@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'bbs'
# Persistent user profile, keyed by lower-cased username. Backed by a
# BBS::Store CSV at +path+.
class ProfileRepository
HEADERS = %w[session_id timestamp username signature location homepage notes].freeze
def initialize(path)
@store = BBS::Store.new(path: path, headers: HEADERS)
@by_name = {}
@mu = Mutex.new
rebuild
end
def upsert(username:, **fields)
return if username.to_s.strip.empty?
key = username.downcase
@mu.synchronize do
session_id = (@by_name[key] && @by_name[key]['session_id']) || "user-#{key}"
@store.upsert(session_id: session_id, username: username, **fields)
rebuild_locked
end
end
def find(username)
@mu.synchronize { @by_name[username.to_s.downcase] }
end
private
def rebuild
@mu.synchronize { rebuild_locked }
end
def rebuild_locked
@by_name = @store.all.each_with_object({}) do |row, h|
name = row['username'].to_s
h[name.downcase] = row unless name.empty?
end
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
class BoardService
def initialize(repo) = @repo = repo
def boards = @repo.boards
def board(id) = @repo.boards.find { |b| b.id == id }
def messages(id, n = 30) = @repo.last(id, n)
def count(id) = @repo.count(id)
def total_count = @repo.total_count
def post(board_id, username, text)
return :empty if text.to_s.strip.empty?
@repo.append(board_id, username, text.strip[0...1000])
:ok
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
# Tiny in-memory chat hub. Each session subscribes to receive broadcast
# messages from other sessions. Backed by a per-session Queue so the
# Application's idle tick can drain pending messages.
class ChatService
ChatLine = Struct.new(:timestamp, :username, :text)
def initialize
@subs = {}
@mu = Mutex.new
@history = []
@history_limit = 200
end
def subscribe(session_id)
@mu.synchronize do
@subs[session_id] = Queue.new
@history.last(50).each { |line| @subs[session_id] << line }
end
end
def unsubscribe(session_id)
@mu.synchronize { @subs.delete(session_id) }
end
def broadcast(username, text)
line = ChatLine.new(Time.now.strftime('%H:%M:%S'), username, text)
@mu.synchronize do
@history << line
@history.shift while @history.size > @history_limit
@subs.each_value { |q| q << line }
end
line
end
# Returns Array<ChatLine> of pending messages for the given session.
def drain(session_id)
queue = @mu.synchronize { @subs[session_id] }
return [] unless queue
pending = []
pending << queue.pop(true) while !queue.empty?
pending
rescue ThreadError
pending
end
def history(n = 50)
@mu.synchronize { @history.last(n).dup }
end
def subscriber_count
@mu.synchronize { @subs.size }
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class LastCallersService
def initialize(repo) = @repo = repo
def record(username, session_id) = @repo.record(username, session_id)
def recent(n = 10) = @repo.recent(n)
end

View File

@@ -1,14 +0,0 @@
# frozen_string_literal: true
class MessageService
def initialize(repo) = @repo = repo
def recent(count = 30) = @repo.last(count)
def count = @repo.count
def post(username, text)
return :empty if text.strip.empty?
@repo.append(username, text.strip[0...200])
:ok
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
class ProfileService
def initialize(repo) = @repo = repo
def find(username) = @repo.find(username)
def update(username, **f) = @repo.upsert(username: username, **f)
end

View File

@@ -1,114 +0,0 @@
# frozen_string_literal: true
module TUIHelpers
# ── Layout ───────────────────────────────────────────────────────────────────
def content_width = term_cols - CONTENT_X - 1
def content_height = term_rows - 4
# ── Theming ──────────────────────────────────────────────────────────────────
def themed_color(style, text)
"#{THEME.fetch(style, THEME[:normal])}#{text}#{THEME[:reset]}"
end
def format_date(iso_string)
Time.parse(iso_string.to_s).strftime('%Y-%m-%d')
rescue
iso_string.to_s
end
# ── Chrome ───────────────────────────────────────────────────────────────────
def render_chrome
screen_fill background: :black
panel_height = term_rows - 1
box x: 1, y: 1, width: LEFT_WIDTH, height: panel_height,
style: THEME[:border], background: :black
box x: LEFT_WIDTH + 1, y: 1, width: term_cols - LEFT_WIDTH, height: panel_height,
title: " #{@page_title}#{@username}#{ONLINE.count} online ",
style: THEME[:border], background: :black
list MENU_ITEMS.map { |item| item[0, LEFT_WIDTH - 4] },
x: 2, y: CONTENT_Y,
selected: @menu_selection,
style: THEME[:normal],
highlight: THEME[:selected]
bar y: term_rows,
content: " \e[1;37;43m↑↓\e[0;30;43m Navigate \e[1;37;43mEnter\e[0;30;43m Select " \
"\e[1;37;43mQ\e[0;30;43m Back \e[1;37;43mR\e[0;30;43m Refresh",
style: THEME[:status]
end
# ── Page renderers ────────────────────────────────────────────────────────────
def render_messages
(@items[@scroll, content_height] || []).each_with_index do |message, index|
line = "#{themed_color(:label, message.timestamp)} " \
"#{themed_color(:bright, message.username)}: #{message.text}"
text line[0, content_width], x: CONTENT_X, y: CONTENT_Y + index
end
text "#{@items.size} msg(s) ↑↓ scroll r refresh q back",
x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label]
end
def render_wiki_list
list_view @items,
x: CONTENT_X, y: CONTENT_Y,
height: content_height - 3, scroll: @scroll, selected: @item_selection,
label: ->(p) { p.title[0, content_width - 2] },
hint: ->(p) { p.description.to_s[0, content_width] },
hint_y: CONTENT_Y + content_height - 2,
style: THEME[:normal], highlight: THEME[:selected]
text '↑↓ navigate Enter read r refresh q back',
x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label]
end
def render_page_view
text @page_metadata[0, content_width], x: CONTENT_X, y: CONTENT_Y, style: THEME[:label]
(@detail[@scroll, content_height - 2] || []).each_with_index do |line, index|
text line[0, content_width], x: CONTENT_X, y: CONTENT_Y + 2 + index, style: THEME[:normal]
end
text "#{@scroll + 1}/#{@detail.size} ↑↓ scroll q back",
x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label]
end
def render_game_card
if @items.empty?
text 'No games found.', x: CONTENT_X, y: CONTENT_Y, style: THEME[:normal]
return
end
game = @items[@item_selection]
links = game.external_links
text "#{@item_selection + 1}/#{@items.size} #{game.title}"[0, content_width],
x: CONTENT_X, y: CONTENT_Y, style: THEME[:bright]
text "#{game.platform} · #{game.author}"[0, content_width],
x: CONTENT_X, y: CONTENT_Y + 1, style: THEME[:normal]
story_row_count = content_height - 4 - links.size
wordwrap(game.story, content_width).first(story_row_count).each_with_index do |line, index|
text line, x: CONTENT_X, y: CONTENT_Y + 3 + index, style: THEME[:normal]
end
link_start_row = CONTENT_Y + content_height - links.size
links.each_with_index do |link, index|
text "#{themed_color(:label, link[:label])} #{link[:url]}"[0, content_width],
x: CONTENT_X, y: link_start_row + index
end
text '↑↓ navigate r refresh q back',
x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label]
end
def render_online_users
@items.each_with_index do |username, index|
indicator = username == @username ? themed_color(:label, ' ← you') : ''
text themed_color(:bright, username) + indicator, x: CONTENT_X, y: CONTENT_Y + index
end
text "#{@items.size} user(s) online r refresh q back",
x: CONTENT_X, y: CONTENT_Y + content_height, style: THEME[:label]
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
module UI
module Windows
module_function
# Login splash: ASCII banner + last callers + new message counts. Closed
# with Enter / Esc / clicking the OK button.
def bulletin(app, services:)
width = 70
height = 17
window = BBS::Dialogs.centred_window(app, width: width, height: height,
title: ' Welcome ')
banner_lines = [
" \e[1;33m _____ _ _ ____ ____ _____\e[0m",
" \e[1;33m|_ _|__ | | | |_ __ _ _ _ ___ | __ )| __ )/ ____|\e[0m",
" \e[1;33m | |/ _ \\ | | | __|/ _` | '_/ _ \\ | _ \\| _ \\\\___ \\\e[0m",
" \e[1;33m | | __/ | |___ | |_| (_| | || __/ | |_) | |_) |___) |\e[0m",
" \e[1;33m |_|\\___| |_____|\\__|\\__,_|_| \\___| |____/|____/|____/\e[0m",
]
banner_lines.each_with_index do |line, i|
l = BBS::Widgets::Label.new(text: line, style_key: :accent)
l.layout(window.bounds.x + 1, window.bounds.y + 1 + i, width - 2, 1)
window.add(l)
end
summary = [
"Logged in as \e[1;33m#{app.context[:username] || 'Anonymous'}\e[0m",
'',
"Online now: #{services[:online].count}",
"Total messages: #{services[:boards].total_count}",
'',
'Last callers:',
]
services[:last_callers].recent(3).each do |c|
summary << " · #{c.username} \e[2;37m#{c.timestamp[0, 16].sub('T', ' ')}\e[0m"
end
summary.each_with_index do |line, i|
l = BBS::Widgets::Label.new(text: line, style_key: :window_text)
l.layout(window.bounds.x + 2, window.bounds.y + 7 + i, width - 4, 1)
window.add(l)
end
ok = BBS::Widgets::Button.new(label: '&Continue',
on_click: ->(_) { app.close_window(window) })
ok.layout(window.bounds.x + (width - 12) / 2, window.bounds.y + height - 2, 12, 1)
window.add(ok)
window.reset_focus
app.open_window(window)
window
end
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
module UI
module Windows
module_function
def chat(app, services:)
cols = app.cols
rows = app.rows
w = cols - 8
h = rows - 6
window = BBS::Window.new(title: ' Live Chat ')
window.layout(5, 3, w, h)
window.theme = app.theme
history = BBS::Widgets::ScrollView.new(lines: services[:chat].history.map { |l| format_line(l) })
history.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, h - 5)
window.add(history)
history.scroll = [history.lines.size - history.bounds.height, 0].max
input = BBS::Widgets::TextInput.new(placeholder: 'Type a message and press Enter…')
input.layout(window.bounds.x + 2, window.bounds.y + h - 3, w - 14, 1)
input.on_submit = lambda do |value, _|
next if value.strip.empty?
services[:chat].broadcast(app.context[:username] || 'Anonymous', value.strip)
input.value = ''
:handled
end
window.add(input)
close = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
close.layout(window.bounds.x + w - 11, window.bounds.y + h - 3, 9, 1)
window.add(close)
# Stash a poll lambda on the window so the app tick can pull updates
window.instance_variable_set(:@chat_poll, lambda do
new_lines = services[:chat].drain(app.session_id).map { |l| format_line(l) }
unless new_lines.empty?
history.lines = (history.lines + new_lines).last(500)
history.scroll = [history.lines.size - history.bounds.height, 0].max
end
end)
window.reset_focus
window.focus_manager.focus(input)
app.open_window(window)
window
end
def format_line(line)
" \e[2;37m#{line.timestamp}\e[0m \e[1;33m#{line.username}\e[0m: #{line.text}"
end
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
module UI
module Windows
module_function
# Game catalog browser. Left=titles, right=card with story + links.
def games(app, services:)
cols = app.cols
rows = app.rows
w = cols - 4
h = rows - 6
window = BBS::Window.new(title: ' Game Catalog ')
window.layout(3, 3, w, h)
window.theme = app.theme
games = services[:games].all
list = BBS::Widgets::ListBox.new(
items: games,
label: ->(g) { " #{g.title}".ljust(26) }
)
list_width = 30
list.layout(window.bounds.x + 2, window.bounds.y + 2, list_width, h - 5)
window.add(list)
card = BBS::Widgets::ScrollView.new(lines: [])
card_x = window.bounds.x + list_width + 4
card_w = w - list_width - 7
card.layout(card_x, window.bounds.y + 2, card_w, h - 7)
window.add(card)
load_card = lambda do
game = list.selected_item
if game.nil?
card.lines = [' (no games)']
next
end
lines = []
lines << " \e[1;33m#{game.title}\e[0m"
lines << " \e[0;36m#{game.platform} · #{game.author}\e[0m"
lines << ''
wrap_para(game.desc.to_s, card_w - 4).each { |l| lines << " #{l}" }
lines << ''
wrap_para(game.story.to_s, card_w - 4).each { |l| lines << " #{l}" }
lines << ''
game.external_links.each do |link|
lines << " \e[0;37m#{link[:label].ljust(10)}\e[0m \e[1;36m#{link[:url]}\e[0m"
end
card.lines = lines
card.scroll = 0
end
list.on_select = ->(_, _) { load_card.call }
close = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
close.layout(window.bounds.x + w - 11, window.bounds.y + h - 2, 9, 1)
window.add(close)
load_card.call
window.reset_focus
window.focus_manager.focus(list)
app.open_window(window)
window
end
def wrap_para(text, width)
return [] if text.empty?
words = text.split
lines = []
cur = +''
words.each do |w|
if cur.empty?
cur << w
elsif cur.length + 1 + w.length <= width
cur << ' ' << w
else
lines << cur.dup
cur = +w
end
end
lines << cur unless cur.empty?
lines
end
end
end

View File

@@ -0,0 +1,158 @@
# frozen_string_literal: true
module UI
module Windows
module_function
# Two-pane message reader: left=board list, right=messages of the selected
# board. Press "n" or click [+ New] to compose, "r" to refresh.
def messages(app, services:)
cols = app.cols
rows = app.rows
w = cols - 4
h = rows - 6
window = BBS::Window.new(title: ' Message Boards ')
window.layout(3, 3, w, h)
window.theme = app.theme
boards = services[:boards].boards
board_list = BBS::Widgets::ListBox.new(
items: boards,
label: ->(b) { " #{b.title}".ljust(18) }
)
board_list.layout(window.bounds.x + 2, window.bounds.y + 2, 20, h - 5)
window.add(board_list)
msg_lines = BBS::Widgets::ScrollView.new(lines: [])
msg_lines.layout(window.bounds.x + 23, window.bounds.y + 2, w - 26, h - 5)
window.add(msg_lines)
reload = lambda do
board = board_list.selected_item
next unless board
msgs = services[:boards].messages(board.id, 100)
wrap_width = msg_lines.bounds.width - 1
lines = []
if msgs.empty?
lines << " (no messages on #{board.title} yet — press 'n' to start one)"
else
msgs.each do |m|
head = "\e[2;37m#{m.timestamp}\e[0m \e[1;33m#{m.username}\e[0m: "
body = m.text.to_s
wrap_text(head + body, wrap_width).each { |l| lines << l }
lines << ''
end
end
msg_lines.lines = lines
msg_lines.scroll = [lines.size - msg_lines.bounds.height, 0].max
end
board_list.on_select = ->(_, _) { reload.call }
compose = BBS::Widgets::Button.new(
label: '&New',
on_click: ->(_) { compose_message(app, services: services, board: board_list.selected_item, on_posted: reload) }
)
compose.layout(window.bounds.x + 2, window.bounds.y + h - 2, 9, 1)
window.add(compose)
refresh = BBS::Widgets::Button.new(
label: '&Refresh',
on_click: ->(_) { reload.call }
)
refresh.layout(window.bounds.x + 13, window.bounds.y + h - 2, 13, 1)
window.add(refresh)
close = BBS::Widgets::Button.new(
label: '&Close',
on_click: ->(_) { app.close_window(window) }
)
close.layout(window.bounds.x + w - 11, window.bounds.y + h - 2, 9, 1)
window.add(close)
reload.call
window.reset_focus
window.focus_manager.focus(board_list)
app.open_window(window)
window
end
def compose_message(app, services:, board:, on_posted: nil)
return BBS::Dialogs.message(app, 'Select a board first.', title: ' Compose ') unless board
w = [app.cols - 8, 70].min
h = 14
window = BBS::Dialogs.centred_window(app, width: w, height: h,
title: " Post to #{board.title} ")
hint = BBS::Widgets::Label.new(
text: "From: #{app.context[:username] || 'Anonymous'} — Ctrl-Enter sends",
style_key: :input_label
)
hint.layout(window.bounds.x + 2, window.bounds.y + 1, w - 4, 1)
window.add(hint)
area = BBS::Widgets::TextArea.new(max_length: 1000)
area.layout(window.bounds.x + 2, window.bounds.y + 3, w - 4, h - 6)
window.add(area)
status = BBS::Widgets::Label.new(text: '', style_key: :error)
status.layout(window.bounds.x + 2, window.bounds.y + h - 3, w - 4, 1)
window.add(status)
send_btn = BBS::Widgets::Button.new(
label: '&Send',
on_click: lambda do |_|
result = services[:boards].post(board.id, app.context[:username] || 'Anonymous', area.value)
if result == :empty
status.text = 'Message cannot be empty.'
else
app.close_window(window)
on_posted&.call
end
end
)
send_btn.layout(window.bounds.x + w - 22, window.bounds.y + h - 2, 9, 1)
window.add(send_btn)
cancel = BBS::Widgets::Button.new(
label: '&Cancel',
on_click: ->(_) { app.close_window(window) }
)
cancel.layout(window.bounds.x + w - 12, window.bounds.y + h - 2, 10, 1)
window.add(cancel)
window.reset_focus
window.focus_manager.focus(area)
app.open_window(window)
window
end
def wrap_text(text, width)
return [''] if width <= 1
# Treat the text as containing ANSI; wrap on visible width approximately
# by splitting on whitespace.
visible = text.gsub(/\e\[[\d;]*m/, '')
return [text] if visible.length <= width
lines = []
cur = +''
cur_visible = +''
text.scan(/(\e\[[\d;]*m|\S+\s*|\s+)/) do |(token)|
if token.start_with?("\e")
cur << token
next
end
visible_tok = token
if cur_visible.length + visible_tok.length > width && !cur_visible.empty?
lines << cur
cur = +''
cur_visible = +''
end
cur << token
cur_visible << token
end
lines << cur unless cur.empty?
lines
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
module UI
module Windows
module_function
def profile(app, services:)
username = app.context[:username] || 'Anonymous'
existing = services[:profile].find(username) || {}
w = 64
h = 14
window = BBS::Dialogs.centred_window(app, width: w, height: h,
title: " Profile — #{username} ")
fields = [
[:signature, 'Signature', existing['signature']],
[:location, 'Location', existing['location']],
[:homepage, 'Homepage', existing['homepage']],
[:notes, 'Notes', existing['notes']],
]
inputs = {}
fields.each_with_index do |(key, label, value), i|
l = BBS::Widgets::Label.new(text: label, style_key: :input_label)
l.layout(window.bounds.x + 2, window.bounds.y + 1 + i * 2, 12, 1)
window.add(l)
input = BBS::Widgets::TextInput.new(value: value.to_s)
input.layout(window.bounds.x + 14, window.bounds.y + 1 + i * 2, w - 18, 1)
window.add(input)
inputs[key] = input
end
save = BBS::Widgets::Button.new(
label: '&Save',
on_click: lambda do |_|
services[:profile].update(username,
signature: inputs[:signature].value,
location: inputs[:location].value,
homepage: inputs[:homepage].value,
notes: inputs[:notes].value
)
app.close_window(window)
BBS::Dialogs.message(app, 'Profile saved.', title: ' Saved ')
end
)
save.layout(window.bounds.x + w - 22, window.bounds.y + h - 2, 9, 1)
window.add(save)
cancel = BBS::Widgets::Button.new(label: '&Cancel',
on_click: ->(_) { app.close_window(window) })
cancel.layout(window.bounds.x + w - 12, window.bounds.y + h - 2, 10, 1)
window.add(cancel)
window.reset_focus
window.focus_manager.focus(inputs[:signature])
app.open_window(window)
window
end
end
end

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
module UI
module Windows
module_function
def sysop_console(app, services:)
width = 70
height = 18
window = BBS::Dialogs.centred_window(app, width: width, height: height,
title: ' Sysop Console ')
lines = []
lines << " Sysop: \e[1;33m#{app.context[:username]}\e[0m"
lines << ''
lines << ' Stats'
lines << " · Online users: #{services[:online].count}"
lines << " · Total messages: #{services[:boards].total_count}"
lines << " · Chat subscribers: #{services[:chat].subscriber_count}"
lines << ''
lines << ' Online users:'
services[:online].user_list.each { |u| lines << " · #{u}" }
lines << ''
lines << ' Last 5 callers:'
services[:last_callers].recent(5).each do |c|
lines << " · #{c.username} #{c.timestamp[0, 16].sub('T', ' ')}"
end
view = BBS::Widgets::ScrollView.new(lines: lines)
view.layout(window.bounds.x + 2, window.bounds.y + 1,
width - 4, height - 4)
window.add(view)
broadcast = BBS::Widgets::Button.new(
label: '&Broadcast',
on_click: lambda do |_|
BBS::Dialogs.input(app, prompt: 'Sysop broadcast message:',
title: ' Broadcast ', width: 64,
on_submit: lambda do |text|
next if text.strip.empty?
services[:chat].broadcast("[SYSOP] #{app.context[:username]}", text.strip)
end)
end
)
broadcast.layout(window.bounds.x + 2, window.bounds.y + height - 2, 14, 1)
window.add(broadcast)
close = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
close.layout(window.bounds.x + width - 11, window.bounds.y + height - 2, 9, 1)
window.add(close)
window.reset_focus
app.open_window(window)
window
end
end
end

View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true
module UI
module Windows
module_function
def system_info(app, services:)
width = 60
height = 14
window = BBS::Dialogs.centred_window(app, width: width, height: height,
title: ' System Info ')
x, y = window.bounds.x + 2, window.bounds.y + 1
rows = [
['Online users', services[:online].count.to_s],
['Messages', services[:boards].total_count.to_s],
['Boards', services[:boards].boards.size.to_s],
['Wiki', 'https://wiki.teletypegames.org'],
['Games API', 'https://teletypegames.org'],
['Platform', RUBY_PLATFORM],
['Ruby', RUBY_VERSION],
['Uptime', format_uptime(services[:uptime])],
]
rows.each_with_index do |(k, v), i|
label = BBS::Widgets::Label.new(text: k.ljust(15), style_key: :input_label)
label.layout(x, y + i, 16, 1)
value = BBS::Widgets::Label.new(text: v.to_s, style_key: :window_text)
value.layout(x + 16, y + i, width - 20, 1)
window.add(label)
window.add(value)
end
ok = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1)
window.add(ok)
window.reset_focus
app.open_window(window)
window
end
def format_uptime(start)
return '' unless start
seconds = (Time.now - start).to_i
h = seconds / 3600
m = (seconds % 3600) / 60
s = seconds % 60
"#{h}h #{m}m #{s}s"
end
end
end

View File

@@ -0,0 +1,61 @@
# frozen_string_literal: true
module UI
module Windows
module_function
def online_users(app, services:)
width = 50
height = [services[:online].user_list.size + 6, 10].max
window = BBS::Dialogs.centred_window(app, width: width, height: height,
title: ' Online Users ')
users = services[:online].user_list
lines = users.map do |u|
u == app.context[:username] ? " #{u} ← you" : " #{u}"
end
lines = [' (no one else here yet)'] if lines.empty?
view = BBS::Widgets::ScrollView.new(lines: lines)
view.layout(window.bounds.x + 2, window.bounds.y + 1,
width - 4, height - 4)
window.add(view)
ok = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1)
window.add(ok)
window.reset_focus
app.open_window(window)
window
end
def last_callers(app, services:)
width = 64
callers = services[:last_callers].recent(15)
height = [callers.size + 6, 10].max
window = BBS::Dialogs.centred_window(app, width: width, height: height,
title: ' Last Callers ')
lines = if callers.empty?
[' (no callers recorded yet)']
else
callers.map do |c|
ts = c.timestamp.to_s[0, 19].sub('T', ' ')
" \e[2;37m#{ts}\e[0m \e[1;33m#{c.username}\e[0m"
end
end
view = BBS::Widgets::ScrollView.new(lines: lines)
view.layout(window.bounds.x + 2, window.bounds.y + 1,
width - 4, height - 4)
window.add(view)
ok = BBS::Widgets::Button.new(label: '&Close',
on_click: ->(_) { app.close_window(window) })
ok.layout(window.bounds.x + (width - 10) / 2, window.bounds.y + height - 2, 10, 1)
window.add(ok)
window.reset_focus
app.open_window(window)
window
end
end
end

View File

@@ -0,0 +1,82 @@
# frozen_string_literal: true
module UI
module Windows
module_function
# Split-view wiki browser. Left=titles, right=selected page content.
def wiki(app, services:, category:, title:)
cols = app.cols
rows = app.rows
w = cols - 4
h = rows - 6
window = BBS::Window.new(title: " #{title} ")
window.layout(3, 3, w, h)
window.theme = app.theme
items = services[:wiki].list(category)
list = BBS::Widgets::ListBox.new(
items: items,
label: ->(p) { " #{p.title}".ljust(28) }
)
list_width = [32, (w / 3)].max
list.layout(window.bounds.x + 2, window.bounds.y + 2, list_width, h - 5)
window.add(list)
preview = BBS::Widgets::ScrollView.new(lines: [])
preview.layout(window.bounds.x + list_width + 4, window.bounds.y + 2,
w - list_width - 7, h - 5)
window.add(preview)
meta = BBS::Widgets::Label.new(text: '', style_key: :input_label)
meta.layout(window.bounds.x + 2, window.bounds.y + h - 2, w - 4, 1)
window.add(meta)
load_preview = lambda do
page = list.selected_item
next unless page
meta.text = "#{page.created_at.to_s[0, 10]} #{services[:wiki].page_url(page.locale, page.path)}"
text = services[:wiki].content(page.id)
preview.lines = wrap_md(text, preview.bounds.width - 1)
preview.scroll = 0
end
list.on_select = ->(_, _) { load_preview.call }
if items.empty?
preview.lines = [' (no pages found)']
else
load_preview.call
end
window.reset_focus
window.focus_manager.focus(list)
app.open_window(window)
window
end
def wrap_md(text, width)
stripped = text.to_s
.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1')
.gsub(/[#*_`~>|\\]/, '')
.gsub(/\r?\n+/, ' ')
.strip
words = stripped.split
lines = []
cur = +''
words.each do |w|
if cur.empty?
cur << w
elsif cur.length + 1 + w.length <= width
cur << ' ' << w
else
lines << cur.dup
cur = +w
end
end
lines << cur unless cur.empty?
lines.empty? ? [''] : lines
end
end
end