Report abuse

#!/usr/bin/env ruby
# Author: Joseph Pecoraro
# Date: Friday December 11, 2009
# Help: http://snippets.dzone.com/posts/show/1785
# Description: IRC bot that
#  - joins a channel
#  - responds to some simple commands
#  - admin interface allows for adding/removing simple commands
#  - is just plain awesome

require "rubygems"
require "htmlentities"
require "open-uri"
require "timeout"
require "socket"
require "open4"
require "json"
require "yaml"
require "cgi"


# Run and Timeout+Kill Sub Process
# Original Source: https://www.ruby-forum.com/topic/104941
# Modified to use open4 to gain stderr output
def run(s, maxt = 10*60, errorIsOnStdout=false)
  begin
    pid, cmdin, cmdout, cmderr = Open4.popen4(s)
  rescue Exception => e
    $stderr << 'Execution of '+s+' has failed :' + e.to_s
    raise "run failed"
  end

  begin
    Timeout::timeout(maxt) do |t|
      a = Process.waitpid2(pid)
      if a[1].exitstatus != 0
        $stderr << 'Error while executing ' + s
        errorMessage = (errorIsOnStdout ? cmdout.read : cmderr.read)
        raise errorMessage.gsub(/\n/, ' ')
      end
    end
  rescue Timeout::Error => e
    $stderr << 'Execution of '+s+' has timed out. Killing subprocess'
    begin
      Process.kill('KILL', pid)
      cmdout.close; cmdin.close; cmderr.close
    rescue Object => e
      $stderr << 'Failed killing process : ' + e.to_s
    end
    raise "timeout"
  end

  out = cmdout.read
  cmdout.close; cmdin.close; cmderr.close
  out
end


class IRC
  attr_reader :server, :port, :nick, :channel, :count

  # Interpreter Keys
  V8 = 'v8'
  JSC = 'jsc'
  RHINO = 'rhino'
  ALL = '__ALL__'
  MOZ = '__MOZ__'
  SPIDERMONKEY = 'spidermonkey'

  # File Constants
  PASSWORD_FILE = ".password"
  COMMANDS_FILE = "commands.yaml"
  INTERPRETERS_FILE = "interpreters.yaml"

  # URL Constants
  SEARCH_PREFIX = "http://ajax.googleapis.com/ajax/services/search/web?v=1.0&hl=en&rsz=small&safe=active&q="

  def initialize(server, port, nick, nickpassword, channels)
    @server, @port, @nick, @nickpassword, @channels, @count = server, port, nick, nickpassword, channels, 0
    @entitydecoder = HTMLEntities.new

    # Loaded Properties
    @admin_password = nil
    @interpreters = nil
    @tmp_js_file = nil
    @commands = nil
    @timeout = nil
    load_password
    load_commands
    load_interpreter_info
  end

private

  def load_password
    password = File.read(PASSWORD_FILE)
    if password == "password"
      puts "Please Change Password"
      puts "See the '.password' file."
      exit 1
    else
      @admin_password = password
      puts "ADMIN PASSWORD: #{password}"
      puts
    end
  end

  def load_interpreter_info
    @interpreters = YAML.load_file(INTERPRETERS_FILE)
    @tmp_js_file = @interpreters['tmpfile']
    @timeout = @interpreters['timeout']
    @interpreters[ALL] = ALL
  end

  def load_commands
    @commands = YAML.load_file(COMMANDS_FILE)
  end

  def save_commands
    File.open(COMMANDS_FILE, "w") { |f| f.puts @commands.to_yaml }
  end

  def add_command(term, value)
    @commands[term] = value
    save_commands
  end

  def remove_command(term)
    @commands.delete(term)
    save_commands
  end

