Add profile automation and transcripts

This commit is contained in:
2026-09-17 23:43:47 +02:00
parent 573f1b7acb
commit 90cb662334
12 changed files with 1253 additions and 53 deletions
+9 -5
View File
@@ -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
+5 -3
View File
@@ -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
+339
View File
@@ -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
+403
View File
@@ -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
View File
@@ -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
+112
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
module MeowClient
VERSION = "0.3.0"
VERSION = "0.4.0"
end
+148
View File
@@ -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
+2
View File
@@ -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!
+5
View File
@@ -1,3 +1,8 @@
module Kernel
def _(text)
text
end unless method_defined?(:_)
end
module MeowTest
Failure = Class.new(StandardError)
+45
View File
@@ -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