Skip to content
Snippets Groups Projects
outvoke.rb 9.99 KiB
#!/usr/bin/env ruby
#
# ==========================================================================
# Outvoke -- version 0.0.99.20240108
#
# 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'
class Outvoke
include Enumerable
attr_accessor :sources
def initialize
@sources = Hash.new
@hooks = []
end
def each
return @hooks.enum_for unless block_given?
@hooks.each {|x| yield x }
end
def << (x)
@hooks << x
end
def mainloop
loop do
@sources.each do |dslabel, ds|
ds.before_each_event
ds.each_event do |event|
next unless ds.enable
if ds.is_first_time_log_check
ds.first_time_log_check(ds, event)
else
hooks_exec(ds, event)
end
end
ds.after_each_event
end
sleep 0.1 # TODO
end
end
def hooks_exec(ds, event)
@hooks.each do |hook|
# check data source
next unless hook['source'] == ds.label
# check condition
hook_cond_array = [hook['cond']]
hook_cond_array = hook['cond'] if hook['cond'].respond_to?(:each)
hook_cond_array.each do |cond|
if cond.is_a?(String)
event.m = [event.body] if cond == event.body
elsif cond.respond_to?(:match)
event.m = cond.match(event.body)
elsif cond.respond_to?(:call)
event.m = cond.call(event)
end
break if event.m
end
next unless event.m
hook_result = nil
hook_result = hook['proc'].call(event) if hook['proc'] # execute
is_log_outputted = false
if hook_result.is_a?(String)
puts "[#{event.time}] #{hook_result}".gsub("\n", " ")
is_log_outputted = true
elsif hook_result.is_a?(Hash)
if hook_result['log']
puts "[#{event.time}] #{hook_result['log']}".gsub("\n", " ")
is_log_outputted = true
end
if hook_result['ignore']
event.status.ignore_list[hook_result['ignore'][0]] = [event.time, hook_result['ignore'][1]]
end
end
if not is_log_outputted
if (not hook['proc']) || (hook['proc'] && hook_result)
puts "[#{event.time}] #{event.body}".gsub("\n", " ")
end
end
end
end
# extracts only the labels of the appropriate data sources from $sources
def extract_ds(dslabel)
return @sources.keys if dslabel == true
label_cond_array = [dslabel]
label_cond_array = dslabel if dslabel.respond_to?(:each)
result = []
label_cond_array.each do |label_cond|
next unless label_cond.respond_to?(:===)
@sources.keys.each do |label|
result << label if label_cond === label
end
end
result.uniq
end
end
module OutvokeDSL
refine Kernel do
def hook(dslabel, cond, &block)
raise '$outvoke is not an Outvoke object' unless $outvoke.is_a?(Outvoke)
$outvoke.extract_ds(dslabel).each do |label|
$outvoke << {
'source' => label,
'cond' => cond,
'proc' => block
}
end
end
# TODO (stub)
def ignore?(dslabel, value, sec)
raise '$outvoke is not an Outvoke object' unless $outvoke.is_a?(Outvoke)
is_empty = false
ignore_limit = nil
if not $outvoke.sources[dslabel].status.ignore_list[value]
is_empty = true
$outvoke.sources[dslabel].status.ignore_list[value] = []
else
ignore_limit = $outvoke.sources[dslabel].status.ignore_list[value][0] +
$outvoke.sources[dslabel].status.ignore_list[value][1]
end
$outvoke.sources[dslabel].status.ignore_list[value][0] = Time.now
$outvoke.sources[dslabel].status.ignore_list[value][1] = sec
return false if is_empty
#p "ignore_limit: #{ignore_limit} ---- now: #{Time.now}" # DEBUG
return ignore_limit > Time.now
end
end
end
class OutvokeDataSource
attr_accessor :label, :enable, :status, :log_lines, :is_first_time_log_check
def initialize(label)
@label = label
@enable = true
@log_lines = []
@is_first_time_log_check = false
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
end
class OutvokeDataSourceVrchat < OutvokeDataSource
attr_accessor :log_dir
def initialize(label)
super
@status = Struct.new(:now, :last_joined_time, :elapsed, :ignore_list, :count, :lineno).new
@status.ignore_list = Hash.new
@status.count = 0
@status.lineno = 0
@log_dir = ''
@is_first_time_log_check = true
end
def each_event
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
      return self unless m

      event = Struct.new(:status, :raw, :time, :type, :body, :m).new
      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.body   = m[3].sub(/\A *- */, '')

      event.status = @status
      event.status.now = Time.now

      if event.body =~ /\A\[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
    @log_lines = []
    vrchat_log_file = Dir.glob("#{@log_dir}/output_log_*.txt").last

    # return if not found
    unless vrchat_log_file
      if @is_first_time_log_check
        puts "[#{Time.now}] [outvoke-system] #{@label}: ERROR: VRChat log file not found"
      end
      return
    end

    if @is_first_time_log_check
      tmp = vrchat_log_file.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
    @log_lines = File.readlines(vrchat_log_file)

    @status.lineno = 0 if @log_lines.length < @status.lineno   # maybe a new file (TODO)
  end

  def after_each_event
    if @is_first_time_log_check
      puts "[#{Time.now}] [outvoke-system] #{@label}: first time log check has done."
      @is_first_time_log_check = false
    end
  end

  def first_time_log_check(ds, event)
    if event.body =~ /\A\[Behaviour\] entering room:/i
      @status.count = 0
    elsif event.body =~ /\A\[Behaviour\] onplayerjoined /i
      @status.count += 1
    elsif event.body =~ /\A\[Behaviour\] onplayerleft /i
      @status.count -= 1
      @status.count = 0 if @status.count < 0
    end
  end
end

class OutvokeDataSourceEverysec < OutvokeDataSource
  def initialize(label)
    super
    @status = Struct.new(:now, :last_time).new
    @status.last_time = Time.now.floor
  end

  def each_event
    @log_lines.each do |line|
      event = Struct.new(:status, :time, :body, :m).new
      event.time = line
      event.body = line.to_s
      event.status = @status
      event.status.now = Time.now

      yield event

      @status.last_time = line
    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 << 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 = '')
    if line_head == ''
      puts "#{str.gsub("\n", " ")}"
    else
      puts "#{line_head}#{str.gsub("\n", "\n" + line_head)}"
    end
  end

  log_puts "# starting Outvoke 0.1 ---- #{Time.now}"
  log_puts "# ----"

  require 'optparse'

  is_ruby_code_given = false
  ruby_code  = nil
  webui_port = nil

  opts = OptionParser.new
  opts.default_argv = ['main.rb']
  opts.on('-e CODE')           {|optvalue| ruby_code  = optvalue}
  opts.on('--webui-port PORT') {|optvalue| webui_port = optvalue}
  if ARGV[0]
    specified_file = opts.parse!(ARGV)[0]
  else
    specified_file = opts.parse![0]
  end

  $outvoke = Outvoke.new

  'every-sec'.then do
    $outvoke.sources[_1] = OutvokeDataSourceEverysec.new(_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.sources[_1] = OutvokeDataSourceVrchat.new(_1)
    $outvoke.sources[_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

  log_puts "# ----"
  puts

  # TODO (stub: do not use --webui-port option)
  if webui_port
    Thread.start { $outvoke.mainloop }
  
    require 'webrick'
    websrv = WEBrick::HTTPServer.new({:DocumentRoot => './', :Port => webui_port})
    websrv.start
  else
    begin
      $outvoke.mainloop
    rescue Interrupt
      puts "[#{Time.now}] [outvoke-system] interrupted"
    end
  end
end