From 90cb66233448d21b0ddc9d843d0b10e3f8d30147 Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 17 Sep 2026 23:43:47 +0200 Subject: [PATCH] Add profile automation and transcripts --- README.md | 14 +- __app.rb | 8 +- lib/meow/automation.rb | 339 ++++++++++++++++++++++++++++++++ lib/meow/automation_ui.rb | 403 ++++++++++++++++++++++++++++++++++++++ lib/meow/session.rb | 213 ++++++++++++++++---- lib/meow/transcript.rb | 112 +++++++++++ lib/meow/ui.rb | 15 +- lib/meow/version.rb | 2 +- test/automation_test.rb | 148 ++++++++++++++ test/run.rb | 2 + test/test_helper.rb | 5 + test/transcript_test.rb | 45 +++++ 12 files changed, 1253 insertions(+), 53 deletions(-) create mode 100644 lib/meow/automation.rb create mode 100644 lib/meow/automation_ui.rb create mode 100644 lib/meow/transcript.rb create mode 100644 test/automation_test.rb create mode 100644 test/transcript_test.rb diff --git a/README.md b/README.md index 88bc6ad..79da3af 100644 --- a/README.md +++ b/README.md @@ -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 - Bounded in-memory scrollback and speech queues - 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 - 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 @@ -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/session_gmcp.rb` integrates GMCP packages with accessible session views. - `lib/meow/text_pipeline.rb` handles ANSI filtering, decoding, lines, and prompts. -- `lib/meow/session.rb` owns connected-session state, scrollback, input, and speech. +- `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. ## Next steps @@ -60,9 +65,8 @@ Automated regression coverage now exercises the streaming Telnet parser and text Planned work, roughly in order: -1. Add aliases, triggers, timers, optional transcripts, and configurable command shortcuts. -2. Add multiple simultaneous sessions with independent output, media, and speech queues. -3. Add localization catalogs and package/signing automation for releases. +1. Add multiple simultaneous sessions with independent output, media, automation, transcripts, and speech queues. +2. Add localization catalogs and package/signing automation for releases. ## Running the tests diff --git a/__app.rb b/__app.rb index 27036a2..a6e5143 100644 --- a/__app.rb +++ b/__app.rb @@ -2,8 +2,8 @@ { "id": "51a92051-84e0-415f-84e3-98f08a320a15", "name": "Meow", - "version": "0.3.0", - "build_id": 5, + "version": "0.4.0", + "build_id": 6, "EltenAPIVersion": "3.0.3", "author": "Talon", "main_language": "en", @@ -23,9 +23,12 @@ require_relative "lib/meow/telnet" require_relative "lib/meow/text_pipeline" require_relative "lib/meow/media" require_relative "lib/meow/msp" +require_relative "lib/meow/automation" +require_relative "lib/meow/transcript" require_relative "lib/meow/transport" require_relative "lib/meow/session_gmcp" require_relative "lib/meow/session" +require_relative "lib/meow/automation_ui" require_relative "lib/meow/ui" class Meow < Program @@ -33,4 +36,3 @@ class Meow < Program MeowClient::UI.new(self).main end end - diff --git a/lib/meow/automation.rb b/lib/meow/automation.rb new file mode 100644 index 0000000..925fbcf --- /dev/null +++ b/lib/meow/automation.rb @@ -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 diff --git a/lib/meow/automation_ui.rb b/lib/meow/automation_ui.rb new file mode 100644 index 0000000..da47625 --- /dev/null +++ b/lib/meow/automation_ui.rb @@ -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 diff --git a/lib/meow/session.rb b/lib/meow/session.rb index 43a704d..67f8b8b 100644 --- a/lib/meow/session.rb +++ b/lib/meow/session.rb @@ -1,4 +1,7 @@ +require "uri" require_relative "session_gmcp" +require_relative "automation" +require_relative "transcript" module MeowClient class Session @@ -34,7 +37,24 @@ module MeowClient :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 @@ -42,7 +62,10 @@ module MeowClient @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 @@ -63,9 +86,10 @@ module MeowClient @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, @disconnect], + [@status, @output, @command, @send, @speech_button, @stop_speech, @gmcp_button, @media_button, @transcript_button, @disconnect], :quiet => true ) @form.accept_button = @send @@ -77,23 +101,22 @@ module MeowClient @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 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| 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 - }) + add_system_line(_("Connected to %{host} on port %{port}.") % {:host => @profile.host, :port => @profile.port}) end def tick @@ -102,7 +125,11 @@ module MeowClient 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 @@ -142,15 +169,12 @@ module MeowClient def handle_msp_action(action) case action.type - when :play - @media.play(action.request) - when :stop - @media.stop(action.request) + 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 - @media.report_error(action.error) + when :error then @media.report_error(action.error) end true rescue ArgumentError, URI::InvalidURIError => error @@ -159,14 +183,16 @@ module MeowClient end def receive_record(record) - text = record.to_s - append_line(text) - @sensitive_next = true if text.strip.match?(PASSWORD_PATTERN) + original = record.to_s + @sensitive_next = true if original.strip.match?(PASSWORD_PATTERN) apply_sensitive_flag - enqueue_speech(text) + result = @automation.process(original) + return if result.gagged + append_line(result.text, "mud") + enqueue_speech(result.text) end - def append_line(text) + def append_line(text, transcript_type = nil) value = text.to_s @lines << value @line_bytes += value.bytesize + 1 @@ -174,37 +200,46 @@ module MeowClient removed = @lines.shift.to_s @line_bytes -= removed.bytesize + 1 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 def add_system_line(text) - append_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 = text.encode(@profile.encoding) + 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 + 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 + return false end - sensitive = @sensitive_next unless sensitive - add_history(text) - append_line("> #{text}") unless @pipeline.remote_echo? + add_history(text.to_s) if source == nil + unless @pipeline.remote_echo? + prefix = source == nil ? "> " : "> [#{source}] " + append_line(prefix + value, "command") + end end - @command.set_text("") - @history_index = nil - set_sensitive(false, false) + true end def add_history(text) @@ -228,15 +263,25 @@ module MeowClient @command.focus end - def set_sensitive(value, announce = true) - @sensitive_next = value == true - apply_sensitive_flag - if announce - speak(@sensitive_next ? _("Sensitive input enabled.") : _("Sensitive input disabled.")) + 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 @@ -245,9 +290,8 @@ module MeowClient end def enqueue_speech(text) - return if text.strip == "" - chunks = split_speech(text) - chunks.each do |chunk| + return if text.to_s.strip == "" + split_speech(text).each do |chunk| if @speech_queue.size >= MAX_SPEECH @speech_queue.shift @speech_overflow += 1 @@ -296,6 +340,93 @@ module MeowClient @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.")) @@ -306,11 +437,13 @@ module MeowClient 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 - diff --git a/lib/meow/transcript.rb b/lib/meow/transcript.rb new file mode 100644 index 0000000..5e95ac7 --- /dev/null +++ b/lib/meow/transcript.rb @@ -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 diff --git a/lib/meow/ui.rb b/lib/meow/ui.rb index 0b6e91e..ddeb834 100644 --- a/lib/meow/ui.rb +++ b/lib/meow/ui.rb @@ -3,6 +3,8 @@ module MeowClient def initialize(program) @program = program @profiles = ProfileRepository.new(program) + @automation = Automation::Repository.new(program) + @transcripts = TranscriptStore.new(program.data_path("transcripts")) end def main @@ -17,6 +19,8 @@ module MeowClient edit_profile(profile) when :delete delete_profile(profile) + when :automation + AutomationUI.new(@program, profile).main else break end @@ -45,8 +49,9 @@ module MeowClient new_button = Button.new(_("New")) edit_button = Button.new(_("Edit")) delete_button = Button.new(_("Delete")) + automation_button = Button.new(_("Automation and transcripts")) 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.cancel_button = exit_button result = nil @@ -66,6 +71,7 @@ module MeowClient end edit_button.on(:press) { choose.call(:edit) } delete_button.on(:press) { choose.call(:delete) } + automation_button.on(:press) { choose.call(:automation) } exit_button.on(:press) do result = [:exit, nil] form.resume @@ -131,9 +137,11 @@ module MeowClient def delete_profile(profile) 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) - speak(_("Connection profile deleted.")) + @automation.delete_profile(profile.id) + @transcripts.delete_profile(profile.id) + speak(_("Connection profile, automation, and transcripts deleted.")) rescue Exception => error alert(_("Could not delete the connection profile: %{message}") % {:message => error.message}) end @@ -175,4 +183,3 @@ module MeowClient end end end - diff --git a/lib/meow/version.rb b/lib/meow/version.rb index b7fd055..951efda 100644 --- a/lib/meow/version.rb +++ b/lib/meow/version.rb @@ -1,3 +1,3 @@ module MeowClient - VERSION = "0.3.0" + VERSION = "0.4.0" end diff --git a/test/automation_test.rb b/test/automation_test.rb new file mode 100644 index 0000000..3967c42 --- /dev/null +++ b/test/automation_test.rb @@ -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 diff --git a/test/run.rb b/test/run.rb index c394160..047ca84 100644 --- a/test/run.rb +++ b/test/run.rb @@ -4,6 +4,8 @@ require_relative "gmcp_test" require_relative "media_test" require_relative "msp_test" require_relative "text_pipeline_test" +require_relative "automation_test" +require_relative "transcript_test" begin MeowTest.run! diff --git a/test/test_helper.rb b/test/test_helper.rb index a404833..5091280 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,8 @@ +module Kernel + def _(text) + text + end unless method_defined?(:_) +end module MeowTest Failure = Class.new(StandardError) diff --git a/test/transcript_test.rb b/test/transcript_test.rb new file mode 100644 index 0000000..e60372a --- /dev/null +++ b/test/transcript_test.rb @@ -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