From 9d52a2e2c687279f63326160701f4c36fafd4946 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 12 May 2026 22:36:19 +0200 Subject: [PATCH] new framework --- Gemfile.lock | 4 +- README.md | 103 +++++-- bbs.rb | 343 +++++++++------------ data/art/cybr.ansi | 11 + data/art/synchronet.ansi | 13 + data/art/teletype.ansi | 20 ++ data/boards/general.csv | 8 + data/last_callers.csv | 33 ++ data/profiles.csv | 1 + env-example | 10 +- lib/repository/board_repository.rb | 83 +++++ lib/repository/last_callers_repository.rb | 41 +++ lib/repository/message_board_repository.rb | 42 --- lib/repository/profile_repository.rb | 43 +++ lib/service/board_service.rb | 17 + lib/service/chat_service.rb | 55 ++++ lib/service/last_callers_service.rb | 8 + lib/service/message_service.rb | 14 - lib/service/profile_service.rb | 8 + lib/tui/helpers.rb | 114 ------- lib/ui/windows/bulletin_window.rb | 55 ++++ lib/ui/windows/chat_window.rb | 55 ++++ lib/ui/windows/games_window.rb | 87 ++++++ lib/ui/windows/messages_window.rb | 158 ++++++++++ lib/ui/windows/profile_window.rb | 62 ++++ lib/ui/windows/sysop_window.rb | 58 ++++ lib/ui/windows/system_info_window.rb | 51 +++ lib/ui/windows/users_window.rb | 61 ++++ lib/ui/windows/wiki_window.rb | 82 +++++ 29 files changed, 1240 insertions(+), 400 deletions(-) create mode 100644 data/art/cybr.ansi create mode 100644 data/art/synchronet.ansi create mode 100644 data/art/teletype.ansi create mode 100644 data/boards/general.csv create mode 100644 data/last_callers.csv create mode 100644 data/profiles.csv create mode 100644 lib/repository/board_repository.rb create mode 100644 lib/repository/last_callers_repository.rb delete mode 100644 lib/repository/message_board_repository.rb create mode 100644 lib/repository/profile_repository.rb create mode 100644 lib/service/board_service.rb create mode 100644 lib/service/chat_service.rb create mode 100644 lib/service/last_callers_service.rb delete mode 100644 lib/service/message_service.rb create mode 100644 lib/service/profile_service.rb delete mode 100644 lib/tui/helpers.rb create mode 100644 lib/ui/windows/bulletin_window.rb create mode 100644 lib/ui/windows/chat_window.rb create mode 100644 lib/ui/windows/games_window.rb create mode 100644 lib/ui/windows/messages_window.rb create mode 100644 lib/ui/windows/profile_window.rb create mode 100644 lib/ui/windows/sysop_window.rb create mode 100644 lib/ui/windows/system_info_window.rb create mode 100644 lib/ui/windows/users_window.rb create mode 100644 lib/ui/windows/wiki_window.rb diff --git a/Gemfile.lock b/Gemfile.lock index 63c5251..777fc27 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://git.teletype.hu/tools/rubbs.git - revision: be21826eccc8eb2930b7a83a97b8ed9672219a9c + revision: 9ae939a213d4cf45140f77ac3baacfa061dde2fb specs: - bbs (0.3.0) + bbs (0.4.0) artii (~> 2.1) GEM diff --git a/README.md b/README.md index a088132..5a9c782 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,26 @@ # 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 -**Docker Compose (recommended):** +**Docker Compose:** ```bash docker compose up --build ``` -**Directly (requires Ruby 3.x):** +**Directly (Ruby 3.x):** ```bash bundle install ruby bbs.rb ``` -Connect with: +Connect: ```bash telnet localhost 2323 @@ -25,39 +28,91 @@ telnet localhost 2323 ## Configuration -Copy `env-example` to `.env` and fill in the values: +Copy `env-example` to `.env`: | Variable | Default | Description | |---|---|---| -| `WEBAPP_WIKIJS_TOKEN` | — | Bearer token for Wiki.js API (optional) | -| `MESSAGES_PATH` | `data/messages.dat` | Path to the messages CSV file | +| `BBS_PORT` | `2323` | TCP port to listen on | +| `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 | -| New Message | Post a message (max 200 chars) | -| Blog Posts | Browse blog entries from Wiki.js | -| HowTo Guides | Browse how-to articles from Wiki.js | -| Game Catalog | Browse the Teletype game catalog | -| Online Users | List of currently connected users | -| System Info | Server stats | +| `Alt+letter` | Open the matching top menu | +| `F10` | Open the leftmost menu | +| `Tab` / `Shift-Tab` | Move focus inside a window | +| `Enter` | Activate the focused button / submit input | +| `Esc` | Close the top window / menu | +| `F1` | Help | +| `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 ``` -bbs.rb Entry point — BBS configuration and flow definition +bbs.rb entry point — services + Application lib/ - online_users.rb Thread-safe connected-users map - message_board.rb CSV-backed message store - wiki.rb Wiki.js GraphQL client - catalog.rb Games API client - display.rb ANSI rendering helpers and content handlers + repository/ + online_users_repository.rb thread-safe connected-user map + board_repository.rb multi-board CSV store + wiki_repository.rb Wiki.js GraphQL client + 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/ - messages.dat Message board records (auto-created) + boards/.csv per-board messages (auto-created) + last_callers.csv login log + profiles.csv persistent user profiles ``` ## 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. diff --git a/bbs.rb b/bbs.rb index 0db4026..2529de2 100644 --- a/bbs.rb +++ b/bbs.rb @@ -2,231 +2,168 @@ require 'bbs' require 'time' + 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/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/message_service' +require_relative 'lib/service/board_service' +require_relative 'lib/service/profile_service' require_relative 'lib/service/wiki_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) -MESSAGES = MessageService.new(MessageBoardRepository.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat'))) -WIKI = WikiService.new(WikiRepository.new(token: ENV['WEBAPP_WIKIJS_TOKEN'])) -GAMES = GamesService.new(CatalogRepository.new) +ONLINE = OnlineService.new(OnlineUsersRepository.new) +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'])) +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 = [ - 'Messages', 'Post Message', 'Blog Posts', - 'HowTo Guides', 'Game Catalog', 'Online Users', - 'System Info', 'Exit' -].freeze +STARTED_AT = Time.now +SYSOPS = ENV.fetch('BBS_SYSOPS', '').split(',').map { |s| s.strip.downcase }.reject(&:empty?) +DESKTOP_ART = ENV.fetch('BBS_DESKTOP_ART', 'data/art') -LEFT_WIDTH = 21 -CONTENT_X = 23 -CONTENT_Y = 2 - -THEME = { - bg: "\e[0;40m", - normal: "\e[0;33m", - bright: "\e[1;33m", - border: "\e[0;33m", - 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", +SERVICES = { + online: ONLINE, + boards: BOARDS, + wiki: WIKI, + games: GAMES, + chat: CHAT, + last_callers: LAST_CALLERS, + profile: PROFILE, + uptime: STARTED_AT, }.freeze -MAIN_TUI = BBS::TUI.define do - helpers { include TUIHelpers } +MAIN_APP = BBS::Application.define do + 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 - @menu_selection = 0 - @page_title = 'TELETYPE BBS' - @items = [] - @item_selection = 0 - @scroll = 0 - @detail = [] - @page_metadata = +'' - @input = +'' - @status = +'' - @previous_page = nil - @modal = nil - end - - chrome { render_chrome } - - 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] + art = if File.directory?(DESKTOP_ART) + BBS::Widgets::AnsiArt.random(DESKTOP_ART) + elsif File.file?(DESKTOP_ART) + BBS::Widgets::AnsiArt.from_file(DESKTOP_ART) + else + BBS::Widgets::AnsiArt.new(lines: BBS::Widgets::AnsiArt.default_lines) 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 } + art.layout(1, 1, cols, rows - 2) + add(art) 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 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 big_banner 'TELETYPE BBS', style: :success ask :username, prompt: 'Name (blank for Anonymous)', - transform: ->(value) { value.strip.empty? ? 'Anonymous' : value.strip[0...20] } - call { |ctx| ONLINE.add(ctx[:session_id], ctx[:username]) } - tui MAIN_TUI + transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] } + call do |ctx| + 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 end end diff --git a/data/art/cybr.ansi b/data/art/cybr.ansi new file mode 100644 index 0000000..48613da --- /dev/null +++ b/data/art/cybr.ansi @@ -0,0 +1,11 @@ + + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + ░ █▀▀ █▄█ █▄▄ █▀▀ █▀█ █▀█ █▀█ █▀█ █▀▀ █▄ █ █▀█ █▄ █ █░ + ░ █▄▄ █ █▄█ ██▄ █▀▄ █▀ █▄█ █▀ █▄▄ █ ▀█ █▄█ █ ▀█ ░ + ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + + // JACK IN. DROP CARRIER. STAY A WHILE. + + // online users: live + // message log: active + // carrier: 2400 baud diff --git a/data/art/synchronet.ansi b/data/art/synchronet.ansi new file mode 100644 index 0000000..323c428 --- /dev/null +++ b/data/art/synchronet.ansi @@ -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 diff --git a/data/art/teletype.ansi b/data/art/teletype.ansi new file mode 100644 index 0000000..fde0452 --- /dev/null +++ b/data/art/teletype.ansi @@ -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 diff --git a/data/boards/general.csv b/data/boards/general.csv new file mode 100644 index 0000000..653e4d4 --- /dev/null +++ b/data/boards/general.csv @@ -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 diff --git a/data/last_callers.csv b/data/last_callers.csv new file mode 100644 index 0000000..2529c68 --- /dev/null +++ b/data/last_callers.csv @@ -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 diff --git a/data/profiles.csv b/data/profiles.csv new file mode 100644 index 0000000..b3c9c89 --- /dev/null +++ b/data/profiles.csv @@ -0,0 +1 @@ +session_id,timestamp,username,signature,location,homepage,notes diff --git a/env-example b/env-example index 27b4305..90d9981 100644 --- a/env-example +++ b/env-example @@ -1,2 +1,10 @@ WEBAPP_WIKIJS_TOKEN= -MESSAGES_PATH=data/messages.dat \ No newline at end of file +BOARDS_PATH=data/boards +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 diff --git a/lib/repository/board_repository.rb b/lib/repository/board_repository.rb new file mode 100644 index 0000000..cd67e75 --- /dev/null +++ b/lib/repository/board_repository.rb @@ -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 diff --git a/lib/repository/last_callers_repository.rb b/lib/repository/last_callers_repository.rb new file mode 100644 index 0000000..cb86446 --- /dev/null +++ b/lib/repository/last_callers_repository.rb @@ -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 diff --git a/lib/repository/message_board_repository.rb b/lib/repository/message_board_repository.rb deleted file mode 100644 index 5eef56f..0000000 --- a/lib/repository/message_board_repository.rb +++ /dev/null @@ -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 diff --git a/lib/repository/profile_repository.rb b/lib/repository/profile_repository.rb new file mode 100644 index 0000000..d670ebd --- /dev/null +++ b/lib/repository/profile_repository.rb @@ -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 diff --git a/lib/service/board_service.rb b/lib/service/board_service.rb new file mode 100644 index 0000000..88857d1 --- /dev/null +++ b/lib/service/board_service.rb @@ -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 diff --git a/lib/service/chat_service.rb b/lib/service/chat_service.rb new file mode 100644 index 0000000..66807f6 --- /dev/null +++ b/lib/service/chat_service.rb @@ -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 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 diff --git a/lib/service/last_callers_service.rb b/lib/service/last_callers_service.rb new file mode 100644 index 0000000..20685b2 --- /dev/null +++ b/lib/service/last_callers_service.rb @@ -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 diff --git a/lib/service/message_service.rb b/lib/service/message_service.rb deleted file mode 100644 index c48d2f4..0000000 --- a/lib/service/message_service.rb +++ /dev/null @@ -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 diff --git a/lib/service/profile_service.rb b/lib/service/profile_service.rb new file mode 100644 index 0000000..845d7be --- /dev/null +++ b/lib/service/profile_service.rb @@ -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 diff --git a/lib/tui/helpers.rb b/lib/tui/helpers.rb deleted file mode 100644 index f182d76..0000000 --- a/lib/tui/helpers.rb +++ /dev/null @@ -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 diff --git a/lib/ui/windows/bulletin_window.rb b/lib/ui/windows/bulletin_window.rb new file mode 100644 index 0000000..3f96bf0 --- /dev/null +++ b/lib/ui/windows/bulletin_window.rb @@ -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 diff --git a/lib/ui/windows/chat_window.rb b/lib/ui/windows/chat_window.rb new file mode 100644 index 0000000..a115dab --- /dev/null +++ b/lib/ui/windows/chat_window.rb @@ -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 diff --git a/lib/ui/windows/games_window.rb b/lib/ui/windows/games_window.rb new file mode 100644 index 0000000..65a2468 --- /dev/null +++ b/lib/ui/windows/games_window.rb @@ -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 diff --git a/lib/ui/windows/messages_window.rb b/lib/ui/windows/messages_window.rb new file mode 100644 index 0000000..7d29b85 --- /dev/null +++ b/lib/ui/windows/messages_window.rb @@ -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 diff --git a/lib/ui/windows/profile_window.rb b/lib/ui/windows/profile_window.rb new file mode 100644 index 0000000..0e42699 --- /dev/null +++ b/lib/ui/windows/profile_window.rb @@ -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 diff --git a/lib/ui/windows/sysop_window.rb b/lib/ui/windows/sysop_window.rb new file mode 100644 index 0000000..9e3e801 --- /dev/null +++ b/lib/ui/windows/sysop_window.rb @@ -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 diff --git a/lib/ui/windows/system_info_window.rb b/lib/ui/windows/system_info_window.rb new file mode 100644 index 0000000..d6e6138 --- /dev/null +++ b/lib/ui/windows/system_info_window.rb @@ -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 diff --git a/lib/ui/windows/users_window.rb b/lib/ui/windows/users_window.rb new file mode 100644 index 0000000..ea6ae56 --- /dev/null +++ b/lib/ui/windows/users_window.rb @@ -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 diff --git a/lib/ui/windows/wiki_window.rb b/lib/ui/windows/wiki_window.rb new file mode 100644 index 0000000..a4db0f2 --- /dev/null +++ b/lib/ui/windows/wiki_window.rb @@ -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