#!/usr/bin/env ruby # # ========================================================================== # Outvoke -- version 0.0.99.20250830.3 # # written by cleemy desu wayo / Licensed under CC0 1.0 # # official repository: https://gitlab.com/cleemy-desu-wayo/outvoke # ========================================================================== # # * requirements: # * Ruby 3.0 or later # require 'rbconfig' require 'time' require 'yaml' require 'json' require 'bigdecimal' require 'bigdecimal/util' require 'bigdecimal/math' class Outvoke attr_accessor :version, :hooks, :hooks_first_time, :wait, :nodup_list, :mutex_nodup, :init_time, :ds_thread_group, :hookcc_thread_group, :default_output_mode attr_reader :os, :presets, :last_msgid def initialize @version = Struct.new(:branch, :body).new( '0.1', '0.0.99.20250830.3' ) @os = RbConfig::CONFIG["host_os"] @os.define_singleton_method(:uptime) do Process.clock_gettime(Process::CLOCK_MONOTONIC) end @ds = Hash.new @is_ds_locked = false @is_preset_locked = false @hooks = [] @hooks_first_time = [] @wait = 0.5 @mutex_msgid = Mutex.new @last_msgid = 0 @hookcnt = 0 @nodup_list = Hash.new @mutex_nodup = Mutex.new @init_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) @ds_thread_group = ThreadGroup.new @hookcc_thread_group = ThreadGroup.new @default_output_mode = nil @is_quiet_mode = false @builtin_ds_list = [ OutvokeDataSourceIo, OutvokeDataSourceStdin, OutvokeDataSourceAuxinFifo, OutvokeDataSourceLoopback, OutvokeDataSourceErr, OutvokeDataSourceWeb, OutvokeDataSourceOsc, OutvokeDataSourceVrchat, OutvokeDataSourceEverySec, ] @presets = OutvokePresetList.new(self) end # # this returns a copy, so you can't write like this: # $outvoke.ds["dstest"] = newds # def ds @ds.dup end alias_method :sources, :ds # sources is deprecated def [](str) @ds[str] end def <<(ds) if @is_ds_locked && !@builtin_ds_list.include?(ds.class) raise "ERROR: invalid ds_label (ds is locked)" if ds.label !~ /\A_[a-z][-_:.@=a-z0-9]*\z/ else raise "ERROR: invalid ds_label" if ds.label !~ /\A[-_a-z0-9][-_:.@=a-z0-9]*\z/ end @ds[ds.label] = ds end def ds_locked? !!@is_ds_locked end def ds_lock @is_ds_locked = true end def quiet_mode? @is_quiet_mode end def quiet_mode=(x) @is_quiet_mode = x end def preset=(mode) @ds.each do |ds_label, ds| next if ds.protected_preset? ds.output_mode = mode end end alias_method :output_mode=, :preset= def preset_locked? !!@is_preset_locked end def preset_lock @is_preset_locked = true end def uptime Process.clock_gettime(Process::CLOCK_MONOTONIC) - @init_time end alias_method :elapsed, :uptime # elapsed is deprecated # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # def nodup(time, cond) unless block_given? @mutex_nodup.synchronize do register_nodup(time, cond) end return end is_nodup = false @mutex_nodup.synchronize do is_nodup = sweep_and_check_nodup(cond) register_nodup(time, cond) end return unless is_nodup yield time, cond end def nodup?(x) @mutex_nodup.synchronize do sweep_and_check_nodup(x) end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # def loputs(body = "", attachment = nil) @ds['lo'] << { "body" => body.to_s.dup, "attachment" => attachment } nil end def mainloop loop do enabled_ds_list = [] @ds.each do |ds_label, ds| next unless ds.enabled? enabled_ds_list << ds ds.before_each_event ds.each_event do |event| hooks = @hooks @hookcnt = 0 if ds.is_first_time_log_check ds.first_time_log_check(ds, event) hooks = @hooks_first_time end hooks.each do |hook| hooks_exec(ds, hook, event) end end ds.after_each_event end # when there is no enabled DS or only "stdin" is enabled, terminate Outvoke itself if necessary if enabled_ds_list.length == 1 enabled_ds_list[0]&.then do |ds| if ds.is_a?(OutvokeDataSourceStdin) if ds.eof_reached? && ds.log_lines.empty? && ds.io_new_lines.empty? enabled_ds_list = [] end end end end if enabled_ds_list.empty? if (ThreadGroup::Default.list - [Thread.current]).empty? if $outvoke.hookcc_thread_group.list.empty? exit 0 end end end sleep @wait end end def register_proc(ds_cond, pattern, hookby, &block) hooks = @hooks if hookby.end_with?("ft") hooks = @hooks_first_time end extract_ds(ds_cond).each do |ds_label| @ds[ds_label].enabled = true hooks << { 'ds_label' => ds_label, 'pattern' => pattern, 'hookby' => hookby, 'proc' => block } end end # extracts only the labels of the appropriate data sources from $sources def extract_ds(ds_cond) return @ds.keys if ds_cond == true ds_cond_array = [ds_cond] if ds_cond.respond_to?(:each) ds_cond_array = ds_cond end result = [] ds_cond_array.each do |label_cond| next unless label_cond.respond_to?(:===) # TODO (automatically generate a new data source) if label_cond.is_a?(String) label_cond.match(/\Aauxin:(fifo)?:([1-9]+[0-9]*)\z/)&.then do |m| new_ds_label = "auxin:fifo:#{m[2]}" unless @ds[new_ds_label] @ds[new_ds_label] = OutvokeDataSourceAuxinFifo.new(self, new_ds_label) end end end @ds.keys.each do |label| result << label if label_cond === label end end result.uniq end def generate_new_msgid new_msgid = nil @mutex_msgid.synchronize do @last_msgid += 1 new_msgid = @last_msgid end new_msgid end def read_env_settings if ENV["OUTVOKE_QUIET_MODE"] == "1" @is_quiet_mode = true end ENV["OUTVOKE_OUTPUT_MODE"]&.then do @default_output_mode = _1 end ENV["OUTVOKE_WAIT"]&.then do @wait = _1.to_f end end # basically, var_list is assumed to be an array of arrays def self.set_global_vars(var_list) return unless var_list.respond_to?(:each) var_list.each do |var_info| next unless var_info.respond_to?(:to_a) var_info.to_a.then do |var_name, value| next if ["stdin", "stdout", "stderr", "outvoke", "o", "ds"].include?(var_name) if var_name.ascii_only? && var_name =~ /\A[a-z][_a-z0-9]*\z/ OUTVOKE_VAR[var_name] = value end end end end def self.deep_copy(obj) YAML.unsafe_load(obj.to_yaml) end private # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # def register_nodup(time, obj) expiration_time = Time.now + time @nodup_list.each_key do |key| @nodup_list[key] = expiration_time if key === obj end @nodup_list[obj] = expiration_time end def sweep_and_check_nodup(x) is_nodup = true tmp_now = Time.now # sweep (remove expired items) @nodup_list = @nodup_list.reject do |_, exp| tmp_now > exp end # immediately after sweep, the list can be considered as a simple blocklist @nodup_list.each_key do |key| if key === x is_nodup = false break end end is_nodup end def hooks_exec(ds, hook, event) # check data source return unless hook['ds_label'] == ds.label patterns = [hook['pattern']] if hook['pattern'].respond_to?(:each) patterns = hook['pattern'] end event.is_quiet_mode = @is_quiet_mode # TODO (is_quiet_mode is deprecated) event.status.quiet_mode = @is_quiet_mode proc_exec_context = nil # for instance_exec # check condition patterns.detect do |pattern| event.m = nil if pattern == true event.m = [event.body] elsif pattern.is_a?(String) event.m = [event.body] if event.body.start_with?(pattern) elsif pattern.respond_to?(:match) event.m = pattern.match(event.body) elsif pattern.respond_to?(:call) proc_result = nil proc_exec_context = ds.generate_context(event) if pattern.arity == 0 proc_result = proc_exec_context.instance_exec(&pattern) elsif pattern.arity == 1 proc_result = proc_exec_context.instance_exec(event, &pattern) end if proc_result if proc_result.respond_to?(:each) event.m = proc_result.each elsif proc_result.respond_to?(:to_a) event.m = proc_result.to_a else event.m = [proc_result] end end end event.m end return unless event.m event.m = event.m.to_a # with YAML.unsafe_load and to_yaml in mind event.hookby = hook["hookby"] @hookcnt += 1 event.hookcnt = @hookcnt hook_result = nil hook_result_for_post_process = nil # pre process ds.pre_procs.each do |pr| pr.call(event) end # execute if hook['proc'] proc_exec_context ||= ds.generate_context(event) hook_result = proc_exec_context.instance_exec(event, &hook['proc']) else hook_result_for_post_process = event end if hook_result == true hook_result_for_post_process = event elsif hook_result.nil? || hook_result == false # nop else hook_result_for_post_process = hook_result end # post process ds.post_procs.each do |pr| proc_exec_context ||= ds.generate_context(event) if pr.arity == 2 hook_result = proc_exec_context.instance_exec(event, hook_result_for_post_process, &pr) elsif pr.arity == 3 hook_result = proc_exec_context.instance_exec(event, hook_result_for_post_process, event.hookby, &pr) else raise "ERROR: invalid Proc was found in post_procs (ds: \"#{ds.label}\", pr.arity: #{pr.arity})" end end end end class OutvokePresetList include Enumerable def initialize(outvoke) @outvoke = outvoke @preset_list = Hash.new end def <<(obj) if @outvoke.preset_locked? raise "ERROR: invalid preset label (preset is locked)" if obj.label !~ /\A_[a-z][-_:.@=a-z0-9]*\z/ else raise "ERROR: invalid preset label" if obj.label !~ /\A[-_a-z0-9][-_:.@=a-z0-9]*\z/ end @preset_list[obj.label] = obj end def [](label) @preset_list[label] end def each return @preset_list.each.enum_for unless block_given? @preset_list.each {|x| yield x} end end module OutvokeDSL refine Kernel do def hook(ds_cond, pattern = true, &block) $outvoke.register_proc(ds_cond, pattern, "hook", &block) end def hookft(ds_cond, pattern = true, &block) $outvoke.register_proc(ds_cond, pattern, "hookft", &block) end # if block is given, execute in another thread (concurrent computing) def hookcc(ds_cond, pattern = true, &block) register_hookcc(ds_cond, pattern, "hookcc", &block) end # if block is given, execute in another thread (concurrent computing) def hookccft(ds_cond, pattern = true, &block) register_hookcc(ds_cond, pattern, "hookccft", &block) end def register_hookcc(ds_cond, pattern, hookby, &block) $outvoke.register_proc(ds_cond, pattern, hookby) do |e| # to_yaml and YAML.unsafe_load are used for deep copy # a Proc registered in Outvoke's @hooks is expected to be executed with instance_exec # therefore, this @event is an instance variable of OutvokeProcExecContext object. @event = Outvoke.deep_copy(e) $outvoke.hookcc_thread_group.add Thread.start { if block_given? Thread.current["hookcc_result"] = instance_exec(@event, &block) if Thread.current["hookcc_result"] == true Thread.current["hook_result_for_post_process"] = @event elsif Thread.current["hookcc_result"].nil? || Thread.current["hookcc_result"] == false Thread.current["hook_result_for_post_process"] = nil else Thread.current["hook_result_for_post_process"] = Thread.current["hookcc_result"] end else Thread.current["hook_result_for_post_process"] = @event end # post process @ds.post_procs.each do |pr| if pr.arity == 2 instance_exec(@event, Thread.current["hook_result_for_post_process"], &pr) elsif pr.arity == 3 instance_exec(@event, Thread.current["hook_result_for_post_process"], hookby, &pr) else raise "ERROR: invalid Proc was found in post_procs (ds: \"#{@ds.label}\", pr.arity: #{pr.arity})" end end } nil end end def hooklo(pattern = true, &block) hook("lo", pattern, &block) end def hookio(pattern = true, &block) hook("io", pattern, &block) end def hookstd(pattern = true, &block) hook("stdin", pattern, &block) end def hookerr(pattern = true, &block) hook("err", pattern, &block) end def hookaux(pattern = true, &block) hook("auxin:fifo:1", pattern, &block) end def hooksec(pattern = true, &block) hook("every-sec", pattern, &block) end def hookweb(pattern = true, &block) hook("web-001", pattern, &block) end def hookosc(pattern = true, &block) hook("osc-001", pattern, &block) end def hookvr(pattern = true, &block) hook(/^vr/, pattern, &block) end def loputs(x = "", attachment = nil) $outvoke.loputs(x, attachment) end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # def nodup(time, cond) raise '$outvoke is not an Outvoke object' unless $outvoke.is_a?(Outvoke) if block_given? $outvoke.nodup(time, cond) {|time, cond| yield time, cond } else $outvoke.nodup(time, cond) end end def nodup?(x) raise '$outvoke is not an Outvoke object' unless $outvoke.is_a?(Outvoke) $outvoke.nodup?(x) end def newlo(x) OutvokeDataSourceLoopback.new($outvoke, x) end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # refine BasicObject do def to(ds_cond, attachment = nil) $outvoke.extract_ds(ds_cond).map{ $outvoke.ds[_1] }.each do |ds| ds << { "body" => self.to_s.dup, "attachment" => attachment } end nil end end end using OutvokeDSL # # this class provided only for instance_exec # class OutvokeProcExecContext def initialize(outvoke, ds, event) @outvoke = outvoke @ds = ds @event = event @event_body = @event.body.dup end def to(ds_cond, attachment = nil) @outvoke.extract_ds(ds_cond).map{ @outvoke.ds[_1] }.each do |ds| ds << { "body" => @event_body.to_s.dup, "attachment" => attachment } end nil end def e = @event def o = @outvoke def os = @outvoke.os def loputs(x = @event) @outvoke["lo"] << { "body" => x.to_s.dup, "attachment" => nil } nil end end class OutvokeEvent < String attr_accessor :status, :msgid, :time, :attachment, :m attr_accessor :hookby, :hookcnt attr_accessor :is_quiet_mode # deprecated def initialize(x) super x.to_s.dup @time = Time.now @hookcnt = 0 end def body = self.to_s end class OutvokeDataSource attr_accessor :label, :status, :log_lines, :mutex_lo, :is_first_time_log_check, :pre_procs, :post_procs attr_reader :recommended_label_format, :lo_data def initialize(outvoke, label) @outvoke = outvoke @label = label @recommended_label_format ||= nil @status = nil @log_lines = [] @lo_data = [] @mutex_lo = Mutex.new @is_first_time_log_check = false @is_enabled = false raise "ERROR: invalid label \"#{label}\"".gsub("\n", " ") unless valid_label? # # Proc objects for pre process # (Procs to process before the execution of a Proc registered by hook) # @pre_procs = [] # # Proc objects for post process # (Procs to process the value returned by Proc registered by hook) # @post_procs = [] @post_procs << @outvoke.presets["terminal"].generate_proc @is_protected_preset = false @outvoke << self end def valid_label? return true unless @recommended_label_format @recommended_label_format === @label end def enabled? @is_enabled end def enabled=(x) @is_enabled = !!x end def protected_preset? !!@is_protected_preset end def <<(x) new_msgid = @outvoke.generate_new_msgid x["msgid"] = new_msgid @mutex_lo.synchronize do @lo_data << x end new_msgid end def output_mode @status.output_mode end def preset=(mode) raise "ERROR: invalid mode" if (!mode) || mode.empty? || mode == "+" is_mode_reset = false procs = [] if mode.is_a?(Array) procs[0] = Hash.new procs[0]["proc_label"] = mode[0].to_s procs[0]["mode_str"] = mode.join(":") procs[0]["args"] = mode.drop(1) if procs[0]["proc_label"].start_with?("+") procs[0]["proc_label"] = procs[0]["proc_label"].sub("+", "") else is_mode_reset = true end else is_mode_reset = true if not mode.start_with?("+") mode.split("+").each_with_index do |label, i| next if label == "" procs << { "proc_label" => label, "mode_str" => label, "args" => [], } end end if is_mode_reset @post_procs = [] @status.output_mode = "" end procs.each do |pr| next unless pr next unless pr["proc_label"] next unless pr["proc_label"] != "" if @outvoke.presets[pr["proc_label"]].respond_to?(:generate_proc) @post_procs << @outvoke.presets[pr["proc_label"]].generate_proc(*pr["args"]) if @status.output_mode == "" @status.output_mode = pr["mode_str"] else @status.output_mode += "+#{pr["mode_str"]}" end else raise "ERROR: @outvoke.presets[\"#{pr["proc_label"]}\"] is not callable" end end end alias_method :output_mode=, :preset= def each_event raise NotImplementedError end def before_each_event raise NotImplementedError end def after_each_event raise NotImplementedError end def first_time_log_check raise NotImplementedError end def generate_context(event) OutvokeProcExecContext.new(@outvoke, self, event) end end StructDSIoStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time) class OutvokeDataSourceIo < OutvokeDataSource attr_accessor :rs, :create_enum_proc attr_reader :io_new_lines def initialize(outvoke, label) if self.class == OutvokeDataSourceIo @recommended_label_format = /\Aio/ end super @status = StructDSIoStatus.new @status.last_time = Time.now.floor self.output_mode = "terminal" @rs = $/ # record separator @io = nil @io_enum = nil @thread_io = nil @io_new_lines = [] @mutex_io = Mutex.new @create_enum_proc = ->(io){ io.each(rs = @rs, chomp: true) } @is_eof_reached = false end def enabled=(x) return true if @is_enabled == true && x @is_enabled = !!x return false unless @is_enabled server_start true end def io @io end def io=(x) raise "ERROR: not Enumerable" unless x.is_a?(Enumerable) @io = x end def eof_reached? !!@is_eof_reached end def server_start tmp = instance_exec(@io, &@create_enum_proc) raise "ERROR: not Enumerator" unless tmp.is_a?(Enumerator) @io_enum = tmp @thread_io = Thread.start do loop do Thread.stop unless @is_enabled begin Thread.current["tmp"] = @io_enum.next rescue StopIteration @is_eof_reached = true break end Thread.stop unless @is_enabled Thread.current["new_msgid"] = @outvoke.generate_new_msgid @mutex_io.synchronize do @io_new_lines << { "msgid" => Thread.current["new_msgid"], "body" => Thread.current["tmp"] } end end end @outvoke.ds_thread_group.add @thread_io end def each_event @log_lines.each do |line| event = OutvokeEvent.new(line["body"]) event.msgid = line["msgid"] event.status = @status event.status.now = event.time yield event @status.last_time = event.time end self end def before_each_event @log_lines = [] return unless @is_enabled @mutex_io.synchronize do @log_lines = @io_new_lines @io_new_lines = [] end end def after_each_event nil end def first_time_log_check(ds, event) nil end end class OutvokeDataSourceStdin < OutvokeDataSourceIo def initialize(outvoke, label) super if self.class == OutvokeDataSourceStdin @recommended_label_format = /\Astdin/ end @io = $stdin end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # StructDSAuxinStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time) class OutvokeDataSourceAuxinFifo < OutvokeDataSource def initialize(outvoke, label) if self.class == OutvokeDataSourceAuxinFifo @recommended_label_format = /\Aauxin/ end super @status = StructDSAuxinStatus.new @status.last_time = Time.now.floor self.output_mode = "terminal" @new_lines = [] @mutex_new_lines = Mutex.new m = label.match(/\Aauxin:fifo:([1-9]+[0-9]*)\z/) @fifo_file_name = "./.outvoke.auxin.#{m[1]}.fifo" @thread_auxin = Thread.start do Thread.stop loop do next if File.symlink?(@fifo_file_name) @fifo_file = File.open(@fifo_file_name).each_line loop do Thread.stop unless @is_enabled Thread.current["tmp"] = @fifo_file.next Thread.stop unless @is_enabled Thread.current["new_msgid"] = @outvoke.generate_new_msgid @mutex_new_lines.synchronize do @new_lines << { "msgid" => Thread.current["new_msgid"], "body" => Thread.current["tmp"].chomp } end end end end @outvoke.ds_thread_group.add @thread_auxin end def enabled=(x) return true if @is_enabled == true && x @is_enabled = !!x return false unless @is_enabled sleep 0.05 # TODO # create fifo file (named pipe) unless File.pipe?(@fifo_file_name) unless File.exist?(@fifo_file_name) File.mkfifo(@fifo_file_name, 0606) end end unless File.pipe?(@fifo_file_name) @is_enabled = false return end tmp_status = @thread_auxin.status if tmp_status == "run" || tmp_status == "sleep" begin @thread_auxin.run rescue ThreadError return false end end true end def each_event @log_lines.each do |line| event = OutvokeEvent.new(line["body"]) event.msgid = line["msgid"] event.status = @status event.status.now = event.time yield event @status.last_time = event.time end self end def before_each_event @log_lines = [] return unless @is_enabled @mutex_new_lines.synchronize do @log_lines = @new_lines @new_lines = [] end end def after_each_event nil end def first_time_log_check(ds, event) nil end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # StructDSLoopbackStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time) class OutvokeDataSourceLoopback < OutvokeDataSource def initialize(outvoke, label) if self.class == OutvokeDataSourceLoopback @recommended_label_format = /\Alo/ end super @status = StructDSLoopbackStatus.new @status.last_time = Time.now.floor self.output_mode = "terminal" end def each_event @log_lines.each do |line| event = OutvokeEvent.new(line["body"]) event.msgid = line["msgid"] event.attachment = line["attachment"] event.status = @status event.status.now = event.time yield event @status.last_time = event.time end self end def before_each_event @log_lines = [] @mutex_lo.synchronize do @log_lines = @lo_data @lo_data = [] end end def after_each_event nil end def first_time_log_check(ds, event) nil end end class OutvokeDataSourceErr < OutvokeDataSourceLoopback def initialize(outvoke, label) if self.class == OutvokeDataSourceErr @recommended_label_format = /\Aerr/ end super self.output_mode = "warn" @is_protected_preset = true end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # StructDSWebStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time, :ds_label) class OutvokeEventWeb < OutvokeEvent attr_accessor :req, :get, :post, :res end class OutvokeDataSourceWeb < OutvokeDataSource attr_accessor :port, :document_root, :mount_proc_dir, :timeout attr_reader :first_res_list def initialize(outvoke, label) if self.class == OutvokeDataSourceWeb @recommended_label_format = /\Aweb/ end super @status = StructDSWebStatus.new @status.last_time = Time.now.floor self.output_mode = "web" @websrv = nil @port = 8080 @document_root = nil @mount_proc_dir = "/" @timeout = 5 @first_res_list = Hash.new @mutex_first_res = Mutex.new end # TODO def enabled=(x) @is_enabled = !!x end def websrv_start require 'uri' require 'webrick' websrv = WEBrick::HTTPServer.new({:Port => @port, :DocumentRoot => @document_root}) if @mount_proc_dir websrv.mount_proc @mount_proc_dir do |req, res| req.query # TODO msgid = self << { "body" => req.path.to_s.dup, "attachment" => [req, res] } res_found = false loop_start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) loop do break if loop_start_time + @timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC) # TODO (delete from @first_res_list when done) tmp_res = nil @mutex_first_res.synchronize do tmp_res = @first_res_list[msgid] end if tmp_res if res == tmp_res # nop elsif tmp_res.is_a?(WEBrick::HTTPResponse) # maybe hookcc res.body = tmp_res.body tmp_res.header.each do |key, value| res.header[key] = value end else res.body = tmp_res.to_s res.body += "\n" if res.body[-1] != "\n" end res_found = true break end end unless res_found res.status = 500 res.body = "error\n" end end end @outvoke.ds_thread_group.add Thread.start { websrv.start } end def each_event @log_lines.each do |line| event = OutvokeEventWeb.new(line["body"]) event.msgid = line["msgid"] event.attachment = line["attachment"] event.req = line["attachment"][0] event.res = line["attachment"][1] if event.req.request_method == "GET" event.get = event.req.query event.post = Hash.new elsif event.req.request_method == "POST" event.get = Hash[URI::decode_www_form(event.req.request_uri.query)] event.post = event.req.query end event.status = @status event.status.now = event.time event.status.ds_label = @label yield event @status.last_time = event.time end self end def before_each_event @log_lines = [] @mutex_lo.synchronize do @log_lines = @lo_data @lo_data = [] end end def after_each_event nil end def first_time_log_check(ds, event) nil end def register_first_res(msgid, res) return if res.nil? @mutex_first_res.synchronize do if @first_res_list[msgid].nil? @first_res_list[msgid] = res end end end def generate_context(event) context = OutvokeProcExecContext.new(@outvoke, self, event) context.extend(Module.new do def res(hook_result) @ds.register_first_res(@event.msgid, hook_result) end end) end end # # TODO: experimental and incomplete implementation # # (specifications are subject to sudden change without notice) # StructDSOscStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time, :ds_label) class OutvokeEventOsc < OutvokeEvent attr_accessor :args, :tags, :ip, :port def addr = self.to_s end class OutvokeDataSourceOsc < OutvokeDataSource attr_accessor :port def initialize(outvoke, label) if self.class == OutvokeDataSourceOsc @recommended_label_format = /\Aosc/ end super @status = StructDSOscStatus.new @status.last_time = Time.now.floor self.output_mode = "terminal" @thread_osc = nil @port = 9001 @new_msgs = [] @mutex_osc = Mutex.new end # TODO def enabled=(x) @is_enabled = !!x end def server_start require 'osc-ruby' require 'osc-ruby/em_server' oscsrv = OSC::EMServer.new(@port) oscsrv.add_method(nil) do |oscmsg| @mutex_osc.synchronize do @new_msgs << { "msgid" => @outvoke.generate_new_msgid, "body" => oscmsg.address.chomp, "args" => oscmsg.to_a, "tags" => oscmsg.tags, "ip" => oscmsg.ip_address, # sender info "port" => oscmsg.ip_port, # sender info } end end @thread_osc = Thread.start { oscsrv.run } @outvoke.ds_thread_group.add @thread_osc end def each_event @log_lines.each do |line| event = OutvokeEventOsc.new(line["body"]) event.msgid = line["msgid"] event.args = line["args"] event.tags = line["tags"] event.ip = line["ip"] event.port = line["port"] event.status = @status event.status.now = event.time yield event @status.last_time = event.time end self end def before_each_event @log_lines = [] return unless @is_enabled @mutex_osc.synchronize do @log_lines = @new_msgs @new_msgs = [] end end def after_each_event nil end end StructDSVrchatStatus = Struct.new(:now, :quiet_mode, :output_mode, :logfile, :file_size, :last_joined_time, :elapsed, :count, :lineno) class OutvokeEventVrchat < OutvokeEvent attr_accessor :raw, :type end class OutvokeDataSourceVrchat < OutvokeDataSource attr_accessor :log_dir def initialize(outvoke, label) if self.class == OutvokeDataSourceVrchat @recommended_label_format = /\Avrc/ end super @status = StructDSVrchatStatus.new @status.logfile = nil @status.file_size = 0 @status.count = 0 @status.lineno = 0 @status.last_joined_time = Time.now self.output_mode = "terminal" @is_first_time_log_check = true @log_dir = '' if @outvoke.os == "linux" @log_dir = "#{Dir.home}/.steam/debian-installation/steamapps/compatdata/438100/pfx/drive_c/users/steamuser/AppData/LocalLow/VRChat/VRChat" end @has_new_log_lines = false end def each_event return unless @has_new_log_lines loop do cnt = 0 line_raw = '' m = nil @log_lines[@status.lineno..].each do |line| cnt += 1 line_raw = line m = line.chomp.match(/\A([0-9]{4}\.[0-9]{2}\.[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}) +([-_a-zA-Z0-9]+) +(.*)\z/) break if m # if found end @status.lineno += cnt @has_new_log_lines = false return self unless m event = OutvokeEventVrchat.new(m[3].sub(/\A *- */, '')) event.raw = line_raw event.time = Time.parse(m[1]) # unlike event.status.now, a value taken from log data event.type = m[2] event.msgid = @outvoke.generate_new_msgid event.status = @status event.status.now = Time.now if event.body.start_with?('[Behaviour] Joining or Creating Room: ') event.status.last_joined_time = event.time end event.status.elapsed = event.status.now - event.status.last_joined_time yield event end self end def before_each_event tmp_now = Time.now has_file_changed = false # check if it is a new file Dir.glob("#{@log_dir}/output_log_*.txt").last.then do if @status.logfile != _1 has_file_changed = true @status.logfile = _1 @status.lineno = 0 if not $outvoke.quiet_mode? # TODO puts "[#{tmp_now}] [outvoke-system] #{@label}: a new log file was found." if @status.logfile end end end # return if not found unless @status.logfile if @is_first_time_log_check puts "[#{tmp_now}] [outvoke-system] #{@label}: ERROR: VRChat log file not found" end return end # return if not readable return unless File.readable?(@status.logfile) # check if file size has increased unless has_file_changed if @status.file_size < File.size(@status.logfile) has_file_changed = true end end # return if the log file has not changed return unless has_file_changed if @is_first_time_log_check tmp = @status.logfile.sub( /\A.*output_log_([0-9]{4}-[0-9]{2}-[0-9]{2})_([0-9]{2})-([0-9]{2})-([0-9]{2})\.txt\z/, "\\1 \\2:\\3:\\4") @status.last_joined_time = Time.parse(tmp) end # read VRChat log file File.read(@status.logfile).then do |file_body| @log_lines = file_body.split("\n") @status.file_size = file_body.bytesize end @has_new_log_lines = true end def after_each_event if @is_first_time_log_check if not $outvoke.quiet_mode? # TODO puts "[#{Time.now}] [outvoke-system] #{@label}: first time log check has done." end @is_first_time_log_check = false end end def first_time_log_check(ds, event) return unless event.body.start_with?('[B') if event.body.start_with?('[Behaviour] Entering Room: ') @status.count = 0 elsif event.body.start_with?('[Behaviour] OnPlayerJoined ') @status.count += 1 elsif event.body.start_with?('[Behaviour] OnPlayerLeft ') @status.count -= 1 @status.count = 0 if @status.count < 0 end end end StructDSEverySecStatus = Struct.new(:now, :quiet_mode, :output_mode, :last_time) class OutvokeEventEverySec < OutvokeEvent def year = time.year def month = time.month def day = time.day def hour = time.hour def min = time.min def sec = time.sec end class OutvokeDataSourceEverySec < OutvokeDataSource def initialize(outvoke, label) if self.class == OutvokeDataSourceEverySec @recommended_label_format = /\A(every-sec|sec)/ end super @status = StructDSEverySecStatus.new @status.last_time = Time.now.floor self.output_mode = "terminal" end def each_event @log_lines.each do |line| event = OutvokeEventEverySec.new(line["body"]) event.time = line["body"] event.msgid = line["msgid"] event.status = @status event.status.now = Time.now yield event @status.last_time = line["body"] end self end def before_each_event @log_lines = [] tmp_time = @status.last_time.floor loop do tmp_time += 1 return if tmp_time >= Time.now @log_lines << {"msgid" => @outvoke.generate_new_msgid, "body" => tmp_time} end end def after_each_event nil end def first_time_log_check(ds, event) nil end end class OutvokePrePostProc attr_reader :label def initialize(outvoke, label) @outvoke = outvoke @label = label @outvoke.presets << self end def generate_proc raise NotImplementedError end end class OutvokePrePostProcNop < OutvokePrePostProc def generate_proc(*_args) ->(_e, hook_result) { hook_result } end end class OutvokePrePostProcWarn < OutvokePrePostProc def generate_proc(*_args) ->(_e, hook_result) { tmp = hook_result if not hook_result.is_a?(String) tmp = hook_result.inspect end warn "[#{Time.now}] #{tmp}".gsub("\n", " ") hook_result } end end class OutvokePrePostProcSimpleOutput < OutvokePrePostProc def initialize(outvoke, label) super @proc_output = ->(obj) { puts obj } end def get_time_str ->(e, hookby){ time_str = "" if hookby =~ /\Ahook(ft)?\z/ time_str = e.time.to_s elsif hookby =~ /\Ahookcc(ft)?\z/ time_str = Time.now.to_s end time_str } end end class OutvokePrePostProcTerminal < OutvokePrePostProcSimpleOutput def generate_proc(*args) proc_get_time_str = get_time_str proc_output = @proc_output if args[0].respond_to?(:call) proc_output = args[0] end ->(e, hook_result, hookby) { return hook_result unless hook_result hookby ||= "hook" if e.status.quiet_mode tmp = hook_result.to_s else time_str = instance_exec(e, hookby, &proc_get_time_str) tmp = "[#{time_str}] #{hook_result}".gsub("\n", " ") end instance_exec(tmp, &proc_output) hook_result } end end class OutvokePrePostProcModernFormat < OutvokePrePostProcSimpleOutput def generate_proc(*args) proc_get_time_str = get_time_str proc_generate_output_str = @generate_output_str proc_output = @proc_output if args[0].respond_to?(:call) proc_output = args[0] end ->(e, hook_result, hookby) { return hook_result unless hook_result hookby ||= "hook" if hook_result.is_a?(Hash) && hook_result["time"].nil? && hook_result[:time].nil? hook_result["time"] = instance_exec(e, hookby, &proc_get_time_str) end output_str = instance_exec(hook_result, &proc_generate_output_str) instance_exec(output_str, &proc_output) hook_result } end end class OutvokePrePostProcJsonl < OutvokePrePostProcModernFormat def initialize(outvoke, label) super @generate_output_str = ->(obj) { obj.to_json } end end class OutvokePrePostProcYaml < OutvokePrePostProcModernFormat def initialize(outvoke, label) super @generate_output_str = ->(obj) { obj.to_yaml } end end class OutvokePrePostProcOsc < OutvokePrePostProc def generate_proc(*args) require 'osc-ruby' if args.length == 0 || args[0].nil? || args[0] == "" osc_server_addr = "localhost" else osc_server_addr = args[0].to_s end if args.length <= 1 || args[1].nil? || args[1] == "" osc_server_port = 9000 else osc_server_port = args[1].to_i end ->(_e, hook_result, _hookby) { return hook_result unless hook_result return hook_result unless hook_result.respond_to?(:each) osc_client = OSC::Client.new(osc_server_addr, osc_server_port) # hook_result may be overwritten by other threads copied_hook_result = Outvoke.deep_copy(hook_result) Thread.start do osc_msgs = [copied_hook_result] if copied_hook_result.each.first.is_a?(Array) osc_msgs = copied_hook_result end osc_msgs.each do |msg| next unless msg next unless msg.respond_to?(:to_a) tmp_msg = msg.to_a # only sleep if tmp_msg[0].nil? if tmp_msg.length >= 2 && tmp_msg[-1]&.respond_to?(:to_f) sleep [tmp_msg[-1].to_f, 0].max end next end sleep_sec = 0 if tmp_msg.length >= 3 sleep_sec = tmp_msg.pop end begin osc_client.send(OSC::Message.new(*tmp_msg.flatten)) rescue => err "[preset osc] #{err.class} -- #{err}".to "err" end if sleep_sec&.respond_to?(:to_f) sleep [sleep_sec.to_f, 0].max end end end copied_hook_result } end end class OutvokePrePostProcWeb < OutvokePrePostProc def generate_proc(*_args) ->(e, hook_result) { @ds.register_first_res(e.msgid, hook_result) hook_result } end end # -------------------------------------------------------------------- # from this point forward, it will only be executed when this file run # as a command, not when this file included by require. if $0 == __FILE__ $stdout.sync = true def log_puts(str, line_head = '') return if $outvoke.quiet_mode? if line_head == '' puts str.gsub("\n", " ") else puts "#{line_head}#{str.gsub("\n", "\n" + line_head)}" end end $outvoke = Outvoke.new OUTVOKE_VAR = Hash.new version_str = "#{$outvoke.version.branch} (version #{$outvoke.version.body})" $outvoke.read_env_settings var_list = ENV.filter_map do |key, value| if key =~ /\AOUTVOKE_VAR_[A-Z][_A-Z0-9]*\z/ [key.sub(/\AOUTVOKE_VAR_/, "").downcase, value] end end Outvoke.set_global_vars(var_list) require 'optparse' is_version_puts_mode = false ruby_code = nil opts = OptionParser.new opts.default_argv = ['main.rb'] opts.on('-v', '--version') { is_version_puts_mode = true } opts.on('-e CODE') { |optvalue| ruby_code = optvalue } opts.on('-r LIBRARY') { |optvalue| require optvalue } opts.on('-q', '--quiet') { $outvoke.quiet_mode = true } opts.on('-w INTERVAL', '--wait INTERVAL') do |optvalue| raise "--wait option allows only numeric" if optvalue !~ /\A[0-9]+(\.[0-9]+)?\z/ $outvoke.wait = optvalue.to_f end opts.on('-p OUTPUT_MODE', '--preset OUTPUT_MODE', '--output-mode OUTPUT_MODE') do |optvalue| if $outvoke.default_output_mode if optvalue.start_with?("+") $outvoke.default_output_mode += optvalue else $outvoke.default_output_mode = optvalue end else $outvoke.default_output_mode = optvalue end if ["jsonl", "yaml"].include?(optvalue) $outvoke.quiet_mode = true end end opts.on('-V VAR:VALUE', '--var VAR:VALUE') do |optvalue| if optvalue =~ /\A[a-z][_a-z0-9]*:/ Outvoke.set_global_vars([optvalue.split(":", 2)]) else raise "--var option only allows the format varname:value" end end # parse options (from environment) opts.environment("OUTVOKE_OPT") # parse options if ARGV[0] specified_file = opts.parse!(ARGV)[0] else specified_file = opts.parse![0] end if !ruby_code && !specified_file specified_file = 'main.rb' end # output version info and exit if is_version_puts_mode if $outvoke.quiet_mode? puts version_str else puts "Outvoke #{version_str}" puts "Ruby #{RUBY_VERSION} (#{RUBY_DESCRIPTION})" end exit 0 end log_puts "# starting Outvoke #{version_str} ---- #{Time.now}" log_puts "# Ruby: #{RUBY_DESCRIPTION}" log_puts "# ----" OutvokePrePostProcNop .new($outvoke, "nop") OutvokePrePostProcWarn .new($outvoke, "warn") OutvokePrePostProcTerminal .new($outvoke, "terminal") OutvokePrePostProcJsonl .new($outvoke, "jsonl") OutvokePrePostProcYaml .new($outvoke, "yaml") OutvokePrePostProcOsc .new($outvoke, "osc") OutvokePrePostProcWeb .new($outvoke, "web") $outvoke.preset_lock OutvokeDataSourceIo .new($outvoke, "io") OutvokeDataSourceStdin .new($outvoke, "stdin") OutvokeDataSourceLoopback .new($outvoke, "lo") OutvokeDataSourceErr .new($outvoke, "err") OutvokeDataSourceAuxinFifo .new($outvoke, "auxin:fifo:1") OutvokeDataSourceEverySec .new($outvoke, "every-sec") OutvokeDataSourceWeb .new($outvoke, "web-001") OutvokeDataSourceOsc .new($outvoke, "osc-001") OutvokeDataSourceVrchat .new($outvoke, "vrchat-001") $outvoke.ds_lock $outvoke.default_output_mode&.then do $outvoke.output_mode = _1 end './outvoke.conf.rb'.then do if File.readable?(_1) log_puts "# loading #{_1} ..." require _1 end end if ruby_code log_puts '# given ruby code:' log_puts ruby_code, '# ' eval "using OutvokeDSL;#{ruby_code}" else file_prefix = '' file_prefix = './' if not specified_file.start_with?('/') specified_file = "#{file_prefix }#{specified_file}" log_puts "# loading #{specified_file} ..." if File.readable?(specified_file) require specified_file else log_puts "# ERROR: #{specified_file} does not exist or is not readable" exit 1 end end log_puts '# ----' 'web-001'.then do if $outvoke[_1].enabled? log_puts "# starting web server ..." $outvoke[_1].websrv_start end end 'osc-001'.then do if $outvoke[_1].enabled? log_puts "# starting OSC server ..." $outvoke[_1].server_start end end OUTVOKE_VAR.freeze # execute mainloop log_puts '# starting $outvoke.mainloop ...' $outvoke.mainloop end