module NakiIRCBot

Public Class Methods

start(server, port, bot_name, master_name, welcome001, *channels, password: nil, masterword: nil, processors: [], tags: false) { |str, ->(where, what){ push [where, what]| ... } click to toggle source

@@channels = [] class << self

attr_accessor :channels

end

# File lib/nakiircbot.rb, line 6
def self.start server, port, bot_name, master_name, welcome001, *channels, password: nil, masterword: nil, processors: [], tags: false
  # @@channels.replace channels.dup

  abort "matching bot_name and master_name may cause infinite recursion" if bot_name == master_name
  require "base64"
  require "fileutils"
  FileUtils.mkdir_p "logs"
  require "logger"
  original_formatter = Logger::Formatter.new
  logger = Logger.new "logs/txt", "daily",
                      progname: bot_name, datetime_format: "%y%m%d %H%M%S",
                      formatter: lambda{ |severity, datetime, progname, msg|
    puts "#{datetime.strftime "%H%M%S"} #{severity.to_s[0]} #{progname} #{msg.scrub.inspect[1..-2]}"
    original_formatter.call severity, datetime, progname, Base64.strict_encode64(msg)
    # TODO: maybe encode the whole string for a case of invalid progname?
  }
  logger.level = ENV["LOGLEVEL_#{name}"].to_sym if ENV.include? "LOGLEVEL_#{name}"
  puts "#{name} logger.level = #{logger.level}"

  # https://en.wikipedia.org/wiki/List_of_Internet_Relay_Chat_commands
  loop do
    logger.info "reconnect"
    require "socket"
    socket = TCPSocket.new server, port

    # https://stackoverflow.com/a/49476047/322020
    socket_send = lambda do |str|
      logger.info "> #{str}"
      socket.send str + "\n", 0
    end
    # socket.send "PASS #{password.strip}\n", 0 if twitch
    socket_send.call "NICK #{bot_name}"
    socket_send.call "USER #{bot_name} #{bot_name} #{bot_name} #{bot_name}" #unless twitch

    queue = []
    prev_socket_time = prev_privmsg_time = Time.now
    loop do
      begin
        addr, msg = queue.shift
        next unless addr && msg
        addr = addr.codepoints.pack("U*")
        fail "I should not PRIVMSG myself" if addr == bot_name
        msg = msg.to_s.codepoints.pack("U*").chomp[/^(\x01*)(.*)/m,2].gsub("\x00", "[NUL]").gsub("\x0A", "[LF]").gsub("\x0D", "[CR]")
        privmsg = "PRIVMSG #{addr} :#{msg}"[0,513]
        privmsg[-4..-1] = "..." until privmsg.bytesize <= 475   # Libera in fact cuts last ~31 bytes
        prev_socket_time = prev_privmsg_time = Time.now
        socket_send.call privmsg
        break
      end until queue.empty? if prev_privmsg_time + 5 < Time.now

      unless _ = Kernel::select([socket], nil, nil, 1)
        break if Time.now - prev_socket_time > 300
        next
      end
      prev_socket_time = Time.now
      socket_str = _[0][0].gets(chomp: true)
      break unless socket_str
      str = socket_str.force_encoding("utf-8").scrub
      if /\A:\S+ 372 /.match? str   # MOTD
        logger.debug "< #{str}"
      elsif /\APING :/.match? str
        logger.debug "< #{str}"
      else
        logger.info "< #{str}"
      end
      break if /\AERROR :Closing Link: /.match? str

      # if str[/^:\S+ 433 * #{Regexp.escape bot_name} :Nickname is already in use\.$/]
      #   socket_send.call "NICK #{bot_name + "_"}"
      #   next
      # end

      # next socket.send("JOIN #{$2}"+"\n"),0 if str[/^:(.+?)!\S+ KICK (\S+) #{Regexp.escape bot_name} /i]
      case str
        when /\A:[a-z.]+ 001 #{Regexp.escape bot_name} :Welcome to the #{Regexp.escape welcome001} #{Regexp.escape bot_name}\z/
          # we join only when we are sure we are on the correct server
          # TODO: maybe abort if the server is wrong?
          next socket_send.call "JOIN #{channels.join ","}"
        when /\A:tmi.twitch.tv 001 #{Regexp.escape bot_name} :Welcome, GLHF!\z/
          socket_send.call "JOIN #{channels.join ","}"
          socket_send.call "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands"
          next
        when /\A:NickServ!NickServ@services\. NOTICE #{Regexp.escape bot_name} :This nickname is registered. Please choose a different nickname, or identify via \x02\/msg NickServ identify <password>\x02\.\z/
          abort "no password" unless password
          logger.info "password"
          # next socket.send "PASS #{password.strip}\n", 0
          next socket.send "PRIVMSG NickServ :identify #{bot_name} #{password.strip}\n", 0
        # TODO: get rid of this Libera hard code
        when /\A:NickServ!NickServ@services\.libera\.chat NOTICE #{Regexp.escape bot_name} :This nickname is registered. Please choose a different nickname, or identify via \x02\/msg NickServ IDENTIFY #{Regexp.escape bot_name} <password>\x02\z/
          abort "no password" unless password
          logger.info "password"
          next socket.send "PRIVMSG NickServ :identify #{bot_name} #{password.strip}\n", 0
        when /\APING :/
          next socket.send "PONG :#{$'}\n", 0   # Quakenet uses timestamp, Freenode and Twitch use server name
        when /\A:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\x01VERSION\x01\z/
          next socket_send.call "NOTICE #{$1} :\x01VERSION name 0.0.0\x01"
        # when /^:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\001PING (\d+)\001$/
        #   socket_send.call "NOTICE",$1,"\001PING #{rand 10000000000}\001"
        # when /^:([^!]+)!\S+ PRIVMSG #{Regexp.escape bot_name} :\001TIME\001$/
        #   socket_send.call "NOTICE",$1,"\001TIME 6:06:06, 6 Jun 06\001"
        when /\A#{'\S+ ' if tags}:(?<who>[^!]+)!\S+ PRIVMSG (?<where>\S+) :(?<what>.+)/
          next( if processors.empty?
            queue.push [master_name, "nothing to reload"]
          else
            processors.each do |processor|
              queue.push [master_name, "reloading #{processor}"]
              load File.absolute_path processor
            end
          end ) if $~.named_captures == {"who"=>master_name, "where"=>bot_name, "what"=>"#{masterword.strip} reload"}
      end

      begin
        yield str, ->(where, what){ queue.push [where, what] }
      rescue => e
        puts e.full_message
        queue.push [master_name, "yield error: #{e}"]
      end

    rescue => e
      puts e.full_message
      case e
      when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ETIMEDOUT, Errno::EPIPE
        sleep 5
        break
      else
        queue.push [master_name, "unhandled error: #{e}"]
        sleep 5
      end
    end

  end

end