450 lines
15 KiB
Ruby
450 lines
15 KiB
Ruby
require "uri"
|
|
require_relative "session_gmcp"
|
|
require_relative "automation"
|
|
require_relative "transcript"
|
|
|
|
module MeowClient
|
|
class Session
|
|
include SessionGMCP
|
|
MAX_LINES = 5_000
|
|
MAX_BYTES = 1_048_576
|
|
MAX_HISTORY = 100
|
|
MAX_SPEECH = 200
|
|
SPEECH_CHUNK = 600
|
|
PASSWORD_PATTERN = /\b(?:password|passphrase|secret|token|pin)\b.*[:?>]\s*\z/i
|
|
|
|
def initialize(program, profile, transport)
|
|
@program = program
|
|
@profile = profile
|
|
@transport = transport
|
|
@pipeline = TextPipeline.new(profile.encoding, :enable_msp => profile.remote_media_enabled)
|
|
@lines = []
|
|
@line_bytes = 0
|
|
@history = []
|
|
@history_index = nil
|
|
@speech_queue = []
|
|
@speech_paused = profile.auto_read == false
|
|
@speech_overflow = 0
|
|
@sensitive_next = false
|
|
@gmcp_enabled = false
|
|
@gmcp_state = GMCP::State.new
|
|
@gmcp_log = GMCP::MessageLog.new
|
|
@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
|
|
@automation_media = Media::Manager.new(@program.cache_path("automation-media"))
|
|
@automation_sounds = {}
|
|
@automation_configuration = Automation::Repository.new(@program).load(profile.id)
|
|
@command_processor = Automation::CommandProcessor.new(@automation_configuration["aliases"])
|
|
@automation = Automation::OutputProcessor.new(
|
|
@automation_configuration["triggers"],
|
|
{
|
|
:send => proc { |text| send_text(text, :expand_aliases => false, :source => _("Automation")) },
|
|
:speak => proc { |text| enqueue_speech(text) },
|
|
:play_media => proc { |source, action| play_automation_media(source, action) },
|
|
:stop_media => proc { |key| stop_automation_media(key) },
|
|
:suspended => proc { add_system_line(_("Automation was suspended because its safety limit was exceeded.")) }
|
|
}
|
|
)
|
|
@timer_scheduler = Automation::TimerScheduler.new(@automation_configuration["timers"])
|
|
@transcript = TranscriptStore.new(@program.data_path("transcripts"))
|
|
@closed = false
|
|
start_transcript(false) if @automation_configuration["transcript_default"]
|
|
build_form
|
|
end
|
|
|
|
def run
|
|
@form.wait
|
|
ensure
|
|
@pipeline.close
|
|
@transcript.close
|
|
@media.close if @media != nil
|
|
@automation_media.close
|
|
close_automation_sounds
|
|
@transport.close
|
|
@program.release(@transport, :close => false)
|
|
end
|
|
|
|
private
|
|
|
|
def build_form
|
|
@status = Static.new(_("Connected to %{name}.") % {:name => @profile.name})
|
|
@output = EditBox.new(
|
|
_("MUD output"),
|
|
:type => EditBox::Flags::ReadOnly | EditBox::Flags::MultiLine,
|
|
:text => "",
|
|
:detect_text_links => false
|
|
)
|
|
@command = EditBox.new(_("Command"), :text => "")
|
|
@send = Button.new(_("Send"))
|
|
@speech_button = Button.new(@speech_paused ? _("Resume automatic reading") : _("Pause automatic reading"))
|
|
@stop_speech = Button.new(_("Stop current speech"))
|
|
@gmcp_button = Button.new(_("GMCP information"))
|
|
@media_button = Button.new(_("Media"))
|
|
@transcript_button = Button.new(transcript_button_label)
|
|
@disconnect = Button.new(_("Disconnect"))
|
|
@form = Form.new(
|
|
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @transcript_button, @disconnect],
|
|
:quiet => true
|
|
)
|
|
@form.accept_button = @send
|
|
@form.cancel_button = @disconnect
|
|
@form.add_timer(FormTimer.new(0.05, :repeat => true) { tick })
|
|
|
|
@send.on(:press) { send_command }
|
|
@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 }
|
|
@transcript_button.on(:press) { toggle_transcript }
|
|
@disconnect.on(:press) { request_disconnect }
|
|
|
|
@output.add_tip(_("Use the context menu to jump to the latest output."))
|
|
@output.bind_context do |menu|
|
|
menu.option(_("Jump to latest output")) { jump_to_latest }
|
|
end
|
|
@command.add_tip(_("Use the context menu to recall commands, control transcripts, or mark the next command as sensitive."))
|
|
@command.bind_context do |menu|
|
|
menu.option(_("Previous command")) { recall(-1) }
|
|
menu.option(_("Next command")) { recall(1) }
|
|
menu.option(_("Sensitive command")) { set_sensitive(true) }
|
|
menu.option(transcript_button_label) { toggle_transcript }
|
|
menu.option(_("Resume reading from latest output")) { resume_from_latest }
|
|
end
|
|
add_system_line(_("Connected to %{host} on port %{port}.") % {:host => @profile.host, :port => @profile.port})
|
|
end
|
|
|
|
def tick
|
|
drain_events
|
|
if @pipeline.prompt_due?
|
|
record = @pipeline.flush_prompt
|
|
receive_record(record) if record != nil
|
|
end
|
|
process_shortcuts
|
|
@timer_scheduler.due.each { |timer| @automation.execute_actions(timer["actions"]) }
|
|
@media.tick if @media != nil
|
|
@automation_media.tick
|
|
prune_automation_sounds
|
|
speak_new_media_captions
|
|
dispatch_speech
|
|
rescue Telnet::CompressionError
|
|
add_system_line(_("The compressed data stream could not be processed safely. The connection was closed."))
|
|
close_form
|
|
rescue Exception => error
|
|
Log.error("Meow session update failed: #{error.class}: #{error.message}") if defined?(Log)
|
|
add_system_line(_("Session update error: %{message}") % {:message => error.message})
|
|
close_form
|
|
end
|
|
|
|
def drain_events
|
|
loop do
|
|
event = @transport.events.pop(true)
|
|
case event.type
|
|
when :bytes
|
|
result = @pipeline.feed(event.payload)
|
|
result.replies.each { |reply| @transport.send_bytes(reply) }
|
|
result.events.each { |protocol_event| handle_protocol_event(protocol_event) }
|
|
result.records.each { |record| receive_protocol_record(record) }
|
|
when :disconnected, :error
|
|
@pipeline.finish.each { |record| receive_record(record) }
|
|
add_system_line(event.payload.to_s)
|
|
close_form
|
|
end
|
|
end
|
|
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 then @media.play(action.request)
|
|
when :stop then @media.stop(action.request)
|
|
when :default_url
|
|
normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true)
|
|
@msp.default_url = normalized
|
|
when :error then @media.report_error(action.error)
|
|
end
|
|
true
|
|
rescue ArgumentError, URI::InvalidURIError => error
|
|
@media.report_error(error.message)
|
|
true
|
|
end
|
|
|
|
def receive_record(record)
|
|
original = record.to_s
|
|
@sensitive_next = true if original.strip.match?(PASSWORD_PATTERN)
|
|
apply_sensitive_flag
|
|
result = @automation.process(original)
|
|
return if result.gagged
|
|
append_line(result.text, "mud")
|
|
enqueue_speech(result.text)
|
|
end
|
|
|
|
def append_line(text, transcript_type = nil)
|
|
value = text.to_s
|
|
@lines << value
|
|
@line_bytes += value.bytesize + 1
|
|
while @lines.size > MAX_LINES || @line_bytes > MAX_BYTES
|
|
removed = @lines.shift.to_s
|
|
@line_bytes -= removed.bytesize + 1
|
|
end
|
|
@output.set_text(@lines.join("\n"), false) if @output != nil
|
|
write_transcript(transcript_type, value) if transcript_type != nil
|
|
end
|
|
|
|
def add_system_line(text)
|
|
append_line("[#{text}]", "system")
|
|
end
|
|
|
|
def send_command
|
|
text = @command.text.to_s
|
|
return if text == ""
|
|
sensitive = @sensitive_next
|
|
return unless send_text(text, :expand_aliases => true, :source => nil, :sensitive => sensitive)
|
|
@command.set_text("")
|
|
@history_index = nil
|
|
set_sensitive(false, false)
|
|
end
|
|
|
|
def send_text(text, expand_aliases: false, source: nil, sensitive: false)
|
|
value = @command_processor.process(text, :expand_aliases => expand_aliases)
|
|
return false if value == ""
|
|
begin
|
|
encoded = value.encode(@profile.encoding)
|
|
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
alert(_("This command contains characters that cannot be represented by %{encoding}.") % {:encoding => @profile.encoding})
|
|
return false
|
|
end
|
|
unless @transport.send_bytes(encoded.b + "\r\n".b)
|
|
alert(_("The connection is no longer available."))
|
|
close_form
|
|
return false
|
|
end
|
|
unless sensitive
|
|
add_history(text.to_s) if source == nil
|
|
unless @pipeline.remote_echo?
|
|
prefix = source == nil ? "> " : "> [#{source}] "
|
|
append_line(prefix + value, "command")
|
|
end
|
|
end
|
|
true
|
|
end
|
|
|
|
def add_history(text)
|
|
@history.delete(text)
|
|
@history << text
|
|
@history.shift while @history.size > MAX_HISTORY
|
|
end
|
|
|
|
def recall(direction)
|
|
return alert(_("No command history is available."), false) if @history.empty?
|
|
if @history_index == nil
|
|
@history_index = direction < 0 ? @history.size - 1 : 0
|
|
else
|
|
@history_index = [[@history_index + direction, 0].max, @history.size - 1].min
|
|
end
|
|
@command.set_text(@history[@history_index])
|
|
@command.index = @command.text.length
|
|
@command.check = @command.index
|
|
set_sensitive(false, false)
|
|
@form.index = @command
|
|
@command.focus
|
|
end
|
|
|
|
def process_shortcuts
|
|
Array(@automation_configuration["shortcuts"]).each do |shortcut|
|
|
next if shortcut["enabled"] == false
|
|
key = shortcut["digit"].to_i.to_s
|
|
if @form.shortcut_pressed?(key, :shift => shortcut["shift"] == true, :first => true)
|
|
send_text(shortcut["command"], :expand_aliases => true, :source => _("Shortcut"))
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
def set_sensitive(value, announce = true)
|
|
@sensitive_next = value == true
|
|
apply_sensitive_flag
|
|
speak(@sensitive_next ? _("Sensitive input enabled.") : _("Sensitive input disabled.")) if announce
|
|
end
|
|
|
|
def apply_sensitive_flag
|
|
return if @command == nil
|
|
if @sensitive_next
|
|
@command.flags |= EditBox::Flags::Password
|
|
else
|
|
@command.flags &= ~EditBox::Flags::Password
|
|
end
|
|
end
|
|
|
|
def enqueue_speech(text)
|
|
return if text.to_s.strip == ""
|
|
split_speech(text).each do |chunk|
|
|
if @speech_queue.size >= MAX_SPEECH
|
|
@speech_queue.shift
|
|
@speech_overflow += 1
|
|
end
|
|
@speech_queue << chunk
|
|
end
|
|
end
|
|
|
|
def split_speech(text)
|
|
value = text.to_s.strip
|
|
return [] if value == ""
|
|
value.scan(/.{1,#{SPEECH_CHUNK}}/m).map(&:strip).reject(&:empty?)
|
|
end
|
|
|
|
def dispatch_speech
|
|
return if @speech_paused || speech_actived
|
|
if @speech_overflow > 0
|
|
count = @speech_overflow
|
|
@speech_overflow = 0
|
|
speak(n_("%{count} older line was skipped.", "%{count} older lines were skipped.", count) % {:count => count})
|
|
return
|
|
end
|
|
text = @speech_queue.shift
|
|
speak(text, :stop => false) if text != nil
|
|
end
|
|
|
|
def toggle_speech
|
|
@speech_paused = !@speech_paused
|
|
@speech_button.label = @speech_paused ? _("Resume automatic reading") : _("Pause automatic reading")
|
|
speak(@speech_paused ? _("Automatic reading paused.") : _("Automatic reading resumed."))
|
|
end
|
|
|
|
def resume_from_latest
|
|
@speech_queue.clear
|
|
@speech_overflow = 0
|
|
@speech_paused = false
|
|
@speech_button.label = _("Pause automatic reading")
|
|
speak(_("Automatic reading resumed from the latest output."))
|
|
end
|
|
|
|
def jump_to_latest
|
|
position = @output.text.length
|
|
@output.index = position
|
|
@output.check = position
|
|
@form.index = @output
|
|
@output.focus
|
|
end
|
|
|
|
def transcript_button_label
|
|
@transcript.active? ? _("Stop transcript") : _("Start transcript")
|
|
end
|
|
|
|
def toggle_transcript
|
|
@transcript.active? ? stop_transcript : start_transcript
|
|
@transcript_button.label = transcript_button_label if @transcript_button != nil
|
|
end
|
|
|
|
def start_transcript(announce = true)
|
|
return if @transcript.active?
|
|
@transcript.start(@profile.id, @profile.name)
|
|
add_system_line(_("Transcript started.")) if announce
|
|
rescue Exception => error
|
|
alert(_("Could not start the transcript: %{message}") % {:message => error.message})
|
|
end
|
|
|
|
def stop_transcript
|
|
return unless @transcript.active?
|
|
write_transcript("system", _("Transcript stopped."))
|
|
@transcript.close
|
|
add_system_line(_("Transcript stopped."))
|
|
end
|
|
|
|
def write_transcript(type, text)
|
|
@transcript.write_record(type, text)
|
|
rescue Exception => error
|
|
@transcript.close
|
|
@transcript_button.label = transcript_button_label if @transcript_button != nil
|
|
append_line("[#{_("Transcript error: %{message}") % {:message => error.message}}]")
|
|
end
|
|
|
|
def play_automation_media(source, action)
|
|
value = source.to_s.strip
|
|
return if value == ""
|
|
key = action["key"].to_s
|
|
volume = [[action["volume"].to_i, 0].max, 100].min
|
|
if Automation::MediaSource.classify(value) == :https
|
|
request = Media::PlayRequest.new(:name => "", :url => value, :kind => "sound", :volume => volume, :key => key, :loops => 1)
|
|
@automation_media.play(request)
|
|
else
|
|
raise ArgumentError, _("Automation media file was not found.") unless File.file?(value)
|
|
raise ArgumentError, _("Automation media file type is not supported.") unless Automation::MediaSource.supported_local_file?(value)
|
|
stop_local_automation_sound(key) if key != ""
|
|
sound = Sound.new(value)
|
|
sound.attribute(:volume).value = volume.to_f / 100.0
|
|
sound.play
|
|
@automation_sounds[key == "" ? SecureRandom.uuid : key] = sound
|
|
end
|
|
rescue Exception => error
|
|
add_system_line(_("Automation media error: %{message}") % {:message => error.message})
|
|
end
|
|
|
|
def stop_automation_media(key)
|
|
value = key.to_s
|
|
@automation_media.stop(value == "" ? Media::StopRequest.new : Media::StopRequest.new(:key => value))
|
|
if value == ""
|
|
close_automation_sounds
|
|
else
|
|
stop_local_automation_sound(value)
|
|
end
|
|
end
|
|
|
|
def stop_local_automation_sound(key)
|
|
sound = @automation_sounds.delete(key)
|
|
sound.close if sound != nil
|
|
rescue Exception
|
|
end
|
|
|
|
def prune_automation_sounds
|
|
@automation_sounds.delete_if do |_key, sound|
|
|
if sound.finished?
|
|
sound.close
|
|
true
|
|
else
|
|
false
|
|
end
|
|
rescue Exception
|
|
true
|
|
end
|
|
end
|
|
|
|
def close_automation_sounds
|
|
@automation_sounds.each_value { |sound| sound.close rescue nil }
|
|
@automation_sounds.clear
|
|
end
|
|
|
|
def request_disconnect
|
|
if @closed || confirm(_("Disconnect from %{name}?") % {:name => @profile.name})
|
|
add_system_line(_("Disconnected."))
|
|
close_form
|
|
end
|
|
end
|
|
|
|
def close_form
|
|
return if @closed
|
|
@closed = true
|
|
@transcript.close
|
|
@media.close if @media != nil
|
|
@media = nil
|
|
@automation_media.close
|
|
close_automation_sounds
|
|
@transport.close
|
|
@form.resume
|
|
end
|
|
end
|
|
end
|