Add MSP media protocol support

This commit is contained in:
2026-09-17 17:53:08 +02:00
parent 3fd28c2e0b
commit 573f1b7acb
14 changed files with 357 additions and 51 deletions
+7 -6
View File
@@ -9,11 +9,12 @@ The project is currently an early but usable Telnet client. Its interface is bui
- Connection profiles with name, host, port, TLS, encoding, and automatic-reading preferences - Connection profiles with name, host, port, TLS, encoding, and automatic-reading preferences
- Plain Telnet and certificate-verified TLS connections - Plain Telnet and certificate-verified TLS connections
- UTF-8, Windows-1252, and ISO-8859-1 character encodings - UTF-8, Windows-1252, and ISO-8859-1 character encodings
- Streaming Telnet negotiation with ECHO, Suppress Go Ahead, and Terminal Type support - Streaming Telnet negotiation with ECHO, Suppress Go Ahead, Terminal Type, and MSP support
- MCCP2 compressed-stream negotiation and bounded streaming decompression - MCCP2 compressed-stream negotiation and bounded streaming decompression
- GMCP negotiation with bounded UTF-8/JSON parsing and a raw diagnostic inspector - GMCP negotiation with bounded UTF-8/JSON parsing and a raw diagnostic inspector
- Accessible live views for character, room, items, skills, group, and communication data - Accessible live views for character, room, items, skills, group, and communication data
- Opt-in Client.Media audio with captions, per-profile volumes, HTTPS-only bounded downloads, and cache limits - Opt-in Client.Media and MSP audio with captions, per-profile volumes, bounded downloads, and cache limits
- Safe start-of-line MSP fallback for legacy servers, including explicitly disclosed HTTP media support
- Streaming ANSI control-sequence filtering - Streaming ANSI control-sequence filtering
- Accessible scrollback and command entry - Accessible scrollback and command entry
- Automatic reading of incoming lines and prompts - Automatic reading of incoming lines and prompts
@@ -45,6 +46,7 @@ The output and command fields provide additional actions through their context m
- `lib/meow/telnet.rb` implements the streaming Telnet state machine. - `lib/meow/telnet.rb` implements the streaming Telnet state machine.
- `lib/meow/gmcp.rb` parses GMCP and maintains package state. - `lib/meow/gmcp.rb` parses GMCP and maintains package state.
- `lib/meow/media.rb` provides protocol-neutral media requests, safe caching, and Elten audio playback. - `lib/meow/media.rb` provides protocol-neutral media requests, safe caching, and Elten audio playback.
- `lib/meow/msp.rb` parses line and Telnet-option MSP commands into shared media requests.
- `lib/meow/session_gmcp.rb` integrates GMCP packages with accessible session views. - `lib/meow/session_gmcp.rb` integrates GMCP packages with accessible session views.
- `lib/meow/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts. - `lib/meow/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts.
- `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech. - `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech.
@@ -58,10 +60,9 @@ Automated regression coverage now exercises the streaming Telnet parser and text
Planned work, roughly in order: Planned work, roughly in order:
1. Add MSP parsing as a second adapter to the shared media request and playback layer. 1. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
2. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. 2. Add multiple simultaneous sessions with independent output, media, and speech queues.
3. Add multiple simultaneous sessions with independent output, media, and speech queues. 3. Add localization catalogs and package/signing automation for releases.
4. Add localization catalogs and package/signing automation for releases.
## Running the tests ## Running the tests
+3 -2
View File
@@ -2,8 +2,8 @@
{ {
"id": "51a92051-84e0-415f-84e3-98f08a320a15", "id": "51a92051-84e0-415f-84e3-98f08a320a15",
"name": "Meow", "name": "Meow",
"version": "0.2.0", "version": "0.3.0",
"build_id": 4, "build_id": 5,
"EltenAPIVersion": "3.0.3", "EltenAPIVersion": "3.0.3",
"author": "Talon", "author": "Talon",
"main_language": "en", "main_language": "en",
@@ -22,6 +22,7 @@ require_relative "lib/meow/gmcp"
require_relative "lib/meow/telnet" require_relative "lib/meow/telnet"
require_relative "lib/meow/text_pipeline" require_relative "lib/meow/text_pipeline"
require_relative "lib/meow/media" require_relative "lib/meow/media"
require_relative "lib/meow/msp"
require_relative "lib/meow/transport" require_relative "lib/meow/transport"
require_relative "lib/meow/session_gmcp" require_relative "lib/meow/session_gmcp"
require_relative "lib/meow/session" require_relative "lib/meow/session"
+48 -20
View File
@@ -9,7 +9,7 @@ module MeowClient
PlayRequest = Struct.new( PlayRequest = Struct.new(
:name, :url, :kind, :tag, :volume, :fade_in, :fade_out, :start_at, :name, :url, :kind, :tag, :volume, :fade_in, :fade_out, :start_at,
:finish_at, :loops, :priority, :continue_existing, :key, :caption, :finish_at, :loops, :priority, :continue_existing, :key, :caption,
keyword_init: true :allow_insecure_http, keyword_init: true
) )
StopRequest = Struct.new( StopRequest = Struct.new(
:name, :kind, :tag, :priority, :key, :fade_away, :fade_out, :name, :kind, :tag, :priority, :key, :fade_away, :fade_out,
@@ -20,21 +20,23 @@ module MeowClient
Track = Struct.new(:request, :handle, :url, :remaining_loops, :started_at, keyword_init: true) Track = Struct.new(:request, :handle, :url, :remaining_loops, :started_at, keyword_init: true)
class URLPolicy class URLPolicy
AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac].freeze AUDIO_EXTENSIONS = %w[.mp3 .ogg .opus .wav .flac .m4a .aac .mid .midi].freeze
def resolve(base_url, name, explicit_url = nil) def resolve(base_url, name, explicit_url = nil, allow_insecure_http: false)
clean_name = validate_name(name) clean_name = validate_name(name)
base = explicit_url.to_s.strip == "" ? base_url : explicit_url base = explicit_url.to_s.strip == "" ? base_url : explicit_url
raise ArgumentError, "No media base URL was supplied" if base.to_s.strip == "" raise ArgumentError, "No media base URL was supplied" if base.to_s.strip == ""
base_uri = validate_uri(URI.parse(base.to_s)) base_uri = validate_uri(URI.parse(base.to_s), :allow_insecure_http => allow_insecure_http)
value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name) value = clean_name == "" ? base_uri : URI.join(ensure_directory(base_uri.to_s), clean_name)
validate_uri(value) validate_uri(value, :allow_insecure_http => allow_insecure_http)
rescue URI::InvalidURIError => error rescue URI::InvalidURIError => error
raise ArgumentError, "Invalid media URL: #{error.message}" raise ArgumentError, "Invalid media URL: #{error.message}"
end end
def validate_uri(uri) def validate_uri(uri, allow_insecure_http: false)
raise ArgumentError, "Only HTTPS media URLs are allowed" unless uri.scheme.to_s.downcase == "https" scheme = uri.scheme.to_s.downcase
allowed = scheme == "https" || (allow_insecure_http && scheme == "http")
raise ArgumentError, allow_insecure_http ? "Only HTTP or HTTPS media URLs are allowed" : "Only HTTPS media URLs are allowed" unless allowed
raise ArgumentError, "Media URL must include a host" if uri.host.to_s == "" 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 credentials are not allowed" if uri.userinfo != nil
raise ArgumentError, "Media URL contains control characters" if uri.to_s.match?(/[\x00-\x1f\x7f]/) raise ArgumentError, "Media URL contains control characters" if uri.to_s.match?(/[\x00-\x1f\x7f]/)
@@ -90,7 +92,7 @@ module MeowClient
@worker.report_on_exception = false @worker.report_on_exception = false
end end
def enqueue(id, uri) def enqueue(id, uri, allow_insecure_http: false)
return false if @closed return false if @closed
cached = cache_path(uri) cached = cache_path(uri)
if File.file?(cached) if File.file?(cached)
@@ -98,7 +100,7 @@ module MeowClient
@results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s) @results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s)
return true return true
end end
@queue.push([id, uri], true) @queue.push([id, uri, allow_insecure_http], true)
true true
rescue ThreadError rescue ThreadError
false false
@@ -119,9 +121,9 @@ module MeowClient
loop do loop do
job = @queue.pop job = @queue.pop
break if job == nil || @closed break if job == nil || @closed
id, uri = job id, uri, allow_insecure_http = job
begin begin
path, final_uri = download(uri) path, final_uri = download(uri, 0, :allow_insecure_http => allow_insecure_http)
@results << DownloadResult.new(:id => id, :path => path, :url => final_uri.to_s) @results << DownloadResult.new(:id => id, :path => path, :url => final_uri.to_s)
rescue Exception => error rescue Exception => error
@results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message) @results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message)
@@ -129,14 +131,14 @@ module MeowClient
end end
end end
def download(uri, redirects = 0) def download(uri, redirects = 0, allow_insecure_http: false)
raise "Too many media redirects" if redirects > MAX_REDIRECTS raise "Too many media redirects" if redirects > MAX_REDIRECTS
current = @policy.validate_uri(uri) current = @policy.validate_uri(uri, :allow_insecure_http => allow_insecure_http)
response = nil response = nil
Net::HTTP.start( Net::HTTP.start(
current.host, current.host,
current.port, current.port,
:use_ssl => true, :use_ssl => current.scheme.to_s.downcase == "https",
:open_timeout => OPEN_TIMEOUT, :open_timeout => OPEN_TIMEOUT,
:read_timeout => READ_TIMEOUT :read_timeout => READ_TIMEOUT
) do |http| ) do |http|
@@ -146,8 +148,11 @@ module MeowClient
if incoming.is_a?(Net::HTTPRedirection) if incoming.is_a?(Net::HTTPRedirection)
location = incoming["location"] location = incoming["location"]
raise "Media redirect did not include a location" if location.to_s == "" raise "Media redirect did not include a location" if location.to_s == ""
redirected = @policy.validate_uri(URI.join(current.to_s, location)) redirected = @policy.validate_uri(
return download(redirected, redirects + 1) URI.join(current.to_s, location),
:allow_insecure_http => allow_insecure_http
)
return download(redirected, redirects + 1, :allow_insecure_http => allow_insecure_http)
end end
raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess) raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess)
length = Integer(incoming["content-length"], :exception => false) length = Integer(incoming["content-length"], :exception => false)
@@ -274,11 +279,22 @@ module MeowClient
end end
def default_url=(value) def default_url=(value)
@default_url = @policy.validate_uri(URI.parse(value.to_s)).to_s @default_url = normalize_base_url(value)
rescue ArgumentError, URI::InvalidURIError => error rescue ArgumentError, URI::InvalidURIError => error
add_error(error.message) add_error(error.message)
end end
def normalize_base_url(value, allow_insecure_http: false)
@policy.validate_uri(
URI.parse(value.to_s),
:allow_insecure_http => allow_insecure_http
).to_s
end
def report_error(value)
add_error(value)
end
def preload(request) def preload(request)
queue(request, false) queue(request, false)
end end
@@ -353,15 +369,27 @@ module MeowClient
def queue(request, play_after) def queue(request, play_after)
loaded = @loaded_urls[request.name.to_s] if request.url.to_s == "" loaded = @loaded_urls[request.name.to_s] if request.url.to_s == ""
if loaded != nil if loaded != nil
uri = @policy.validate_uri(URI.parse(loaded)) uri = @policy.validate_uri(
URI.parse(loaded),
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
else else
base = request.url.to_s == "" ? @default_url : request.url base = request.url.to_s == "" ? @default_url : request.url
uri = @policy.resolve(base, request.name, request.url) uri = @policy.resolve(
base,
request.name,
request.url,
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
end end
raise "Unsupported audio file type" unless @policy.supported_audio?(uri) raise "Unsupported audio file type" unless @policy.supported_audio?(uri)
@sequence += 1 @sequence += 1
@pending[@sequence] = [request, play_after] @pending[@sequence] = [request, play_after]
unless @downloader.enqueue(@sequence, uri) unless @downloader.enqueue(
@sequence,
uri,
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
@pending.delete(@sequence) @pending.delete(@sequence)
raise "Media download queue is full" raise "Media download queue is full"
end end
+123
View File
@@ -0,0 +1,123 @@
module MeowClient
module MSP
MAX_TRIGGER_BYTES = 8_192
Action = Struct.new(:type, :request, :url, :error, keyword_init: true)
class Adapter
attr_reader :default_url
def initialize
@default_url = nil
end
def default_url=(value)
@default_url = value.to_s
end
def parse(text, out_of_band: false)
value = text.to_s
raise ArgumentError, "MSP trigger exceeds #{MAX_TRIGGER_BYTES} bytes" if value.bytesize > MAX_TRIGGER_BYTES
value = "!!#{value}" if out_of_band && value.match?(/\A(?:SOUND|MUSIC)\(/i)
match = value.match(/\A!!(SOUND|MUSIC)\((.*)\)\z/i)
return nil if match == nil
kind = match[1].downcase
tokens = tokenize(match[2])
raise ArgumentError, "MSP trigger does not include a file name" if tokens.empty?
name = tokens.shift
parameters = parse_parameters(tokens)
url = parameters["U"]
if name.casecmp?("off")
return Action.new(:type => :default_url, :url => url) if url != nil
return Action.new(:type => :stop, :request => Media::StopRequest.new(:kind => kind))
end
raise ArgumentError, "MSP wildcard media names require a local sound pack" if name.include?("*") || name.include?("?")
raise ArgumentError, "MSP media name must use forward slashes" if name.include?("\\")
name += kind == "sound" ? ".wav" : ".mid" if File.extname(name) == ""
volume = integer_parameter(parameters, "V", 100, 0..100)
loops = integer_parameter(parameters, "L", 1, nil)
raise ArgumentError, "MSP L must be -1 or greater than zero" unless loops == -1 || loops > 0
priority = integer_parameter(parameters, "P", 50, 0..100)
continuing = integer_parameter(parameters, "C", 1, 0..1) == 1
tag = parameters["T"]
if tag.to_s == ""
directory = File.dirname(name)
tag = directory unless directory == "."
end
request = Media::PlayRequest.new(
:name => name,
:url => url || @default_url,
:kind => kind,
:tag => tag,
:volume => volume,
:loops => loops,
:priority => priority,
:continue_existing => kind == "music" ? continuing : false,
:key => kind == "music" ? "msp:music" : nil,
:allow_insecure_http => true
)
Action.new(:type => :play, :request => request)
rescue ArgumentError => error
Action.new(:type => :error, :error => error.message)
end
def parse_bytes(bytes, encoding)
value = bytes.to_s.b.force_encoding(encoding.to_s).encode(
Encoding::UTF_8,
:invalid => :replace,
:undef => :replace,
:replace => "\uFFFD"
)
parse(value, :out_of_band => true)
rescue Encoding::ConverterNotFoundError => error
Action.new(:type => :error, :error => "Could not decode MSP trigger: #{error.message}")
end
private
def tokenize(body)
tokens = []
token = +""
quoted = false
body.to_s.each_char do |character|
if character == '"'
quoted = !quoted
token << character
elsif character.match?(/\s/) && !quoted
unless token.empty?
tokens << token
token = +""
end
else
token << character
end
end
raise ArgumentError, "MSP trigger contains an unterminated quote" if quoted
tokens << token unless token.empty?
tokens
end
def parse_parameters(tokens)
tokens.each_with_object({}) do |token, parameters|
name, value = token.split("=", 2)
next if value == nil || !name.match?(/\A[A-Za-z]\z/)
value = value[1...-1] if value.start_with?('"') && value.end_with?('"')
parameters[name.upcase] = value
end
end
def integer_parameter(parameters, name, default, range)
return default unless parameters.key?(name)
value = Integer(parameters[name], :exception => false)
raise ArgumentError, "MSP #{name} must be an integer" if value == nil
raise ArgumentError, "MSP #{name} is outside its supported range" if range != nil && !range.cover?(value)
value
end
end
end
end
+39 -4
View File
@@ -14,7 +14,7 @@ module MeowClient
@program = program @program = program
@profile = profile @profile = profile
@transport = transport @transport = transport
@pipeline = TextPipeline.new(profile.encoding) @pipeline = TextPipeline.new(profile.encoding, :enable_msp => profile.remote_media_enabled)
@lines = [] @lines = []
@line_bytes = 0 @line_bytes = 0
@history = [] @history = []
@@ -26,7 +26,14 @@ module MeowClient
@gmcp_enabled = false @gmcp_enabled = false
@gmcp_state = GMCP::State.new @gmcp_state = GMCP::State.new
@gmcp_log = GMCP::MessageLog.new @gmcp_log = GMCP::MessageLog.new
@media = nil @msp = profile.remote_media_enabled ? MSP::Adapter.new : nil
@media = if profile.remote_media_enabled
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
@closed = false @closed = false
build_form build_form
end end
@@ -55,9 +62,10 @@ module MeowClient
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading")) @speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
@stop_speech = Button.new(_("Stop current speech")) @stop_speech = Button.new(_("Stop current speech"))
@gmcp_button = Button.new(_("GMCP information")) @gmcp_button = Button.new(_("GMCP information"))
@media_button = Button.new(_("Media"))
@disconnect = Button.new(_("Disconnect")) @disconnect = Button.new(_("Disconnect"))
@form = Form.new( @form = Form.new(
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @disconnect], [@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @disconnect],
:quiet => true :quiet => true
) )
@form.accept_button = @send @form.accept_button = @send
@@ -68,6 +76,7 @@ module MeowClient
@speech_button.on(:press) { toggle_speech } @speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop } @stop_speech.on(:press) { speech_stop }
@gmcp_button.on(:press) { open_gmcp_hub } @gmcp_button.on(:press) { open_gmcp_hub }
@media_button.on(:press) { open_media_view }
@disconnect.on(:press) { request_disconnect } @disconnect.on(:press) { request_disconnect }
@output.add_tip(_("Use the context menu to jump to the latest output.")) @output.add_tip(_("Use the context menu to jump to the latest output."))
@@ -113,7 +122,7 @@ module MeowClient
result = @pipeline.feed(event.payload) result = @pipeline.feed(event.payload)
result.replies.each { |reply| @transport.send_bytes(reply) } result.replies.each { |reply| @transport.send_bytes(reply) }
result.events.each { |protocol_event| handle_protocol_event(protocol_event) } result.events.each { |protocol_event| handle_protocol_event(protocol_event) }
result.records.each { |record| receive_record(record) } result.records.each { |record| receive_protocol_record(record) }
when :disconnected, :error when :disconnected, :error
@pipeline.finish.each { |record| receive_record(record) } @pipeline.finish.each { |record| receive_record(record) }
add_system_line(event.payload.to_s) add_system_line(event.payload.to_s)
@@ -123,6 +132,32 @@ module MeowClient
rescue ThreadError rescue ThreadError
end end
def receive_protocol_record(record)
if @msp != nil
action = @msp.parse(record)
return handle_msp_action(action) if action != nil
end
receive_record(record)
end
def handle_msp_action(action)
case action.type
when :play
@media.play(action.request)
when :stop
@media.stop(action.request)
when :default_url
normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true)
@msp.default_url = normalized
when :error
@media.report_error(action.error)
end
true
rescue ArgumentError, URI::InvalidURIError => error
@media.report_error(error.message)
true
end
def receive_record(record) def receive_record(record)
text = record.to_s text = record.to_s
append_line(text) append_line(text)
+11 -9
View File
@@ -11,6 +11,17 @@ module MeowClient
receive_gmcp(payload) receive_gmcp(payload)
when :gmcp_error when :gmcp_error
@gmcp_log.add_error(payload) @gmcp_log.add_error(payload)
when :msp_enabled
add_system_line(payload ? _("MSP negotiated.") : _("MSP disabled by the server."))
when :msp
action = @msp.parse_bytes(payload, @profile.encoding) if @msp != nil
if action == nil
@media.report_error("Invalid MSP subnegotiation") if @media != nil
else
handle_msp_action(action)
end
when :msp_error
@media.report_error(payload) if @media != nil
end end
end end
@@ -22,13 +33,6 @@ module MeowClient
packages = ["Char 1", "Char.Items 1", "Char.Skills 1", "Room 1", "Group 1", "Comm 1"] packages = ["Char 1", "Char.Items 1", "Char.Skills 1", "Room 1", "Group 1", "Comm 1"]
packages << "Client.Media 1" if @profile.remote_media_enabled packages << "Client.Media 1" if @profile.remote_media_enabled
send_gmcp("Core.Supports.Set", packages) 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.")) add_system_line(_("GMCP negotiated."))
end end
@@ -37,8 +41,6 @@ module MeowClient
@gmcp_enabled = false @gmcp_enabled = false
@gmcp_button.label = _("GMCP information") if @gmcp_button != nil @gmcp_button.label = _("GMCP information") if @gmcp_button != nil
@gmcp_state.reset @gmcp_state.reset
@media.close if @media != nil
@media = nil
add_system_line(_("GMCP disabled by the server.")) add_system_line(_("GMCP disabled by the server."))
end end
+10 -2
View File
@@ -15,6 +15,7 @@ module MeowClient
SUPPRESS_GO_AHEAD = 3 SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24 TERMINAL_TYPE = 24
COMPRESS2 = 86 COMPRESS2 = 86
MSP = 90
GMCP = 201 GMCP = 201
TTYPE_IS = 0 TTYPE_IS = 0
TTYPE_SEND = 1 TTYPE_SEND = 1
@@ -40,7 +41,7 @@ module MeowClient
class Parser class Parser
attr_reader :remote_echo attr_reader :remote_echo
def initialize def initialize(enable_msp: false)
@state = :data @state = :data
@negotiation_command = nil @negotiation_command = nil
@subnegotiation_option = nil @subnegotiation_option = nil
@@ -51,6 +52,7 @@ module MeowClient
@local_options = {} @local_options = {}
@inflater = nil @inflater = nil
@compression_finished = false @compression_finished = false
@enable_msp = enable_msp == true
end end
def feed(bytes) def feed(bytes)
@@ -167,7 +169,7 @@ module MeowClient
def negotiate(command, option) def negotiate(command, option)
case command case command
when WILL when WILL
enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) enabled = [ECHO, SUPPRESS_GO_AHEAD, COMPRESS2, GMCP].include?(option) || (option == MSP && @enable_msp)
changed = @remote_options[option] != enabled changed = @remote_options[option] != enabled
@remote_options[option] = enabled @remote_options[option] = enabled
return [nil, nil] unless changed return [nil, nil] unless changed
@@ -176,6 +178,7 @@ module MeowClient
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]] return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end end
return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled return [packet(DO, option), [:gmcp_enabled, true]] if option == GMCP && enabled
return [packet(DO, option), [:msp_enabled, true]] if option == MSP && enabled
[packet(enabled ? DO : DONT, option), nil] [packet(enabled ? DO : DONT, option), nil]
when WONT when WONT
changed = @remote_options[option] != false changed = @remote_options[option] != false
@@ -185,6 +188,7 @@ module MeowClient
return [packet(DONT, option), [:remote_echo, false]] if changed return [packet(DONT, option), [:remote_echo, false]] if changed
end end
return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed return [changed ? packet(DONT, option) : nil, [:gmcp_enabled, false]] if option == GMCP && changed
return [changed ? packet(DONT, option) : nil, [:msp_enabled, false]] if option == MSP && changed && @enable_msp
[changed ? packet(DONT, option) : nil, nil] [changed ? packet(DONT, option) : nil, nil]
when DO when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option) enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
@@ -203,6 +207,7 @@ module MeowClient
def finish_subnegotiation def finish_subnegotiation
if @subnegotiation_overflow if @subnegotiation_overflow
return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP return [nil, [:gmcp_error, "Telnet subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == GMCP
return [nil, [:msp_error, "MSP subnegotiation exceeded #{MAX_SUBNEGOTIATION_BYTES} bytes"]] if @subnegotiation_option == MSP
return [nil, [:subnegotiation, @subnegotiation_option]] return [nil, [:subnegotiation, @subnegotiation_option]]
end end
if @subnegotiation_option == GMCP && @remote_options[GMCP] == true if @subnegotiation_option == GMCP && @remote_options[GMCP] == true
@@ -212,6 +217,9 @@ module MeowClient
return [nil, [:gmcp_error, error.message]] return [nil, [:gmcp_error, error.message]]
end end
end end
if @subnegotiation_option == MSP && @remote_options[MSP] == true
return [nil, [:msp, @subnegotiation.dup]]
end
if @subnegotiation_option == COMPRESS2 && if @subnegotiation_option == COMPRESS2 &&
@remote_options[COMPRESS2] == true && @remote_options[COMPRESS2] == true &&
@subnegotiation.empty? @subnegotiation.empty?
+2 -2
View File
@@ -41,8 +41,8 @@ module MeowClient
class TextPipeline class TextPipeline
Result = Struct.new(:records, :replies, :events, keyword_init: true) Result = Struct.new(:records, :replies, :events, keyword_init: true)
def initialize(encoding) def initialize(encoding, enable_msp: false)
@telnet = Telnet::Parser.new @telnet = Telnet::Parser.new(:enable_msp => enable_msp)
@ansi = AnsiFilter.new @ansi = AnsiFilter.new
@encoding = encoding.to_s @encoding = encoding.to_s
@decode_pending = +"".b @decode_pending = +"".b
+1 -1
View File
@@ -81,7 +81,7 @@ module MeowClient
port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s) port = EditBox.new(_("Port"), :type => EditBox::Flags::Numbers, :text => profile.port.to_s)
tls = CheckBox.new(_("Use TLS"), :checked => profile.tls) tls = CheckBox.new(_("Use TLS"), :checked => profile.tls)
auto_read = CheckBox.new(_("Read incoming text automatically"), :checked => profile.auto_read) 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) remote_media = CheckBox.new(_("Allow remote media; legacy MSP servers may use insecure HTTP"), :checked => profile.remote_media_enabled)
speak_captions = CheckBox.new(_("Read media captions automatically"), :checked => profile.speak_media_captions) 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) 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) music_volume = EditBox.new(_("Music volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => profile.music_volume.to_i.to_s)
+1 -1
View File
@@ -1,3 +1,3 @@
module MeowClient module MeowClient
VERSION = "0.2.0" VERSION = "0.3.0"
end end
+25 -4
View File
@@ -14,8 +14,8 @@ module MeowMediaTest
@requests = [] @requests = []
end end
def enqueue(id, uri) def enqueue(id, uri, allow_insecure_http: false)
@requests << [id, uri] @requests << [id, uri, allow_insecure_http]
true true
end end
@@ -76,13 +76,34 @@ module MeowMediaTest
MeowTest.assert_equal("https://media.example/game/weather/rain.ogg", uri.to_s) MeowTest.assert_equal("https://media.example/game/weather/rain.ogg", uri.to_s)
end end
MeowTest.test("Media URL policy rejects insecure private and traversing URLs") do MeowTest.test("Media URL policy isolates legacy HTTP from secure requests") do
policy = Media::URLPolicy.new policy = Media::URLPolicy.new
MeowTest.assert_raises(ArgumentError) { policy.resolve("http://example.com/", "sound.mp3") } MeowTest.assert_raises(ArgumentError) { policy.resolve("http://example.com/", "sound.mp3") }
MeowTest.assert_raises(ArgumentError) { policy.resolve("https://127.0.0.1/", "sound.mp3") } legacy = policy.resolve("http://example.com/", "sound.mp3", nil, :allow_insecure_http => true)
MeowTest.assert_equal("http://example.com/sound.mp3", legacy.to_s)
MeowTest.assert_raises(ArgumentError) do
policy.resolve("http://127.0.0.1/", "sound.mp3", nil, :allow_insecure_http => true)
end
MeowTest.assert_raises(ArgumentError) { policy.resolve("https://example.com/", "../secret.mp3") } MeowTest.assert_raises(ArgumentError) { policy.resolve("https://example.com/", "../secret.mp3") }
end end
MeowTest.test("Media manager carries HTTP permission on each request") do
downloader = FakeDownloader.new
manager = Media::Manager.new("unused", :downloader => downloader)
secure = Media::PlayRequest.new(:name => "secure.mp3", :url => "https://media.example/")
legacy = Media::PlayRequest.new(
:name => "legacy.mid",
:url => "http://media.example/",
:allow_insecure_http => true
)
manager.play(secure)
manager.play(legacy)
MeowTest.assert_equal(false, downloader.requests[0][2])
MeowTest.assert_equal(true, downloader.requests[1][2])
ensure
manager.close if manager != nil
end
MeowTest.test("Client.Media adapter normalizes play and stop commands") do MeowTest.test("Client.Media adapter normalizes play and stop commands") do
play = Media::ClientMedia.play_request({"name" => "rain.ogg", "type" => "music", "loops" => -1, "caption" => "Rain"}) play = Media::ClientMedia.play_request({"name" => "rain.ogg", "type" => "music", "loops" => -1, "caption" => "Rain"})
stop = Media::ClientMedia.stop_request({"type" => "music", "fadeaway" => true}) stop = Media::ClientMedia.stop_request({"type" => "music", "fadeaway" => true})
+68
View File
@@ -0,0 +1,68 @@
# encoding: UTF-8
require_relative "test_helper"
require_relative "../lib/meow/media"
require_relative "../lib/meow/msp"
module MeowMSPTest
MSP = MeowClient::MSP
MeowTest.test("MSP maps sound defaults into a shared media request") do
action = MSP::Adapter.new.parse("!!SOUND(weather/thunder)")
request = action.request
MeowTest.assert_equal(:play, action.type)
MeowTest.assert_equal("weather/thunder.wav", request.name)
MeowTest.assert_equal("sound", request.kind)
MeowTest.assert_equal("weather", request.tag)
MeowTest.assert_equal(100, request.volume)
MeowTest.assert_equal(1, request.loops)
MeowTest.assert_equal(50, request.priority)
MeowTest.refute(request.continue_existing)
MeowTest.assert(request.allow_insecure_http)
end
MeowTest.test("MSP accepts case-insensitive parameters and quoted legacy URLs") do
action = MSP::Adapter.new.parse(
'!!MUSIC(theme v=75 l=-1 c=0 t=zone u="http://media.example/game sounds/")'
)
request = action.request
MeowTest.assert_equal("theme.mid", request.name)
MeowTest.assert_equal("music", request.kind)
MeowTest.assert_equal("zone", request.tag)
MeowTest.assert_equal(75, request.volume)
MeowTest.assert_equal(-1, request.loops)
MeowTest.refute(request.continue_existing)
MeowTest.assert_equal("msp:music", request.key)
MeowTest.assert_equal("http://media.example/game sounds/", request.url)
end
MeowTest.test("MSP default URL and stop actions remain protocol-local") do
adapter = MSP::Adapter.new
default_action = adapter.parse("!!SOUND(Off U=http://media.example/sounds/)")
MeowTest.assert_equal(:default_url, default_action.type)
adapter.default_url = default_action.url
play = adapter.parse("!!SOUND(bell.wav)")
stop = adapter.parse("!!MUSIC(Off)")
MeowTest.assert_equal("http://media.example/sounds/", play.request.url)
MeowTest.assert_equal(:stop, stop.type)
MeowTest.assert_equal("music", stop.request.kind)
end
MeowTest.test("MSP recognizes out-of-band bodies but not mid-line text") do
adapter = MSP::Adapter.new
action = adapter.parse_bytes("SOUND(chime.wav U=http://media.example/)".b, "UTF-8")
MeowTest.assert_equal(:play, action.type)
MeowTest.assert_equal(nil, adapter.parse("chat says !!SOUND(chime.wav)"))
end
MeowTest.test("MSP reports invalid values and unsupported wildcards") do
adapter = MSP::Adapter.new
bad_volume = adapter.parse("!!SOUND(chime.wav V=101)")
wildcard = adapter.parse("!!SOUND(chime*.wav)")
unterminated = adapter.parse('!!SOUND(chime.wav U="http://media.example/)')
MeowTest.assert_equal(:error, bad_volume.type)
MeowTest.assert_equal(:error, wildcard.type)
MeowTest.assert_equal(:error, unterminated.type)
end
end
+1
View File
@@ -2,6 +2,7 @@ require_relative "test_helper"
require_relative "telnet_test" require_relative "telnet_test"
require_relative "gmcp_test" require_relative "gmcp_test"
require_relative "media_test" require_relative "media_test"
require_relative "msp_test"
require_relative "text_pipeline_test" require_relative "text_pipeline_test"
begin begin
+18
View File
@@ -66,6 +66,24 @@ module MeowTelnetTest
MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WONT, 43)], local_unknown.replies) MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WONT, 43)], local_unknown.replies)
end end
MeowTest.test("Telnet negotiates MSP only when media is enabled") do
disabled = Telnet::Parser.new.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP))
enabled = Telnet::Parser.new(:enable_msp => true).feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP))
MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DONT, Telnet::MSP)], disabled.replies)
MeowTest.assert_empty(disabled.events)
MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::MSP)], enabled.replies)
MeowTest.assert_equal([[:msp_enabled, true]], enabled.events)
end
MeowTest.test("Telnet emits negotiated MSP subnegotiation payloads") do
parser = Telnet::Parser.new(:enable_msp => true)
parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::MSP))
body = "SOUND(chime.wav U=http://media.example/)".b
packet = bytes(Telnet::IAC, Telnet::SB, Telnet::MSP) + body + bytes(Telnet::IAC, Telnet::SE)
result = collect(parser, packet.bytes.map { |byte| bytes(byte) })
MeowTest.assert_equal([[:msp, body]], result[:events])
end
MeowTest.test("Telnet sends terminal type only after accepting the option") do MeowTest.test("Telnet sends terminal type only after accepting the option") do
request = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_SEND, Telnet::IAC, Telnet::SE) request = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_SEND, Telnet::IAC, Telnet::SE)
parser = Telnet::Parser.new parser = Telnet::Parser.new