refact
This commit is contained in:
25
lib/domain/model/game_model.rb
Normal file
25
lib/domain/model/game_model.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GameModel
|
||||
attr_reader :title, :platform, :author, :desc, :story,
|
||||
:play_path, :download_path, :source_path, :docs_path,
|
||||
:release_count, :external_links
|
||||
|
||||
def initialize(entry)
|
||||
sw = entry['software'] || {}
|
||||
latest = entry['latestRelease'] || {}
|
||||
@title = sw['title'].to_s
|
||||
@platform = sw['platform'].to_s
|
||||
@author = sw['author'].to_s
|
||||
@desc = sw['desc'].to_s
|
||||
@story = sw['story'].to_s
|
||||
@play_path = latest['htmlFolderPath'].to_s
|
||||
@download_path = latest['cartridgePath'].to_s
|
||||
@source_path = latest['sourcePath'].to_s
|
||||
@docs_path = latest['docsFolderPath'].to_s
|
||||
@release_count = (entry['releases'] || []).length
|
||||
@external_links = (sw['externalLinks'] || []).filter_map do |l|
|
||||
{ label: l['label'].to_s, url: l['url'].to_s } unless l['url'].to_s.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
3
lib/domain/model/message_model.rb
Normal file
3
lib/domain/model/message_model.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
MessageModel = Struct.new(:timestamp, :username, :text)
|
||||
3
lib/domain/model/wiki_page_model.rb
Normal file
3
lib/domain/model/wiki_page_model.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
WikiPageModel = Struct.new(:id, :path, :title, :description, :created_at, :locale, keyword_init: true)
|
||||
83
lib/domain/repository/board_repository.rb
Normal file
83
lib/domain/repository/board_repository.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
require 'time'
|
||||
require 'fileutils'
|
||||
require_relative '../model/message_model'
|
||||
|
||||
# Multi-board store. Each board ("general", "tech", "offtopic"…) maps to one
|
||||
# CSV file inside +base_dir+. Lazy-loaded the first time a board is touched.
|
||||
class BoardRepository
|
||||
BoardMeta = Struct.new(:id, :title, :description)
|
||||
|
||||
DEFAULT_BOARDS = [
|
||||
BoardMeta.new('general', 'General', 'Open chat, anything goes'),
|
||||
BoardMeta.new('tech', 'Tech', 'Hardware, software, code'),
|
||||
BoardMeta.new('offtopic', 'Off-Topic','Random topics, jokes'),
|
||||
BoardMeta.new('sysop', 'Sysop', 'Notes from the operator'),
|
||||
].freeze
|
||||
|
||||
attr_reader :boards
|
||||
|
||||
def initialize(base_dir, boards: DEFAULT_BOARDS, legacy_path: nil)
|
||||
@base_dir = base_dir
|
||||
@boards = boards
|
||||
@cache = {}
|
||||
@mu = Mutex.new
|
||||
FileUtils.mkdir_p(@base_dir)
|
||||
migrate_legacy!(legacy_path) if legacy_path
|
||||
end
|
||||
|
||||
def append(board_id, username, text)
|
||||
msg = MessageModel.new(Time.now.strftime('%m-%d %H:%M'), username, text)
|
||||
@mu.synchronize do
|
||||
load_board(board_id) << msg
|
||||
CSV.open(path_for(board_id), 'a') { |csv| csv << [msg.timestamp, msg.username, msg.text] }
|
||||
end
|
||||
msg
|
||||
end
|
||||
|
||||
def last(board_id, n = 30)
|
||||
@mu.synchronize { load_board(board_id).last(n) }
|
||||
end
|
||||
|
||||
def count(board_id)
|
||||
@mu.synchronize { load_board(board_id).size }
|
||||
end
|
||||
|
||||
def total_count
|
||||
@mu.synchronize { @boards.sum { |b| load_board(b.id).size } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def migrate_legacy!(legacy_path)
|
||||
return unless File.exist?(legacy_path)
|
||||
target = path_for('general')
|
||||
return if File.exist?(target) # already migrated or general has its own data
|
||||
rows = []
|
||||
CSV.foreach(legacy_path) { |row| rows << row }
|
||||
return if rows.empty?
|
||||
CSV.open(target, 'w') { |csv| rows.each { |r| csv << r } }
|
||||
warn "BoardRepository: migrated #{rows.size} legacy messages " \
|
||||
"from #{legacy_path} to #{target}"
|
||||
rescue StandardError => e
|
||||
warn "BoardRepository: legacy migration failed: #{e}"
|
||||
end
|
||||
|
||||
def path_for(id)
|
||||
File.join(@base_dir, "#{id}.csv")
|
||||
end
|
||||
|
||||
def load_board(id)
|
||||
return @cache[id] if @cache.key?(id)
|
||||
list = []
|
||||
if File.exist?(path_for(id))
|
||||
CSV.foreach(path_for(id)) { |row| list << MessageModel.new(*row) }
|
||||
end
|
||||
@cache[id] = list
|
||||
rescue StandardError => e
|
||||
warn "BoardRepository load error (#{id}): #{e}"
|
||||
@cache[id] = []
|
||||
end
|
||||
end
|
||||
29
lib/domain/repository/catalog_repository.rb
Normal file
29
lib/domain/repository/catalog_repository.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
require_relative '../model/game_model'
|
||||
|
||||
class CatalogRepository
|
||||
API_URL = 'https://teletypegames.org/api/software'
|
||||
GAMES_URL = 'https://teletypegames.org'
|
||||
|
||||
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)
|
||||
entries = data.is_a?(Hash) ? (data['softwares'] || []) : data
|
||||
entries.filter_map { |e| GameModel.new(e) if e.is_a?(Hash) }
|
||||
rescue => e
|
||||
warn "CatalogRepository fetch error: #{e}"
|
||||
[]
|
||||
end
|
||||
|
||||
def play_url(path)
|
||||
"#{GAMES_URL}#{path}"
|
||||
end
|
||||
end
|
||||
41
lib/domain/repository/last_callers_repository.rb
Normal file
41
lib/domain/repository/last_callers_repository.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'csv'
|
||||
require 'time'
|
||||
require 'fileutils'
|
||||
|
||||
# Append-only log of the last N logins. Stored as CSV so it survives restarts.
|
||||
class LastCallersRepository
|
||||
Caller = Struct.new(:timestamp, :username, :session_id)
|
||||
|
||||
def initialize(path, limit: 50)
|
||||
@path = path
|
||||
@limit = limit
|
||||
@mu = Mutex.new
|
||||
@entries = load
|
||||
end
|
||||
|
||||
def record(username, session_id)
|
||||
entry = Caller.new(Time.now.iso8601, username, session_id)
|
||||
@mu.synchronize do
|
||||
@entries << entry
|
||||
@entries.shift while @entries.size > @limit
|
||||
FileUtils.mkdir_p(File.dirname(@path))
|
||||
CSV.open(@path, 'a') { |csv| csv << [entry.timestamp, entry.username, entry.session_id] }
|
||||
end
|
||||
entry
|
||||
end
|
||||
|
||||
def recent(n = 10)
|
||||
@mu.synchronize { @entries.last(n).reverse }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load
|
||||
return [] unless File.exist?(@path)
|
||||
CSV.read(@path).map { |row| Caller.new(*row) }
|
||||
rescue StandardError
|
||||
[]
|
||||
end
|
||||
end
|
||||
24
lib/domain/repository/online_users_repository.rb
Normal file
24
lib/domain/repository/online_users_repository.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OnlineUsersRepository
|
||||
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
|
||||
43
lib/domain/repository/profile_repository.rb
Normal file
43
lib/domain/repository/profile_repository.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bbs'
|
||||
|
||||
# Persistent user profile, keyed by lower-cased username. Backed by a
|
||||
# BBS::Store CSV at +path+.
|
||||
class ProfileRepository
|
||||
HEADERS = %w[session_id timestamp username signature location homepage notes].freeze
|
||||
|
||||
def initialize(path)
|
||||
@store = BBS::Store.new(path: path, headers: HEADERS)
|
||||
@by_name = {}
|
||||
@mu = Mutex.new
|
||||
rebuild
|
||||
end
|
||||
|
||||
def upsert(username:, **fields)
|
||||
return if username.to_s.strip.empty?
|
||||
key = username.downcase
|
||||
@mu.synchronize do
|
||||
session_id = (@by_name[key] && @by_name[key]['session_id']) || "user-#{key}"
|
||||
@store.upsert(session_id: session_id, username: username, **fields)
|
||||
rebuild_locked
|
||||
end
|
||||
end
|
||||
|
||||
def find(username)
|
||||
@mu.synchronize { @by_name[username.to_s.downcase] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rebuild
|
||||
@mu.synchronize { rebuild_locked }
|
||||
end
|
||||
|
||||
def rebuild_locked
|
||||
@by_name = @store.all.each_with_object({}) do |row, h|
|
||||
name = row['username'].to_s
|
||||
h[name.downcase] = row unless name.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
63
lib/domain/repository/wiki_repository.rb
Normal file
63
lib/domain/repository/wiki_repository.rb
Normal file
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'uri'
|
||||
require_relative '../model/wiki_page_model'
|
||||
|
||||
class WikiRepository
|
||||
BASE_URL = 'https://wiki.teletypegames.org'
|
||||
|
||||
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') || []).map do |p|
|
||||
WikiPageModel.new(
|
||||
id: p['id'],
|
||||
path: p['path'],
|
||||
title: p['title'],
|
||||
description: p['description'],
|
||||
created_at: p['createdAt'],
|
||||
locale: p['locale']
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
warn "WikiRepository 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 "WikiRepository 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
|
||||
17
lib/domain/service/board_service.rb
Normal file
17
lib/domain/service/board_service.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BoardService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def boards = @repo.boards
|
||||
def board(id) = @repo.boards.find { |b| b.id == id }
|
||||
def messages(id, n = 30) = @repo.last(id, n)
|
||||
def count(id) = @repo.count(id)
|
||||
def total_count = @repo.total_count
|
||||
|
||||
def post(board_id, username, text)
|
||||
return :empty if text.to_s.strip.empty?
|
||||
@repo.append(board_id, username, text.strip[0...1000])
|
||||
:ok
|
||||
end
|
||||
end
|
||||
55
lib/domain/service/chat_service.rb
Normal file
55
lib/domain/service/chat_service.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Tiny in-memory chat hub. Each session subscribes to receive broadcast
|
||||
# messages from other sessions. Backed by a per-session Queue so the
|
||||
# Application's idle tick can drain pending messages.
|
||||
class ChatService
|
||||
ChatLine = Struct.new(:timestamp, :username, :text)
|
||||
|
||||
def initialize
|
||||
@subs = {}
|
||||
@mu = Mutex.new
|
||||
@history = []
|
||||
@history_limit = 200
|
||||
end
|
||||
|
||||
def subscribe(session_id)
|
||||
@mu.synchronize do
|
||||
@subs[session_id] = Queue.new
|
||||
@history.last(50).each { |line| @subs[session_id] << line }
|
||||
end
|
||||
end
|
||||
|
||||
def unsubscribe(session_id)
|
||||
@mu.synchronize { @subs.delete(session_id) }
|
||||
end
|
||||
|
||||
def broadcast(username, text)
|
||||
line = ChatLine.new(Time.now.strftime('%H:%M:%S'), username, text)
|
||||
@mu.synchronize do
|
||||
@history << line
|
||||
@history.shift while @history.size > @history_limit
|
||||
@subs.each_value { |q| q << line }
|
||||
end
|
||||
line
|
||||
end
|
||||
|
||||
# Returns Array<ChatLine> of pending messages for the given session.
|
||||
def drain(session_id)
|
||||
queue = @mu.synchronize { @subs[session_id] }
|
||||
return [] unless queue
|
||||
pending = []
|
||||
pending << queue.pop(true) while !queue.empty?
|
||||
pending
|
||||
rescue ThreadError
|
||||
pending
|
||||
end
|
||||
|
||||
def history(n = 50)
|
||||
@mu.synchronize { @history.last(n).dup }
|
||||
end
|
||||
|
||||
def subscriber_count
|
||||
@mu.synchronize { @subs.size }
|
||||
end
|
||||
end
|
||||
7
lib/domain/service/games_service.rb
Normal file
7
lib/domain/service/games_service.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GamesService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def all = @repo.fetch
|
||||
end
|
||||
8
lib/domain/service/last_callers_service.rb
Normal file
8
lib/domain/service/last_callers_service.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LastCallersService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def record(username, session_id) = @repo.record(username, session_id)
|
||||
def recent(n = 10) = @repo.recent(n)
|
||||
end
|
||||
10
lib/domain/service/online_service.rb
Normal file
10
lib/domain/service/online_service.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class OnlineService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def add(session_id, username) = @repo.add(session_id, username)
|
||||
def remove(session_id) = @repo.remove(session_id)
|
||||
def count = @repo.count
|
||||
def user_list = @repo.snapshot.sort.map { |_, name| name }
|
||||
end
|
||||
8
lib/domain/service/profile_service.rb
Normal file
8
lib/domain/service/profile_service.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProfileService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def find(username) = @repo.find(username)
|
||||
def update(username, **f) = @repo.upsert(username: username, **f)
|
||||
end
|
||||
9
lib/domain/service/wiki_service.rb
Normal file
9
lib/domain/service/wiki_service.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WikiService
|
||||
def initialize(repo) = @repo = repo
|
||||
|
||||
def list(category) = @repo.list(category)
|
||||
def content(page_id) = @repo.content(page_id)
|
||||
def page_url(locale, path) = @repo.page_url(locale, path)
|
||||
end
|
||||
Reference in New Issue
Block a user