commit 4690ade51052a75cffb88f69413635b4478614fc Author: Zsolt Tasnadi Date: Tue Apr 28 22:18:57 2026 +0200 Initial commit: extracted from impostor-bbs gems/bbs Co-Authored-By: Claude Sonnet 4.6 diff --git a/bbs.gemspec b/bbs.gemspec new file mode 100644 index 0000000..e77025a --- /dev/null +++ b/bbs.gemspec @@ -0,0 +1,10 @@ +Gem::Specification.new do |s| + s.name = 'bbs' + s.version = '0.1.0' + s.summary = 'Universal telnet BBS server library' + s.author = 'Zsolt Tasnadi' + s.files = Dir['lib/**/*.rb'] + s.require_paths = ['lib'] + s.required_ruby_version = '>= 3.0' + s.add_dependency 'artii', '~> 2.1' +end diff --git a/lib/bbs.rb b/lib/bbs.rb new file mode 100644 index 0000000..fd07122 --- /dev/null +++ b/lib/bbs.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative 'bbs/config' +require_relative 'bbs/telnet' +require_relative 'bbs/banner' +require_relative 'bbs/flow' +require_relative 'bbs/flow_runner' +require_relative 'bbs/renderer' +require_relative 'bbs/store' +require_relative 'bbs/session' +require_relative 'bbs/server' + +module BBS + def self.configure + yield(@config ||= Config.new) + end + + def self.config + @config ||= Config.new + end + + def self.start(port: (ENV['BBS_PORT'] || 2323).to_i) + Server.new(port: port).run + end +end diff --git a/lib/bbs/banner.rb b/lib/bbs/banner.rb new file mode 100644 index 0000000..e1f3e5d --- /dev/null +++ b/lib/bbs/banner.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'artii' + +module BBS + module Banner + def self.render(text, color: "\e[1;32m", padding: 2) + inner = "#{' ' * padding}#{text}#{' ' * padding}" + bar = '═' * inner.length + rst = "\e[0m" + "\r\n #{color}╔#{bar}╗#{rst}\r\n" \ + " #{color}║#{inner}║#{rst}\r\n" \ + " #{color}╚#{bar}╝#{rst}\r\n\r\n" + end + + def self.render_big(text, color: "\e[1;32m", font: 'slant') + rst = "\e[0m" + art = Artii::Base.new(font: font).asciify(text) + lines = art.split("\n").map { |l| " #{color}#{l}#{rst}" } + "\r\n#{lines.join("\r\n")}\r\n\r\n" + end + end +end diff --git a/lib/bbs/config.rb b/lib/bbs/config.rb new file mode 100644 index 0000000..c8efa15 --- /dev/null +++ b/lib/bbs/config.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module BBS + class Config + attr_accessor :screens_dir, :flow + end +end diff --git a/lib/bbs/flow.rb b/lib/bbs/flow.rb new file mode 100644 index 0000000..4f1bd54 --- /dev/null +++ b/lib/bbs/flow.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module BBS + class Flow + attr_reader :steps + + def self.define(&block) + flow = new + flow.instance_eval(&block) + flow.freeze + end + + def initialize + @steps = [] + end + + def screen(name, **vars) + @steps << { type: :screen, name: name, vars: vars } + end + + def banner(text, style: :success) + @steps << { type: :banner, text: text, style: style } + end + + def big_banner(text, style: :success, font: 'slant') + @steps << { type: :big_banner, text: text, style: style, font: font } + end + + def say(text, style: :muted) + @steps << { type: :say, text: text, style: style } + end + + def set(name, value) + @steps << { type: :set, name: name, value: value } + end + + def ask(name, prompt:, transform: nil, validate: nil) + @steps << { type: :ask, name: name, prompt: prompt, + transform: transform, validate: validate } + end + + def persist(*fields, store:) + @steps << { type: :persist, fields: fields.flatten, store: store } + end + + def pause(message, seconds: 1.0) + @steps << { type: :pause, message: message, seconds: seconds } + end + + def gate(denied:, &check) + @steps << { type: :gate, check: check, denied: denied } + end + + def confirm(message, denied: nil, &block) + step = { message: message, denied: denied } + if block_given? + sub = Flow.new + sub.instance_eval(&block) + @steps << step.merge(type: :confirm_block, sub_steps: sub.steps) + else + @steps << step.merge(type: :confirm_halt) + end + end + + def menu(prompt, loop: false, &block) + builder = MenuBuilder.new + builder.instance_eval(&block) + @steps << { type: :menu, prompt: prompt, loop: loop, options: builder.options } + end + + def exit_menu + @steps << { type: :exit_menu } + end + end + + class MenuBuilder + attr_reader :options + + def initialize + @options = [] + end + + def option(label, &block) + sub = Flow.new + sub.instance_eval(&block) if block_given? + @options << { label: label, steps: sub.steps } + end + end +end diff --git a/lib/bbs/flow_runner.rb b/lib/bbs/flow_runner.rb new file mode 100644 index 0000000..1609e69 --- /dev/null +++ b/lib/bbs/flow_runner.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +module BBS + class FlowRunner + STYLES = { + success: "\e[1;32m", + muted: "\e[0;37m", + error: "\e[1;31m", + info: "\e[0;33m", + prompt: "\e[1;37m", + confirm: "\e[1;36m", + }.freeze + + def initialize(session, session_id, flow) + @session = session + @session_id = session_id + @flow = flow + @ctx = {} + end + + def run + execute(@flow.steps) + end + + private + + def execute(steps) + steps.each do |step| + result = dispatch(step) + return result if result == :halt || result == :exit_menu + end + nil + end + + def dispatch(step) + case step[:type] + when :screen then run_screen(step) + when :banner then run_banner(step) + when :big_banner then run_big_banner(step) + when :say then run_say(step) + when :set then run_set(step) + when :ask then run_ask(step) + when :persist then run_persist(step) + when :pause then run_pause(step) + when :gate then run_gate(step) + when :confirm_halt then run_confirm_halt(step) + when :confirm_block then run_confirm_block(step) + when :menu then run_menu(step) + when :exit_menu then :exit_menu + end + end + + # ── step handlers ────────────────────────────────────────────────────────── + + def run_screen(step) + vars = step[:vars].transform_values { |v| v.is_a?(Symbol) ? @ctx[v] : v } + write Renderer.render(step[:name].to_s, @ctx.merge(vars)) + end + + def run_banner(step) + color = STYLES.fetch(step[:style], STYLES[:success]) + write Banner.render(step[:text], color: color) + end + + def run_big_banner(step) + color = STYLES.fetch(step[:style], STYLES[:success]) + write Banner.render_big(step[:text], color: color, font: step[:font]) + end + + def run_say(step) + color = STYLES.fetch(step[:style], STYLES[:muted]) + write "\r\n #{color}#{step[:text]}\e[0m\r\n\r\n" + end + + def run_set(step) + @ctx[step[:name]] = step[:value] + nil + end + + def run_ask(step) + write " #{STYLES[:prompt]}#{step[:prompt]}\e[0m: " + value = readline&.strip + return :halt if value.nil? + + value = apply_transform(value, step[:transform]) + + if step[:validate] && !valid?(value, step[:validate]) + write "\r\n #{STYLES[:error]}#{validation_message(step[:validate])}\e[0m\r\n\r\n" + return :halt + end + + @ctx[step[:name]] = value + nil + end + + def run_persist(step) + fields = step[:fields].filter_map { |f| [f, @ctx[f]] if @ctx.key?(f) }.to_h + step[:store].upsert(session_id: @session_id, **fields) unless fields.empty? + nil + end + + def run_pause(step) + write "\r\n #{STYLES[:info]}#{step[:message]}\e[0m\r\n" + sleep step[:seconds] + nil + end + + def run_gate(step) + return nil if step[:check].call(@ctx) + write " #{STYLES[:error]}#{step[:denied]}\e[0m\r\n\r\n" + :halt + end + + def run_confirm_halt(step) + write "\r\n #{STYLES[:confirm]}#{step[:message]}\e[0m [yes/no]: " + answer = readline&.strip&.downcase + return :halt if answer.nil? + return nil if %w[yes y].include?(answer) + write "\r\n #{STYLES[:muted]}#{step[:denied]}\e[0m\r\n\r\n" if step[:denied] + :halt + end + + def run_confirm_block(step) + write "\r\n #{STYLES[:prompt]}#{step[:message]}\e[0m [yes/no]: " + answer = readline&.strip&.downcase + return :halt if answer.nil? + + if %w[yes y].include?(answer) + execute(step[:sub_steps]) + else + write "\r\n #{STYLES[:muted]}#{step[:denied]}\e[0m\r\n\r\n" if step[:denied] + nil + end + end + + def run_menu(step) + loop do + write "\r\n" + step[:options].each_with_index do |opt, i| + write " #{STYLES[:muted]}#{i + 1}. #{opt[:label]}\e[0m\r\n" + end + write "\r\n #{STYLES[:prompt]}#{step[:prompt]}\e[0m " + + input = readline&.strip + return :halt if input.nil? + + index = input.to_i - 1 + unless (0...step[:options].length).include?(index) + write "\r\n #{STYLES[:error]}Enter a number between 1 and #{step[:options].length}.\e[0m\r\n" + next + end + + result = execute(step[:options][index][:steps]) + return :halt if result == :halt + return nil if result == :exit_menu + + break unless step[:loop] + end + nil + end + + # ── helpers ──────────────────────────────────────────────────────────────── + + def apply_transform(value, transform) + case transform + when :upcase then value.upcase + when :downcase then value.downcase + else value + end + end + + def valid?(value, validator) + case validator + when :email then value.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/) + when :non_empty then !value.empty? + else true + end + end + + def validation_message(validator) + case validator + when :email then "That doesn't look like a valid email address. Farewell." + else "Invalid input. Farewell." + end + end + + def write(data) = @session.write(data) + def readline = @session.readline + end +end diff --git a/lib/bbs/renderer.rb b/lib/bbs/renderer.rb new file mode 100644 index 0000000..05c5932 --- /dev/null +++ b/lib/bbs/renderer.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'erb' +require 'artii' + +module BBS + class Renderer + def self.render(name, vars = {}) + dir = BBS.config.screens_dir or raise "BBS.config.screens_dir is not set" + path = File.join(dir, "#{name}.erb") + ctx = Context.new(vars) + ERB.new(File.read(path), trim_mode: '-').result(ctx.ctx_binding).gsub("\n", "\r\n") + end + + class Context + def initialize(vars) + vars.each { |k, v| instance_variable_set(:"@#{k}", v) } + end + + def ctx_binding = binding + + def banner(text, padding: 2) + inner = "#{' ' * padding}#{text}#{' ' * padding}" + bar = '═' * inner.length + "#{green}╔#{bar}╗#{reset}\n" \ + "#{green}║#{inner}║#{reset}\n" \ + "#{green}╚#{bar}╝#{reset}" + end + + def big_banner(text, font: 'slant') + Artii::Base.new(font: font).asciify(text) + .split("\n") + .map { |l| " #{green}#{l}#{reset}" } + .join("\n") + end + + def clr = "\e[2J\e[H" + def reset = "\e[0m" + def green = "\e[1;32m" + def yellow = "\e[1;33m" + def cyan = "\e[1;36m" + def white = "\e[1;37m" + def gray = "\e[0;37m" + def red = "\e[1;31m" + def dim_green = "\e[0;32m" + end + end +end diff --git a/lib/bbs/server.rb b/lib/bbs/server.rb new file mode 100644 index 0000000..6296c82 --- /dev/null +++ b/lib/bbs/server.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'socket' + +module BBS + class Server + def initialize(port:) + @port = port + end + + def run + server = TCPServer.new('0.0.0.0', @port) + puts "BBS listening on port #{@port} — connect with: telnet localhost #{@port}" + loop do + client = server.accept + Thread.new(client) { |c| Session.new(c).run rescue nil } + end + rescue Interrupt + puts "\nServer stopped." + end + end +end diff --git a/lib/bbs/session.rb b/lib/bbs/session.rb new file mode 100644 index 0000000..4af9a0b --- /dev/null +++ b/lib/bbs/session.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'securerandom' + +module BBS + class Session + include Telnet + + def initialize(client) + @client = client + @session_id = SecureRandom.hex(8) + end + + def run + negotiate + flow = BBS.config.flow or raise "BBS.config.flow is not set" + FlowRunner.new(self, @session_id, flow).run + ensure + @client.close rescue nil + end + end +end diff --git a/lib/bbs/store.rb b/lib/bbs/store.rb new file mode 100644 index 0000000..e9b140e --- /dev/null +++ b/lib/bbs/store.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'csv' +require 'fileutils' +require 'time' + +module BBS + class Store + def initialize(path:, headers:) + @path = path + @headers = headers + @mutex = Mutex.new + end + + def upsert(session_id:, **fields) + @mutex.synchronize do + ensure_file! + rows = CSV.read(@path, headers: true) + row = rows.find { |r| r['session_id'] == session_id } + + if row + fields.each { |k, v| row[k.to_s] = v } + else + values = @headers.map do |h| + case h + when 'session_id' then session_id + when 'timestamp' then Time.now.utc.iso8601 + else fields[h.to_sym] + end + end + rows << CSV::Row.new(@headers, values) + end + + CSV.open(@path, 'w') do |csv| + csv << @headers + rows.each { |r| csv << r.fields } + end + end + end + + private + + def ensure_file! + FileUtils.mkdir_p(File.dirname(@path)) + return if File.exist?(@path) + CSV.open(@path, 'w') { |csv| csv << @headers } + end + end +end diff --git a/lib/bbs/telnet.rb b/lib/bbs/telnet.rb new file mode 100644 index 0000000..43eb8a3 --- /dev/null +++ b/lib/bbs/telnet.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module BBS + module Telnet + IAC = 255 + WILL = 251 + WONT = 252 + DO = 253 + DONT = 254 + SB = 250 + SE = 240 + + ECHO_OPT = 1 + SGA_OPT = 3 + + def negotiate + send_raw [IAC, WILL, SGA_OPT].pack('C*') + send_raw [IAC, WILL, ECHO_OPT].pack('C*') + send_raw [IAC, DO, SGA_OPT].pack('C*') + end + + def readline + line = +"" + last_cr = false + + loop do + raw = @client.read(1) + return nil if raw.nil? + byte = raw.ord + + if byte == IAC + absorb_iac + last_cr = false + next + end + + if byte == 13 + last_cr = true + next + end + + if byte == 10 || (byte == 0 && last_cr) + write "\r\n" + return line + end + + last_cr = false + next if byte == 0 + + if byte == 8 || byte == 127 + unless line.empty? + line.chop! + write "\b \b" + end + next + end + + next if byte < 32 + + line << raw + write raw + end + end + + def write(data) + @client.write(data) + rescue StandardError + nil + end + + private + + def absorb_iac + cmd = @client.read(1) + return if cmd.nil? + + case cmd.ord + when SB + loop do + b = @client.read(1) + break if b.nil? + if b.ord == IAC + s = @client.read(1) + break if s.nil? || s.ord == SE + end + end + when WILL, WONT, DO, DONT + @client.read(1) + end + end + + def send_raw(data) + @client.write(data) + rescue StandardError + nil + end + end +end