Add MSP media protocol support
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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?
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
module MeowClient
|
module MeowClient
|
||||||
VERSION = "0.2.0"
|
VERSION = "0.3.0"
|
||||||
end
|
end
|
||||||
|
|||||||
+25
-4
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user