Files
meow/lib/meow/session.rb
T

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