A rubbs kézikönyvének első oldala: hogyan telepítsd a gemet, hogyan írj és indíts el egy első boardot, melyik réteget mire használd a háromból, mit csinál a
BBS.configureegyes kulcsa, hogyan bánik a szerver a kapcsolatokkal (korlátok, idle timeout, logolás), és hogyan épül fel és megy ki Dockerben egy valódi board. A rubbs 1.0.0 alapján.
A gem neve bbs, és nincs fent a rubygems.org-on: a saját Git-szerverünkről jön. A Gemfile-ba:
source 'https://rubygems.org'
gem 'bbs', git: 'https://git.teletypegames.org/engines/rubbs.git'
utána bundle install. Ruby 3.2 vagy újabb kell (a kapcsolat idle timeoutját az IO#timeout= állítja be, lásd Idle timeoutok). A futásidejű függőségek az artii (FIGlet bannerek) és az sqlite3 (BBS::SQLite és BBS::Store); utóbbi natív kiterjesztést fordít, ezért a gépen vagy az image-ben C-fordító kell.
Minden kiadásnak van tagje, vX.Y.Z (v1.0.0), a verzió pedig a bbs.gemspec-ben áll. A Bundler a bundle install pillanatában HEAD-en lévő commitot rögzíti a Gemfile.lock-ban, a bundle update bbs pedig a legújabbra lép. Egy kiadáson a tag: 'v1.0.0' tart meg, egy adott commitra a ref: 'COMMIT' köt. Aki 0.x-ről jön, a repó UPGRADING.md-jében minden megszűnt vagy átnevezett API-t megtalál a helyettesítőjével együtt.
Minden betöltéséhez egy require elég:
require 'bbs'
# bbs.rb
require 'bbs'
BBS.configure do |c|
c.flow = BBS::Flow.define do
big_banner 'My BBS'
ask :name, prompt: 'Your name'
say 'Welcome!', style: :success
pause 'Hanging up...', seconds: 2
end
end
BBS.start
$ bundle exec ruby bbs.rb
BBS listening on port 2323 — connect with: telnet localhost 2323
Egy másik terminálban:
$ telnet localhost 2323
__ ___ ____ ____ _____
/ |/ /_ __ / __ )/ __ ) ___/
/ /|_/ / / / / / __ / __ \__ \
/ / / / /_/ / / /_/ / /_/ /__/ /
/_/ /_/\__, / /_____/_____/____/
/____/
Your name: Zsolt
Welcome!
Hanging up...
Amikor a flow lépései elfogynak, a szerver bontja a vonalat. A szerver termináljában a Ctrl-C leállítja (Server stopped.).
Bármilyen telnetkliens jó: a telnet parancs (macOS-en brew install telnet), a SyncTERM, a NetRunner, a Qodem, a PuTTY telnet módban. Az egérhez és a teljes képernyős rétegekhez xterm-escape-eket értő terminál kell (SyncTERM, vagy telnet iTerm2-ben, Terminal.app-ban, egy linuxos terminálban). Egy sima nc nem elég: nem válaszol a telnet negotiationre, így a szerver nem tudja meg az ablak méretét.
A rubbs háromféleképpen tud a hívóval beszélni. Ezek nem egy egész boardra szóló alternatívák: egy hívás általában többön is végigmegy, és mindig egy Flow-ban kezdődik.
| Réteg | Mi ez | Mire való | Kézikönyv |
|---|---|---|---|
BBS::Flow |
Sororientált, deklaratív párbeszéd: a lépések fentről lefelé futnak (say, ask, menu, persist, call, …), a szöveg görög, mint egy régi BBS-en. |
A terminál-kérdés, a login, egy kérdőív, ragasztó a többi köré. Minden board ezzel indul. | Flow |
BBS::Application |
Widget framework: menubar, státuszsor, egymást átfedő window-k, űrlapok, dialógusok, egér, témák, doorok. | Synchronet-stílusú főfelület több window-val és funkcióval, ANSI-terminálra. | Application |
BBS::Petscii::Screen és BBS::Selector |
Egy soralapú board képernyője: fordított címsor, bekezdések, RETURN-nel lapozott oldalak, számozott kurzoros menük, beviteli mezők és egy szerkesztő. Commodore 64-en 40×25, modern terminálon bármekkora. | Egy board C64-es arca, vagy egy egész board, ami soronként dolgozik. | Commodore 64 |
Ökölszabály: kezdj egy Flow-val. Ha a boardnak window-k, dialógusok, menubar vagy egér kell, a fő része egy Application, előtte egy Flow-val a terminál-kérdéshez vagy a loginhoz. A C64-es arc, vagy egy board, ami soronként egyszerűbb, egy call lépésből hajtott Petscii::Screen.
A három egymás mellett, egy boardban:
require 'bbs'
NEWS = ['rubbs 1.0 is out', 'Feedia reads RSS on a C64', 'New door: Pixelia'].freeze
DESKTOP = BBS::Application.define do
theme BBS::Theme.synchronet
menubar do
menu '&File' do
item '&About' do BBS::Dialogs.message(self, 'Hello from rubbs!') end
item 'E&xit' do :halt end
end
end
status_hint('F10', 'Quit') { :halt }
end
def read_news(session)
screen = BBS::Petscii::Screen.new(session, cols: 80, rows: 24)
item = screen.pick('the news', NEWS)
screen.say(item, color: BBS::Petscii::Colors::YE) if item
screen.pause
end
BBS.configure do |c|
c.flow = BBS::Flow.define do
ask :name, prompt: 'Your name'
menu 'Where to?', loop: true do
option('News (line by line)') { call { |_ctx, runner| read_news(runner.session) } }
option('Desktop (Application)') { app DESKTOP }
option('Log off') { exit_menu }
end
say 'Goodbye!'
end
end
BBS.start
A rétegek mellett vannak építőelemek, amelyeket belőlük érsz el: a BBS::Door és a BBS::Gateway átadja a vonalat egy másik programnak vagy boardnak (Doorok), a BBS::FakeOS egy szimulált gép (Doorok), a BBS::AI egy karakter, akivel beszélgetni lehet (AI), a BBS::Petscii és a BBS::Terminal a Commodore 64-es hívót szolgálja ki (Commodore), a BBS::SQLite, a BBS::Store, a BBS::HTTP, a BBS::Cache, a BBS::RateLimit és a BBS::Wikipedia pedig egy tartalmas board adat- és hálózati részei (Adat és hálózat).
flowchart TD
A[BBS::Server elfogad egy TCP-kapcsolatot] --> B{"kevesebb hívó van, mint max_sessions, és erről a címről kevesebb, mint max_sessions_per_ip?"}
B -- nem --> X["kiírja: 'Too many callers right now' vagy 'Too many calls from your address', és bont"]
B -- igen --> C[új thread, socket timeout = idle_seconds vagy 900 s]
C --> D[BBS::Session.new: véletlen session_id]
D --> E[telnet negotiation: WILL SGA, WILL ECHO, DO SGA, DO NAWS]
E --> F{config.mouse és config.mouse_on_connect?}
F -- igen --> G[xterm egérjelentés be]
F -- nem --> H
G --> H[a FlowRunner futtatja a config.flow-t]
H --> I[lépések: say, ask, menu, call, ...]
I --> K[app: Application]
I --> L[call: Petscii::Screen, doorok, al-flow-k]
K --> I
L --> I
I --> M[elfogyott a lépés, :halt jött, vagy a hívó bontott]
M --> N[egér ki, socket zárva, on_session_end meghívva]
Ugyanez szövegesen:
TCPServer ─ accept ─ admit? ─ Thread ─ Session#run
├ negotiate (SGA, ECHO, NAWS)
├ enable_mouse (config.mouse && config.mouse_on_connect)
└ FlowRunner(config.flow).run
├ app ─▶ Application#run ─ vissza a következő lépésre
└ call ─▶ a saját kódod (Petscii::Screen, doorok, al-flow-k, BBS::Terminal)
ensure: disable_mouse, socket zárása, config.on_session_end.call(session)
Minden kapcsolat a saját threadjében fut, saját Session-nel és saját flow-contexttel (ctx); a definíciók (Flow.define, Application.define) közös, csak olvasott sablonok. Ami változtatható dolgot betöltéskor egy definícióba teszel (egy konstans Hash vagy Array), azon minden hívó osztozik.
BBS.configure do |c|
c.flow = MY_FLOW
end
A BBS.configure az egyetlen, process-szintű BBS::Config objektumot adja a blokknak; a BBS.config visszaadja (első használatkor létrehozza). A BBS.start előtt konfigurálj; a szerver és a sessionök élőben olvassák az értékeket, így egy később átírt kulcs a későbbi kapcsolatokra hat.
| Kulcs | Alapérték | Mit csinál |
|---|---|---|
flow |
nil |
A BBS::Flow, amit minden kapcsolat futtat. Kötelező: nélküle minden session BBS.config.flow is not set hibával áll le (logolva, a hívót bontja). |
port |
ENV['BBS_PORT'] vagy 2323 |
A BBS.start TCP-portja. A környezeti változót a config objektum létrejöttekor olvassa, vagyis az első BBS.configure vagy BBS.config hívásnál. |
max_sessions |
ENV['BBS_MAX_SESSIONS'] vagy 100 |
Egyszerre hány kapcsolatot szolgál ki. Eggyel több Too many callers right now, try again later. üzenetet kap, a szerver bont, és egy BBS busy sort logol. A környezeti változót a BBS_PORT-hoz hasonlóan olvassa; a BBS.configure-ban megadott érték erősebb. |
max_sessions_per_ip |
ENV['BBS_MAX_SESSIONS_PER_IP'] vagy 6 |
Egy címről egyszerre hány kapcsolatot szolgál ki. Eggyel több Too many calls from your address, try again later. üzenetet kap, és a szerver bont (a logban BBS crowded). A privát, loopback és link-local címekre nincs korlát: egy webes proxy, például a bbs-web, a Docker-hálózatból sok hívót hoz. |
idle_seconds |
nil |
Az idle-korlát másodpercben. Ez minden kapcsolat socketjének olvasási/írási timeoutja (nil esetén 900 s), és egy Application kemény idle-bontása ("Idle" üzenettel). Lásd Idle timeoutok. |
mouse |
false |
Használ-e a board xterm egérjelentést (SGR, gombok és húzás). Az Applicationök és a doorok csak akkor kapcsolják be-ki maguk körül, ha ez igaz. |
mouse_on_connect |
true |
A mouse mellett: elinduljon-e a jelentés rögtön a negotiation után. Állítsd false-ra, ha a flow előbb kérdez valamit, pl. egy kétarcú board, ami azt kérdezi, modern vagy C64: az egér escape-szekvenciája egy Commodore-on szemét volna. Modern terminálnál ilyenkor a BBS::Terminal.flow kapcsolja be. |
screens_dir |
nil |
A screen lépések ERB-fájljainak mappája azoknál a flow-knál, amelyek nem adnak meg sajátot (Flow.define(screens_dir:)). Lásd Flow: screenek. |
on_session_end |
nil |
Egy callable, amit minden kapcsolat vége után meghív a BBS::Session-nel, rendes vég és hiba esetén is. A kapcsolat threadjében fut; logoláshoz, egy "ki van bent" listához vagy a sessionhöz kötött állapot takarításához jó. |
theme |
nil |
Minden olyan Application témája, amelynek nincs saját theme hívása (és az alap nélküli theme do … end blokk kiindulópontja); nil esetén BBS::Theme.synchronet. A 0.19.0 óta. Lásd Rajzolás. |
log_tag |
ENV['BBS_LOG_TAG'] vagy 'bbs' |
Ezzel kezdődik minden BBS.log-sor: a board technikai neve (feedia-bbs). Lásd Logolás. |
ai_concurrency |
2 |
A processzben egyszerre hány BBS::AI::Client-kérés fut; a következő legfeljebb 5 s-ot vár, aztán BBS::AI::Busy-t kap. Lásd AI. |
user_agent |
'rubbs/VERSION (+https://teletypegames.org)' |
A BBS::HTTP-kérések User-Agent-je. Lásd Adat és hálózat. |
Egy teljes konfiguráció:
require 'bbs'
WHO = {}
WHO_LOCK = Mutex.new
BBS.configure do |c|
c.port = 2323
c.max_sessions = 20
c.log_tag = 'harbor-bbs'
c.idle_seconds = 600
c.mouse = true
c.mouse_on_connect = true
c.screens_dir = File.join(__dir__, 'screens')
c.on_session_end = lambda do |session|
WHO_LOCK.synchronize { WHO.delete(session.session_id) }
puts "caller #{session.session_id} left"
end
c.flow = BBS::Flow.define do
call do |ctx, _runner|
WHO_LOCK.synchronize { WHO[ctx[:session_id]] = Time.now }
nil
end
say 'Hello!', style: :success
pause 'See you.', seconds: 2
end
end
BBS.start
BBS.start # a port a BBS.config.port-ból
BBS.start(port: 2424) # felülírva
A BBS.start(port: BBS.config.port) létrehoz egy BBS::Server-t, és meghívja a run-ját, ami a process teljes élete alatt blokkol. Ugyanez kézzel:
BBS::Server.new(port: 2424).run
| Cím | 0.0.0.0 (minden interfész), a megadott porton. Foglalt portnál induláskor Errno::EADDRINUSE-t dob. |
| Threadek | Kapcsolatonként egy Ruby-thread; az accept loop sosem vár egy sessionre. |
| Session-korlát | config.max_sessions, mutex alatt számolva; a hely a session threadjének végén szabadul fel. |
| Címenkénti korlát | config.max_sessions_per_ip a hívó címére, kivéve a privát, loopback és link-local címeket. |
| Accept-hibák | Az accept minden SystemCallError és IOError hibáját logolja (accept: …), 0,5 s-ot vár, és megy tovább. |
| Session vége | A hívó bontása, a broken pipe és az idle timeout session: dropped by the client (…) sorként kerül a logba; minden más kivétel session: OSZTÁLY: ÜZENET sorként, három sor backtrace-szel. Egyik sem állítja le a szervert. A hely akárhogy ér véget a session, felszabadul. |
| Leállás | Ctrl-C (SIGINT) vagy SIGTERM (docker stop): kiírja, hogy Server stopped., és a run visszatér. |
Az idle_seconds két szinten hat:
timeout = idle_seconds-ot kap, vagy BBS::Server::DEFAULT_TIMEOUT-ot (900 s), ha az nil. Az ennél tovább váró blokkoló olvasás vagy írás kivételt dob, és a session "dropped by the client"-ként ér véget. Ez bontja azt a hívót, aki egy ask promptnál, egy "nyomj RETURN-t"-nél vagy egy doorban ül.idle_seconds-nál lefuttatja az on_idle blokkját (ha van), és hacsak az nem :keep-et ad vissza, kiírja, hogy "Disconnected after N s idle.", és visszatér. nil esetén magától sosem bont.
A session.idle_seconds = 3600 egyetlen hívónál mindkettőt átállítja (egy sysopnál, egy hosszú olvasásnál).A könyvtár minden logsort egyetlen metóduson, a BBS.log(tag, message)-en át ír a stderr-re, LOG_TAG: TAG: ÜZENET alakban, így sok board sorai egy logban is egyformán olvashatók. A log_tag legyen a board technikai neve, és a board maga is ezt a metódust használja:
BBS.configure { |c| c.log_tag = 'feedia-bbs' }
BBS.log('catalog refresh', 'done in 3.2 s') # feedia-bbs: catalog refresh: done in 3.2 s
BBS.log('catalog refresh', error) # feedia-bbs: catalog refresh: SocketError: …
A hívóktól független munka (egy induláskor betöltött index, egy óránként frissülő katalógus) a BBS.background(name, every: nil) { … }-be kerül: egy threadbe, ami a hibát logolja, és egyre hosszabb szünet után újrapróbálja. Lásd Adat és hálózat.
A hálózatunk boardjai (Feedia BBS, Alexandria BBS, a Teletype BBS) ugyanazt a szerkezetet követik: egy rövid bbs.rb köti össze a részeket, a board saját logikája a lib/domain-ben van (repositoryk, amik a külvilággal beszélnek, és szolgáltatások, amik eldöntik, mit lát a hívó), a board minden arca pedig a lib/ui egy-egy mappájában.
feedia/
├── bbs.rb # configure + start, semmi más
├── Gemfile # gem 'bbs', git: 'https://git.teletypegames.org/engines/rubbs.git'
├── Gemfile.lock
├── lib/
│ ├── domain/ # a board logikája, vonal nélkül tesztelhető
│ │ ├── feeds.rb
│ │ ├── inbox.rb
│ │ └── html_text.rb
│ └── ui/
│ ├── modern.rb # UI::Modern.flow(...) → Flow + Application ANSI-terminálra
│ ├── petscii.rb # UI::Petscii.flow(...) → Flow Commodore 64-re (PETSCII, 40 oszlop)
│ └── petscii/screen.rb
├── config/feeds.yml # az image-dzsel szállított adat
├── data/ # futásidejű adat (store-ok, logok), Dockerben volume
├── bin/ # kis admin szkriptek
├── Dockerfile
├── compose.yaml
├── entrypoint.sh
└── env-example # a környezeti változók, amiket a board olvas
A Feedia teljes bbs.rb-je:
require 'bbs'
require_relative 'lib/domain/feeds'
require_relative 'lib/domain/inbox'
require_relative 'lib/ui/modern'
require_relative 'lib/ui/petscii'
feeds = Feeds.load(ENV.fetch('FEEDIA_FEEDS', File.join(__dir__, 'config/feeds.yml')))
inbox = Inbox.new(path: ENV.fetch('FEEDIA_INBOX', File.join(__dir__, 'data/inbox.jsonl')),
webhook: ENV.fetch('FEEDIA_DISCORD_WEBHOOK', ''))
BBS.configure do |config|
config.mouse = true
config.mouse_on_connect = false
config.idle_seconds = 600
config.flow = BBS::Terminal.flow(title: 'Feedia', modern: UI::Modern.flow(feeds, inbox),
petscii: UI::Petscii.flow(feeds, inbox))
end
BBS.start
A részek:
bbs.rb-ben, és a UI-gyárak kapják meg őket (UI::Modern.flow(feeds, inbox)); a UI-kód Application services-ként vagy a closure-ön át éri el őket. A domain használhatja a rubbs adat- és hálózati részeit (BBS::HTTP, BBS::Cache, BBS::SQLite, BBS.log), a képernyőit soha. A repository dob, a szolgáltatás elkapja, egyszer logolja, és eldönti, mit lát a hívó; a UI sosem kap el BBS::HTTP::Error-t (lásd Adat és hálózat).UI::Modern.flow felépít egy Application-t, és egy kis Flow-t ad vissza, aminek a call lépése futtatja (app.run(session: runner.session, session_id: ctx[:session_id], context: ctx)), utána elköszön. A UI::Petscii.flow egy olyan Flow-t ad vissza, aminek a call lépése egy BBS::Petscii::Screen-t hajt.BBS::Terminal.flow megkérdezi a hívót, milyen terminálja van, és a hozzá illő arcot futtatja; ezért false a mouse_on_connect. Egy egyarcú board a config.flow-ba közvetlenül a saját Flow-ját teszi. Lásd Commodore.Az Alexandria BBS ugyanilyen, egy domain-objektummal (lib/domain/wikipedia.rb) és egy külön window-modullal (lib/ui/modern/pharos.rb). Az Impostor BBS a másik gyakori forma: egy gem, aminek a lib/impostor.rb-je egy saját screens/ mappás Flow-t definiál, így ugyanaz a Flow önálló boardként és egy másik board doorjaként is fut (lásd Flow).
A boardok Dockerben futnak, mindegyik a saját konténerében, a hivatalos Ruby Alpine image-ből. A git a Gitből jövő gemhez kell, a build-base a natív kiterjesztésekhez (sqlite3).
Dockerfile:
FROM ruby:3.3-alpine
RUN apk add --no-cache git build-base tzdata
WORKDIR /app
ENV BUNDLE_WITHOUT=development
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY bbs.rb entrypoint.sh ./
COPY lib/ ./lib/
RUN chmod +x entrypoint.sh
EXPOSE 2323
ENTRYPOINT ["./entrypoint.sh"]
entrypoint.sh: a boardjaink minden induláskor frissítik a rubbs-t, így egy újraindítás rebuild nélkül is felveszi az új kiadást. Szerver-újraindítás után a Git-szerver egy ideig 502-t adhat, ezért legfeljebb egy percig vár, és ha kell, a telepített verzióval indul:
#!/bin/sh
set -e
RUBBS_GIT=https://git.teletypegames.org/engines/rubbs.git
tries=0
until git ls-remote "$RUBBS_GIT" HEAD >/dev/null 2>&1 || [ "$tries" -ge 12 ]; do
tries=$((tries + 1))
sleep 5
done
bundle update bbs || echo "rubbs update failed, starting with the installed version" >&2
exec bundle exec ruby bbs.rb
Az exec számít: így a Ruby lesz a PID 1, és a docker stop jelzése eljut hozzá.
compose.yaml:
services:
myboard:
build: .
container_name: myboard
mem_limit: 128m
ports:
- "2323:2323"
volumes:
- ./data:/app/data
environment:
- TZ=Europe/Budapest
restart: unless-stopped
A konténerben a board mindig a 2323-on figyel; a ports hoszt oldala adja a nyilvános portot (a Feediáé "2327:2323"). Amit a board futás közben ír (BBS::Store- és BBS::SQLite-fájlok), az a /app/data alá kerül, ami volume, így túléli a rebuildet.
docker compose up -d --build
docker compose logs -f myboard
telnet localhost 2323
A board kimenete (puts, BBS.log) a konténer logjába megy: a figyelő sor, a bontott sessionök és a session-hibák backtrace-szel, minden sor a board log_tag-je után.
BBS::Petscii::Screen, BBS::SelectorBBS::SQLite, BBS::Store, BBS::HTTP, BBS.log, BBS.background