public

  def silent_send(s)
    @irc.send "#{s}\n", 0
  end

  def send(s, bool=false)
    if bool
      @count += 1
      puts "[#{@count}] --> #{s}"
    else
      puts "--> #{s}"
    end
    @irc.send "#{s}\n", 0
  end

  def msg(s)
    send "PRIVMSG #{@channels.first} :#{s}"
  end

  def connect()
    @irc = TCPSocket.open(@server, @port)
    send "USER bot JoePeckBot bogojoker.com :Joseph Pecoraro (BOT)"
    send "NICK #{@nick}"
    @channels.each { |channel| send "JOIN #{channel}" }
    send "PRIVMSG NickServ :identify #{@nickpassword}" unless @nickpassword == "-"
  end

  def parsedmsg(personSending, channel, str, to='')
    out = (channel==@nick) ? personSending : channel
    msg = (to.nil?) ? str : "#{to}: #{str}"
    send "PRIVMSG #{out} :#{msg}", true
  end

  def lucky(personSending, channel, str, to='')
    begin
      url = "#{SEARCH_PREFIX}#{CGI.escape(str)}"
      json = JSON.parse( open(url).read )
      results = json['responseData']['results']
      if results.size.zero?
        parsedmsg(personSending, channel, "No Results", to)
      else
        firstResult = results[0]
        title = @entitydecoder.decode( firstResult['titleNoFormatting'] )
        href = firstResult['unescapedUrl']
        result = "#{title} - #{href}"
        parsedmsg(personSending, channel, result, to)
      end
    rescue
      parsedmsg(personSending, channel, "No Results (Error)", to)
    end
  end

  def admin(personSending, password, cmd, term, value)
    if password != @admin_password
      send "PRIVMSG #{personSending} :Bad Password"
      return
    end

    confirm = ""
    if cmd =~ /add/i
      if value.nil?
        confirm = "Forgot Value!"
      else
        add_command(term, value)
        confirm = "ADDED TERM #{term}"
      end
    elsif cmd =~ /remove/i
      remove_command(term)
      confirm = "REMOVED TERM #{term}"
    end

    send "PRIVMSG #{personSending} :#{confirm}"
  end

  def parse_cmd(cmd, terms)
    result = @commands[cmd]
    return nil if result.nil?
    if terms.nil?
      return result.gsub("$1", '')
    else
      return result.gsub("$1", CGI.escape(terms))
    end
  end

  def jseval(engine, personSending, channel, code, to)
    puts "<-- CODE: #{code}"
    code = code.gsub(/\\/, '\\\\\\\\').gsub(/"/, '\\"')
    modded_code = "
// Security
(function() {
  var whitelist = ['version', 'print'];
  for (var i in this)
    if (whitelist.indexOf(i) === -1)
      delete this[i];
})();

(function(load,readline,help,quit,gc,gcParam,trap,untrap,clear,sleep,snarf,timeout,elapsed) {

  // Pretty Printing functions courtesy of inimino
  // Source: http://boshi.inimino.org/3box/3box/util.js
  function pp(o,depth){return pp_r(o,depth==undefined?8:depth)}

  function pp_r(o,d){var a=[],p
   if(!d)return '...'
   if(o===undefined)return 'undefined'
   if(o===null)return 'null'
   if(typeof o=='boolean')return o.toString()
   if(typeof o=='string')return '\"'+o.replace(/\\n/g,'\\\\n').replace(/\"/g,'\\\\\"')+'\"'
   if(typeof o=='number')return o.toString()
   if(o instanceof RegExp)return '/'+o.source+'/'
   if(typeof o=='function')return '<'+o.toString().replace(/(.{32}).*/,'$1\\u2026').replace(/\\s+/g,' ')+'>'
   if(typeof o=='xml')return o.toXMLString()
   if(o.constructor==Array){
    o.forEach(function(e,i){
     a.push(pp_r(o[i],d-1))})
    return '['+a.join(',')+']'}
   if(o.constructor==Date){
    return o.toString()}
   for(p in o) if(Object.prototype.hasOwnProperty.call(o,p))
    a.push(p+':'+pp_r(o[p],d-1))
   return '{'+a.join(',')+'}'}

  // Run the Code and Pretty Print it
  var res = eval(\"#{code}\");
  print(pp(res));

})();
"

    # Write the Modded Code to the temp file
    File.open(@tmp_js_file, 'w') { |f| f.write(modded_code) }

    # Create a list of the engines we will use
    engines = [engine]
    prefixes = ['']
    if (engine == ALL)
      engines  = [JSC, V8, RHINO, SPIDERMONKEY]
      prefixes = engines.collect { |e| e + ': ' }
    elsif (engine == MOZ)
      engines  = [RHINO, SPIDERMONKEY]
      prefixes = engines.collect { |e| e + ': ' }
    end

    # Execute the Modded Code on each engine
    engines.each_with_index do |engine, index|
      interpreter = @interpreters[engine]
      pre = prefixes[index];
      next if interpreter.nil? or interpreter.empty?

      begin
        timeout = (engine == RHINO ? @timeout+3 : @timeout) # give Rhino a little more time since it may boot the Java VM
        res = run("#{interpreter} -f #{@tmp_js_file}", timeout, (engine == V8));
        lines = res.split(/[\n\r]+/)
        res = (lines.size <= 1 ? lines[0] : lines[0..lines.size-2].join(''))
        res = res[0..200] + '...' if res.size > 200
        parsedmsg(personSending, channel, pre+res, to)
      rescue RuntimeError => e
        if e.message =~ /timeout/
          parsedmsg(personSending, channel, pre+"Timeout.", to)
        else
          errorMessage = e.message.sub(/^.*?\d+.*?\s/, '') # most
          errorMessage.gsub!(/line 1: uncaught JavaScript runtime exception:/, '') # rhino
          errorMessage.gsub!(/\/tmp\/jsevalbot.js#\d+\(eval\)/, '') # rhino
          errorMessage.gsub!(/\/tmp\/jsevalbot.js:\d+:/, '') # spider
          errorMessage = errorMessage[0..200]
          errorMessage = '[interpreter shows no error messages]' if errorMessage.size == 0
          parsedmsg(personSending, channel, pre+"Error: "+errorMessage, to)
        end
      rescue Object => e
        p e
        parsedmsg(personSending, channel, pre+"Unhandled Error, Please Contact JoePeck.", to)
      end
    end
  end

  # Convenience methods
  def jsc(a,b,c,d);          jseval(JSC,a,b,c,d);          end
  def v8(a,b,c,d);           jseval(V8,a,b,c,d);           end
  def rhino(a,b,c,d);        jseval(RHINO,a,b,c,d);        end
  def spidermonkey(a,b,c,d); jseval(SPIDERMONKEY,a,b,c,d); end
  def all(a,b,c,d);          jseval(ALL,a,b,c,d);          end
  def moz(a,b,c,d);          jseval(MOZ,a,b,c,d);          end


  #  /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:EVAL (.+)$/i
  #    $1 = The username of the person sending the message
  #    $2 = ... some info
  #    $3 = dns address
  #    $4 = channel (with the #)
  #    $5 = the person's message (the data)
  def handle_server_input(s)
    case s.strip

      # Handle Pings - puts "[ Server ping ]"
      when /^PING :(.+)$/i
        silent_send "PONG :#{$1}"

      # On Join
      when /JOIN :(.+)$/i
        puts "<-- JOINED: #{$1}"

      # More Pings - "[ CTCP PING from #{$1}!#{$2}@#{$3} ]"
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s.+\s:[\001]PING (.+)[\001]$/i
        send "NOTICE #{$1} :\001PING #{$4}\001"

      # Version Request - "[ CTCP VERSION from #{$1}!#{$2}@#{$3} ]"
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s.+\s:[\001]VERSION[\001]$/i
        send "NOTICE #{$1} :\001VERSION Ruby-irc v0.042\001"

      # `list or `commands
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:`(list|commands)$/i
        parsedmsg($1, $1, @commands.keys.sort.join(', '), $7)

      # generic "bot:"
      #   $5 is the "bot" part
      #   $6 is the source code to eval
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:(.*?)[>:]\s+(.+?)$/i
        case $5
          when "jsc"          then jsc($1, $4, $6, $1)
          when "r", "rhino"   then rhino($1, $4, $6, $1)
          when "sm", "spider", "js" then spidermonkey($1, $4, $6, $1)
          when "v8"           then v8($1, $4, $6, $1)
          when "all"          then all($1, $4, $6, $1)
          when "moz"          then moz($1, $4, $6, $1)
          when "strict"       then strict($1, $4, $6, $1)
        end

      # `g or `lucky for Google Search
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:`(?:g|lucky)\s+(.+?)(\s*@\s*(\S+))?$/i
        lucky($1, $4, $5, $7)

      # `admin (ADD|REMOVE) password
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:`admin\s+(.*?)\s+(ADD|REMOVE)\s+(\S+)(?:\s+(.*?))?$/i
        admin($1, $5, $6, $7, $8)

      # Generic Commands
      when /^:(.+?)!(.+?)@(.+?)\sPRIVMSG\s(.+)\s:`(\S+)(?:\s+(.*?))?(\s*@\s*(\S+))?$/i
        result = parse_cmd($5, $6)
        parsedmsg($1, $4, result, $8) unless result.nil?

      else
        puts s.strip

    end
    true
  end

  def main_loop()
    while true
      ready = select([@irc,$stdin], nil, nil, nil)
      next if !ready
      for s in ready[0]
        if s == $stdin then
          return if $stdin.eof
          s = $stdin.gets
          msg s
        elsif s == @irc then
          return if @irc.eof
          s = @irc.gets
          unless handle_server_input(s)
            @irc.close
            return
          end
        end
      end
    end
  end

end


# Usage
if ARGV.size < 5
  puts "usage: jsircbot server port nick password channels..."
  puts "example: jsircbot irc.freenode.net 6667 JoePeckBotTest NickServPassword #JoePeck ##javascript"
  puts
  puts "   To NOT send a NickServ password, provide a - as your password."
  puts
  puts "   Channels must be prefixed with their hash symbol. You may need to"
  puts "   escape them in your shell, for example: \\#\\#javascript"
else
  server, port, nick, nickpassword = ARGV
  channels = ARGV.drop(4)
  puts "Connecting to: #{server}:#{port}"
  puts "Nickname:      #{nick}"
  puts "Channels:      #{channels.join(' ')}"
  puts
  irc = IRC.new(server, port, nick, nickpassword, channels)
  irc.connect()
  irc.main_loop()
end