EN /HU | Belépés

Telnet, session és store

A rubbs legalja: hogyan lesz egy kapcsolatból BBS::Session, mit csinál a telnet-egyeztetés, az ablakméret (NAWS), a readkey összes lehetséges billentyűje, a readline, az írás, az egérjelentés, az idle-korlátok és a bontás (lib/bbs/server.rb, session.rb, telnet.rb); valamint a BBS::Store, a tábla, amelyben a board a hívói adatait tartja, a 0.33.0 óta SQLite-fájlban (lib/bbs/store.rb). A többi építőelem, ami egy tartalmas boardnak kell (SQLite, HTTP, cache, rate limit, naplózás), az Adat és hálózat oldalon van. A legtöbb board ezekkel csak egy Flow-n vagy egy Applicationön keresztül találkozik; közvetlenül egy Flow call lépésében, egy doorban vagy egy C64-es arcban van rájuk szükség.

A kapcsolattól a sessionig

BBS.start és BBS::Server

RUBY
BBS.start                 # port from BBS.config.port (ENV['BBS_PORT'] or 2323)
BBS.start(port: 2400)

A BBS.start(port: config.port) a BBS::Server.new(port:).run-t futtatja, ami blokkol:

  1. a 0.0.0.0:PORT-on figyel (kiírja: BBS listening on port …);
  2. minden kapcsolatnál ellenőrzi a korlátokat: ha már max_sessions hívó van bent, kiírja, hogy Too many callers right now, try again later., naplózza: BBS busy: …, és bont; ha arról a címről már max_sessions_per_ip hívás fut, azt írja ki, hogy Too many calls from your address, try again later., és BBS crowded: …-ot naplóz;
  3. szálat indít, a socket olvasási/írási időkorlátját idle_seconds-ra állítja (nil esetén 900 s; ehhez a Ruby 3.2 IO#timeout=-ja kell), és egy új BBS::Session-t futtat;
  4. Session#run: negotiate, enable_mouse, ha a config.mouse és a config.mouse_on_connect is be van kapcsolva, majd FlowRunner.new(session, session_id, config.flow).run; utána mindig kikapcsolja az egeret és lezárja a socketet;
  5. amikor a session véget ér, bárhogy is, a szál csökkenti a számlálót, és meghívja a config.on_session_end-et.

Minden sor a BBS.log-on át megy, és a config.log_tag-gel kezdődik (lásd Adat és hálózat). A bontás vagy az elnémult vonal (IOError, EPIPE, ECONNRESET, ETIMEDOUT, IO::TimeoutError) feedia-bbs: session: dropped by the client (…) formában kerül a naplóba, nem hibaként. Minden más kivételt a session címke alatt naplóz, az osztályával, az üzenetével és három sor backtrace-szel. Magának az accept-nek a hibáinál (ECONNABORTED, EMFILE, ENFILE) a ciklus naplóz, és fél másodpercet vár ahelyett, hogy pörögne. Ctrl-C-re és SIGTERM-re (docker stop) kiírja: Server stopped..

A réteg config-mezői

Mező Alapértelmezés Jelentés
port ENV['BBS_PORT'] vagy 2323 A BBS.start TCP-portja.
idle_seconds nil A socket olvasási/írási időkorlátja (nil esetén 900 s) és az Application idle-bontása. Lásd Idle-korlátok.
max_sessions ENV['BBS_MAX_SESSIONS'] vagy 100 Egyszerre kiszolgált hívók.
max_sessions_per_ip ENV['BBS_MAX_SESSIONS_PER_IP'] vagy 6 Egy címről egyszerre kiszolgált hívások. A privát, loopback és link-local címekre (a bbs-web a Docker-hálózatból sok hívót proxyz) nem vonatkozik. A 0.33.0 óta.
log_tag ENV['BBS_LOG_TAG'] vagy bbs Amivel minden naplósor kezdődik: a board technikai neve, pl. feedia-bbs. Az 1.0.0 óta.
mouse false Használ-e a board egyáltalán egeret.
mouse_on_connect true A mouse mellett: kapcsolódáskor bekapcsolja-e a jelentést. A kétarcú board false-ra állítja (lásd Commodore 64).
on_session_end nil ->(session) { … }, a szerver szálából hívva minden session után, hiba után is.
RUBY
require 'bbs'

BBS.configure do |c|
  c.port           = 2400
  c.idle_seconds   = 600
  c.max_sessions   = 20
  c.on_session_end = ->(session) { puts "#{session.session_id} left (#{session.term_cols}x#{session.term_rows})" }
  c.flow = BBS::Flow.define do
    say 'Hello!'
  end
end

BBS.start

Telnet-egyeztetés

Közvetlenül a kapcsolódás után a session négy telnet-parancsot küld, és amíg mást nem hall, 80×24-es ablakot feltételez:

Bájtok Parancs Miért
ff fb 03 IAC WILL SGA Go-ahead elnyomása: karakterenként, nem soronként.
ff fb 01 IAC WILL ECHO A szerver visszhangoz, így a readline dönti el, mi jelenik meg (pl. egy maszk).
ff fd 03 IAC DO SGA Arra kéri a klienst, hogy ő is nyomja el a go-ahead-et.
ff fd 1f IAC DO NAWS Arra kéri a klienst, hogy jelentse az ablakméretét.

A kliens válaszait (WILL/WONT/DO/DONT + opció) ott olvassa be és dobja el, ahol épp érkeznek: a readkey-ben, a readline-ban, a settle-ben. Más opciókezelés nincs (se BINARY, se TTYPE). Mindezt egyetlen IAC-értelmező végzi, a BBS::Telnet::Parser (1.0.0 óta), ugyanaz, amelyet egy gateway mindkét irányban használ. Egy telnetet egyáltalán nem beszélő kliens (WiFi-modem mögötti C64, nc) egyszerűen sosem válaszol.

NAWS: az ablakméret

Amikor a kliens jelenti a méretét (IAC SB NAWS w1 w2 h1 h2 IAC SE), a session frissíti a term_cols-t és a term_rows-t:

  • az oszlopszám 20–255, a sorszám 8–100 közé szorul (0.33.0 óta); a nulla figyelmen kívül marad (a régi érték megmarad);
  • a jelentésen belüli escape-elt 255 (IAC IAC) 255-ként olvasódik;
  • egy alegyeztetésből legfeljebb 64 bájtot olvas, a többit eldobja (egy 65535×65535-ös jelentés nem tud gigabájtokat foglaltatni minden rendereléssel);
  • jelentés bármikor jöhet: az átméretezett ablakot a következő olvasás észreveszi, és egy Application újrarendezi az ablakait;
  • PETSCII-vonalon (a petscii! után) a méret 40×25 marad, de a naws_reported? ettől még igaz lesz.
Metódus Visszaad Jelentés
term_cols Integer Oszlopok: 80, amíg nem jön jelentés, utána a korlátok közé szorított érték; PETSCII-vonalon 40.
term_rows Integer Sorok: 24, amíg nem jön jelentés; PETSCII-vonalon 25.
naws_reported? Boolean Igaz, ha a kliens már jelentette az ablakméretét. Az igazi terminálok jelentik; a portszkennerek és a jelszópróbálgató botok általában nem.
settle(timeout: 1.5) a session vagy nil Legfeljebb timeout másodpercig vár az ablakméretre, és minden mást, ami közben jön, beolvas és eldob (a bot prompt előtt begépelt root-ja nem jut sehová). Ha megjött a méret, a sessiont adja vissza, ha nem, nil-t (az 1.0 előtt true-t vagy false-t). Ha a méret már ismert, azonnal visszatér.
resize!(cols, rows) — Úgy állítja be a méretet, mint egy NAWS-jelentés: ugyanúgy korlátok közé szorítja, a nullát figyelmen kívül hagyja, PETSCII-vonalon nem változtat semmit, és a naws_reported? igaz lesz. A telnetes NAWS-kezelés ezt hívja, és a BBS::Gateway is, ha a hívó átkelés közben méretez át (0.17.1 óta).

A settle-t használja a BBS::Terminal.guess is, hogy egy modern terminált megkülönböztessen egy C64-től. Olcsó botszűrő is lesz belőle:

RUBY
BOT_FILTER = BBS::Flow.define do
  call do |_ctx, runner|
    session = runner.session
    next nil if session.settle(timeout: 2)

    session.write "This board needs a telnet client that reports its window size.\r\n"
    :halt
  end
  say 'Welcome, human.'
end

A :halt-ot visszaadó call lépés befejezi a flow-t; a next egy értékkel lép ki a blokkból.

Billentyűk olvasása: readkey

RUBY
key = session.readkey                # blocks until a key (or the socket timeout)
key = session.readkey(timeout: 1.0)  # :idle when nothing came in 1 s

Ezek egyikét adja vissza:

  • egykarakteres String — nyomtatható karakter, UTF-8 is ("a", " ", "é", "ő");
  • Symbol — egy nevesített billentyű (lásd a táblázatot);
  • BBS::Telnet::MouseEvent — egérjelentés (csak bekapcsolt jelentésnél);
  • :idle — letelt a timeout, és nem jött billentyű;
  • nil — a hívó bontott.

A közben érkező telnet-parancsokat és NAWS-jelentéseket feldolgozza, sosem adja vissza. timeout nélkül az olvasás addig vár, amíg a socket saját időkorlátja (idle_seconds, alapból 900 s) IO::TimeoutError-t nem dob, ami egy IOError.

Billentyű-szimbólumok

Billentyű Symbol Amilyen bájtokból jön
Enter / Return :enter CR, LF, CR LF, CR NUL (mindegyik egy Enter)
Tab :tab 09
Shift-Tab :shift_tab ESC [ Z
Backspace :backspace 08, 7f
Esc :escape magányos ESC (0,15 s-on belül nem jön semmi), ESC ESC, vagy bármilyen escape-szekvencia, amit a rubbs nem ismer
Ctrl-C :interrupt 03
Ctrl-A … Ctrl-Z :ctrl_a … :ctrl_z 01–1a, kivéve a Ctrl-C-t, Ctrl-H-t, Ctrl-I-t, Ctrl-J-t és Ctrl-M-et, amelyek a fenti billentyűk
Nyilak :up, :down, :right, :left ESC [ A…D, ESC O A…D
Home / End :home, :end ESC [ H / F, ESC O H / F, ESC [ 1 ~ / 7 ~, ESC [ 4 ~ / 8 ~
Insert / Delete :insert, :delete ESC [ 2 ~, ESC [ 3 ~
Page Up / Down :page_up, :page_down ESC [ 5 ~, ESC [ 6 ~
F1 – F4 :f1 … :f4 ESC O P…S, ESC [ 11 ~…14 ~
F5 – F12 :f5 … :f12 ESC [ 15 ~, 17 ~, 18 ~, 19 ~, 20 ~, 21 ~, 23 ~, 24 ~
Alt + Enter :alt_enter ESC CR
Alt + nyomtatható :alt_a, :alt_x, :alt_1, … ESC + a karakter, kisbetűsítve (az Alt-Shift-A is :alt_a)
Módosított billentyűk :shift_up, :ctrl_f5, :alt_home, :ctrl_shift_right, … az xterm második paramétere, lásd lent
Időben nem jött semmi :idle letelt a timeout:

Módosítók. Az xterm a Shiftet, az Altot és a Ctrl-t egy második CSI-paraméterként jelenti: az ESC [ 1 ; 2 A a Shift-Fel, az ESC [ 15 ; 5 ~ a Ctrl-F5. A rubbs előtagot tesz a billentyű szimbóluma elé:

Paraméter Előtag Példa
2 shift_ :shift_up, :shift_f6
3 alt_ :alt_home, :alt_page_down
5 ctrl_ :ctrl_right, :ctrl_f5
6 ctrl_shift_ :ctrl_shift_left
4, 7, 8 nincs a sima billentyű (az ESC [ 1 ; 4 A :up)

A CSI-alakokra vonatkozik: nyilak, Home/End, Insert/Delete, Page Up/Down és a ~-os funkcióbillentyűk. Az xterm ESC [ 1 ; 5 P alakú módosított F1–F4-ét nem dekódolja, az :escape-ként olvasódik.

Más bájtok (NUL, 1c–1f, egy törött UTF-8 szekvencia) kimaradnak. PETSCII-vonalon a billentyűk a C64-éi: lásd Commodore 64.

Egéresemények

Bekapcsolt egérjelentésnél (enable_mouse) a kattintások, a húzások és a görgő BBS::Telnet::MouseEvent-ként jönnek vissza, ami egy Struct:

Mező Értékek
kind :press, :release, :move (mozgás lenyomott gombbal), :wheel
button :left, :middle, :right, :none; :wheel-nél :up vagy :down
x, y 1-től számolt oszlop és sor
mods :shift, :meta, :ctrl tömbje

Mindkét jelentésformátumot olvassa: az SGR-t (ESC [ < b ; x ; y M lenyomás, … m felengedés) és a régi X10-et (ESC [ M b x y, mindegyik plusz 32).

Metódus Mit csinál
enable_mouse Kiírja: \e[?1002h\e[?1006h: xterm gombesemény-követés (lenyomás, felengedés, mozgás lenyomott gombbal) SGR-formátumban. Nem csinál semmit, ha már be van kapcsolva, vagy PETSCII-vonalon.
disable_mouse Kiírja: \e[?1006l\e[?1002l\e[?1000l, ha be volt kapcsolva. A Session#run minden session végén meghívja.

Egy Application a MouseEventekből BBS::Event-eket csinál a widgeteknek; egy Door a programja idejére kikapcsolja a jelentést. Nyers call lépésben úgy kapod meg őket, ahogy jönnek.

Példa: nyers billentyűciklus

Egy call lépés, amíg fut, az övé a vonal. Ez kiír minden billentyűt, amit olvas, Esc-re vagy 30 tétlen másodperc után megáll, és túléli a bontást:

RUBY
require 'bbs'

KEYS = BBS::Flow.define do
  call do |_ctx, runner|
    s = runner.session
    s.settle                       # wait for NAWS, or term_cols is still the default 80
    s.enable_mouse
    s.write "#{s.term_cols}x#{s.term_rows}, NAWS: #{s.naws_reported?}. Press keys, Esc ends.\r\n"
    idle = 0
    loop do
      key = s.readkey(timeout: 1)
      break if key.nil? || key == :escape

      if key == :idle
        idle += 1
        break if idle >= 30

        next
      end
      idle = 0
      text = key.is_a?(BBS::Telnet::MouseEvent) ? "#{key.kind} #{key.button} at #{key.x},#{key.y}" : key.inspect
      s.write "\e[1;33m#{text}\e[0m\r\n"
    end
    s.disable_mouse
    nil
  end
  say 'Bye.'
end

BBS.configure { |c| c.flow = KEYS }
BBS.start

A hívás első lépése rögtön az egyeztetés elküldése után fut, mielőtt a kliens válaszai megérkeznének: settle nélkül a term_cols és a term_rows még 80×24 lenne, a naws_reported? pedig hamis. A ciklusból break-kel lépj ki, ne return-nel: a blokkon belüli return LocalJumpError-t dob (a FlowRunner hibaként kiírja, és megy tovább).

Amit néhány, tesztszkriptből küldött bemenetre kiírt (100×30-as NAWS-szal):

TEXT
a                  "a"
ESC [ A            :up
ESC [ 1 ; 2 A      :shift_up
ESC [ 15 ; 5 ~     :ctrl_f5
ESC O P            :f1
ESC [ Z            :shift_tab
ESC x              :alt_x
ESC CR             :alt_enter
01                 :ctrl_a
03                 :interrupt
7f                 :backspace
c3 a1              "á"
ESC [ < 0;10;5 M   press left at 10,5
ESC [ < 64;3;4 M   wheel up at 3,4
ESC [ < 2;7;8 m    release right at 7,8
ESC [ 1 ; 6 C      :ctrl_shift_right
ESC [ 24 ~         :f12

Példa: nyilakkal vezérelt menü

RUBY
require 'bbs'

MENU = BBS::Flow.define do
  call do |ctx, runner|
    s     = runner.session
    items = %w[News Files Chat Logoff]
    pick  = 0
    loop do
      s.write "\e[2J\e[H"
      items.each_with_index do |item, i|
        s.write(i == pick ? "\e[7m > #{item} \e[0m\r\n" : "   #{item}\r\n")
      end
      key = s.readkey
      case key
      when nil then break
      when :up then pick = (pick - 1) % items.size
      when :down, :tab then pick = (pick + 1) % items.size
      when :enter
        ctx[:choice] = items[pick]
        break
      when String
        hit  = items.index { |i| i.downcase.start_with?(key.downcase) }
        pick = hit if hit
      end
    end
    nil
  end
  say 'Done.'
end

BBS.configure { |c| c.flow = MENU }
BBS.start

Sor olvasása: readline

RUBY
name     = session.readline               # echoes what is typed
password = session.readline(mask: '*')    # echoes a * for each character
Viselkedés
Visszaad A sort UTF-8 stringként, sorvég nélkül; az érvénytelen bájtok kimaradnak. nil, ha a hívó bont.
Vége CR, LF, CR LF vagy CR NUL (egy sorvég). A végén kiír egy \r\n-t.
Visszhang Minden karakter, ahogy begépelték, vagy karakterenként a mask (egy UTF-8 karakter egy maszkot kap).
Backspace A 08 vagy a 7f egy teljes karaktert töröl, és \b \b-t ír.
Korlátok Legfeljebb 1024 bájt (Telnet::MAX_LINE); a további billentyűk elvesznek. A vezérlőbájtok is.
Időkorlát Nincs sajátja: a socket időkorlátjáig (idle_seconds) vár.
PETSCII C64-vonalon a C64 billentyűit olvassa, és PETSCII-ben visszhangoz.

A Flow ask lépése ezt használja; egy call lépésben:

RUBY
LOGIN = BBS::Flow.define do
  call do |ctx, runner|
    s = runner.session
    s.write 'Name: '
    ctx[:name] = s.readline or next :halt
    s.write 'Password: '
    password = s.readline(mask: '*') or next :halt
    next :halt unless password == 'secret'

    nil
  end
  say 'Logged in.'
end

Kipróbálva: a Zsé, Backspace, o, Enter "Zso"-t ad vissza (UTF-8); a maszkolt abc visszhangja ***.

Írás

Metódus Mit csinál
write(data) Stringet ír a hívónak. PETSCII-vonalon előbb az Encoderen megy át. Sosem dob kivételt: hibánál (lezárt socket) nil-t ad, és a bontás a következő olvasásnál derül ki.
line Maga a hívó socketje, egy doornak, amely változatlanul továbbadja a vonalat (BBS::Gateway), vagy olyan bájtokhoz, amelyeknek ki kell kerülniük a PETSCII-enkódert.
read_byte(timeout = nil) Egy nyers bájt Stringként, :timeout vagy nil; telnet-kezelés nélkül.
read_raw_byte(timeout) Egy bájt Integerként, bináris protokollokhoz (ZMODEM): a telnet-parancsok kiszűrve, a duplázott IAC 255-ként jön vissza; bontáskor :timeout vagy nil.

A send_raw létezik, de privát (ezzel kerüli meg az egyeztetés és a petscii! az Encodert); kívülről a session.line-ra írj.

RUBY
session.write "\e[1;32mOK\e[0m\r\n"
session.line.write "\x07".b          # a bell, past any encoder

A session azonosítója és állapota

Metódus Jelentés
session_id 16 hexa karakter (SecureRandom.hex(8)), minden kapcsolatnak új. A FlowRunner ctx[:session_id]-ként beteszi a contextbe, és a persist ezt használja store-kulcsnak.
term_cols, term_rows Lásd NAWS.
idle_seconds, idle_seconds= Ennek a hívónak a tétlenségi limitje, kapcsolódáskor a config.idle_seconds-ból másolódik. Az Application-hurok ezt olvassa; az állítása a socket timeoutját is átírja (nil esetén 900 s), így a session.idle_seconds = 3600 egy hívónak (egy sysopnak) egy órát ad. A 0.19.0 óta.
petscii!, petscii? A C64-vonal, lásd Commodore 64.

Idle-korlátok, session-korlátok, bontás

Idle. A config.idle_seconds két helyen hat:

Hol Mi történik
A socket Olvasási/írási időkorlát (nil esetén 900 s). Az ennyi ideig néma vonal, akár egy promptnál, egy call lépésben vagy egy doorban, IO::TimeoutError-t dob: a session megszakad.
BBS::Application Számolja a billentyűk közti tétlen másodperceket; a korlátnál kiírja: Disconnected after N s idle., és véget ér.

Max sessions. Egyszerre config.max_sessions (BBS_MAX_SESSIONS, különben 100) hívó, és egy nyilvános címről config.max_sessions_per_ip (BBS_MAX_SESSIONS_PER_IP, különben 6); a következő egy sort kap, a szerver bont, és BBS busy-t vagy BBS crowded-ot naplóz a két korláttal és a címmel (a stderr-re, puffer nélkül, így azonnal megjelenik a docker logs-ban). A max_sessions-t a konténer memóriájához kell méretezni: egy session nagyjából 0,5–1,7 MB a board saját 20–40 MB-ja fölött.

Bontás. Amikor a hívó bont:

  • a readkey és a readline nil-t ad, a write csendben nil-t;
  • a Flow IOError-t, EPIPE-ot vagy ECONNRESET-et dobó call lépése csendben leállítja a flow-t; nézd a nil-t és lépj ki break-kel, vagy dobj magad IOError-t (ahogy a C64-es képernyőosztályok teszik);
  • a Session#run kikapcsolja az egeret és lezárja a socketet; a szerver session dropped-ot logol, és meghívja az on_session_end-et.

Perzisztencia: BBS::Store

A BBS::Store egy kis tábla, kulcsonként (általában session-azonosítónként) egy sorral: thread-safe, és ellenáll a bájtoknak, amiket egy terminál küldhet. A 0.33.0 óta egy SQLite-fájlban él (egy BBS::SQLite-ban, lásd Adat és hálózat); előtte CSV-fájl volt, és egy régebbi board CSV-jét az első használatkor beimportálja.

RUBY
SESSIONS = BBS::Store.new(path: 'data/sessions.csv', headers: %w[session_id timestamp player_name code])

BBS::Store.new(path:, headers:)

Argumentum Jelentés
path: A fájl. A .csv végű útvonal a mellette lévő .sqlite3 fájlt jelenti (data/sessions.csv → data/sessions.sqlite3), így egy régebbi board megtarthatja az útvonalát; bármely más útvonal maga az SQLite-fájl. A mappa első használatkor jön létre.
headers: Az oszlopnevek tömbje (Stringek). A session_id a kulcsoszlop; a timestamp oszlop, ha van, magától kitöltődik. A később felvett fejlécből új oszlop lesz.

A store.path, a store.database_path és a store.headers visszaadja őket.

Metódusok

Metódus Visszaad Mit csinál
upsert(session_id:, **fields) a store-t Beszúr egy sort a session_id-hoz, vagy frissíti a meglévőt, egy tranzakcióban.
find(session_id) Hash vagy nil A sor Hashként, String kulcsokkal, csak a fejléc oszlopaival.
all Hash-ek tömbje Minden sor, a legrégebbivel kezdve.
delete(session_id) nil Törli a sort; ha nincs ilyen, nem történik semmi.
open! a store-t Azonnal megnyitja a fájlt (és beimportálja a régi CSV-t), hogy a board ezt induláskor tegye meg, ne az első hívója sessionjében.

Beszúrás (még nincs sor ezzel a kulccsal): a session_id a kulcs, a timestamp a pillanatnyi UTC-idő ISO 8601-ben (2026-10-27T22:15:45Z), a többi oszlop az azonos nevű mező (username: → username), üres (nil), ha nincs megadva.

Frissítés (a sor létezik): csak a megadott mezők változnak. A timestamp a beszúrás idejét őrzi. A nil-re állított mező üres string lesz.

A fejlécben nem szereplő mezőket figyelmen kívül hagyja, beszúrásnál és frissítésnél egyaránt.

Példa: oda-vissza

RUBY
require 'bbs'

USERS = BBS::Store.new(path: 'data/users.sqlite3', headers: %w[session_id timestamp username email])

USERS.upsert(session_id: 'a1', username: 'joe', email: 'joe@example.com')
USERS.upsert(session_id: 'b2', username: 'ann')
USERS.upsert(session_id: 'a1', email: 'joe@new.example')

USERS.find('a1')
# => {"session_id"=>"a1", "timestamp"=>"2026-10-27T22:15:45Z", "username"=>"joe", "email"=>"joe@new.example"}
USERS.find('zz')            # => nil
USERS.all.size              # => 2
USERS.delete('b2')
USERS.all.map { |r| r['username'] }   # => ["joe"]

A sorok a data/users.sqlite3 store táblájában vannak; a sqlite3 data/users.sqlite3 'SELECT * FROM store' a board futása közben is megmutatja őket.

Egy régebbi board CSV-je

Ha a tábla még üres, és létezik az útvonal CSV-fájlja, a Store egy tranzakcióban beimportálja, majd átnevezi users.csv.imported-ra. Az a sor, amely nem érvényes CSV, több mezője van, mint a fejlécnek, vagy nincs session_id-ja, nem állítja meg az importot: a fejléc alá kerül a users.csv.rejected fájlba a sysopnak, és a naplósor megszámolja (store: 412 rows of data/users.csv imported into data/users.sqlite3, 2 set aside in data/users.csv.rejected). Hívd meg induláskor az open!-t, hogy ez még az első hívó előtt lezajoljon.

Tiszta értékek

Ezekbe a táblákba a hívók gépelnek, a terminál pedig bármit küldhet. Minden érték Stringgé alakul, és a BBS::Text.clean tisztítja meg: az érvénytelen UTF-8 bájtok kimaradnak, ahogy a C0 vezérlőkarakterek (a tab és az újsor marad), a DEL, a C1 vezérlők és a kétirányú szöveg felülíró karakterei is. Egy ANSI-szekvenciából csak az ESC bájt tűnik el: az "ann\e[31m" ann[31m-ként tárolódik.

RUBY
s = BBS::Store.new(path: 'data/log.sqlite3', headers: %w[session_id name note])
s.upsert(session_id: 's1', name: "ann\e[31m\x00 bé", note: "two\nlines")
s.find('s1')
# => {"session_id"=>"s1", "name"=>"ann[31m bé", "note"=>"two\nlines"}

Szálak

Minden hívás egyetlen, zár mögötti közös kapcsolaton át éri el az SQLite-fájlt, így egy Store objektumot az összes session-szál megoszthat. A fájl WAL módban van: a board egy parancssori eszköze futás közben is olvashatja (és írhatja), a board pedig a következő hívásánál már látja a változást. Memóriabeli cache nincs többé.

Flow-val: persist

A Flow persist lépése context-kulcsokat ír egy store-ba, a session-azonosítóval mint kulccsal:

RUBY
persist :player_name, :code, store: SESSIONS

A megnevezett kulcsok közül azokat veszi, amelyek benne vannak a contextben (a még be nem állítottak kimaradnak), és meghívja a store.upsert(session_id: a context session-azonosítója, **ezek)-et; ha egyik sincs beállítva, nem csinál semmit. Egy későbbi persist ugyanazt a sort frissíti. Lásd Flow.

Az Impostor BBS (bbs/impostor-bbs/lib/impostor.rb) így tart minden kihallgatást egyetlen store-ban:

RUBY
module Impostor
  SESSION_HEADERS = %w[session_id timestamp player_name code feedback email].freeze

  def self.sessions_store(path) = BBS::Store.new(path: path, headers: SESSION_HEADERS)

  def self.debrief(sessions:, screens_dir:)
    BBS::Flow.define(screens_dir: screens_dir) do
      ask :player_name, prompt: 'Player Name', transform: :upcase
      ask :code,        prompt: 'Code'
      persist :player_name, :code, store: sessions
      # ...
      menu '>', loop: true do
        option 'Leave feedback' do
          ask :feedback, prompt: 'Feedback'
          persist :feedback, store: sessions
        end
        option 'Subscribe to updates' do
          ask :email, prompt: 'Email address', validate: :email
          persist :email, store: sessions
        end
        option('Exit') { exit_menu }
      end
    end
  end
end

# bbs.rb: one store, shared by both faces and every session
sessions = Impostor.sessions_store(File.expand_path('data/sessions.csv', __dir__)).open!

Flow-n kívül

A kulcs csak egy string: a hívó nevével kulcsolt store hívásokon át megőrzi az adatot.

RUBY
PROFILES = BBS::Store.new(path: 'data/profiles.sqlite3', headers: %w[session_id timestamp calls last_seen])

def count_call(name)
  calls = PROFILES.find(name)&.fetch('calls').to_i
  PROFILES.upsert(session_id: name, calls: calls + 1, last_seen: Time.now.utc.iso8601)
end

Egy Application-handlerben vagy egy call lépésben a session-azonosító a ctx[:session_id] (Applicationön belül a context[:session_id]).

Ha kinövöd

A Store egyetlen, szöveges oszlopokból álló tábla, egyetlen stringgel kulcsolva. Egy board, amelynek posztjai, szálai vagy több táblája van, közvetlenül a BBS::SQLite-ot használja, saját sémával és számozott migrációkkal: lásd Adat és hálózat.

Lásd még