EN /HU | Login

Doors

Everything in rubbs that hands the caller's telnet line to something else and takes it back: doors (BBS::Door), simulated machines to type into (BBS::FakeOS), telnet gateways to other boards (BBS::Gateway), and ZMODEM file downloads to the caller's terminal (BBS::Zmodem). Every example on this page was run against rubbs 0.17.0, and the page is updated for 0.17.1.

What a door is

On the old boards a door was a separate program the caller stepped into: the BBS handed over the line, the program talked to the caller directly, and the line came back when the program exited. In rubbs a door is a BBS::Door that wraps one of three kinds of program:

Program What it is How the door runs it How the caller leaves
BBS::FakeOS definition a simulated machine: a boot script and a command shell FakeOS::Runner.new(session, definition, context: context).run a command that calls io.halt (EXIT, BYE, …)
BBS::Flow a line dialogue FlowRunner on a copy of the context, then "Press ENTER to return to the BBS..." the flow ends, then ENTER
anything with call(session, context) a lambda, a method object, a class of your own, a BBS::Gateway program.call(session, context) the method returns

A door round trip, from an Application:

MERMAID
sequenceDiagram
  participant C as Caller
  participant A as Application
  participant B as Builder#run
  participant D as Door#run
  participant P as Program
  C->>A: picks Doors > TT-80
  A->>A: open_door(:tt80) sets context[:door], stop!
  A-->>B: event loop ends
  B->>B: on_door block (door, context)
  B->>D: door.run(session, context)
  D->>C: mouse off, reset colours, clear screen
  D->>P: run the program
  P->>C: talks to the caller on the line
  C->>P: EXIT
  P-->>D: returns
  D->>C: mouse back on (if BBS.config.mouse)
  D-->>B: returns
  B->>A: builds the Application again (same context)
  A->>C: the desktop, repainted

The smallest door is a lambda, run from a flow:

RUBY
require 'bbs'

DICE = BBS::Door.new(:dice, lambda { |session, ctx|
  session.write("Rolling for #{ctx[:username]}... #{rand(1..6)}\r\nPress a key.")
  session.readkey
}, label: '&Dice')

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      DICE.run(runner.session, ctx)
      runner.write "\r\nBack on the board.\r\n"
      nil
    end
  end
end

BBS.start

BBS::Door

Source: lib/bbs/door.rb.

Door.new

RUBY
BBS::Door.new(name, program, label: nil)
Argument Default Meaning
name required the door's name; stored as a Symbol (door.name)
program required a BBS::FakeOS::Definition, a BBS::Flow, or any object that responds to call
label: name.to_s.upcase the menu text; & marks the hotkey, as in '&SERMO'

Anything else raises ArgumentError ("not a FakeOS definition or a callable"). The readers are name, label and program.

Door#run

RUBY
door.run(session, context = {})

