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.
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:
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:
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
Source: lib/bbs/door.rb.
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(session, context = {})
What it does, in order:
session.disable_mouse, so the program does not get mouse reports it cannot read.ESC[0m ESC[2J ESC[H: default colours, a clear screen, the cursor at the top left.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 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.
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 |
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
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:
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:
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
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:
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:
BBS.config.flow = BBS::Flow.define do
ask :username, prompt: 'Your name'
os TT80
say 'Back on the board.'
end
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.
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.| 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:
prompt { |ctx| "#{ctx[:drive] || 'A'}> " }
# or
prompt ->(ctx) { "#{ctx[:drive] || 'A'}> " }
baseEvery 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.
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"
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 |
After boot and ready the console loops until halt or a hang-up:
prompt role.session.readline); nil, a hang-up, ends the loop.commands, so commands are case-insensitive. The block gets the rest of the line as a String ('' when there is none), and the console.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:
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:
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
| 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 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:
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:
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:
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.
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:
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:
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.
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.
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:
door :feedia, BBS::Gateway.new(host: 'feedia.example', port: 2327, name: 'Feedia'), label: '&Feedia'
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:
FEEDIA = BBS::Gateway.new(host: 'host.docker.internal', port: 2327, name: 'Feedia', public_only: false)
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 |
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 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):
ESC [ A…;ANSI and NAWS with its 40×25, so the board formats for 40 columns.BBS::Gateway.new(host: board.host, port: board.port, translate: !board.petscii?).call(session)
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
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:
IAC IAC (a 0xFF data byte) stays doubled.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.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.
| 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:
idle: seconds (BBS.config.idle_seconds, 900 s when unset); a caller who only reads while the other board keeps sending stays on.BBS.config.mouse off, the caller's terminal keeps sending mouse reports.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:
# 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
# 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."
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:
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:
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.)
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.
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.
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:
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:
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.
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.
WARP_ENGINE_BBS_DOWNLOAD_MAX (1 MB by default).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 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.session.write goes through the PETSCII encoder and would mangle the binary stream; a C64 gets its file by XMODEM (below).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.
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:
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
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.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).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.on_unknown or in a windowcall and os stepsreadline, readkey, read_raw_byte, linecolors 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)BBS::Address, BBS.log and the rest of the shared blocks