Add GMCP support and shared media layer

This commit is contained in:
2026-09-17 17:15:46 +02:00
parent 158ee2568c
commit 3fd28c2e0b
13 changed files with 1344 additions and 18 deletions
+231
View File
@@ -0,0 +1,231 @@
require "json"
module MeowClient
module GMCP
MAX_PAYLOAD_BYTES = 65_536
MAX_DEPTH = 24
MAX_NODES = 4_096
MAX_MESSAGES = 200
MAX_MESSAGE_BYTES = 262_144
PACKAGE_PATTERN = /\A[A-Za-z]+(?:\.[A-Za-z]+)*\z/
Message = Struct.new(:package, :data, :raw_json, keyword_init: true) do
def key
package.to_s.downcase
end
end
ParseError = Class.new(StandardError)
module Codec
module_function
def parse(bytes)
input = bytes.to_s.b
raise ParseError, "GMCP message exceeds #{MAX_PAYLOAD_BYTES} bytes" if input.bytesize > MAX_PAYLOAD_BYTES
separator = input.index(" ".b)
package_bytes = separator == nil ? input : input.byteslice(0, separator)
json_bytes = separator == nil ? nil : input.byteslice(separator + 1..-1).to_s.b
package = utf8(package_bytes, "package")
raise ParseError, "Invalid GMCP package name" unless package.match?(PACKAGE_PATTERN)
raw_json = json_bytes == nil ? nil : utf8(json_bytes, "JSON payload")
data = raw_json == nil || raw_json == "" ? nil : JSON.parse(raw_json)
validate_shape(data)
Message.new(:package => package, :data => data, :raw_json => raw_json)
rescue JSON::ParserError => error
raise ParseError, "Invalid GMCP JSON: #{error.message}"
end
def encode(package, data = :__absent__)
name = package.to_s
raise ArgumentError, "Invalid GMCP package name" unless name.match?(PACKAGE_PATTERN)
return name.b if data == :__absent__
json = JSON.generate(data)
raise ArgumentError, "GMCP message exceeds #{MAX_PAYLOAD_BYTES} bytes" if name.bytesize + 1 + json.bytesize > MAX_PAYLOAD_BYTES
(name + " " + json).encode(Encoding::UTF_8).b
end
def utf8(bytes, label)
value = bytes.to_s.b.force_encoding(Encoding::UTF_8)
raise ParseError, "Invalid UTF-8 in GMCP #{label}" unless value.valid_encoding?
value
end
def validate_shape(root)
nodes = 0
visit = lambda do |value, depth|
raise ParseError, "GMCP JSON is nested too deeply" if depth > MAX_DEPTH
nodes += 1
raise ParseError, "GMCP JSON contains too many values" if nodes > MAX_NODES
case value
when Hash
value.each do |key, child|
raise ParseError, "GMCP JSON object key is too large" if key.to_s.bytesize > MAX_PAYLOAD_BYTES
visit.call(child, depth + 1)
end
when Array
value.each { |child| visit.call(child, depth + 1) }
when String
raise ParseError, "GMCP JSON string is too large" if value.bytesize > MAX_PAYLOAD_BYTES
end
end
visit.call(root, 0)
end
end
class MessageLog
attr_reader :messages, :errors
def initialize
@messages = []
@message_bytes = 0
@errors = []
end
def add(message)
entry = format_message(message)
@messages << entry
@message_bytes += entry.bytesize
trim
entry
end
def add_error(text)
@errors << text.to_s
@errors.shift while @errors.size > 50
end
def text
parts = []
parts << "Diagnostics:\n#{@errors.join("\n")}" unless @errors.empty?
parts << @messages.join("\n") unless @messages.empty?
parts.empty? ? "No GMCP messages have been received." : parts.join("\n\n")
end
private
def format_message(message)
body = message.raw_json
body == nil || body == "" ? message.package.to_s : "#{message.package} #{body}"
end
def trim
while @messages.size > MAX_MESSAGES || @message_bytes > MAX_MESSAGE_BYTES
@message_bytes -= @messages.shift.to_s.bytesize
end
end
end
class State
MAX_COMMUNICATIONS = 200
attr_reader :character, :room, :items, :skill_groups, :skill_lists,
:skill_info, :group, :communications
def initialize
reset
end
def reset
@character = {}
@room = {}
@items = {}
@skill_groups = []
@skill_lists = {}
@skill_info = {}
@group = {}
@communications = []
end
def apply(message)
key = message.key
data = message.data
case key
when "char.base", "char.name", "char.vitals", "char.stats", "char.maxstats", "char.statusvars", "char.status", "char.worth"
@character[key.split(".", 2)[1]] = copy(data)
when "room.info"
@room["info"] = hash(data)
when "room.players"
@room["players"] = array(data)
when "room.addplayer"
players = (@room["players"] ||= [])
additions = data.is_a?(Array) ? array(data) : [hash(data)]
additions.each do |player|
name = hash(player)["name"].to_s.downcase
players.reject! { |row| hash(row)["name"].to_s.downcase == name }
players << copy(player)
end
when "room.removeplayer"
name = data.is_a?(Hash) ? data["name"] : data
(@room["players"] ||= []).reject! { |row| hash(row)["name"].to_s.casecmp?(name.to_s) }
when "room.wrongdir"
@room["wrong_direction"] = copy(data)
when "char.items.list"
value = hash(data)
@items[value["location"].to_s] = array(value["items"])
when "char.items.add", "char.items.update"
update_item(hash(data), key.end_with?("update"))
when "char.items.remove"
remove_item(hash(data))
when "char.skills.group", "char.skills.groups"
@skill_groups = array(data)
when "char.skills.list"
value = hash(data)
@skill_lists[value["group"].to_s] = copy(value)
when "char.skills.info"
value = hash(data)
@skill_info[[value["group"].to_s, (value["skill"] || value["name"]).to_s]] = copy(value)
when "group"
@group = hash(data)
else
add_communication(message) if key.start_with?("comm.")
end
self
end
private
def hash(value)
value.is_a?(Hash) ? copy(value) : {}
end
def array(value)
value.is_a?(Array) ? copy(value) : []
end
def copy(value)
Marshal.load(Marshal.dump(value))
rescue TypeError
value
end
def update_item(data, update)
location = data["location"].to_s
item = hash(data["item"])
rows = (@items[location] ||= [])
id = item["id"].to_s
index = rows.index { |row| hash(row)["id"].to_s == id }
if index == nil
rows << item
elsif update
rows[index] = hash(rows[index]).merge(item)
else
rows[index] = item
end
end
def remove_item(data)
location = data["location"].to_s
item = data["item"]
id = item.is_a?(Hash) ? item["id"] : item
(@items[location] ||= []).reject! { |row| hash(row)["id"].to_s == id.to_s }
end
def add_communication(message)
@communications << {"package" => message.package, "data" => copy(message.data)}
@communications.shift while @communications.size > MAX_COMMUNICATIONS
end
end
end
end
+487
View File
@@ -0,0 +1,487 @@
require "digest"
require "fileutils"
require "ipaddr"
require "net/http"
require "uri"
module MeowClient
module Media
PlayRequest = Struct.new(
:name, :url, :kind, :tag, :volume, :fade_in, :fade_out, :start_at,
:finish_at, :loops, :priority, :continue_existing, :key, :caption,
keyword_init: true
)
StopRequest = Struct.new(
:name, :kind, :tag, :priority, :key, :fade_away, :fade_out,
keyword_init: true
)
PreloadRequest = Struct.new(:name, :url, keyword_init: true)
DownloadResult = Struct.new(:id, :path, :url, :error, keyword_init: true)
Track = Struct.new(:request, :handle, :url, :remaining_loops, :started_at, keyword_init: true)
class URLPolicy
AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac].freeze
def resolve(base_url, name, explicit_url = nil)
clean_name = validate_name(name)
base = explicit_url.to_s.strip == "" ? base_url : explicit_url
raise ArgumentError, "No media base URL was supplied" if base.to_s.strip == ""
base_uri = validate_uri(URI.parse(base.to_s))
value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name)
validate_uri(value)
rescue URI::InvalidURIError => error
raise ArgumentError, "Invalid media URL: #{error.message}"
end
def validate_uri(uri)
raise ArgumentError, "Only HTTPS media URLs are allowed" unless uri.scheme.to_s.downcase == "https"
raise ArgumentError, "Media URL must include a host" if uri.host.to_s == ""
raise ArgumentError, "Media URL credentials are not allowed" if uri.userinfo != nil
raise ArgumentError, "Media URL contains control characters" if uri.to_s.match?(/[\x00-\x1f\x7f]/)
reject_private_literal(uri.host)
uri
end
def validate_name(name)
value = name.to_s.tr("\\", "/")
raise ArgumentError, "Media name contains control characters" if value.match?(/[\x00-\x1f\x7f]/)
raise ArgumentError, "Media name must be relative" if value.start_with?("/", "//") || value.match?(/\A[A-Za-z]:/)
segments = value.split("/")
raise ArgumentError, "Media name may not traverse parent directories" if segments.include?("..")
value
end
def supported_audio?(uri)
AUDIO_EXTENSIONS.include?(File.extname(uri.path.to_s).downcase)
end
private
def ensure_directory(value)
value.end_with?("/") ? value : value + "/"
end
def reject_private_literal(host)
ip = IPAddr.new(host)
private_ip = ip.loopback? || ip.link_local? || ip.private? || ip.multicast? || ip.unspecified?
raise ArgumentError, "Private or reserved media addresses are not allowed" if private_ip
rescue IPAddr::InvalidAddressError
end
end
class Downloader
MAX_FILE_BYTES = 25 * 1_048_576
MAX_CACHE_BYTES = 100 * 1_048_576
MAX_QUEUE = 32
MAX_REDIRECTS = 5
OPEN_TIMEOUT = 10
READ_TIMEOUT = 15
attr_reader :results
def initialize(cache_directory, policy = URLPolicy.new)
@cache_directory = cache_directory.to_s
@policy = policy
@queue = SizedQueue.new(MAX_QUEUE)
@results = Queue.new
@closed = false
FileUtils.mkdir_p(@cache_directory)
@worker = Thread.new { work }
@worker.report_on_exception = false
end
def enqueue(id, uri)
return false if @closed
cached = cache_path(uri)
if File.file?(cached)
File.utime(Time.now, Time.now, cached) rescue nil
@results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s)
return true
end
@queue.push([id, uri], true)
true
rescue ThreadError
false
end
def close
return if @closed
@closed = true
@queue.push(nil, true) rescue nil
@worker.join(1.0) if @worker != nil && @worker != Thread.current
@worker.kill if @worker != nil && @worker.alive?
nil
end
private
def work
loop do
job = @queue.pop
break if job == nil || @closed
id, uri = job
begin
path, final_uri = download(uri)
@results << DownloadResult.new(:id => id, :path => path, :url => final_uri.to_s)
rescue Exception => error
@results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message)
end
end
end
def download(uri, redirects = 0)
raise "Too many media redirects" if redirects > MAX_REDIRECTS
current = @policy.validate_uri(uri)
response = nil
Net::HTTP.start(
current.host,
current.port,
:use_ssl => true,
:open_timeout => OPEN_TIMEOUT,
:read_timeout => READ_TIMEOUT
) do |http|
request = Net::HTTP::Get.new(current.request_uri)
http.request(request) do |incoming|
response = incoming
if incoming.is_a?(Net::HTTPRedirection)
location = incoming["location"]
raise "Media redirect did not include a location" if location.to_s == ""
redirected = @policy.validate_uri(URI.join(current.to_s, location))
return download(redirected, redirects + 1)
end
raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess)
length = Integer(incoming["content-length"], :exception => false)
raise "Media file exceeds #{MAX_FILE_BYTES} bytes" if length != nil && length > MAX_FILE_BYTES
target = cache_path(current)
temporary = target + ".part-#{Thread.current.object_id}"
total = 0
begin
File.open(temporary, "wb") do |file|
incoming.read_body do |chunk|
total += chunk.bytesize
raise "Media file exceeds #{MAX_FILE_BYTES} bytes" if total > MAX_FILE_BYTES
file.write(chunk)
end
end
if File.file?(target)
File.delete(temporary)
else
File.rename(temporary, target)
end
ensure
File.delete(temporary) if File.exist?(temporary)
end
prune_cache(target)
return [target, current]
end
end
raise "Media download failed" if response == nil
end
def cache_path(uri)
extension = File.extname(uri.path.to_s).downcase
extension = ".media" unless URLPolicy::AUDIO_EXTENSIONS.include?(extension)
File.join(@cache_directory, Digest::SHA256.hexdigest(uri.to_s) + extension)
end
def prune_cache(protected_path)
rows = Dir.glob(File.join(@cache_directory, "*")).filter_map do |file|
next unless File.file?(file) && !File.basename(file).include?(".part-")
stat = File.stat(file)
[file, stat.size, stat.mtime]
rescue SystemCallError
nil
end
total = rows.sum { |row| row[1] }
rows.sort_by { |row| row[2] }.each do |file, size, _time|
break if total <= MAX_CACHE_BYTES
next if file == protected_path
File.delete(file) rescue next
total -= size
end
end
end
class EltenSoundHandle
def initialize(path, request, category_volume)
@sound = Sound.new(path, :loop => request.loops.to_i == -1)
@target_volume = [[request.volume.to_i, 0].max, 100].min / 100.0 * category_volume
@sound.position = request.start_at.to_f / 1000.0 if request.start_at.to_i > 0
volume = @sound.attribute(:volume)
volume.value = request.fade_in.to_i > 0 ? 0.0 : @target_volume
@sound.play
volume.slide(@target_volume, :duration => request.fade_in.to_f / 1000.0) if request.fade_in.to_i > 0
end
def finished?
@sound.finished?
end
def position_ms
(@sound.position.to_f * 1000).to_i
end
def restart(position_ms)
@sound.position = position_ms.to_f / 1000.0
@sound.play
end
def stop(fade_ms = 0)
if fade_ms.to_i > 0 && !finished?
@sound.attribute(:volume).slide(0.0, :duration => fade_ms.to_f / 1000.0)
Thread.new do
sleep(fade_ms.to_f / 1000.0)
close
end
else
close
end
end
def close
@sound.stop rescue nil
@sound.close rescue nil
end
end
class EltenBackend
def start(path, request, category_volume)
EltenSoundHandle.new(path, request, category_volume)
end
end
class Manager
MAX_TRACKS = 16
attr_reader :captions, :errors, :tracks
attr_reader :muted
def initialize(cache_directory, sound_volume: 1.0, music_volume: 1.0, backend: EltenBackend.new, downloader: nil)
@policy = URLPolicy.new
@downloader = downloader || Downloader.new(cache_directory, @policy)
@backend = backend
@sound_volume = sound_volume.to_f
@music_volume = music_volume.to_f
@tracks = []
@pending = {}
@sequence = 0
@captions = []
@pending_captions = []
@errors = []
@default_url = nil
@loaded_urls = {}
@muted = false
end
def default_url=(value)
@default_url = @policy.validate_uri(URI.parse(value.to_s)).to_s
rescue ArgumentError, URI::InvalidURIError => error
add_error(error.message)
end
def preload(request)
queue(request, false)
end
def play(request)
request.kind = normalize_kind(request.kind)
request.volume = 50 if request.volume == nil
request.loops = 1 if request.loops == nil || request.loops.to_i == 0
request.priority = 50 if request.priority == nil
remember_caption(request.caption)
return nil if @muted
existing = matching_identity(request)
return existing if request.continue_existing != false && existing != nil
stop(StopRequest.new(:key => request.key)) if request.key.to_s != ""
queue(request, true)
rescue Exception => error
add_error(error.message)
nil
end
def muted=(value)
@muted = value == true
stop if @muted
@muted
end
def stop(request = StopRequest.new)
pending_ids = @pending.filter_map do |id, (pending_request, play_after)|
id if play_after && stop_match?(pending_request, request)
end
pending_ids.each { |id| @pending.delete(id) }
selected = @tracks.select { |track| stop_match?(track.request, request) }
selected.each do |track|
fade = request.fade_away ? (track.request.fade_out || request.fade_out || 5_000) : (request.fade_out || 0)
track.handle.stop(fade)
@tracks.delete(track)
end
selected.size + pending_ids.size
end
def drain_captions
values = @pending_captions
@pending_captions = []
values
end
def tick
drain_downloads
@tracks.dup.each do |track|
request = track.request
if request.finish_at.to_i > 0 && track.handle.position_ms >= request.finish_at.to_i
finish_iteration(track)
elsif track.handle.finished?
finish_iteration(track)
end
rescue Exception => error
add_error(error.message)
remove_track(track)
end
end
def close
@pending.clear
@tracks.dup.each { |track| remove_track(track) }
@downloader.close
nil
end
private
def queue(request, play_after)
loaded = @loaded_urls[request.name.to_s] if request.url.to_s == ""
if loaded != nil
uri = @policy.validate_uri(URI.parse(loaded))
else
base = request.url.to_s == "" ? @default_url : request.url
uri = @policy.resolve(base, request.name, request.url)
end
raise "Unsupported audio file type" unless @policy.supported_audio?(uri)
@sequence += 1
@pending[@sequence] = [request, play_after]
unless @downloader.enqueue(@sequence, uri)
@pending.delete(@sequence)
raise "Media download queue is full"
end
@loaded_urls[request.name.to_s] = uri.to_s
@sequence
end
def drain_downloads
loop do
result = @downloader.results.pop(true)
request, play_after = @pending.delete(result.id)
next if request == nil
if result.error != nil
add_error(result.error)
elsif play_after
start_track(request, result.path, result.url)
end
end
rescue ThreadError
end
def start_track(request, path, url)
priority = request.priority.to_i
return if @tracks.any? { |track| track.request.priority.to_i > priority }
@tracks.select { |track| track.request.priority.to_i < priority }.each { |track| remove_track(track) }
if @tracks.size >= MAX_TRACKS
victim = @tracks.min_by { |track| [track.request.priority.to_i, track.started_at] }
return if victim.request.priority.to_i > priority
remove_track(victim)
end
volume = request.kind == "music" ? @music_volume : @sound_volume
handle = @backend.start(path, request, volume)
loops = request.loops.to_i
@tracks << Track.new(
:request => request,
:handle => handle,
:url => url,
:remaining_loops => loops < 0 ? -1 : loops,
:started_at => Process.clock_gettime(Process::CLOCK_MONOTONIC)
)
end
def finish_iteration(track)
if track.remaining_loops == -1
track.handle.restart(track.request.start_at.to_i)
elsif track.remaining_loops > 1
track.remaining_loops -= 1
track.handle.restart(track.request.start_at.to_i)
else
remove_track(track)
end
end
def remove_track(track)
@tracks.delete(track)
track.handle.close rescue nil
end
def matching_identity(request)
@tracks.find do |track|
(request.key.to_s != "" && track.request.key.to_s == request.key.to_s) ||
track.request.name.to_s == request.name.to_s
end
end
def stop_match?(playing, stop)
filters = []
filters << playing.name.to_s == stop.name.to_s if stop.name.to_s != ""
filters << playing.kind.to_s == stop.kind.to_s if stop.kind.to_s != ""
filters << playing.tag.to_s == stop.tag.to_s if stop.tag.to_s != ""
filters << playing.key.to_s == stop.key.to_s if stop.key.to_s != ""
filters << playing.priority.to_i <= stop.priority.to_i if stop.priority != nil
filters.empty? || filters.all?
end
def normalize_kind(value)
%w[sound music video].include?(value.to_s.downcase) ? value.to_s.downcase : "sound"
end
def remember_caption(value)
return if value.to_s.strip == ""
@captions << value.to_s
@pending_captions << value.to_s
@captions.shift while @captions.size > 100
end
def add_error(value)
@errors << value.to_s
@errors.shift while @errors.size > 50
end
end
module ClientMedia
module_function
def play_request(data)
value = data.is_a?(Hash) ? data : {}
PlayRequest.new(
:name => value["name"], :url => value["url"], :kind => value["type"],
:tag => value["tag"], :volume => value["volume"], :fade_in => value["fadein"],
:fade_out => value["fadeout"], :start_at => value["start"], :finish_at => value["finish"],
:loops => value["loops"], :priority => value["priority"],
:continue_existing => value.fetch("continue", true), :key => value["key"],
:caption => value["caption"]
)
end
def stop_request(data)
value = data.is_a?(Hash) ? data : {}
StopRequest.new(
:name => value["name"], :kind => value["type"], :tag => value["tag"],
:priority => value["priority"], :key => value["key"],
:fade_away => value["fadeaway"] == true, :fade_out => value["fadeout"]
)
end
def preload_request(data)
value = data.is_a?(Hash) ? data : {}
PreloadRequest.new(:name => value["name"], :url => value["url"])
end
end
end
end
+29 -5
View File
@@ -3,6 +3,7 @@ require "securerandom"
module MeowClient
ConnectionProfile = Struct.new(
:id, :name, :host, :port, :tls, :encoding, :auto_read,
:remote_media_enabled, :speak_media_captions, :sound_volume, :music_volume,
keyword_init: true
) do
def to_h
@@ -13,14 +14,18 @@ module MeowClient
"port" => port,
"tls" => tls == true,
"encoding" => encoding,
"auto_read" => auto_read != false
"auto_read" => auto_read != false,
"remote_media_enabled" => remote_media_enabled == true,
"speak_media_captions" => speak_media_captions == true,
"sound_volume" => sound_volume.to_i,
"music_volume" => music_volume.to_i
}
end
end
class ProfileRepository
FILE_NAME = "profiles.json"
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
ENCODINGS = {
"UTF-8" => "UTF-8",
"Windows-1252" => "Windows-1252",
@@ -74,7 +79,11 @@ module MeowClient
:port => attributes[:port] || 23,
:tls => attributes[:tls] == true,
:encoding => attributes[:encoding] || "UTF-8",
:auto_read => attributes.fetch(:auto_read, true)
:auto_read => attributes.fetch(:auto_read, true),
:remote_media_enabled => attributes[:remote_media_enabled] == true,
:speak_media_captions => attributes[:speak_media_captions] == true,
:sound_volume => attributes.fetch(:sound_volume, 100),
:music_volume => attributes.fetch(:music_volume, 100)
)
end
@@ -85,11 +94,18 @@ module MeowClient
port = Integer(profile.port, :exception => false)
errors << _("Port must be between 1 and 65535.") if port == nil || port < 1 || port > 65_535
errors << _("Choose a supported character encoding.") unless ENCODINGS.value?(profile.encoding.to_s)
errors << _("Sound volume must be between 0 and 100.") unless valid_volume?(profile.sound_volume)
errors << _("Music volume must be between 0 and 100.") unless valid_volume?(profile.music_volume)
errors
end
private
def valid_volume?(value)
number = Integer(value, :exception => false)
number != nil && number >= 0 && number <= 100
end
def default_document
{"version" => SCHEMA_VERSION, "profiles" => []}
end
@@ -102,7 +118,11 @@ module MeowClient
:port => Integer(profile.port, :exception => false) || 23,
:tls => profile.tls == true,
:encoding => ENCODINGS.value?(profile.encoding.to_s) ? profile.encoding.to_s : "UTF-8",
:auto_read => profile.auto_read != false
:auto_read => profile.auto_read != false,
:remote_media_enabled => profile.remote_media_enabled == true,
:speak_media_captions => profile.speak_media_captions == true,
:sound_volume => [[profile.sound_volume.to_i, 0].max, 100].min,
:music_volume => [[profile.music_volume.to_i, 0].max, 100].min
)
end
@@ -115,7 +135,11 @@ module MeowClient
:port => row["port"],
:tls => row["tls"] == true,
:encoding => row["encoding"],
:auto_read => row["auto_read"] != false
:auto_read => row["auto_read"] != false,
:remote_media_enabled => row["remote_media_enabled"] == true,
:speak_media_captions => row["speak_media_captions"] == true,
:sound_volume => row.fetch("sound_volume", 100),
:music_volume => row.fetch("music_volume", 100)
)
validate(profile).empty? ? profile : nil
end
+16 -1
View File
@@ -1,5 +1,8 @@
require_relative "session_gmcp"
module MeowClient
class Session
include SessionGMCP
MAX_LINES = 5_000
MAX_BYTES = 1_048_576
MAX_HISTORY = 100
@@ -20,6 +23,10 @@ module MeowClient
@speech_paused = profile.auto_read == false
@speech_overflow = 0
@sensitive_next = false
@gmcp_enabled = false
@gmcp_state = GMCP::State.new
@gmcp_log = GMCP::MessageLog.new
@media = nil
@closed = false
build_form
end
@@ -28,6 +35,7 @@ module MeowClient
@form.wait
ensure
@pipeline.close
@media.close if @media != nil
@transport.close
@program.release(@transport, :close => false)
end
@@ -46,9 +54,10 @@ module MeowClient
@send = Button.new(_("Send"))
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
@stop_speech = Button.new(_("Stop current speech"))
@gmcp_button = Button.new(_("GMCP information"))
@disconnect = Button.new(_("Disconnect"))
@form = Form.new(
[@status, @output, @command, @send, @speech_button, @stop_speech, @disconnect],
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @disconnect],
:quiet => true
)
@form.accept_button = @send
@@ -58,6 +67,7 @@ module MeowClient
@send.on(:press) { send_command }
@speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop }
@gmcp_button.on(:press) { open_gmcp_hub }
@disconnect.on(:press) { request_disconnect }
@output.add_tip(_("Use the context menu to jump to the latest output."))
@@ -83,6 +93,8 @@ module MeowClient
record = @pipeline.flush_prompt
receive_record(record) if record != nil
end
@media.tick if @media != nil
speak_new_media_captions
dispatch_speech
rescue Telnet::CompressionError
add_system_line(_("The compressed data stream could not be processed safely. The connection was closed."))
@@ -100,6 +112,7 @@ module MeowClient
when :bytes
result = @pipeline.feed(event.payload)
result.replies.each { |reply| @transport.send_bytes(reply) }
result.events.each { |protocol_event| handle_protocol_event(protocol_event) }
result.records.each { |record| receive_record(record) }
when :disconnected, :error
@pipeline.finish.each { |record| receive_record(record) }
@@ -258,6 +271,8 @@ module MeowClient
def close_form
return if @closed
@closed = true
@media.close if @media != nil
@media = nil
@transport.close
@form.resume
end
+272
View File
@@ -0,0 +1,272 @@
module MeowClient
module SessionGMCP
private
def handle_protocol_event(event)
type, payload = event
case type
when :gmcp_enabled
payload ? enable_gmcp : disable_gmcp
when :gmcp
receive_gmcp(payload)
when :gmcp_error
@gmcp_log.add_error(payload)
end
end
def enable_gmcp
return if @gmcp_enabled
@gmcp_enabled = true
@gmcp_button.label = _("GMCP information, available") if @gmcp_button != nil
send_gmcp("Core.Hello", {"client" => "Meow", "version" => MeowClient::VERSION})
packages = ["Char 1", "Char.Items 1", "Char.Skills 1", "Room 1", "Group 1", "Comm 1"]
packages << "Client.Media 1" if @profile.remote_media_enabled
send_gmcp("Core.Supports.Set", packages)
if @profile.remote_media_enabled
@media = Media::Manager.new(
@program.cache_path("mud-media"),
:sound_volume => @profile.sound_volume.to_f / 100.0,
:music_volume => @profile.music_volume.to_f / 100.0
)
end
add_system_line(_("GMCP negotiated."))
end
def disable_gmcp
return unless @gmcp_enabled
@gmcp_enabled = false
@gmcp_button.label = _("GMCP information") if @gmcp_button != nil
@gmcp_state.reset
@media.close if @media != nil
@media = nil
add_system_line(_("GMCP disabled by the server."))
end
def send_gmcp(package, data = :__absent__)
return false unless @gmcp_enabled
@transport.send_bytes(Telnet.gmcp_packet(package, data))
rescue ArgumentError => error
@gmcp_log.add_error(error.message)
false
end
def send_gmcp_raw(body)
return false unless @gmcp_enabled
@transport.send_bytes(Telnet.gmcp_raw_packet(body))
rescue ArgumentError => error
@gmcp_log.add_error(error.message)
false
end
def receive_gmcp(message)
@gmcp_log.add(message)
@gmcp_state.apply(message)
case message.key
when "core.goodbye"
add_system_line(message.data.to_s)
when "core.ping"
send_gmcp("Core.Ping")
when "client.media.default"
@media.default_url = message.data["url"] if @media != nil && message.data.is_a?(Hash)
when "client.media.load"
@media.preload(Media::ClientMedia.preload_request(message.data)) if @media != nil
when "client.media.play"
@media.play(Media::ClientMedia.play_request(message.data)) if @media != nil
when "client.media.stop"
@media.stop(Media::ClientMedia.stop_request(message.data)) if @media != nil
end
rescue Exception => error
@gmcp_log.add_error("#{message.package}: #{error.message}")
end
def speak_new_media_captions
return if @media == nil
captions = @media.drain_captions
return unless @profile.speak_media_captions
captions.each do |caption|
enqueue_speech(_("Sound: %{caption}") % {:caption => caption})
end
end
def open_gmcp_hub
return alert(_("This server has not negotiated GMCP."), false) unless @gmcp_enabled
status = Static.new(_("GMCP is active."))
character = Button.new(_("Character"))
room = Button.new(_("Room"))
items = Button.new(_("Items"))
skills = Button.new(_("Skills"))
group = Button.new(_("Group"))
communications = Button.new(_("Communications"))
media = Button.new(_("Media"))
inspector = Button.new(_("Raw GMCP inspector"))
back = Button.new(_("Back"))
form = Form.new(
[status, character, room, items, skills, group, communications, media, inspector, back],
:quiet => true
)
form.cancel_button = back
form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
character.on(:press) { show_live_text(_("Character")) { formatted_or_empty(@gmcp_state.character) } }
room.on(:press) { show_live_text(_("Room")) { formatted_or_empty(@gmcp_state.room) } }
items.on(:press) { open_items_view }
skills.on(:press) { open_skills_view }
group.on(:press) { open_group_view }
communications.on(:press) { show_live_text(_("Communications")) { formatted_or_empty(@gmcp_state.communications) } }
media.on(:press) { open_media_view }
inspector.on(:press) { show_live_text(_("Raw GMCP inspector")) { @gmcp_log.text } }
back.on(:press) { form.resume }
form.wait
end
def show_live_text(title, &source)
output = EditBox.new(
title,
:type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine,
:text => source.call.to_s
)
back = Button.new(_("Back"))
form = Form.new([output, back], :quiet => true)
form.cancel_button = back
form.add_timer(FormTimer.new(0.1, :repeat => true) do
tick
output.set_text(source.call.to_s, false)
end)
back.on(:press) { form.resume }
form.wait
end
def open_items_view
rows = @gmcp_state.items.values.flatten
labels = rows.map { |item| item.is_a?(Hash) ? item["name"].to_s : item.to_s }
list = ListBox.new(labels, :header => _("Known items"), :empty_label => _("No item data has been received."))
inventory = Button.new(_("Refresh inventory"))
room = Button.new(_("Refresh room items"))
contents = Button.new(_("Open selected container"))
details = Button.new(_("View all item details"))
back = Button.new(_("Back"))
form = Form.new([list, inventory, room, contents, details, back], :quiet => true)
form.cancel_button = back
form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
inventory.on(:press) { send_gmcp("Char.Items.Inv", "") }
room.on(:press) { send_gmcp("Char.Items.Room", "") }
contents.on(:press) do
item = rows[list.index]
if item.is_a?(Hash) && item["id"] != nil
send_gmcp("Char.Items.Contents", item["id"])
else
alert(_("Select an item with an identifier."), false)
end
end
details.on(:press) { show_live_text(_("Item details")) { formatted_or_empty(@gmcp_state.items) } }
back.on(:press) { form.resume }
form.wait
end
def open_skills_view
groups = @gmcp_state.skill_groups
labels = groups.map do |group|
group.is_a?(Hash) ? "#{group["name"]}: #{group["rank"]}" : group.to_s
end
list = ListBox.new(labels, :header => _("Skill groups"), :empty_label => _("No skill groups have been received."))
refresh = Button.new(_("Refresh skill groups"))
load = Button.new(_("Load selected group"))
details = Button.new(_("View loaded skills"))
back = Button.new(_("Back"))
form = Form.new([list, refresh, load, details, back], :quiet => true)
form.cancel_button = back
form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
refresh.on(:press) { send_gmcp("Char.Skills.Get", {}) }
load.on(:press) do
group = groups[list.index]
name = group.is_a?(Hash) ? group["name"] : group
send_gmcp("Char.Skills.Get", {"group" => name.to_s}) if name != nil
end
details.on(:press) do
show_live_text(_("Skills")) do
formatted_or_empty({"lists" => @gmcp_state.skill_lists, "details" => @gmcp_state.skill_info})
end
end
back.on(:press) { form.resume }
form.wait
end
def open_group_view
enable = Button.new(_("Enable Aardwolf group updates"))
details = Button.new(_("View group"))
back = Button.new(_("Back"))
form = Form.new([enable, details, back], :quiet => true)
form.cancel_button = back
form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
enable.on(:press) { send_gmcp_raw("group on") }
details.on(:press) { show_live_text(_("Group")) { formatted_or_empty(@gmcp_state.group) } }
back.on(:press) { form.resume }
form.wait
end
def open_media_view
return alert(_("Remote media is disabled for this connection profile."), false) if @media == nil
summary = EditBox.new(
_("Media activity"),
:type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine,
:text => media_text
)
mute = Button.new(@media.muted ? _("Unmute media") : _("Mute media"))
stop_music = Button.new(_("Stop music"))
stop_sounds = Button.new(_("Stop sounds"))
stop_all = Button.new(_("Stop all media"))
back = Button.new(_("Back"))
form = Form.new([summary, mute, stop_music, stop_sounds, stop_all, back], :quiet => true)
form.cancel_button = back
form.add_timer(FormTimer.new(0.1, :repeat => true) do
tick
summary.set_text(media_text, false)
end)
mute.on(:press) do
@media.muted = !@media.muted
mute.label = @media.muted ? _("Unmute media") : _("Mute media")
end
stop_music.on(:press) { @media.stop(Media::StopRequest.new(:kind => "music")) }
stop_sounds.on(:press) { @media.stop(Media::StopRequest.new(:kind => "sound")) }
stop_all.on(:press) { @media.stop }
back.on(:press) { form.resume }
form.wait
end
def media_text
lines = [_("Active tracks: %{count}") % {:count => @media.tracks.size}]
lines << _("Captions:") + "\n" + @media.captions.join("\n") unless @media.captions.empty?
lines << _("Errors:") + "\n" + @media.errors.join("\n") unless @media.errors.empty?
lines.join("\n\n")
end
def formatted_or_empty(value)
return _("No data has been received.") if value.respond_to?(:empty?) && value.empty?
format_gmcp_value(value)
end
def format_gmcp_value(value, indent = 0)
prefix = " " * indent
case value
when Hash
value.keys.sort_by(&:to_s).map do |key|
child = value[key]
if child.is_a?(Hash) || child.is_a?(Array)
"#{prefix}#{key}:\n#{format_gmcp_value(child, indent + 1)}"
else
"#{prefix}#{key}: #{child}"
end
end.join("\n")
when Array
value.each_with_index.map do |child, index|
if child.is_a?(Hash) || child.is_a?(Array)
"#{prefix}#{index + 1}:\n#{format_gmcp_value(child, indent + 1)}"
else
"#{prefix}#{child}"
end
end.join("\n")
else
"#{prefix}#{value}"
end
end
end
end
+43 -3
View File
@@ -1,4 +1,5 @@
require "zlib"
require_relative "gmcp"
module MeowClient
module Telnet
@@ -14,13 +15,28 @@ module MeowClient
SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24
COMPRESS2 = 86
GMCP = 201
TTYPE_IS = 0
TTYPE_SEND = 1
MAX_INFLATED_BYTES_PER_FEED = 1_048_576
MAX_SUBNEGOTIATION_BYTES = 65_536
Result = Struct.new(:data, :replies, :events, keyword_init: true)
CompressionError = Class.new(StandardError)
def self.gmcp_packet(package, data = :__absent__)
payload = MeowClient::GMCP::Codec.encode(package, data)
escaped = payload.gsub([IAC].pack("C"), [IAC, IAC].pack("C*"))
[IAC, SB, GMCP].pack("C*") + escaped + [IAC, SE].pack("C*")
end
def self.gmcp_raw_packet(payload)
value = payload.to_s.encode(Encoding::UTF_8).b
raise ArgumentError, "GMCP message exceeds #{MAX_SUBNEGOTIATION_BYTES} bytes" if value.bytesize > MAX_SUBNEGOTIATION_BYTES
escaped = value.gsub([IAC].pack("C"), [IAC, IAC].pack("C*"))
[IAC, SB, GMCP].pack("C*") + escaped + [IAC, SE].pack("C*")
end
class Parser
attr_reader :remote_echo
@@ -29,6 +45,7 @@ module MeowClient
@negotiation_command = nil
@subnegotiation_option = nil
@subnegotiation = +"".b
@subnegotiation_overflow = false
@remote_echo = false
@remote_options = {}
@local_options = {}
@@ -89,16 +106,17 @@ module MeowClient
when :sb_option
@subnegotiation_option = byte
@subnegotiation.clear
@subnegotiation_overflow = false
@state = :sb
when :sb
if byte == IAC
@state = :sb_iac
else
@subnegotiation << byte
append_subnegotiation(byte)
end
when :sb_iac
if byte == IAC
@subnegotiation << IAC
append_subnegotiation(IAC)
@state = :sb
elsif byte == SE
reply, event = finish_subnegotiation
@@ -149,7 +167,7 @@ module MeowClient
def negotiate(command, option)
case command
when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2].include?(option)
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option)
changed = @remote_options[option] != enabled
@remote_options[option] = enabled
return [nil, nil] unless changed
@@ -157,6 +175,7 @@ module MeowClient
@remote_echo = enabled
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end
return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled
[packet(enabled ? DO : DONT, option), nil]
when WONT
changed = @remote_options[option] != false
@@ -165,6 +184,7 @@ module MeowClient
@remote_echo = false
return [packet(DONT, option), [:remote_echo, false]] if changed
end
return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed
[changed ? packet(DONT, option) : nil, nil]
when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
@@ -181,6 +201,17 @@ module MeowClient
end
def finish_subnegotiation
if @subnegotiation_overflow
return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP
return [nil, [:subnegotiation, @subnegotiation_option]]
end
if @subnegotiation_option == GMCP && @remote_options[GMCP] == true
begin
return [nil, [:gmcp, MeowClient::GMCP::Codec.parse(@subnegotiation)]]
rescue MeowClient::GMCP::ParseError => error
return [nil, [:gmcp_error, error.message]]
end
end
if @subnegotiation_option == COMPRESS2 &&
@remote_options[COMPRESS2] == true &&
@subnegotiation.empty?
@@ -198,6 +229,15 @@ module MeowClient
ensure
@subnegotiation_option = nil
@subnegotiation.clear
@subnegotiation_overflow = false
end
def append_subnegotiation(byte)
if @subnegotiation.bytesize < MAX_SUBNEGOTIATION_BYTES
@subnegotiation << byte
else
@subnegotiation_overflow = true
end
end
end
end
+10 -2
View File
@@ -81,6 +81,10 @@ module MeowClient
port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s)
tls = CheckBox.new(_("Use TLS"), :checked => profile.tls)
auto_read = CheckBox.new(_("Read incoming text automatically"), :checked => profile.auto_read)
remote_media = CheckBox.new(_("Allow this server to download and play HTTPS media"), :checked => profile.remote_media_enabled)
speak_captions = CheckBox.new(_("Read media captions automatically"), :checked => profile.speak_media_captions)
sound_volume = EditBox.new(_("Sound volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.sound_volume.to_i.to_s)
music_volume = EditBox.new(_("Music volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.music_volume.to_i.to_s)
encoding_names = ProfileRepository::ENCODINGS.keys
encoding_index = [ProfileRepository::ENCODINGS.values.index(profile.encoding) || 0, 0].max
encoding = ChoiceListBox.new(
@@ -89,7 +93,7 @@ module MeowClient
)
save = Button.new(_("Save"))
cancel = Button.new(_("Cancel"))
form = Form.new([name, host, port, tls, auto_read, encoding, save, cancel], :quiet => true)
form = Form.new([name, host, port, tls, auto_read, remote_media, speak_captions, sound_volume, music_volume, encoding, save, cancel], :quiet => true)
form.accept_button = save
form.cancel_button = cancel
saved = false
@@ -102,7 +106,11 @@ module MeowClient
:port => port.text,
:tls => tls.checked,
:encoding => ProfileRepository::ENCODINGS[encoding_names[encoding.value(0)]],
:auto_read => auto_read.checked
:auto_read => auto_read.checked,
:remote_media_enabled => remote_media.checked,
:speak_media_captions => speak_captions.checked,
:sound_volume => sound_volume.text,
:music_volume => music_volume.text
)
errors = @profiles.validate(candidate)
if errors.empty?
+3
View File
@@ -0,0 +1,3 @@
module MeowClient
VERSION = "0.2.0"
end