nadoka/nadoka

View on GitHub
ndk/server.rb

Summary

Maintainability
F
1 wk
Test Coverage
#
# Copyright (c) 2004-2005 SASADA Koichi <ko1 at atdot.net>
#
# This program is free software with ABSOLUTELY NO WARRANTY.
# You can re-distribute and/or modify this program under
# the same terms of the Ruby's license.
#
#
# $Id$
# Create : K.S. 04/04/17 17:00:44
#

require 'rice/irc'
require 'ndk/error'
require 'ndk/config'
require 'ndk/server_state'
require 'ndk/client'
begin
  require 'openssl'
rescue LoadError
end

module Nadoka
  Cmd = ::RICE::Command
  Rpl = ::RICE::Reply

  class NDK_Server
    TimerIntervalSec = 60
    MAX_PONG_FAIL    = 5
    
    def initialize rc
      @rc = rc
      @clients = []
      @prev_timer = Time.now

      @server_thread = nil
      @clients_thread = nil

      @state  = nil

      @state  = NDK_State.new self
      reload_config
      
      @server = nil
      @cserver = nil

      @connected = false
      @exitting  = false

      @pong_recieved   = true
      @pong_fail_count = 0

      @isupport = {}

      set_signal_trap
    end
    attr_reader :state, :connected, :rc
    attr_reader :isupport

    def client_count
      @clients.size
    end
    
    def next_server_info
      svinfo = @config.server_list.sort_by{rand}.shift
      @config.server_list.push svinfo
      [svinfo[:host], svinfo[:port], svinfo[:pass], svinfo[:ssl_params]]
    end
    
    def reload_config
      @config.remove_previous_setting if defined?(@config)
      @config = NDK_Config.new(self, @rc)

      # reset logger
      @logger = @config.logger
      @state.logger = @logger
      @state.config = @config
      @clients.each{|c|
        c.logger = @logger
      }
    end

    def start_server_thread
      @server_thread = Thread.new{
        begin
          @server = make_server()
          @logger.slog "Server connection to #{@server.server}:#{@server.port}."
          @pong_recieved = true
          
          @server.start(1){|sv|
            sv << Cmd.quit(@config.quit_message) if @config.quit_message
          }
          
        rescue RICE::Connection::Closed, SystemCallError, IOError
          @connected = false
          part_from_all_channels
          @logger.slog "Connection closed by server. Trying to reconnect."
          
          sleep @config.reconnect_delay
          retry unless @exitting
          
        rescue NDK_ReconnectToServer
          @connected = false
          part_from_all_channels
          
          begin
            @server.close if @server
          rescue RICE::Connection::Closed, SystemCallError, IOError
          end
          
          @logger.slog "Reconnect request (no server response, or client request)."
          
          sleep @config.reconnect_delay
          retry unless @exitting
          
        rescue Exception => e
          ndk_error e
          @clients_thread.kill if @clients_thread && @clients_thread.alive?
        end
      }
    end
    
    def make_server
      host, port, @server_passwd, ssl_params = next_server_info
      server = ::RICE::Connection.new(host, port, "\r\n", ssl_params)
      server.regist{|rq, wq|
        Thread.stop
        @rq = rq
        begin
          @connected = false
          server_main_proc
        rescue Exception => e
          ndk_error e
          @server_thread.kill  if @server_thread  && @server_thread.alive?
          @clients_thread.kill if @clients_thread && @clients_thread.alive?
        ensure
          @server.close
        end
      }
      server
    end
    
    def server_main_proc
      ## login

      # send passwd
      if @server_passwd
        send_to_server Cmd.pass(@server_passwd)
      end

      # send nick
      if @config.away_nick && client_count == 0
        @state.original_nick = @config.nick
        @state.nick = @config.away_nick
      else
        @state.nick = @config.nick
      end
      send_to_server Cmd.nick(@state.nick)

      # send user info
      send_to_server Cmd.user(@config.user,
                              @config.hostname, @config.servername,
                              @config.realname)
      
      # wait welcome message
      while q = recv_from_server
        case q.command
        when '001'
          break
        when '433', '437'
          # Nickname is already in use.
          # 437 Nick/channel is temporarily unavailable
          nick = @state.nick_succ(q.params[1])
          @state.nick = nick
          send_to_server Cmd.nick(nick)
        when 'NOTICE'
          # ignore
        when 'ERROR'
          msg = "Server login fail!(#{q})"
          @server_thread.raise NDK_ReconnectToServer
        when '020'
          # ignore
        when 'PRIVMSG'
          # ignore
        when '004'
          # ignore
        else
          msg = "Server login fail!(#{q})"
          @logger.slog msg
          raise msg
        end
      end
      
      # change user mode
      if @config.mode
        send_to_server Cmd.mode(@state.nick, @config.mode)
      end
      
      
      # join to default channels
      if @state.current_channels.size > 0
        # if reconnect
        @state.current_channels.each{|ch, chs|
          join_to_channel ch
        }
      else
        # default join process
        @config.default_channels.each{|ch|
          join_to_channel ch
        }
      end

      @connected = true
      @isupport = {}

      ##
      if @clients.size == 0
        enter_away
      end

      invoke_event :invoke_bot, :server_connected
      $NDK_NOTIFY_SOCKET.ready!

      # loop
      while q = recv_from_server
        
        case q.command
        when 'PING'
          send_to_server Cmd.pong(q.params[0])
          next
        when 'PRIVMSG'
          if ctcp_message?(q.params[1])
            ctcp_message(q)
          end
        when 'JOIN'
          @state.on_join(nick_of(q), q.params[0])
        when 'PART'
          @state.on_part(nick_of(q), q.params[0])
        when 'NICK'
          @state.on_nick(nick_of(q), q.params[0], q)
        when 'QUIT'
          @state.on_quit(nick_of(q), q.params[0], q)
        when 'TOPIC'
          @state.on_topic(nick_of(q), q.params[0], q.params[1])
        when 'MODE'
          @state.on_mode(nick_of(q), q.params[0], q.params[1..-1])
        when 'KICK'
          @state.on_kick(nick_of(q), q.params[0], q.params[1], q.params[2])
          
        when '353' # RPL_NAMREPLY
          @state.on_353(q.params[2], q.params[3])
        when '332' # RPL_TOPIC
          @state.on_332(q.params[1], q.params[2])
          
        when '403' # ERR_NOSUCHCHANNEL
          @state.on_403(q.params[1])
          
        when '433', '436', '437'
          # ERR_NICKNAMEINUSE, ERR_NICKCOLLISION, ERR_UNAVAILRESOURCE
          # change try nick
          case q.params[1]
          when /\A[\#&!+]/
            # retry join after 1 minute
            Thread.start(q.params[1]) do |ch|
              sleep 60
              join_to_channel ch
            end
          else
            nick = @state.nick_succ(q.params[1])
            send_to_server Cmd.nick(nick)
            @logger.slog("Retry nick setting: #{nick}")
          end

        when '005' # RPL_ISUPPORT or RPL_BOUNCE
          if /supported/i =~ q.params[-1]
            q.params[1..-2].each do |param|
              if /\A(-)?([A-Z0-9]+)(?:=(.*))?\z/ =~ param
                negate, key, value = $~.captures
                if negate
                  @isupport.delete(key)
                else
                  @isupport[key] = value || true
                end
              end
            end
          end
          @logger.dlog "isupport: #{@isupport.inspect}"

        else
          # 
        end

        
        send_to_clients q
        @logger.logging q
        send_to_bot q
      end
    end

    def join_to_channel ch
      if @config.channel_info[ch] && @config.channel_info[ch][:key]
        send_to_server Cmd.join(ch, @config.channel_info[ch][:key])
      else
        send_to_server Cmd.join(ch)
      end
    end
    
    def enter_away
      return if @exitting || !@connected
      
      send_to_server Cmd.away(@config.away_message) if @config.away_message

      # change nick
      if @state.nick != @config.away_nick && @config.away_nick
        @state.original_nick = @state.nick
        send_to_server Cmd.nick(@config.away_nick)
      end

      # part channel
      @config.login_channels.each{|ch|
        if @config.channel_info[ch] && @state.channels.include?(ch)
          if @config.channel_info[ch][:part_message]
            send_to_server Cmd.part(ch, @config.channel_info[ch][:part_message])
          else
            send_to_server Cmd.part(ch)
          end
        end
      }
    end

    def leave_away
      return if @exitting || !@connected

      send_to_server Cmd.away()

      if @config.away_nick && @state.original_nick
        sleep 2 # wait for server response
        send_to_server Cmd.nick(@state.original_nick)
        @state.original_nick = nil
        sleep 1 # wait for server response
      end

      @config.login_channels.each{|ch|
        send_to_server Cmd.join(ch)
      }
    end
    
    def start_clients_thread
      return unless @config.client_server_port
      @clients_thread = Thread.new{
        begin
          @cserver = TCPServer.new(@config.client_server_host,
                                   @config.client_server_port)
          @logger.slog "Open Client Server Port: #{@cserver.addr.join(' ')}"
          
          if @config.client_server_ssl_cert_file && @config.client_server_ssl_key_file
            context = OpenSSL::SSL::SSLContext.new
            context.cert = OpenSSL::X509::Certificate.new(File.read(@config.client_server_ssl_cert_file))
            context.key = OpenSSL::PKey::RSA.new(File.read(@config.client_server_ssl_key_file))
            @cserver = OpenSSL::SSL::SSLServer.new(@cserver, context)
            @cserver.start_immediately = false
          end
          
          while true
            # wait for client connections
            Thread.start(@cserver.accept){|cc|
              cc.accept if OpenSSL::SSL::SSLSocket === cc rescue NameError
              client = nil
              begin
                if !@config.acl_object || @config.acl_object.allow_socket?(cc)
                  client = NDK_Client.new(@config, cc, self)
                  @clients << client
                  client.start
                else
                  @logger.slog "ACL denied: #{cc.peeraddr.join(' ')}"
                end
              rescue Exception => e
                ndk_error e
              ensure
                @clients.delete client
                invoke_event :enter_away, client_count
                cc.close unless cc.closed?
              end
            }
          end
        rescue Exception => e
          ndk_error e
        ensure
          @clients.each{|cl|
            cl.kill
          }
          if @cserver
            @logger.slog "Close Client Server Port: #{@cserver.addr.join(' ')}"
            @cserver.close unless @cserver.closed?
          end
          @server_thread.kill if @server_thread.alive?
        end
      }
    end
    
    def start
      start_server_thread
      start_clients_thread
      timer_thread = Thread.new{
        begin
          @pong_recieved   = true
          @pong_fail_count = 0
          while true
            slp = Time.now.to_i % TimerIntervalSec
            slp = TimerIntervalSec if slp < (TimerIntervalSec / 2)
            sleep slp
            send_to_bot :timer, Time.now

            if @connected
              if @pong_recieved
                @pong_fail_count = 0
                $NDK_NOTIFY_SOCKET.watchdog!
              else
                # fail
                @pong_fail_count += 1
                @logger.slog "PONG MISS: #{@pong_fail_count}"
                
                if @pong_fail_count > MAX_PONG_FAIL
                  @pong_fail_count = 0
                  invoke_event :reconnect_to_server
                end
              end
              
              @pong_recieved = false
              @server << Cmd.ping(@server.server)
            else
              @pong_recieved   = true
              @pong_fail_count = 0
            end
            
          end
          
        rescue Exception => e
          ndk_error e
        end
      }
      
      begin
        sleep
      rescue Interrupt
        @exitting = true
      rescue Nadoka::NDK_Error
        @exitting = true
        raise
      ensure
        @server_thread.kill  if @server_thread  && @server_thread.alive?
        @clients_thread.kill if @clients_thread && @clients_thread.alive?
        timer_thread.kill if timer_thread && timer_thread.alive?
        
        @server.close if @server
      end
    end
    
    def send_to_server msg
      str = msg.to_s
      if /[\r\n]/ =~ str.chomp
        @logger.dlog "![>S] #{str}"
        raise NDK_InvalidMessage, "Message must not include [\\r\\n]: #{str.inspect}"
      else
        @logger.dlog "[>S] #{str}"
        @server << msg
      end
    end
    
    def recv_from_server
      while q = @rq.pop
        
        # Event
        if q.kind_of? Array
          exec_event q
          next
        end
        
        # Server -> Nadoka message
        if !@config.primitive_filters.nil? && !@config.primitive_filters[q.command].nil? && !@config.primitive_filters[q.command].empty?
          next unless filter_message(@config.primitive_filters[q.command], q)
        end

        case q.command
        when 'PING'
          @server << Cmd.pong(q.params[0])
        when 'PONG'
          @pong_recieved = true
        when 'NOTICE'
          @logger.dlog "[<S] #{q}"
          if msg = filter_message(@config.notice_filter, q)
            return q
          end
        when 'PRIVMSG'
          @logger.dlog "[<S] #{q}"
          if msg = filter_message(@config.privmsg_filter, q)
            return q
          end
        else
          @logger.dlog "[<S] #{q}"
          return q
        end
      end
    end

    def filter_message filter, msg
      return msg if filter.nil? || filter.empty?
      
      begin
        if filter.respond_to? :each
          filter.each{|fil|
            fil.call msg.dup
          }
        else
          filter.call msg.dup
        end
      rescue NDK_FilterMessage_SendCancel
        @logger.dlog "[NDK] Message Canceled"
        return false
      rescue NDK_FilterMessage_Replace => e
        @logger.dlog "[NDK] Message Replaced: #{e}"
        return e.msg
      rescue NDK_FilterMessage_OnlyBot
        @logger.dlog "[NDK] Message only bot"
        send_to_bot msg
        return false
      rescue NDK_FilterMessage_OnlyLog
        @logger.dlog "[NDK] Message only log"
        @logger.logging msg
        return false
      rescue NDK_FilterMessage_BotAndLog
        @logger.dlog "[NDK] Message log and bot"
        send_to_bot msg
        @logger.logging msg
        return false
      end
      msg
    end
    
    def invoke_event ev, *arg
      arg.unshift ev
      @rq && (@rq << arg)
    end

    def exec_event q
      # special event
      case q[0]
      when :reload_config
        # q[1] must be client object
        begin
          reload_config
          @logger.slog "configuration is reloaded"
        rescue Exception => e
          @logger.slog "error is occure while reloading configuration"
          ndk_error e
        end
        
      when :quit_program
        @exitting = true
        Thread.main.raise NDK_QuitProgram
        
      when :restart_program
        @exitting = true
        Thread.main.raise NDK_RestartProgram

      when :reconnect_to_server
        @connected = false
        @server_thread.raise NDK_ReconnectToServer
        
      when :invoke_bot
        # q[1], q[2] are message and argument
        send_to_bot q[1], *q[2..-1]
        
      when :enter_away
        if q[1] == 0
          enter_away
        end
        
      when :leave_away
        if q[1] == 1
          leave_away
        end
      end
    end

    def set_signal_trap
      list = Signal.list
      Signal.trap(:INT){
        # invoke_event :quit_program
        Thread.main.raise NDK_QuitProgram
      } if list['INT']
      Signal.trap(:TERM){
        # invoke_event :quit_program
        Thread.main.raise NDK_QuitProgram
      } if list.any?{|e| e == 'TERM'}

      Signal.trap(:HUP){
        # reload config
        invoke_event :reload_config
      } if list['HUP']
      trap(:USR1){
        # SIGUSR1
        invoke_event :invoke_bot, :sigusr1
      } if list['USR1']
      trap(:USR2){
        # SIGUSR2
        invoke_event :invoke_bot, :sigusr2
      } if list['USR2']
    end
    
    def about_me? msg
      qnick = Regexp.quote(@state.nick || '')
      if msg.prefix =~ /^#{qnick}!/
        true
      else
        false
      end
    end

    def own_nick_change? msg
      if msg.command == 'NICK' && msg.params[0] == @state.nick
        nick_of(msg)
      else
        false
      end
    end

    def part_from_all_channels
      @state.channels.each{|ch, cs|
        cmd = Cmd.part(ch)
        cmd.prefix = @state.nick #m
        send_to_clients cmd
      }
      @state.clear_channels_member
    end
    
    # server -> clients
    def send_to_clients msg
      if msg.command == 'PRIVMSG' && !(msg = filter_message(@config.privmsg_filter_light, msg))
        return
      end

      if(old_nick = own_nick_change?(msg))
        @clients.each{|cl|
          cl.add_prefix2(msg, old_nick)
          cl << msg
        }
      elsif about_me? msg
        @clients.each{|cl|
          cl.add_prefix(msg)
          cl << msg
        }
      else
        @clients.each{|cl|
          cl << msg
        }
      end
    end

    def ping_to_clients
      @clients.each{|cl|
        cl << Cmd.ping(cl.remote_host)
      }
    end

    # clientA -> other clients
    # bot     -> clients
    def send_to_clients_otherwise msg, elt
      @clients.each{|cl|
        if cl != elt
          cl.add_prefix(msg) unless msg.prefix
          cl << msg
        end
      }
      invoke_event :invoke_bot, msg if elt
      @logger.logging msg
    end
    
    def ctcp_message? arg
      arg[0] == ?\x1
    end
    
    def ctcp_message msg
      if /\001(.+)\001/ =~ msg.params[1]
        ctcp_cmd = $1
        case ctcp_cmd
        when 'VERSION'
          send_to_server Cmd.notice(nick_of(msg), "\001VERSION #{Nadoka.version}\001")
        when 'TIME'
          send_to_server Cmd.notice(nick_of(msg), "\001TIME #{Time.now}\001")
        else
          
        end
      end
    end
    
    def nick_of msg
      if /^([^!]+)\!?/ =~ msg.prefix.to_s
        $1
      else
        @state.nick
      end
    end

    class PrefixObject
      def initialize prefix
        parse_prefix prefix
        @prefix = prefix
      end
      attr_reader :nick, :user, :host, :prefix

      def parse_prefix prefix
        if /^(.+?)\!(.+?)@(.+)/ =~ prefix.to_s
          # command
          @nick, @user, @host = $1, $2, $3
        else
          # server reply
          @nick, @user, @host = nil, nil, prefix
        end
      end

      def to_s
        @prefix
      end
    end
    
    def make_prefix_object msg
      prefix = msg.prefix
      if prefix
        PrefixObject.new(prefix)
      else
        if /^d+$/ =~ msg.command
          PrefixObject.new(@config.nadoka_server_name)
        else
          PrefixObject.new("#{@state.nick}!#{@config.user}@#{@config.nadoka_server_name}")
        end
      end
    end
    
    # dispatch to bots
    def send_to_bot msg, *arg

      selector = 'on_' +
        if msg.respond_to? :command
          if /^\d+$/ =~ msg.command
            # reply
            prefix = make_prefix_object msg
            RICE::Reply::Replies_num_to_name[msg.command]
          else
            # command
            prefix = make_prefix_object msg
            msg.command.downcase
          end
        else
          prefix = nil
          msg.to_s
        end
      
      @config.bots.each{|bot|
        begin
          if bot.respond_to? selector
            unless prefix
              bot.__send__(selector, *arg)
            else
              bot.__send__(selector, prefix, *msg.params)
            end
          end

          if prefix && bot.respond_to?(:on_every_message)
            bot.__send__(:on_every_message, prefix, msg.command, *msg.params)
          end
          
        rescue NDK_BotBreak
          break
          
        rescue NDK_BotSendCancel
          return false
          
        rescue Exception
          ndk_error $!
        end
      }
      true
    end

    def ndk_status
      [ '== Nadoka Running Status ==',
        '- nadoka version: ' + Nadoka.version,
        '- connecting to ' + "#{@server.server}:#{@server.port}",
        '- clients status:',
          @clients.map{|e| '-- ' + e.state},
        '- Bots status:',
          @config.bots.map{|bot| '-- ' + bot.bot_state},
        '== End of Status =='
      ].flatten
    end
    
    def ndk_error err
      @logger.slog "Exception #{err.class} - #{err}"
      @logger.slog "-- backtrace --"
      err.backtrace.each{|line|
        @logger.slog "| " + line
      }
    end
    
  end
end