diff options
Diffstat (limited to 'lib')
50 files changed, 8241 insertions, 0 deletions
diff --git a/lib/rbot/auth.rb b/lib/rbot/auth.rb new file mode 100644 index 00000000..7811d9e4 --- /dev/null +++ b/lib/rbot/auth.rb @@ -0,0 +1,199 @@ +module Irc + + # globmask:: glob to test with + # netmask:: netmask to test against + # Compare a netmask with a standard IRC glob, e.g foo!bar@baz.com would + # match *!*@baz.com, foo!*@*, *!bar@*, etc. + def Irc.netmaskmatch(globmask, netmask) + regmask = globmask.gsub(/\*/, ".*?") + return true if(netmask =~ /#{regmask}/) + return false + end + + # check if a string is an actual IRC hostmask + def Irc.ismask(mask) + mask =~ /^.+!.+@.+$/ + end + + + # User-level authentication to allow/disallow access to bot commands based + # on hostmask and userlevel. + class IrcAuth + # create a new IrcAuth instance. + # bot:: associated bot class + def initialize(bot) + @bot = bot + @users = Hash.new(0) + @levels = Hash.new(0) + if(File.exist?("#{@bot.botclass}/users.rbot")) + IO.foreach("#{@bot.botclass}/users.rbot") do |line| + if(line =~ /\s*(\d+)\s*(\S+)/) + level = $1.to_i + mask = $2 + @users[mask] = level + end + end + end + if(File.exist?("#{@bot.botclass}/levels.rbot")) + IO.foreach("#{@bot.botclass}/levels.rbot") do |line| + if(line =~ /\s*(\d+)\s*(\S+)/) + level = $1.to_i + command = $2 + @levels[command] = level + end + end + end + end + + # save current users and levels to files. + # levels are written to #{botclass}/levels.rbot + # users are written to #{botclass}/users.rbot + def save + Dir.mkdir("#{@bot.botclass}") if(!File.exist?("#{@bot.botclass}")) + File.open("#{@bot.botclass}/users.rbot", "w") do |file| + @users.each do |key, value| + file.puts "#{value} #{key}" + end + end + File.open("#{@bot.botclass}/levels.rbot", "w") do |file| + @levels.each do |key, value| + file.puts "#{value} #{key}" + end + end + end + + # command:: command user wishes to perform + # mask:: hostmask of user + # tell:: optional recipient for "insufficient auth" message + # + # returns true if user with hostmask +mask+ is permitted to perform + # +command+ optionally pass tell as the target for the "insufficient auth" + # message, if the user is not authorised + def allow?(command, mask, tell=nil) + auth = userlevel(mask) + if(auth >= @levels[command]) + return true + else + debug "#{mask} is not allowed to perform #{command}" + @bot.say tell, "insufficient \"#{command}\" auth (have #{auth}, need #{@levels[command]})" if tell + return false + end + end + + # add user with hostmask matching +mask+ with initial auth level +level+ + def useradd(mask, level) + if(Irc.ismask(mask)) + @users[mask] = level + end + end + + # mask:: mask of user to remove + # remove user with mask +mask+ + def userdel(mask) + if(Irc.ismask(mask)) + @users.delete(mask) + end + end + + # command:: command to adjust + # level:: new auth level for the command + # set required auth level of +command+ to +level+ + def setlevel(command, level) + @levels[command] = level + end + + # specific users. + # mask:: mask of user + # returns the authlevel of user with mask +mask+ + # finds the matching user which has the highest authlevel (so you can have + # a default level of 5 for *!*@*, and yet still give higher levels to + def userlevel(mask) + # go through hostmask list, find match with _highest_ level (all users + # will match *!*@*) + level = 0 + @users.each {|user,userlevel| + if(Irc.netmaskmatch(user, mask)) + level = userlevel if userlevel > level + end + } + level + end + + # return all currently defined commands (for which auth is required) and + # their required authlevels + def showlevels + reply = "Current levels are:" + @levels.sort.each {|a| + key = a[0] + value = a[1] + reply += " #{key}(#{value})" + } + reply + end + + # return all currently defined users and their authlevels + def showusers + reply = "Current users are:" + @users.sort.each {|a| + key = a[0] + value = a[1] + reply += " #{key}(#{value})" + } + reply + end + + # module help + def help(topic="") + case topic + when "setlevel" + return "setlevel <command> <level> => Sets required level for <command> to <level> (private addressing only)" + when "useradd" + return "useradd <mask> <level> => Add user <mask> at level <level> (private addressing only)" + when "userdel" + return "userdel <mask> => Remove user <mask> (private addressing only)" + when "auth" + return "auth <masterpw> => Recognise your hostmask as bot master (private addressing only)" + when "levels" + return "levels => list commands and their required levels (private addressing only)" + when "users" + return "users => list users and their levels (private addressing only)" + else + return "Auth module (User authentication) topics: setlevel, useradd, userdel, auth, levels, users" + end + end + + # privmsg handler + def privmsg(m) + if(m.address? && m.private?) + case m.message + when (/^setlevel\s+(\S+)\s+(\d+)$/) + if(@bot.auth.allow?("auth", m.source, m.replyto)) + @bot.auth.setlevel($1, $2.to_i) + m.reply "level for #$1 set to #$2" + end + when (/^useradd\s+(\S+)\s+(\d+)/) + if(@bot.auth.allow?("auth", m.source, m.replyto)) + @bot.auth.useradd($1, $2.to_i) + m.reply "added user #$1 at level #$2" + end + when (/^userdel\s+(\S+)/) + if(@bot.auth.allow?("auth", m.source, m.replyto)) + @bot.auth.userdel($1) + m.reply "user #$1 is gone" + end + when (/^auth\s+(\S+)/) + if($1 == @bot.config["auth.password"]) + @bot.auth.useradd(Regexp.escape(m.source), 1000) + m.reply "Identified, security level maxed out" + else + m.reply "incorrect password" + end + when ("levels") + m.reply @bot.auth.showlevels if(@bot.auth.allow?("config", m.source, m.replyto)) + when ("users") + m.reply @bot.auth.showusers if(@bot.auth.allow?("config", m.source, m.replyto)) + end + end + end + end +end diff --git a/lib/rbot/channel.rb b/lib/rbot/channel.rb new file mode 100644 index 00000000..edd206bf --- /dev/null +++ b/lib/rbot/channel.rb @@ -0,0 +1,54 @@ +module Irc + + # class to store IRC channel data (users, topic, per-channel configurations) + class IRCChannel + # name of channel + attr_reader :name + + # current channel topic + attr_reader :topic + + # hash containing users currently in the channel + attr_accessor :users + + # if true, bot won't talk in this channel + attr_accessor :quiet + + # name:: channel name + # create a new IRCChannel + def initialize(name) + @name = name + @users = Hash.new + @quiet = false + @topic = Topic.new + end + + # eg @bot.channels[chan].topic = topic + def topic=(name) + @topic.name = name + end + + # class to store IRC channel topic information + class Topic + # topic name + attr_accessor :name + + # timestamp + attr_accessor :timestamp + + # topic set by + attr_accessor :by + + def initialize + @name = "" + end + + # when called like "puts @bots.channels[chan].topic" + def to_s + @name + end + end + + end + +end diff --git a/lib/rbot/config.rb b/lib/rbot/config.rb new file mode 100644 index 00000000..971a413c --- /dev/null +++ b/lib/rbot/config.rb @@ -0,0 +1,205 @@ +module Irc + + require 'yaml' + + # container for bot configuration + class BotConfig + + # currently we store values in a hash but this could be changed in the + # future. We use hash semantics, however. + def method_missing(method, *args, &block) + return @config.send(method, *args, &block) + end + + # bot:: parent bot class + # create a new config hash from #{botclass}/conf.rbot + def initialize(bot) + @bot = bot + # some defaults + @config = Hash.new(false) + + @config['server.name'] = "localhost" + @config['server.port'] = 6667 + @config['server.password'] = false + @config['server.bindhost'] = false + @config['irc.nick'] = "rbot" + @config['irc.user'] = "rbot" + @config['irc.join_channels'] = "" + @config['core.language'] = "english" + @config['core.save_every'] = 60 + @config['keyword.listen'] = false + @config['auth.password'] = "" + @config['server.sendq_delay'] = 2.0 + @config['server.sendq_burst'] = 4 + @config['keyword.address'] = true + @config['keyword.listen'] = false + + # TODO + # have this class persist key/values in hash using yaml as it kinda + # already does. + # have other users of the class describe config to it on init, like: + # @config.add(:key => 'server.name', :type => 'string', + # :default => 'localhost', :restart => true, + # :help => 'irc server to connect to') + # that way the config module doesn't have to know about all the other + # classes but can still provide help and defaults. + # Classes don't have to add keys, they can just use config as a + # persistent hash, but then they won't be presented by the config + # module for runtime display/changes. + # (:restart, if true, makes the bot reply to changes with "this change + # will take effect after the next restart) + # :proc => Proc.new {|newvalue| ...} + # (:proc, proc to run on change of setting) + # or maybe, @config.add_key(...) do |newvalue| .... end + # :validate => /regex/ + # (operates on received string before conversion) + # Special handling for arrays so the config module can be used to + # add/remove elements as well as changing the whole thing + # Allow config options to list possible valid values (if type is enum, + # for example). Then things like the language module can list the + # available languages for choosing. + + if(File.exist?("#{@bot.botclass}/conf.yaml")) + newconfig = YAML::load_file("#{@bot.botclass}/conf.yaml") + @config.update(newconfig) + else + # first-run wizard! + wiz = BotConfigWizard.new(@bot) + newconfig = wiz.run(@config) + @config.update(newconfig) + end + end + + # write current configuration to #{botclass}/conf.rbot + def save + Dir.mkdir("#{@bot.botclass}") if(!File.exist?("#{@bot.botclass}")) + File.open("#{@bot.botclass}/conf.yaml", "w") do |file| + file.puts @config.to_yaml + end + end + end + + # I don't see a nice way to avoid the first start wizard knowing way too + # much about other modules etc, because it runs early and stuff it + # configures is used to initialise the other modules... + # To minimise this we'll do as little as possible and leave the rest to + # online modification + class BotConfigWizard + + # TODO things to configure.. + # config directory (botclass) - people don't realise they should set + # this. The default... isn't good. + # users? - default *!*@* to 10 + # levels? - need a way to specify a default level, methinks, for + # unconfigured items. + # + def initialize(bot) + @bot = bot + @questions = [ + { + :question => "What server should the bot connect to?", + :prompt => "Hostname", + :key => "server.name", + :type => :string, + }, + { + :question => "What port should the bot connect to?", + :prompt => "Port", + :key => "server.port", + :type => :number, + }, + { + :question => "Does this IRC server require a password for access? Leave blank if not.", + :prompt => "Password", + :key => "server.password", + :type => :password, + }, + { + :question => "Would you like rbot to bind to a specific local host or IP? Leave blank if not.", + :prompt => "Local bind", + :key => "server.bindhost", + :type => :string, + }, + { + :question => "What IRC nickname should the bot attempt to use?", + :prompt => "Nick", + :key => "irc.nick", + :type => :string, + }, + { + :question => "What local user should the bot appear to be?", + :prompt => "User", + :key => "irc.user", + :type => :string, + }, + { + :question => "What channels should the bot always join at startup? List multiple channels using commas to separate. If a channel requires a password, use a space after the channel name. e.g: '#chan1, #chan2, #secretchan secritpass, #chan3'", + :prompt => "Channels", + :key => "irc.join_channels", + :type => :string, + }, + { + :question => "Which language file should the bot use?", + :prompt => "Language", + :key => "core.language", + :type => :enum, + :items => Dir.new(File.dirname(__FILE__) + "/languages/").collect {|f| + f =~ /\.lang$/ ? f.gsub(/\.lang$/, "") : nil + }.compact + }, + { + :question => "Enter your password for maxing your auth with the bot (used to associate new hostmasks with your owner-status etc)", + :prompt => "Password", + :key => "auth.password", + :type => :password, + }, + ] + end + + def run(defaults) + config = defaults.clone + puts "First time rbot configuration wizard" + puts "====================================" + puts "This is the first time you have run rbot with a config directory of:" + puts @bot.botclass + puts "This wizard will ask you a few questions to get you started." + puts "The rest of rbot's configuration can be manipulated via IRC once" + puts "rbot is connected and you are auth'd." + puts "-----------------------------------" + + @questions.each do |q| + puts q[:question] + begin + key = q[:key] + if q[:type] == :enum + puts "valid values are: " + q[:items].join(", ") + end + if (defaults.has_key?(key)) + print q[:prompt] + " [#{defaults[key]}]: " + else + print q[:prompt] + " []: " + end + response = STDIN.gets + response.chop! + response = defaults[key] if response == "" && defaults.has_key?(key) + case q[:type] + when :string + when :number + raise "value '#{response}' is not a number" unless (response.class == Fixnum || response =~ /^\d+$/) + response = response.to_i + when :password + when :enum + raise "selected value '#{response}' is not one of the valid values" unless q[:items].include?(response) + end + config[key] = response + puts "configured #{key} => #{config[key]}" + puts "-----------------------------------" + rescue RuntimeError => e + puts e.message + retry + end + end + return config + end + end +end diff --git a/lib/rbot/dbhash.rb b/lib/rbot/dbhash.rb new file mode 100644 index 00000000..5ae2ba87 --- /dev/null +++ b/lib/rbot/dbhash.rb @@ -0,0 +1,133 @@ +# Copyright (C) 2002 Tom Gilbert. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of the Software and its documentation and acknowledgment shall be +# given in the documentation and software packages that this Software was +# used. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +begin + require 'bdb' +rescue Exception => e + puts "Got exception: "+e + puts "rbot couldn't load the bdb module, perhaps you need to install it? try: http://www.ruby-lang.org/en/raa-list.rhtml?name=bdb" + exit 2 +end + +# make BTree lookups case insensitive +module BDB + class CIBtree < Btree + def bdb_bt_compare(a, b) + a.downcase <=> b.downcase + end + end +end + +module Irc + + # DBHash is for tying a hash to disk (using bdb). + # Call it with an identifier, for example "mydata". It'll look for + # mydata.db, if it exists, it will load and reference that db. + # Otherwise it'll create and empty db called mydata.db + class DBHash + + # absfilename:: use +key+ as an actual filename, don't prepend the bot's + # config path and don't append ".db" + def initialize(bot, key, absfilename=false) + @bot = bot + @key = key + if absfilename && File.exist?(key) + # db already exists, use it + @db = DBHash.open_db(key) + elsif File.exist?(@bot.botclass + "/#{key}.db") + # db already exists, use it + @db = DBHash.open_db(@bot.botclass + "/#{key}.db") + elsif absfilename + # create empty db + @db = DBHash.create_db(key) + else + # create empty db + @db = DBHash.create_db(@bot.botclass + "/#{key}.db") + end + end + + def method_missing(method, *args, &block) + return @db.send(method, *args, &block) + end + + def DBHash.create_db(name) + debug "DBHash: creating empty db #{name}" + return BDB::Hash.open(name, nil, + BDB::CREATE | BDB::EXCL | BDB::TRUNCATE, + 0600, "set_pagesize" => 1024, + "set_cachesize" => [(0), (32 * 1024), (0)]) + end + + def DBHash.open_db(name) + debug "DBHash: opening existing db #{name}" + return BDB::Hash.open(name, nil, + "r+", 0600, "set_pagesize" => 1024, + "set_cachesize" => [(0), (32 * 1024), (0)]) + end + + end + + + # DBTree is a BTree equivalent of DBHash, with case insensitive lookups. + class DBTree + + # absfilename:: use +key+ as an actual filename, don't prepend the bot's + # config path and don't append ".db" + def initialize(bot, key, absfilename=false) + @bot = bot + @key = key + if absfilename && File.exist?(key) + # db already exists, use it + @db = DBTree.open_db(key) + elsif absfilename + # create empty db + @db = DBTree.create_db(key) + elsif File.exist?(@bot.botclass + "/#{key}.db") + # db already exists, use it + @db = DBTree.open_db(@bot.botclass + "/#{key}.db") + else + # create empty db + @db = DBTree.create_db(@bot.botclass + "/#{key}.db") + end + end + + def method_missing(method, *args, &block) + return @db.send(method, *args, &block) + end + + def DBTree.create_db(name) + debug "DBTree: creating empty db #{name}" + return BDB::CIBtree.open(name, nil, + BDB::CREATE | BDB::EXCL | BDB::TRUNCATE, + 0600, "set_pagesize" => 1024, + "set_cachesize" => [(0), (32 * 1024), (0)]) + end + + def DBTree.open_db(name) + debug "DBTree: opening existing db #{name}" + return BDB::CIBtree.open(name, nil, + "r+", 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0]) + end + + end + +end diff --git a/lib/rbot/httputil.rb b/lib/rbot/httputil.rb new file mode 100644 index 00000000..ff3216a6 --- /dev/null +++ b/lib/rbot/httputil.rb @@ -0,0 +1,88 @@ +module Irc + +require 'net/http' +Net::HTTP.version_1_2 + +# class for making http requests easier (mainly for plugins to use) +# this class can check the bot proxy configuration to determine if a proxy +# needs to be used, which includes support for per-url proxy configuration. +class HttpUtil + def initialize(bot) + @bot = bot + @headers = { + 'User-Agent' => "rbot http util #{$version} (http://linuxbrit.co.uk/rbot/)", + } + end + + # uri:: Uri to create a proxy for + # + # return a net/http Proxy object, which is configured correctly for + # proxying based on the bot's proxy configuration. + # This will include per-url proxy configuration based on the bot config + # +http_proxy_include/exclude+ options. + def get_proxy(uri) + proxy = nil + if (ENV['http_proxy']) + proxy = URI.parse ENV['http_proxy'] + end + if (@bot.config["http_proxy"]) + proxy = URI.parse ENV['http_proxy'] + end + + # if http_proxy_include or http_proxy_exclude are set, then examine the + # uri to see if this is a proxied uri + if uri + if @bot.config["http_proxy_exclude"] + # TODO + end + if @bot.config["http_proxy_include"] + end + end + + proxy_host = nil + proxy_port = nil + proxy_user = nil + proxy_pass = nil + if @bot.config["http_proxy_user"] + proxy_user = @bot.config["http_proxy_user"] + if @bot.config["http_proxy_pass"] + proxy_pass = @bot.config["http_proxy_pass"] + end + end + if proxy + proxy_host = proxy.host + proxy_port = proxy.port + end + + return Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port, proxy_user, proxy_port) + end + + # uri:: uri to query (Uri object) + # readtimeout:: timeout for reading the response + # opentimeout:: timeout for opening the connection + # + # simple get request, returns response body if the status code is 200 and + # the request doesn't timeout. + def get(uri, readtimeout=10, opentimeout=5) + proxy = get_proxy(uri) + proxy.open_timeout = opentimeout + proxy.read_timeout = readtimeout + + begin + proxy.start() {|http| + resp = http.get(uri.request_uri(), @headers) + if resp.code == "200" + return resp.body + else + puts "HttpUtil.get return code #{resp.code} #{resp.body}" + end + return nil + } + rescue StandardError, Timeout::Error => e + $stderr.puts "HttpUtil.get exception: #{e}, while trying to get #{uri}" + end + return nil + end +end + +end diff --git a/lib/rbot/ircbot.rb b/lib/rbot/ircbot.rb new file mode 100644 index 00000000..844231dd --- /dev/null +++ b/lib/rbot/ircbot.rb @@ -0,0 +1,750 @@ +# Copyright (C) 2002 Tom Gilbert. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of the Software and its documentation and acknowledgment shall be +# given in the documentation and software packages that this Software was +# used. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +require 'thread' + +require 'rbot/rfc2812' +require 'rbot/keywords' +require 'rbot/config' +require 'rbot/ircsocket' +require 'rbot/auth' +require 'rbot/timer' +require 'rbot/plugins' +require 'rbot/channel' +require 'rbot/utils' +require 'rbot/message' +require 'rbot/language' +require 'rbot/dbhash' +require 'rbot/registry' +require 'rbot/httputil' + +module Irc + +# Main bot class, which receives messages, handles them or passes them to +# plugins, and stores runtime data +class IrcBot + # the bot's current nickname + attr_reader :nick + + # the bot's IrcAuth data + attr_reader :auth + + # the bot's BotConfig data + attr_reader :config + + # the botclass for this bot (determines configdir among other things) + attr_reader :botclass + + # used to perform actions periodically (saves configuration once per minute + # by default) + attr_reader :timer + + # bot's Language data + attr_reader :lang + + # bot's configured addressing prefixes + attr_reader :addressing_prefixes + + # channel info for channels the bot is in + attr_reader :channels + + # bot's object registry, plugins get an interface to this for persistant + # storage (hash interface tied to a bdb file, plugins use Accessors to store + # and restore objects in their own namespaces.) + attr_reader :registry + + # bot's httputil help object, for fetching resources via http. Sets up + # proxies etc as defined by the bot configuration/environment + attr_reader :httputil + + # create a new IrcBot with botclass +botclass+ + def initialize(botclass) + @botclass = botclass.gsub(/\/$/, "") + @startup_time = Time.new + + Dir.mkdir("#{botclass}") if(!File.exist?("#{botclass}")) + Dir.mkdir("#{botclass}/logs") if(!File.exist?("#{botclass}/logs")) + + @config = Irc::BotConfig.new(self) + @timer = Timer::Timer.new + @registry = BotRegistry.new self + @timer.add(@config['core.save_every']) { save } if @config['core.save_every'] + @channels = Hash.new + @logs = Hash.new + + @httputil = Irc::HttpUtil.new(self) + @lang = Irc::Language.new(@config['core.language']) + @keywords = Irc::Keywords.new(self) + @auth = Irc::IrcAuth.new(self) + @plugins = Irc::Plugins.new(self, ["#{botclass}/plugins"]) + + @socket = Irc::IrcSocket.new(@config['server.name'], @config['server.port'], @config['server.bindhost'], @config['server.sendq_delay'], @config['server.sendq_burst']) + @nick = @config['irc.nick'] + if @config['core.address_prefix'] + @addressing_prefixes = @config['core.address_prefix'].split(" ") + else + @addressing_prefixes = Array.new + end + + @client = Irc::IrcClient.new + @client["PRIVMSG"] = proc { |data| + message = PrivMessage.new(self, data["SOURCE"], data["TARGET"], data["MESSAGE"]) + onprivmsg(message) + } + @client["NOTICE"] = proc { |data| + message = NoticeMessage.new(self, data["SOURCE"], data["TARGET"], data["MESSAGE"]) + # pass it off to plugins that want to hear everything + @plugins.delegate "listen", message + } + @client["MOTD"] = proc { |data| + data['MOTD'].each_line { |line| + log "MOTD: #{line}", "server" + } + } + @client["NICKTAKEN"] = proc { |data| + nickchg "#{@nick}_" + } + @client["BADNICK"] = proc {|data| + puts "WARNING, bad nick (#{data['NICK']})" + } + @client["PING"] = proc {|data| + # (jump the queue for pongs) + @socket.puts "PONG #{data['PINGID']}" + } + @client["NICK"] = proc {|data| + sourcenick = data["SOURCENICK"] + nick = data["NICK"] + m = NickMessage.new(self, data["SOURCE"], data["SOURCENICK"], data["NICK"]) + if(sourcenick == @nick) + @nick = nick + end + @channels.each {|k,v| + if(v.users.has_key?(sourcenick)) + log "@ #{sourcenick} is now known as #{nick}", k + v.users[nick] = v.users[sourcenick] + v.users.delete(sourcenick) + end + } + @plugins.delegate("listen", m) + @plugins.delegate("nick", m) + } + @client["QUIT"] = proc {|data| + source = data["SOURCE"] + sourcenick = data["SOURCENICK"] + sourceurl = data["SOURCEADDRESS"] + message = data["MESSAGE"] + m = QuitMessage.new(self, data["SOURCE"], data["SOURCENICK"], data["MESSAGE"]) + if(data["SOURCENICK"] =~ /#{@nick}/i) + else + @channels.each {|k,v| + if(v.users.has_key?(sourcenick)) + log "@ Quit: #{sourcenick}: #{message}", k + v.users.delete(sourcenick) + end + } + end + @plugins.delegate("listen", m) + @plugins.delegate("quit", m) + } + @client["MODE"] = proc {|data| + source = data["SOURCE"] + sourcenick = data["SOURCENICK"] + sourceurl = data["SOURCEADDRESS"] + channel = data["CHANNEL"] + targets = data["TARGETS"] + modestring = data["MODESTRING"] + log "@ Mode #{modestring} #{targets} by #{sourcenick}", channel + } + @client["WELCOME"] = proc {|data| + log "joined server #{data['SOURCE']} as #{data['NICK']}", "server" + debug "I think my nick is #{@nick}, server thinks #{data['NICK']}" + if data['NICK'] && data['NICK'].length > 0 + @nick = data['NICK'] + end + if(@config['irc.quser']) + puts "authing with Q using #{@config['quakenet.user']} #{@config['quakenet.auth']}" + @socket.puts "PRIVMSG Q@CServe.quakenet.org :auth #{@config['quakenet.user']} #{@config['quakenet.auth']}" + end + + if(@config['irc.join_channels']) + @config['irc.join_channels'].split(", ").each {|c| + puts "autojoining channel #{c}" + if(c =~ /^(\S+)\s+(\S+)$/i) + join $1, $2 + else + join c if(c) + end + } + end + } + @client["JOIN"] = proc {|data| + m = JoinMessage.new(self, data["SOURCE"], data["CHANNEL"], data["MESSAGE"]) + onjoin(m) + } + @client["PART"] = proc {|data| + m = PartMessage.new(self, data["SOURCE"], data["CHANNEL"], data["MESSAGE"]) + onpart(m) + } + @client["KICK"] = proc {|data| + m = KickMessage.new(self, data["SOURCE"], data["TARGET"],data["CHANNEL"],data["MESSAGE"]) + onkick(m) + } + @client["INVITE"] = proc {|data| + if(data["TARGET"] =~ /^#{@nick}$/i) + join data["CHANNEL"] if (@auth.allow?("join", data["SOURCE"], data["SOURCENICK"])) + end + } + @client["CHANGETOPIC"] = proc {|data| + channel = data["CHANNEL"] + sourcenick = data["SOURCENICK"] + topic = data["TOPIC"] + timestamp = data["UNIXTIME"] || Time.now.to_i + if(sourcenick == @nick) + log "@ I set topic \"#{topic}\"", channel + else + log "@ #{sourcenick} set topic \"#{topic}\"", channel + end + m = TopicMessage.new(self, data["SOURCE"], data["CHANNEL"], timestamp, data["TOPIC"]) + + ontopic(m) + @plugins.delegate("listen", m) + @plugins.delegate("topic", m) + } + @client["TOPIC"] = @client["TOPICINFO"] = proc {|data| + channel = data["CHANNEL"] + m = TopicMessage.new(self, data["SOURCE"], data["CHANNEL"], data["UNIXTIME"], data["TOPIC"]) + ontopic(m) + } + @client["NAMES"] = proc {|data| + channel = data["CHANNEL"] + users = data["USERS"] + unless(@channels[channel]) + puts "bug: got names for channel '#{channel}' I didn't think I was in\n" + exit 2 + end + @channels[channel].users.clear + users.each {|u| + @channels[channel].users[u[0].sub(/^[@&~+]/, '')] = ["mode", u[1]] + } + } + @client["UNKNOWN"] = proc {|data| + debug "UNKNOWN: #{data['SERVERSTRING']}" + } + end + + # connect the bot to IRC + def connect + trap("SIGTERM") { quit } + trap("SIGHUP") { quit } + trap("SIGINT") { quit } + begin + @socket.connect + rescue => e + raise "failed to connect to IRC server at #{@config['server.name']} #{@config['server.port']}: " + e + end + @socket.puts "PASS " + @config['server.password'] if @config['server.password'] + @socket.puts "NICK #{@nick}\nUSER #{@config['server.user']} 4 #{@config['server.name']} :Ruby bot. (c) Tom Gilbert" + end + + # begin event handling loop + def mainloop + socket_timeout = 0.2 + reconnect_wait = 5 + + while true + connect + + begin + while true + if @socket.select socket_timeout + break unless reply = @socket.gets + @client.process reply + end + @timer.tick + end + rescue => e + puts "connection closed: #{e}" + puts e.backtrace.join("\n") + end + + puts "disconnected" + @channels.clear + @socket.clearq + + puts "waiting to reconnect" + sleep reconnect_wait + end + end + + # type:: message type + # where:: message target + # message:: message text + # send message +message+ of type +type+ to target +where+ + # Type can be PRIVMSG, NOTICE, etc, but those you should really use the + # relevant say() or notice() methods. This one should be used for IRCd + # extensions you want to use in modules. + def sendmsg(type, where, message) + # limit it 440 chars + CRLF.. so we have to split long lines + left = 440 - type.length - where.length - 3 + begin + if(left >= message.length) + sendq("#{type} #{where} :#{message}") + log_sent(type, where, message) + return + end + line = message.slice!(0, left) + lastspace = line.rindex(/\s+/) + if(lastspace) + message = line.slice!(lastspace, line.length) + message + message.gsub!(/^\s+/, "") + end + sendq("#{type} #{where} :#{line}") + log_sent(type, where, line) + end while(message.length > 0) + end + + def sendq(message="") + # temporary + @socket.queue(message) + end + + # send a notice message to channel/nick +where+ + def notice(where, message) + message.each_line { |line| + line.chomp! + next unless(line.length > 0) + sendmsg("NOTICE", where, line) + } + end + + # say something (PRIVMSG) to channel/nick +where+ + def say(where, message) + message.to_s.gsub(/[\r\n]+/, "\n").each_line { |line| + line.chomp! + next unless(line.length > 0) + unless((where =~ /^#/) && (@channels.has_key?(where) && @channels[where].quiet)) + sendmsg("PRIVMSG", where, line) + end + } + end + + # perform a CTCP action with message +message+ to channel/nick +where+ + def action(where, message) + sendq("PRIVMSG #{where} :\001ACTION #{message}\001") + if(where =~ /^#/) + log "* #{@nick} #{message}", where + elsif (where =~ /^(\S*)!.*$/) + log "* #{@nick}[#{where}] #{message}", $1 + else + log "* #{@nick}[#{where}] #{message}", where + end + end + + # quick way to say "okay" (or equivalent) to +where+ + def okay(where) + say where, @lang.get("okay") + end + + # log message +message+ to a file determined by +where+. +where+ can be a + # channel name, or a nick for private message logging + def log(message, where="server") + message.chomp! + stamp = Time.now.strftime("%Y/%m/%d %H:%M:%S") + unless(@logs.has_key?(where)) + @logs[where] = File.new("#{@botclass}/logs/#{where}", "a") + @logs[where].sync = true + end + @logs[where].puts "[#{stamp}] #{message}" + #debug "[#{stamp}] <#{where}> #{message}" + end + + # set topic of channel +where+ to +topic+ + def topic(where, topic) + sendq "TOPIC #{where} :#{topic}" + end + + # message:: optional IRC quit message + # quit IRC, shutdown the bot + def quit(message=nil) + trap("SIGTERM", "DEFAULT") + trap("SIGHUP", "DEFAULT") + trap("SIGINT", "DEFAULT") + message = @lang.get("quit") if (!message || message.length < 1) + @socket.clearq + save + @plugins.cleanup + @channels.each_value {|v| + log "@ quit (#{message})", v.name + } + @socket.puts "QUIT :#{message}" + @socket.flush + @socket.shutdown + @registry.close + puts "rbot quit (#{message})" + exit 0 + end + + # call the save method for bot's config, keywords, auth and all plugins + def save + @registry.flush + @config.save + @keywords.save + @auth.save + @plugins.save + end + + # call the rescan method for the bot's lang, keywords and all plugins + def rescan + @lang.rescan + @plugins.rescan + @keywords.rescan + end + + # channel:: channel to join + # key:: optional channel key if channel is +s + # join a channel + def join(channel, key=nil) + if(key) + sendq "JOIN #{channel} :#{key}" + else + sendq "JOIN #{channel}" + end + end + + # part a channel + def part(channel, message="") + sendq "PART #{channel} :#{message}" + end + + # attempt to change bot's nick to +name+ + def nickchg(name) + sendq "NICK #{name}" + end + + # changing mode + def mode(channel, mode, target) + sendq "MODE #{channel} #{mode} #{target}" + end + + # m:: message asking for help + # topic:: optional topic help is requested for + # respond to online help requests + def help(topic=nil) + topic = nil if topic == "" + case topic + when nil + helpstr = "help topics: core, auth, keywords" + helpstr += @plugins.helptopics + helpstr += " (help <topic> for more info)" + when /^core$/i + helpstr = corehelp + when /^core\s+(.+)$/i + helpstr = corehelp $1 + when /^auth$/i + helpstr = @auth.help + when /^auth\s+(.+)$/i + helpstr = @auth.help $1 + when /^keywords$/i + helpstr = @keywords.help + when /^keywords\s+(.+)$/i + helpstr = @keywords.help $1 + else + unless(helpstr = @plugins.help(topic)) + helpstr = "no help for topic #{topic}" + end + end + return helpstr + end + + def status + secs_up = Time.new - @startup_time + uptime = Utils.secs_to_string secs_up + return "Uptime #{uptime}, #{@plugins.length} plugins active, #{@registry.length} items stored in registry, #{@socket.lines_sent} lines sent, #{@socket.lines_received} received." + end + + + private + + # handle help requests for "core" topics + def corehelp(topic="") + case topic + when "quit" + return "quit [<message>] => quit IRC with message <message>" + when "join" + return "join <channel> [<key>] => join channel <channel> with secret key <key> if specified. #{@nick} also responds to invites if you have the required access level" + when "part" + return "part <channel> => part channel <channel>" + when "hide" + return "hide => part all channels" + when "save" + return "save => save current dynamic data and configuration" + when "rescan" + return "rescan => reload modules and static facts" + when "nick" + return "nick <nick> => attempt to change nick to <nick>" + when "say" + return "say <channel>|<nick> <message> => say <message> to <channel> or in private message to <nick>" + when "action" + return "action <channel>|<nick> <message> => does a /me <message> to <channel> or in private message to <nick>" + when "topic" + return "topic <channel> <message> => set topic of <channel> to <message>" + when "quiet" + return "quiet [in here|<channel>] => with no arguments, stop speaking in all channels, if \"in here\", stop speaking in this channel, or stop speaking in <channel>" + when "talk" + return "talk [in here|<channel>] => with no arguments, resume speaking in all channels, if \"in here\", resume speaking in this channel, or resume speaking in <channel>" + when "version" + return "version => describes software version" + when "botsnack" + return "botsnack => reward #{@nick} for being good" + when "hello" + return "hello|hi|hey|yo [#{@nick}] => greet the bot" + else + return "Core help topics: quit, join, part, hide, save, rescan, nick, say, action, topic, quiet, talk, version, botsnack, hello" + end + end + + # handle incoming IRC PRIVMSG +m+ + def onprivmsg(m) + # log it first + if(m.action?) + if(m.private?) + log "* [#{m.sourcenick}(#{m.sourceaddress})] #{m.message}", m.sourcenick + else + log "* #{m.sourcenick} #{m.message}", m.target + end + else + if(m.public?) + log "<#{m.sourcenick}> #{m.message}", m.target + else + log "[#{m.sourcenick}(#{m.sourceaddress})] #{m.message}", m.sourcenick + end + end + + # pass it off to plugins that want to hear everything + @plugins.delegate "listen", m + + if(m.private? && m.message =~ /^\001PING\s+(.+)\001/) + notice m.sourcenick, "\001PING #$1\001" + log "@ #{m.sourcenick} pinged me" + return + end + + if(m.address?) + case m.message + when (/^join\s+(\S+)\s+(\S+)$/i) + join $1, $2 if(@auth.allow?("join", m.source, m.replyto)) + when (/^join\s+(\S+)$/i) + join $1 if(@auth.allow?("join", m.source, m.replyto)) + when (/^part$/i) + part m.target if(m.public? && @auth.allow?("join", m.source, m.replyto)) + when (/^part\s+(\S+)$/i) + part $1 if(@auth.allow?("join", m.source, m.replyto)) + when (/^quit(?:\s+(.*))?$/i) + quit $1 if(@auth.allow?("quit", m.source, m.replyto)) + when (/^hide$/i) + join 0 if(@auth.allow?("join", m.source, m.replyto)) + when (/^save$/i) + if(@auth.allow?("config", m.source, m.replyto)) + save + m.okay + end + when (/^nick\s+(\S+)$/i) + nickchg($1) if(@auth.allow?("nick", m.source, m.replyto)) + when (/^say\s+(\S+)\s+(.*)$/i) + say $1, $2 if(@auth.allow?("say", m.source, m.replyto)) + when (/^action\s+(\S+)\s+(.*)$/i) + action $1, $2 if(@auth.allow?("say", m.source, m.replyto)) + when (/^topic\s+(\S+)\s+(.*)$/i) + topic $1, $2 if(@auth.allow?("topic", m.source, m.replyto)) + when (/^mode\s+(\S+)\s+(\S+)\s+(.*)$/i) + mode $1, $2, $3 if(@auth.allow?("mode", m.source, m.replyto)) + when (/^ping$/i) + say m.replyto, "pong" + when (/^rescan$/i) + if(@auth.allow?("config", m.source, m.replyto)) + m.okay + rescan + end + when (/^quiet$/i) + if(auth.allow?("talk", m.source, m.replyto)) + m.okay + @channels.each_value {|c| c.quiet = true } + end + when (/^quiet in (\S+)$/i) + where = $1 + if(auth.allow?("talk", m.source, m.replyto)) + m.okay + where.gsub!(/^here$/, m.target) if m.public? + @channels[where].quiet = true if(@channels.has_key?(where)) + end + when (/^talk$/i) + if(auth.allow?("talk", m.source, m.replyto)) + @channels.each_value {|c| c.quiet = false } + m.okay + end + when (/^talk in (\S+)$/i) + where = $1 + if(auth.allow?("talk", m.source, m.replyto)) + where.gsub!(/^here$/, m.target) if m.public? + @channels[where].quiet = false if(@channels.has_key?(where)) + m.okay + end + # TODO break this out into a config module + when (/^options get sendq_delay$/i) + if auth.allow?("config", m.source, m.replyto) + m.reply "options->sendq_delay = #{@socket.sendq_delay}" + end + when (/^options get sendq_burst$/i) + if auth.allow?("config", m.source, m.replyto) + m.reply "options->sendq_burst = #{@socket.sendq_burst}" + end + when (/^options set sendq_burst (.*)$/i) + num = $1.to_i + if auth.allow?("config", m.source, m.replyto) + @socket.sendq_burst = num + @config['irc.sendq_burst'] = num + m.okay + end + when (/^options set sendq_delay (.*)$/i) + freq = $1.to_f + if auth.allow?("config", m.source, m.replyto) + @socket.sendq_delay = freq + @config['irc.sendq_delay'] = freq + m.okay + end + when (/^status$/i) + m.reply status if auth.allow?("status", m.source, m.replyto) + when (/^registry stats$/i) + if auth.allow?("config", m.source, m.replyto) + m.reply @registry.stat.inspect + end + when (/^(version)|(introduce yourself)$/i) + say m.replyto, "I'm a v. #{$version} rubybot, (c) Tom Gilbert - http://linuxbrit.co.uk/rbot/" + when (/^help(?:\s+(.*))?$/i) + say m.replyto, help($1) + when (/^(botsnack|ciggie)$/i) + say m.replyto, @lang.get("thanks_X") % m.sourcenick if(m.public?) + say m.replyto, @lang.get("thanks") if(m.private?) + when (/^(hello|howdy|hola|salut|bonjour|sup|niihau|hey|hi(\W|$)|yo(\W|$)).*/i) + say m.replyto, @lang.get("hello_X") % m.sourcenick if(m.public?) + say m.replyto, @lang.get("hello") if(m.private?) + else + delegate_privmsg(m) + end + else + # stuff to handle when not addressed + case m.message + when (/^\s*(hello|howdy|hola|salut|bonjour|sup|niihau|hey|hi(\W|$)|yo(\W|$))\s+#{@nick}$/i) + say m.replyto, @lang.get("hello_X") % m.sourcenick + when (/^#{@nick}!*$/) + say m.replyto, @lang.get("hello_X") % m.sourcenick + else + @keywords.privmsg(m) + end + end + end + + # log a message. Internal use only. + def log_sent(type, where, message) + case type + when "NOTICE" + if(where =~ /^#/) + log "-=#{@nick}=- #{message}", where + elsif (where =~ /(\S*)!.*/) + log "[-=#{where}=-] #{message}", $1 + else + log "[-=#{where}=-] #{message}" + end + when "PRIVMSG" + if(where =~ /^#/) + log "<#{@nick}> #{message}", where + elsif (where =~ /^(\S*)!.*$/) + log "[msg(#{where})] #{message}", $1 + else + log "[msg(#{where})] #{message}", where + end + end + end + + def onjoin(m) + @channels[m.channel] = IRCChannel.new(m.channel) unless(@channels.has_key?(m.channel)) + if(m.address?) + log "@ Joined channel #{m.channel}", m.channel + puts "joined channel #{m.channel}" + else + log "@ #{m.sourcenick} joined channel #{m.channel}", m.channel + @channels[m.channel].users[m.sourcenick] = Hash.new + @channels[m.channel].users[m.sourcenick]["mode"] = "" + end + + @plugins.delegate("listen", m) + @plugins.delegate("join", m) + end + + def onpart(m) + if(m.address?) + log "@ Left channel #{m.channel} (#{m.message})", m.channel + @channels.delete(m.channel) + puts "left channel #{m.channel}" + else + log "@ #{m.sourcenick} left channel #{m.channel} (#{m.message})", m.channel + @channels[m.channel].users.delete(m.sourcenick) + end + + # delegate to plugins + @plugins.delegate("listen", m) + @plugins.delegate("part", m) + end + + # respond to being kicked from a channel + def onkick(m) + if(m.address?) + @channels.delete(m.channel) + log "@ You have been kicked from #{m.channel} by #{m.sourcenick} (#{m.message})", m.channel + puts "kicked from channel #{m.channel}" + else + @channels[m.channel].users.delete(m.sourcenick) + log "@ #{m.target} has been kicked from #{m.channel} by #{m.sourcenick} (#{m.message})", m.channel + end + + @plugins.delegate("listen", m) + @plugins.delegate("kick", m) + end + + def ontopic(m) + @channels[m.channel] = IRCChannel.new(m.channel) unless(@channels.has_key?(m.channel)) + @channels[m.channel].topic = m.topic if !m.topic.nil? + @channels[m.channel].topic.timestamp = m.timestamp if !m.timestamp.nil? + @channels[m.channel].topic.by = m.source if !m.source.nil? + + debug "topic of channel #{m.channel} is now #{@channels[m.channel].topic}" + end + + # delegate a privmsg to auth, keyword or plugin handlers + def delegate_privmsg(message) + [@auth, @plugins, @keywords].each {|m| + break if m.privmsg(message) + } + end + +end + +end diff --git a/lib/rbot/ircsocket.rb b/lib/rbot/ircsocket.rb new file mode 100644 index 00000000..35857736 --- /dev/null +++ b/lib/rbot/ircsocket.rb @@ -0,0 +1,186 @@ +module Irc + + require 'socket' + require 'thread' + + # wrapped TCPSocket for communication with the server. + # emulates a subset of TCPSocket functionality + class IrcSocket + # total number of lines sent to the irc server + attr_reader :lines_sent + + # total number of lines received from the irc server + attr_reader :lines_received + + # delay between lines sent + attr_reader :sendq_delay + + # max lines to burst + attr_reader :sendq_burst + + # server:: server to connect to + # port:: IRCd port + # host:: optional local host to bind to (ruby 1.7+ required) + # create a new IrcSocket + def initialize(server, port, host, sendq_delay=2, sendq_burst=4) + @server = server.dup + @port = port.to_i + @host = host + @lines_sent = 0 + @lines_received = 0 + if sendq_delay + @sendq_delay = sendq_delay.to_f + else + @sendq_delay = 2 + end + @last_send = Time.new - @sendq_delay + @burst = 0 + if sendq_burst + @sendq_burst = sendq_burst.to_i + else + @sendq_burst = 4 + end + end + + # open a TCP connection to the server + def connect + if(@host) + begin + @sock=TCPSocket.new(@server, @port, @host) + rescue ArgumentError => e + $stderr.puts "Your version of ruby does not support binding to a " + $stderr.puts "specific local address, please upgrade if you wish " + $stderr.puts "to use HOST = foo" + $stderr.puts "(this option has been disabled in order to continue)" + @sock=TCPSocket.new(@server, @port) + end + else + @sock=TCPSocket.new(@server, @port) + end + @qthread = false + @qmutex = Mutex.new + @sendq = Array.new + if (@sendq_delay > 0) + @qthread = Thread.new { spooler } + end + end + + def sendq_delay=(newfreq) + debug "changing sendq frequency to #{newfreq}" + @qmutex.synchronize do + @sendq_delay = newfreq + if newfreq == 0 && @qthread + clearq + Thread.kill(@qthread) + @qthread = false + elsif(newfreq != 0 && !@qthread) + @qthread = Thread.new { spooler } + end + end + end + + def sendq_burst=(newburst) + @qmutex.synchronize do + @sendq_burst = newburst + end + end + + # used to send lines to the remote IRCd + # message: IRC message to send + def puts(message) + @qmutex.synchronize do + # debug "In puts - got mutex" + puts_critical(message) + end + end + + # get the next line from the server (blocks) + def gets + reply = @sock.gets + @lines_received += 1 + if(reply) + reply.strip! + end + debug "RECV: #{reply.inspect}" + reply + end + + def queue(msg) + if @sendq_delay > 0 + @qmutex.synchronize do + # debug "QUEUEING: #{msg}" + @sendq.push msg + end + else + # just send it if queueing is disabled + self.puts(msg) + end + end + + def spooler + while true + spool + sleep 0.2 + end + end + + # pop a message off the queue, send it + def spool + unless @sendq.empty? + now = Time.new + if (now >= (@last_send + @sendq_delay)) + # reset burst counter after @sendq_delay has passed + @burst = 0 + debug "in spool, resetting @burst" + elsif (@burst >= @sendq_burst) + # nope. can't send anything + return + end + @qmutex.synchronize do + debug "(can send #{@sendq_burst - @burst} lines, there are #{@sendq.length} to send)" + (@sendq_burst - @burst).times do + break if @sendq.empty? + puts_critical(@sendq.shift) + end + end + end + end + + def clearq + unless @sendq.empty? + @qmutex.synchronize do + @sendq.clear + end + end + end + + # flush the TCPSocket + def flush + @sock.flush + end + + # Wraps Kernel.select on the socket + def select(timeout) + Kernel.select([@sock], nil, nil, timeout) + end + + # shutdown the connection to the server + def shutdown(how=2) + @sock.shutdown(how) + end + + private + + # same as puts, but expects to be called with a mutex held on @qmutex + def puts_critical(message) + # debug "in puts_critical" + debug "SEND: #{message.inspect}" + @sock.send(message + "\n",0) + @last_send = Time.new + @lines_sent += 1 + @burst += 1 + end + + end + +end diff --git a/lib/rbot/keywords.rb b/lib/rbot/keywords.rb new file mode 100644 index 00000000..3305af29 --- /dev/null +++ b/lib/rbot/keywords.rb @@ -0,0 +1,427 @@ +require 'pp' + +module Irc + + # Keyword class + # + # Encapsulates a keyword ("foo is bar" is a keyword called foo, with type + # is, and has a single value of bar). + # Keywords can have multiple values, to_s() will choose one at random + class Keyword + + # type of keyword (e.g. "is" or "are") + attr_reader :type + + # type:: type of keyword (e.g "is" or "are") + # values:: array of values + # + # create a keyword of type +type+ with values +values+ + def initialize(type, values) + @type = type.downcase + @values = values + end + + # pick a random value for this keyword and return it + def to_s + if(@values.length > 1) + Keyword.unescape(@values[rand(@values.length)]) + else + Keyword.unescape(@values[0]) + end + end + + # describe the keyword (show all values without interpolation) + def desc + @values.join(" | ") + end + + # return the keyword in a stringified form ready for storage + def dump + @type + "/" + Keyword.unescape(@values.join("<=or=>")) + end + + # deserialize the stringified form to an object + def Keyword.restore(str) + if str =~ /^(\S+?)\/(.*)$/ + type = $1 + vals = $2.split("<=or=>") + return Keyword.new(type, vals) + end + return nil + end + + # values:: array of values to add + # add values to a keyword + def <<(values) + if(@values.length > 1 || values.length > 1) + values.each {|v| + @values << v + } + else + @values[0] += " or " + values[0] + end + end + + # unescape special words/characters in a keyword + def Keyword.unescape(str) + str.gsub(/\\\|/, "|").gsub(/ \\is /, " is ").gsub(/ \\are /, " are ").gsub(/\\\?(\s*)$/, "?\1") + end + + # escape special words/characters in a keyword + def Keyword.escape(str) + str.gsub(/\|/, "\\|").gsub(/ is /, " \\is ").gsub(/ are /, " \\are ").gsub(/\?(\s*)$/, "\\?\1") + end + end + + # keywords class. + # + # Handles all that stuff like "bot: foo is bar", "bot: foo?" + # + # Fallback after core and auth have had a look at a message and refused to + # handle it, checks for a keyword command or lookup, otherwise the message + # is delegated to plugins + class Keywords + + # create a new Keywords instance, associated to bot +bot+ + def initialize(bot) + @bot = bot + @statickeywords = Hash.new + upgrade_data + @keywords = DBTree.new bot, "keyword" + + scan + + # import old format keywords into DBHash + if(File.exist?("#{@bot.botclass}/keywords.rbot")) + puts "auto importing old keywords.rbot" + IO.foreach("#{@bot.botclass}/keywords.rbot") do |line| + if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/) + lhs = $1 + mhs = $2 + rhs = $3 + mhs = "is" unless mhs + rhs = Keyword.escape rhs + values = rhs.split("<=or=>") + @keywords[lhs] = Keyword.new(mhs, values).dump + end + end + File.delete("#{@bot.botclass}/keywords.rbot") + end + end + + # drop static keywords and reload them from files, picking up any new + # keyword files that have been added + def rescan + @statickeywords = Hash.new + scan + end + + # load static keywords from files, picking up any new keyword files that + # have been added + def scan + # first scan for old DBHash files, and convert them + Dir["#{@bot.botclass}/keywords/*"].each {|f| + next unless f =~ /\.db$/ + puts "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format" + newname = f.gsub(/\.db$/, ".kdb") + old = BDB::Hash.open f, nil, + "r+", 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + new = BDB::CIBtree.open newname, nil, + BDB::CREATE | BDB::EXCL | BDB::TRUNCATE, + 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + old.each {|k,v| + new[k] = v + } + old.close + new.close + File.delete(f) + } + + # then scan for current DBTree files, and load them + Dir["#{@bot.botclass}/keywords/*"].each {|f| + next unless f =~ /\.kdb$/ + hsh = DBTree.new @bot, f, true + key = File.basename(f).gsub(/\.kdb$/, "") + debug "keywords module: loading DBTree file #{f}, key #{key}" + @statickeywords[key] = hsh + } + + # then scan for non DB files, and convert/import them and delete + Dir["#{@bot.botclass}/keywords/*"].each {|f| + next if f =~ /\.kdb$/ + next if f =~ /CVS$/ + puts "auto converting keywords from #{f}" + key = File.basename(f) + unless @statickeywords.has_key?(key) + @statickeywords[key] = DBHash.new @bot, "#{f}.db", true + end + IO.foreach(f) {|line| + if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/) + lhs = $1 + mhs = $2 + rhs = $3 + # support infobot style factfiles, by fixing them up here + rhs.gsub!(/\$who/, "<who>") + mhs = "is" unless mhs + rhs = Keyword.escape rhs + values = rhs.split("<=or=>") + @statickeywords[key][lhs] = Keyword.new(mhs, values).dump + end + } + File.delete(f) + @statickeywords[key].flush + } + end + + # upgrade data files found in old rbot formats to current + def upgrade_data + if File.exist?("#{@bot.botclass}/keywords.db") + puts "upgrading old keywords (rbot 0.9.5 or prior) database format" + old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil, + "r+", 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + new = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil, + BDB::CREATE | BDB::EXCL | BDB::TRUNCATE, + 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + old.each {|k,v| + new[k] = v + } + old.close + new.close + File.delete("#{@bot.botclass}/keywords.db") + end + end + + # save dynamic keywords to file + def save + @keywords.flush + end + def oldsave + File.open("#{@bot.botclass}/keywords.rbot", "w") do |file| + @keywords.each do |key, value| + file.puts "#{key}<=#{value.type}=>#{value.dump}" + end + end + end + + # lookup keyword +key+, return it or nil + def [](key) + debug "keywords module: looking up key #{key}" + if(@keywords.has_key?(key)) + return Keyword.restore(@keywords[key]) + else + # key name order for the lookup through these + @statickeywords.keys.sort.each {|k| + v = @statickeywords[k] + if v.has_key?(key) + return Keyword.restore(v[key]) + end + } + end + return nil + end + + # does +key+ exist as a keyword? + def has_key?(key) + if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil + return true + end + @statickeywords.each {|k,v| + if v.has_key?(key) && Keyword.restore(v[key]) != nil + return true + end + } + return false + end + + # m:: PrivMessage containing message info + # key:: key being queried + # dunno:: optional, if true, reply "dunno" if +key+ not found + # + # handle a message asking about a keyword + def keyword(m, key, dunno=true) + unless(kw = self[key]) + m.reply @bot.lang.get("dunno") if (dunno) + return + end + response = kw.to_s + response.gsub!(/<who>/, m.sourcenick) + if(response =~ /^<reply>\s*(.*)/) + m.reply "#$1" + elsif(response =~ /^<action>\s*(.*)/) + @bot.action m.replyto, "#$1" + elsif(m.public? && response =~ /^<topic>\s*(.*)/) + topic = $1 + @bot.topic m.target, topic + else + m.reply "#{key} #{kw.type} #{response}" + end + end + + + # m:: PrivMessage containing message info + # target:: channel/nick to tell about the keyword + # key:: key being queried + # + # handle a message asking the bot to tell someone about a keyword + def keyword_tell(m, target, key) + unless(kw = self[key]) + @bot.say m.sourcenick, @bot.lang.get("dunno_about_X") % key + return + end + response = kw.to_s + response.gsub!(/<who>/, m.sourcenick) + if(response =~ /^<reply>\s*(.*)/) + @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1" + m.reply "okay, I told #{target}: (#{key}) #$1" + elsif(response =~ /^<action>\s*(.*)/) + @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)" + m.reply "okay, I told #{target}: * #$1" + else + @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}" + m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}" + end + end + + # handle a message which alters a keyword + # like "foo is bar", or "no, foo is baz", or "foo is also qux" + def keyword_command(sourcenick, target, lhs, mhs, rhs, quiet=false) + debug "got keyword command #{lhs}, #{mhs}, #{rhs}" + overwrite = false + overwrite = true if(lhs.gsub!(/^no,\s*/, "")) + also = true if(rhs.gsub!(/^also\s+/, "")) + values = rhs.split(/\s+\|\s+/) + lhs = Keyword.unescape lhs + if(overwrite || also || !has_key?(lhs)) + if(also && has_key?(lhs)) + kw = self[lhs] + kw << values + @keywords[lhs] = kw.dump + else + @keywords[lhs] = Keyword.new(mhs, values).dump + end + @bot.okay target if !quiet + elsif(has_key?(lhs)) + kw = self[lhs] + @bot.say target, "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet + end + end + + # return help string for Keywords with option topic +topic+ + def help(topic="") + case topic + when "overview" + return "set: <keyword> is <definition>, overide: no, <keyword> is <definition>, add to definition: <keyword> is also <definition>, random responses: <keyword> is <definition> | <definition> [| ...], plurals: <keyword> are <definition>, escaping: \\is, \\are, \\|, specials: <reply>, <action>, <who>" + when "set" + return "set => <keyword> is <definition>" + when "plurals" + return "plurals => <keywords> are <definition>" + when "override" + return "overide => no, <keyword> is <definition>" + when "also" + return "also => <keyword> is also <definition>" + when "random" + return "random responses => <keyword> is <definition> | <definition> [| ...]" + when "get" + return "asking for keywords => (with addressing) \"<keyword>?\", (without addressing) \"'<keyword>\"" + when "tell" + return "tell <nick> about <keyword> => if <keyword> is known, tell <nick>, via /msg, its definition" + when "forget" + return "forget <keyword> => forget fact <keyword>" + when "keywords" + return "keywords => show current keyword counts" + when "<reply>" + return "<reply> => normal response is \"<keyword> is <definition>\", but if <definition> begins with <reply>, the response will be \"<definition>\"" + when "<action>" + return "<action> => makes keyword respnse \"/me <definition>\"" + when "<who>" + return "<who> => replaced with questioner in reply" + when "<topic>" + return "<topic> => respond by setting the topic to the rest of the definition" + when "search" + return "keywords search [--all] [--full] <regexp> => search keywords for <regexp>. If --all is set, search static keywords too, if --full is set, search definitions too." + else + return "Keyword module (Fact learning and regurgitation) topics: overview, set, plurals, override, also, random, get, tell, forget, keywords, keywords search, <reply>, <action>, <who>, <topic>" + end + end + + # privmsg handler + def privmsg(m) + return if m.replied? + if(m.address?) + if(!(m.message =~ /\\\?\s*$/) && m.message =~ /^(.*\S)\s*\?\s*$/) + keyword m, $1 if(@bot.auth.allow?("keyword", m.source, m.replyto)) + elsif(m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/) + keyword_command(m.sourcenick, m.replyto, $1, $2, $3) if(@bot.auth.allow?("keycmd", m.source, m.replyto)) + elsif (m.message =~ /^tell\s+(\S+)\s+about\s+(.+)$/) + keyword_tell(m, $1, $2) if(@bot.auth.allow?("keyword", m.source, m.replyto)) + elsif (m.message =~ /^forget\s+(.*)$/) + key = $1 + if((@bot.auth.allow?("keycmd", m.source, m.replyto)) && @keywords.has_key?(key)) + @keywords.delete(key) + @bot.okay m.replyto + end + elsif (m.message =~ /^keywords$/) + if(@bot.auth.allow?("keyword", m.source, m.replyto)) + length = 0 + @statickeywords.each {|k,v| + length += v.length + } + m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined." + end + elsif (m.message =~ /^keywords search\s+(.*)$/) + str = $1 + all = false + all = true if str.gsub!(/--all\s+/, "") + full = false + full = true if str.gsub!(/--full\s+/, "") + + re = Regexp.new(str, Regexp::IGNORECASE) + if(@bot.auth.allow?("keyword", m.source, m.replyto)) + matches = Array.new + @keywords.each {|k,v| + kw = Keyword.restore(v) + if re.match(k) || (full && re.match(kw.desc)) + matches << [k,kw] + end + } + if all + @statickeywords.each {|k,v| + v.each {|kk,vv| + kw = Keyword.restore(vv) + if re.match(kk) || (full && re.match(kw.desc)) + matches << [kk,kw] + end + } + } + end + if matches.length == 1 + rkw = matches[0] + m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" + elsif matches.length > 0 + i = 0 + matches.each {|rkw| + m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" + i += 1 + break if i == 3 + } + else + m.reply "no keywords match #{str}" + end + end + end + else + # in channel message, not to me + if(m.message =~ /^'(.*)$/ || (!@bot.config["keyword.noaddress"] && m.message =~ /^(.*\S)\s*\?\s*$/)) + keyword m, $1, false if(@bot.auth.allow?("keyword", m.source)) + elsif(@bot.config["keyword.listen"] == true && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)) + # TODO MUCH more selective on what's allowed here + keyword_command(m.sourcenick, m.replyto, $1, $2, $3, true) if(@bot.auth.allow?("keycmd", m.source)) + end + end + end + end +end diff --git a/lib/rbot/language.rb b/lib/rbot/language.rb new file mode 100644 index 00000000..9788b2bb --- /dev/null +++ b/lib/rbot/language.rb @@ -0,0 +1,55 @@ +module Irc + + class Language + def initialize(language, file="") + @language = language + if file.empty? + file = File.dirname(__FILE__) + "/languages/#{@language}.lang" + end + unless(FileTest.exist?(file)) + raise "no such language: #{@language} (no such file #{file})" + end + @file = file + scan + end + + def scan + @strings = Hash.new + current_key = nil + IO.foreach(@file) {|l| + next if l =~ /^$/ + next if l =~ /^\s*#/ + if(l =~ /^(\S+):$/) + @strings[$1] = Array.new + current_key = $1 + elsif(l =~ /^\s*(.*)$/) + @strings[current_key] << $1 + end + } + end + + def rescan + scan + end + + def get(key) + if(@strings.has_key?(key)) + return @strings[key][rand(@strings[key].length)] + else + raise "undefined language key" + end + end + + def save + File.open(@file, "w") {|file| + @strings.each {|key,val| + file.puts "#{key}:" + val.each_value {|v| + file.puts " #{v}" + } + } + } + end + end + +end diff --git a/lib/rbot/languages/dutch.lang b/lib/rbot/languages/dutch.lang new file mode 100644 index 00000000..db116264 --- /dev/null +++ b/lib/rbot/languages/dutch.lang @@ -0,0 +1,73 @@ +okay: + ok + ok dan :) + goed + mooi + voila + in orde + 't is gebeurd + zeker + dat kan ik! + komt in orde + k + ik zal het eens doen +dunno: + geen idee + dat weet ik niet + dat gaat m'n petje te boven + *haal schouder op* + vraag dat aan iemand anders + dat moet je niet aan mij vragen + wie weet dat? + dat kan ik niet + laat je eens nakijken... + wat vraag je nu? +dunno_about_X: + maar ik weet niks over %s + Ik heb nog nooit van %s gehoord :( + maar wat is %s? +insult: + %s: idioot! + %s: :( + %s: Ik haat je:( + %s: val dood! + %s: Ik ben beledigd! +hello: + hallo :) + hey! + hi + yo + yow + joe + jowjowjow +hello_X: + hallo %s :) + %s: hallo + hey %s :) + %s: hi! + yo %s! + joe %s! + alles ok %s? + %s: alles goed? +welcome: + geen probleem + 't is niks + altijd welkom + graag gedaan + np :) +thanks: + danku :) + bedankt! + thx :) + =D + je bent een schatje :) +thanks_X: + %s: danku :) + %s: bedankt! + %s: =D + %s: thx :) + %s: je bent een schatje :) +quit: + ok ik ben weg + yo + ciao diff --git a/lib/rbot/languages/english.lang b/lib/rbot/languages/english.lang new file mode 100644 index 00000000..f275d82f --- /dev/null +++ b/lib/rbot/languages/english.lang @@ -0,0 +1,75 @@ +okay: + okay + okay then :) + okies! + fine + done + can do! + alright + sure + aight + lemme take care of that for you +dunno: + dunno + beats me + no idea + no clue + ...eh? + *shrug* + don't ask me + who knows? + I can't do that Dave. + you best check yo'self! +dunno_about_X: + but I dunno anything about %s + I never heard of %s :( + %s? what's that then? + but what's %s? +insult: + %s: wanker! + %s: :( + %s: I hate you :( + %s: die! + %s: I'm offended! + %s: you hurt my feelings +hello: + hello :) + hola :) + salut + hey! + word. + hi + yo + 'sup? +hello_X: + hello %s :) + %s: hey there + %s: hola :) + %s: salut + hey %s :) + %s: word + %s: hi! + yo %s! + %s: 'sup? + 'sup %s? +welcome: + no probbie + you're welcome + de nada + any time + np :) +thanks: + thanks :) + schweet! + ta :) + =D + cheers! +thanks_X: + %s: thanks :) + %s: schweet! + %s: =D + %s: ta :) + %s: cheers +quit: + okay bye + seeya diff --git a/lib/rbot/languages/german.lang b/lib/rbot/languages/german.lang new file mode 100644 index 00000000..3e23268d --- /dev/null +++ b/lib/rbot/languages/german.lang @@ -0,0 +1,67 @@ +okay: + okay + okay na dann :) + gut + gemacht + wird gemacht! + also los + sicher + klar + lass mich sorge tragen :-) +dunno: + kann nicht + schlag mich + habe keine Idee + habe keine Ahnung + *achselzuck* + frag mich nicht + wer weiss? + ich kann es nicht tun + am besten du schaust selber nach +dunno_about_X: + ich weiss nichts uber %s + was zum Teufel ist %s? +insult: + %s: Arsch! + %s: :( + %s: Ich hasse dich :( + %s: Stirb! + %s: Ich bin beleidigt! +hello: + hallo :) + hola :) + salut + hey! + sag nichts. + hi + yo + Was geht? +hello_X: + hallo %s :) + %s: guggus + %s: hola :) + %s: salut + hey %s :) + %s: sag nichts + %s: hi! + yo %s! + %s: Was geht? + Was geht %s? +welcome: + no probbie + you're welcome + de nada + any time + np :) +thanks: + Danke :) + juhu :) + :-D + Prost! +thanks_X: + %s: danke :) + %s: :-D + %s: juhu :) + %s: Prost +quit: + forkbomb rockt diff --git a/lib/rbot/message.rb b/lib/rbot/message.rb new file mode 100644 index 00000000..d7f614ab --- /dev/null +++ b/lib/rbot/message.rb @@ -0,0 +1,256 @@ +module Irc + + # base user message class, all user messages derive from this + # (a user message is defined as having a source hostmask, a target + # nick/channel and a message part) + class BasicUserMessage + + # associated bot + attr_reader :bot + + # when the message was received + attr_reader :time + + # hostmask of message source + attr_reader :source + + # nick of message source + attr_reader :sourcenick + + # url part of message source + attr_reader :sourceaddress + + # nick/channel message was sent to + attr_reader :target + + # contents of the message + attr_accessor :message + + # has the message been replied to/handled by a plugin? + attr_accessor :replied + + # instantiate a new Message + # bot:: associated bot class + # source:: hostmask of the message source + # target:: nick/channel message is destined for + # message:: message part + def initialize(bot, source, target, message) + @time = Time.now + @bot = bot + @source = source + @address = false + @target = target + @message = BasicUserMessage.stripcolour message + @replied = false + + # split source into consituent parts + if source =~ /^((\S+)!(\S+))$/ + @sourcenick = $2 + @sourceaddress = $3 + end + + if target && target.downcase == @bot.nick.downcase + @address = true + end + + end + + # returns true if the message was addressed to the bot. + # This includes any private message to the bot, or any public message + # which looks like it's addressed to the bot, e.g. "bot: foo", "bot, foo", + # a kick message when bot was kicked etc. + def address? + return @address + end + + # has this message been replied to by a plugin? + def replied? + return @replied + end + + # strip mIRC colour escapes from a string + def BasicUserMessage.stripcolour(string) + return "" unless string + ret = string.gsub(/\cC\d\d?(?:,\d\d?)?/, "") + #ret.tr!("\x00-\x1f", "") + ret + end + + end + + # class for handling IRC user messages. Includes some utilities for handling + # the message, for example in plugins. + # The +message+ member will have any bot addressing "^bot: " removed + # (address? will return true in this case) + class UserMessage < BasicUserMessage + + # for plugin messages, the name of the plugin invoked by the message + attr_reader :plugin + + # for plugin messages, the rest of the message, with the plugin name + # removed + attr_reader :params + + # convenience member. Who to reply to (i.e. would be sourcenick for a + # privately addressed message, or target (the channel) for a publicly + # addressed message + attr_reader :replyto + + # channel the message was in, nil for privately addressed messages + attr_reader :channel + + # for PRIVMSGs, true if the message was a CTCP ACTION (CTCP stuff + # will be stripped from the message) + attr_reader :action + + # instantiate a new UserMessage + # bot:: associated bot class + # source:: hostmask of the message source + # target:: nick/channel message is destined for + # message:: message part + def initialize(bot, source, target, message) + super(bot, source, target, message) + @target = target + @private = false + @plugin = nil + @action = false + + if target.downcase == @bot.nick.downcase + @private = true + @address = true + @channel = nil + @replyto = @sourcenick + else + @replyto = @target + @channel = @target + end + + # check for option extra addressing prefixes, e.g "|search foo", or + # "!version" - first match wins + bot.addressing_prefixes.each {|mprefix| + if @message.gsub!(/^#{Regexp.escape(mprefix)}\s*/, "") + @address = true + break + end + } + + # even if they used above prefixes, we allow for silly people who + # combine all possible types, e.g. "|rbot: hello", or + # "/msg rbot rbot: hello", etc + if @message.gsub!(/^\s*#{bot.nick}\s*([:;,>]|\s)\s*/, "") + @address = true + end + + if(@message =~ /^\001ACTION\s(.+)\001/) + @message = $1 + @action = true + end + + # free splitting for plugins + @params = @message.dup + if @params.gsub!(/^\s*(\S+)[\s$]*/, "") + @plugin = $1.downcase + @params = nil unless @params.length > 0 + end + end + + # returns true for private messages, e.g. "/msg bot hello" + def private? + return @private + end + + # returns true if the message was in a channel + def public? + return !@private + end + + def action? + return @action + end + + # convenience method to reply to a message, useful in plugins. It's the + # same as doing: + # <tt>@bot.say m.replyto, string</tt> + # So if the message is private, it will reply to the user. If it was + # in a channel, it will reply in the channel. + def reply(string) + @bot.say @replyto, string + @replied = true + end + + # convenience method to reply "okay" in the current language to the + # message + def okay + @bot.say @replyto, @bot.lang.get("okay") + end + + end + + # class to manage IRC PRIVMSGs + class PrivMessage < UserMessage + end + + # class to manage IRC NOTICEs + class NoticeMessage < UserMessage + end + + # class to manage IRC KICKs + # +address?+ can be used as a shortcut to see if the bot was kicked, + # basically, +target+ was kicked from +channel+ by +source+ with +message+ + class KickMessage < BasicUserMessage + # channel user was kicked from + attr_reader :channel + + def initialize(bot, source, target, channel, message="") + super(bot, source, target, message) + @channel = channel + end + end + + # class to pass IRC Nick changes in. @message contains the old nickame, + # @sourcenick contains the new one. + class NickMessage < BasicUserMessage + def initialize(bot, source, oldnick, newnick) + super(bot, source, oldnick, newnick) + end + end + + class QuitMessage < BasicUserMessage + def initialize(bot, source, target, message="") + super(bot, source, target, message) + end + end + + class TopicMessage < BasicUserMessage + # channel topic + attr_reader :topic + # topic set at (unixtime) + attr_reader :timestamp + # topic set on channel + attr_reader :channel + + def initialize(bot, source, channel, timestamp, topic="") + super(bot, source, channel, topic) + @topic = topic + @timestamp = timestamp + @channel = channel + end + end + + # class to manage channel joins + class JoinMessage < BasicUserMessage + # channel joined + attr_reader :channel + def initialize(bot, source, channel, message="") + super(bot, source, channel, message) + @channel = channel + # in this case sourcenick is the nick that could be the bot + @address = (sourcenick.downcase == @bot.nick.downcase) + end + end + + # class to manage channel parts + # same as a join, but can have a message too + class PartMessage < JoinMessage + end +end diff --git a/lib/rbot/messagemapper.rb b/lib/rbot/messagemapper.rb new file mode 100644 index 00000000..42563d23 --- /dev/null +++ b/lib/rbot/messagemapper.rb @@ -0,0 +1,168 @@ +module Irc + class MessageMapper + attr_writer :fallback + + def initialize(parent) + @parent = parent + @routes = Array.new + @fallback = 'usage' + end + + def map(*args) + @routes << Route.new(*args) + end + + def each + @routes.each {|route| yield route} + end + def last + @routes.last + end + + def handle(m) + return false if @routes.empty? + failures = [] + @routes.each do |route| + options, failure = route.recognize(m) + if options.nil? + failures << [route, failure] + else + action = route.options[:action] ? route.options[:action] : route.items[0] + next unless @parent.respond_to?(action) + auth = route.options[:auth] ? route.options[:auth] : action + if m.bot.auth.allow?(auth, m.source, m.replyto) + debug "route found and auth'd: #{action.inspect} #{options.inspect}" + @parent.send(action, m, options) + return true + end + # if it's just an auth failure but otherwise the match is good, + # don't try any more handlers + break + end + end + debug failures.inspect + debug "no handler found, trying fallback" + if @fallback != nil && @parent.respond_to?(@fallback) + if m.bot.auth.allow?(@fallback, m.source, m.replyto) + @parent.send(@fallback, m, {}) + return true + end + end + return false + end + + end + + class Route + attr_reader :defaults # The defaults hash + attr_reader :options # The options hash + attr_reader :items + def initialize(template, hash={}) + raise ArgumentError, "Second argument must be a hash!" unless hash.kind_of?(Hash) + @defaults = hash[:defaults].kind_of?(Hash) ? hash.delete(:defaults) : {} + @requirements = hash[:requirements].kind_of?(Hash) ? hash.delete(:requirements) : {} + self.items = template + @options = hash + end + def items=(str) + items = str.split(/\s+/).collect {|c| (/^(:|\*)(\w+)$/ =~ c) ? (($1 == ':' ) ? $2.intern : "*#{$2}".intern) : c} if str.kind_of?(String) # split and convert ':xyz' to symbols + items.shift if items.first == "" + items.pop if items.last == "" + @items = items + + if @items.first.kind_of? Symbol + raise ArgumentError, "Illegal template -- first component cannot be dynamic\n #{str.inspect}" + end + + # Verify uniqueness of each component. + @items.inject({}) do |seen, item| + if item.kind_of? Symbol + raise ArgumentError, "Illegal template -- duplicate item #{item}\n #{str.inspect}" if seen.key? item + seen[item] = true + end + seen + end + end + + # Recognize the provided string components, returning a hash of + # recognized values, or [nil, reason] if the string isn't recognized. + def recognize(m) + components = m.message.split(/\s+/) + options = {} + + @items.each do |item| + if /^\*/ =~ item.to_s + if components.empty? + value = @defaults.has_key?(item) ? @defaults[item].clone : [] + else + value = components.clone + end + components = [] + def value.to_s() self.join(' ') end + options[item.to_s.sub(/^\*/,"").intern] = value + elsif item.kind_of? Symbol + value = components.shift || @defaults[item] + if passes_requirements?(item, value) + options[item] = value + else + if @defaults.has_key?(item) + debug "item #{item} doesn't pass reqs but has a default of #{@defaults[item]}" + options[item] = @defaults[item].clone + # push the test-failed component back on the stack + components.unshift value + else + return nil, requirements_for(item) + end + end + else + return nil, "No value available for component #{item.inspect}" if components.empty? + component = components.shift + return nil, "Value for component #{item.inspect} doesn't match #{component}" if component != item + end + end + + return nil, "Unused components were left: #{components.join '/'}" unless components.empty? + + return nil, "route is not configured for private messages" if @options.has_key?(:private) && !@options[:private] && m.private? + return nil, "route is not configured for public messages" if @options.has_key?(:public) && !@options[:public] && !m.private? + + options.delete_if {|k, v| v.nil?} # Remove nil values. + return options, nil + end + + def inspect + when_str = @requirements.empty? ? "" : " when #{@requirements.inspect}" + default_str = @defaults.empty? ? "" : " || #{@defaults.inspect}" + "<#{self.class.to_s} #{@items.collect{|c| c.kind_of?(String) ? c : c.inspect}.join(' ').inspect}#{default_str}#{when_str}>" + end + + # Verify that the given value passes this route's requirements + def passes_requirements?(name, value) + return @defaults.key?(name) && @defaults[name].nil? if value.nil? # Make sure it's there if it should be + + case @requirements[name] + when nil then true + when Regexp then + value = value.to_s + match = @requirements[name].match(value) + match && match[0].length == value.length + else + @requirements[name] == value.to_s + end + end + + def requirements_for(name) + name = name.to_s.sub(/^\*/,"").intern if (/^\*/ =~ name.inspect) + presence = (@defaults.key?(name) && @defaults[name].nil?) + requirement = case @requirements[name] + when nil then nil + when Regexp then "match #{@requirements[name].inspect}" + else "be equal to #{@requirements[name].inspect}" + end + if presence && requirement then "#{name} must be present and #{requirement}" + elsif presence || requirement then "#{name} must #{requirement || 'be present'}" + else "#{name} has no requirements" + end + end + end +end diff --git a/lib/rbot/plugins.rb b/lib/rbot/plugins.rb new file mode 100644 index 00000000..5db047fb --- /dev/null +++ b/lib/rbot/plugins.rb @@ -0,0 +1,300 @@ +module Irc + require 'rbot/messagemapper' + + # base class for all rbot plugins + # certain methods will be called if they are provided, if you define one of + # the following methods, it will be called as appropriate: + # + # map(template, options):: + # map is the new, cleaner way to respond to specific message formats + # without littering your plugin code with regexps + # examples: + # plugin.map 'karmastats', :action => 'karma_stats' + # + # # while in the plugin... + # def karma_stats(m, params) + # m.reply "..." + # end + # + # # the default action is the first component + # plugin.map 'karma' + # + # # attributes can be pulled out of the match string + # plugin.map 'karma for :key' + # plugin.map 'karma :key' + # + # # while in the plugin... + # def karma(m, params) + # item = params[:key] + # m.reply 'karma for #{item}' + # end + # + # # you can setup defaults, to make parameters optional + # plugin.map 'karma :key', :defaults => {:key => 'defaultvalue'} + # + # # the default auth check is also against the first component + # # but that can be changed + # plugin.map 'karmastats', :auth => 'karma' + # + # # maps can be restricted to public or private message: + # plugin.map 'karmastats', :private false, + # plugin.map 'karmastats', :public false, + # end + # + # To activate your maps, you simply register them + # plugin.register_maps + # This also sets the privmsg handler to use the map lookups for + # handling messages. You can still use listen(), kick() etc methods + # + # listen(UserMessage):: + # Called for all messages of any type. To + # differentiate them, use message.kind_of? It'll be + # either a PrivMessage, NoticeMessage, KickMessage, + # QuitMessage, PartMessage, JoinMessage, NickMessage, + # etc. + # + # privmsg(PrivMessage):: + # called for a PRIVMSG if the first word matches one + # the plugin register()d for. Use m.plugin to get + # that word and m.params for the rest of the message, + # if applicable. + # + # kick(KickMessage):: + # Called when a user (or the bot) is kicked from a + # channel the bot is in. + # + # join(JoinMessage):: + # Called when a user (or the bot) joins a channel + # + # part(PartMessage):: + # Called when a user (or the bot) parts a channel + # + # quit(QuitMessage):: + # Called when a user (or the bot) quits IRC + # + # nick(NickMessage):: + # Called when a user (or the bot) changes Nick + # topic(TopicMessage):: + # Called when a user (or the bot) changes a channel + # topic + # + # save:: Called when you are required to save your plugin's + # state, if you maintain data between sessions + # + # cleanup:: called before your plugin is "unloaded", prior to a + # plugin reload or bot quit - close any open + # files/connections or flush caches here + class Plugin + attr_reader :bot # the associated bot + # initialise your plugin. Always call super if you override this method, + # as important variables are set up for you + def initialize + @bot = Plugins.bot + @names = Array.new + @handler = MessageMapper.new(self) + @registry = BotRegistryAccessor.new(@bot, self.class.to_s.gsub(/^.*::/, "")) + end + + def map(*args) + @handler.map(*args) + # register this map + name = @handler.last.items[0] + self.register name + unless self.respond_to?('privmsg') + def self.privmsg(m) + @handler.handle(m) + end + end + end + + # return an identifier for this plugin, defaults to a list of the message + # prefixes handled (used for error messages etc) + def name + @names.join("|") + end + + # return a help string for your module. for complex modules, you may wish + # to break your help into topics, and return a list of available topics if + # +topic+ is nil. +plugin+ is passed containing the matching prefix for + # this message - if your plugin handles multiple prefixes, make sure your + # return the correct help for the prefix requested + def help(plugin, topic) + "no help" + end + + # register the plugin as a handler for messages prefixed +name+ + # this can be called multiple times for a plugin to handle multiple + # message prefixes + def register(name) + return if Plugins.plugins.has_key?(name) + Plugins.plugins[name] = self + @names << name + end + + # default usage method provided as a utility for simple plugins. The + # MessageMapper uses 'usage' as its default fallback method. + def usage(m, params) + m.reply "incorrect usage, ask for help using '#{@bot.nick}: help #{m.plugin}'" + end + + end + + # class to manage multiple plugins and delegate messages to them for + # handling + class Plugins + # hash of registered message prefixes and associated plugins + @@plugins = Hash.new + # associated IrcBot class + @@bot = nil + + # bot:: associated IrcBot class + # dirlist:: array of directories to scan (in order) for plugins + # + # create a new plugin handler, scanning for plugins in +dirlist+ + def initialize(bot, dirlist) + @@bot = bot + @dirs = dirlist + scan + end + + # access to associated bot + def Plugins.bot + @@bot + end + + # access to list of plugins + def Plugins.plugins + @@plugins + end + + # load plugins from pre-assigned list of directories + def scan + dirs = Array.new + dirs << File.dirname(__FILE__) + "/plugins" + dirs += @dirs + dirs.each {|dir| + if(FileTest.directory?(dir)) + d = Dir.new(dir) + d.each {|file| + next if(file =~ /^\./) + next unless(file =~ /\.rb$/) + @tmpfilename = "#{dir}/#{file}" + + # create a new, anonymous module to "house" the plugin + plugin_module = Module.new + + begin + plugin_string = IO.readlines(@tmpfilename).join("") + puts "loading module: #{@tmpfilename}" + plugin_module.module_eval(plugin_string) + rescue StandardError, NameError, LoadError, SyntaxError => err + puts "plugin #{@tmpfilename} load failed: " + err + puts err.backtrace.join("\n") + end + } + end + } + end + + # call the save method for each active plugin + def save + @@plugins.values.uniq.each {|p| + next unless(p.respond_to?("save")) + begin + p.save + rescue StandardError, NameError, SyntaxError => err + puts "plugin #{p.name} save() failed: " + err + puts err.backtrace.join("\n") + end + } + end + + # call the cleanup method for each active plugin + def cleanup + @@plugins.values.uniq.each {|p| + next unless(p.respond_to?("cleanup")) + begin + p.cleanup + rescue StandardError, NameError, SyntaxError => err + puts "plugin #{p.name} cleanup() failed: " + err + puts err.backtrace.join("\n") + end + } + end + + # drop all plugins and rescan plugins on disk + # calls save and cleanup for each plugin before dropping them + def rescan + save + cleanup + @@plugins = Hash.new + scan + end + + # return list of help topics (plugin names) + def helptopics + if(@@plugins.length > 0) + # return " [plugins: " + @@plugins.keys.sort.join(", ") + "]" + return " [#{length} plugins: " + @@plugins.values.uniq.collect{|p| p.name}.sort.join(", ") + "]" + else + return " [no plugins active]" + end + end + + def length + @@plugins.values.uniq.length + end + + # return help for +topic+ (call associated plugin's help method) + def help(topic="") + if(topic =~ /^(\S+)\s*(.*)$/) + key = $1 + params = $2 + if(@@plugins.has_key?(key)) + begin + return @@plugins[key].help(key, params) + rescue StandardError, NameError, SyntaxError => err + puts "plugin #{@@plugins[key].name} help() failed: " + err + puts err.backtrace.join("\n") + end + else + return false + end + end + end + + # see if each plugin handles +method+, and if so, call it, passing + # +message+ as a parameter + def delegate(method, message) + @@plugins.values.uniq.each {|p| + if(p.respond_to? method) + begin + p.send method, message + rescue StandardError, NameError, SyntaxError => err + puts "plugin #{p.name} #{method}() failed: " + err + puts err.backtrace.join("\n") + end + end + } + end + + # see if we have a plugin that wants to handle this message, if so, pass + # it to the plugin and return true, otherwise false + def privmsg(m) + return unless(m.plugin) + if (@@plugins.has_key?(m.plugin) && + @@plugins[m.plugin].respond_to?("privmsg") && + @@bot.auth.allow?(m.plugin, m.source, m.replyto)) + begin + @@plugins[m.plugin].privmsg(m) + rescue StandardError, NameError, SyntaxError => err + puts "plugin #{@@plugins[m.plugin].name} privmsg() failed: " + err + puts err.backtrace.join("\n") + end + return true + end + return false + end + end + +end diff --git a/lib/rbot/plugins/autoop.rb b/lib/rbot/plugins/autoop.rb new file mode 100644 index 00000000..fdbcf6e0 --- /dev/null +++ b/lib/rbot/plugins/autoop.rb @@ -0,0 +1,68 @@ +class AutoOP < Plugin + @@handlers = { + "addop" => "handle_addop", + "rmop" => "handle_rmop", + "listop" => "handle_listop" + } + + def help(plugin, topic="") + "perform autoop based on hostmask - usage: addop <hostmask>, rmop <hostmask>, listop" + end + + def join(m) + if(!m.address?) + @registry.each { |mask,channels| + if(Irc.netmaskmatch(mask, m.source) && channels.include?(m.channel)) + @bot.mode(m.channel, "+o", m.sourcenick) + end + } + end + end + + def privmsg(m) + if(m.private?) + if (!m.params || m.params == "list") + handle_listop(m) + elsif (m.params =~ /^add\s+(.+)$/) + handle_addop(m, $1) + elsif (m.params =~ /^rm\s+(.+)$/) + handle_rmop(m, $1) + end + end + end + + def handle_addop(m, params) + ma = /^(.+?)(\s+(.+))?$/.match(params) + channels = ma[2] ? ma[2] : @bot.config['JOIN_CHANNELS'] + if(ma[1] && channels) + @registry[ma[1]] = channels.split(/,\s*/).collect { |x| + x.strip + } + m.okay + else + m.reply @bot.lang.get('dunno') + end + end + + def handle_rmop(m, params) + if(!@registry.delete(params)) + m.reply @bot.lang.get('dunno') + else + m.okay + end + end + + def handle_listop(m) + if(@registry.length) + @registry.each { |mask,channels| + m.reply "#{mask} in #{channels.join(', ')}" + } + else + m.reply "No entrys" + end + end +end + +plugin = AutoOP.new +plugin.register("autoop") + diff --git a/lib/rbot/plugins/autorejoin.rb b/lib/rbot/plugins/autorejoin.rb new file mode 100644 index 00000000..aba46507 --- /dev/null +++ b/lib/rbot/plugins/autorejoin.rb @@ -0,0 +1,16 @@ +class AutoRejoinPlugin < Plugin + def help(plugin, topic="") + "performs an automatic rejoin if the bot is kicked from a channel" + end + def kick(m) + if m.address? + @bot.timer.add_once(10, m) {|m| + @bot.join m.channel + @bot.say m.channel, @bot.lang.get("insult") % m.sourcenick + } + end + end +end + +plugin = AutoRejoinPlugin.new +plugin.register("autorejoin") diff --git a/lib/rbot/plugins/cal.rb b/lib/rbot/plugins/cal.rb new file mode 100644 index 00000000..4f28310b --- /dev/null +++ b/lib/rbot/plugins/cal.rb @@ -0,0 +1,15 @@ +class CalPlugin < Plugin + def help(plugin, topic="") + "cal [options] => show current calendar [unix cal options]" + end + def cal(m, params) + if params.has_key?(:month) + m.reply Utils.safe_exec("cal", params[:month], params[:year]) + else + m.reply Utils.safe_exec("cal") + end + end +end +plugin = CalPlugin.new +plugin.map 'cal :month :year', :requirements => {:month => /^\d+$/, :year => /^\d+$/} +plugin.map 'cal' diff --git a/lib/rbot/plugins/dice.rb b/lib/rbot/plugins/dice.rb new file mode 100644 index 00000000..928da894 --- /dev/null +++ b/lib/rbot/plugins/dice.rb @@ -0,0 +1,81 @@ +################## +# Filename: dice.rb +# Description: Rbot plugin. Rolls rpg style dice +# Author: David Dorward (http://david.us-lot.org/ - you might find a more up to date version of this plugin there) +# Version: 0.3.2 +# Date: Sat 6 Apr 2002 +# +# You can get rbot from: http://www.linuxbrit.co.uk/rbot/ +# +# Changelog +# 0.1 - Initial release +# 0.1.1 - bug fix, only 1 digit for number of dice sides on first roll +# 0.3.0 - Spelling correction on changelog 0.1.1 +# - Return results of each roll +# 0.3.1 - Minor documentation update +# 0.3.2 - Bug fix, could not subtract numbers (String can't be coerced into Fixnum) +# +# TODO: Test! Test! Test! +# Comment! +# Fumble/Critical counter (1's and x's where x is sides on dice) +#################################################### + +class DiceDisplay + attr_reader :total, :view + def initialize(view, total) + @total = total + @view = view + end +end + +class DicePlugin < Plugin + def help(plugin, topic="") + "dice <string> (where <string> is something like: d6 or 2d6 or 2d6+4 or 2d6+1d20 or 2d6+1d5+4d7-3d4-6) => Rolls that set of virtual dice" + end + + def rolldice(d) + dice = d.split(/d/) + r = 0 + unless dice[0] =~ /^[0-9]+/ + dice[0] = 1 + end + for i in 0...dice[0].to_i + r = r + rand(dice[1].to_i) + 1 + end + return r + end + + def iddice(d) + porm = d.slice!(0,1) + if d =~ /d/ + r = rolldice(d) + else + r = d + end + if porm == "-" + r = 0 - r.to_i + end + viewer = DiceDisplay.new("[" + porm.to_s + d.to_s + "=" + r.to_s + "] ", r) + return viewer + end + + def privmsg(m) + unless(m.params && m.params =~ /^[0-9]*d[0-9]+([+-]([0-9]+|[0-9]*d[0-9])+)*$/) + m.reply "incorrect usage: " + help(m.plugin) + return + end + a = m.params.scan(/^[0-9]*d[0-9]+|[+-][0-9]*d[0-9]+|[+-][0-9]+/) + r = rolldice(a[0]) + t = "[" + a[0].to_s + "=" + r.to_s + "] " + for i in 1...a.length + tmp = iddice(a[i]) + r = r + tmp.total.to_i + t = t + tmp.view.to_s + end + m.reply r.to_s + " | " + t + end +end +plugin = DicePlugin.new +plugin.register("dice") +############################################## +#fin diff --git a/lib/rbot/plugins/eightball.rb b/lib/rbot/plugins/eightball.rb new file mode 100644 index 00000000..64748490 --- /dev/null +++ b/lib/rbot/plugins/eightball.rb @@ -0,0 +1,19 @@ +# Author: novex, daniel@novex.net.nz based on code from slap.rb by oct + +class EightBallPlugin < Plugin + def initialize + super + @answers=['yes', 'no', 'outlook not so good', 'all signs point to yes', 'all signs point to no', 'why the hell are you asking me?', 'the answer is unclear'] + end + def help(plugin, topic="") + "magic 8-ball ruby bot module written by novex for nvinfo on #dumber@quakenet, usage:<botname> 8ball will i ever beat this cancer?" + end + def eightball(m, params) + answers = @answers[rand(@answers.length)] + action = "shakes the magic 8-ball... #{answers}" + @bot.action m.replyto, action + end +end +plugin = EightBallPlugin.new +plugin.map '8ball', :action => 'usage' +plugin.map '8ball *params', :action => 'eightball' diff --git a/lib/rbot/plugins/excuse.rb b/lib/rbot/plugins/excuse.rb new file mode 100644 index 00000000..38e85ad6 --- /dev/null +++ b/lib/rbot/plugins/excuse.rb @@ -0,0 +1,470 @@ +class ExcusePlugin < Plugin + # excuses courtesy of http://www.cs.wisc.edu/~ballard/bofh/ +@@excuses = [ +"clock speed", +"solar flares", +"electromagnetic radiation from satellite debris", +"static from nylon underwear", +"static from plastic slide rules", +"global warming", +"poor power conditioning", +"static buildup", +"doppler effect", +"hardware stress fractures", +"magnetic interference from money/credit cards", +"dry joints on cable plug", +"we're waiting for [the phone company] to fix that line", +"sounds like a Windows problem, try calling Microsoft support", +"temporary routing anomaly", +"somebody was calculating pi on the server", +"fat electrons in the lines", +"excess surge protection", +"floating point processor overflow", +"divide-by-zero error", +"POSIX compliance problem", +"monitor resolution too high", +"improperly oriented keyboard", +"network packets travelling uphill (use a carrier pigeon)", +"Decreasing electron flux", +"first Saturday after first full moon in Winter", +"radiosity depletion", +"CPU radiator broken", +"It works the way the Wang did, what's the problem", +"positron router malfunction", +"cellular telephone interference", +"techtonic stress", +"piezo-electric interference", +"(l)user error", +"working as designed", +"dynamic software linking table corrupted", +"heavy gravity fluctuation, move computer to floor rapidly", +"secretary plugged hairdryer into UPS", +"terrorist activities", +"not enough memory, go get system upgrade", +"interrupt configuration error", +"spaghetti cable cause packet failure", +"boss forgot system password", +"bank holiday - system operating credits not recharged", +"virus attack, luser responsible", +"waste water tank overflowed onto computer", +"Complete Transient Lockout", +"bad ether in the cables", +"Bogon emissions", +"Change in Earth's rotational speed", +"Cosmic ray particles crashed through the hard disk platter", +"Smell from unhygienic janitorial staff wrecked the tape heads", +"Little hamster in running wheel had coronary; waiting for replacement to be Fedexed from Wyoming", +"Evil dogs hypnotised the night shift", +"Plumber mistook routing panel for decorative wall fixture", +"Electricians made popcorn in the power supply", +"Groundskeepers stole the root password", +"high pressure system failure", +"failed trials, system needs redesigned", +"system has been recalled", +"not approved by the FCC", +"need to wrap system in aluminum foil to fix problem", +"not properly grounded, please bury computer", +"CPU needs recalibration", +"system needs to be rebooted", +"bit bucket overflow", +"descramble code needed from software company", +"only available on a need to know basis", +"knot in cables caused data stream to become twisted and kinked", +"nesting roaches shorted out the ether cable", +"The file system is full of it", +"Satan did it", +"Daemons did it", +"You're out of memory", +"There isn't any problem", +"Unoptimized hard drive", +"Typo in the code", +"Yes, yes, its called a design limitation", +"Look, buddy: Windows 3.1 IS A General Protection Fault.", +"That's a great computer you have there; have you considered how it would work as a BSD machine?", +"Please excuse me, I have to circuit an AC line through my head to get this database working.", +"Yeah, yo mama dresses you funny and you need a mouse to delete files.", +"Support staff hung over, send aspirin and come back LATER.", +"Someone is standing on the ethernet cable, causing a kink in the cable", +"Windows 95 undocumented 'feature'", +"Runt packets", +"Password is too complex to decrypt", +"Boss' kid fucked up the machine", +"Electromagnetic energy loss", +"Budget cuts", +"Mouse chewed through power cable", +"Stale file handle (next time use Tupperware(tm)!)", +"Feature not yet implemented", +"Internet outage", +"Pentium FDIV bug", +"Vendor no longer supports the product", +"Small animal kamikaze attack on power supplies", +"The vendor put the bug there.", +"SIMM crosstalk.", +"IRQ dropout", +"Collapsed Backbone", +"Power company testing new voltage spike (creation) equipment", +"operators on strike due to broken coffee machine", +"backup tape overwritten with copy of system manager's favourite CD", +"UPS interrupted the server's power", +"The electrician didn't know what the yellow cable was so he yanked the ethernet out.", +"The keyboard isn't plugged in", +"The air conditioning water supply pipe ruptured over the machine room", +"The electricity substation in the car park blew up.", +"The rolling stones concert down the road caused a brown out", +"The salesman drove over the CPU board.", +"The monitor is plugged into the serial port", +"Root nameservers are out of sync", +"electro-magnetic pulses from French above ground nuke testing.", +"your keyboard's space bar is generating spurious keycodes.", +"the real ttys became pseudo ttys and vice-versa.", +"the printer thinks its a router.", +"the router thinks its a printer.", +"evil hackers from Serbia.", +"we just switched to FDDI.", +"halon system went off and killed the operators.", +"because Bill Gates is a Jehovah's witness and so nothing can work on St. Swithin's day.", +"user to computer ratio too high.", +"user to computer ration too low.", +"we just switched to Sprint.", +"it has Intel Inside", +"Sticky bits on disk.", +"Power Company having EMP problems with their reactor", +"The ring needs another token", +"new management", +"telnet: Unable to connect to remote host: Connection refused", +"SCSI Chain overterminated", +"It's not plugged in.", +"because of network lag due to too many people playing deathmatch", +"You put the disk in upside down.", +"Daemons loose in system.", +"User was distributing pornography on server; system seized by FBI.", +"BNC (brain not connected)", +"UBNC (user brain not connected)", +"LBNC (luser brain not connected)", +"disks spinning backwards - toggle the hemisphere jumper.", +"new guy cross-connected phone lines with ac power bus.", +"had to use hammer to free stuck disk drive heads.", +"Too few computrons available.", +"Communications satellite used by the military for star wars.", +"Party-bug in the Aloha protocol.", +"Insert coin for new game", +"Dew on the telephone lines.", +"Arcserve crashed the server again.", +"Some one needed the powerstrip, so they pulled the switch plug.", +"My pony-tail hit the on/off switch on the power strip.", +"Big to little endian conversion error", +"You can tune a file system, but you can't tune a fish (from most tunefs man pages)", +"Dumb terminal", +"Zombie processes haunting the computer", +"Incorrect time synchronization", +"Defunct processes", +"Stubborn processes", +"non-redundant fan failure ", +"monitor VLF leakage", +"bugs in the RAID", +"no 'any' key on keyboard", +"root rot", +"Backbone Scoliosis", +"/pub/lunch", +"excessive collisions & not enough packet ambulances", +"le0: no carrier: transceiver cable problem?", +"broadcast packets on wrong frequency", +"popper unable to process jumbo kernel", +"NOTICE: alloc: /dev/null: filesystem full", +"pseudo-user on a pseudo-terminal", +"Recursive traversal of loopback mount points", +"Backbone adjustment", +"OS swapped to disk", +"vapors from evaporating sticky-note adhesives", +"sticktion", +"short leg on process table", +"multicasts on broken packets", +"ether leak", +"Atilla the Hub", +"endothermal recalibration", +"filesystem not big enough for Jumbo Kernel Patch", +"loop found in loop in redundant loopback", +"system consumed all the paper for paging", +"permission denied", +"Reformatting Page. Wait...", +"..disk or the processor is on fire.", +"SCSI's too wide.", +"Proprietary Information.", +"Just type 'mv * /dev/null'.", +"runaway cat on system.", +"Did you pay the new Support Fee?", +"We only support a 1200 bps connection.", +"We only support a 28000 bps connection.", +"Me no internet, only janitor, me just wax floors.", +"I'm sorry a pentium won't do, you need an SGI to connect with us.", +"Post-it Note Sludge leaked into the monitor.", +"the curls in your keyboard cord are losing electricity.", +"The monitor needs another box of pixels.", +"RPC_PMAP_FAILURE", +"kernel panic: write-only-memory (/dev/wom0) capacity exceeded.", +"Write-only-memory subsystem too slow for this machine. Contact your local dealer.", +"Just pick up the phone and give modem connect sounds. 'Well you said we should get more lines so we don't have voice lines.'", +"Quantum dynamics are affecting the transistors", +"Police are examining all internet packets in the search for a narco-net-trafficker", +"We are currently trying a new concept of using a live mouse. Unfortunately, one has yet to survive being hooked up to the computer.....please bear with us.", +"Your mail is being routed through Germany ... and they're censoring us.", +"Only people with names beginning with 'A' are getting mail this week (a la Microsoft)", +"We didn't pay the Internet bill and it's been cut off.", +"Lightning strikes.", +"Of course it doesn't work. We've performed a software upgrade.", +"Change your language to Finnish.", +"Fluorescent lights are generating negative ions. If turning them off doesn't work, take them out and put tin foil on the ends.", +"High nuclear activity in your area.", +"What office are you in? Oh, that one. Did you know that your building was built over the universities first nuclear research site? And wow, aren't you the lucky one, your office is right over where the core is buried!", +"The MGs ran out of gas.", +"The UPS doesn't have a battery backup.", +"Recursivity. Call back if it happens again.", +"Someone thought The Big Red Button was a light switch.", +"The mainframe needs to rest. It's getting old, you know.", +"I'm not sure. Try calling the Internet's head office -- it's in the book.", +"The lines are all busy (busied out, that is -- why let them in to begin with?).", +"Jan 9 16:41:27 huber su: 'su root' succeeded for .... on /dev/pts/1", +"It's those computer people in X {city of world}. They keep stuffing things up.", +"A star wars satellite accidently blew up the WAN.", +"Fatal error right in front of screen", +"That function is not currently supported, but Bill Gates assures us it will be featured in the next upgrade.", +"wrong polarity of neutron flow", +"Lusers learning curve appears to be fractal", +"We had to turn off that service to comply with the CDA Bill.", +"Ionization from the air-conditioning", +"TCP/IP UDP alarm threshold is set too low.", +"Someone is broadcasting pygmy packets and the router doesn't know how to deal with them.", +"The new frame relay network hasn't bedded down the software loop transmitter yet. ", +"Fanout dropping voltage too much, try cutting some of those little traces", +"Plate voltage too low on demodulator tube", +"You did wha... oh _dear_....", +"CPU needs bearings repacked", +"Too many little pins on CPU confusing it, bend back and forth until 10-20% are neatly removed. Do _not_ leave metal bits visible!", +"_Rosin_ core solder? But...", +"Software uses US measurements, but the OS is in metric...", +"The computer fleetly, mouse and all.", +"Your cat tried to eat the mouse.", +"The Borg tried to assimilate your system. Resistance is futile.", +"It must have been the lightning storm we had (yesterday) (last week) (last month)", +"Due to Federal Budget problems we have been forced to cut back on the number of users able to access the system at one time. (namely none allowed....)", +"Too much radiation coming from the soil.", +"Unfortunately we have run out of bits/bytes/whatever. Don't worry, the next supply will be coming next week.", +"Program load too heavy for processor to lift.", +"Processes running slowly due to weak power supply", +"Our ISP is having {switching,routing,SMDS,frame relay} problems", +"We've run out of licenses", +"Interference from lunar radiation", +"Standing room only on the bus.", +"You need to install an RTFM interface.", +"That would be because the software doesn't work.", +"That's easy to fix, but I can't be bothered.", +"Someone's tie is caught in the printer, and if anything else gets printed, he'll be in it too.", +"We're upgrading /dev/null", +"The Usenet news is out of date", +"Our POP server was kidnapped by a weasel.", +"It's stuck in the Web.", +"Your modem doesn't speak English.", +"The mouse escaped.", +"All of the packets are empty.", +"The UPS is on strike.", +"Neutrino overload on the nameserver", +"Melting hard drives", +"Someone has messed up the kernel pointers", +"The kernel license has expired", +"Netscape has crashed", +"The cord jumped over and hit the power switch.", +"It was OK before you touched it.", +"Bit rot", +"U.S. Postal Service", +"Your Flux Capacitor has gone bad.", +"The Dilithium Crystals need to be rotated.", +"The static electricity routing is acting up...", +"Traceroute says that there is a routing problem in the backbone. It's not our problem.", +"The co-locator cannot verify the frame-relay gateway to the ISDN server.", +"High altitude condensation from U.S.A.F prototype aircraft has contaminated the primary subnet mask. Turn off your computer for 9 days to avoid damaging it.", +"Lawn mower blade in your fan need sharpening", +"Electrons on a bender", +"Telecommunications is upgrading. ", +"Telecommunications is downgrading.", +"Telecommunications is downshifting.", +"Hard drive sleeping. Let it wake up on it's own...", +"Interference between the keyboard and the chair.", +"The CPU has shifted, and become decentralized.", +"Due to the CDA, we no longer have a root account.", +"We ran out of dial tone and we're and waiting for the phone company to deliver another bottle.", +"You must've hit the wrong any key.", +"PCMCIA slave driver", +"The Token fell out of the ring. Call us when you find it.", +"The hardware bus needs a new token.", +"Too many interrupts", +"Not enough interrupts", +"The data on your hard drive is out of balance.", +"Digital Manipulator exceeding velocity parameters", +"appears to be a Slow/Narrow SCSI-0 Interface problem", +"microelectronic Riemannian curved-space fault in write-only file system", +"fractal radiation jamming the backbone", +"routing problems on the neural net", +"IRQ-problems with the Un-Interruptible-Power-Supply", +"CPU-angle has to be adjusted because of vibrations coming from the nearby road", +"emissions from GSM-phones", +"CD-ROM server needs recalibration", +"firewall needs cooling", +"asynchronous inode failure", +"transient bus protocol violation", +"incompatible bit-registration operators", +"your process is not ISO 9000 compliant", +"You need to upgrade your VESA local bus to a MasterCard local bus.", +"The recent proliferation of Nuclear Testing", +"Elves on strike. (Why do they call EMAG Elf Magic)", +"Internet exceeded Luser level, please wait until a luser logs off before attempting to log back on.", +"Your EMAIL is now being delivered by the USPS.", +"Your computer hasn't been returning all the bits it gets from the Internet.", +"You've been infected by the Telescoping Hubble virus.", +"Scheduled global CPU outage", +"Your Pentium has a heating problem - try cooling it with ice cold water.(Do not turn off your computer, you do not want to cool down the Pentium Chip while he isn't working, do you?)", +"Your processor has processed too many instructions. Turn it off immediately, do not type any commands!!", +"Your packets were eaten by the terminator", +"Your processor does not develop enough heat.", +"We need a licensed electrician to replace the light bulbs in the computer room.", +"The POP server is out of Coke", +"Fiber optics caused gas main leak", +"Server depressed, needs Prozac", +"quantum decoherence", +"those damn raccoons!", +"suboptimal routing experience", +"A plumber is needed, the network drain is clogged", +"50% of the manual is in .pdf readme files", +"the AA battery in the wallclock sends magnetic interference", +"the xy axis in the trackball is coordinated with the summer solstice", +"the butane lighter causes the pincushioning", +"old inkjet cartridges emanate barium-based fumes", +"manager in the cable duct", +"We'll fix that in the next (upgrade, update, patch release, service pack).", +"HTTPD Error 666 : BOFH was here", +"HTTPD Error 4004 : very old Intel cpu - insufficient processing power", +"The ATM board has run out of 10 pound notes. We are having a whip round to refill it, care to contribute ?", +"Network failure - call NBC", +"Having to manually track the satellite.", +"Your/our computer(s) had suffered a memory leak, and we are waiting for them to be topped up.", +"The rubber band broke", +"We're on Token Ring, and it looks like the token got loose.", +"Stray Alpha Particles from memory packaging caused Hard Memory Error on Server.", +"paradigm shift...without a clutch", +"PEBKAC (Problem Exists Between Keyboard And Chair)", +"The cables are not the same length.", +"Second-system effect.", +"Chewing gum on /dev/sd3c", +"Boredom in the Kernel.", +"the daemons! the daemons! the terrible daemons!", +"I'd love to help you -- it's just that the Boss won't let me near the computer. ", +"struck by the Good Times virus", +"YOU HAVE AN I/O ERROR -> Incompetent Operator error", +"Your parity check is overdrawn and you're out of cache.", +"Communist revolutionaries taking over the server room and demanding all the computers in the building or they shoot the sysadmin. Poor misguided fools.", +"Plasma conduit breach", +"Out of cards on drive D:", +"Sand fleas eating the Internet cables", +"parallel processors running perpendicular today", +"ATM cell has no roaming feature turned on, notebooks can't connect", +"Webmasters kidnapped by evil cult.", +"Failure to adjust for daylight savings time.", +"Virus transmitted from computer to sysadmins.", +"Virus due to computers having unsafe sex.", +"Incorrectly configured static routes on the corerouters.", +"Forced to support NT servers; sysadmins quit.", +"Suspicious pointer corrupted virtual machine", +"It's the InterNIC's fault.", +"Root name servers corrupted.", +"Budget cuts forced us to sell all the power cords for the servers.", +"Someone hooked the twisted pair wires into the answering machine.", +"Operators killed by year 2000 bug bite.", +"We've picked COBOL as the language of choice.", +"Operators killed when huge stack of backup tapes fell over.", +"Robotic tape changer mistook operator's tie for a backup tape.", +"Someone was smoking in the computer room and set off the halon systems.", +"Your processor has taken a ride to Heaven's Gate on the UFO behind Hale-Bopp's comet.", +"it's an ID-10-T error", +"Dyslexics retyping hosts file on servers", +"The Internet is being scanned for viruses.", +"Your computer's union contract is set to expire at midnight.", +"Bad user karma.", +"/dev/clue was linked to /dev/null", +"Increased sunspot activity.", +"We already sent around a notice about that.", +"It's union rules. There's nothing we can do about it. Sorry.", +"Interference from the Van Allen Belt.", +"Jupiter is aligned with Mars.", +"Redundant ACLs. ", +"Mail server hit by UniSpammer.", +"T-1's congested due to porn traffic to the news server.", +"Data for intranet got routed through the extranet and landed on the internet.", +"We are a 100% Microsoft Shop.", +"We are Microsoft. What you are experiencing is not a problem; it is an undocumented feature.", +"Sales staff sold a product we don't offer.", +"Secretary sent chain letter to all 5000 employees.", +"Sysadmin didn't hear pager go off due to loud music from bar-room speakers.", +"Sysadmin accidentally destroyed pager with a large hammer.", +"Sysadmins unavailable because they are in a meeting talking about why they are unavailable so much.", +"Bad cafeteria food landed all the sysadmins in the hospital.", +"Route flapping at the NAP.", +"Computers under water due to SYN flooding.", +"The vulcan-death-grip ping has been applied.", +"Electrical conduits in machine room are melting.", +"Traffic jam on the Information Superhighway.", +"Radial Telemetry Infiltration", +"Cow-tippers tipped a cow onto the server.", +"tachyon emissions overloading the system", +"Maintenance window broken", +"We're out of slots on the server", +"Computer room being moved. Our systems are down for the weekend.", +"Sysadmins busy fighting SPAM.", +"Repeated reboots of the system failed to solve problem", +"Feature was not beta tested", +"Domain controller not responding", +"Someone else stole your IP address, call the Internet detectives!", +"It's not RFC-822 compliant.", +"operation failed because: there is no message for this error (#1014)", +"stop bit received", +"internet is needed to catch the etherbunny", +"network down, IP packets delivered via UPS", +"Firmware update in the coffee machine", +"Temporal anomaly", +"Mouse has out-of-cheese-error", +"Borg implants are failing", +"Borg nanites have infested the server", +"error: one bad user found in front of screen", +"Please state the nature of the technical emergency", +"Internet shut down due to maintenance", +"Daemon escaped from pentagram", +"crop circles in the corn shell", +"sticky bit has come loose", +"Hot Java has gone cold", +"Cache miss - please take better aim next time", +"Hash table has woodworm", +"Trojan horse ran out of hay", +"Zombie processes detected, machine is haunted.", +"overflow error in /dev/null", +"Browser's cookie is corrupted -- someone's been nibbling on it.", +"Mailer-daemon is busy burning your message in hell.", +"According to Microsoft, it's by design", +"vi needs to be upgraded to vii", +"greenpeace free'd the mallocs", +"Terrorists crashed an airplane into the server room, have to remove /bin/laden. (rm -rf /bin/laden)", +"astropneumatic oscillations in the water-cooling", +"Somebody ran the operating system through a spelling checker.", +"Rhythmic variations in the voltage reaching the power supply.", +"Keyboard Actuator Failure. Order and Replace." +] + + def help(plugin, topic="") + "excuse => supply a random excuse" + end + def privmsg(m) + excuse = @@excuses[rand(@@excuses.length)] + m.reply excuse + end +end + +plugin = ExcusePlugin.new +plugin.register("excuse") + diff --git a/lib/rbot/plugins/fish.rb b/lib/rbot/plugins/fish.rb new file mode 100644 index 00000000..57aaafc2 --- /dev/null +++ b/lib/rbot/plugins/fish.rb @@ -0,0 +1,61 @@ +require 'net/http' +require 'uri/common' +Net::HTTP.version_1_2 + +class BabelPlugin < Plugin + def help(plugin, topic="") + "translate to <lang> <string> => translate from english to <lang>, translate from <lang> <string> => translate to english from <lang>, translate <fromlang> <tolang> <string> => translate from <fromlang> to <tolang>. Languages: en, fr, de, it, pt, es, nl" + end + def translate(m, params) + langs = ["en", "fr", "de", "it", "pt", "es", "nl"] + trans_from = params[:fromlang] ? params[:fromlang] : 'en' + trans_to = params[:tolang] ? params[:tolang] : 'en' + trans_text = params[:phrase].to_s + + query = "/babelfish/tr" + lang_match = langs.join("|") + unless(trans_from =~ /^(#{lang_match})$/ && trans_to =~ /^(#{lang_match})$/) + m.reply "invalid language: valid languagess are: #{langs.join(' ')}" + return + end + + data_text = URI.escape trans_text + trans_pair = "#{trans_from}_#{trans_to}" + data = "lp=#{trans_pair}&doit=done&intl=1&tt=urltext&urltext=#{data_text}" + + # check cache for previous lookups + if @registry.has_key?("#{trans_pair}/#{data_text}") + m.reply @registry["#{trans_pair}/#{data_text}"] + return + end + + http = @bot.httputil.get_proxy(URI.parse("http://babelfish.altavista.com")) + + http.start {|http| + resp = http.post(query, data, {"content-type", + "application/x-www-form-urlencoded"}) + + if (resp.code == "200") + resp.body.each_line do |l| + if(l =~ /^\s+<td bgcolor=white class=s><div style=padding:10px;>(.*)<\/div>/) + answer = $1 + # cache the answer + if(answer.length > 0) + @registry["#{trans_pair}/#{data_text}"] = answer + end + m.reply answer + return + end + end + m.reply "couldn't parse babelfish response html :(" + else + m.reply "couldn't talk to babelfish :(" + end + } + end +end +plugin = BabelPlugin.new +plugin.map 'translate to :tolang *phrase' +plugin.map 'translate from :fromlang *phrase' +plugin.map 'translate :fromlang :tolang *phrase' + diff --git a/lib/rbot/plugins/fortune.rb b/lib/rbot/plugins/fortune.rb new file mode 100644 index 00000000..184b6b13 --- /dev/null +++ b/lib/rbot/plugins/fortune.rb @@ -0,0 +1,22 @@ +class FortunePlugin < Plugin + def help(plugin, topic="") + "fortune [<module>] => get a (short) fortune, optionally specifying fortune db" + end + def fortune(m, params) + db = params[:db] + fortune = nil + ["/usr/games/fortune", "/usr/bin/fortune", "/usr/local/bin/fortune"].each {|f| + if FileTest.executable? f + fortune = f + break + end + } + m.reply "fortune binary not found" unless fortune + ret = Utils.safe_exec(fortune, "-n", "255", "-s", db) + m.reply ret.gsub(/\t/, " ").split(/\n/).join(" ") + return + end +end +plugin = FortunePlugin.new +plugin.map 'fortune :db', :defaults => {:db => 'fortunes'}, + :requirements => {:db => /^[^-][\w-]+$/} diff --git a/lib/rbot/plugins/freshmeat.rb b/lib/rbot/plugins/freshmeat.rb new file mode 100644 index 00000000..20fa7248 --- /dev/null +++ b/lib/rbot/plugins/freshmeat.rb @@ -0,0 +1,98 @@ +require 'rexml/document' +require 'uri/common' + +class FreshmeatPlugin < Plugin + include REXML + def help(plugin, topic="") + "freshmeat search [<max>=4] <string> => search freshmeat for <string>, freshmeat [<max>=4] => return up to <max> freshmeat headlines" + end + + def search_freshmeat(m, params) + max = params[:limit].to_i + search = params[:search].to_s + max = 8 if max > 8 + begin + xml = @bot.httputil.get(URI.parse("http://freshmeat.net/search-xml/?orderby=locate_projectname_full_DESC&q=#{URI.escape(search)}")) + rescue URI::InvalidURIError, URI::BadURIError => e + m.reply "illegal search string #{search}" + return + end + unless xml + m.reply "search for #{search} failed" + return + end + doc = Document.new xml + unless doc + m.reply "search for #{search} failed" + return + end + matches = Array.new + max_width = 250 + title_width = 0 + url_width = 0 + done = 0 + doc.elements.each("*/match") {|e| + name = e.elements["projectname_short"].text + url = "http://freshmeat.net/projects/#{name}/" + desc = e.elements["desc_short"].text + title = e.elements["projectname_full"].text + #title_width = title.length if title.length > title_width + url_width = url.length if url.length > url_width + matches << [title, url, desc] + done += 1 + break if done >= max + } + if matches.length == 0 + m.reply "not found: #{search}" + end + matches.each {|mat| + title = mat[0] + url = mat[1] + desc = mat[2] + desc.gsub!(/(.{#{max_width - 3 - url_width}}).*/, '\1..') + reply = sprintf("%s | %s", url.ljust(url_width), desc) + m.reply reply + } + end + + def freshmeat(m, params) + max = params[:limit].to_i + max = 8 if max > 8 + xml = @bot.httputil.get(URI.parse("http://images.feedstermedia.com/feedcache/ostg/freshmeat/fm-releases-global.xml")) + unless xml + m.reply "freshmeat news parse failed" + return + end + doc = Document.new xml + unless doc + m.reply "freshmeat news parse failed" + return + end + matches = Array.new + max_width = 60 + title_width = 0 + done = 0 + doc.elements.each("*/channel/item") {|e| + desc = e.elements["description"].text + title = e.elements["title"].text + #title.gsub!(/\s+\(.*\)\s*$/, "") + title.strip! + title_width = title.length if title.length > title_width + matches << [title, desc] + done += 1 + break if done >= max + } + matches.each {|mat| + title = mat[0] + #desc = mat[1] + #desc.gsub!(/(.{#{max_width - 3 - title_width}}).*/, '\1..') + #reply = sprintf("%#{title_width}s | %s", title, desc) + m.reply title + } + end +end +plugin = FreshmeatPlugin.new +plugin.map 'freshmeat search :limit *search', :action => 'search_freshmeat', + :defaults => {:limit => 4}, :requirements => {:limit => /^\d+$/} +plugin.map 'freshmeat :limit', :defaults => {:limit => 4}, + :requirements => {:limit => /^\d+$/} diff --git a/lib/rbot/plugins/google.rb b/lib/rbot/plugins/google.rb new file mode 100644 index 00000000..cd96f23c --- /dev/null +++ b/lib/rbot/plugins/google.rb @@ -0,0 +1,51 @@ +require 'net/http' +require 'uri/common' + +Net::HTTP.version_1_2 + +class GooglePlugin < Plugin + def help(plugin, topic="") + "search <string> => search google for <string>" + end + def privmsg(m) + unless(m.params && m.params.length > 0) + m.reply "incorrect usage: " + help(m.plugin) + return + end + searchfor = URI.escape m.params + + query = "/search?q=#{searchfor}&btnI=I%27m%20feeling%20lucky" + result = "not found!" + + proxy_host = nil + proxy_port = nil + + if(ENV['http_proxy']) + if(ENV['http_proxy'] =~ /^http:\/\/(.+):(\d+)$/) + proxy_host = $1 + proxy_port = $2 + end + end + + http = @bot.httputil.get_proxy(URI.parse("http://www.google.com")) + + begin + http.start {|http| + resp = http.get(query) + if resp.code == "302" + result = resp['location'] + end + } + rescue => e + p e + if e.response && e.response['location'] + result = e.response['location'] + else + result = "error!" + end + end + m.reply "#{m.params}: #{result}" + end +end +plugin = GooglePlugin.new +plugin.register("search") diff --git a/lib/rbot/plugins/host.rb b/lib/rbot/plugins/host.rb new file mode 100644 index 00000000..ef8dc8bc --- /dev/null +++ b/lib/rbot/plugins/host.rb @@ -0,0 +1,14 @@ +class HostPlugin < Plugin + def help(plugin, topic="") + "host <domain> => query nameserver about domain names and zones for <domain>" + end + def privmsg(m) + unless(m.params =~ /^(\w|-|\.)+$/) + m.reply "incorrect usage: " + help(m.plugin) + return + end + m.reply Utils.safe_exec("host", m.params) + end +end +plugin = HostPlugin.new +plugin.register("host") diff --git a/lib/rbot/plugins/httpd.rb b/lib/rbot/plugins/httpd.rb new file mode 100644 index 00000000..92fe3a80 --- /dev/null +++ b/lib/rbot/plugins/httpd.rb @@ -0,0 +1,35 @@ +require 'webrick' + +class HttpPlugin < Plugin + include WEBrick + + + def initialize + super + @http_server = HTTPServer.new( + :Port => 5555 + ) + @http_server.mount_proc("/") { |req, resp| + resp['content-type'] = 'text/html' + resp.body = "<html><head><title>rbot httpd plugin</title></head><body>" + resp.body += "#{@bot.status} <br />" + resp.body += "hello from rbot." + resp.body += "</body>" + raise HTTPStatus::OK + } + Thread.new { + @http_server.start + } + end + def cleanup + @http_server.shutdown + end + def help(plugin, topic="") + "no help yet" + end + def privmsg(m) + end +end + +plugin = HttpPlugin.new +plugin.register("http") diff --git a/lib/rbot/plugins/insult.rb b/lib/rbot/plugins/insult.rb new file mode 100644 index 00000000..5f0384e8 --- /dev/null +++ b/lib/rbot/plugins/insult.rb @@ -0,0 +1,258 @@ +class InsultPlugin < Plugin + +## insults courtesy of http://insulthost.colorado.edu/ + +## +# Adjectives +## +@@adj = [ +"acidic", +"antique", +"contemptible", +"culturally-unsound", +"despicable", +"evil", +"fermented", +"festering", +"foul", +"fulminating", +"humid", +"impure", +"inept", +"inferior", +"industrial", +"left-over", +"low-quality", +"malodorous", +"off-color", +"penguin-molesting", +"petrified", +"pointy-nosed", +"salty", +"sausage-snorfling", +"tastless", +"tempestuous", +"tepid", +"tofu-nibbling", +"unintelligent", +"unoriginal", +"uninspiring", +"weasel-smelling", +"wretched", +"spam-sucking", +"egg-sucking", +"decayed", +"halfbaked", +"infected", +"squishy", +"porous", +"pickled", +"coughed-up", +"thick", +"vapid", +"hacked-up", +"unmuzzled", +"bawdy", +"vain", +"lumpish", +"churlish", +"fobbing", +"rank", +"craven", +"puking", +"jarring", +"fly-bitten", +"pox-marked", +"fen-sucked", +"spongy", +"droning", +"gleeking", +"warped", +"currish", +"milk-livered", +"surly", +"mammering", +"ill-borne", +"beef-witted", +"tickle-brained", +"half-faced", +"headless", +"wayward", +"rump-fed", +"onion-eyed", +"beslubbering", +"villainous", +"lewd-minded", +"cockered", +"full-gorged", +"rude-snouted", +"crook-pated", +"pribbling", +"dread-bolted", +"fool-born", +"puny", +"fawning", +"sheep-biting", +"dankish", +"goatish", +"weather-bitten", +"knotty-pated", +"malt-wormy", +"saucyspleened", +"motley-mind", +"it-fowling", +"vassal-willed", +"loggerheaded", +"clapper-clawed", +"frothy", +"ruttish", +"clouted", +"common-kissing", +"pignutted", +"folly-fallen", +"plume-plucked", +"flap-mouthed", +"swag-bellied", +"dizzy-eyed", +"gorbellied", +"weedy", +"reeky", +"measled", +"spur-galled", +"mangled", +"impertinent", +"bootless", +"toad-spotted", +"hasty-witted", +"horn-beat", +"yeasty", +"boil-brained", +"tottering", +"hedge-born", +"hugger-muggered", +"elf-skinned", +] + +## +# Amounts +## +@@amt = [ +"accumulation", +"bucket", +"coagulation", +"enema-bucketful", +"gob", +"half-mouthful", +"heap", +"mass", +"mound", +"petrification", +"pile", +"puddle", +"stack", +"thimbleful", +"tongueful", +"ooze", +"quart", +"bag", +"plate", +"ass-full", +"assload", +] + +## +# Objects +## +@@noun = [ +"bat toenails", +"bug spit", +"cat hair", +"chicken piss", +"dog vomit", +"dung", +"fat-woman's stomach-bile", +"fish heads", +"guano", +"gunk", +"pond scum", +"rat retch", +"red dye number-9", +"Sun IPC manuals", +"waffle-house grits", +"yoo-hoo", +"dog balls", +"seagull puke", +"cat bladders", +"pus", +"urine samples", +"squirrel guts", +"snake assholes", +"snake bait", +"buzzard gizzards", +"cat-hair-balls", +"rat-farts", +"pods", +"armadillo snouts", +"entrails", +"snake snot", +"eel ooze", +"slurpee-backwash", +"toxic waste", +"Stimpy-drool", +"poopy", +"poop", +"craptacular carpet droppings", +"jizzum", +"cold sores", +"anal warts", +] + + def help(plugin, topic="") + if(plugin == "insult") + return "insult me|<person> => insult you or <person>" + elsif(plugin == "msginsult") + return "msginsult <nick> => insult <nick> via /msg" + else + return "insult module topics: msginsult, insult" + end + end + def privmsg(m) + suffix="" + unless(m.params) + m.reply "incorrect usage: " + help(m.plugin) + return + end + msgto = m.channel + if(m.plugin =~ /^msginsult$/) + prefix = "you are " + if (m.params =~ /^#/) + prefix += "all " + end + msgto = m.params + suffix = " (from #{m.sourcenick})" + elsif(m.params =~ /^me$/) + prefix = "you are " + else + prefix = "#{m.params} is " + end + insult = generate_insult + @bot.say msgto, prefix + insult + suffix + end + def generate_insult + adj = @@adj[rand(@@adj.length)] + adj2 = "" + loop do + adj2 = @@adj[rand(@@adj.length)] + break if adj2 != adj + end + amt = @@amt[rand(@@amt.length)] + noun = @@noun[rand(@@noun.length)] + start = "a " + start = "an " if ['a','e','i','o','u'].include?(adj[0].chr) + "#{start}#{adj} #{amt} of #{adj2} #{noun}" + end +end +plugin = InsultPlugin.new +plugin.register("insult") +plugin.register("msginsult") + diff --git a/lib/rbot/plugins/karma.rb b/lib/rbot/plugins/karma.rb new file mode 100644 index 00000000..148427a5 --- /dev/null +++ b/lib/rbot/plugins/karma.rb @@ -0,0 +1,85 @@ +class KarmaPlugin < Plugin + def initialize + super + + # this plugin only wants to store ints! + class << @registry + def store(val) + val.to_i + end + def restore(val) + val.to_i + end + end + @registry.set_default(0) + + # import if old file format found + if(File.exist?("#{@bot.botclass}/karma.rbot")) + puts "importing old karma data" + IO.foreach("#{@bot.botclass}/karma.rbot") do |line| + if(line =~ /^(\S+)<=>([\d-]+)$/) + item = $1 + karma = $2.to_i + @registry[item] = karma + end + end + File.delete("#{@bot.botclass}/karma.rbot") + end + + end + + def stats(m, params) + if (@registry.length) + max = @registry.values.max + min = @registry.values.min + best = @registry.to_hash.index(max) + worst = @registry.to_hash.index(min) + m.reply "#{@registry.length} items. Best: #{best} (#{max}); Worst: #{worst} (#{min})" + end + end + + def karma(m, params) + thing = params[:key] + thing = m.sourcenick unless thing + thing = thing.to_s + karma = @registry[thing] + if(karma != 0) + m.reply "karma for #{thing}: #{@registry[thing]}" + else + m.reply "#{thing} has neutral karma" + end + end + + + def help(plugin, topic="") + "karma module: <thing>++/<thing>-- => increase/decrease karma for <thing>, karma for <thing>? => show karma for <thing>, karmastats => show stats. Karma is a community rating system - only in-channel messages can affect karma and you cannot adjust your own." + end + def listen(m) + return unless m.kind_of?(PrivMessage) && m.public? + # in channel message, the kind we are interested in + if(m.message =~ /(\+\+|--)/) + string = m.message.sub(/\W(--|\+\+)(\(.*?\)|[^(++)(\-\-)\s]+)/, "\2\1") + seen = Hash.new + while(string.sub!(/(\(.*?\)|[^(++)(\-\-)\s]+)(\+\+|--)/, "")) + key = $1 + change = $2 + next if seen[key] + seen[key] = true + + key.sub!(/^\((.*)\)$/, "\1") + key.gsub!(/\s+/, " ") + next unless(key.length > 0) + next if(key == m.sourcenick) + if(change == "++") + @registry[key] += 1 + elsif(change == "--") + @registry[key] -= 1 + end + end + end + end +end +plugin = KarmaPlugin.new +plugin.map 'karmastats', :action => 'stats' +plugin.map 'karma :key', :defaults => {:key => false} +plugin.map 'karma for :key' diff --git a/lib/rbot/plugins/lart.rb b/lib/rbot/plugins/lart.rb new file mode 100644 index 00000000..de767197 --- /dev/null +++ b/lib/rbot/plugins/lart.rb @@ -0,0 +1,181 @@ +# Author: Michael Brailsford <brailsmt@yahoo.com> +# aka brailsmt +# Purpose: Provide for humorous larts and praises +# Copyright: 2002 Michael Brailsford. All rights reserved. +# License: This plugin is licensed under the BSD license. The terms of +# which follow. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. + +class LartPlugin < Plugin + + # Keep a 1:1 relation between commands and handlers + @@handlers = { + "lart" => "handle_lart", + "praise" => "handle_praise", + "addlart" => "handle_addlart", + "rmlart" => "handle_rmlart", + "addpraise" => "handle_addpraise", + "rmpraise" => "handle_rmpraise" + } + + def name + "lart" + end + + #{{{ + def initialize + super + @larts = Array.new + @praises = Array.new + #read in the lart and praise files + if File.exists? "#{@bot.botclass}/lart/larts" + IO.foreach("#{@bot.botclass}/lart/larts") { |line| + @larts << line.chomp + } + end + if File.exists? "#{@bot.botclass}/lart/praises" + IO.foreach("#{@bot.botclass}/lart/praises") { |line| + @praises << line.chomp + } + end + end + #}}} + #{{{ + def cleanup + end + #}}} + #{{{ + def save + Dir.mkdir("#{@bot.botclass}/lart") if not FileTest.directory? "#{@bot.botclass}/lart" + File.open("#{@bot.botclass}/lart/larts", "w") { |file| + file.puts @larts + } + File.open("#{@bot.botclass}/lart/praises", "w") { |file| + file.puts @praises + } + end + #}}} + #{{{ + def privmsg(m) + if not m.params + m.reply "What a crazy fool! Did you mean |help stats?" + return + end + + meth = self.method(@@handlers[m.plugin]) + meth.call(m) if(@bot.auth.allow?(m.plugin, m.source, m.replyto)) + end + #}}} + #{{{ + def help(plugin, topic="") + "Lart: The lart plugin allows you to punish/praise someone in the channel. You can also add new punishments and new praises as well as delete them. For the curious, LART is an acronym for Luser Attitude Readjustment Tool.\nUsage: punish/lart <nick> <reason> -- punishes <nick> for <reason>. The reason is optional.\n praise <nick> <reason> -- praises <nick> for <reason>. The reason is optional.\n mod[lart|punish|praise] [add|remove] -- Add or remove a lart or praise." + end + #}}} + # The following are command handlers {{{ + #{{{ + def handle_lart(m) + for_idx = m.params =~ /\s+\bfor\b/ + if for_idx + nick = m.params[0, for_idx] + else + nick = m.params + end + lart = @larts[get_msg_idx(@larts.length)] + if lart == nil + m.reply "I dunno any larts" + return + end + if nick == @bot.nick + lart = replace_who lart, m.sourcenick + lart << " for trying to make me lart myself" + else + lart = replace_who lart, nick + lart << m.params[for_idx, m.params.length] if for_idx + end + + @bot.action m.replyto, lart + end + #}}} + #{{{ + def handle_praise(m) + for_idx = m.params =~ /\s+\bfor\b/ + if for_idx + nick = m.params[0, for_idx] + else + nick = m.params + end + praise = @praises[get_msg_idx(@praises.length)] + if not praise + m.reply "I dunno any praises" + return + end + + if nick == m.sourcenick + praise = @larts[get_msg_idx(@larts.length)] + praise = replace_who praise, nick + else + praise = replace_who praise, nick + praise << m.params.gsub(/#{nick}/, "") + end + + @bot.action m.replyto, praise + end + #}}} + #{{{ + def handle_addlart(m) + @larts << m.params + m.okay + end + #}}} + #{{{ + def handle_rmlart(m) + @larts.delete m.params + m.okay + end + #}}} + #{{{ + def handle_addpraise(m) + @praises << m.params + m.okay + end + #}}} + #{{{ + def handle_rmpraise(m) + @praises.delete m.params + m.okay + end + #}}} + #}}} + + # The following are utils for larts/praises {{{ + #{{{ + def replace_who(msg, nick) + msg.gsub(/<who>/i, "#{nick}") + end + #}}} + #{{{ + def get_msg_idx(max) + idx = rand(max) + end + #}}} + #}}} +end +plugin = LartPlugin.new +plugin.register("lart") +plugin.register("praise") + +plugin.register("addlart") +plugin.register("addpraise") + +plugin.register("rmlart") +plugin.register("rmpraise") diff --git a/lib/rbot/plugins/math.rb b/lib/rbot/plugins/math.rb new file mode 100644 index 00000000..4a207389 --- /dev/null +++ b/lib/rbot/plugins/math.rb @@ -0,0 +1,122 @@ +class MathPlugin < Plugin + @@digits = { + "first" => "1", + "second" => "2", + "third" => "3", + "fourth" => "4", + "fifth" => "5", + "sixth" => "6", + "seventh" => "7", + "eighth" => "8", + "ninth" => "9", + "tenth" => "10", + "one" => "1", + "two" => "2", + "three" => "3", + "four" => "4", + "five" => "5", + "six" => "6", + "seven" => "7", + "eight" => "8", + "nine" => "9", + "ten" => "10" + }; + + def help(plugin, topic="") + "math <expression>, evaluate mathematical expression" + end + def privmsg(m) + unless(m.params) + m.reply "incorrect usage: " + help(m.plugin) + return + end + + expr = m.params.dup + @@digits.each {|k,v| + expr.gsub!(/\b#{k}\b/, v) + } + + while expr =~ /(exp ([\w\d]+))/ + exp = $1 + val = Math.exp($2).to_s + expr.gsub!(/#{Regexp.escape exp}/, "+#{val}") + end + + while expr =~ /^\s*(dec2hex\s*(\d+))\s*\?*/ + exp = $1 + val = sprintf("%x", $2) + expr.gsub!(/#{Regexp.escape exp}/, "+#{val}") + end + + expr.gsub(/\be\b/, Math.exp(1).to_s) + + while expr =~ /(log\s*((\d+\.?\d*)|\d*\.?\d+))\s*/ + exp = $1 + res = $2 + + if res == 0 + val = "Infinity" + else + val = Math.log(res).to_s + end + + expr.gsub!(/#{Regexp.escape exp}/, "+#{val}") + end + + while expr =~ /(bin2dec ([01]+))/ + exp = $1 + val = join('', unpack('B*', pack('N', $2))) + val.gsub!(/^0+/, "") + expr.gsub!(/#{Regexp.escape exp}/, "+#{val}") + end + + expr.gsub!(/ to the power of /, " ** ") + expr.gsub!(/ to the /, " ** ") + expr.gsub!(/\btimes\b/, "*") + expr.gsub!(/\bdiv(ided by)? /, "/ ") + expr.gsub!(/\bover /, "/ ") + expr.gsub!(/\bsquared/, "**2 ") + expr.gsub!(/\bcubed/, "**3 ") + expr.gsub!(/\bto\s+(\d+)(r?st|nd|rd|th)?( power)?/, "**\1 ") + expr.gsub!(/\bpercent of/, "*0.01*") + expr.gsub!(/\bpercent/, "*0.01") + expr.gsub!(/\% of\b/, "*0.01*") + expr.gsub!(/\%/, "*0.01") + expr.gsub!(/\bsquare root of (\d+)/, "\1 ** 0.5 ") + expr.gsub!(/\bcubed? root of (\d+)/, "\1 **(1.0/3.0) ") + expr.gsub!(/ of /, " * ") + expr.gsub!(/(bit(-| )?)?xor(\'?e?d( with))?/, "^") + expr.gsub!(/(bit(-| )?)?or(\'?e?d( with))?/, "|") + expr.gsub!(/bit(-| )?and(\'?e?d( with))?/, "& ") + expr.gsub!(/(plus|and)/, "+") + + if (expr =~ /^\s*[-\d*+\s()\/^\.\|\&\*\!]+\s*$/ && + expr !~ /^\s*\(?\d+\.?\d*\)?\s*$/ && + expr !~ /^\s*$/ && + expr !~ /^\s*[( )]+\s*$/) + + begin + debug "evaluating expression \"#{expr}\"" + answer = eval(expr) + if answer =~ /^[-+\de\.]+$/ + answer = sprintf("%1.12f", answer) + answer.gsub!(/\.?0+$/, "") + answer.gsub!(/(\.\d+)000\d+/, '\1') + if (answer.length > 30) + answer = "a number with >30 digits..." + end + end + m.reply answer + rescue Exception => e + puts "couldn't evaluate expression \"#{m.params}\": #{e}" + m.reply "illegal expression \"#{m.params}\"" + return + end + else + m.reply "illegal expression \"#{m.params}\"" + return + end + end +end +plugin = MathPlugin.new +plugin.register("math") diff --git a/lib/rbot/plugins/nickserv.rb b/lib/rbot/plugins/nickserv.rb new file mode 100644 index 00000000..1ef2baf7 --- /dev/null +++ b/lib/rbot/plugins/nickserv.rb @@ -0,0 +1,99 @@ +# automatically lookup nicks in @registry and identify when asked + +class NickServPlugin < Plugin + + def help(plugin, topic="") + case topic + when "" + return "nickserv plugin: handles nickserv protected IRC nicks. topics password, register, identify, listnicks" + when "password" + return "nickserv password <nick> <passwd>: remember the password for nick <nick> and use it to identify in future" + when "register" + return "nickserv register [<password> [<email>]]: register the current nick, choosing a random password unless <password> is supplied - current nick must not already be registered for this to work. Also specify email if required by your services" + when "identify" + return "nickserv identify: identify with nickserv - shouldn't be needed - bot should identify with nickserv immediately on request - however this could be useful after splits or service disruptions, or when you just set the password for the current nick" + when "listnicks" + return "nickserv listnicks: lists nicknames and associated password the bot knows about - you will need config level auth access to do this one and it will reply by privmsg only" + end + end + + def initialize + super + # this plugin only wants to store strings! + class << @registry + def store(val) + val + end + def restore(val) + val + end + end + end + + def privmsg(m) + return unless m.params + + case m.params + when (/^password\s*(\S*)\s*(.*)$/) + nick = $1 + passwd = $2 + @registry[nick] = passwd + m.okay + when (/^register$/) + passwd = genpasswd + @bot.sendmsg "PRIVMSG", "NickServ", "REGISTER " + passwd + @registry[@bot.nick] = passwd + m.okay + when (/^register\s*(\S*)\s*(.*)$/) + passwd = $1 + email = $2 + @bot.sendmsg "PRIVMSG", "NickServ", "REGISTER " + passwd + " " + email + @registry[@bot.nick] = passwd + m.okay + when (/^register\s*(.*)\s*$/) + passwd = $1 + @bot.sendmsg "PRIVMSG", "NickServ", "REGISTER " + passwd + @registry[@bot.nick] = passwd + m.okay + when (/^listnicks$/) + if @bot.auth.allow?("config", m.source, m.replyto) + if @registry.length > 0 + @registry.each {|k,v| + @bot.say m.sourcenick, "#{k} => #{v}" + } + else + m.reply "none known" + end + end + when (/^identify$/) + if @registry.has_key?(@bot.nick) + @bot.sendmsg "PRIVMSG", "NickServ", "IDENTIFY " + @registry[@bot.nick] + m.okay + else + m.reply "I dunno the nickserv password for the nickname #{@bot.nick} :(" + end + end + end + + def listen(m) + return unless(m.kind_of? NoticeMessage) + + if (m.sourcenick == "NickServ" && m.message =~ /This nickname is owned by someone else/) + puts "nickserv asked us to identify for nick #{@bot.nick}" + if @registry.has_key?(@bot.nick) + @bot.sendmsg "PRIVMSG", "NickServ", "IDENTIFY " + @registry[@bot.nick] + end + end + end + + def genpasswd + # generate a random password + passwd = "" + 8.times do + passwd += (rand(26) + (rand(2) == 0 ? 65 : 97) ).chr + end + return passwd + end +end +plugin = NickServPlugin.new +plugin.register("nickserv") diff --git a/lib/rbot/plugins/nslookup.rb b/lib/rbot/plugins/nslookup.rb new file mode 100644 index 00000000..92da1ba7 --- /dev/null +++ b/lib/rbot/plugins/nslookup.rb @@ -0,0 +1,56 @@ +class DnsPlugin < Plugin + begin + require 'resolv-replace' + def gethostname(address) + Resolv.getname(address) + end + def getaddresses(name) + Resolv.getaddresses(name) + end + rescue LoadError + def gethostname(address) + Socket.gethostbyname(address).first + end + def getaddresses(name) + a = Socket.gethostbyname(name) + list = Socket.getaddrinfo(a[0], 'http') + addresses = Array.new + list.each {|line| + addresses << line[3] + } + addresses + end + end + + def help(plugin, topic="") + "nslookup|dns <hostname|ip> => show local resolution results for hostname or ip address" + end + def privmsg(m) + unless(m.params) + m.reply "incorrect usage: " + help(m.plugin) + return + end + Thread.new do + if(m.params =~ /^\d+\.\d+\.\d+\.\d+$/) + begin + a = gethostname(m.params) + m.reply m.params + ": " + a if a + rescue StandardError => err + m.reply "#{m.params}: not found" + end + elsif(m.params =~ /^\S+$/) + begin + a = getaddresses(m.params) + m.reply m.params + ": " + a.join(", ") + rescue StandardError => err + m.reply "#{m.params}: not found" + end + else + m.reply "incorrect usage: " + help(m.plugin) + end + end + end +end +plugin = DnsPlugin.new +plugin.register("nslookup") +plugin.register("dns") diff --git a/lib/rbot/plugins/opmeh.rb b/lib/rbot/plugins/opmeh.rb new file mode 100644 index 00000000..2776de60 --- /dev/null +++ b/lib/rbot/plugins/opmeh.rb @@ -0,0 +1,19 @@ +class OpMehPlugin < Plugin
+
+ def help(plugin, topic="")
+ return "opmeh <channel> => grant user ops in <channel>"
+ end
+
+ def privmsg(m)
+ if(m.params)
+ channel = m.params
+ else
+ channel = m.channel
+ end
+ target = m.sourcenick
+ @bot.sendq("MODE #{channel} +o #{target}")
+ m.okay
+ end
+end
+plugin = OpMehPlugin.new
+plugin.register("opmeh")
diff --git a/lib/rbot/plugins/quotes.rb b/lib/rbot/plugins/quotes.rb new file mode 100644 index 00000000..674a9ed6 --- /dev/null +++ b/lib/rbot/plugins/quotes.rb @@ -0,0 +1,321 @@ +Quote = Struct.new("Quote", "num", "date", "source", "quote") + +class QuotePlugin < Plugin + def initialize + super + @lists = Hash.new + Dir["#{@bot.botclass}/quotes/*"].each {|f| + channel = File.basename(f) + @lists[channel] = Array.new if(!@lists.has_key?(channel)) + IO.foreach(f) {|line| + if(line =~ /^(\d+) \| ([^|]+) \| (\S+) \| (.*)$/) + num = $1.to_i + @lists[channel][num] = Quote.new(num, $2, $3, $4) + end + } + } + end + def save + Dir.mkdir("#{@bot.botclass}/quotes") if(!FileTest.directory?("#{@bot.botclass}/quotes")) + @lists.each {|channel, quotes| + File.open("#{@bot.botclass}/quotes/#{channel}", "w") {|file| + quotes.compact.each {|q| + file.puts "#{q.num} | #{q.date} | #{q.source} | #{q.quote}" + } + } + } + end + def addquote(source, channel, quote) + @lists[channel] = Array.new if(!@lists.has_key?(channel)) + num = @lists[channel].length + @lists[channel][num] = Quote.new(num, Time.new, source, quote) + return num + end + def getquote(source, channel, num=nil) + return nil unless(@lists.has_key?(channel)) + return nil unless(@lists[channel].length > 0) + if(num) + if(@lists[channel][num]) + return @lists[channel][num], @lists[channel].length - 1 + end + else + # random quote + return @lists[channel].compact[rand(@lists[channel].nitems)], + @lists[channel].length - 1 + end + end + def delquote(channel, num) + return false unless(@lists.has_key?(channel)) + return false unless(@lists[channel].length > 0) + if(@lists[channel][num]) + @lists[channel][num] = nil + return true + end + return false + end + def countquote(source, channel=nil, regexp=nil) + unless(channel) + total=0 + @lists.each_value {|l| + total += l.compact.length + } + return total + end + return 0 unless(@lists.has_key?(channel)) + return 0 unless(@lists[channel].length > 0) + if(regexp) + matches = @lists[channel].compact.find_all {|a| a.quote =~ /#{regexp}/i } + else + matches = @lists[channel].compact + end + return matches.length + end + def searchquote(source, channel, regexp) + return nil unless(@lists.has_key?(channel)) + return nil unless(@lists[channel].length > 0) + matches = @lists[channel].compact.find_all {|a| a.quote =~ /#{regexp}/i } + if(matches.length > 0) + return matches[rand(matches.length)], @lists[channel].length - 1 + else + return nil + end + end + def help(plugin, topic="") + case topic + when "addquote" + return "addquote [<channel>] <quote> => Add quote <quote> for channel <channel>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Responds to !addquote without addressing if so configured" + when "delquote" + return "delquote [<channel>] <num> => delete quote from <channel> with number <num>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Responds to !delquote without addressing if so configured" + when "getquote" + return "getquote [<channel>] [<num>] => get quote from <channel> with number <num>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Without <num>, a random quote will be returned. Responds to !getquote without addressing if so configured" + when "searchquote" + return "searchquote [<channel>] <regexp> => search for quote from <channel> that matches <regexp>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Responds to !searchquote without addressing if so configured" + when "topicquote" + return "topicquote [<channel>] [<num>] => set topic to quote from <channel> with number <num>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Without <num>, a random quote will be set. Responds to !topicquote without addressing if so configured" + when "countquote" + return "countquote [<channel>] <regexp> => count quotes from <channel> that match <regexp>. You only need to supply <channel> if you are addressing #{@bot.nick} privately. Responds to !countquote without addressing if so configured" + when "whoquote" + return "whoquote [<channel>] <num> => show who added quote <num>. You only need to supply <channel> if you are addressing #{@bot.nick} privately" + when "whenquote" + return "whenquote [<channel>] <num> => show when quote <num> was added. You only need to supply <channel> if you are addressing #{@bot.nick} privately" + else + return "Quote module (Quote storage and retrieval) topics: addquote, getquote, searchquote, topicquote, countquote, whoquote, whenquote" + end + end + def listen(m) + return unless(m.kind_of? PrivMessage) + + command = m.message.dup + if(m.address? && m.private?) + case command + when (/^addquote\s+(#\S+)\s+(.*)/) + channel = $1 + quote = $2 + if(@bot.auth.allow?("addquote", m.source, m.replyto)) + if(channel =~ /^#/) + num = addquote(m.source, channel, quote) + m.reply "added the quote (##{num})" + end + end + when (/^getquote\s+(#\S+)$/) + channel = $1 + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^getquote\s+(#\S+)\s+(\d+)$/) + channel = $1 + num = $2.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel, num) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^whoquote\s+(#\S+)\s+(\d+)$/) + channel = $1 + num = $2.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel, num) + if(quote) + m.reply "quote #{quote.num} added by #{quote.source}" + else + m.reply "quote not found!" + end + end + when (/^whenquote\s+(#\S+)\s+(\d+)$/) + channel = $1 + num = $2.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel, num) + if(quote) + m.reply "quote #{quote.num} added on #{quote.date}" + else + m.reply "quote not found!" + end + end + when (/^topicquote\s+(#\S+)$/) + channel = $1 + if(@bot.auth.allow?("topicquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel) + if(quote) + @bot.topic channel, "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^topicquote\s+(#\S+)\s+(\d+)$/) + channel = $1 + num = $2.to_i + if(@bot.auth.allow?("topicquote", m.source, m.replyto)) + quote, total = getquote(m.source, channel, num) + if(quote) + @bot.topic channel, "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^delquote\s+(#\S+)\s+(\d+)$/) + channel = $1 + num = $2.to_i + if(@bot.auth.allow?("delquote", m.source, m.replyto)) + if(delquote(channel, num)) + m.okay + else + m.reply "quote not found!" + end + end + when (/^searchquote\s+(#\S+)\s+(.*)$/) + channel = $1 + reg = $2 + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = searchquote(m.source, channel, reg) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^countquote$/) + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + total = countquote(m.source) + m.reply "#{total} quotes" + end + when (/^countquote\s+(#\S+)\s*(.*)$/) + channel = $1 + reg = $2 + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + total = countquote(m.source, channel, reg) + if(reg.length > 0) + m.reply "#{total} quotes match: #{reg}" + else + m.reply "#{total} quotes" + end + end + end + elsif (m.address? || (@bot.config["QUOTE_LISTEN"] && command.gsub!(/^!/, ""))) + case command + when (/^addquote\s+(.+)/) + if(@bot.auth.allow?("addquote", m.source, m.replyto)) + num = addquote(m.source, m.target, $1) + m.reply "added the quote (##{num})" + end + when (/^getquote$/) + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "no quotes found!" + end + end + when (/^getquote\s+(\d+)$/) + num = $1.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target, num) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^whenquote\s+(\d+)$/) + num = $1.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target, num) + if(quote) + m.reply "quote #{quote.num} added on #{quote.date}" + else + m.reply "quote not found!" + end + end + when (/^whoquote\s+(\d+)$/) + num = $1.to_i + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target, num) + if(quote) + m.reply "quote #{quote.num} added by #{quote.source}" + else + m.reply "quote not found!" + end + end + when (/^topicquote$/) + if(@bot.auth.allow?("topicquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target) + if(quote) + @bot.topic m.target, "[#{quote.num}] #{quote.quote}" + else + m.reply "no quotes found!" + end + end + when (/^topicquote\s+(\d+)$/) + num = $1.to_i + if(@bot.auth.allow?("topicquote", m.source, m.replyto)) + quote, total = getquote(m.source, m.target, num) + if(quote) + @bot.topic m.target, "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^delquote\s+(\d+)$/) + num = $1.to_i + if(@bot.auth.allow?("delquote", m.source, m.replyto)) + if(delquote(m.target, num)) + m.okay + else + m.reply "quote not found!" + end + end + when (/^searchquote\s+(.*)$/) + reg = $1 + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + quote, total = searchquote(m.source, m.target, reg) + if(quote) + m.reply "[#{quote.num}] #{quote.quote}" + else + m.reply "quote not found!" + end + end + when (/^countquote(?:\s+(.*))?$/) + reg = $1 + if(@bot.auth.allow?("getquote", m.source, m.replyto)) + total = countquote(m.source, m.target, reg) + if(reg && reg.length > 0) + m.reply "#{total} quotes match: #{reg}" + else + m.reply "#{total} quotes" + end + end + end + end + end +end +plugin = QuotePlugin.new +plugin.register("quotes") diff --git a/lib/rbot/plugins/remind.rb b/lib/rbot/plugins/remind.rb new file mode 100644 index 00000000..5ad980ae --- /dev/null +++ b/lib/rbot/plugins/remind.rb @@ -0,0 +1,154 @@ +require 'rbot/utils' + +class RemindPlugin < Plugin + def initialize + super + @reminders = Hash.new + end + def cleanup + @reminders.each_value {|v| + v.each_value {|vv| + @bot.timer.remove(vv) + } + } + @reminders.clear + end + def help(plugin, topic="") + if(plugin =~ /^remind\+$/) + "see remind. remind+ can be used to remind someone else of something, using <nick> instead of 'me'. However this will generally require a higher auth level than remind." + else + "remind me [about] <message> in <time>, remind me [about] <message> every <time>, remind me [about] <message> at <time>, remind me no more [about] <message>, remind me no more" + end + end + def add_reminder(who, subject, timestr, repeat=false) + begin + period = Irc::Utils.timestr_offset(timestr) + rescue RuntimeError + return "couldn't parse that time string (#{timestr}) :(" + end + if(period <= 0) + return "that time is in the past! (#{timestr})" + end + if(period < 30 && repeat) + return "repeats of less than 30 seconds are forbidden" + end + if(!@reminders.has_key?(who)) + @reminders[who] = Hash.new + elsif(@reminders[who].has_key?(subject)) + del_reminder(who, subject) + end + + if(repeat) + @reminders[who][subject] = @bot.timer.add(period) { + time = Time.now + period + tstr = time.strftime("%H:%M:%S") + @bot.say who, "repeat reminder (next at #{tstr}): #{subject}" + } + else + @reminders[who][subject] = @bot.timer.add_once(period) { + time = Time.now + period + tstr = time.strftime("%H:%M:%S") + @bot.say who, "reminder (#{tstr}): #{subject}" + } + end + return false + end + def del_reminder(who, subject=nil) + if(subject) + if(@reminders.has_key?(who) && @reminders[who].has_key?(subject)) + @bot.timer.remove(@reminders[who][subject]) + @reminders[who].delete(subject) + end + else + if(@reminders.has_key?(who)) + @reminders[who].each_value {|v| + @bot.timer.remove(v) + } + @reminders.delete(who) + end + end + end + def privmsg(m) + + if(m.params =~ /^(\S+)\s+(?:about\s+)?(.*)\s+in\s+(.*)$/) + who = $1 + subject = $2 + period = $3 + if(who =~ /^me$/) + who = m.sourcenick + else + unless(m.plugin =~ /^remind\+$/) + m.reply "incorrect usage: use remind+ to remind persons other than yourself" + return + end + end + if(err = add_reminder(who, subject, period)) + m.reply "incorrect usage: " + err + return + end + elsif(m.params =~ /^(\S+)\s+(?:about\s+)?(.*)\s+every\s+(.*)$/) + who = $1 + subject = $2 + period = $3 + if(who =~ /^me$/) + who = m.sourcenick + else + unless(m.plugin =~ /^remind\+$/) + m.reply "incorrect usage: use remind+ to remind persons other than yourself" + return + end + end + if(err = add_reminder(who, subject, period, true)) + m.reply "incorrect usage: " + err + return + end + elsif(m.params =~ /^(\S+)\s+(?:about\s+)?(.*)\s+at\s+(.*)$/) + who = $1 + subject = $2 + time = $3 + if(who =~ /^me$/) + who = m.sourcenick + else + unless(m.plugin =~ /^remind\+$/) + m.reply "incorrect usage: use remind+ to remind persons other than yourself" + return + end + end + if(err = add_reminder(who, subject, time)) + m.reply "incorrect usage: " + err + return + end + elsif(m.params =~ /^(\S+)\s+no\s+more\s+(?:about\s+)?(.*)$/) + who = $1 + subject = $2 + if(who =~ /^me$/) + who = m.sourcenick + else + unless(m.plugin =~ /^remind\+$/) + m.reply "incorrect usage: use remind+ to remind persons other than yourself" + return + end + end + del_reminder(who, subject) + elsif(m.params =~ /^(\S+)\s+no\s+more$/) + who = $1 + if(who =~ /^me$/) + who = m.sourcenick + else + unless(m.plugin =~ /^remind\+$/) + m.reply "incorrect usage: use remind+ to remind persons other than yourself" + return + end + end + del_reminder(who) + else + m.reply "incorrect usage: " + help(m.plugin) + return + end + m.okay + end +end +plugin = RemindPlugin.new +plugin.register("remind") +plugin.register("remind+") + diff --git a/lib/rbot/plugins/roshambo.rb b/lib/rbot/plugins/roshambo.rb new file mode 100644 index 00000000..4f20fb15 --- /dev/null +++ b/lib/rbot/plugins/roshambo.rb @@ -0,0 +1,54 @@ +# Play the game of roshambo (rock-paper-scissors) +# Copyright (C) 2004 Hans Fugal +# Distributed under the same license as rbot itself +require 'time' +class RoshamboPlugin < Plugin + def initialize + super + @scoreboard = {} + end + def help(plugin, topic="") + "roshambo <rock|paper|scissors> => play roshambo" + end + def privmsg(m) + # simultaneity + choice = choose + + # init scoreboard + if (not @scoreboard.has_key?(m.sourcenick) or (Time.now - @scoreboard[m.sourcenick]['timestamp']) > 3600) + @scoreboard[m.sourcenick] = {'me'=>0,'you'=>0,'timestamp'=>Time.now} + end + case m.params + when 'rock','paper','scissors' + s = score(choice,m.params) + @scoreboard[m.sourcenick]['timestamp'] = Time.now + myscore=@scoreboard[m.sourcenick]['me'] + yourscore=@scoreboard[m.sourcenick]['you'] + case s + when 1 + yourscore=@scoreboard[m.sourcenick]['you'] += 1 + m.reply "#{choice}. You win. Score: me #{myscore} you #{yourscore}" + when 0 + m.reply "#{choice}. We tie. Score: me #{myscore} you #{yourscore}" + when -1 + myscore=@scoreboard[m.sourcenick]['me'] += 1 + m.reply "#{choice}! I win! Score: me #{myscore} you #{yourscore}" + end + else + m.reply "incorrect usage: " + help(m.plugin) + end + end + + def choose + ['rock','paper','scissors'][rand(3)] + end + def score(a,b) + beats = {'rock'=>'scissors', 'paper'=>'rock', 'scissors'=>'paper'} + return -1 if beats[a] == b + return 1 if beats[b] == a + return 0 + end +end +plugin = RoshamboPlugin.new +plugin.register("roshambo") +plugin.register("rps") diff --git a/lib/rbot/plugins/rot13.rb b/lib/rbot/plugins/rot13.rb new file mode 100644 index 00000000..1f367dbd --- /dev/null +++ b/lib/rbot/plugins/rot13.rb @@ -0,0 +1,14 @@ +class RotPlugin < Plugin + def help(plugin, topic="") + "rot13 <string> => encode <string> to rot13 or back" + end + def privmsg(m) + unless(m.params && m.params =~ /^.+$/) + m.reply "incorrect usage: " + help(m.plugin) + return + end + m.reply m.params.tr("A-Za-z", "N-ZA-Mn-za-m"); + end +end +plugin = RotPlugin.new +plugin.register("rot13") diff --git a/lib/rbot/plugins/roulette.rb b/lib/rbot/plugins/roulette.rb new file mode 100644 index 00000000..c9d585ea --- /dev/null +++ b/lib/rbot/plugins/roulette.rb @@ -0,0 +1,147 @@ +RouletteHistory = Struct.new("RouletteHistory", :games, :shots, :deaths, :misses, :wins) + +class RoulettePlugin < Plugin + def initialize + super + reload + end + def help(plugin, topic="") + "roulette => play russian roulette - starts a new game if one isn't already running. One round in a six chambered gun. Take turns to say roulette to the bot, until somebody dies. roulette reload => force the gun to reload, roulette stats => show stats from all games, roulette stats <player> => show stats for <player>, roulette clearstats => clear stats (config level auth required)" + end + def privmsg(m) + if m.params == "reload" + @bot.action m.replyto, "reloads" + reload + # all players win on a reload + # (allows you to play 3-shot matches etc) + @players.each {|plyr| + pdata = @registry[plyr] + next if pdata == nil + pdata.wins += 1 + @registry[plyr] = pdata + } + return + elsif m.params == "stats" + m.reply stats + return + elsif m.params =~ /^stats\s+(.+)$/ + m.reply(playerstats($1)) + return + elsif m.params == "clearstats" + if @bot.auth.allow?("config", m.source, m.replyto) + @registry.clear + m.okay + end + return + elsif m.params + m.reply "incorrect usage: " + help(m.plugin) + return + end + if m.private? + m.reply "you gotta play roulette in channel dude" + return + end + + playerdata = nil + if @registry.has_key?(m.sourcenick) + playerdata = @registry[m.sourcenick] + else + playerdata = RouletteHistory.new(0,0,0,0,0) + end + + unless @players.include?(m.sourcenick) + @players << m.sourcenick + playerdata.games += 1 + end + playerdata.shots += 1 + + shot = @chambers.pop + if shot + m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => *BANG*" + playerdata.deaths += 1 + @players.each {|plyr| + next if plyr == m.sourcenick + pdata = @registry[plyr] + next if pdata == nil + pdata.wins += 1 + @registry[plyr] = pdata + } + else + m.reply "#{m.sourcenick}: chamber #{6 - @chambers.length} of 6 => +click+" + playerdata.misses += 1 + end + + @registry[m.sourcenick] = playerdata + + if shot || @chambers.empty? + @bot.action m.replyto, "reloads" + reload + end + end + def reload + @chambers = [false, false, false, false, false, false] + @chambers[rand(@chambers.length)] = true + @players = Array.new + end + def playerstats(player) + pstats = @registry[player] + return "#{player} hasn't played enough games yet" if pstats.nil? + return "#{player} has played #{pstats.games} games, won #{pstats.wins} and lost #{pstats.deaths}. #{player} pulled the trigger #{pstats.shots} times and found the chamber empty on #{pstats.misses} occasions." + end + def stats + total_players = 0 + total_games = 0 + total_shots = 0 + + died_most = [nil,0] + won_most = [nil,0] + h_win_percent = [nil,0] + l_win_percent = [nil,0] + h_luck_percent = [nil,0] + l_luck_percent = [nil,0] + @registry.each {|k,v| + total_players += 1 + total_games += v.deaths + total_shots += v.shots + + win_rate = v.wins.to_f / v.games * 100 + if h_win_percent[0].nil? || win_rate > h_win_percent[1] && v.games > 2 + h_win_percent = [[k], win_rate] + elsif win_rate == h_win_percent[1] && v.games > 2 + h_win_percent[0] << k + end + if l_win_percent[0].nil? || win_rate < l_win_percent[1] && v.games > 2 + l_win_percent = [[k], win_rate] + elsif win_rate == l_win_percent[1] && v.games > 2 + l_win_percent[0] << k + end + + luck = v.misses.to_f / v.shots * 100 + if h_luck_percent[0].nil? || luck > h_luck_percent[1] && v.games > 2 + h_luck_percent = [[k], luck] + elsif luck == h_luck_percent[1] && v.games > 2 + h_luck_percent[0] << k + end + if l_luck_percent[0].nil? || luck < l_luck_percent[1] && v.games > 2 + l_luck_percent = [[k], luck] + elsif luck == l_luck_percent[1] && v.games > 2 + l_luck_percent[0] << k + end + + if died_most[0].nil? || v.deaths > died_most[1] + died_most = [[k], v.deaths] + elsif v.deaths == died_most[1] + died_most[0] << k + end + if won_most[0].nil? || v.wins > won_most[1] + won_most = [[k], v.wins] + elsif v.wins == won_most[1] + won_most[0] << k + end + } + return "roulette stats: no games played yet" if total_games < 1 + return "roulette stats: #{total_games} games completed, #{total_shots} shots fired at #{total_players} players. Luckiest: #{h_luck_percent[0].join(',')} (#{sprintf '%.1f', h_luck_percent[1]}% clicks). Unluckiest: #{l_luck_percent[0].join(',')} (#{sprintf '%.1f', l_luck_percent[1]}% clicks). Highest survival rate: #{h_win_percent[0].join(',')} (#{sprintf '%.1f', h_win_percent[1]}%). Lowest survival rate: #{l_win_percent[0].join(',')} (#{sprintf '%.1f', l_win_percent[1]}%). Most wins: #{won_most[0].join(',')} (#{won_most[1]}). Most deaths: #{died_most[0].join(',')} (#{died_most[1]})." + end +end +plugin = RoulettePlugin.new +plugin.register("roulette") diff --git a/lib/rbot/plugins/seen.rb b/lib/rbot/plugins/seen.rb new file mode 100644 index 00000000..6bd86a70 --- /dev/null +++ b/lib/rbot/plugins/seen.rb @@ -0,0 +1,89 @@ +Saw = Struct.new("Saw", :nick, :time, :type, :where, :message) + +class SeenPlugin < Plugin + def help(plugin, topic="") + "seen <nick> => have you seen, or when did you last see <nick>" + end + + def privmsg(m) + unless(m.params =~ /^(\S)+$/) + m.reply "incorrect usage: " + help(m.plugin) + return + end + + m.params.gsub!(/\?$/, "") + + if @registry.has_key?(m.params) + m.reply seen(@registry[m.params]) + else + m.reply "nope!" + end + end + + def listen(m) + # keep database up to date with who last said what + if m.kind_of?(PrivMessage) + return if m.private? || m.address? + if m.action? + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "ACTION", + m.target, m.message.dup) + else + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "PUBLIC", + m.target, m.message.dup) + end + elsif m.kind_of?(QuitMessage) + return if m.address? + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "QUIT", + nil, m.message.dup) + elsif m.kind_of?(NickMessage) + return if m.address? + @registry[m.message] = Saw.new(m.sourcenick.dup, Time.new, "NICK", + nil, m.message.dup) + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "NICK", + nil, m.message.dup) + elsif m.kind_of?(PartMessage) + return if m.address? + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "PART", + m.target, m.message.dup) + elsif m.kind_of?(JoinMessage) + return if m.address? + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "JOIN", + m.target, m.message.dup) + elsif m.kind_of?(TopicMessage) + return if m.address? + @registry[m.sourcenick] = Saw.new(m.sourcenick.dup, Time.new, "TOPIC", + m.target, m.message.dup) + end + end + + def seen(saw) + ret = "#{saw.nick} was last seen " + ago = Time.new - saw.time + + if (ago.to_i == 0) + ret += "just now, " + else + ret += Utils.secs_to_string(ago) + " ago, " + end + + case saw.type + when "PUBLIC" + ret += "saying #{saw.message}" + when "ACTION" + ret += "doing #{saw.nick} #{saw.message}" + when "NICK" + ret += "changing nick from #{saw.nick} to #{saw.message}" + when "PART" + ret += "leaving #{saw.where}" + when "JOIN" + ret += "joining #{saw.where}" + when "QUIT" + ret += "quiting IRC (#{saw.message})" + when "TOPIC" + ret += "changing the topic of #{saw.where} to #{saw.message}" + end + end + +end +plugin = SeenPlugin.new +plugin.register("seen") diff --git a/lib/rbot/plugins/slashdot.rb b/lib/rbot/plugins/slashdot.rb new file mode 100644 index 00000000..b09ac7a7 --- /dev/null +++ b/lib/rbot/plugins/slashdot.rb @@ -0,0 +1,95 @@ +require 'rexml/document' +require 'uri/common' + +class SlashdotPlugin < Plugin + include REXML + def help(plugin, topic="") + "slashdot search <string> [<max>=4] => search slashdot for <string>, slashdot [<max>=4] => return up to <max> slashdot headlines (use negative max to return that many headlines, but all on one line.)" + end + def privmsg(m) + if m.params && m.params =~ /^search\s+(.*)\s+(\d+)$/ + search = $1 + limit = $2.to_i + search_slashdot m, search, limit + elsif m.params && m.params =~ /^search\s+(.*)$/ + search = $1 + search_slashdot m, search + elsif m.params && m.params =~ /^([-\d]+)$/ + limit = $1.to_i + slashdot m, limit + else + slashdot m + end + end + + def search_slashdot(m, search, max=4) + begin + xml = @bot.httputil.get(URI.parse("http://slashdot.org/search.pl?content_type=rss&query=#{URI.escape(search)}")) + rescue URI::InvalidURIError, URI::BadURIError => e + m.reply "illegal search string #{search}" + return + end + unless xml + m.reply "search for #{search} failed" + return + end + begin + doc = Document.new xml + rescue REXML::ParseException => e + puts e + m.reply "couldn't parse output XML: #{e.class}" + return + end + unless doc + m.reply "search for #{search} failed" + return + end + max = 8 if max > 8 + done = 0 + doc.elements.each("*/item") {|e| + desc = e.elements["title"].text + desc.gsub!(/(.{150}).*/, '\1..') + reply = sprintf("%s | %s", e.elements["link"].text, desc) + m.reply reply + done += 1 + break if done >= max + } + end + + def slashdot(m, max=4) + xml = @bot.httputil.get(URI.parse("http://slashdot.org/slashdot.xml")) + unless xml + m.reply "slashdot news parse failed" + return + end + doc = Document.new xml + unless doc + m.reply "slashdot news parse failed (invalid xml)" + return + end + done = 0 + oneline = false + if max < 0 + max = (0 - max) + oneline = true + end + max = 8 if max > 8 + matches = Array.new + doc.elements.each("*/story") {|e| + matches << [ e.elements["title"].text, + e.elements["author"].text, + e.elements["time"].text.gsub(/\d{4}-(\d{2})-(\d{2})/, "\\2/\\1").gsub(/:\d\d$/, "") ] + done += 1 + break if done >= max + } + if oneline + m.reply matches.collect{|mat| mat[0]}.join(" | ") + else + matches.each {|mat| + m.reply sprintf("%36s | %8s | %8s", mat[0][0,36], mat[1][0,8], mat[2]) + } + end + end +end +plugin = SlashdotPlugin.new +plugin.register("slashdot") diff --git a/lib/rbot/plugins/spell.rb b/lib/rbot/plugins/spell.rb new file mode 100644 index 00000000..81ee1ac6 --- /dev/null +++ b/lib/rbot/plugins/spell.rb @@ -0,0 +1,36 @@ +class SpellPlugin < Plugin + def help(plugin, topic="") + "spell <word> => check spelling of <word>, suggest alternatives" + end + def privmsg(m) + unless(m.params && m.params =~ /^\S+$/) + m.reply "incorrect usage: " + help(m.plugin) + return + end + p = IO.popen("ispell -a -S", "w+") + if(p) + p.puts m.params + p.close_write + p.each_line {|l| + if(l =~ /^\*/) + m.reply "#{m.params} may be spelled correctly" + return + elsif(l =~ /^\s*&.*: (.*)$/) + m.reply "#{m.params}: #$1" + return + elsif(l =~ /^\s*\+ (.*)$/) + m.reply "#{m.params} is presumably derived from " + $1.downcase + return + elsif(l =~ /^\s*#/) + m.reply "#{m.params}: no suggestions" + return + end + } + else + m.reply "couldn't exec ispell :(" + return + end + end +end +plugin = SpellPlugin.new +plugin.register("spell") diff --git a/lib/rbot/plugins/tube.rb b/lib/rbot/plugins/tube.rb new file mode 100644 index 00000000..77ca5227 --- /dev/null +++ b/lib/rbot/plugins/tube.rb @@ -0,0 +1,77 @@ +#Tube Status Enquiry plugin for rbot +#Plugin by Colm Linehan + +require 'rexml/document' +require 'uri/common' + +class TubePlugin < Plugin + include REXML + def help(plugin, topic="") + "tube [district|circle|metropolitan|central|jubilee|bakerloo|waterloo_city|hammersmith_city|victoria|eastlondon|northern|piccadilly] => display tube service status for the specified line(Docklands Light Railway is not currently supported), tube stations => list tube stations (not lines) with problems" + end + def privmsg(m) + if m.params && m.params =~ /^stations$/ + check_stations m + elsif m.params && m.params =~ /^(.*)$/ + line = $1.downcase.capitalize + check_tube m, line + end + end + + def check_tube(m, line) + begin + tube_page = @bot.httputil.get(URI.parse("http://www.tfl.gov.uk/tfl/service_rt_tube.shtml"), 1, 1) + rescue URI::InvalidURIError, URI::BadURIError => e + m.reply "Cannot contact Tube Service Status page" + return + end + unless tube_page + m.reply "Cannot contact Tube Service Status page" + return + end + next_line = false + tube_page.each_line {|l| + next if l == "\r\n" + next if l == "\n" + if (next_line) + if (l =~ /^<tr valign=top> <td>\s*(.*)<\/td><\/tr>/i) + m.reply $1.split(/<[^>]+>| /i).join(" ") + return + else + m.reply "There are problems on the #{line} line, but I didn't understand the page format. You should check out http://www.tfl.gov.uk/tfl/service_rt_tube.shtml for more details." + return + end + end + next_line = true if (l =~ /class="#{line}"/i) + } + m.reply "No Problems on the #{line} line." + end + + def check_stations(m) + begin + tube_page = @bot.httputil.get(URI.parse("http://www.tfl.gov.uk/tfl/service_rt_tube.shtml")) + rescue URI::InvalidURIError, URI::BadURIError => e + m.reply "Cannot contact Tube Service Status page" + return + end + unless tube_page + m.reply "Cannot contact Tube Service Status page" + return + end + stations_array = Array.new + tube_page.each_line {|l| + if (l =~ /<tr valign=top> <td valign="middle" class="Station"><b>(.*)<\/b><\/td><\/tr>\s*/i) + stations_array.push $1 + end + } + if stations_array.empty? + m.reply "There are no station-specific announcements" + return + else + m.reply stations_array.join(", ") + return + end + end +end +plugin = TubePlugin.new +plugin.register("tube") diff --git a/lib/rbot/plugins/url.rb b/lib/rbot/plugins/url.rb new file mode 100644 index 00000000..ed82d1c1 --- /dev/null +++ b/lib/rbot/plugins/url.rb @@ -0,0 +1,98 @@ +Url = Struct.new("Url", :channel, :nick, :time, :url) + +class UrlPlugin < Plugin + def initialize + super + @registry.set_default(Array.new) + end + def help(plugin, topic="") + "urls [<max>=4] => list <max> last urls mentioned in current channel, urls <channel> [<max>=4] => list <max> last urls mentioned in <channel>, urls search <regexp> => search for matching urls, urls search <channel> <regexp>, search for matching urls in channel <channel>" + end + def listen(m) + return unless m.kind_of?(PrivMessage) + return if m.address? + # TODO support multiple urls in one line + if m.message =~ /(f|ht)tps?:\/\// + if m.message =~ /((f|ht)tps?:\/\/.*?)(?:\s+|$)/ + url = Url.new(m.target, m.sourcenick, Time.new, $1) + list = @registry[m.target] + debug "#{list.length} urls so far" + if list.length > 50 + list.pop + end + debug "storing url #{url.url}" + list.unshift url + debug "#{list.length} urls now" + @registry[m.target] = list + end + end + end + def privmsg(m) + case m.params + when nil + if m.public? + urls m, m.target + else + m.reply "in a private message, you need to specify a channel name for urls" + end + when (/^(\d+)$/) + max = $1.to_i + if m.public? + urls m, m.target, max + else + m.reply "in a private message, you need to specify a channel name for urls" + end + when (/^(#.*?)\s+(\d+)$/) + channel = $1 + max = $2.to_i + urls m, channel, max + when (/^(#.*?)$/) + channel = $1 + urls m, channel + when (/^search\s+(#.*?)\s+(.*)$/) + channel = $1 + string = $2 + search m, channel, string + when (/^search\s+(.*)$/) + string = $1 + if m.public? + search m, m.target, string + else + m.reply "in a private message, you need to specify a channel name for urls" + end + else + m.reply "incorrect usage: " + help(m.plugin) + end + end + + def urls(m, channel, max=4) + max = 10 if max > 10 + max = 1 if max < 1 + list = @registry[channel] + if list.empty? + m.reply "no urls seen yet for channel #{channel}" + else + list[0..(max-1)].each do |url| + m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}" + end + end + end + + def search(m, channel, string, max=4) + max = 10 if max > 10 + max = 1 if max < 1 + regex = Regexp.new(string) + list = @registry[channel].find_all {|url| + regex.match(url.url) || regex.match(url.nick) + } + if list.empty? + m.reply "no matches for channel #{channel}" + else + list[0..(max-1)].each do |url| + m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}" + end + end + end +end +plugin = UrlPlugin.new +plugin.register("urls") diff --git a/lib/rbot/plugins/weather.rb b/lib/rbot/plugins/weather.rb new file mode 100644 index 00000000..3e4134e4 --- /dev/null +++ b/lib/rbot/plugins/weather.rb @@ -0,0 +1,55 @@ +class WeatherPlugin < Plugin + + def help(plugin, topic="") + "weather <ICAO> => display the current weather at the location specified by the ICAO code [Lookup your ICAO code at http://www.nws.noaa.gov/oso/siteloc.shtml] - this will also store the ICAO against your nick, so you can later just say \"weather\", weather => display the current weather at the location you last asked for" + end + + def initialize + super + # this plugin only wants to store strings + class << @registry + def store(val) + val + end + def restore(val) + val + end + end + @metar_cache = Hash.new + end + + def describe(m, where) + if @metar_cache.has_key?(where) && + Time.now - @metar_cache[where].date < 3600 + met = @metar_cache[where] + else + met = Utils.get_metar(where) + end + + if met + m.reply met.pretty_print + @metar_cache[where] = met + else + m.reply "couldn't find weather data for #{where}" + end + end + + def privmsg(m) + case m.params + when nil + if @registry.has_key?(m.sourcenick) + where = @registry[m.sourcenick] + describe(m,where) + else + m.reply "I don't know where #{m.sourcenick} is!" + end + when (/^(\S{4})$/) + where = $1 + @registry[m.sourcenick] = where + describe(m,where) + end + end + +end +plugin = WeatherPlugin.new +plugin.register("weather") diff --git a/lib/rbot/plugins/wserver.rb b/lib/rbot/plugins/wserver.rb new file mode 100644 index 00000000..e1fe10bd --- /dev/null +++ b/lib/rbot/plugins/wserver.rb @@ -0,0 +1,75 @@ +require 'net/http' +require 'uri' +Net::HTTP.version_1_2 + +class WserverPlugin < Plugin + def help(plugin, topic="") + "wserver <uri> => try and determine what webserver <uri> is using" + end + def privmsg(m) + unless(m.params && m.params =~ /^\S+$/) + m.reply "incorrect usage: " + help(m.plugins) + return + end + + redirect_count = 0 + hostname = m.params.dup + hostname = "http://#{hostname}" unless hostname =~ /:\/\// + begin + if(redirect_count > 3) + m.reply "cowardly refusing to follow more than 3 redirects" + return + end + + begin + uri = URI.parse(hostname) + rescue URI::InvalidURIError => err + m.reply "#{m.params} is not a valid URI" + return + end + + unless(uri) + m.reply "incorrect usage: " + help(m.plugin) + return + end + + http = @bot.httputil.get_proxy(uri) + http.open_timeout = 5 + + http.start {|http| + resp = http.head('/') + server = resp['Server'] + if(server && server.length > 0) + m.reply "#{uri.host} is running #{server}" + else + m.reply "couldn't tell what #{uri.host} is running" + end + + if(resp.code == "302" || resp.code == "301") + newloc = resp['location'] + newuri = URI.parse(newloc) + # detect and ignore incorrect redirects (to relative paths etc) + if (newuri.host != nil) + if(uri.host != newuri.host) + m.reply "#{uri.host} redirects to #{newuri.scheme}://#{newuri.host}" + raise resp['location'] + end + end + end + } + rescue TimeoutError => err + m.reply "timed out connecting to #{uri.host}:#{uri.port} :(" + return + rescue RuntimeError => err + redirect_count += 1 + hostname = err.message + retry + rescue StandardError => err + puts err + m.reply "couldn't connect to #{uri.host}:#{uri.port} :(" + return + end + end +end +plugin = WserverPlugin.new +plugin.register("wserver") diff --git a/lib/rbot/registry.rb b/lib/rbot/registry.rb new file mode 100644 index 00000000..cd78dcbf --- /dev/null +++ b/lib/rbot/registry.rb @@ -0,0 +1,292 @@ +# Copyright (C) 2002 Tom Gilbert. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies of the Software and its documentation and acknowledgment shall be +# given in the documentation and software packages that this Software was +# used. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +# IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +require 'rbot/dbhash' + +module Irc + + # this is the backend of the RegistryAccessor class, which ties it to a + # DBHash object called plugin_registry(.db). All methods are delegated to + # the DBHash. + class BotRegistry + def initialize(bot) + @bot = bot + upgrade_data + @db = DBTree.new @bot, "plugin_registry" + end + + # delegation hack + def method_missing(method, *args, &block) + @db.send(method, *args, &block) + end + + # check for older versions of rbot with data formats that require updating + # NB this function is called _early_ in init(), pretty much all you have to + # work with is @bot.botclass. + def upgrade_data + if File.exist?("#{@bot.botclass}/registry.db") + puts "upgrading old-style (rbot 0.9.5 or earlier) plugin registry to new format" + old = BDB::Hash.open "#{@bot.botclass}/registry.db", nil, + "r+", 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + new = BDB::CIBtree.open "#{@bot.botclass}/plugin_registry.db", nil, + BDB::CREATE | BDB::EXCL | BDB::TRUNCATE, + 0600, "set_pagesize" => 1024, + "set_cachesize" => [0, 32 * 1024, 0] + old.each {|k,v| + new[k] = v + } + old.close + new.close + File.delete("#{@bot.botclass}/registry.db") + end + end + end + + # This class provides persistent storage for plugins via a hash interface. + # The default mode is an object store, so you can store ruby objects and + # reference them with hash keys. This is because the default store/restore + # methods of the plugins' RegistryAccessor are calls to Marshal.dump and + # Marshal.restore, + # for example: + # blah = Hash.new + # blah[:foo] = "fum" + # @registry[:blah] = blah + # then, even after the bot is shut down and disconnected, on the next run you + # can access the blah object as it was, with: + # blah = @registry[:blah] + # The registry can of course be used to store simple strings, fixnums, etc as + # well, and should be useful to store or cache plugin data or dynamic plugin + # configuration. + # + # WARNING: + # in object store mode, don't make the mistake of treating it like a live + # object, e.g. (using the example above) + # @registry[:blah][:foo] = "flump" + # will NOT modify the object in the registry - remember that BotRegistry#[] + # returns a Marshal.restore'd object, the object you just modified in place + # will disappear. You would need to: + # blah = @registry[:blah] + # blah[:foo] = "flump" + # @registry[:blah] = blah + + # If you don't need to store objects, and strictly want a persistant hash of + # strings, you can override the store/restore methods to suit your needs, for + # example (in your plugin): + # def initialize + # class << @registry + # def store(val) + # val + # end + # def restore(val) + # val + # end + # end + # end + # Your plugins section of the registry is private, it has its own namespace + # (derived from the plugin's class name, so change it and lose your data). + # Calls to registry.each etc, will only iterate over your namespace. + class BotRegistryAccessor + # plugins don't call this - a BotRegistryAccessor is created for them and + # is accessible via @registry. + def initialize(bot, prefix) + @bot = bot + @registry = @bot.registry + @orig_prefix = prefix + @prefix = prefix + "/" + @default = nil + # debug "initializing registry accessor with prefix #{@prefix}" + end + + # use this to chop up your namespace into bits, so you can keep and + # reference separate object stores under the same registry + def sub_registry(prefix) + return BotRegistryAccessor.new(@bot, @orig_prefix + "+" + prefix) + end + + # convert value to string form for storing in the registry + # defaults to Marshal.dump(val) but you can override this in your module's + # registry object to use any method you like. + # For example, if you always just handle strings use: + # def store(val) + # val + # end + def store(val) + Marshal.dump(val) + end + + # restores object from string form, restore(store(val)) must return val. + # If you override store, you should override restore to reverse the + # action. + # For example, if you always just handle strings use: + # def restore(val) + # val + # end + def restore(val) + Marshal.restore(val) + end + + # lookup a key in the registry + def [](key) + if @registry.has_key?(@prefix + key) + return restore(@registry[@prefix + key]) + elsif @default != nil + return restore(@default) + else + return nil + end + end + + # set a key in the registry + def []=(key,value) + @registry[@prefix + key] = store(value) + end + + # set the default value for registry lookups, if the key sought is not + # found, the default will be returned. The default default (har) is nil. + def set_default (default) + @default = store(default) + end + + # just like Hash#each + def each(&block) + @registry.each {|key,value| + if key.gsub!(/^#{Regexp.escape(@prefix)}/, "") + block.call(key, restore(value)) + end + } + end + + # just like Hash#each_key + def each_key(&block) + @registry.each {|key, value| + if key.gsub!(/^#{Regexp.escape(@prefix)}/, "") + block.call(key) + end + } + end + + # just like Hash#each_value + def each_value(&block) + @registry.each {|key, value| + if key =~ /^#{Regexp.escape(@prefix)}/ + block.call(restore(value)) + end + } + end + + # just like Hash#has_key? + def has_key?(key) + return @registry.has_key?(@prefix + key) + end + alias include? has_key? + alias member? has_key? + + # just like Hash#has_both? + def has_both?(key, value) + return @registry.has_both?(@prefix + key, store(value)) + end + + # just like Hash#has_value? + def has_value?(value) + return @registry.has_value?(store(value)) + end + + # just like Hash#index? + def index(value) + ind = @registry.index(store(value)) + if ind && ind.gsub!(/^#{Regexp.escape(@prefix)}/, "") + return ind + else + return nil + end + end + + # delete a key from the registry + def delete(key) + return @registry.delete(@prefix + key) + end + + # returns a list of your keys + def keys + return @registry.keys.collect {|key| + if key.gsub!(/^#{Regexp.escape(@prefix)}/, "") + key + else + nil + end + }.compact + end + + # Return an array of all associations [key, value] in your namespace + def to_a + ret = Array.new + @registry.each {|key, value| + if key.gsub!(/^#{Regexp.escape(@prefix)}/, "") + ret << [key, restore(value)] + end + } + return ret + end + + # Return an hash of all associations {key => value} in your namespace + def to_hash + ret = Hash.new + @registry.each {|key, value| + if key.gsub!(/^#{Regexp.escape(@prefix)}/, "") + ret[key] = restore(value) + end + } + return ret + end + + # empties the registry (restricted to your namespace) + def clear + @registry.each_key {|key| + if key =~ /^#{Regexp.escape(@prefix)}/ + @registry.delete(key) + end + } + end + alias truncate clear + + # returns an array of the values in your namespace of the registry + def values + ret = Array.new + self.each {|k,v| + ret << restore(v) + } + return ret + end + + # returns the number of keys in your registry namespace + def length + self.keys.length + end + alias size length + + def flush + @registry.flush + end + + end + +end diff --git a/lib/rbot/rfc2812.rb b/lib/rbot/rfc2812.rb new file mode 100644 index 00000000..6f459c80 --- /dev/null +++ b/lib/rbot/rfc2812.rb @@ -0,0 +1,1027 @@ +module Irc + # RFC 2812 Internet Relay Chat: Client Protocol + # + RPL_WELCOME=001 + # "Welcome to the Internet Relay Network + # <nick>!<user>@<host>" + RPL_YOURHOST=002 + # "Your host is <servername>, running version <ver>" + RPL_CREATED=003 + # "This server was created <date>" + RPL_MYINFO=004 + # "<servername> <version> <available user modes> + # <available channel modes>" + # + # - The server sends Replies 001 to 004 to a user upon + # successful registration. + # + RPL_BOUNCE=005 + # "Try server <server name>, port <port number>" + # + # - Sent by the server to a user to suggest an alternative + # server. This is often used when the connection is + # refused because the server is already full. + # + RPL_USERHOST=302 + # ":*1<reply> *( " " <reply> )" + # + # - Reply format used by USERHOST to list replies to + # the query list. The reply string is composed as + # follows: + # + # reply = nickname [ "*" ] "=" ( "+" / "-" ) hostname + # + # The '*' indicates whether the client has registered + # as an Operator. The '-' or '+' characters represent + # whether the client has set an AWAY message or not + # respectively. + # + RPL_ISON=303 + # ":*1<nick> *( " " <nick> )" + # + # - Reply format used by ISON to list replies to the + # query list. + # + RPL_AWAY=301 + # "<nick> :<away message>" + RPL_UNAWAY=305 + # ":You are no longer marked as being away" + RPL_NOWAWAY=306 + # ":You have been marked as being away" + # + # - These replies are used with the AWAY command (if + # allowed). RPL_AWAY is sent to any client sending a + # PRIVMSG to a client which is away. RPL_AWAY is only + # sent by the server to which the client is connected. + # Replies RPL_UNAWAY and RPL_NOWAWAY are sent when the + # client removes and sets an AWAY message. + # + RPL_WHOISUSER=311 + # "<nick> <user> <host> * :<real name>" + RPL_WHOISSERVER=312 + # "<nick> <server> :<server info>" + RPL_WHOISOPERATOR=313 + # "<nick> :is an IRC operator" + RPL_WHOISIDLE=317 + # "<nick> <integer> :seconds idle" + RPL_ENDOFWHOIS=318 + # "<nick> :End of WHOIS list" + RPL_WHOISCHANNELS=319 + # "<nick> :*( ( "@" / "+" ) <channel> " " )" + # + # - Replies 311 - 313, 317 - 319 are all replies + # generated in response to a WHOIS message. Given that + # there are enough parameters present, the answering + # server MUST either formulate a reply out of the above + # numerics (if the query nick is found) or return an + # error reply. The '*' in RPL_WHOISUSER is there as + # the literal character and not as a wild card. For + # each reply set, only RPL_WHOISCHANNELS may appear + # more than once (for long lists of channel names). + # The '@' and '+' characters next to the channel name + # indicate whether a client is a channel operator or + # has been granted permission to speak on a moderated + # channel. The RPL_ENDOFWHOIS reply is used to mark + # the end of processing a WHOIS message. + # + RPL_WHOWASUSER=314 + # "<nick> <user> <host> * :<real name>" + RPL_ENDOFWHOWAS=369 + # "<nick> :End of WHOWAS" + # + # - When replying to a WHOWAS message, a server MUST use + # the replies RPL_WHOWASUSER, RPL_WHOISSERVER or + # ERR_WASNOSUCHNICK for each nickname in the presented + # list. At the end of all reply batches, there MUST + # be RPL_ENDOFWHOWAS (even if there was only one reply + # and it was an error). + # + RPL_LISTSTART=321 + # Obsolete. Not used. + # + RPL_LIST=322 + # "<channel> <# visible> :<topic>" + RPL_LISTEND=323 + # ":End of LIST" + # + # - Replies RPL_LIST, RPL_LISTEND mark the actual replies + # with data and end of the server's response to a LIST + # command. If there are no channels available to return, + # only the end reply MUST be sent. + # + RPL_UNIQOPIS=325 + # "<channel> <nickname>" + # + RPL_CHANNELMODEIS=324 + # "<channel> <mode> <mode params>" + # + RPL_NOTOPIC=331 + # "<channel> :No topic is set" + RPL_TOPIC=332 + # "<channel> :<topic>" + # + # - When sending a TOPIC message to determine the + # channel topic, one of two replies is sent. If + # the topic is set, RPL_TOPIC is sent back else + # RPL_NOTOPIC. + # + RPL_TOPIC_INFO=333 + # <channel> <set by> <unixtime> + RPL_INVITING=341 + # "<channel> <nick>" + # + # - Returned by the server to indicate that the + # attempted INVITE message was successful and is + # being passed onto the end client. + # + RPL_SUMMONING=342 + # "<user> :Summoning user to IRC" + # + # - Returned by a server answering a SUMMON message to + # indicate that it is summoning that user. + # + RPL_INVITELIST=346 + # "<channel> <invitemask>" + RPL_ENDOFINVITELIST=347 + # "<channel> :End of channel invite list" + # + # - When listing the 'invitations masks' for a given channel, + # a server is required to send the list back using the + # RPL_INVITELIST and RPL_ENDOFINVITELIST messages. A + # separate RPL_INVITELIST is sent for each active mask. + # After the masks have been listed (or if none present) a + # RPL_ENDOFINVITELIST MUST be sent. + # + RPL_EXCEPTLIST=348 + # "<channel> <exceptionmask>" + RPL_ENDOFEXCEPTLIST=349 + # "<channel> :End of channel exception list" + # + # - When listing the 'exception masks' for a given channel, + # a server is required to send the list back using the + # RPL_EXCEPTLIST and RPL_ENDOFEXCEPTLIST messages. A + # separate RPL_EXCEPTLIST is sent for each active mask. + # After the masks have been listed (or if none present) + # a RPL_ENDOFEXCEPTLIST MUST be sent. + # + RPL_VERSION=351 + # "<version>.<debuglevel> <server> :<comments>" + # + # - Reply by the server showing its version details. + # The <version> is the version of the software being + # used (including any patchlevel revisions) and the + # <debuglevel> is used to indicate if the server is + # running in "debug mode". + # + # The "comments" field may contain any comments about + # the version or further version details. + # + RPL_WHOREPLY=352 + # "<channel> <user> <host> <server> <nick> + # ( "H" / "G" > ["*"] [ ( "@" / "+" ) ] + # :<hopcount> <real name>" + # + RPL_ENDOFWHO=315 + # "<name> :End of WHO list" + # + # - The RPL_WHOREPLY and RPL_ENDOFWHO pair are used + # to answer a WHO message. The RPL_WHOREPLY is only + # sent if there is an appropriate match to the WHO + # query. If there is a list of parameters supplied + # with a WHO message, a RPL_ENDOFWHO MUST be sent + # after processing each list item with <name> being + # the item. + # + RPL_NAMREPLY=353 + # "( "=" / "*" / "@" ) <channel> + # :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> ) + # - "@" is used for secret channels, "*" for private + # channels, and "=" for others (public channels). + # + RPL_ENDOFNAMES=366 + # "<channel> :End of NAMES list" + # + # - To reply to a NAMES message, a reply pair consisting + # of RPL_NAMREPLY and RPL_ENDOFNAMES is sent by the + # server back to the client. If there is no channel + # found as in the query, then only RPL_ENDOFNAMES is + # returned. The exception to this is when a NAMES + # message is sent with no parameters and all visible + # channels and contents are sent back in a series of + # RPL_NAMEREPLY messages with a RPL_ENDOFNAMES to mark + # the end. + # + RPL_LINKS=364 + # "<mask> <server> :<hopcount> <server info>" + RPL_ENDOFLINKS=365 + # "<mask> :End of LINKS list" + # + # - In replying to the LINKS message, a server MUST send + # replies back using the RPL_LINKS numeric and mark the + # end of the list using an RPL_ENDOFLINKS reply. + # + RPL_BANLIST=367 + # "<channel> <banmask>" + RPL_ENDOFBANLIST=368 + # "<channel> :End of channel ban list" + # + # - When listing the active 'bans' for a given channel, + # a server is required to send the list back using the + # RPL_BANLIST and RPL_ENDOFBANLIST messages. A separate + # RPL_BANLIST is sent for each active banmask. After the + # banmasks have been listed (or if none present) a + # RPL_ENDOFBANLIST MUST be sent. + # + RPL_INFO=371 + # ":<string>" + RPL_ENDOFINFO=374 + # ":End of INFO list" + # + # - A server responding to an INFO message is required to + # send all its 'info' in a series of RPL_INFO messages + # with a RPL_ENDOFINFO reply to indicate the end of the + # replies. + # + RPL_MOTDSTART=375 + # ":- <server> Message of the day - " + RPL_MOTD=372 + # ":- <text>" + RPL_ENDOFMOTD=376 + # ":End of MOTD command" + # + # - When responding to the MOTD message and the MOTD file + # is found, the file is displayed line by line, with + # each line no longer than 80 characters, using + # RPL_MOTD format replies. These MUST be surrounded + # by a RPL_MOTDSTART (before the RPL_MOTDs) and an + # RPL_ENDOFMOTD (after). + # + RPL_YOUREOPER=381 + # ":You are now an IRC operator" + # + # - RPL_YOUREOPER is sent back to a client which has + # just successfully issued an OPER message and gained + # operator status. + # + RPL_REHASHING=382 + # "<config file> :Rehashing" + # + # - If the REHASH option is used and an operator sends + # a REHASH message, an RPL_REHASHING is sent back to + # the operator. + # + RPL_YOURESERVICE=383 + # "You are service <servicename>" + # + # - Sent by the server to a service upon successful + # registration. + # + RPL_TIME=391 + # "<server> :<string showing server's local time>" + # + # - When replying to the TIME message, a server MUST send + # the reply using the RPL_TIME format above. The string + # showing the time need only contain the correct day and + # time there. There is no further requirement for the + # time string. + # + RPL_USERSSTART=392 + # ":UserID Terminal Host" + RPL_USERS=393 + # ":<username> <ttyline> <hostname>" + RPL_ENDOFUSERS=394 + # ":End of users" + RPL_NOUSERS=395 + # ":Nobody logged in" + # + # - If the USERS message is handled by a server, the + # replies RPL_USERSTART, RPL_USERS, RPL_ENDOFUSERS and + # RPL_NOUSERS are used. RPL_USERSSTART MUST be sent + # first, following by either a sequence of RPL_USERS + # or a single RPL_NOUSER. Following this is + # RPL_ENDOFUSERS. + # + RPL_TRACELINK=200 + # "Link <version & debug level> <destination> + # <next server> V<protocol version> + # <link uptime in seconds> <backstream sendq> + # <upstream sendq>" + RPL_TRACECONNECTING=201 + # "Try. <class> <server>" + RPL_TRACEHANDSHAKE=202 + # "H.S. <class> <server>" + RPL_TRACEUNKNOWN=203 + # "???? <class> [<client IP address in dot form>]" + RPL_TRACEOPERATOR=204 + # "Oper <class> <nick>" + RPL_TRACEUSER=205 + # "User <class> <nick>" + RPL_TRACESERVER=206 + # "Serv <class> <int>S <int>C <server> + # <nick!user|*!*>@<host|server> V<protocol version>" + RPL_TRACESERVICE=207 + # "Service <class> <name> <type> <active type>" + RPL_TRACENEWTYPE=208 + # "<newtype> 0 <client name>" + RPL_TRACECLASS=209 + # "Class <class> <count>" + RPL_TRACERECONNECT=210 + # Unused. + RPL_TRACELOG=261 + # "File <logfile> <debug level>" + RPL_TRACEEND=262 + # "<server name> <version & debug level> :End of TRACE" + # + # - The RPL_TRACE* are all returned by the server in + # response to the TRACE message. How many are + # returned is dependent on the TRACE message and + # whether it was sent by an operator or not. There + # is no predefined order for which occurs first. + # Replies RPL_TRACEUNKNOWN, RPL_TRACECONNECTING and + # RPL_TRACEHANDSHAKE are all used for connections + # which have not been fully established and are either + # unknown, still attempting to connect or in the + # process of completing the 'server handshake'. + # RPL_TRACELINK is sent by any server which handles + # a TRACE message and has to pass it on to another + # server. The list of RPL_TRACELINKs sent in + # response to a TRACE command traversing the IRC + # network should reflect the actual connectivity of + # the servers themselves along that path. + # + # RPL_TRACENEWTYPE is to be used for any connection + # which does not fit in the other categories but is + # being displayed anyway. + # RPL_TRACEEND is sent to indicate the end of the list. + # + RPL_STATSLINKINFO=211 + # "<linkname> <sendq> <sent messages> + # <sent Kbytes> <received messages> + # <received Kbytes> <time open>" + # + # - reports statistics on a connection. <linkname> + # identifies the particular connection, <sendq> is + # the amount of data that is queued and waiting to be + # sent <sent messages> the number of messages sent, + # and <sent Kbytes> the amount of data sent, in + # Kbytes. <received messages> and <received Kbytes> + # are the equivalent of <sent messages> and <sent + # Kbytes> for received data, respectively. <time + # open> indicates how long ago the connection was + # opened, in seconds. + # + RPL_STATSCOMMANDS=212 + # "<command> <count> <byte count> <remote count>" + # + # - reports statistics on commands usage. + # + RPL_ENDOFSTATS=219 + # "<stats letter> :End of STATS report" + # + RPL_STATSUPTIME=242 + # ":Server Up %d days %d:%02d:%02d" + # + # - reports the server uptime. + # + RPL_STATSOLINE=243 + # "O <hostmask> * <name>" + # + # - reports the allowed hosts from where user may become IRC + # operators. + # + RPL_UMODEIS=221 + # "<user mode string>" + # + # - To answer a query about a client's own mode, + # RPL_UMODEIS is sent back. + # + RPL_SERVLIST=234 + # "<name> <server> <mask> <type> <hopcount> <info>" + # + RPL_SERVLISTEND=235 + # "<mask> <type> :End of service listing" + # + # - When listing services in reply to a SERVLIST message, + # a server is required to send the list back using the + # RPL_SERVLIST and RPL_SERVLISTEND messages. A separate + # RPL_SERVLIST is sent for each service. After the + # services have been listed (or if none present) a + # RPL_SERVLISTEND MUST be sent. + # + RPL_LUSERCLIENT=251 + # ":There are <integer> users and <integer> + # services on <integer> servers" + RPL_LUSEROP=252 + # "<integer> :operator(s) online" + RPL_LUSERUNKNOWN=253 + # "<integer> :unknown connection(s)" + RPL_LUSERCHANNELS=254 + # "<integer> :channels formed" + RPL_LUSERME=255 + # ":I have <integer> clients and <integer> + # servers" + # + # - In processing an LUSERS message, the server + # sends a set of replies from RPL_LUSERCLIENT, + # RPL_LUSEROP, RPL_USERUNKNOWN, + # RPL_LUSERCHANNELS and RPL_LUSERME. When + # replying, a server MUST send back + # RPL_LUSERCLIENT and RPL_LUSERME. The other + # replies are only sent back if a non-zero count + # is found for them. + # + RPL_ADMINME=256 + # "<server> :Administrative info" + RPL_ADMINLOC1=257 + # ":<admin info>" + RPL_ADMINLOC2=258 + # ":<admin info>" + RPL_ADMINEMAIL=259 + # ":<admin info>" + # + # - When replying to an ADMIN message, a server + # is expected to use replies RPL_ADMINME + # through to RPL_ADMINEMAIL and provide a text + # message with each. For RPL_ADMINLOC1 a + # description of what city, state and country + # the server is in is expected, followed by + # details of the institution (RPL_ADMINLOC2) + # and finally the administrative contact for the + # server (an email address here is REQUIRED) + # in RPL_ADMINEMAIL. + # + RPL_TRYAGAIN=263 + # "<command> :Please wait a while and try again." + # + # - When a server drops a command without processing it, + # it MUST use the reply RPL_TRYAGAIN to inform the + # originating client. + # + # 5.2 Error Replies + # + # Error replies are found in the range from 400 to 599. + # + ERR_NOSUCHNICK=401 + # "<nickname> :No such nick/channel" + # + # - Used to indicate the nickname parameter supplied to a + # command is currently unused. + # + ERR_NOSUCHSERVER=402 + # "<server name> :No such server" + # + # - Used to indicate the server name given currently + # does not exist. + # + ERR_NOSUCHCHANNEL=403 + # "<channel name> :No such channel" + # + # - Used to indicate the given channel name is invalid. + # + ERR_CANNOTSENDTOCHAN=404 + # "<channel name> :Cannot send to channel" + # + # - Sent to a user who is either (a) not on a channel + # which is mode +n or (b) not a chanop (or mode +v) on + # a channel which has mode +m set or where the user is + # banned and is trying to send a PRIVMSG message to + # that channel. + # + ERR_TOOMANYCHANNELS=405 + # "<channel name> :You have joined too many channels" + # + # - Sent to a user when they have joined the maximum + # number of allowed channels and they try to join + # another channel. + # + ERR_WASNOSUCHNICK=406 + # "<nickname> :There was no such nickname" + # + # - Returned by WHOWAS to indicate there is no history + # information for that nickname. + # + ERR_TOOMANYTARGETS=407 + # "<target> :<error code> recipients. <abort message>" + # + # - Returned to a client which is attempting to send a + # PRIVMSG/NOTICE using the user@host destination format + # and for a user@host which has several occurrences. + # + # - Returned to a client which trying to send a + # PRIVMSG/NOTICE to too many recipients. + # + # - Returned to a client which is attempting to JOIN a safe + # channel using the shortname when there are more than one + # such channel. + # + ERR_NOSUCHSERVICE=408 + # "<service name> :No such service" + # + # - Returned to a client which is attempting to send a SQUERY + # to a service which does not exist. + # + ERR_NOORIGIN=409 + # ":No origin specified" + # + # - PING or PONG message missing the originator parameter. + # + ERR_NORECIPIENT=411 + # ":No recipient given (<command>)" + ERR_NOTEXTTOSEND=412 + # ":No text to send" + ERR_NOTOPLEVEL=413 + # "<mask> :No toplevel domain specified" + ERR_WILDTOPLEVEL=414 + # "<mask> :Wildcard in toplevel domain" + ERR_BADMASK=415 + # "<mask> :Bad Server/host mask" + # + # - 412 - 415 are returned by PRIVMSG to indicate that + # the message wasn't delivered for some reason. + # ERR_NOTOPLEVEL and ERR_WILDTOPLEVEL are errors that + # are returned when an invalid use of + # "PRIVMSG $<server>" or "PRIVMSG #<host>" is attempted. + # + ERR_UNKNOWNCOMMAND=421 + # "<command> :Unknown command" + # + # - Returned to a registered client to indicate that the + # command sent is unknown by the server. + # + ERR_NOMOTD=422 + # ":MOTD File is missing" + # + # - Server's MOTD file could not be opened by the server. + # + ERR_NOADMININFO=423 + # "<server> :No administrative info available" + # + # - Returned by a server in response to an ADMIN message + # when there is an error in finding the appropriate + # information. + # + ERR_FILEERROR=424 + # ":File error doing <file op> on <file>" + # + # - Generic error message used to report a failed file + # operation during the processing of a message. + # + ERR_NONICKNAMEGIVEN=431 + # ":No nickname given" + # + # - Returned when a nickname parameter expected for a + # command and isn't found. + # + ERR_ERRONEUSNICKNAME=432 + # "<nick> :Erroneous nickname" + # + # - Returned after receiving a NICK message which contains + # characters which do not fall in the defined set. See + # section 2.3.1 for details on valid nicknames. + # + ERR_NICKNAMEINUSE=433 + # "<nick> :Nickname is already in use" + # + # - Returned when a NICK message is processed that results + # in an attempt to change to a currently existing + # nickname. + # + ERR_NICKCOLLISION=436 + # "<nick> :Nickname collision KILL from <user>@<host>" + # + # - Returned by a server to a client when it detects a + # nickname collision (registered of a NICK that + # already exists by another server). + # + ERR_UNAVAILRESOURCE=437 + # "<nick/channel> :Nick/channel is temporarily unavailable" + # + # - Returned by a server to a user trying to join a channel + # currently blocked by the channel delay mechanism. + # + # - Returned by a server to a user trying to change nickname + # when the desired nickname is blocked by the nick delay + # mechanism. + # + ERR_USERNOTINCHANNEL=441 + # "<nick> <channel> :They aren't on that channel" + # + # - Returned by the server to indicate that the target + # user of the command is not on the given channel. + # + ERR_NOTONCHANNEL=442 + # "<channel> :You're not on that channel" + # + # - Returned by the server whenever a client tries to + # perform a channel affecting command for which the + # client isn't a member. + # + ERR_USERONCHANNEL=443 + # "<user> <channel> :is already on channel" + # + # - Returned when a client tries to invite a user to a + # channel they are already on. + # + ERR_NOLOGIN=444 + # "<user> :User not logged in" + # + # - Returned by the summon after a SUMMON command for a + # user was unable to be performed since they were not + # logged in. + # + # + ERR_SUMMONDISABLED=445 + # ":SUMMON has been disabled" + # + # - Returned as a response to the SUMMON command. MUST be + # returned by any server which doesn't implement it. + # + ERR_USERSDISABLED=446 + # ":USERS has been disabled" + # + # - Returned as a response to the USERS command. MUST be + # returned by any server which does not implement it. + # + ERR_NOTREGISTERED=451 + # ":You have not registered" + # + # - Returned by the server to indicate that the client + # MUST be registered before the server will allow it + # to be parsed in detail. + # + ERR_NEEDMOREPARAMS=461 + # "<command> :Not enough parameters" + # + # - Returned by the server by numerous commands to + # indicate to the client that it didn't supply enough + # parameters. + # + ERR_ALREADYREGISTRED=462 + # ":Unauthorized command (already registered)" + # + # - Returned by the server to any link which tries to + # change part of the registered details (such as + # password or user details from second USER message). + # + ERR_NOPERMFORHOST=463 + # ":Your host isn't among the privileged" + # + # - Returned to a client which attempts to register with + # a server which does not been setup to allow + # connections from the host the attempted connection + # is tried. + # + ERR_PASSWDMISMATCH=464 + # ":Password incorrect" + # + # - Returned to indicate a failed attempt at registering + # a connection for which a password was required and + # was either not given or incorrect. + # + ERR_YOUREBANNEDCREEP=465 + # ":You are banned from this server" + # + # - Returned after an attempt to connect and register + # yourself with a server which has been setup to + # explicitly deny connections to you. + # + ERR_YOUWILLBEBANNED=466 + # + # - Sent by a server to a user to inform that access to the + # server will soon be denied. + # + ERR_KEYSET=467 + # "<channel> :Channel key already set" + ERR_CHANNELISFULL=471 + # "<channel> :Cannot join channel (+l)" + ERR_UNKNOWNMODE=472 + # "<char> :is unknown mode char to me for <channel>" + ERR_INVITEONLYCHAN=473 + # "<channel> :Cannot join channel (+i)" + ERR_BANNEDFROMCHAN=474 + # "<channel> :Cannot join channel (+b)" + ERR_BADCHANNELKEY=475 + # "<channel> :Cannot join channel (+k)" + ERR_BADCHANMASK=476 + # "<channel> :Bad Channel Mask" + ERR_NOCHANMODES=477 + # "<channel> :Channel doesn't support modes" + ERR_BANLISTFULL=478 + # "<channel> <char> :Channel list is full" + # + ERR_NOPRIVILEGES=481 + # ":Permission Denied- You're not an IRC operator" + # + # - Any command requiring operator privileges to operate + # MUST return this error to indicate the attempt was + # unsuccessful. + # + ERR_CHANOPRIVSNEEDED=482 + # "<channel> :You're not channel operator" + # + # - Any command requiring 'chanop' privileges (such as + # MODE messages) MUST return this error if the client + # making the attempt is not a chanop on the specified + # channel. + # + # + ERR_CANTKILLSERVER=483 + # ":You can't kill a server!" + # + # - Any attempts to use the KILL command on a server + # are to be refused and this error returned directly + # to the client. + # + ERR_RESTRICTED=484 + # ":Your connection is restricted!" + # + # - Sent by the server to a user upon connection to indicate + # the restricted nature of the connection (user mode "+r"). + # + ERR_UNIQOPPRIVSNEEDED=485 + # ":You're not the original channel operator" + # + # - Any MODE requiring "channel creator" privileges MUST + # return this error if the client making the attempt is not + # a chanop on the specified channel. + # + ERR_NOOPERHOST=491 + # ":No O-lines for your host" + # + # - If a client sends an OPER message and the server has + # not been configured to allow connections from the + # client's host as an operator, this error MUST be + # returned. + # + ERR_UMODEUNKNOWNFLAG=501 + # ":Unknown MODE flag" + # + # - Returned by the server to indicate that a MODE + # message was sent with a nickname parameter and that + # the a mode flag sent was not recognized. + # + ERR_USERSDONTMATCH=502 + # ":Cannot change mode for other users" + # + # - Error sent to any user trying to view or change the + # user mode for a user other than themselves. + # + # 5.3 Reserved numerics + # + # These numerics are not described above since they fall into one of + # the following categories: + # + # 1. no longer in use; + # + # 2. reserved for future planned use; + # + # 3. in current use but are part of a non-generic 'feature' of + # the current IRC server. + RPL_SERVICEINFO=231 + RPL_ENDOFSERVICES=232 + RPL_SERVICE=233 + RPL_NONE=300 + RPL_WHOISCHANOP=316 + RPL_KILLDONE=361 + RPL_CLOSING=362 + RPL_CLOSEEND=363 + RPL_INFOSTART=373 + RPL_MYPORTIS=384 + RPL_STATSCLINE=213 + RPL_STATSNLINE=214 + RPL_STATSILINE=215 + RPL_STATSKLINE=216 + RPL_STATSQLINE=217 + RPL_STATSYLINE=218 + RPL_STATSVLINE=240 + RPL_STATSLLINE=241 + RPL_STATSHLINE=244 + RPL_STATSSLINE=244 + RPL_STATSPING=246 + RPL_STATSBLINE=247 + RPL_STATSDLINE=250 + ERR_NOSERVICEHOST=492 + + # implements RFC 2812 and prior IRC RFCs. + # clients register handler proc{}s for different server events and IrcClient + # handles dispatch + class IrcClient + # create a new IrcClient instance + def initialize + @handlers = Hash.new + @users = Array.new + end + + # key:: server event to handle + # value:: proc object called when event occurs + # set a handler for a server event + # + # ==server events currently supported: + # + # PING:: server pings you (default handler returns a pong) + # NICKTAKEN:: you tried to change nick to one that's in use + # BADNICK:: you tried to change nick to one that's invalid + # TOPIC:: someone changed the topic of a channel + # TOPICINFO:: on joining a channel or asking for the topic, tells you + # who set it and when + # NAMES:: server sends list of channel members when you join + # WELCOME:: server welcome message on connect + # MOTD:: server message of the day + # PRIVMSG:: privmsg, the core of IRC, a message to you from someone + # PUBLIC:: optionally instead of getting privmsg you can hook to only + # the public ones... + # MSG:: or only the private ones, or both + # KICK:: someone got kicked from a channel + # PART:: someone left a channel + # QUIT:: someone quit IRC + # JOIN:: someone joined a channel + # CHANGETOPIC:: the topic of a channel changed + # INVITE:: you are invited to a channel + # NICK:: someone changed their nick + # MODE:: a mode change + # NOTICE:: someone sends you a notice + # UNKNOWN:: any other message not handled by the above + def []=(key, value) + @handlers[key] = value + end + + # key:: event name + # remove a handler for a server event + def deletehandler(key) + @handlers.delete(key) + end + + # takes a server string, checks for PING, PRIVMSG, NOTIFY, etc, and parses + # numeric server replies, calling the appropriate handler for each, and + # sending it a hash containing the data from the server + def process(serverstring) + data = Hash.new + data["SERVERSTRING"] = serverstring + + unless serverstring =~ /^(:(\S+)\s)?(\S+)(\s(.*))?/ + raise "Unparseable Server Message!!!: #{serverstring}" + end + + prefix, command, params = $2, $3, $5 + + if prefix != nil + data['SOURCE'] = prefix + if prefix =~ /^(\S+)!(\S+)$/ + data['SOURCENICK'] = $1 + data['SOURCEADDRESS'] = $2 + end + end + + # split parameters in an array + argv = [] + params.scan(/(?!:)(\S+)|:(.*)/) { argv << ($1 || $2) } if params + + case command + when 'PING' + data['PINGID'] = argv[0] + handle('PING', data) + when /^(\d+)$/ # numeric server message + num=command.to_i + case num + when ERR_NICKNAMEINUSE + # "* <nick> :Nickname is already in use" + data['NICK'] = argv[1] + data['MESSAGE'] = argv[2] + handle('NICKTAKEN', data) + when ERR_ERRONEUSNICKNAME + # "* <nick> :Erroneous nickname" + data['NICK'] = argv[1] + data['MESSAGE'] = argv[2] + handle('BADNICK', data) + when RPL_TOPIC + data['CHANNEL'] = argv[1] + data['TOPIC'] = argv[2] + handle('TOPIC', data) + when RPL_TOPIC_INFO + data['NICK'] = argv[0] + data['CHANNEL'] = argv[1] + data['SOURCE'] = argv[2] + data['UNIXTIME'] = argv[3] + handle('TOPICINFO', data) + when RPL_NAMREPLY + # "( "=" / "*" / "@" ) <channel> + # :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> ) + # - "@" is used for secret channels, "*" for private + # channels, and "=" for others (public channels). + argv[3].scan(/\S+/).each { |u| + if(u =~ /^([@+])?(.*)$/) + umode = $1 || "" + user = $2 + @users << [user, umode] + end + } + when RPL_ENDOFNAMES + data['CHANNEL'] = argv[1] + data['USERS'] = @users + handle('NAMES', data) + @users = Array.new + when RPL_WELCOME + # "Welcome to the Internet Relay Network + # <nick>!<user>@<host>" + case argv[1] + when /((\S+)!(\S+))/ + data['NETMASK'] = $1 + data['NICK'] = $2 + data['ADDRESS'] = $3 + when /Welcome to the Internet Relay Network\s(\S+)/ + data['NICK'] = $1 + when /Welcome.*\s+(\S+)$/ + data['NICK'] = $1 + when /^(\S+)$/ + data['NICK'] = $1 + end + handle('WELCOME', data) + when RPL_MOTDSTART + # "<nick> :- <server> Message of the Day -" + if argv[1] =~ /^-\s+(\S+)\s/ + server = $1 + @motd = "" + end + when RPL_MOTD + if(argv[1] =~ /^-\s+(.*)$/) + @motd << $1 + @motd << "\n" + end + when RPL_ENDOFMOTD + data['MOTD'] = @motd + handle('MOTD', data) + else + handle('UNKNOWN', data) + end + # end of numeric replies + when 'PRIVMSG' + # you can either bind to 'PRIVMSG', to get every one and + # parse it yourself, or you can bind to 'MSG', 'PUBLIC', + # etc and get it all nicely split up for you. + data['TARGET'] = argv[0] + data['MESSAGE'] = argv[1] + handle('PRIVMSG', data) + + # Now we split it + if(data['TARGET'] =~ /^(#|&).*/) + handle('PUBLIC', data) + else + handle('MSG', data) + end + when 'KICK' + data['CHANNEL'] = argv[0] + data['TARGET'] = argv[1] + data['MESSAGE'] = argv[2] + handle('KICK', data) + when 'PART' + data['CHANNEL'] = argv[0] + data['MESSAGE'] = argv[1] + handle('PART', data) + when 'QUIT' + data['MESSAGE'] = argv[0] + handle('QUIT', data) + when 'JOIN' + data['CHANNEL'] = argv[0] + handle('JOIN', data) + when 'TOPIC' + data['CHANNEL'] = argv[0] + data['TOPIC'] = argv[1] + handle('CHANGETOPIC', data) + when 'INVITE' + data['TARGET'] = argv[0] + data['CHANNEL'] = argv[1] + handle('INVITE', data) + when 'NICK' + data['NICK'] = argv[0] + handle('NICK', data) + when 'MODE' + data['CHANNEL'] = argv[0] + data['MODESTRING'] = argv[1] + data['TARGETS'] = argv[2] + handle('MODE', data) + when 'NOTICE' + data['TARGET'] = argv[0] + data['MESSAGE'] = argv[1] + if data['SOURCENICK'] + handle('NOTICE', data) + else + # "server notice" (not from user, noone to reply to + handle('SNOTICE', data) + end + else + handle('UNKNOWN', data) + end + end + + private + + # key:: server event name + # data:: hash containing data about the event, passed to the proc + # call client's proc for an event, if they set one as a handler + def handle(key, data) + if(@handlers.has_key?(key)) + @handlers[key].call(data) + end + end + end +end diff --git a/lib/rbot/timer.rb b/lib/rbot/timer.rb new file mode 100644 index 00000000..64b060ba --- /dev/null +++ b/lib/rbot/timer.rb @@ -0,0 +1,123 @@ +module Timer + + # timer event, something to do and when/how often to do it + class Action + + # when this action is due next (updated by tick()) + attr_accessor :in + + # is this action blocked? if so it won't be run + attr_accessor :blocked + + # period:: how often (seconds) to run the action + # data:: optional data to pass to the proc + # once:: optional, if true, this action will be run once then removed + # func:: associate a block to be called to perform the action + # + # create a new action + def initialize(period, data=nil, once=false, &func) + @blocked = false + @period = period + @in = period + @func = func + @data = data + @once = once + end + + # run the action by calling its proc + def run + @in += @period + if(@data) + @func.call(@data) + else + @func.call + end + return @once + end + end + + # timer handler, manage multiple Action objects, calling them when required. + # The timer must be ticked by whatever controls it, i.e. regular calls to + # tick() at whatever granularity suits your application's needs. + # Alternatively you can call run(), and the timer will tick itself, but this + # blocks so you gotta do it in a thread (remember ruby's threads block on + # syscalls so that can suck). + class Timer + def initialize + @timers = Array.new + @handle = 0 + @lasttime = 0 + end + + # period:: how often (seconds) to run the action + # data:: optional data to pass to the action's proc + # func:: associate a block with add() to perform the action + # + # add an action to the timer + def add(period, data=nil, &func) + @handle += 1 + @timers[@handle] = Action.new(period, data, &func) + return @handle + end + + # period:: how often (seconds) to run the action + # data:: optional data to pass to the action's proc + # func:: associate a block with add() to perform the action + # + # add an action to the timer which will be run just once, after +period+ + def add_once(period, data=nil, &func) + @handle += 1 + @timers[@handle] = Action.new(period, data, true, &func) + return @handle + end + + # remove action with handle +handle+ from the timer + def remove(handle) + @timers.delete_at(handle) + end + + # block action with handle +handle+ + def block(handle) + @timers[handle].blocked = true + end + + # unblock action with handle +handle+ + def unblock(handle) + @timers[handle].blocked = false + end + + # you can call this when you know you're idle, or you can split off a + # thread and call the run() method to do it for you. + def tick + if(@lasttime != 0) + diff = (Time.now - @lasttime).to_f + @lasttime = Time.now + @timers.compact.each { |timer| + timer.in = timer.in - diff + } + @timers.compact.each { |timer| + if (!timer.blocked) + if(timer.in <= 0) + if(timer.run) + # run once + @timers.delete(timer) + end + end + end + } + else + # don't do anything on the first tick + @lasttime = Time.now + end + end + + # the timer will tick() itself. this blocks, so run it in a thread, and + # watch out for blocking syscalls + def run(granularity=0.1) + while(true) + sleep(granularity) + tick + end + end + end +end diff --git a/lib/rbot/utils.rb b/lib/rbot/utils.rb new file mode 100644 index 00000000..b22a417d --- /dev/null +++ b/lib/rbot/utils.rb @@ -0,0 +1,778 @@ +require 'net/http' +require 'uri' + +module Irc + + # miscellaneous useful functions + module Utils + # read a time in string format, turn it into "seconds from now". + # example formats handled are "5 minutes", "2 days", "five hours", + # "11:30", "15:45:11", "one day", etc. + # + # Throws:: RunTimeError "invalid time string" on parse failure + def Utils.timestr_offset(timestr) + case timestr + when (/^(\S+)\s+(\S+)$/) + mult = $1 + unit = $2 + if(mult =~ /^([\d.]+)$/) + num = $1.to_f + raise "invalid time string" unless num + else + case mult + when(/^(one|an|a)$/) + num = 1 + when(/^two$/) + num = 2 + when(/^three$/) + num = 3 + when(/^four$/) + num = 4 + when(/^five$/) + num = 5 + when(/^six$/) + num = 6 + when(/^seven$/) + num = 7 + when(/^eight$/) + num = 8 + when(/^nine$/) + num = 9 + when(/^ten$/) + num = 10 + when(/^fifteen$/) + num = 15 + when(/^twenty$/) + num = 20 + when(/^thirty$/) + num = 30 + when(/^sixty$/) + num = 60 + else + raise "invalid time string" + end + end + case unit + when (/^(s|sec(ond)?s?)$/) + return num + when (/^(m|min(ute)?s?)$/) + return num * 60 + when (/^(h|h(ou)?rs?)$/) + return num * 60 * 60 + when (/^(d|days?)$/) + return num * 60 * 60 * 24 + else + raise "invalid time string" + end + when (/^(\d+):(\d+):(\d+)$/) + hour = $1.to_i + min = $2.to_i + sec = $3.to_i + now = Time.now + later = Time.mktime(now.year, now.month, now.day, hour, min, sec) + return later - now + when (/^(\d+):(\d+)$/) + hour = $1.to_i + min = $2.to_i + now = Time.now + later = Time.mktime(now.year, now.month, now.day, hour, min, now.sec) + return later - now + when (/^(\d+):(\d+)(am|pm)$/) + hour = $1.to_i + min = $2.to_i + ampm = $3 + if ampm == "pm" + hour += 12 + end + now = Time.now + later = Time.mktime(now.year, now.month, now.day, hour, min, now.sec) + return later - now + when (/^(\S+)$/) + num = 1 + unit = $1 + case unit + when (/^(s|sec(ond)?s?)$/) + return num + when (/^(m|min(ute)?s?)$/) + return num * 60 + when (/^(h|h(ou)?rs?)$/) + return num * 60 * 60 + when (/^(d|days?)$/) + return num * 60 * 60 * 24 + else + raise "invalid time string" + end + else + raise "invalid time string" + end + end + + # turn a number of seconds into a human readable string, e.g + # 2 days, 3 hours, 18 minutes, 10 seconds + def Utils.secs_to_string(secs) + ret = "" + days = (secs / (60 * 60 * 24)).to_i + secs = secs % (60 * 60 * 24) + hours = (secs / (60 * 60)).to_i + secs = (secs % (60 * 60)) + mins = (secs / 60).to_i + secs = (secs % 60).to_i + ret += "#{days} days, " if days > 0 + ret += "#{hours} hours, " if hours > 0 || days > 0 + ret += "#{mins} minutes and " if mins > 0 || hours > 0 || days > 0 + ret += "#{secs} seconds" + return ret + end + + def Utils.safe_exec(command, *args) + IO.popen("-") {|p| + if(p) + return p.readlines.join("\n") + else + begin + $stderr = $stdout + exec(command, *args) + rescue Exception => e + puts "exec of #{command} led to exception: #{e}" + Kernel::exit! 0 + end + puts "exec of #{command} failed" + Kernel::exit! 0 + end + } + end + + # returns a string containing the result of an HTTP GET on the uri + def Utils.http_get(uristr, readtimeout=8, opentimeout=4) + + # ruby 1.7 or better needed for this (or 1.6 and debian unstable) + Net::HTTP.version_1_2 + # (so we support the 1_1 api anyway, avoids problems) + + uri = URI.parse uristr + query = uri.path + if uri.query + query += "?#{uri.query}" + end + + proxy_host = nil + proxy_port = nil + if(ENV['http_proxy'] && proxy_uri = URI.parse(ENV['http_proxy'])) + proxy_host = proxy_uri.host + proxy_port = proxy_uri.port + end + + begin + http = Net::HTTP.new(uri.host, uri.port, proxy_host, proxy_port) + http.open_timeout = opentimeout + http.read_timeout = readtimeout + + http.start {|http| + resp = http.get(query) + if resp.code == "200" + return resp.body + end + } + rescue => e + # cheesy for now + $stderr.puts "Utils.http_get exception: #{e}, while trying to get #{uristr}" + return nil + end + end + + # This is nasty-ass. I hate writing parsers. + class Metar + attr_reader :decoded + attr_reader :input + attr_reader :date + attr_reader :nodata + def initialize(string) + str = nil + @nodata = false + string.each_line {|l| + if str == nil + # grab first line (date) + @date = l.chomp.strip + str = "" + else + if(str == "") + str = l.chomp.strip + else + str += " " + l.chomp.strip + end + end + } + if @date && @date =~ /^(\d+)\/(\d+)\/(\d+) (\d+):(\d+)$/ + # 2002/02/26 05:00 + @date = Time.gm($1, $2, $3, $4, $5, 0) + else + @date = Time.now + end + @input = str.chomp + @cloud_layers = 0 + @cloud_coverage = { + 'SKC' => '0', + 'CLR' => '0', + 'VV' => '8/8', + 'FEW' => '1/8 - 2/8', + 'SCT' => '3/8 - 4/8', + 'BKN' => '5/8 - 7/8', + 'OVC' => '8/8' + } + @wind_dir_texts = [ + 'North', + 'North/Northeast', + 'Northeast', + 'East/Northeast', + 'East', + 'East/Southeast', + 'Southeast', + 'South/Southeast', + 'South', + 'South/Southwest', + 'Southwest', + 'West/Southwest', + 'West', + 'West/Northwest', + 'Northwest', + 'North/Northwest', + 'North' + ] + @wind_dir_texts_short = [ + 'N', + 'N/NE', + 'NE', + 'E/NE', + 'E', + 'E/SE', + 'SE', + 'S/SE', + 'S', + 'S/SW', + 'SW', + 'W/SW', + 'W', + 'W/NW', + 'NW', + 'N/NW', + 'N' + ] + @weather_array = { + 'MI' => 'Mild ', + 'PR' => 'Partial ', + 'BC' => 'Patches ', + 'DR' => 'Low Drifting ', + 'BL' => 'Blowing ', + 'SH' => 'Shower(s) ', + 'TS' => 'Thunderstorm ', + 'FZ' => 'Freezing', + 'DZ' => 'Drizzle ', + 'RA' => 'Rain ', + 'SN' => 'Snow ', + 'SG' => 'Snow Grains ', + 'IC' => 'Ice Crystals ', + 'PE' => 'Ice Pellets ', + 'GR' => 'Hail ', + 'GS' => 'Small Hail and/or Snow Pellets ', + 'UP' => 'Unknown ', + 'BR' => 'Mist ', + 'FG' => 'Fog ', + 'FU' => 'Smoke ', + 'VA' => 'Volcanic Ash ', + 'DU' => 'Widespread Dust ', + 'SA' => 'Sand ', + 'HZ' => 'Haze ', + 'PY' => 'Spray', + 'PO' => 'Well-Developed Dust/Sand Whirls ', + 'SQ' => 'Squalls ', + 'FC' => 'Funnel Cloud Tornado Waterspout ', + 'SS' => 'Sandstorm/Duststorm ' + } + @cloud_condition_array = { + 'SKC' => 'clear', + 'CLR' => 'clear', + 'VV' => 'vertical visibility', + 'FEW' => 'a few', + 'SCT' => 'scattered', + 'BKN' => 'broken', + 'OVC' => 'overcast' + } + @strings = { + 'mm_inches' => '%s mm (%s inches)', + 'precip_a_trace' => 'a trace', + 'precip_there_was' => 'There was %s of precipitation ', + 'sky_str_format1' => 'There were %s at a height of %s meters (%s feet)', + 'sky_str_clear' => 'The sky was clear', + 'sky_str_format2' => ', %s at a height of %s meter (%s feet) and %s at a height of %s meters (%s feet)', + 'sky_str_format3' => ' and %s at a height of %s meters (%s feet)', + 'clouds' => ' clouds', + 'clouds_cb' => ' cumulonimbus clouds', + 'clouds_tcu' => ' towering cumulus clouds', + 'visibility_format' => 'The visibility was %s kilometers (%s miles).', + 'wind_str_format1' => 'blowing at a speed of %s meters per second (%s miles per hour)', + 'wind_str_format2' => ', with gusts to %s meters per second (%s miles per hour),', + 'wind_str_format3' => ' from the %s', + 'wind_str_calm' => 'calm', + 'precip_last_hour' => 'in the last hour. ', + 'precip_last_6_hours' => 'in the last 3 to 6 hours. ', + 'precip_last_24_hours' => 'in the last 24 hours. ', + 'precip_snow' => 'There is %s mm (%s inches) of snow on the ground. ', + 'temp_min_max_6_hours' => 'The maximum and minimum temperatures over the last 6 hours were %s and %s degrees Celsius (%s and %s degrees Fahrenheit).', + 'temp_max_6_hours' => 'The maximum temperature over the last 6 hours was %s degrees Celsius (%s degrees Fahrenheit). ', + 'temp_min_6_hours' => 'The minimum temperature over the last 6 hours was %s degrees Celsius (%s degrees Fahrenheit). ', + 'temp_min_max_24_hours' => 'The maximum and minimum temperatures over the last 24 hours were %s and %s degrees Celsius (%s and %s degrees Fahrenheit). ', + 'light' => 'Light ', + 'moderate' => 'Moderate ', + 'heavy' => 'Heavy ', + 'mild' => 'Mild ', + 'nearby' => 'Nearby ', + 'current_weather' => 'Current weather is %s. ', + 'pretty_print_metar' => '%s on %s, the wind was %s at %s. The temperature was %s degrees Celsius (%s degrees Fahrenheit), and the pressure was %s hPa (%s inHg). The relative humidity was %s%%. %s %s %s %s %s' + } + + parse + end + + def store_speed(value, windunit, meterspersec, knots, milesperhour) + # Helper function to convert and store speed based on unit. + # &$meterspersec, &$knots and &$milesperhour are passed on + # reference + if (windunit == 'KT') + # The windspeed measured in knots: + @decoded[knots] = sprintf("%.2f", value) + # The windspeed measured in meters per second, rounded to one decimal place: + @decoded[meterspersec] = sprintf("%.2f", value.to_f * 0.51444) + # The windspeed measured in miles per hour, rounded to one decimal place: */ + @decoded[milesperhour] = sprintf("%.2f", value.to_f * 1.1507695060844667) + elsif (windunit == 'MPS') + # The windspeed measured in meters per second: + @decoded[meterspersec] = sprintf("%.2f", value) + # The windspeed measured in knots, rounded to one decimal place: + @decoded[knots] = sprintf("%.2f", value.to_f / 0.51444) + #The windspeed measured in miles per hour, rounded to one decimal place: + @decoded[milesperhour] = sprintf("%.1f", value.to_f / 0.51444 * 1.1507695060844667) + elsif (windunit == 'KMH') + # The windspeed measured in kilometers per hour: + @decoded[meterspersec] = sprintf("%.1f", value.to_f * 1000 / 3600) + @decoded[knots] = sprintf("%.1f", value.to_f * 1000 / 3600 / 0.51444) + # The windspeed measured in miles per hour, rounded to one decimal place: + @decoded[milesperhour] = sprintf("%.1f", knots.to_f * 1.1507695060844667) + end + end + + def parse + @decoded = Hash.new + puts @input + @input.split(" ").each {|part| + if (part == 'METAR') + # Type of Report: METAR + @decoded['type'] = 'METAR' + elsif (part == 'SPECI') + # Type of Report: SPECI + @decoded['type'] = 'SPECI' + elsif (part == 'AUTO') + # Report Modifier: AUTO + @decoded['report_mod'] = 'AUTO' + elsif (part == 'NIL') + @nodata = true + elsif (part =~ /^\S{4}$/ && ! (@decoded.has_key?('station'))) + # Station Identifier + @decoded['station'] = part + elsif (part =~ /([0-9]{2})([0-9]{2})([0-9]{2})Z/) + # ignore this bit, it's useless without month/year. some of these + # things are hideously out of date. + # now = Time.new + # time = Time.gm(now.year, now.month, $1, $2, $3, 0) + # Date and Time of Report + # @decoded['time'] = time + elsif (part == 'COR') + # Report Modifier: COR + @decoded['report_mod'] = 'COR' + elsif (part =~ /([0-9]{3}|VRB)([0-9]{2,3}).*(KT|MPS|KMH)/) + # Wind Group + windunit = $3 + # now do ereg to get the actual values + part =~ /([0-9]{3}|VRB)([0-9]{2,3})((G[0-9]{2,3})?#{windunit})/ + if ($1 == 'VRB') + @decoded['wind_deg'] = 'variable directions' + @decoded['wind_dir_text'] = 'variable directions' + @decoded['wind_dir_text_short'] = 'VAR' + else + @decoded['wind_deg'] = $1 + @decoded['wind_dir_text'] = @wind_dir_texts[($1.to_i/22.5).round] + @decoded['wind_dir_text_short'] = @wind_dir_texts_short[($1.to_i/22.5).round] + end + store_speed($2, windunit, + 'wind_meters_per_second', + 'wind_knots', + 'wind_miles_per_hour') + + if ($4 != nil) + # We have a report with information about the gust. + # First we have the gust measured in knots + if ($4 =~ /G([0-9]{2,3})/) + store_speed($1,windunit, + 'wind_gust_meters_per_second', + 'wind_gust_knots', + 'wind_gust_miles_per_hour') + end + end + elsif (part =~ /([0-9]{3})V([0-9]{3})/) + # Variable wind-direction + @decoded['wind_var_beg'] = $1 + @decoded['wind_var_end'] = $2 + elsif (part == "9999") + # A strange value. When you look at other pages you see it + # interpreted like this (where I use > to signify 'Greater + # than'): + @decoded['visibility_miles'] = '>7'; + @decoded['visibility_km'] = '>11.3'; + elsif (part =~ /^([0-9]{4})$/) + # Visibility in meters (4 digits only) + # The visibility measured in kilometers, rounded to one decimal place. + @decoded['visibility_km'] = sprintf("%.1f", $1.to_i / 1000) + # The visibility measured in miles, rounded to one decimal place. + @decoded['visibility_miles'] = sprintf("%.1f", $1.to_i / 1000 / 1.609344) + elsif (part =~ /^[0-9]$/) + # Temp Visibility Group, single digit followed by space + @decoded['temp_visibility_miles'] = part + elsif (@decoded['temp_visibility_miles'] && (@decoded['temp_visibility_miles']+' '+part) =~ /^M?(([0-9]?)[ ]?([0-9])(\/?)([0-9]*))SM$/) + # Visibility Group + if ($4 == '/') + vis_miles = $2.to_i + $3.to_i/$5.to_i + else + vis_miles = $1.to_i; + end + if (@decoded['temp_visibility_miles'][0] == 'M') + # The visibility measured in miles, prefixed with < to indicate 'Less than' + @decoded['visibility_miles'] = '<' + sprintf("%.1f", vis_miles) + # The visibility measured in kilometers. The value is rounded + # to one decimal place, prefixed with < to indicate 'Less than' */ + @decoded['visibility_km'] = '<' . sprintf("%.1f", vis_miles * 1.609344) + else + # The visibility measured in mile.s */ + @decoded['visibility_miles'] = sprintf("%.1f", vis_miles) + # The visibility measured in kilometers, rounded to one decimal place. + @decoded['visibility_km'] = sprintf("%.1f", vis_miles * 1.609344) + end + elsif (part =~ /^(-|\+|VC|MI)?(TS|SH|FZ|BL|DR|BC|PR|RA|DZ|SN|SG|GR|GS|PE|IC|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+$/) + # Current weather-group + @decoded['weather'] = '' unless @decoded.has_key?('weather') + if (part[0].chr == '-') + # A light phenomenon + @decoded['weather'] += @strings['light'] + part = part[1,part.length] + elsif (part[0].chr == '+') + # A heavy phenomenon + @decoded['weather'] += @strings['heavy'] + part = part[1,part.length] + elsif (part[0,2] == 'VC') + # Proximity Qualifier + @decoded['weather'] += @strings['nearby'] + part = part[2,part.length] + elsif (part[0,2] == 'MI') + @decoded['weather'] += @strings['mild'] + part = part[2,part.length] + else + # no intensity code => moderate phenomenon + @decoded['weather'] += @strings['moderate'] + end + + while (part && bite = part[0,2]) do + # Now we take the first two letters and determine what they + # mean. We append this to the variable so that we gradually + # build up a phrase. + + @decoded['weather'] += @weather_array[bite] + # Here we chop off the two first letters, so that we can take + # a new bite at top of the while-loop. + part = part[2,-1] + end + elsif (part =~ /(SKC|CLR)/) + # Cloud-layer-group. + # There can be up to three of these groups, so we store them as + # cloud_layer1, cloud_layer2 and cloud_layer3. + + @cloud_layers += 1; + # Again we have to translate the code-characters to a + # meaningful string. + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_condition'] = @cloud_condition_array[$1] + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_coverage'] = @cloud_coverage[$1] + elsif (part =~ /^(VV|FEW|SCT|BKN|OVC)([0-9]{3})(CB|TCU)?$/) + # We have found (another) a cloud-layer-group. There can be up + # to three of these groups, so we store them as cloud_layer1, + # cloud_layer2 and cloud_layer3. + @cloud_layers += 1; + # Again we have to translate the code-characters to a meaningful string. + if ($3 == 'CB') + # cumulonimbus (CB) clouds were observed. */ + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_condition'] = + @cloud_condition_array[$1] + @strings['clouds_cb'] + elsif ($3 == 'TCU') + # towering cumulus (TCU) clouds were observed. + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_condition'] = + @cloud_condition_array[$1] + @strings['clouds_tcu'] + else + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_condition'] = + @cloud_condition_array[$1] + @strings['clouds'] + end + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_coverage'] = @cloud_coverage[$1] + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_altitude_ft'] = $2.to_i * 100 + @decoded['cloud_layer'+ (@cloud_layers.to_s) +'_altitude_m'] = ($2.to_f * 30.48).round + elsif (part =~ /^T([0-9]{4})$/) + store_temp($1,'temp_c','temp_f') + elsif (part =~ /^T?(M?[0-9]{2})\/(M?[0-9\/]{1,2})?$/) + # Temperature/Dew Point Group + # The temperature and dew-point measured in Celsius. + @decoded['temp_c'] = sprintf("%d", $1.tr('M', '-')) + if $2 == "//" || !$2 + @decoded['dew_c'] = 0 + else + @decoded['dew_c'] = sprintf("%.1f", $2.tr('M', '-')) + end + # The temperature and dew-point measured in Fahrenheit, rounded to + # the nearest degree. + @decoded['temp_f'] = ((@decoded['temp_c'].to_f * 9 / 5) + 32).round + @decoded['dew_f'] = ((@decoded['dew_c'].to_f * 9 / 5) + 32).round + elsif(part =~ /A([0-9]{4})/) + # Altimeter + # The pressure measured in inHg + @decoded['altimeter_inhg'] = sprintf("%.2f", $1.to_i/100) + # The pressure measured in mmHg, hPa and atm + @decoded['altimeter_mmhg'] = sprintf("%.1f", $1.to_f * 0.254) + @decoded['altimeter_hpa'] = sprintf("%d", ($1.to_f * 0.33863881578947).to_i) + @decoded['altimeter_atm'] = sprintf("%.3f", $1.to_f * 3.3421052631579e-4) + elsif(part =~ /Q([0-9]{4})/) + # Altimeter + # This is strange, the specification doesnt say anything about + # the Qxxxx-form, but it's in the METARs. + # The pressure measured in hPa + @decoded['altimeter_hpa'] = sprintf("%d", $1.to_i) + # The pressure measured in mmHg, inHg and atm + @decoded['altimeter_mmhg'] = sprintf("%.1f", $1.to_f * 0.7500616827) + @decoded['altimeter_inhg'] = sprintf("%.2f", $1.to_f * 0.0295299875) + @decoded['altimeter_atm'] = sprintf("%.3f", $1.to_f * 9.869232667e-4) + elsif (part =~ /^T([0-9]{4})([0-9]{4})/) + # Temperature/Dew Point Group, coded to tenth of degree. + # The temperature and dew-point measured in Celsius. + store_temp($1,'temp_c','temp_f') + store_temp($2,'dew_c','dew_f') + elsif (part =~ /^1([0-9]{4}$)/) + # 6 hour maximum temperature Celsius, coded to tenth of degree + store_temp($1,'temp_max6h_c','temp_max6h_f') + elsif (part =~ /^2([0-9]{4}$)/) + # 6 hour minimum temperature Celsius, coded to tenth of degree + store_temp($1,'temp_min6h_c','temp_min6h_f') + elsif (part =~ /^4([0-9]{4})([0-9]{4})$/) + # 24 hour maximum and minimum temperature Celsius, coded to + # tenth of degree + store_temp($1,'temp_max24h_c','temp_max24h_f') + store_temp($2,'temp_min24h_c','temp_min24h_f') + elsif (part =~ /^P([0-9]{4})/) + # Precipitation during last hour in hundredths of an inch + # (store as inches) + @decoded['precip_in'] = sprintf("%.2f", $1.to_f/100) + @decoded['precip_mm'] = sprintf("%.2f", $1.to_f * 0.254) + elsif (part =~ /^6([0-9]{4})/) + # Precipitation during last 3 or 6 hours in hundredths of an + # inch (store as inches) + @decoded['precip_6h_in'] = sprintf("%.2f", $1.to_f/100) + @decoded['precip_6h_mm'] = sprintf("%.2f", $1.to_f * 0.254) + elsif (part =~ /^7([0-9]{4})/) + # Precipitation during last 24 hours in hundredths of an inch + # (store as inches) + @decoded['precip_24h_in'] = sprintf("%.2f", $1.to_f/100) + @decoded['precip_24h_mm'] = sprintf("%.2f", $1.to_f * 0.254) + elsif(part =~ /^4\/([0-9]{3})/) + # Snow depth in inches + @decoded['snow_in'] = sprintf("%.2f", $1); + @decoded['snow_mm'] = sprintf("%.2f", $1.to_f * 25.4) + else + # If we couldn't match the group, we assume that it was a + # remark. + @decoded['remarks'] = '' unless @decoded.has_key?("remarks") + @decoded['remarks'] += ' ' + part; + end + } + + # Relative humidity + # p @decoded['dew_c'] # 11.0 + # p @decoded['temp_c'] # 21.0 + # => 56.1 + @decoded['rel_humidity'] = sprintf("%.1f",100 * + (6.11 * (10.0**(7.5 * @decoded['dew_c'].to_f / (237.7 + @decoded['dew_c'].to_f)))) / (6.11 * (10.0 ** (7.5 * @decoded['temp_c'].to_f / (237.7 + @decoded['temp_c'].to_f))))) if @decoded.has_key?('dew_c') + end + + def store_temp(temp,temp_cname,temp_fname) + # Given a numerical temperature temp in Celsius, coded to tenth of + # degree, store in @decoded[temp_cname], convert to Fahrenheit + # and store in @decoded[temp_fname] + # Note: temp is converted to negative if temp > 100.0 (See + # Federal Meteorological Handbook for groups T, 1, 2 and 4) + + # Temperature measured in Celsius, coded to tenth of degree + temp = temp.to_f/10 + if (temp >100.0) + # first digit = 1 means minus temperature + temp = -(temp - 100.0) + end + @decoded[temp_cname] = sprintf("%.1f", temp) + # The temperature in Fahrenheit. + @decoded[temp_fname] = sprintf("%.1f", (temp * 9 / 5) + 32) + end + + def pretty_print_precip(precip_mm, precip_in) + # Returns amount if $precip_mm > 0, otherwise "trace" (see Federal + # Meteorological Handbook No. 1 for code groups P, 6 and 7) used in + # several places, so standardized in one function. + if (precip_mm.to_i > 0) + amount = sprintf(@strings['mm_inches'], precip_mm, precip_in) + else + amount = @strings['a_trace'] + end + return sprintf(@strings['precip_there_was'], amount) + end + + def pretty_print + if @nodata + return "The weather stored for #{@decoded['station']} consists of the string 'NIL' :(" + end + + ["temp_c", "altimeter_hpa"].each {|key| + if !@decoded.has_key?(key) + return "The weather stored for #{@decoded['station']} could not be parsed (#{@input})" + end + } + + mins_old = ((Time.now - @date.to_i).to_f/60).round + if (mins_old <= 60) + weather_age = mins_old.to_s + " minutes ago," + elsif (mins_old <= 60 * 25) + weather_age = (mins_old / 60).to_s + " hours, " + weather_age += (mins_old % 60).to_s + " minutes ago," + else + # return "The weather stored for #{@decoded['station']} is hideously out of date :( (Last update #{@date})" + weather_age = "The weather stored for #{@decoded['station']} is hideously out of date :( here it is anyway:" + end + + if(@decoded.has_key?("cloud_layer1_altitude_ft")) + sky_str = sprintf(@strings['sky_str_format1'], + @decoded["cloud_layer1_condition"], + @decoded["cloud_layer1_altitude_m"], + @decoded["cloud_layer1_altitude_ft"]) + else + sky_str = @strings['sky_str_clear'] + end + + if(@decoded.has_key?("cloud_layer2_altitude_ft")) + if(@decoded.has_key?("cloud_layer3_altitude_ft")) + sky_str += sprintf(@strings['sky_str_format2'], + @decoded["cloud_layer2_condition"], + @decoded["cloud_layer2_altitude_m"], + @decoded["cloud_layer2_altitude_ft"], + @decoded["cloud_layer3_condition"], + @decoded["cloud_layer3_altitude_m"], + @decoded["cloud_layer3_altitude_ft"]) + else + sky_str += sprintf(@strings['sky_str_format3'], + @decoded["cloud_layer2_condition"], + @decoded["cloud_layer2_altitude_m"], + @decoded["cloud_layer2_altitude_ft"]) + end + end + sky_str += "." + + if(@decoded.has_key?("visibility_miles")) + visibility = sprintf(@strings['visibility_format'], + @decoded["visibility_km"], + @decoded["visibility_miles"]) + else + visibility = "" + end + + if (@decoded.has_key?("wind_meters_per_second") && @decoded["wind_meters_per_second"].to_i > 0) + wind_str = sprintf(@strings['wind_str_format1'], + @decoded["wind_meters_per_second"], + @decoded["wind_miles_per_hour"]) + if (@decoded.has_key?("wind_gust_meters_per_second") && @decoded["wind_gust_meters_per_second"].to_i > 0) + wind_str += sprintf(@strings['wind_str_format2'], + @decoded["wind_gust_meters_per_second"], + @decoded["wind_gust_miles_per_hour"]) + end + wind_str += sprintf(@strings['wind_str_format3'], + @decoded["wind_dir_text"]) + else + wind_str = @strings['wind_str_calm'] + end + + prec_str = "" + if (@decoded.has_key?("precip_in")) + prec_str += pretty_print_precip(@decoded["precip_mm"], @decoded["precip_in"]) + @strings['precip_last_hour'] + end + if (@decoded.has_key?("precip_6h_in")) + prec_str += pretty_print_precip(@decoded["precip_6h_mm"], @decoded["precip_6h_in"]) + @strings['precip_last_6_hours'] + end + if (@decoded.has_key?("precip_24h_in")) + prec_str += pretty_print_precip(@decoded["precip_24h_mm"], @decoded["precip_24h_in"]) + @strings['precip_last_24_hours'] + end + if (@decoded.has_key?("snow_in")) + prec_str += sprintf(@strings['precip_snow'], @decoded["snow_mm"], @decoded["snow_in"]) + end + + temp_str = "" + if (@decoded.has_key?("temp_max6h_c") && @decoded.has_key?("temp_min6h_c")) + temp_str += sprintf(@strings['temp_min_max_6_hours'], + @decoded["temp_max6h_c"], + @decoded["temp_min6h_c"], + @decoded["temp_max6h_f"], + @decoded["temp_min6h_f"]) + else + if (@decoded.has_key?("temp_max6h_c")) + temp_str += sprintf(@strings['temp_max_6_hours'], + @decoded["temp_max6h_c"], + @decoded["temp_max6h_f"]) + end + if (@decoded.has_key?("temp_min6h_c")) + temp_str += sprintf(@strings['temp_max_6_hours'], + @decoded["temp_min6h_c"], + @decoded["temp_min6h_f"]) + end + end + if (@decoded.has_key?("temp_max24h_c")) + temp_str += sprintf(@strings['temp_min_max_24_hours'], + @decoded["temp_max24h_c"], + @decoded["temp_min24h_c"], + @decoded["temp_max24h_f"], + @decoded["temp_min24h_f"]) + end + + if (@decoded.has_key?("weather")) + weather_str = sprintf(@strings['current_weather'], @decoded["weather"]) + else + weather_str = '' + end + + return sprintf(@strings['pretty_print_metar'], + weather_age, + @date, + wind_str, @decoded["station"], @decoded["temp_c"], + @decoded["temp_f"], @decoded["altimeter_hpa"], + @decoded["altimeter_inhg"], + @decoded["rel_humidity"], sky_str, + visibility, weather_str, prec_str, temp_str).strip + end + + def to_s + @input + end + end + + def Utils.get_metar(station) + station.upcase! + + result = Utils.http_get("http://weather.noaa.gov/pub/data/observations/metar/stations/#{station}.TXT") + return nil unless result + return Metar.new(result) + end + end +end |