diff --git a/Dockerfile b/Dockerfile index 7f66a8a..b80ef55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,31 +1,17 @@ -# Build stage -FROM golang:1.26.1-alpine AS builder +FROM ruby:3.3-alpine + +RUN apk add --no-cache git build-base WORKDIR /app -# Install dependencies -COPY go.mod ./ -# COPY go.sum ./ # Uncomment if you have a go.sum file -RUN go mod download +COPY Gemfile Gemfile.lock ./ +RUN bundle install -# Copy source code -COPY . . +COPY bbs.rb ./ +COPY lib/ ./lib/ -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bbs-server main.go +RUN mkdir -p /app/data -# Final stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates tzdata - -WORKDIR /root/ - -# Copy the binary from the builder stage -COPY --from=builder /app/bbs-server . - -# Expose the port EXPOSE 2323 -# Run the binary -CMD ["./bbs-server"] +CMD ["bundle", "exec", "ruby", "bbs.rb"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..499b12f --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' + +gem 'bbs', git: 'https://git.teletype.hu/tools/rubbs.git' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..f4fda8f --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,21 @@ +GIT + remote: https://git.teletype.hu/tools/rubbs.git + revision: d9232eabb4ddba2b860921657cf20fe7f3cbd830 + specs: + bbs (0.1.0) + artii (~> 2.1) + +GEM + remote: https://rubygems.org/ + specs: + artii (2.1.2) + +PLATFORMS + arm64-darwin-22 + ruby + +DEPENDENCIES + bbs! + +BUNDLED WITH + 2.5.9 diff --git a/bbs.rb b/bbs.rb new file mode 100644 index 0000000..593606a --- /dev/null +++ b/bbs.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'bbs' +require_relative 'lib/online_users' +require_relative 'lib/message_board' +require_relative 'lib/wiki' +require_relative 'lib/catalog' +require_relative 'lib/display' + +ONLINE = OnlineUsers.new +MESSAGES = MessageBoard.new(ENV.fetch('MESSAGES_PATH', 'data/messages.dat')) +WIKI = WikiClient.new(token: ENV['WEBAPP_WIKIJS_TOKEN']) +CATALOG = CatalogClient.new + +BBS.configure do |c| + c.on_session_end = ->(session) { ONLINE.remove(session.session_id) } + + c.flow = BBS::Flow.define do + big_banner 'TELETYPE BBS', style: :success + + ask :username, prompt: 'Name (blank for Anonymous)', + transform: ->(v) { v.strip.empty? ? 'Anonymous' : v.strip[0...20] } + + call do |ctx, runner| + ONLINE.add(runner.session_id, ctx[:username]) + end + + menu 'Choice', loop: true do + option 'Message Board' do + call { |ctx, runner| Display.render_messages(MESSAGES.last(30), runner) } + end + + option 'New Message' do + ask :message_text, prompt: 'Message (max 200 chars)', validate: :non_empty + call do |ctx, runner| + MESSAGES.append(ctx[:username], ctx[:message_text][0...200]) + runner.write "\r\n #{Display::GREEN}Sent.#{Display::RESET}\r\n\r\n" + end + end + + option 'Blog Posts' do + call { |ctx, runner| Display.render_wiki_list(WIKI, 'blog', 'Blog Posts', Display::BLUE, runner) } + end + + option 'HowTo Guides' do + call { |ctx, runner| Display.render_wiki_list(WIKI, 'howto', 'HowTo Guides', Display::MAGENTA, runner) } + end + + option 'Game Catalog' do + call { |ctx, runner| Display.render_catalog(CATALOG, runner) } + end + + option 'Online Users' do + call { |ctx, runner| Display.render_online(ONLINE, runner.session_id, ctx[:username], runner) } + end + + option 'System Info' do + call { |ctx, runner| Display.render_sysinfo(ONLINE, MESSAGES, runner) } + end + + option 'Exit' do + exit_menu + end + end + + say 'Goodbye!', style: :muted + end +end + +BBS.start diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 26cbb3c..d91e0fc 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -1,18 +1,15 @@ services: bbs-server: - build: - context: . - dockerfile: Dockerfile + build: . container_name: bbs-server-prod ports: - "2323:2323" environment: - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN} - - MESSAGES_PATH=/data/messages.dat + - MESSAGES_PATH=/app/data/messages.dat volumes: - - bbs-messages:/data + - bbs-messages:/app/data restart: always - read_only: true tmpfs: - /tmp cap_drop: @@ -21,4 +18,4 @@ services: - no-new-privileges:true volumes: - bbs-messages: \ No newline at end of file + bbs-messages: diff --git a/docker-compose.yaml b/docker-compose.yaml index e15e450..2053ed2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,17 +1,14 @@ services: bbs: - build: - context: . - dockerfile: Dockerfile.development + build: . container_name: teletype-bbs ports: - "2323:2323" volumes: - - ./:/app/ - - ./data:/data + - ./data:/app/data environment: - WEBAPP_WIKIJS_TOKEN=${WEBAPP_WIKIJS_TOKEN:-} - - MESSAGES_PATH=/data/messages.dat + - MESSAGES_PATH=/app/data/messages.dat restart: unless-stopped stdin_open: true tty: true diff --git a/env-example b/env-example index f6ee431..27b4305 100644 --- a/env-example +++ b/env-example @@ -1 +1,2 @@ -WEBAPP_WIKIJS_TOKEN= \ No newline at end of file +WEBAPP_WIKIJS_TOKEN= +MESSAGES_PATH=data/messages.dat \ No newline at end of file diff --git a/lib/catalog.rb b/lib/catalog.rb new file mode 100644 index 0000000..bb2c6e9 --- /dev/null +++ b/lib/catalog.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'uri' + +class CatalogClient + API_URL = 'https://games.teletype.hu/api/software' + GAMES_URL = 'https://games.teletype.hu' + + def fetch + uri = URI(API_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.open_timeout = 12 + http.read_timeout = 12 + data = JSON.parse(http.get(uri.path).body) + data.is_a?(Hash) ? (data['softwares'] || []) : data + rescue => e + warn "Catalog fetch error: #{e}" + [] + end + + def play_url(path) + "#{GAMES_URL}#{path}" + end +end diff --git a/lib/display.rb b/lib/display.rb new file mode 100644 index 0000000..dccb95b --- /dev/null +++ b/lib/display.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'time' + +module Display + W = 70 + + RESET = "\e[0m" + BOLD = "\e[1m" + CYAN = "\e[0;36m" + YELLOW = "\e[0;33m" + GREEN = "\e[1;32m" + RED = "\e[1;31m" + MAGENTA = "\e[0;35m" + WHITE = "\e[1;37m" + BLUE = "\e[0;34m" + GRAY = "\e[0;37m" + + module_function + + def hr(color = GRAY) + " #{color}#{'─' * (W - 4)}#{RESET}\r\n" + end + + def box_header(title, color = CYAN) + pad = [W - title.length - 6, 2].max + " #{color}┌─ #{WHITE}#{title} #{color}#{'─' * pad}┐#{RESET}\r\n" + end + + def strip_md(text) + text.gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') + .gsub(/[#*_`~>|\\]/, '') + .gsub(/\r?\n+/, ' ') + .strip + end + + def wrap(text, indent: 4, width: W - 6) + words = strip_md(text).split + lines = [] + line = +'' + words.each do |word| + if line.empty? + line << word + elsif line.length + 1 + word.length <= width + line << ' ' << word + else + lines << line.dup + line = +word + end + end + lines << line unless line.empty? + lines.map { |l| "#{' ' * indent}#{l}\r\n" }.join + end + + def fmt_date(iso) + Time.parse(iso).strftime('%Y-%m-%d') + rescue + iso.to_s + end + + def wait_enter(runner) + runner.write "\r\n #{GRAY}Press ENTER to continue...#{RESET}" + runner.readline + end + + def render_messages(messages, runner) + runner.write "\r\n" + runner.write box_header('Message Board') + runner.write "\r\n" + if messages.empty? + runner.write " #{GRAY}No messages yet.#{RESET}\r\n" + else + messages.each_with_index do |msg, i| + runner.write " #{GRAY}[#{i + 1}]#{RESET} #{YELLOW}#{msg.timestamp}#{RESET} #{WHITE}#{msg.username}#{RESET}: #{GRAY}#{msg.text}#{RESET}\r\n" + end + end + runner.write "\r\n" + wait_enter(runner) + end + + def render_wiki_list(wiki, tag, title, color, runner) + runner.write "\r\n" + runner.write box_header(title, color) + runner.write "\r\n #{GRAY}Loading...#{RESET}\r\n" + + pages = wiki.list(tag) + runner.write "\e[1A\e[2K" + + if pages.empty? + runner.write " #{RED}No results.#{RESET}\r\n\r\n" + wait_enter(runner) + return + end + + shown = pages.first(25) + shown.each_with_index do |page, i| + runner.write " #{GRAY}#{i + 1}.#{RESET} #{color}#{page['title']}#{RESET} #{GRAY}#{fmt_date(page['createdAt'])}#{RESET}\r\n" + desc = page['description'].to_s.strip + runner.write " #{GRAY}#{desc[0...65]}#{RESET}\r\n" unless desc.empty? + end + + runner.write "\r\n #{GRAY}Enter number to read (blank to go back):#{RESET} " + input = runner.readline&.strip + return if input.nil? || input.empty? + + idx = input.to_i - 1 + page = shown[idx] + unless page + runner.write "\r\n #{RED}Invalid selection.#{RESET}\r\n" + wait_enter(runner) + return + end + + runner.write "\r\n #{GRAY}Loading page...#{RESET}\r\n" + body = wiki.content(page['id']) + runner.write "\e[1A\e[2K" + + runner.write "\r\n" + runner.write hr(color) + runner.write " #{color}#{page['title']}#{RESET}\r\n" + runner.write " #{GRAY}#{fmt_date(page['createdAt'])} #{wiki.page_url(page['locale'], page['path'])}#{RESET}\r\n" + runner.write hr(color) + runner.write "\r\n" + runner.write wrap(body) + runner.write "\r\n" + wait_enter(runner) + end + + def render_catalog(catalog, runner) + runner.write "\r\n" + runner.write box_header('Game Catalog', CYAN) + runner.write "\r\n #{GRAY}Loading...#{RESET}\r\n" + + games = catalog.fetch + runner.write "\e[1A\e[2K" + + unless games.is_a?(Array) && !games.empty? + runner.write " #{RED}Could not load catalog.#{RESET}\r\n\r\n" + wait_enter(runner) + return + end + + games.each_with_index do |entry, i| + next unless entry.is_a?(Hash) + sw = entry['software'] || {} + latest = entry['latestRelease'] || {} + count = (entry['releases'] || []).length + + runner.write "\r\n" + runner.write hr + runner.write " #{CYAN}#{i + 1}. #{sw['title']}#{RESET} #{GRAY}#{sw['platform']} #{sw['author']}#{RESET}\r\n" + runner.write wrap(sw['desc'].to_s) unless sw['desc'].to_s.strip.empty? + + badges = [] + badges << "#{GREEN}[▶ Play]#{RESET}" if latest['htmlFolderPath'].to_s != '' + badges << "#{YELLOW}[⬇ Download]#{RESET}" if latest['cartridgePath'].to_s != '' + badges << "#{BLUE}[Source]#{RESET}" if latest['sourcePath'].to_s != '' + badges << "#{MAGENTA}[Docs]#{RESET}" if latest['docsFolderPath'].to_s != '' + runner.write " #{badges.join(' ')}\r\n" unless badges.empty? + + if latest['htmlFolderPath'].to_s != '' + runner.write " #{GRAY}#{catalog.play_url(latest['htmlFolderPath'])}#{RESET}\r\n" + end + runner.write " #{GRAY}#{count} versions available#{RESET}\r\n" if count > 1 + end + + runner.write "\r\n" + runner.write hr + runner.write " #{GRAY}#{CatalogClient::GAMES_URL}#{RESET}\r\n\r\n" + wait_enter(runner) + end + + def render_online(online, my_sid, my_name, runner) + runner.write "\r\n" + runner.write box_header('Online Users', YELLOW) + runner.write "\r\n" + + snapshot = online.snapshot + snapshot.sort.each do |sid, name| + marker = sid == my_sid ? " #{GRAY}← you#{RESET}" : '' + runner.write " #{WHITE}#{name}#{RESET}#{marker}\r\n" + end + + runner.write "\r\n #{GRAY}#{snapshot.size} user(s) online#{RESET}\r\n\r\n" + wait_enter(runner) + end + + def render_sysinfo(online, messages, runner) + runner.write "\r\n" + runner.write box_header('System Info', MAGENTA) + runner.write "\r\n" + runner.write " #{GRAY}Online users #{WHITE}#{online.count}#{RESET}\r\n" + runner.write " #{GRAY}Messages #{WHITE}#{messages.count}#{RESET}\r\n" + runner.write " #{GRAY}Wiki #{WHITE}https://wiki.teletype.hu#{RESET}\r\n" + runner.write " #{GRAY}Games API #{WHITE}https://games.teletype.hu#{RESET}\r\n" + runner.write " #{GRAY}Platform #{WHITE}#{RUBY_PLATFORM}#{RESET}\r\n" + runner.write " #{GRAY}Ruby #{WHITE}#{RUBY_VERSION}#{RESET}\r\n" + runner.write "\r\n" + wait_enter(runner) + end +end diff --git a/lib/message_board.rb b/lib/message_board.rb new file mode 100644 index 0000000..1420e75 --- /dev/null +++ b/lib/message_board.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'csv' +require 'time' +require 'fileutils' + +class MessageBoard + Message = Struct.new(:timestamp, :username, :text) + + def initialize(path) + @path = path + @messages = [] + @mu = Mutex.new + load_csv + end + + def append(username, text) + msg = Message.new(Time.now.strftime('%m-%d %H:%M'), username, text) + @mu.synchronize do + @messages << msg + FileUtils.mkdir_p(File.dirname(@path)) + CSV.open(@path, 'a') { |csv| csv << [msg.timestamp, msg.username, msg.text] } + end + msg + end + + def last(n = 30) + @mu.synchronize { @messages.last(n) } + end + + def count + @mu.synchronize { @messages.size } + end + + private + + def load_csv + return unless File.exist?(@path) + CSV.foreach(@path) do |row| + @messages << Message.new(*row) + end + rescue => e + warn "MessageBoard load error: #{e}" + end +end diff --git a/lib/online_users.rb b/lib/online_users.rb new file mode 100644 index 0000000..60ec858 --- /dev/null +++ b/lib/online_users.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OnlineUsers + def initialize + @users = {} + @mu = Mutex.new + end + + def add(session_id, name) + @mu.synchronize { @users[session_id] = name } + end + + def remove(session_id) + @mu.synchronize { @users.delete(session_id) } + end + + def snapshot + @mu.synchronize { @users.dup } + end + + def count + @mu.synchronize { @users.size } + end +end diff --git a/lib/wiki.rb b/lib/wiki.rb new file mode 100644 index 0000000..a3cc75a --- /dev/null +++ b/lib/wiki.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'uri' + +class WikiClient + BASE_URL = 'https://wiki.teletype.hu' + + def initialize(token: nil) + @token = token + end + + def list(tag) + query = <<~GQL + { pages { list(orderBy: CREATED, orderByDirection: DESC, tags: ["#{tag}"]) { + id path title description createdAt locale + }}} + GQL + graphql(query).dig('data', 'pages', 'list') || [] + rescue => e + warn "Wiki list error: #{e}" + [] + end + + def content(page_id) + query = "{ pages { single(id: #{page_id}) { content } } }" + graphql(query).dig('data', 'pages', 'single', 'content') || '' + rescue => e + warn "Wiki content error: #{e}" + '' + end + + def page_url(locale, path) + "#{BASE_URL}/#{locale}/#{path}" + end + + private + + def graphql(query) + uri = URI("#{BASE_URL}/graphql") + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.open_timeout = 12 + http.read_timeout = 12 + + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req['Authorization'] = "Bearer #{@token}" if @token + req.body = JSON.generate(query: query) + + JSON.parse(http.request(req).body) + end +end