What it does, in order:

  1. session.disable_mouse, so the program does not get mouse reports it cannot read.
  2. Writes ESC[0m ESC[2J ESC[H: default colours, a clear screen, the cursor at the top left.
  3. Runs the program by its kind (the table above).
  4. Afterwards, whatever happened: session.enable_mouse if BBS.config.mouse is set.

It returns nil when the program fails (see below); otherwise what the program returned.

The context

The context hash is the caller's state for the whole call. A FakeOS door and a callable door get the same hash: what the door writes there stays after it returns, so a door can keep its state (an AI chat history, a drive letter, a score) for the rest of the call, and the board can read it afterwards.

A BBS::Flow door gets context.dup instead, so its ask steps cannot overwrite the board's own keys (:username, :authenticated, …). The flow runner also needs context[:session_id]; inside an Application it is already there, on its own pass it in. Give a flow that ships with its own ERB screens a screens_dir: (see Flow). A flow has no exit command of its own, so when it ends the door writes "Press ENTER to return to the BBS..." and waits for a line.

Errors and hang-ups

A door's fault must not end the caller's call:

What happens in the program Result
any StandardError (a bug, a failed lookup) logged with BBS.log under door NAME (class, message, three backtrace lines); run returns nil and the caller is back on the board
IOError or SystemCallError (the line dropped) re-raised: the call ends, as it should
a FakeOS or Flow program sees the line drop its runner swallows IOError, EPIPE and ECONNRESET and returns; the board notices the hang-up at its next read
RUBY
require 'bbs'

broken = BBS::Door.new(:broken, ->(_session, _ctx) { raise 'boom' })
broken.run(session, ctx)   # logs "my-bbs: door broken: RuntimeError: boom", returns nil

dropped = BBS::Door.new(:dropped, ->(_session, _ctx) { raise IOError, 'line dropped' })
dropped.run(session, ctx)  # raises IOError: the call ends

Doors in an Application

The Application DSL declares doors, lists them in a menu, and opens them from any handler. Source: lib/bbs/application.rb.

DSL / method Where What it does
door(name, program, label: nil) Application.define declares a door: Door.new(name, program, label: label), kept in declaration order
doors inside menu(...) { } one menu item per declared door, labelled with door.label, each calling open_door(door.name)
open_door(name) on the running application (menu items, status hints, key handlers, window buttons, list callbacks, helpers) puts the door in context[:door], stops the event loop after the current event and returns :halt; raises ArgumentError for an unknown name
on_door { \|door, context\| } Application.define runs just before the line goes to a door: a node list entry, a log line
Builder#run(session:, session_id:, context: {}) the defined application builds and runs the application; when it ends through a door, runs the door and builds the application again; returns when the caller leaves without a door, or the line is lost (then a pending door is not opened)
Application#doors the running application the declared doors as a Hash by name

open_door works through Builder#run, which the flow step app BOARD also uses (since 0.17.1). A plain BOARD.build(...).run runs the event loop once and stops: open_door just ends the application there. Start an application with doors from a flow app step, or from a call step when code goes around it, as here:

RUBY
require 'bbs'
require_relative 'tt80' # the TT-80 FakeOS from the complete example below

QUIZ = BBS::Flow.define do
  big_banner 'Quiz', font: 'small'
  ask :answer, prompt: 'What is 6 x 7'
  call do |ctx, runner|
    runner.write(ctx[:answer] == '42' ? "  Right!\r\n" : "  No, it is 42.\r\n")
    nil
  end
end

DICE = lambda do |session, ctx|
  session.write("Rolling for #{ctx[:username]}... #{rand(1..6)}\r\nPress a key.")
  session.readkey
end

BOARD = BBS::Application.define do
  door :tt80, TT80, label: '&TT-80'
  door :quiz, QUIZ, label: '&Quiz'
  door :dice, DICE, label: '&Dice'

  menubar do
    menu('&Doors') { doors }
  end

  status_hint('F5', 'Dice') { open_door(:dice) }
  status_hint('Alt-X', 'Bye') { :halt }

  on_door do |door, ctx|
    warn "#{ctx[:username]} enters #{door.name}"
  end
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      BOARD.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
  end
end

BBS.start

F5 rolls the dice, Alt-D opens the Doors menu with TT-80, Quiz and Dice. The quiz's :answer stays in the quiz's copy of the context; the TT-80's drive letter stays in the board's context.

In the Teletype BBS (bbs/teletype-bbs) the board keeps its doors as data (TeletypeBBS#doors in lib/domain/teletype_bbs.rb: name, program, label, the narrowest width it needs, an optional C64 version), the modern face declares them in a loop, and on_door puts "Door: SERMO" on the caller's node in Who is Online:

RUBY
bbs.doors.each { |d| door d.name, d.program, label: d.label }

on_door do |door, ctx|
  bbs.online.activity(ctx[:session_id], "Door: #{door.label.delete('&')}")
end

A door without an Application

Door#run needs only a session and a context. A flow call step, a PETSCII screen or your own code can open one directly, as in the first example on this page. The PETSCII face of the Teletype BBS (lib/ui/petscii/board.rb) has no Application and does exactly this, picking the 40-column version of a door when it has one:

RUBY
BBS::Door.new(door.name, door.petscii || door.program, label: door.label).run(@screen.session, @ctx)

Without the Door wrapper, a flow can also run a FakeOS in place with the os step. It shares the flow's context, but gets no screen clearing, no mouse handling and no error guard:

RUBY
BBS.config.flow = BBS::Flow.define do
  ask :username, prompt: 'Your name'
  os TT80
  say 'Back on the board.'
end

BBS::FakeOS

Source: lib/bbs/fake_os.rb. A teletype-style simulated machine: a scripted boot sequence, then a command shell. Each simulated OS is a different definition; the engine is generic, so a new machine is configuration, not new engine code.

The shape of a definition

RUBY
require 'bbs'

DOS = BBS::FakeOS.define do
  name   'MS-DOS'
  prompt 'C:\\>'
  colors text: "\e[0;37m", accent: "\e[1;32m"

  boot do
    print 'Award Modular BIOS v4.51PG', delay: 0.4
    memtest 16_384
    pause 0.5
    cls
    big_banner 'MS-DOS'
  end

  ready { puts 'Type VER, CLS or EXIT.' }

  command('ver')          { |_args, io| io.puts 'MS-DOS Version 6.22' }
  command('cls')          { |_args, io| io.cls }
  command('exit', 'quit') { |_args, io| io.halt }

  on_unknown do |_input, io|
    io.puts 'Bad command or file name'
  end
end

BBS::FakeOS.define { ... } evaluates the block on a new FakeOS::Definition and returns it. There are two kinds of block in a definition, and they see different things:

  • boot and ready run on the console (instance_eval): print, puts, cls, ctx, session and the rest work without a receiver. Note that puts and print there write to the caller, not to the server's stdout; log with warn.
  • command and on_unknown blocks are called with arguments: (args, io) and (input, io). Use io for all output and state.

The definition DSL

Method Default What it does
name(value = nil) 'OS' sets the name; without an argument returns it. Informational: the shell does not show it
prompt(value = nil, &block) '> ' a String, or a block / lambda taking the context and returning the prompt, evaluated before every line; without arguments returns the current one. '' is allowed (the Commodore doors print their own READY.)
colors(overrides = nil) DEFAULT_COLORS merges SGR strings into the colour roles; without an argument returns the role Hash
boot { } none the power-on script, run once on the console
ready { } none runs once after boot, before the first prompt
command(*names, help: nil) { \|args, io\| } none registers a command under one or more names; the first is the canonical one
on_unknown { \|input, io\| } prints "Bad command or file name" what happens with input that matches no command

The readers boot_block, ready_block, commands, order and unknown expose what was declared. commands maps every lower-cased name (aliases included) to { name:, help:, block: }; order lists the canonical names in declaration order. A HELP command builds its list from these (see the complete example).

A prompt that follows the state of the machine:

RUBY
prompt { |ctx| "#{ctx[:drive] || 'A'}> " }
# or
prompt ->(ctx) { "#{ctx[:drive] || 'A'}> " }

Colour roles and base

Every output primitive takes a style: role. An unknown role falls back to :text.

Role Default Used by
text "\e[0;37m" (light grey) puts, print, type, memtest
bright "\e[1;37m" your highlights
accent "\e[1;32m" banner, big_banner
error "\e[1;31m" "Bad command or file name", "Error: …"
dim "\e[1;30m" your quiet text (AI replies, hints)
prompt "\e[0;37m" the prompt
base '' what every reset returns to

After every piece of output the console writes ESC[0m followed by base, and cls writes the same before erasing the screen. So when base sets a background, that background survives the prompt, the caller's echo and the new lines, and cls fills the whole screen with it. Give every other role the same background too: an SGR that starts with 0; clears it.

RUBY
colors base:   "\e[44m",          # blue screen
       text:   "\e[0;37;44m",
       bright: "\e[0;97;44m",
       error:  "\e[0;91;44m",
       prompt: "\e[0;97;44m"

The console

FakeOS::Console owns the session and the output. It is self in boot and ready, and the io argument of commands.

Method Defaults What it does
puts(text = '', style: :text) one line, ending in CRLF
print(text = '', delay: 0, style: :text, newline: 1) text, then newline CRLFs, then a pause of delay seconds; newline: 0 leaves the cursor on the line
type(text, style: :text, cps: 900, newline: 1) a typewriter: one character at a time at cps characters a second (cps: 0 writes at once)
newline(count = 1) count CRLFs
cls resets the colours (to base) and clears the screen
pause(seconds) sleeps
banner(text, style: :accent) a boxed one-line banner (BBS::Banner.render)
big_banner(text, style: :accent, font: 'standard') a figlet banner through artii ('standard', 'small', 'slant', …)
memtest(kilobytes, step: 2048, delay: 0.03, label: 'Memory Test : ') the counting memory test, in place on one line, ending in "64K OK"
cols, rows 80, 24 the caller's window size (telnet NAWS)
halt ends the shell after the current command
ctx the context hash (the door's context)
session the BBS::Session, for readline, readkey, write
definition the definition being run

The shell loop

After boot and ready the console loops until halt or a hang-up:

  1. Writes the prompt in the prompt role.
  2. Reads a line (session.readline); nil, a hang-up, ends the loop.
  3. Strips it; an empty line just prompts again.
  4. Splits off the first word, lower-cases it and looks it up in commands, so commands are case-insensitive. The block gets the rest of the line as a String ('' when there is none), and the console.
  5. No match: on_unknown gets the whole stripped line; without on_unknown, "Bad command or file name" in the error role.

A StandardError in a command or in on_unknown is logged (my-bbs: fake os command: …) and shown to the caller as "Error: MESSAGE" in the error role, and the shell goes on. IOError and SystemCallError pass through. An exception in boot or ready is not caught by the console: inside a door it takes the caller back to the board.

There is no built-in EXIT or HELP. Register them yourself; EXIT calls io.halt:

RUBY
command 'exit', 'quit', 'bye', help: 'back to the BBS' do |_args, io|
  io.puts 'Goodbye.'
  io.halt
end

A command can hold its own little dialogue through the session:

RUBY
command 'name', help: 'asks for your name' do |_args, io|
  io.print 'NAME? ', newline: 0
  name = io.session.readline.to_s.strip
  io.puts "HELLO, #{name.upcase}."
end

Running a FakeOS

How Context Extras
BBS::Door.new(:name, DEFINITION).run(session, ctx) the same hash clear screen, mouse off and on, error guard
door :name, DEFINITION in an Application the application's context all of the above, plus the menu and on_door
os DEFINITION step in a flow the flow's context none
BBS::FakeOS::Runner.new(session, DEFINITION, context: {}).run the given hash none; returns nil on a hang-up

A complete machine: the TT-80

A made-up 1980 microcomputer with a blue screen, a boot sequence, a prompt that shows the current drive, a generated HELP, a command with arguments, one with its own question, one that fails on purpose, and EXIT. Save it as tt80.rb:

RUBY
require 'bbs'

TT80 = BBS::FakeOS.define do
  name   'TT-80'
  prompt ->(ctx) { "#{ctx[:drive] || 'A'}> " }

  blue = "\e[44m"
  colors base:   blue,
         text:   "\e[0;37;44m",
         bright: "\e[0;97;44m",
         accent: "\e[0;93;44m",
         error:  "\e[0;91;44m",
         dim:    "\e[0;36;44m",
         prompt: "\e[0;97;44m"

  boot do
    cls
    print 'TT-80 ROM MONITOR V1.3', delay: 0.2
    memtest 64, step: 16, delay: 0.02, label: 'RAM CHECK '
    pause 0.3
    cls
    banner 'TT-80 DISK OPERATING SYSTEM'
    type 'Insert a disk, or just type.', style: :dim, cps: 120
    ctx[:drive] ||= 'A'
  end

  ready do
    puts "#{session.term_cols}x#{session.term_rows} screen. HELP lists the commands.", style: :dim
    newline
  end

  command 'help', '?', help: 'this list' do |_args, io|
    io.definition.order.each do |name|
      entry = io.definition.commands[name]
      io.puts format('  %-6s %s', name.upcase, entry[:help]), style: :bright
    end
  end

  command 'dir', help: 'list the disk' do |_args, io|
    io.puts "  #{io.ctx[:drive]}: GAMES    BAS   #{io.ctx[:drive]}: README   TXT"
  end

  command 'drive', help: 'DRIVE B switches the drive' do |args, io|
    letter = args.strip.upcase
    if letter.match?(/\A[AB]\z/)
      io.ctx[:drive] = letter
    else
      io.puts 'DRIVE A or DRIVE B', style: :error
    end
  end

  command 'name', help: 'asks for your name' do |_args, io|
    io.print 'NAME? ', newline: 0
    name = io.session.readline.to_s.strip
    io.puts "HELLO, #{name.upcase}."
  end

  command 'size', help: 'the screen size' do |_args, io|
    io.puts "#{io.cols} COLUMNS, #{io.rows} ROWS"
  end

  command 'crash', help: 'a broken command' do |_args, _io|
    raise 'DISK NOT READY'
  end

  command 'exit', 'bye', help: 'back to the BBS' do |_args, io|
    io.puts 'SYSTEM HALTED.'
    io.halt
  end

  on_unknown do |input, io|
    io.puts "#{input.split.first.upcase}? SYNTAX ERROR", style: :error
  end
end

And a board that opens it as a door, bbs.rb:

RUBY
require 'bbs'
require_relative 'tt80'

TT80_DOOR = BBS::Door.new(:tt80, TT80, label: '&TT-80')

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    ask :username, prompt: 'Your name'
    call do |ctx, runner|
      TT80_DOOR.run(runner.session, ctx)
      runner.write "\r\nBack on the board, #{ctx[:username]}. Drive was #{ctx[:drive]}.\r\n"
      nil
    end
  end
end

BBS.start

A session with a 100×30 terminal:

TEXT
TT-80 ROM MONITOR V1.3
RAM CHECK 64K OK

  ╔═══════════════════════════════╗
  ║  TT-80 DISK OPERATING SYSTEM  ║
  ╚═══════════════════════════════╝

Insert a disk, or just type.
100x30 screen. HELP lists the commands.

A> help
  HELP   this list
  DIR    list the disk
  DRIVE  DRIVE B switches the drive
  NAME   asks for your name
  SIZE   the screen size
  CRASH  a broken command
  EXIT   back to the BBS
A> drive b
B> crash
Error: DISK NOT READY
B> foo bar
FOO? SYNTAX ERROR
B> exit
SYSTEM HALTED.

Back on the board, Zsolt. Drive was B.

FakeOS in production

SERMO (bbs/sermo-bbs, gem sermo, lib/sermo/sermo_os.rb): a green-phosphor machine from the Neumatronic universe. The boot sequence is a page of print ..., delay: lines and a memtest with its own label:; eleven literal commands (VER, DIR, MEM, …) are the "digital side", and on_unknown sends everything else to an AI character through BBS::AI::Typewriter (see AI characters). The gem is only the definition, Sermo::OS; the host board runs it:

RUBY
require 'sermo'

BBS::FakeOS::Runner.new(session, Sermo::OS, context: ctx).run
# or, in an Application:
door :sermo, Sermo::OS, label: '&SERMO'

Commodore 64 and Plus/4 (bbs/teletype-bbs, lib/doors/commodore.rb): one Commodore.os(machine, games:, sizes:) builds a FakeOS for either machine. The definition is tiny; the machine lives in a Shell object kept in the context:

RUBY
BBS::FakeOS.define do
  name   machine.platform
  prompt ''                      # the BASIC prints READY. itself
  colors machine.colors          # base: "\e[94;44m" for the C64's blue screen

  boot do
    ctx[:commodore] = Shell.new(self, machine, games: games, files: files)
    ctx[:commodore].power_on
  end

  on_unknown { |input, io| io.ctx[:commodore].enter(input) }
end

There are no command entries at all: every line goes to the shell, which parses LOAD"$",8, LIST, RUN and BASIC lines, wraps output at 40 columns itself, and calls io.halt on EXIT. A running BASIC program polls io.session.readkey(timeout: 0) so any key works as RUN/STOP.

Chat of Ra (lib/doors/ra.rb): a sun-god character with commands for the game logic (RIDDLE, HINT, SKIP, GAME) and on_unknown for free talk to the model.

BBS::Gateway

Source: lib/bbs/gateway.rb and lib/bbs/gateway/, new in 0.17.0; the report and connect_timeout: came in 0.31.0, translate: in 0.34.0, public_only: in 1.0.0. A door to another telnet board, like the telnet gateways of the old BBSes: the caller's line goes on to that board as it is, and comes back when that board hangs up. The other board runs its own code on its own server; the gateway only carries the bytes.

Gateway.new

RUBY
BBS::Gateway.new(host:, port:, name: nil, terminal: 'XTERM-256COLOR', connect_timeout: 5, idle: nil,
                 translate: false, public_only: true)
Option Default Meaning
host: required the other board's host name or address
port: required its telnet port (converted with to_i)
name: "HOST:PORT" the name in "Connecting to …", "… hung up" and the log
terminal: 'XTERM-256COLOR' the terminal type reported when the other board asks (telnet TTYPE)
connect_timeout: 5 seconds to wait for the other board to accept the connection
idle: nil seconds the line may stay silent both ways before the call ends as if the caller hung up; nil means BBS.config.idle_seconds (900 s when that is unset)
translate: false a PETSCII caller gets the other board through an AsciiBridge (see A C64 calling an ASCII board); a modern caller's line is never translated
public_only: true call only an address on the public internet (BBS::Address.public?); false for a trusted neighbour such as host.docker.internal
Constant Value Meaning
Gateway::CONNECT_TIMEOUT 5 the default connect_timeout:
Gateway::BACK_WAIT 30 seconds to wait for the caller's key before taking the line back anyway

The readers are host, port and name. A gateway is a callable, call(session, context = {}), so it becomes a door like any program:

RUBY
door :feedia, BBS::Gateway.new(host: 'feedia.example', port: 2327, name: 'Feedia'), label: '&Feedia'

Public addresses only

Since 1.0.0 the gateway resolves the host once (an IPv4 address when it has one), checks the address with BBS::Address.public? and connects to that vetted address. Loopback, private, link-local, CGNAT and ULA addresses are refused: a listing that names 127.0.0.1 or a container cannot turn the board into a relay into its own network. The caller reads "NAME may not be called from here.", and the report's error is a BBS::Gateway::Refused.

A board that dials its own neighbours on purpose says so:

RUBY
FEEDIA = BBS::Gateway.new(host: 'host.docker.internal', port: 2327, name: 'Feedia', public_only: false)

A report of each call

A directory that dials many boards wants to know how each call went. With a block, call yields a BBS::Gateway::Report once the call is over (also when the caller hung up inside):

Field Meaning
connected whether the other board accepted the connection
error what kept it from connecting: SocketError (the name does not resolve), BBS::Gateway::Refused, Errno::ECONNREFUSED, a timeout; nil when it connected
address the IP address called
connect_seconds how long the connection took to open
received the bytes the other board sent, telnet commands not counted
hung_up who ended the crossing: :board or :caller
RUBY
BBS::Gateway.new(host: board.host, port: board.port, name: board.name, connect_timeout: 10).call(session) do |report|
  record(board, report.connected ? 'ok' : report.error.class.name)
end

A C64 calling an ASCII board

A gateway carries the bytes untouched, so without help a Commodore 64 can only call boards that speak PETSCII. With translate: true a PETSCII caller's line goes through a BBS::Gateway::AsciiBridge (since 0.34.0):

  • the board's text (UTF-8, or CP437 once it turns out not to be UTF-8) reaches the C64 as PETSCII through the PETSCII encoder: letters in the C64's case, ANSI colours as C64 colours, line drawing as the nearest glyph;
  • the C64's keys reach the board as an ANSI terminal sends them: RETURN as CR, DEL as backspace, the cursor keys as ESC [ A…;
  • behind the bridge the C64 answers TTYPE as ANSI and NAWS with its 40×25, so the board formats for 40 columns.
RUBY
BBS::Gateway.new(host: board.host, port: board.port, translate: !board.petscii?).call(session)

A crossing

MERMAID
sequenceDiagram
  participant C as Caller
  participant G as Gateway (this board)
  participant R as Other board
  C->>G: picks the door
  G->>C: Connecting to NAME…
  G->>R: TCP connect (5 s)
  R->>G: IAC DO NAWS, IAC DO TTYPE, IAC WILL ECHO …
  G->>R: IAC WILL NAWS + the caller's size, IAC WILL TTYPE, IAC DO ECHO …
  R->>G: IAC SB TTYPE SEND
  G->>R: IAC SB TTYPE IS XTERM-256COLOR
  loop until one side hangs up
    R->>G: screen bytes
    G->>C: passed on as they are
    C->>G: keys, mouse reports
    G->>R: passed on as they are
    C->>G: IAC SB NAWS (window resized)
    G->>R: IAC SB NAWS (new size)
  end
  R--xG: hangs up
  G->>C: NAME hung up. Press a key to return.
  C->>G: a key
  G-->>C: back on this board

The telnet client side

Towards the other board the gateway is the telnet client. It answers each (command, option) pair once, so a board that asks again gets no loop:

The other board sends The gateway answers
WILL ECHO, WILL SGA DO
WILL anything else DONT
DO NAWS WILL NAWS, then the caller's window size; on a PETSCII line WONT NAWS
DO SGA WILL SGA
DO TTYPE (option 24) WILL TTYPE; on a PETSCII line WONT TTYPE
WILL BINARY, DO BINARY agreed, and the same asked of the caller's terminal, so a BBS::Xmodem transfer works through the gateway
DO anything else WONT
WONT, DONT nothing
SB TTYPE SEND SB TTYPE IS plus the terminal: string; nothing on a PETSCII line

And in the other direction:

  • Ordinary bytes go through untouched both ways: ANSI, UTF-8, mouse reports, PETSCII. A doubled IAC IAC (a 0xFF data byte) stays doubled.
  • The caller's window-size reports (SB NAWS) update this board's session too (Session#resize!), so after the crossing the board redraws at the new size; they are passed on as fresh NAWS reports once the other board has asked for NAWS.
  • The caller's other telnet commands are not passed on: this board already negotiated with the caller's terminal, and the other board gets the gateway's answers instead.

A PETSCII line (after session.petscii!, see Commodore 64) reports no window size and no terminal type, so a two-faced board on the other end (BBS::Terminal) takes the caller for a Commodore 64 and speaks PETSCII. The bytes it sends go to the line untouched, not through this board's PETSCII encoder; only with translate: does the gateway step in (see A C64 calling an ASCII board).

session.line is the caller's raw socket. The gateway reads and writes it directly; a door of your own that hands the line on as it is can do the same.

Hang-ups and failures

What happens What the caller sees What call does
the other board hangs up "NAME hung up. Press a key to return." waits up to 30 s for a key, then returns: back on this board
the connection is refused, the host is unknown, or connect_timeout passes "NAME does not answer. Try again later. Press a key." logs the error (gateway: NAME (HOST:PORT): …), waits up to 30 s for a key, returns nil
the address is not public and public_only is on "NAME may not be called from here. Press a key." the same, with BBS::Gateway::Refused
the line stays silent both ways for idle seconds nothing ends the call as if the caller hung up
the caller hangs up nothing shuts the other board's socket and raises IOError: the call ends (Door#run re-raises it)

Two consequences worth knowing:

  • A crossing ends when the line is silent both ways for idle: seconds (BBS.config.idle_seconds, 900 s when unset); a caller who only reads while the other board keeps sending stays on.
  • The other board's terminal modes stay as it left them. If it switched mouse reporting on and hung up without switching it off, and this board has BBS.config.mouse off, the caller's terminal keeps sending mouse reports.

Example: one board as a door of another

Board B on port 2473 runs the TT-80 from above; board A on port 2474 offers it, and a board that is not there, as gateways:

RUBY
# board_b.rb
require 'bbs'
require_relative 'tt80'

BBS.configure do |c|
  c.port = 2473
  c.flow = BBS::Flow.define do
    call do |ctx, runner|
      runner.session.settle(timeout: 1.5)   # let the window size arrive first
      BBS::FakeOS::Runner.new(runner.session, TT80, context: ctx).run
      nil
    end
  end
end

BBS.start
RUBY
# board_a.rb
require 'bbs'

TT80_BBS = BBS::Gateway.new(host: '127.0.0.1', port: 2473, name: 'TT-80 BBS', public_only: false)
NOWHERE  = BBS::Gateway.new(host: '127.0.0.1', port: 2479, name: 'Nowhere BBS', public_only: false)

BBS.configure do |c|
  c.port = 2474
  c.flow = BBS::Flow.define do
    menu 'Where to?', loop: true do
      option 'TT-80 BBS' do
        call { |ctx, runner| BBS::Door.new(:tt80, TT80_BBS).run(runner.session, ctx); nil }
      end
      option 'Nowhere BBS' do
        call { |ctx, runner| BBS::Door.new(:nowhere, NOWHERE).run(runner.session, ctx); nil }
      end
      option 'Hang up' do
        exit_menu
      end
    end
  end
end

BBS.start

telnet localhost 2474 with a 120×40 window, option 1: board B prints "120x40 screen"; resize the window and its SIZE command shows the new size; EXIT on board B brings "TT-80 BBS hung up. Press a key to return." Option 2 answers "Nowhere BBS does not answer. Try again later. Press a key."

In production: TTG Gate BBS

TTG Gate BBS (bbs/ttg-gate-bbs, port 2399) reaches every board of our network through a gateway: its dialing menu picks a board from config/boards.yml, and lib/ui/dial.rb hands the line on. Our own boards are neighbours on the host, so that gateway says public_only: false; the boards of its Global Discovery directory are dialed with the default, at the vetted public address of the listing, with translate: when a C64 calls a board made for ASCII terminals, and the report of each call is recorded:

RUBY
BBS::Gateway.new(host: board.host, port: board.port, name: board.name, public_only: false).call(session)

Every board publishes its telnet port on the host, so the gate's container reaches them through the host (TTG_GATE_BBS_GATEWAY_HOST, host.docker.internal). Docker on Linux has no host.docker.internal by default; compose.yaml maps it to the host's gateway address:

YAML
services:
  ttg-gate-bbs:
    extra_hosts:
      - "host.docker.internal:host-gateway"

A C64 caller reports no window size through the gateway either, so the board on the other end picks its PETSCII face. (Until 2026-11-01 the Teletype BBS had gateway doors of its own to Alexandria, Feedia and the rest; now the gate is the one way through.)

BBS::Zmodem

Source: lib/bbs/zmodem.rb, since 0.9.0. The sending side of Chuck Forsberg's ZMODEM, enough for the caller's terminal to save files from the board.

send_files

RUBY
BBS::Zmodem.send_files(session, files, wait: 30, telnet: true)
Argument Default Meaning
session required the caller's session; the transfer reads it with read_raw_byte and writes with write
files required an Array of [name, data] or [name, data, mtime]; name is reduced to its base name, data is a String (binary is fine), mtime a Time (default Time.now)
wait: 30 seconds to wait for a ZMODEM receiver to answer
telnet: true double every 0xFF as a telnet IAC; false for a plain byte pipe
Result When
:ok the terminal took the files, or skipped some of them (ZSKIP)
:cancelled the caller pressed Esc or Ctrl-C while the board waited for the terminal, the terminal sent five CANs, or it answered ZCAN, ZABORT or ZFERR
:timeout no receiver answered within wait:, or a file was offered five times without an answer
:hangup the line dropped

On :cancelled and :timeout the board sends the abort sequence (eight CANs, eight backspaces), so a terminal that is half-way in stops too.

Application#with_line

A transfer needs the raw line and a clean screen. with_line hands the session to its block on a cleared screen without mouse reporting, switches the mouse back on (if BBS.config.mouse) afterwards, forces a full repaint of the application, and returns what the block returned:

RUBY
require 'bbs'

README = "Welcome to the files area.\r\n"

FILES = BBS::Application.define do
  helpers do
    def download_readme
      result = with_line do |session|
        session.write("  Sending README.TXT by ZMODEM; Esc gives up.\r\n")
        BBS::Zmodem.send_files(session, [['README.TXT', README, Time.now]], wait: 20)
      end
      message = {
        ok:        'README.TXT is on your disk.',
        cancelled: 'The download was cancelled.',
        timeout:   'Your terminal did not start a ZMODEM download.'
      }.fetch(result, 'The line dropped.')
      BBS::Dialogs.message(self, message, title: ' Download ')
    end
  end

  status_hint('F2', 'Download') { download_readme }
  status_hint('Alt-X', 'Bye') { :halt }
end

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    call do |ctx, runner|
      FILES.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
      nil
    end
  end
end

BBS.start

Outside an Application, call send_files with the session directly, for example from a flow:

RUBY
require 'bbs'

BBS.configure do |c|
  c.flow = BBS::Flow.define do
    call do |_ctx, runner|
      session = runner.session
      data    = (0..255).map(&:chr).join.b * 64   # 16 KB holding every byte value
      session.write("Sending BYTES.BIN by ZMODEM; Esc gives up.\r\n")
      result = BBS::Zmodem.send_files(session, [['BYTES.BIN', data, Time.at(0)], ['HELLO.TXT', "Hello\r\n"]])
      session.write("\r\nResult: #{result}\r\n")
      nil
    end
  end
end

BBS.start

Both examples were checked with lrzsz's rz behind a telnet bridge: the files arrived byte for byte, 0xFF included, and the result was :ok.

Which terminals work

SyncTERM, NetRunner and Qodem start the download by themselves when they see the ZMODEM request. Behind a plain telnet, lrzsz's rz receives it. In a web terminal, zmodem.js does (our bbsweb saves the file as a browser download). The board first sends rz and a CR, which starts rz in a shell-style client, then the ZRQINIT header, repeated at most every 10 s until the receiver answers.

How it sends

  • Every control character (0x00 to 0x1F and 0x80 to 0x9F) goes out ZDLE-escaped, so the stream holds no bare CR and needs no telnet BINARY mode.
  • Data goes with CRC-32 when the receiver offers it (CANFC32 in its ZRINIT), otherwise CRC-16, in 1 KB subpackets.
  • When the receiver announces a buffer size, the board stops after that many bytes for a ZACK.
  • A ZRPOS restarts the data from that position; a ZCRC request is answered with the file's CRC-32.
  • After the last file: ZFIN, then "OO".
  • The whole file is a Ruby String in memory: put a size limit in front of it. The WarpEngine BBS sends files up to WARP_ENGINE_BBS_DOWNLOAD_MAX (1 MB by default).

Gotchas

  • 0xFF is a doubled telnet IAC, not ZRUB1. ZMODEM can escape 0xFF as ZDLE plus ZRUB1, but zmodem.js does not know that escape: its ZDLE decoding is just "minus 64". So 0xFF is sent raw and, on a telnet line, doubled as IAC IAC, which the telnet layer of every receiver turns back into one 0xFF. Without telnet in between (a socket straight into rz, a pipe) pass telnet: false.
  • rz repeats itself. lrzsz's rz sends several ZRINITs at the start and repeats ZRPOS; the sender ignores a ZRINIT that answers a ZFILE and a ZRPOS for the position it just started from, otherwise it would send the data twice.
  • Not on a PETSCII line. On a C64 line session.write goes through the PETSCII encoder and would mangle the binary stream; a C64 gets its file by XMODEM (below).
  • Inside with_line. In an Application, always run a transfer inside with_line: otherwise the event loop's mouse reporting and screen diffing interfere with the binary stream and the screen is not repainted afterwards.

In production the WarpEngine BBS (bbs/warp-engine-bbs, lib/ui/modern.rb and lib/ui/transfer.rb) does exactly the with_line example: it fetches the file from the catalog, sends it with send_files(session, [[title.file, data]]) (or by XMODEM), and turns the result into a dialog.

BBS::Xmodem

Source: lib/bbs/xmodem.rb, since 0.26.0. The sending side of XMODEM, XMODEM-CRC and XMODEM-1K: the transfer the terminals of a C64 know (CCGMS, Novaterm, UltimateTerm), and SyncTERM, Qodem or rx too. XMODEM sends no file name, so tell the caller to name the file and start the download in the terminal:

RUBY
result = app.with_line do |session|
  session.write("  Start an XMODEM download of ARIADNE.PRG now; Esc gives up.\r\n")
  BBS::Xmodem.send_file(session, bytes, one_k: true)
end
# => :ok, :cancelled, :timeout (no receiver started) or :hangup
  • The receiver picks the check: a C gets CRC-16, a NAK the checksum. With one_k: true the blocks are 1024 bytes when the receiver asked for a CRC, else always 128.
  • The last block is padded with SUB (0x1a), so the saved file can be up to a block longer; a C64 program loads all the same.
  • It works on a PETSCII line: the bytes go out with session.write_raw, 0xFF as a doubled telnet IAC, and the line is asked into telnet BINARY mode for the transfer and back out after it (telnet: false for a plain pipe).
  • It waits wait: seconds (60) for the receiver; Esc or Ctrl-C (RUN/STOP) gives up meanwhile, and two CANs from the terminal cancel at any time. A block goes again on a NAK or 20 s of silence, at most 10 times.

See also

  • rubbs — the overview and the table of contents
  • AI characters — an AI character in a FakeOS on_unknown or in a window
  • Application — the desktop that doors leave and come back to
  • Flow — flows as doors, the call and os steps
  • Commodore 64 — PETSCII lines, and why a gateway reports no window size on them
  • Telnet and the store — the session: readline, readkey, read_raw_byte, line
  • Changelog — Door (0.6.0), Flow doors and colors base: (0.7.0), ZMODEM (0.9.0), Gateway (0.17.0), XMODEM (0.26.0), the gateway report (0.31.0), translate: (0.34.0), public_only: (1.0.0)
  • Data and network — BBS::Address, BBS.log and the rest of the shared blocks