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
- Plain Telnet and certificate-verified TLS connections
- 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
- 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
- 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
- Accessible scrollback and command entry
- 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/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/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/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts.
- `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:
1. Add MSP parsing as a second adapter to the shared media request and playback layer.
2. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
3. Add multiple simultaneous sessions with independent output, media, and speech queues.
4. Add localization catalogs and package/signing automation for releases.
1. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
2. Add multiple simultaneous sessions with independent output, media, and speech queues.
3. Add localization catalogs and package/signing automation for releases.
## Running the tests
+3 -2
View File
@@ -2,8 +2,8 @@
{
"id": "51a92051-84e0-415f-84e3-98f08a320a15",
"name": "Meow",
"version": "0.2.0",
"build_id": 4,
"version": "0.3.0",
"build_id": 5,
"EltenAPIVersion": "3.0.3",
"author": "Talon",
"main_language": "en",
@@ -22,6 +22,7 @@ require_relative "lib/meow/gmcp"
require_relative "lib/meow/telnet"
require_relative "lib/meow/text_pipeline"
require_relative "lib/meow/media"
require_relative "lib/meow/msp"
require_relative "lib/meow/transport"
require_relative "lib/meow/session_gmcp"
require_relative "lib/meow/session"
+48 -20
View File
@@ -9,7 +9,7 @@ module MeowClient
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
:allow_insecure_http, keyword_init: true
)
StopRequest = Struct.new(
: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)
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)
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))
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)
validate_uri(value)
validate_uri(value, :allow_insecure_http => allow_insecure_http)
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"
def validate_uri(uri, allow_insecure_http: false)
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 credentials are not allowed" if uri.userinfo != nil
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
end
def enqueue(id, uri)
def enqueue(id, uri, allow_insecure_http: false)
return false if @closed
cached = cache_path(uri)
if File.file?(cached)
@@ -98,7 +100,7 @@ module MeowClient
@results << DownloadResult.new(:id => id, :path => cached, :url => uri.to_s)
return true
end
@queue.push([id, uri], true)
@queue.push([id, uri, allow_insecure_http], true)
true
rescue ThreadError
false
@@ -119,9 +121,9 @@ module MeowClient
loop do
job = @queue.pop
break if job == nil || @closed
id, uri = job
id, uri, allow_insecure_http = job
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)
rescue Exception => error
@results << DownloadResult.new(:id => id, :url => uri.to_s, :error => error.message)
@@ -129,14 +131,14 @@ module MeowClient
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
current = @policy.validate_uri(uri)
current = @policy.validate_uri(uri, :allow_insecure_http => allow_insecure_http)
response = nil
Net::HTTP.start(
current.host,
current.port,
:use_ssl => true,
:use_ssl => current.scheme.to_s.downcase == "https",
:open_timeout => OPEN_TIMEOUT,
:read_timeout => READ_TIMEOUT
) do |http|
@@ -146,8 +148,11 @@ module MeowClient
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)
redirected = @policy.validate_uri(
URI.join(current.to_s, location),
:allow_insecure_http => allow_insecure_http
)
return download(redirected, redirects + 1, :allow_insecure_http => allow_insecure_http)
end
raise "Media download failed with HTTP #{incoming.code}" unless incoming.is_a?(Net::HTTPSuccess)
length = Integer(incoming["content-length"], :exception => false)
@@ -274,11 +279,22 @@ module MeowClient
end
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
add_error(error.message)
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)
queue(request, false)
end
@@ -353,15 +369,27 @@ module MeowClient
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))
uri = @policy.validate_uri(
URI.parse(loaded),
:allow_insecure_http => request.respond_to?(:allow_insecure_http) && request.allow_insecure_http == true
)
else
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
raise "Unsupported audio file type" unless @policy.supported_audio?(uri)
@sequence += 1
@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)
raise "Media download queue is full"
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
@profile = profile
@transport = transport
@pipeline = TextPipeline.new(profile.encoding)
@pipeline = TextPipeline.new(profile.encoding, :enable_msp => profile.remote_media_enabled)
@lines = []
@line_bytes = 0
@history = []
@@ -26,7 +26,14 @@ module MeowClient
@gmcp_enabled = false
@gmcp_state = GMCP::State.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
build_form
end
@@ -55,9 +62,10 @@ module MeowClient
@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"))
@media_button = Button.new(_("Media"))
@disconnect = Button.new(_("Disconnect"))
@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
)
@form.accept_button = @send
@@ -68,6 +76,7 @@ module MeowClient
@speech_button.on(:press) { toggle_speech }
@stop_speech.on(:press) { speech_stop }
@gmcp_button.on(:press) { open_gmcp_hub }
@media_button.on(:press) { open_media_view }
@disconnect.on(:press) { request_disconnect }
@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.replies.each { |reply| @transport.send_bytes(reply) }
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
@pipeline.finish.each { |record| receive_record(record) }
add_system_line(event.payload.to_s)
@@ -123,6 +132,32 @@ module MeowClient
rescue ThreadError
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)
text = record.to_s
append_line(text)
+11 -9
View File
@@ -11,6 +11,17 @@ module MeowClient
receive_gmcp(payload)
when :gmcp_error
@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
@@ -22,13 +33,6 @@ module MeowClient
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
@@ -37,8 +41,6 @@ module MeowClient
@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
+10 -2
View File
@@ -15,6 +15,7 @@ module MeowClient
SUPPRESS_GO_AHEAD = 3
TERMINAL_TYPE = 24
COMPRESS2 = 86
MSP = 90
GMCP = 201
TTYPE_IS = 0
TTYPE_SEND = 1
@@ -40,7 +41,7 @@ module MeowClient
class Parser
attr_reader :remote_echo
def initialize
def initialize(enable_msp: false)
@state = :data
@negotiation_command = nil
@subnegotiation_option = nil
@@ -51,6 +52,7 @@ module MeowClient
@local_options = {}
@inflater = nil
@compression_finished = false
@enable_msp = enable_msp == true
end
def feed(bytes)
@@ -167,7 +169,7 @@ module MeowClient
def negotiate(command, option)
case command
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
@remote_options[option] = enabled
return [nil, nil] unless changed
@@ -176,6 +178,7 @@ module MeowClient
return [packet(enabled ? DO : DONT, option), [:remote_echo, enabled]]
end
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]
when WONT
changed = @remote_options[option] != false
@@ -185,6 +188,7 @@ module MeowClient
return [packet(DONT, option), [:remote_echo, false]] if changed
end
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]
when DO
enabled = [SUPPRESS_GO_AHEAD, TERMINAL_TYPE].include?(option)
@@ -203,6 +207,7 @@ module MeowClient
def finish_subnegotiation
if @subnegotiation_overflow
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]]
end
if @subnegotiation_option == GMCP && @remote_options[GMCP] == true
@@ -212,6 +217,9 @@ module MeowClient
return [nil, [:gmcp_error, error.message]]
end
end
if @subnegotiation_option == MSP && @remote_options[MSP] == true
return [nil, [:msp, @subnegotiation.dup]]
end
if @subnegotiation_option == COMPRESS2 &&
@remote_options[COMPRESS2] == true &&
@subnegotiation.empty?
+2 -2
View File
@@ -41,8 +41,8 @@ module MeowClient
class TextPipeline
Result = Struct.new(:records, :replies, :events, keyword_init: true)
def initialize(encoding)
@telnet = Telnet::Parser.new
def initialize(encoding, enable_msp: false)
@telnet = Telnet::Parser.new(:enable_msp => enable_msp)
@ansi = AnsiFilter.new
@encoding = encoding.to_s
@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)
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)
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)
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)
+1 -1
View File
@@ -1,3 +1,3 @@
module MeowClient
VERSION = "0.2.0"
VERSION = "0.3.0"
end
+25 -4
View File
@@ -14,8 +14,8 @@ module MeowMediaTest
@requests = []
end
def enqueue(id, uri)
@requests << [id, uri]
def enqueue(id, uri, allow_insecure_http: false)
@requests << [id, uri, allow_insecure_http]
true
end
@@ -76,13 +76,34 @@ module MeowMediaTest
MeowTest.assert_equal("https://media.example/game/weather/rain.ogg", uri.to_s)
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
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") }
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
play = Media::ClientMedia.play_request({"name" => "rain.ogg", "type" => "music", "loops" => -1, "caption" => "Rain"})
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 "gmcp_test"
require_relative "media_test"
require_relative "msp_test"
require_relative "text_pipeline_test"
begin
+18
View File
@@ -66,6 +66,24 @@ module MeowTelnetTest
MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::WONT, 43)], local_unknown.replies)
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
request = bytes(Telnet::IAC, Telnet::SB, Telnet::TERMINAL_TYPE, Telnet::TTYPE_SEND, Telnet::IAC, Telnet::SE)
parser = Telnet::Parser.new