codekitchen/tkellem

View on GitHub
lib/tkellem/tkellem_bot.rb

Summary

Maintainability
D
2 days
Test Coverage
# encoding: utf-8
require 'shellwords'
require 'yaml'

module Tkellem

class TkellemBot
  # careful here -- if no bouncer is given, it's assumed the command is running as
  # an admin
  def self.run_command(line, bouncer, conn, &block)
    args = Shellwords.shellwords(line)
    command_name = args.shift.try(:upcase)
    command = commands[command_name]

    unless command
      yield "Invalid command. Use help for a command listing."
      return
    end

    command.run(args, bouncer, conn, block)
  end

  class Command
    attr_accessor :args, :bouncer, :conn, :opts, :options

    def self.option(name, *args)
      @options ||= {}
      @options[name] = args
    end

    def self.admin_option(name, *args)
      option(name, *args)
      @admin_onlies ||= []
      @admin_onlies << name
    end

    def self.register(cmd_name)
      cattr_accessor :name
      self.name = cmd_name
      TkellemBot.commands[name.upcase] = self
    end

    def self.resources(name)
      @resources ||= YAML.load_file(File.expand_path("../../../resources/bot_command_descriptions.yml", __FILE__))
      @resources[name.upcase] || {}
    end

    class ArgumentError < RuntimeError; end

    def self.admin_only?
      true
    end

    def self.build_options(user, cmd = nil)
      OptionParser.new.tap do |options|
        @options.try(:each) { |opt_name,args|
          next if !admin_user?(user) && @admin_onlies.include?(opt_name)
          options.on(*args) { |v| cmd.opts[opt_name] = v }
        }
        resources = self.resources(name)
        options.banner = resources['banner'] if resources['banner']
        options.separator(resources['help']) if resources['help']
      end
    end

    def self.run(args_arr, bouncer, conn, block)
      if admin_only? && !admin_user?(bouncer.try(:user))
        block.call "You can only run #{name} as an admin."
        return
      end
      cmd = self.new(block)

      cmd.args = args_arr
      cmd.bouncer = bouncer
      cmd.conn = conn

      cmd.options = build_options(bouncer.try(:user), cmd)
      cmd.options.parse!(args_arr)

      cmd.execute
    rescue ArgumentError, OptionParser::InvalidOption => e
      cmd.respond e.to_s
    end

    def initialize(responder)
      @responder = responder
      @opts = {}
    end

    def user
      bouncer.try(:user)
    end

    def show_help
      respond(options)
    end

    def respond(text)
      text.to_s.each_line { |l| @responder.call(l.chomp) }
    end
    alias_method :r, :respond

    def self.admin_user?(user)
      !user || user.admin?
    end
  end

  cattr_accessor :commands
  self.commands = {}

  class Help < Command
    register 'help'

    def self.admin_only?
      false
    end

    def execute
      name = args.shift.try(:upcase)
      r "**** tkellem help ****"
      if name.nil?
        r "For more information on a command, type:"
        r "help <command>"
        r ""
        r "The following commands are available:"
        TkellemBot.commands.keys.sort.each do |name|
          command = TkellemBot.commands[name]
          next if command.admin_only? && user && !user.admin?
          r "#{name}#{' ' * (25-name.length)}"
        end
      elsif (command = TkellemBot.commands[name])
        r "Help for #{command.name}:"
        r ""
        r command.build_options(user)
      else
        r "No help available for #{name}."
      end
      r "**** end of help ****"
    end
  end

  class CRUDCommand < Command
    def self.register_crud(name, model)
      register(name)
      cattr_accessor :model
      self.model = model
      option('remove', '--remove', '-r', "delete the specified record")
    end

    def show(m)
      m.to_s
    end

    def find_attributes
      attributes
    end

    def list
      r "All #{self.class.name.pluralize}:"
      model.all.each { |m| r "    #{show(m)}" }
    end

    def modify
      instance = model.where(find_attributes).first
      new_record = false
      if instance
        instance.attributes = attributes
        if instance.changed?
          instance.save
        else
          respond "   #{show(instance)}"
          return
        end
      else
        new_record = true
        instance = model.create(attributes)
      end
      if instance.errors.any?
        respond "Error:"
        instance.errors.full_messages.each { |m| respond "    #{m}" }
        respond "    #{show(instance)}"
      else
        respond(new_record ? "created:" : "updated:")
        respond "    #{show(instance)}"
      end
    end

    def remove
      instance = model.where(find_attributes).first
      if instance
        instance.destroy
        respond "Removed #{show(instance)}"
      else
        respond "Not found"
      end
    end

    def execute
      if opts['remove'] && args.length == 1
        remove
      elsif args.length == 0
        list
      elsif args.length == 1
        modify
      else
        raise Command::ArgumentError, "Unknown sub-command"
      end
    end
  end

  class ListenCommand < CRUDCommand
    register_crud 'listen', ListenAddress

    def self.get_uri(arg)
      require 'uri'
      uri = URI.parse(arg)
      unless %w(irc ircs).include?(uri.scheme)
        raise Command::ArgumentError, "Invalid URI scheme: #{uri}"
      end
      uri
    rescue URI::InvalidURIError
      raise Command::ArgumentError, "Invalid new address: #{arg}"
    end

    def attributes
      uri = self.class.get_uri(args.first)
      { :address => uri.host, :port => uri.port, :ssl => (uri.scheme == 'ircs') }
    end
  end

  class UserCommand < CRUDCommand
    register_crud 'user', User

    option('role', '--role=ROLE', 'Set user role [admin|user]')

    def show(user)
      "#{user.username}:#{user.role}"
    end

    def find_attributes
      { :username => args.first.downcase }
    end

    def attributes
      find_attributes.tap { |attrs|
        role = opts['role'].try(:downcase)
        attrs['role'] = role if %w(user admin).include?(role)
      }
    end
  end

  class PasswordCommand < Command
    register 'password'

    admin_option('username', '--user=username', '-u', 'Change password for other username')

    def self.admin_only?
      false
    end

    def execute
      user = self.user

      if opts['username']
        if Command.admin_user?(user)
          user = User.where({ :username => opts['username'] }).first
        else
          raise Command::ArgumentError, "Only admins can change other passwords"
        end
      end

      unless user
        raise Command::ArgumentError, "User required"
      end

      password = args.shift || ''

      if password.size < 4
        raise Command::ArgumentError, "New password too short"
      end

      user.password = password
      user.save!
      respond "New password set for #{user.username}"
    end
  end

  class AtConnectCommand < Command
    register 'atconnect'

    option('remove', '--remove', '-r', 'Remove previously configured command')
    admin_option('network', '--network=network', '-n', 'Change atconnect for all users on a public network')

    def self.admin_only?
      false
    end

    def list(target)
      target.reload
      if target.is_a?(NetworkUser) && target.network.public?
        r "Network-wide commands are prefixed with [N], user-specific commands with [U]."
        r "Network-wide commands can only be modified by admins."
        list(target.network)
      end
      prefix = target.is_a?(Network) ? 'N' : 'U'
      target.at_connect.try(:each) { |line| r "    [#{prefix}] #{line}" }
    end

    def execute
      if opts['network'].present? # only settable by admins
        target = Network.where(["LOWER(name) = LOWER(?) AND user_id IS NULL", opts['network']]).first
      else
        target = bouncer.try(:network_user)
      end
      raise(Command::ArgumentError, "No network found") unless target

      if args.size == 0
        r "At connect:"
        list(target)
      else
        line = args.join(' ')
        raise(Command::ArgumentError, "atconnect commands must start with a /") unless line[0] == '/'[0]
        if opts['remove']
          target.at_connect = (target.at_connect || []).reject { |l| l == line }
        else
          target.at_connect = (target.at_connect || []) + [line]
        end
        target.save
        r "At connect commands modified:"
        list(target)
      end
    end
  end

  class NetworkCommand < Command
    register 'network'

    def self.admin_only?
      false
    end

    option('remove', '--remove', '-r', "Remove a hostname for a network, or the entire network if no host is given.")
    option('network', '--name=NETWORK', '-n', "Operate on a different network than the current connection.")
    admin_option('public', '--public', "Create new public network. Once created, public/private status can't be modified.")

    def list
      public_networks = Network.where('user_id IS NULL').to_a
      user_networks = user.try(:reload).try(:networks) || []
      if user_networks.present? && public_networks.present?
        r "Public networks are prefixed with [P], user-specific networks with [U]."
      end
      (public_networks + user_networks).each do |net|
        prefix = net.public? ? 'P' : 'U'
        r "    [#{prefix}] #{show(net)}"
      end
    end

    def show(network)
      "#{network.name} " + network.hosts.map { |h| "[#{h}]" }.join(' ')
    end

    def execute
      # TODO: this got gross
      if args.empty? && !opts['remove']
        list
        return
      end

      if opts['network'].present?
        target = Network.where(["LOWER(name) = LOWER(?) AND user_id = ?", opts['network'], user.try(:id)]).first
        target ||= Network.where(["LOWER(name) = LOWER(?) AND user_id IS NULL", opts['network']]).first if self.class.admin_user?(user)
      else
        target = bouncer.try(:network)
        if target && target.public? && !self.class.admin_user?(user)
          raise(Command::ArgumentError, "Only admins can modify public networks")
        end
        raise(Command::ArgumentError, "No network found") unless target
      end

      uri = ListenCommand.get_uri(args.shift) unless args.empty?
      addr_args = { :address => uri.host, :port => uri.port, :ssl => (uri.scheme == 'ircs') } if uri

      if opts['remove']
        raise(Command::ArgumentError, "No network found") unless target
        raise(Command::ArgumentError, "You must explicitly specify the network to remove") unless opts['network']
        if uri
          target.hosts.where(addr_args).first.try(:destroy)
          respond "    #{show(target)}"
        else
          target.destroy
          r "Network #{target.name} removed"
        end
      else
        unless target
          create_public = (self.class.admin_user?(user) && opts['public'])
          raise(Command::ArgumentError, "Only public networks can be created without a user") unless create_public || user
          admin_or_user_networks = self.class.admin_user?(user) || Setting.get('allow_user_networks') == 'true'
          raise(Command::ArgumentError, "Creating user networks has been disabled by the admins") unless admin_or_user_networks
          target = Network.create(:name => opts['network'], :user => (create_public ? nil : user))
          unless create_public
            NetworkUser.create(:user => user, :network => target)
          end
        end

        target.attributes = { :hosts_attributes => [addr_args] }
        target.save
        if target.errors.any?
          respond "Error:"
          target.errors.full_messages.each { |m| respond "    #{m}" }
          respond "    #{show(target)}"
        else
          respond("updated:")
          respond "    #{show(target)}"
        end
      end
    end
  end

  class SettingCommand < Command
    register 'setting'

    def self.setting_resources(name)
      @setting_resources ||= YAML.load_file(File.expand_path("../../../resources/setting_descriptions.yml", __FILE__))
      @setting_resources[name] || {}
    end

    def execute
      case args.size
      when 0
        r "Settings:"
        Setting.all.each { |s| r "    #{s}" }
      when 1
        setting = Setting.where(name: args.first).first
        if setting
          r(setting.to_s)
          desc = self.class.setting_resources(setting.name)
          if desc['help']
            desc['help'].each_line { |l| r l }
          end
        else
          r("No setting with that name")
        end
      when 2
        setting = Setting.set(args[0], args[1])
        setting ? r(setting.to_s) : r("No setting with that name")
      else
        show_help
      end
    end
  end

  class ConnectionsCommand < Command
    register 'connections'

    def execute
      require 'socket'
      $tkellem_server.bouncers.each do |k, bouncer|
        respond "#{bouncer.user.username}@#{bouncer.network.name} (#{bouncer.connected? ? 'connected' : 'connecting'}) #{"since #{bouncer.connected_at}" if bouncer.connected?}"
        bouncer.active_conns.each do |conn|
          port, addr = Socket.unpack_sockaddr_in(conn.get_peername)
          respond "    #{addr} device=#{conn.device_name} since #{conn.connected_at}"
        end
      end
    end
  end
end

end