Add profile automation and transcripts
This commit is contained in:
@@ -21,10 +21,13 @@ The project is currently an early but usable Telnet client. Its interface is bui
|
|||||||
- Pause, resume, stop-current-speech, and resume-from-latest controls
|
- Pause, resume, stop-current-speech, and resume-from-latest controls
|
||||||
- Bounded in-memory scrollback and speech queues
|
- Bounded in-memory scrollback and speech queues
|
||||||
- In-memory command history
|
- In-memory command history
|
||||||
|
- Per-profile aliases, regular-expression triggers, repeating timers, and command shortcuts
|
||||||
|
- Ordered automation actions for rewriting, hiding, speaking, sending, and playing local or HTTPS media
|
||||||
|
- Optional bounded plain-text transcripts with in-session controls and a profile default
|
||||||
- Automatic password-prompt detection and an explicit sensitive-input mode
|
- Automatic password-prompt detection and an explicit sensitive-input mode
|
||||||
- Clean socket, worker-thread, and program resource shutdown
|
- Clean socket, worker-thread, and program resource shutdown
|
||||||
|
|
||||||
Passwords, command history, transcripts, and scrollback are not persisted. Only connection profiles are written to the program's private data directory.
|
Passwords, command history, and scrollback are not persisted. Connection profiles and per-profile automation are stored in the program's private data directory. Transcripts are persisted there only when explicitly started for a session or enabled for a profile; sensitive commands and hidden trigger input are excluded.
|
||||||
|
|
||||||
## Using Meow
|
## Using Meow
|
||||||
|
|
||||||
@@ -49,7 +52,9 @@ The output and command fields provide additional actions through their context m
|
|||||||
- `lib/meow/msp.rb` parses line and Telnet-option MSP commands into shared media requests.
|
- `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/automation.rb` owns rule persistence, matching, templates, safety limits, and timers.
|
||||||
|
- `lib/meow/transcript.rb` provides bounded per-profile transcript storage.
|
||||||
|
- `lib/meow/session.rb` owns connected-session state, scrollback, input, automation, transcripts, and speech.
|
||||||
- `lib/meow/ui.rb` implements the connection manager and profile editor.
|
- `lib/meow/ui.rb` implements the connection manager and profile editor.
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
@@ -60,9 +65,8 @@ Automated regression coverage now exercises the streaming Telnet parser and text
|
|||||||
|
|
||||||
Planned work, roughly in order:
|
Planned work, roughly in order:
|
||||||
|
|
||||||
1. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts.
|
1. Add multiple simultaneous sessions with independent output, media, automation, transcripts, and speech queues.
|
||||||
2. Add multiple simultaneous sessions with independent output, media, and speech queues.
|
2. Add localization catalogs and package/signing automation for releases.
|
||||||
3. 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.3.0",
|
"version": "0.4.0",
|
||||||
"build_id": 5,
|
"build_id": 6,
|
||||||
"EltenAPIVersion": "3.0.3",
|
"EltenAPIVersion": "3.0.3",
|
||||||
"author": "Talon",
|
"author": "Talon",
|
||||||
"main_language": "en",
|
"main_language": "en",
|
||||||
@@ -23,9 +23,12 @@ 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/msp"
|
||||||
|
require_relative "lib/meow/automation"
|
||||||
|
require_relative "lib/meow/transcript"
|
||||||
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"
|
||||||
|
require_relative "lib/meow/automation_ui"
|
||||||
require_relative "lib/meow/ui"
|
require_relative "lib/meow/ui"
|
||||||
|
|
||||||
class Meow < Program
|
class Meow < Program
|
||||||
@@ -33,4 +36,3 @@ class Meow < Program
|
|||||||
MeowClient::UI.new(self).main
|
MeowClient::UI.new(self).main
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
require "securerandom"
|
||||||
|
|
||||||
|
module MeowClient
|
||||||
|
module Automation
|
||||||
|
MAX_RULES_PER_KIND = 100
|
||||||
|
MAX_ACTIONS_PER_TRIGGER = 16
|
||||||
|
MAX_ACTIONS_PER_RECORD = 32
|
||||||
|
MAX_AUTOMATED_SENDS_PER_SECOND = 20
|
||||||
|
MAX_TEXT_BYTES = 4_096
|
||||||
|
MAX_PATTERN_BYTES = 512
|
||||||
|
MIN_TIMER_INTERVAL = 1.0
|
||||||
|
MAX_TIMER_INTERVAL = 86_400.0
|
||||||
|
ACTION_TYPES = %w[rewrite gag highlight speak send play_media stop_media].freeze
|
||||||
|
|
||||||
|
Result = Struct.new(:text, :gagged, keyword_init: true)
|
||||||
|
|
||||||
|
module Template
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def alias_expand(template, arguments)
|
||||||
|
values = arguments.to_s.split(/\s+/).reject(&:empty?)
|
||||||
|
substitute(template) { |token| token == "*" ? arguments.to_s : values[token.to_i - 1].to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_expand(template, match)
|
||||||
|
substitute(template) { |token| token == "*" ? match[0].to_s : match[token.to_i].to_s }
|
||||||
|
end
|
||||||
|
|
||||||
|
def substitute(template)
|
||||||
|
template.to_s.gsub(/\$\$|\$(\*|[0-9])/) do |value|
|
||||||
|
value == "$$" ? "$" : yield(Regexp.last_match(1))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
module MediaSource
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def classify(source)
|
||||||
|
value = source.to_s.strip
|
||||||
|
return :https if value.match?(/\Ahttps:\/\//i)
|
||||||
|
return :local if value.match?(/\A[A-Za-z]:[\\\/]/) || value.start_with?("\\", "/")
|
||||||
|
raise ArgumentError, _("Automation media URLs must use HTTPS.") if value.match?(/\A[A-Za-z][A-Za-z0-9+.-]*:/)
|
||||||
|
:local
|
||||||
|
end
|
||||||
|
|
||||||
|
def supported_local_file?(source)
|
||||||
|
Media::URLPolicy::AUDIO_EXTENSIONS.include?(File.extname(source.to_s).downcase)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class Repository
|
||||||
|
FILE_NAME = "automation.json"
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def initialize(program)
|
||||||
|
@program = program
|
||||||
|
end
|
||||||
|
|
||||||
|
def load(profile_id)
|
||||||
|
data = @program.read_json(FILE_NAME, :default => default_document)
|
||||||
|
rows = data.is_a?(Hash) && data["profiles"].is_a?(Hash) ? data["profiles"] : {}
|
||||||
|
normalize_configuration(rows[profile_id.to_s])
|
||||||
|
rescue Exception => error
|
||||||
|
Log.warning("Meow automation read failed: #{error.class}: #{error.message}") if defined?(Log)
|
||||||
|
default_configuration
|
||||||
|
end
|
||||||
|
|
||||||
|
def save(profile_id, configuration)
|
||||||
|
normalized = normalize_configuration(configuration, :strict => true)
|
||||||
|
@program.update_json(FILE_NAME, :default => default_document) do |data|
|
||||||
|
data.replace(default_document) unless data.is_a?(Hash)
|
||||||
|
data["version"] = SCHEMA_VERSION
|
||||||
|
data["profiles"] = {} unless data["profiles"].is_a?(Hash)
|
||||||
|
data["profiles"][profile_id.to_s] = normalized
|
||||||
|
end
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_profile(profile_id)
|
||||||
|
@program.update_json(FILE_NAME, :default => default_document) do |data|
|
||||||
|
next unless data.is_a?(Hash) && data["profiles"].is_a?(Hash)
|
||||||
|
data["profiles"].delete(profile_id.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_configuration
|
||||||
|
{"transcript_default" => false, "aliases" => [], "triggers" => [], "timers" => [], "shortcuts" => []}
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate(configuration)
|
||||||
|
normalize_configuration(configuration, :strict => true)
|
||||||
|
[]
|
||||||
|
rescue ArgumentError => error
|
||||||
|
[error.message]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def default_document
|
||||||
|
{"version" => SCHEMA_VERSION, "profiles" => {}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_configuration(value, strict: false)
|
||||||
|
source = value.is_a?(Hash) ? value : {}
|
||||||
|
result = default_configuration
|
||||||
|
result["transcript_default"] = source["transcript_default"] == true
|
||||||
|
result["aliases"] = normalize_rules(source["aliases"], "alias", strict)
|
||||||
|
result["triggers"] = normalize_rules(source["triggers"], "trigger", strict)
|
||||||
|
result["timers"] = normalize_rules(source["timers"], "timer", strict)
|
||||||
|
result["shortcuts"] = normalize_rules(source["shortcuts"], "shortcut", strict)
|
||||||
|
validate_duplicates(result) if strict
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_rules(rows, kind, strict)
|
||||||
|
rows = [] unless rows.is_a?(Array)
|
||||||
|
raise ArgumentError, _("Too many %{kind} rules.") % {:kind => kind} if strict && rows.size > MAX_RULES_PER_KIND
|
||||||
|
rows.first(MAX_RULES_PER_KIND).filter_map do |row|
|
||||||
|
next unless row.is_a?(Hash)
|
||||||
|
send("normalize_#{kind}", row, strict)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def base(row)
|
||||||
|
{
|
||||||
|
"id" => row["id"].to_s == "" ? SecureRandom.uuid : row["id"].to_s,
|
||||||
|
"name" => bounded(row["name"], _("Rule name")),
|
||||||
|
"enabled" => row["enabled"] != false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_alias(row, strict)
|
||||||
|
result = base(row).merge("key" => bounded(row["key"], _("Alias"), 128).strip, "command" => command(row["command"]))
|
||||||
|
raise ArgumentError, _("Alias names may not contain spaces.") if strict && result["key"].match?(/\s/)
|
||||||
|
raise ArgumentError, _("Enter an alias name.") if strict && result["key"] == ""
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_trigger(row, strict)
|
||||||
|
pattern = bounded(row["pattern"], _("Trigger pattern"), MAX_PATTERN_BYTES)
|
||||||
|
begin
|
||||||
|
Regexp.new(pattern, row["case_sensitive"] == true ? nil : Regexp::IGNORECASE)
|
||||||
|
rescue RegexpError => error
|
||||||
|
raise ArgumentError, _("Invalid trigger expression: %{message}") % {:message => error.message} if strict
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
actions = normalize_actions(row["actions"], strict)
|
||||||
|
raise ArgumentError, _("A trigger needs at least one action.") if strict && actions.empty?
|
||||||
|
base(row).merge("pattern" => pattern, "case_sensitive" => row["case_sensitive"] == true, "actions" => actions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_timer(row, strict)
|
||||||
|
interval = Float(row["interval"], :exception => false)
|
||||||
|
if interval == nil || interval < MIN_TIMER_INTERVAL || interval > MAX_TIMER_INTERVAL
|
||||||
|
raise ArgumentError, _("Timer interval must be between 1 and 86400 seconds.") if strict
|
||||||
|
interval = MIN_TIMER_INTERVAL
|
||||||
|
end
|
||||||
|
actions = normalize_actions(row["actions"], strict).select { |action| %w[send speak play_media stop_media].include?(action["type"]) }
|
||||||
|
raise ArgumentError, _("A timer needs at least one send, speak, or media action.") if strict && actions.empty?
|
||||||
|
base(row).merge("interval" => interval, "actions" => actions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_shortcut(row, strict)
|
||||||
|
digit = Integer(row["digit"], :exception => false)
|
||||||
|
raise ArgumentError, _("Shortcut digit must be between 0 and 9.") if strict && (digit == nil || digit < 0 || digit > 9)
|
||||||
|
digit = 0 if digit == nil || digit < 0 || digit > 9
|
||||||
|
base(row).merge("digit" => digit, "shift" => row["shift"] == true, "command" => command(row["command"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_actions(rows, strict)
|
||||||
|
rows = [] unless rows.is_a?(Array)
|
||||||
|
raise ArgumentError, _("A trigger may contain at most %{count} actions.") % {:count => MAX_ACTIONS_PER_TRIGGER} if strict && rows.size > MAX_ACTIONS_PER_TRIGGER
|
||||||
|
rows.first(MAX_ACTIONS_PER_TRIGGER).filter_map do |row|
|
||||||
|
next unless row.is_a?(Hash)
|
||||||
|
type = row["type"].to_s
|
||||||
|
next unless ACTION_TYPES.include?(type)
|
||||||
|
action = {"type" => type}
|
||||||
|
case type
|
||||||
|
when "rewrite", "speak", "send"
|
||||||
|
action["text"] = bounded(row["text"], _("Action text"))
|
||||||
|
when "highlight"
|
||||||
|
action["label"] = bounded(row["label"], _("Highlight label"), 256)
|
||||||
|
action["source"] = bounded(row["source"], _("Highlight sound"))
|
||||||
|
when "play_media"
|
||||||
|
action["source"] = bounded(row["source"], _("Media source"))
|
||||||
|
action["volume"] = [[Integer(row["volume"], :exception => false) || 100, 0].max, 100].min
|
||||||
|
action["key"] = bounded(row["key"], _("Media key"), 128)
|
||||||
|
when "stop_media"
|
||||||
|
action["key"] = bounded(row["key"], _("Media key"), 128)
|
||||||
|
end
|
||||||
|
action
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_duplicates(configuration)
|
||||||
|
aliases = configuration["aliases"].map { |row| row["key"].downcase }
|
||||||
|
raise ArgumentError, _("Alias names must be unique.") unless aliases.uniq.size == aliases.size
|
||||||
|
shortcuts = configuration["shortcuts"].map { |row| [row["digit"], row["shift"]] }
|
||||||
|
raise ArgumentError, _("Shortcut key combinations must be unique.") unless shortcuts.uniq.size == shortcuts.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def command(value)
|
||||||
|
result = bounded(value, _("Command"))
|
||||||
|
raise ArgumentError, _("Commands may not contain line breaks.") if result.match?(/[\r\n]/)
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def bounded(value, label, maximum = MAX_TEXT_BYTES)
|
||||||
|
result = value.to_s
|
||||||
|
raise ArgumentError, _("%{label} is too long.") % {:label => label} if result.bytesize > maximum
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class CommandProcessor
|
||||||
|
def initialize(aliases)
|
||||||
|
@aliases = Array(aliases).select { |row| row["enabled"] != false }
|
||||||
|
end
|
||||||
|
|
||||||
|
def process(text, expand_aliases: true)
|
||||||
|
value = text.to_s
|
||||||
|
return value unless expand_aliases
|
||||||
|
key, arguments = value.split(/\s+/, 2)
|
||||||
|
rule = @aliases.find { |row| row["key"].to_s.casecmp?(key.to_s) }
|
||||||
|
rule == nil ? value : Template.alias_expand(rule["command"], arguments.to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class OutputProcessor
|
||||||
|
def initialize(triggers, callbacks = {}, clock: nil)
|
||||||
|
@callbacks = callbacks
|
||||||
|
@clock = clock || proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
||||||
|
@send_times = []
|
||||||
|
@suspended = false
|
||||||
|
@triggers = Array(triggers).filter_map do |row|
|
||||||
|
next if row["enabled"] == false
|
||||||
|
flags = row["case_sensitive"] == true ? nil : Regexp::IGNORECASE
|
||||||
|
[row, Regexp.new(row["pattern"].to_s, flags)]
|
||||||
|
rescue RegexpError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def process(line)
|
||||||
|
return Result.new(:text => line.to_s, :gagged => false) if @suspended
|
||||||
|
original = line.to_s
|
||||||
|
visible = original.dup
|
||||||
|
gagged = false
|
||||||
|
count = 0
|
||||||
|
@triggers.each do |row, expression|
|
||||||
|
match = expression.match(original)
|
||||||
|
next if match == nil
|
||||||
|
Array(row["actions"]).each do |action|
|
||||||
|
count += 1
|
||||||
|
return suspend(visible) if count > MAX_ACTIONS_PER_RECORD
|
||||||
|
case action["type"]
|
||||||
|
when "rewrite" then visible = Template.match_expand(action["text"], match)
|
||||||
|
when "gag" then gagged = true
|
||||||
|
when "highlight"
|
||||||
|
label = Template.match_expand(action["label"], match).strip
|
||||||
|
visible = label == "" ? visible : "#{label}: #{visible}"
|
||||||
|
media(action["source"], action, match) if action["source"].to_s != ""
|
||||||
|
when "speak" then call(:speak, Template.match_expand(action["text"], match))
|
||||||
|
when "send" then automated_send(Template.match_expand(action["text"], match))
|
||||||
|
when "play_media" then media(action["source"], action, match)
|
||||||
|
when "stop_media" then call(:stop_media, Template.match_expand(action["key"], match))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Result.new(:text => visible, :gagged => gagged)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute_actions(actions)
|
||||||
|
return false if @suspended
|
||||||
|
Array(actions).first(MAX_ACTIONS_PER_RECORD).each do |action|
|
||||||
|
case action["type"]
|
||||||
|
when "send" then automated_send(action["text"].to_s)
|
||||||
|
when "speak" then call(:speak, action["text"].to_s)
|
||||||
|
when "play_media" then media(action["source"], action, nil)
|
||||||
|
when "stop_media" then call(:stop_media, action["key"].to_s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def automated_send(text)
|
||||||
|
now = @clock.call
|
||||||
|
@send_times.reject! { |value| value <= now - 1.0 }
|
||||||
|
return suspend(nil) if @send_times.size >= MAX_AUTOMATED_SENDS_PER_SECOND
|
||||||
|
@send_times << now
|
||||||
|
call(:send, text)
|
||||||
|
end
|
||||||
|
|
||||||
|
def media(source, action, match)
|
||||||
|
value = match == nil ? source.to_s : Template.match_expand(source, match)
|
||||||
|
call(:play_media, value, action)
|
||||||
|
end
|
||||||
|
|
||||||
|
def suspend(text)
|
||||||
|
@suspended = true
|
||||||
|
call(:suspended)
|
||||||
|
Result.new(:text => text.to_s, :gagged => false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call(name, *arguments)
|
||||||
|
callback = @callbacks[name]
|
||||||
|
callback.call(*arguments) if callback != nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TimerScheduler
|
||||||
|
def initialize(timers, clock: nil)
|
||||||
|
@clock = clock || proc { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
||||||
|
now = @clock.call
|
||||||
|
@entries = Array(timers).filter_map do |row|
|
||||||
|
next if row["enabled"] == false
|
||||||
|
interval = row["interval"].to_f
|
||||||
|
next if interval < MIN_TIMER_INTERVAL || interval > MAX_TIMER_INTERVAL
|
||||||
|
[row, now + interval]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def due
|
||||||
|
now = @clock.call
|
||||||
|
ready = []
|
||||||
|
@entries.each do |entry|
|
||||||
|
row, deadline = entry
|
||||||
|
next if now < deadline
|
||||||
|
ready << row
|
||||||
|
entry[1] = now + row["interval"].to_f
|
||||||
|
end
|
||||||
|
ready
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
module MeowClient
|
||||||
|
class AutomationUI
|
||||||
|
KINDS = {
|
||||||
|
"aliases" => _("Aliases"),
|
||||||
|
"triggers" => _("Triggers"),
|
||||||
|
"timers" => _("Timers"),
|
||||||
|
"shortcuts" => _("Command shortcuts")
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def initialize(program, profile)
|
||||||
|
@program = program
|
||||||
|
@profile = profile
|
||||||
|
@repository = Automation::Repository.new(program)
|
||||||
|
@configuration = Marshal.load(Marshal.dump(@repository.load(profile.id)))
|
||||||
|
@transcripts = TranscriptStore.new(program.data_path("transcripts"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def main
|
||||||
|
automatic = CheckBox.new(_("Start a transcript automatically"), :checked => @configuration["transcript_default"])
|
||||||
|
buttons = KINDS.map do |kind, label|
|
||||||
|
button = Button.new(label)
|
||||||
|
button.on(:press) { manage_rules(kind) }
|
||||||
|
button
|
||||||
|
end
|
||||||
|
transcripts = Button.new(_("Saved transcripts"))
|
||||||
|
save = Button.new(_("Save"))
|
||||||
|
cancel = Button.new(_("Cancel"))
|
||||||
|
form = Form.new([automatic] + buttons + [transcripts, save, cancel], :quiet => true)
|
||||||
|
form.accept_button = save
|
||||||
|
form.cancel_button = cancel
|
||||||
|
saved = false
|
||||||
|
transcripts.on(:press) { manage_transcripts }
|
||||||
|
save.on(:press) do
|
||||||
|
@configuration["transcript_default"] = automatic.checked
|
||||||
|
errors = @repository.validate(@configuration)
|
||||||
|
if errors.empty?
|
||||||
|
@configuration = @repository.save(@profile.id, @configuration)
|
||||||
|
saved = true
|
||||||
|
form.resume
|
||||||
|
else
|
||||||
|
alert(errors.join("\n"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
cancel.on(:press) { form.resume }
|
||||||
|
form.wait
|
||||||
|
speak(_("Automation settings saved.")) if saved
|
||||||
|
rescue Exception => error
|
||||||
|
Log.error("Meow automation editor failed: #{error.class}: #{error.message}") if defined?(Log)
|
||||||
|
alert(_("Could not edit automation: %{message}") % {:message => error.message})
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def manage_rules(kind)
|
||||||
|
loop do
|
||||||
|
rows = @configuration[kind]
|
||||||
|
list = ListBox.new(rule_labels(kind, rows), :header => KINDS[kind], :empty_label => _("No rules. Choose New to create one."))
|
||||||
|
new_button = Button.new(_("New"))
|
||||||
|
edit = Button.new(_("Edit"))
|
||||||
|
toggle = Button.new(_("Enable or disable"))
|
||||||
|
up = Button.new(_("Move up"))
|
||||||
|
down = Button.new(_("Move down"))
|
||||||
|
delete = Button.new(_("Delete"))
|
||||||
|
back = Button.new(_("Back"))
|
||||||
|
form = Form.new([list, new_button, edit, toggle, up, down, delete, back], :quiet => true)
|
||||||
|
form.cancel_button = back
|
||||||
|
changed = false
|
||||||
|
new_button.on(:press) do
|
||||||
|
row = edit_rule(kind, nil)
|
||||||
|
if row != nil
|
||||||
|
rows << row
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
edit.on(:press) do
|
||||||
|
row = selected(rows, list)
|
||||||
|
next if row == nil
|
||||||
|
replacement = edit_rule(kind, row)
|
||||||
|
if replacement != nil
|
||||||
|
rows[list.index] = replacement
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
toggle.on(:press) do
|
||||||
|
row = selected(rows, list)
|
||||||
|
next if row == nil
|
||||||
|
row["enabled"] = row["enabled"] == false
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
up.on(:press) do
|
||||||
|
index = list.index
|
||||||
|
next if index <= 0 || index >= rows.size
|
||||||
|
rows[index - 1], rows[index] = rows[index], rows[index - 1]
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
down.on(:press) do
|
||||||
|
index = list.index
|
||||||
|
next if index < 0 || index >= rows.size - 1
|
||||||
|
rows[index + 1], rows[index] = rows[index], rows[index + 1]
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
delete.on(:press) do
|
||||||
|
row = selected(rows, list)
|
||||||
|
next if row == nil || !confirm(_("Delete %{name}?") % {:name => row["name"]})
|
||||||
|
rows.delete_at(list.index)
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
back.on(:press) { form.resume }
|
||||||
|
form.wait
|
||||||
|
break unless changed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def rule_labels(kind, rows)
|
||||||
|
rows.map do |row|
|
||||||
|
state = row["enabled"] == false ? _("disabled") : _("enabled")
|
||||||
|
detail = case kind
|
||||||
|
when "aliases" then "#{row["key"]} = #{row["command"]}"
|
||||||
|
when "triggers" then row["pattern"].to_s
|
||||||
|
when "timers" then n_("%{count} second", "%{count} seconds", row["interval"].to_i) % {:count => row["interval"].to_i}
|
||||||
|
when "shortcuts"
|
||||||
|
shift = row["shift"] ? "SHIFT+" : ""
|
||||||
|
"#{main_modifier_name}+#{shift}#{row["digit"]}: #{row["command"]}"
|
||||||
|
end
|
||||||
|
"#{row["name"]}, #{state}, #{detail}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_rule(kind, existing)
|
||||||
|
case kind
|
||||||
|
when "aliases" then edit_alias(existing)
|
||||||
|
when "triggers" then edit_trigger(existing)
|
||||||
|
when "timers" then edit_timer(existing)
|
||||||
|
when "shortcuts" then edit_shortcut(existing)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_alias(existing)
|
||||||
|
row = copy_or_new(existing)
|
||||||
|
name = EditBox.new(_("Name"), :text => row["name"].to_s)
|
||||||
|
key = EditBox.new(_("Alias word"), :text => row["key"].to_s)
|
||||||
|
command = EditBox.new(_("Command template; use $1 through $9 or $* for arguments"), :text => row["command"].to_s)
|
||||||
|
enabled = CheckBox.new(_("Enabled"), :checked => row["enabled"] != false)
|
||||||
|
save, form = save_form([name, key, command, enabled])
|
||||||
|
result = nil
|
||||||
|
save.on(:press) do
|
||||||
|
candidate = row.merge("name" => name.text, "key" => key.text, "command" => command.text, "enabled" => enabled.checked)
|
||||||
|
if validate_candidate("aliases", candidate)
|
||||||
|
result = candidate
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
form.wait
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_shortcut(existing)
|
||||||
|
row = copy_or_new(existing)
|
||||||
|
name = EditBox.new(_("Name"), :text => row["name"].to_s)
|
||||||
|
digits = (0..9).map(&:to_s)
|
||||||
|
digit = ChoiceListBox.new([[_("Digit"), digits, digits.index(row["digit"].to_i.to_s) || 0]], :header => _("Shortcut"))
|
||||||
|
shift = CheckBox.new(_("Also require Shift"), :checked => row["shift"] == true)
|
||||||
|
command = EditBox.new(_("Command"), :text => row["command"].to_s)
|
||||||
|
enabled = CheckBox.new(_("Enabled"), :checked => row["enabled"] != false)
|
||||||
|
save, form = save_form([name, digit, shift, command, enabled])
|
||||||
|
result = nil
|
||||||
|
save.on(:press) do
|
||||||
|
candidate = row.merge("name" => name.text, "digit" => digit.value(0), "shift" => shift.checked, "command" => command.text, "enabled" => enabled.checked)
|
||||||
|
if validate_candidate("shortcuts", candidate)
|
||||||
|
result = candidate
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
form.wait
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_trigger(existing)
|
||||||
|
row = copy_or_new(existing)
|
||||||
|
row["actions"] = [] unless row["actions"].is_a?(Array)
|
||||||
|
name = EditBox.new(_("Name"), :text => row["name"].to_s)
|
||||||
|
pattern = EditBox.new(_("Ruby regular expression"), :text => row["pattern"].to_s)
|
||||||
|
case_sensitive = CheckBox.new(_("Case sensitive"), :checked => row["case_sensitive"] == true)
|
||||||
|
enabled = CheckBox.new(_("Enabled"), :checked => row["enabled"] != false)
|
||||||
|
actions = Button.new(_("Edit actions"))
|
||||||
|
save, form = save_form([name, pattern, case_sensitive, enabled, actions])
|
||||||
|
result = nil
|
||||||
|
actions.on(:press) { manage_actions(row["actions"], false) }
|
||||||
|
save.on(:press) do
|
||||||
|
candidate = row.merge("name" => name.text, "pattern" => pattern.text, "case_sensitive" => case_sensitive.checked, "enabled" => enabled.checked)
|
||||||
|
if validate_candidate("triggers", candidate)
|
||||||
|
result = candidate
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
form.wait
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_timer(existing)
|
||||||
|
row = copy_or_new(existing)
|
||||||
|
row["actions"] = [] unless row["actions"].is_a?(Array)
|
||||||
|
name = EditBox.new(_("Name"), :text => row["name"].to_s)
|
||||||
|
interval = EditBox.new(_("Interval in seconds"), :type => EditBox::Flags::Numbers, :text => (row["interval"] || 60).to_i.to_s)
|
||||||
|
enabled = CheckBox.new(_("Enabled"), :checked => row["enabled"] != false)
|
||||||
|
actions = Button.new(_("Edit actions"))
|
||||||
|
save, form = save_form([name, interval, enabled, actions])
|
||||||
|
result = nil
|
||||||
|
actions.on(:press) { manage_actions(row["actions"], true) }
|
||||||
|
save.on(:press) do
|
||||||
|
candidate = row.merge("name" => name.text, "interval" => interval.text, "enabled" => enabled.checked)
|
||||||
|
if validate_candidate("timers", candidate)
|
||||||
|
result = candidate
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
form.wait
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def manage_actions(rows, timer)
|
||||||
|
loop do
|
||||||
|
list = ListBox.new(action_labels(rows), :header => _("Actions"), :empty_label => _("No actions. Choose New to create one."))
|
||||||
|
new_button = Button.new(_("New"))
|
||||||
|
edit = Button.new(_("Edit"))
|
||||||
|
up = Button.new(_("Move up"))
|
||||||
|
down = Button.new(_("Move down"))
|
||||||
|
delete = Button.new(_("Delete"))
|
||||||
|
back = Button.new(_("Back"))
|
||||||
|
form = Form.new([list, new_button, edit, up, down, delete, back], :quiet => true)
|
||||||
|
form.cancel_button = back
|
||||||
|
changed = false
|
||||||
|
new_button.on(:press) do
|
||||||
|
action = edit_action(nil, timer)
|
||||||
|
if action != nil
|
||||||
|
rows << action
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
edit.on(:press) do
|
||||||
|
action = selected(rows, list)
|
||||||
|
next if action == nil
|
||||||
|
replacement = edit_action(action, timer)
|
||||||
|
if replacement != nil
|
||||||
|
rows[list.index] = replacement
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
end
|
||||||
|
up.on(:press) do
|
||||||
|
index = list.index
|
||||||
|
next if index <= 0 || index >= rows.size
|
||||||
|
rows[index - 1], rows[index] = rows[index], rows[index - 1]
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
down.on(:press) do
|
||||||
|
index = list.index
|
||||||
|
next if index < 0 || index >= rows.size - 1
|
||||||
|
rows[index + 1], rows[index] = rows[index], rows[index + 1]
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
delete.on(:press) do
|
||||||
|
next if selected(rows, list) == nil
|
||||||
|
rows.delete_at(list.index)
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
back.on(:press) { form.resume }
|
||||||
|
form.wait
|
||||||
|
break unless changed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_labels(rows)
|
||||||
|
rows.map do |row|
|
||||||
|
detail = row["text"] || row["label"] || row["source"] || row["key"]
|
||||||
|
detail.to_s == "" ? row["type"].to_s : "#{row["type"]}: #{detail}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit_action(existing, timer)
|
||||||
|
row = existing == nil ? {"type" => "send"} : Marshal.load(Marshal.dump(existing))
|
||||||
|
types = timer ? %w[send speak play_media stop_media] : Automation::ACTION_TYPES
|
||||||
|
type_index = types.index(row["type"]) || 0
|
||||||
|
type = ChoiceListBox.new([[_("Action type"), types.map { |value| action_type_label(value) }, type_index]], :header => _("Action"))
|
||||||
|
text = EditBox.new(_("Text or command template"), :text => row["text"].to_s)
|
||||||
|
label = EditBox.new(_("Highlight label"), :text => row["label"].to_s)
|
||||||
|
source = EditBox.new(_("Local audio path or HTTPS URL"), :text => row["source"].to_s)
|
||||||
|
choose = Button.new(_("Choose local audio file"))
|
||||||
|
key = EditBox.new(_("Media key"), :text => row["key"].to_s)
|
||||||
|
volume = EditBox.new(_("Volume, 0 to 100"), :type => EditBox::Flags::Numbers, :text => (row["volume"] || 100).to_i.to_s)
|
||||||
|
save, form = save_form([type, text, label, source, choose, key, volume])
|
||||||
|
result = nil
|
||||||
|
choose.on(:press) do
|
||||||
|
file = get_file(
|
||||||
|
_("Select audio file"),
|
||||||
|
:save => false,
|
||||||
|
:extensions => %w[.mp3 .ogg .opus .wav .flac .m4a .aac .mid .midi]
|
||||||
|
)
|
||||||
|
source.set_text(file) if file != nil
|
||||||
|
end
|
||||||
|
save.on(:press) do
|
||||||
|
selected_type = types[type.value(0)]
|
||||||
|
result = {"type" => selected_type}
|
||||||
|
case selected_type
|
||||||
|
when "rewrite", "speak", "send"
|
||||||
|
result["text"] = text.text
|
||||||
|
when "highlight"
|
||||||
|
result["label"] = label.text
|
||||||
|
result["source"] = source.text
|
||||||
|
when "play_media"
|
||||||
|
result["source"] = source.text
|
||||||
|
result["key"] = key.text
|
||||||
|
result["volume"] = volume.text
|
||||||
|
when "stop_media"
|
||||||
|
result["key"] = key.text
|
||||||
|
end
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
form.wait
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_type_label(value)
|
||||||
|
{
|
||||||
|
"rewrite" => _("Rewrite output"),
|
||||||
|
"gag" => _("Hide output"),
|
||||||
|
"highlight" => _("Accessible highlight"),
|
||||||
|
"speak" => _("Speak"),
|
||||||
|
"send" => _("Send command"),
|
||||||
|
"play_media" => _("Play media"),
|
||||||
|
"stop_media" => _("Stop media")
|
||||||
|
}[value]
|
||||||
|
end
|
||||||
|
|
||||||
|
def manage_transcripts
|
||||||
|
loop do
|
||||||
|
rows = @transcripts.list(@profile.id)
|
||||||
|
labels = rows.map { |row| "#{row["name"]}, #{row["size"]} bytes" }
|
||||||
|
list = ListBox.new(labels, :header => _("Saved transcripts"), :empty_label => _("No transcripts have been saved."))
|
||||||
|
open_button = Button.new(_("Open"))
|
||||||
|
delete = Button.new(_("Delete"))
|
||||||
|
back = Button.new(_("Back"))
|
||||||
|
form = Form.new([list, open_button, delete, back], :quiet => true)
|
||||||
|
form.cancel_button = back
|
||||||
|
changed = false
|
||||||
|
open_button.on(:press) do
|
||||||
|
row = selected(rows, list)
|
||||||
|
display_text(@transcripts.read(row["path"]), :header => _("Transcript")) if row != nil
|
||||||
|
end
|
||||||
|
delete.on(:press) do
|
||||||
|
row = selected(rows, list)
|
||||||
|
next if row == nil || !confirm(_("Delete transcript %{name}?") % {:name => row["name"]})
|
||||||
|
@transcripts.delete(row["path"])
|
||||||
|
changed = true
|
||||||
|
form.resume
|
||||||
|
end
|
||||||
|
back.on(:press) { form.resume }
|
||||||
|
form.wait
|
||||||
|
break unless changed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_candidate(kind, candidate)
|
||||||
|
probe = Marshal.load(Marshal.dump(@configuration))
|
||||||
|
probe[kind] = probe[kind].reject { |row| row["id"] == candidate["id"] } + [candidate]
|
||||||
|
errors = @repository.validate(probe)
|
||||||
|
if candidate["name"].to_s.strip == ""
|
||||||
|
errors << _("Enter a rule name.")
|
||||||
|
end
|
||||||
|
alert(errors.join("\n")) unless errors.empty?
|
||||||
|
errors.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_form(fields)
|
||||||
|
save = Button.new(_("Save"))
|
||||||
|
cancel = Button.new(_("Cancel"))
|
||||||
|
form = Form.new(fields + [save, cancel], :quiet => true)
|
||||||
|
form.accept_button = save
|
||||||
|
form.cancel_button = cancel
|
||||||
|
cancel.on(:press) { form.resume }
|
||||||
|
[save, form]
|
||||||
|
end
|
||||||
|
|
||||||
|
def copy_or_new(existing)
|
||||||
|
existing == nil ? {"id" => SecureRandom.uuid, "enabled" => true, "name" => ""} : Marshal.load(Marshal.dump(existing))
|
||||||
|
end
|
||||||
|
|
||||||
|
def selected(rows, list)
|
||||||
|
return nil if rows.empty? || list.index < 0 || list.index >= rows.size
|
||||||
|
rows[list.index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+173
-40
@@ -1,4 +1,7 @@
|
|||||||
|
require "uri"
|
||||||
require_relative "session_gmcp"
|
require_relative "session_gmcp"
|
||||||
|
require_relative "automation"
|
||||||
|
require_relative "transcript"
|
||||||
|
|
||||||
module MeowClient
|
module MeowClient
|
||||||
class Session
|
class Session
|
||||||
@@ -34,7 +37,24 @@ module MeowClient
|
|||||||
:music_volume => profile.music_volume.to_f / 100.0
|
:music_volume => profile.music_volume.to_f / 100.0
|
||||||
)
|
)
|
||||||
end
|
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
|
@closed = false
|
||||||
|
start_transcript(false) if @automation_configuration["transcript_default"]
|
||||||
build_form
|
build_form
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -42,7 +62,10 @@ module MeowClient
|
|||||||
@form.wait
|
@form.wait
|
||||||
ensure
|
ensure
|
||||||
@pipeline.close
|
@pipeline.close
|
||||||
|
@transcript.close
|
||||||
@media.close if @media != nil
|
@media.close if @media != nil
|
||||||
|
@automation_media.close
|
||||||
|
close_automation_sounds
|
||||||
@transport.close
|
@transport.close
|
||||||
@program.release(@transport, :close => false)
|
@program.release(@transport, :close => false)
|
||||||
end
|
end
|
||||||
@@ -63,9 +86,10 @@ module MeowClient
|
|||||||
@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"))
|
@media_button = Button.new(_("Media"))
|
||||||
|
@transcript_button = Button.new(transcript_button_label)
|
||||||
@disconnect = Button.new(_("Disconnect"))
|
@disconnect = Button.new(_("Disconnect"))
|
||||||
@form = Form.new(
|
@form = Form.new(
|
||||||
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @disconnect],
|
[@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @transcript_button, @disconnect],
|
||||||
:quiet => true
|
:quiet => true
|
||||||
)
|
)
|
||||||
@form.accept_button = @send
|
@form.accept_button = @send
|
||||||
@@ -77,23 +101,22 @@ module MeowClient
|
|||||||
@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 }
|
@media_button.on(:press) { open_media_view }
|
||||||
|
@transcript_button.on(:press) { toggle_transcript }
|
||||||
@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."))
|
||||||
@output.bind_context do |menu|
|
@output.bind_context do |menu|
|
||||||
menu.option(_("Jump to latest output")) { jump_to_latest }
|
menu.option(_("Jump to latest output")) { jump_to_latest }
|
||||||
end
|
end
|
||||||
@command.add_tip(_("Use the context menu to recall commands or mark the next command as sensitive."))
|
@command.add_tip(_("Use the context menu to recall commands, control transcripts, or mark the next command as sensitive."))
|
||||||
@command.bind_context do |menu|
|
@command.bind_context do |menu|
|
||||||
menu.option(_("Previous command")) { recall(-1) }
|
menu.option(_("Previous command")) { recall(-1) }
|
||||||
menu.option(_("Next command")) { recall(1) }
|
menu.option(_("Next command")) { recall(1) }
|
||||||
menu.option(_("Sensitive command")) { set_sensitive(true) }
|
menu.option(_("Sensitive command")) { set_sensitive(true) }
|
||||||
|
menu.option(transcript_button_label) { toggle_transcript }
|
||||||
menu.option(_("Resume reading from latest output")) { resume_from_latest }
|
menu.option(_("Resume reading from latest output")) { resume_from_latest }
|
||||||
end
|
end
|
||||||
add_system_line(_("Connected to %{host} on port %{port}.") % {
|
add_system_line(_("Connected to %{host} on port %{port}.") % {:host => @profile.host, :port => @profile.port})
|
||||||
:host => @profile.host,
|
|
||||||
:port => @profile.port
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def tick
|
def tick
|
||||||
@@ -102,7 +125,11 @@ module MeowClient
|
|||||||
record = @pipeline.flush_prompt
|
record = @pipeline.flush_prompt
|
||||||
receive_record(record) if record != nil
|
receive_record(record) if record != nil
|
||||||
end
|
end
|
||||||
|
process_shortcuts
|
||||||
|
@timer_scheduler.due.each { |timer| @automation.execute_actions(timer["actions"]) }
|
||||||
@media.tick if @media != nil
|
@media.tick if @media != nil
|
||||||
|
@automation_media.tick
|
||||||
|
prune_automation_sounds
|
||||||
speak_new_media_captions
|
speak_new_media_captions
|
||||||
dispatch_speech
|
dispatch_speech
|
||||||
rescue Telnet::CompressionError
|
rescue Telnet::CompressionError
|
||||||
@@ -142,15 +169,12 @@ module MeowClient
|
|||||||
|
|
||||||
def handle_msp_action(action)
|
def handle_msp_action(action)
|
||||||
case action.type
|
case action.type
|
||||||
when :play
|
when :play then @media.play(action.request)
|
||||||
@media.play(action.request)
|
when :stop then @media.stop(action.request)
|
||||||
when :stop
|
|
||||||
@media.stop(action.request)
|
|
||||||
when :default_url
|
when :default_url
|
||||||
normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true)
|
normalized = @media.normalize_base_url(action.url, :allow_insecure_http => true)
|
||||||
@msp.default_url = normalized
|
@msp.default_url = normalized
|
||||||
when :error
|
when :error then @media.report_error(action.error)
|
||||||
@media.report_error(action.error)
|
|
||||||
end
|
end
|
||||||
true
|
true
|
||||||
rescue ArgumentError, URI::InvalidURIError => error
|
rescue ArgumentError, URI::InvalidURIError => error
|
||||||
@@ -159,14 +183,16 @@ module MeowClient
|
|||||||
end
|
end
|
||||||
|
|
||||||
def receive_record(record)
|
def receive_record(record)
|
||||||
text = record.to_s
|
original = record.to_s
|
||||||
append_line(text)
|
@sensitive_next = true if original.strip.match?(PASSWORD_PATTERN)
|
||||||
@sensitive_next = true if text.strip.match?(PASSWORD_PATTERN)
|
|
||||||
apply_sensitive_flag
|
apply_sensitive_flag
|
||||||
enqueue_speech(text)
|
result = @automation.process(original)
|
||||||
|
return if result.gagged
|
||||||
|
append_line(result.text, "mud")
|
||||||
|
enqueue_speech(result.text)
|
||||||
end
|
end
|
||||||
|
|
||||||
def append_line(text)
|
def append_line(text, transcript_type = nil)
|
||||||
value = text.to_s
|
value = text.to_s
|
||||||
@lines << value
|
@lines << value
|
||||||
@line_bytes += value.bytesize + 1
|
@line_bytes += value.bytesize + 1
|
||||||
@@ -174,37 +200,46 @@ module MeowClient
|
|||||||
removed = @lines.shift.to_s
|
removed = @lines.shift.to_s
|
||||||
@line_bytes -= removed.bytesize + 1
|
@line_bytes -= removed.bytesize + 1
|
||||||
end
|
end
|
||||||
@output.set_text(@lines.join("\n"), false)
|
@output.set_text(@lines.join("\n"), false) if @output != nil
|
||||||
|
write_transcript(transcript_type, value) if transcript_type != nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_system_line(text)
|
def add_system_line(text)
|
||||||
append_line("[#{text}]")
|
append_line("[#{text}]", "system")
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_command
|
def send_command
|
||||||
text = @command.text.to_s
|
text = @command.text.to_s
|
||||||
return if text == ""
|
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
|
begin
|
||||||
encoded = text.encode(@profile.encoding)
|
encoded = value.encode(@profile.encoding)
|
||||||
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
||||||
alert(_("This command contains characters that cannot be represented by %{encoding}.") % {
|
alert(_("This command contains characters that cannot be represented by %{encoding}.") % {:encoding => @profile.encoding})
|
||||||
:encoding => @profile.encoding
|
return false
|
||||||
})
|
|
||||||
return
|
|
||||||
end
|
end
|
||||||
unless @transport.send_bytes(encoded.b + "\r\n".b)
|
unless @transport.send_bytes(encoded.b + "\r\n".b)
|
||||||
alert(_("The connection is no longer available."))
|
alert(_("The connection is no longer available."))
|
||||||
close_form
|
close_form
|
||||||
return
|
return false
|
||||||
end
|
end
|
||||||
sensitive = @sensitive_next
|
|
||||||
unless sensitive
|
unless sensitive
|
||||||
add_history(text)
|
add_history(text.to_s) if source == nil
|
||||||
append_line("> #{text}") unless @pipeline.remote_echo?
|
unless @pipeline.remote_echo?
|
||||||
|
prefix = source == nil ? "> " : "> [#{source}] "
|
||||||
|
append_line(prefix + value, "command")
|
||||||
end
|
end
|
||||||
@command.set_text("")
|
end
|
||||||
@history_index = nil
|
true
|
||||||
set_sensitive(false, false)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_history(text)
|
def add_history(text)
|
||||||
@@ -228,15 +263,25 @@ module MeowClient
|
|||||||
@command.focus
|
@command.focus
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_sensitive(value, announce = true)
|
def process_shortcuts
|
||||||
@sensitive_next = value == true
|
Array(@automation_configuration["shortcuts"]).each do |shortcut|
|
||||||
apply_sensitive_flag
|
next if shortcut["enabled"] == false
|
||||||
if announce
|
key = shortcut["digit"].to_i.to_s
|
||||||
speak(@sensitive_next ? _("Sensitive input enabled.") : _("Sensitive input disabled."))
|
if @form.shortcut_pressed?(key, :shift => shortcut["shift"] == true, :first => true)
|
||||||
|
send_text(shortcut["command"], :expand_aliases => true, :source => _("Shortcut"))
|
||||||
|
break
|
||||||
|
end
|
||||||
end
|
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
|
def apply_sensitive_flag
|
||||||
|
return if @command == nil
|
||||||
if @sensitive_next
|
if @sensitive_next
|
||||||
@command.flags |= EditBox::Flags::Password
|
@command.flags |= EditBox::Flags::Password
|
||||||
else
|
else
|
||||||
@@ -245,9 +290,8 @@ module MeowClient
|
|||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_speech(text)
|
def enqueue_speech(text)
|
||||||
return if text.strip == ""
|
return if text.to_s.strip == ""
|
||||||
chunks = split_speech(text)
|
split_speech(text).each do |chunk|
|
||||||
chunks.each do |chunk|
|
|
||||||
if @speech_queue.size >= MAX_SPEECH
|
if @speech_queue.size >= MAX_SPEECH
|
||||||
@speech_queue.shift
|
@speech_queue.shift
|
||||||
@speech_overflow += 1
|
@speech_overflow += 1
|
||||||
@@ -296,6 +340,93 @@ module MeowClient
|
|||||||
@output.focus
|
@output.focus
|
||||||
end
|
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
|
def request_disconnect
|
||||||
if @closed || confirm(_("Disconnect from %{name}?") % {:name => @profile.name})
|
if @closed || confirm(_("Disconnect from %{name}?") % {:name => @profile.name})
|
||||||
add_system_line(_("Disconnected."))
|
add_system_line(_("Disconnected."))
|
||||||
@@ -306,11 +437,13 @@ module MeowClient
|
|||||||
def close_form
|
def close_form
|
||||||
return if @closed
|
return if @closed
|
||||||
@closed = true
|
@closed = true
|
||||||
|
@transcript.close
|
||||||
@media.close if @media != nil
|
@media.close if @media != nil
|
||||||
@media = nil
|
@media = nil
|
||||||
|
@automation_media.close
|
||||||
|
close_automation_sounds
|
||||||
@transport.close
|
@transport.close
|
||||||
@form.resume
|
@form.resume
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
require "fileutils"
|
||||||
|
require "time"
|
||||||
|
|
||||||
|
module MeowClient
|
||||||
|
class TranscriptStore
|
||||||
|
MAX_FILE_BYTES = 25 * 1_048_576
|
||||||
|
MAX_TOTAL_BYTES = 250 * 1_048_576
|
||||||
|
|
||||||
|
attr_reader :path
|
||||||
|
|
||||||
|
def initialize(root, clock: nil)
|
||||||
|
@root = File.expand_path(root.to_s)
|
||||||
|
@clock = clock || proc { Time.now }
|
||||||
|
@file = nil
|
||||||
|
@path = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def start(profile_id, profile_name)
|
||||||
|
close
|
||||||
|
raise IOError, _("Transcript storage has reached its 250 MB limit.") if total_bytes >= MAX_TOTAL_BYTES
|
||||||
|
directory = profile_directory(profile_id)
|
||||||
|
FileUtils.mkdir_p(directory)
|
||||||
|
stamp = @clock.call.strftime("%Y%m%d-%H%M%S")
|
||||||
|
sequence = 0
|
||||||
|
begin
|
||||||
|
suffix = sequence == 0 ? "" : "-#{sequence}"
|
||||||
|
candidate = File.join(directory, "#{stamp}#{suffix}.txt")
|
||||||
|
sequence += 1
|
||||||
|
end while File.exist?(candidate)
|
||||||
|
@file = File.open(candidate, "wb")
|
||||||
|
@path = candidate
|
||||||
|
write_record("session", _("Transcript for %{name}") % {:name => profile_name})
|
||||||
|
@path
|
||||||
|
rescue Exception
|
||||||
|
close
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
@file != nil && !@file.closed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_record(type, text)
|
||||||
|
return false unless active?
|
||||||
|
value = text.to_s.gsub("\r\n", "\n").gsub("\r", "\n")
|
||||||
|
lines = value.split("\n", -1)
|
||||||
|
timestamp = @clock.call.iso8601
|
||||||
|
payload = lines.map { |line| "[#{timestamp}] [#{type}] #{line}\n" }.join.encode(Encoding::UTF_8)
|
||||||
|
if @file.size + payload.bytesize > MAX_FILE_BYTES
|
||||||
|
close
|
||||||
|
raise IOError, _("The transcript reached its 25 MB limit and was stopped.")
|
||||||
|
end
|
||||||
|
@file.write(payload)
|
||||||
|
@file.flush
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
@file.close if @file != nil && !@file.closed?
|
||||||
|
@file = nil
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def list(profile_id)
|
||||||
|
directory = profile_directory(profile_id)
|
||||||
|
return [] unless File.directory?(directory)
|
||||||
|
Dir.glob(File.join(directory, "*.txt")).sort.reverse.map do |file|
|
||||||
|
stat = File.stat(file)
|
||||||
|
{"name" => File.basename(file), "path" => file, "size" => stat.size, "modified_at" => stat.mtime}
|
||||||
|
end
|
||||||
|
rescue SystemCallError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def read(file)
|
||||||
|
File.binread(checked_file(file)).force_encoding(Encoding::UTF_8).scrub
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(file)
|
||||||
|
File.delete(checked_file(file))
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_profile(profile_id)
|
||||||
|
directory = profile_directory(profile_id)
|
||||||
|
FileUtils.remove_dir(directory, true) if File.directory?(directory)
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_bytes
|
||||||
|
return 0 unless File.directory?(@root)
|
||||||
|
Dir.glob(File.join(@root, "*", "*.txt")).sum { |file| File.file?(file) ? File.size(file) : 0 }
|
||||||
|
rescue SystemCallError
|
||||||
|
0
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def profile_directory(profile_id)
|
||||||
|
component = profile_id.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
|
||||||
|
raise ArgumentError, _("Invalid profile identifier.") if component == ""
|
||||||
|
File.join(@root, component)
|
||||||
|
end
|
||||||
|
|
||||||
|
def checked_file(file)
|
||||||
|
candidate = File.expand_path(file.to_s)
|
||||||
|
root_prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
|
||||||
|
raise ArgumentError, _("Invalid transcript path.") unless candidate.start_with?(root_prefix)
|
||||||
|
raise ArgumentError, _("Invalid transcript file.") unless File.extname(candidate).downcase == ".txt"
|
||||||
|
candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+11
-4
@@ -3,6 +3,8 @@ module MeowClient
|
|||||||
def initialize(program)
|
def initialize(program)
|
||||||
@program = program
|
@program = program
|
||||||
@profiles = ProfileRepository.new(program)
|
@profiles = ProfileRepository.new(program)
|
||||||
|
@automation = Automation::Repository.new(program)
|
||||||
|
@transcripts = TranscriptStore.new(program.data_path("transcripts"))
|
||||||
end
|
end
|
||||||
|
|
||||||
def main
|
def main
|
||||||
@@ -17,6 +19,8 @@ module MeowClient
|
|||||||
edit_profile(profile)
|
edit_profile(profile)
|
||||||
when :delete
|
when :delete
|
||||||
delete_profile(profile)
|
delete_profile(profile)
|
||||||
|
when :automation
|
||||||
|
AutomationUI.new(@program, profile).main
|
||||||
else
|
else
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -45,8 +49,9 @@ module MeowClient
|
|||||||
new_button = Button.new(_("New"))
|
new_button = Button.new(_("New"))
|
||||||
edit_button = Button.new(_("Edit"))
|
edit_button = Button.new(_("Edit"))
|
||||||
delete_button = Button.new(_("Delete"))
|
delete_button = Button.new(_("Delete"))
|
||||||
|
automation_button = Button.new(_("Automation and transcripts"))
|
||||||
exit_button = Button.new(_("Exit"))
|
exit_button = Button.new(_("Exit"))
|
||||||
form = Form.new([list, connect_button, new_button, edit_button, delete_button, exit_button], :quiet => true)
|
form = Form.new([list, connect_button, new_button, edit_button, delete_button, automation_button, exit_button], :quiet => true)
|
||||||
form.accept_button = connect_button
|
form.accept_button = connect_button
|
||||||
form.cancel_button = exit_button
|
form.cancel_button = exit_button
|
||||||
result = nil
|
result = nil
|
||||||
@@ -66,6 +71,7 @@ module MeowClient
|
|||||||
end
|
end
|
||||||
edit_button.on(:press) { choose.call(:edit) }
|
edit_button.on(:press) { choose.call(:edit) }
|
||||||
delete_button.on(:press) { choose.call(:delete) }
|
delete_button.on(:press) { choose.call(:delete) }
|
||||||
|
automation_button.on(:press) { choose.call(:automation) }
|
||||||
exit_button.on(:press) do
|
exit_button.on(:press) do
|
||||||
result = [:exit, nil]
|
result = [:exit, nil]
|
||||||
form.resume
|
form.resume
|
||||||
@@ -131,9 +137,11 @@ module MeowClient
|
|||||||
|
|
||||||
def delete_profile(profile)
|
def delete_profile(profile)
|
||||||
return if profile == nil
|
return if profile == nil
|
||||||
return unless confirm(_("Delete connection %{name}?") % {:name => profile.name})
|
return unless confirm(_("Delete connection %{name}, including its automation and transcripts?") % {:name => profile.name})
|
||||||
@profiles.delete(profile.id)
|
@profiles.delete(profile.id)
|
||||||
speak(_("Connection profile deleted."))
|
@automation.delete_profile(profile.id)
|
||||||
|
@transcripts.delete_profile(profile.id)
|
||||||
|
speak(_("Connection profile, automation, and transcripts deleted."))
|
||||||
rescue Exception => error
|
rescue Exception => error
|
||||||
alert(_("Could not delete the connection profile: %{message}") % {:message => error.message})
|
alert(_("Could not delete the connection profile: %{message}") % {:message => error.message})
|
||||||
end
|
end
|
||||||
@@ -175,4 +183,3 @@ module MeowClient
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
module MeowClient
|
module MeowClient
|
||||||
VERSION = "0.3.0"
|
VERSION = "0.4.0"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# encoding: UTF-8
|
||||||
|
|
||||||
|
require_relative "test_helper"
|
||||||
|
require_relative "../lib/meow/automation"
|
||||||
|
|
||||||
|
module MeowAutomationTest
|
||||||
|
Automation = MeowClient::Automation
|
||||||
|
|
||||||
|
class FakeProgram
|
||||||
|
def initialize
|
||||||
|
@documents = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_json(name, default:)
|
||||||
|
Marshal.load(Marshal.dump(@documents.fetch(name, default)))
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_json(name, default:)
|
||||||
|
value = Marshal.load(Marshal.dump(@documents.fetch(name, default)))
|
||||||
|
yield value
|
||||||
|
@documents[name] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Automation templates expand aliases and regular expression captures") do
|
||||||
|
MeowTest.assert_equal("cast fire goblin $", Automation::Template.alias_expand("cast $1 $2 $$", "fire goblin"))
|
||||||
|
match = /(red) (dragon)/.match("red dragon")
|
||||||
|
MeowTest.assert_equal("dragon/red dragon/$", Automation::Template.match_expand("$2/$0/$$", match))
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Automation repository round trips normalized profile rules") do
|
||||||
|
repository = Automation::Repository.new(FakeProgram.new)
|
||||||
|
configuration = repository.default_configuration
|
||||||
|
configuration["aliases"] << {"name" => "Heal", "key" => "h", "command" => "cast heal $*", "enabled" => true}
|
||||||
|
configuration["shortcuts"] << {"name" => "Look", "digit" => 1, "shift" => false, "command" => "look", "enabled" => true}
|
||||||
|
saved = repository.save("profile", configuration)
|
||||||
|
loaded = repository.load("profile")
|
||||||
|
MeowTest.assert_equal(saved, loaded)
|
||||||
|
MeowTest.assert(saved["aliases"][0]["id"].to_s != "")
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Automation repository rejects duplicate aliases and shortcuts") do
|
||||||
|
repository = Automation::Repository.new(FakeProgram.new)
|
||||||
|
configuration = repository.default_configuration
|
||||||
|
configuration["aliases"] = [
|
||||||
|
{"name" => "One", "key" => "x", "command" => "one"},
|
||||||
|
{"name" => "Two", "key" => "X", "command" => "two"}
|
||||||
|
]
|
||||||
|
MeowTest.assert_raises(ArgumentError) { repository.save("profile", configuration) }
|
||||||
|
|
||||||
|
configuration = repository.default_configuration
|
||||||
|
configuration["shortcuts"] = [
|
||||||
|
{"name" => "One", "digit" => 1, "shift" => false, "command" => "one"},
|
||||||
|
{"name" => "Two", "digit" => 1, "shift" => false, "command" => "two"}
|
||||||
|
]
|
||||||
|
MeowTest.assert_raises(ArgumentError) { repository.save("profile", configuration) }
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Command processor expands only one exact first-word alias") do
|
||||||
|
processor = Automation::CommandProcessor.new([
|
||||||
|
{"key" => "k", "command" => "kill $1", "enabled" => true},
|
||||||
|
{"key" => "disabled", "command" => "say no", "enabled" => false}
|
||||||
|
])
|
||||||
|
MeowTest.assert_equal("kill orc", processor.process("K orc"))
|
||||||
|
MeowTest.assert_equal("kk orc", processor.process("kk orc"))
|
||||||
|
MeowTest.assert_equal("k orc", processor.process("k orc", :expand_aliases => false))
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Output processor applies ordered actions against original input") do
|
||||||
|
spoken = []
|
||||||
|
sent = []
|
||||||
|
media = []
|
||||||
|
triggers = [
|
||||||
|
{
|
||||||
|
"enabled" => true,
|
||||||
|
"pattern" => "HP: (\\d+)",
|
||||||
|
"case_sensitive" => false,
|
||||||
|
"actions" => [
|
||||||
|
{"type" => "rewrite", "text" => "Health $1"},
|
||||||
|
{"type" => "highlight", "label" => "Warning", "source" => "tone.wav"},
|
||||||
|
{"type" => "speak", "text" => "Health is $1"},
|
||||||
|
{"type" => "send", "text" => "score"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
processor = Automation::OutputProcessor.new(
|
||||||
|
triggers,
|
||||||
|
{
|
||||||
|
:speak => proc { |text| spoken << text },
|
||||||
|
:send => proc { |text| sent << text },
|
||||||
|
:play_media => proc { |source, _action| media << source }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = processor.process("hp: 12")
|
||||||
|
MeowTest.assert_equal("Warning: Health 12", result.text)
|
||||||
|
MeowTest.refute(result.gagged)
|
||||||
|
MeowTest.assert_equal(["Health is 12"], spoken)
|
||||||
|
MeowTest.assert_equal(["score"], sent)
|
||||||
|
MeowTest.assert_equal(["tone.wav"], media)
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Gag suppresses output while preserving side effects") do
|
||||||
|
spoken = []
|
||||||
|
processor = Automation::OutputProcessor.new(
|
||||||
|
[{"enabled" => true, "pattern" => "secret", "actions" => [{"type" => "gag"}, {"type" => "speak", "text" => "noticed"}]}],
|
||||||
|
{:speak => proc { |text| spoken << text }}
|
||||||
|
)
|
||||||
|
result = processor.process("secret")
|
||||||
|
MeowTest.assert(result.gagged)
|
||||||
|
MeowTest.assert_equal(["noticed"], spoken)
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Automation send limiter suspends a runaway rule") do
|
||||||
|
now = 10.0
|
||||||
|
sent = []
|
||||||
|
suspended = 0
|
||||||
|
processor = Automation::OutputProcessor.new(
|
||||||
|
[],
|
||||||
|
{:send => proc { |text| sent << text }, :suspended => proc { suspended += 1 }},
|
||||||
|
:clock => proc { now }
|
||||||
|
)
|
||||||
|
actions = [{"type" => "send", "text" => "look"}]
|
||||||
|
(Automation::MAX_AUTOMATED_SENDS_PER_SECOND + 1).times { processor.execute_actions(actions) }
|
||||||
|
MeowTest.assert_equal(Automation::MAX_AUTOMATED_SENDS_PER_SECOND, sent.size)
|
||||||
|
MeowTest.assert_equal(1, suspended)
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Timer scheduler starts at session time and skips missed bursts") do
|
||||||
|
now = 100.0
|
||||||
|
scheduler = Automation::TimerScheduler.new(
|
||||||
|
[{"name" => "pulse", "enabled" => true, "interval" => 5.0, "actions" => []}],
|
||||||
|
:clock => proc { now }
|
||||||
|
)
|
||||||
|
MeowTest.assert_empty(scheduler.due)
|
||||||
|
now = 105.0
|
||||||
|
MeowTest.assert_equal(["pulse"], scheduler.due.map { |row| row["name"] })
|
||||||
|
now = 130.0
|
||||||
|
MeowTest.assert_equal(["pulse"], scheduler.due.map { |row| row["name"] })
|
||||||
|
MeowTest.assert_empty(scheduler.due)
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Automation media distinguishes Windows files from secure URLs") do
|
||||||
|
MeowTest.assert_equal(:local, Automation::MediaSource.classify("C:\\sounds\\alert.ogg"))
|
||||||
|
MeowTest.assert_equal(:local, Automation::MediaSource.classify("\\\\server\\sounds\\alert.wav"))
|
||||||
|
MeowTest.assert_equal(:https, Automation::MediaSource.classify("https://media.example/alert.mp3"))
|
||||||
|
MeowTest.assert_raises(ArgumentError) { Automation::MediaSource.classify("http://media.example/alert.mp3") }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,8 @@ require_relative "gmcp_test"
|
|||||||
require_relative "media_test"
|
require_relative "media_test"
|
||||||
require_relative "msp_test"
|
require_relative "msp_test"
|
||||||
require_relative "text_pipeline_test"
|
require_relative "text_pipeline_test"
|
||||||
|
require_relative "automation_test"
|
||||||
|
require_relative "transcript_test"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
MeowTest.run!
|
MeowTest.run!
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
module Kernel
|
||||||
|
def _(text)
|
||||||
|
text
|
||||||
|
end unless method_defined?(:_)
|
||||||
|
end
|
||||||
module MeowTest
|
module MeowTest
|
||||||
Failure = Class.new(StandardError)
|
Failure = Class.new(StandardError)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# encoding: UTF-8
|
||||||
|
|
||||||
|
require "tmpdir"
|
||||||
|
require_relative "test_helper"
|
||||||
|
require_relative "../lib/meow/transcript"
|
||||||
|
|
||||||
|
module MeowTranscriptTest
|
||||||
|
MeowTest.test("Transcript store writes timestamped typed records and lists them") do
|
||||||
|
Dir.mktmpdir("meow-transcript") do |directory|
|
||||||
|
now = Time.new(2026, 9, 17, 12, 30, 0, "+02:00")
|
||||||
|
store = MeowClient::TranscriptStore.new(directory, :clock => proc { now })
|
||||||
|
path = store.start("profile-1", "Test MUD")
|
||||||
|
store.write_record("mud", "Welcome\nPrompt>")
|
||||||
|
store.write_record("command", "> look")
|
||||||
|
store.close
|
||||||
|
|
||||||
|
text = File.binread(path)
|
||||||
|
MeowTest.assert(text.include?("[2026-09-17T12:30:00+02:00] [mud] Welcome"))
|
||||||
|
MeowTest.assert(text.include?("[command] > look"))
|
||||||
|
MeowTest.assert_equal(1, store.list("profile-1").size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Transcript paths stay inside the transcript root") do
|
||||||
|
Dir.mktmpdir("meow-transcript") do |directory|
|
||||||
|
store = MeowClient::TranscriptStore.new(directory)
|
||||||
|
MeowTest.assert_raises(ArgumentError) { store.read(File.join(directory, "..", "outside.txt")) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
MeowTest.test("Transcript deletion removes one file and profile deletion removes the directory") do
|
||||||
|
Dir.mktmpdir("meow-transcript") do |directory|
|
||||||
|
store = MeowClient::TranscriptStore.new(directory)
|
||||||
|
first = store.start("profile", "One")
|
||||||
|
store.close
|
||||||
|
store.delete(first)
|
||||||
|
MeowTest.assert_empty(store.list("profile"))
|
||||||
|
|
||||||
|
store.start("profile", "Two")
|
||||||
|
store.close
|
||||||
|
store.delete_profile("profile")
|
||||||
|
MeowTest.assert_empty(store.list("profile"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user