---
title: 'Telnet, session és store'
url: 'https://wiki.teletypegames.org/hu/engines/rubbs/telnet-and-store'
markdown: 'https://wiki.teletypegames.org/hu/engines/rubbs/telnet-and-store.md'
lang: hu
date: '2026-10-27'
description: '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, …'
taxonomy:
  tag:
    - reference
---

# Telnet, session és store

> A [rubbs](https://wiki.teletypegames.org/hu/engines/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](https://wiki.teletypegames.org/hu/engines/rubbs/data-and-network) 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](https://wiki.teletypegames.org/hu/engines/rubbs/data-and-network)). 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](https://wiki.teletypegames.org/hu/engines/rubbs/commodore)). |
| `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](https://wiki.teletypegames.org/hu/engines/rubbs/commodore).

## 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](https://wiki.teletypegames.org/hu/engines/rubbs/commodore). |

# 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](https://wiki.teletypegames.org/hu/engines/rubbs/data-and-network)); 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](https://wiki.teletypegames.org/hu/engines/rubbs/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](https://wiki.teletypegames.org/hu/engines/rubbs/data-and-network).

# Lásd még

- [rubbs](https://wiki.teletypegames.org/hu/engines/rubbs) — az áttekintés
- [Első lépések](https://wiki.teletypegames.org/hu/engines/rubbs/getting-started) — `BBS.configure` és az első board
- [Flow](https://wiki.teletypegames.org/hu/engines/rubbs/flow) — `ask`, `persist`, `call`
- [Application](https://wiki.teletypegames.org/hu/engines/rubbs/application) — az ezekből a billentyűkből épülő események
- [Commodore 64](https://wiki.teletypegames.org/hu/engines/rubbs/commodore) — a PETSCII-vonal és a billentyűi
- [Doorok](https://wiki.teletypegames.org/hu/engines/rubbs/doors) — a vonal továbbadása
- [Adat és hálózat](https://wiki.teletypegames.org/hu/engines/rubbs/data-and-network) — SQLite, HTTP, cache, rate limit, naplózás

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [RuBBS](https://wiki.teletypegames.org/hu/engines/rubbs.md)
- Previous: [Minimális projekt](https://wiki.teletypegames.org/hu/engines/rubbs/minimal-project.md)
- Next: [Widgetek](https://wiki.teletypegames.org/hu/engines/rubbs/widgets.md)
