Skip to content
Snippets Groups Projects
Select Git revision
  • main
  • main default protected
2 results

outvoke.rb

Blame
    • cleemy desu wayo's avatar
      4688b8d7
      Fix bug: fix error for Psych 4.0.0 or later · 4688b8d7
      cleemy desu wayo authored
      If the version of Psych was 4.0.0 or later, an error occurred
      with hookcc.
      
      Even a simple example like this will cause an error:
      
        $ ./outvoke.rb -e 'hookcc("every-sec") { e }'
      
      This bug appeared in version 0.0.99.20241123 (1c328546).
      
      Outvoke used Object#to_yaml and YAML.load for deep copying.
      This commit makes the change to use YAML.unsafe_load instead of
      YAML.load.
      
      Since YAML.unsafe_load is executed immediately after executing
      Object#to_yaml, the security risk is considered small. However,
      please note that this has not been carefully verified to ensure
      it is safe in all cases.
      
      In Outvoke, the approach for deep copying may be radically
      changed in the future.
      4688b8d7
      History
      Fix bug: fix error for Psych 4.0.0 or later
      cleemy desu wayo authored
      If the version of Psych was 4.0.0 or later, an error occurred
      with hookcc.
      
      Even a simple example like this will cause an error:
      
        $ ./outvoke.rb -e 'hookcc("every-sec") { e }'
      
      This bug appeared in version 0.0.99.20241123 (1c328546).
      
      Outvoke used Object#to_yaml and YAML.load for deep copying.
      This commit makes the change to use YAML.unsafe_load instead of
      YAML.load.
      
      Since YAML.unsafe_load is executed immediately after executing
      Object#to_yaml, the security risk is considered small. However,
      please note that this has not been carefully verified to ensure
      it is safe in all cases.
      
      In Outvoke, the approach for deep copying may be radically
      changed in the future.
    outvoke.rb 28.84 KiB
    #!/usr/bin/env ruby
    #
    # ==========================================================================
    # Outvoke -- version 0.0.99.20250614
    #
    # 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 'time'
    require 'yaml'
    require 'json'
    require 'bigdecimal'
    require 'bigdecimal/util'
    require 'bigdecimal/math'
    class Outvoke
    attr_accessor :version, :ds, :hooks, :hooks_first_time, :wait, :nodup_list,
    :mutex_nodup, :init_time
    attr_reader :last_msgid
    alias_method :sources, :ds # eventually "sources" will be removed
    def initialize
    @version = Struct.new(:branch, :body).new(
    '0.1',
    '0.0.99.20250614'
    )
    @ds = Hash.new
    @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)
    @is_quiet_mode = false
    end
    def [](str)
    @ds[str]
    end
    def quiet_mode?
    @is_quiet_mode
    end
    def quiet_mode=(x)
    @is_quiet_mode = x
    end
    def elapsed
    Process.clock_gettime(Process::CLOCK_MONOTONIC) - @init_time
    end
    #
    # 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
    @ds.each do |ds_label, ds|
    next unless ds.enabled?
    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
    sleep @wait
    end
    end
    def register_proc(ds_cond, event_cond, 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,
    'event_cond' => event_cond,
    '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
    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
    event_cond_array = [hook['event_cond']]
    if hook['event_cond'].respond_to?(:each)
    event_cond_array = hook['event_cond']
    end
    event.is_quiet_mode = @is_quiet_mode # TODO (is_quiet_mode is deprecated)
    event.status.quiet_mode = @is_quiet_mode
    # check condition
    event_cond_array.detect do |event_cond|
    event.m = nil
    if event_cond == true
    event.m = [event.body]
    elsif event_cond.is_a?(String)
    event.m = [event.body] if event.body.start_with?(event_cond)
    elsif event_cond.respond_to?(:match)
    event.m = event_cond.match(event.body)
    elsif event_cond.respond_to?(:call)
    proc_result = event_cond.call(event)
    if proc_result
    if proc_result.respond_to?(:each)
    event.m = proc_result.each
    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 |proc|
    proc.call(event)
    end
    # execute
    if hook['proc']
    tmp_context = ds.generate_context(event)
    hook_result = tmp_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 |proc|
    proc.call(event, hook_result_for_post_process)
    end
    end
    end
    #
    # 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 loputs(x = @event)
    @outvoke["lo"] << { "body" => x.to_s.dup, "attachment" => nil }
    nil
    end
    end
    module OutvokeDSL
    refine Kernel do
    def hook(ds_cond, event_cond = true, &block)
    $outvoke.register_proc(ds_cond, event_cond, "hook", &block)
    end
    def hookft(ds_cond, event_cond = true, &block)
    $outvoke.register_proc(ds_cond, event_cond, "hookft", &block)
    end
    # if block is given, execute in another thread (concurrent computing)
    def hookcc(ds_cond, event_cond = true, &block)
    register_hookcc(ds_cond, event_cond, "hookcc", &block)
    end
    # if block is given, execute in another thread (concurrent computing)
    def hookccft(ds_cond, event_cond = true, &block)
    register_hookcc(ds_cond, event_cond, "hookccft", &block)
    end
    def register_hookcc(ds_cond, event_cond, hookby, &block)
    if block_given?
    $outvoke.register_proc(ds_cond, event_cond, hookby) do |e; tmp_event|
    tmp_event = YAML.unsafe_load(e.to_yaml) # deep copy
    Thread.start do
    Thread.current["hookcc_result"] = instance_exec(tmp_event , &block)
    if Thread.current["hookcc_result"] == true
    Thread.current["hook_result_for_post_process"] = tmp_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
    @ds.post_procs_cc.each do |proc|
    proc.call(e, Thread.current["hook_result_for_post_process"])
    end
    end
    nil
    end
    else
    hook(ds_cond, event_cond)
    end
    end
    def hooklo(event_cond = true, &block)
    hook("lo", event_cond, &block)
    end
    def hookstd(event_cond = true, &block)
    hook("stdin", event_cond, &block)
    end
    def hookaux(event_cond = true, &block)
    hook("auxin:fifo:1", event_cond, &block)
    end
    def hooksec(event_cond = true, &block)
    hook("every-sec", event_cond, &block)
    end
    def hookweb(event_cond = true, &block)
    hook("web-001", event_cond, &block)
    end
    def hookvr(event_cond = true, &block)
    hook(/^vr/, event_cond, &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
    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
    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
    @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, :post_procs_cc
    attr_reader :lo_data
    def initialize(outvoke, label)
    @outvoke = outvoke
    @label = label
    @status = nil
    @log_lines = []
    @lo_data = []
    @mutex_lo = Mutex.new
    @is_first_time_log_check = false
    @is_enabled = false
    #
    # 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 << ->(e, hook_result) {
    return unless hook_result
    if e.status.quiet_mode
    puts hook_result.to_s
    else
    puts "[#{e.time}] #{hook_result}".gsub("\n", " ")
    end
    }
    #
    # Proc objects for post process
    # (Procs to process the value returned by Proc registered by hook)
    #
    @post_procs_cc = []
    @post_procs_cc << ->(e, hook_result) {
    return unless hook_result
    if e.status.quiet_mode
    puts hook_result.to_s
    else
    puts "[#{Time.now}] #{hook_result}".gsub("\n", " ")
    end
    }
    end
    def enabled?
    @is_enabled
    end
    def enabled=(x)
    @is_enabled = !!x
    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 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
    #
    # TODO: experimental and incomplete implementation
    #
    # (specifications are subject to sudden change without notice)
    #
    StructDSStdinStatus = Struct.new(:now, :quiet_mode, :last_time)
    class OutvokeDataSourceStdin < OutvokeDataSource
    def initialize(outvoke, label)
    super
    @status = StructDSStdinStatus.new
    @status.last_time = Time.now.floor
    @stdin = $stdin.each_line
    @stdin_new_lines = []
    @mutex_stdin = Mutex.new
    @thread_stdin = Thread.start do
    Thread.stop
    loop do
    Thread.stop unless @is_enabled
    Thread.current["tmp"] = @stdin.next
    Thread.stop unless @is_enabled
    Thread.current["new_msgid"] = @outvoke.generate_new_msgid
    @mutex_stdin.synchronize do
    @stdin_new_lines << {
    "msgid" => Thread.current["new_msgid"],
    "body" => Thread.current["tmp"].chomp
    }
    end
    end
    end
    end
    def enabled=(x)
    return true if @is_enabled == true && x
    @is_enabled = !!x
    return false unless @is_enabled
    sleep 0.05 # TODO
    tmp_status = @thread_stdin.status
    if tmp_status == "run" || tmp_status == "sleep"
    begin
    @thread_stdin.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_stdin.synchronize do
    @log_lines = @stdin_new_lines
    @stdin_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)
    #
    StructDSAuxinStatus = Struct.new(:now, :quiet_mode, :last_time)
    class OutvokeDataSourceAuxinFifo < OutvokeDataSource
    def initialize(outvoke, label)
    super
    @status = StructDSAuxinStatus.new
    @status.last_time = Time.now.floor
    @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
    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, :last_time)
    class OutvokeDataSourceLoopback < OutvokeDataSource
    def initialize(outvoke, label)
    super
    @status = StructDSLoopbackStatus.new
    @status.last_time = Time.now.floor
    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
    #
    # TODO: experimental and incomplete implementation
    #
    # (specifications are subject to sudden change without notice)
    #
    StructDSWebStatus = Struct.new(:now, :quiet_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)
    super
    @status = StructDSWebStatus.new
    @status.last_time = Time.now.floor
    @websrv = nil
    @port = 8080
    @document_root = nil
    @mount_proc_dir = "/"
    @timeout = 5
    @first_res_list = Hash.new
    @mutex_first_res = Mutex.new
    @post_procs = []
    @post_procs << ->(e, hook_result) {
    register_first_res(e.msgid, hook_result)
    }
    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
    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
    StructDSVrchatStatus = Struct.new(:now, :quiet_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)
    super
    @status = StructDSVrchatStatus.new
    @status.logfile = nil
    @status.file_size = 0
    @status.count = 0
    @status.lineno = 0
    @status.last_joined_time = Time.now
    @is_first_time_log_check = true
    @log_dir = ''
    @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, :last_time)
    class OutvokeDataSourceEverySec < OutvokeDataSource
    def initialize(outvoke, label)
    super
    @status = StructDSEverySecStatus.new
    @status.last_time = Time.now.floor
    end
    def each_event
    @log_lines.each do |line|
    event = OutvokeEvent.new(line["body"])
    event.time = line["body"] # TODO
    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
    # --------------------------------------------------------------------
    # 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
    version_str = "#{$outvoke.version.branch} (version #{$outvoke.version.body})"
    require 'optparse'
    is_ruby_code_given = false
    ruby_code = nil
    mainloop_interval = nil
    opts = OptionParser.new
    opts.default_argv = ['main.rb']
    opts.on('-e CODE') { |optvalue| ruby_code = optvalue }
    opts.on('-q', '--quiet') { $outvoke.quiet_mode = true }
    opts.on('--wait INTERVAL') do |optvalue|
    raise "--wait option allows only numeric" if optvalue !~ /\A[0-9]+(\.[0-9]+)?\z/
    mainloop_interval = optvalue.to_f
    end
    if ARGV[0]
    specified_file = opts.parse!(ARGV)[0]
    else
    specified_file = opts.parse![0]
    end
    log_puts "# starting Outvoke #{version_str} ---- #{Time.now}"
    log_puts "# ----"
    $outvoke.wait = mainloop_interval if mainloop_interval
    'stdin'.then do
    $outvoke.ds[_1] = OutvokeDataSourceStdin.new($outvoke, _1)
    end
    'auxin:fifo:1'.then do
    $outvoke.ds[_1] = OutvokeDataSourceAuxinFifo.new($outvoke, _1)
    end
    'lo'.then do
    $outvoke.ds[_1] = OutvokeDataSourceLoopback.new($outvoke, _1)
    end
    'every-sec'.then do
    $outvoke.ds[_1] = OutvokeDataSourceEverySec.new($outvoke, _1)
    end
    'web-001'.then do
    $outvoke.ds[_1] = OutvokeDataSourceWeb.new($outvoke, _1)
    end
    'vrchat-001'.then do
    vrchat_log_dir = "#{Dir.home}/.steam/debian-installation/steamapps/compatdata/438100/pfx/drive_c/users/steamuser/AppData/LocalLow/VRChat/VRChat"
    $outvoke.ds[_1] = OutvokeDataSourceVrchat.new($outvoke, _1)
    $outvoke.ds[_1].log_dir = vrchat_log_dir
    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
    'web-001'.then do
    if $outvoke[_1].enabled?
    log_puts "# =========================== websrv start ==========================="
    $outvoke[_1].websrv_start
    end
    end
    log_puts '# ----'
    log_puts ''
    # execute mainloop
    begin
    $outvoke.mainloop
    rescue Interrupt
    if not $outvoke.quiet_mode?
    puts "[#{Time.now}] [outvoke-system] interrupted"
    end
    end
    end