]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blobdiff - data/rbot/plugins/url.rb
rbot-remote: allow override of function
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / url.rb
index 286e8c4a38c8a4d449b5c15321fae3008428e163..3faeeab91e33ea056d97d77cf2f10a56c56c7cc3 100644 (file)
-require 'open-uri'
+#-- vim:sw=2:et
+#++
+#
+# :title: Url plugin
 
-Url = Struct.new("Url", :channel, :nick, :time, :url)
-TITLE_RE = /<\s*title\s*>(.+)<\s*\/title\s*>/im
+define_structure :Url, :channel, :nick, :time, :url, :info
 
 class UrlPlugin < Plugin
-  BotConfig.register BotConfigIntegerValue.new('url.max_urls',
+  LINK_INFO = "[Link Info]"
+  OUR_UNSAFE = Regexp.new("[^#{URI::PATTERN::UNRESERVED}#{URI::PATTERN::RESERVED}%# ]", false, 'N')
+
+  Config.register Config::IntegerValue.new('url.max_urls',
     :default => 100, :validate => Proc.new{|v| v > 0},
     :desc => "Maximum number of urls to store. New urls replace oldest ones.")
-  BotConfig.register BotConfigBooleanValue.new('url.say_titles',
-    :default => true, 
-    :desc => "Get the title of any links pasted to the channel and display it (Also, tells if the link is broken)")
-  
+  Config.register Config::IntegerValue.new('url.display_link_info',
+    :default => 0,
+    :desc => "Get the title of links pasted to the channel and display it (also tells if the link is broken or the site is down). Do it for at most this many links per line (set to 0 to disable)")
+  Config.register Config::BooleanValue.new('url.auto_shorten',
+    :default => false,
+    :desc => "Automatically spit out shortened URLs when they're seen. Check shortenurls for config options")
+  Config.register Config::IntegerValue.new('url.auto_shorten_min_length',
+    :default => 48,
+    :desc => "Minimum length of URL to auto-shorten.  Only has an effect when url.auto_shorten is true.")
+  Config.register Config::BooleanValue.new('url.titles_only',
+    :default => false,
+    :desc => "Only show info for links that have <title> tags (in other words, don't display info for jpegs, mpegs, etc.)")
+  Config.register Config::BooleanValue.new('url.first_par',
+    :default => false,
+    :desc => "Also try to get the first paragraph of a web page")
+  Config.register Config::IntegerValue.new('url.first_par_length',
+    :default => 150,
+    :desc => "The max length of the first paragraph")
+  Config.register Config::ArrayValue.new('url.first_par_whitelist',
+    :default => ['twitter.com'],
+    :desc => "List of url patterns to show the content for.")
+  Config.register Config::BooleanValue.new('url.info_on_list',
+    :default => false,
+    :desc => "Show link info when listing/searching for urls")
+  Config.register Config::ArrayValue.new('url.no_info_hosts',
+    :default => ['localhost', '^192\.168\.', '^10\.', '^127\.', '^172\.(1[6-9]|2\d|31)\.'],
+    :on_change => Proc.new { |bot, v| bot.plugins['url'].reset_no_info_hosts },
+    :desc => "A list of regular expressions matching hosts for which no info should be provided")
+  Config.register Config::ArrayValue.new('url.only_on_channels',
+    :desc => "Show link info only on these channels",
+    :default => [])
+  Config.register Config::ArrayValue.new('url.ignore',
+    :desc => "Don't show link info for urls from users represented as hostmasks on this list. Useful for ignoring other bots, for example.",
+    :default => [])
+
   def initialize
     super
     @registry.set_default(Array.new)
+    unless @bot.config['url.display_link_info'].kind_of?(Integer)
+      @bot.config.items[:'url.display_link_info'].set_string(@bot.config['url.display_link_info'].to_s)
+    end
+    reset_no_info_hosts
+    self.filter_group = :htmlinfo
+    load_filters
+  end
+
+  def reset_no_info_hosts
+    @no_info_hosts = Regexp.new(@bot.config['url.no_info_hosts'].join('|'), true)
+    debug "no info hosts regexp set to #{@no_info_hosts}"
   end
 
   def help(plugin, topic="")
-    "urls [<max>=4] => list <max> last urls mentioned in current channel, urls search [<max>=4] <regexp> => search for matching urls. In a private message, you must specify the channel to query, eg. urls <channel> [max], urls search <channel> [max] <regexp>"
+    "url info <url> => display link info for <url> (set url.display_link_info > 0 if you want the bot to do it automatically when someone writes an url), urls [<max>=4] => list <max> last urls mentioned in current channel, urls search [<max>=4] <regexp> => search for matching urls. In a private message, you must specify the channel to query, eg. urls <channel> [max], urls search <channel> [max] <regexp>"
+  end
+
+  def get_title_from_html(pagedata)
+    return pagedata.ircify_html_title
   end
 
-  def get_title_for_url(url)
+  def get_title_for_url(uri_str, opts = {})
+
+    url = uri_str.kind_of?(URI) ? uri_str : URI.parse(uri_str)
+    return if url.scheme !~ /https?/
+
+    # also check the ip, the canonical name and the aliases
+    begin
+      checks = TCPSocket.gethostbyname(url.host)
+      checks.delete_at(-2)
+    rescue => e
+      return "Unable to retrieve info for #{url.host}: #{e.message}"
+    end
+
+    checks << url.host
+    checks.flatten!
+
+    unless checks.grep(@no_info_hosts).empty?
+      return ( opts[:always_reply] ? "Sorry, info retrieval for #{url.host} (#{checks.first}) is disabled" : false )
+    end
+
+    logopts = opts.dup
+
+    title = nil
+    extra = []
+
     begin
-      pagedata = open(url).read
-      return unless TITLE_RE.match(pagedata)
-      title = $1.strip.gsub(/\s*\n\s*/, " ")
-      title = title[0..255] if title.length > 255 
-      "Title: #{title}"
-
-    rescue SocketError => e
-      "Title: ^- Error connecting to site (#{e.message})"
-    rescue OpenURI::HTTPError => e
-      "Title: ^- Error getting page (#{e.message})"
+      debug "+ getting info for #{url.request_uri}"
+      info = @bot.filter(:htmlinfo, url)
+      logopts[:htmlinfo] = info
+      resp = info[:headers]
+
+      logopts[:title] = title = info[:title]
+
+      if info[:content]
+        logopts[:extra] = info[:content]
+
+        max_length = @bot.config['url.first_par_length']
+
+        whitelist = @bot.config['url.first_par_whitelist']
+        content = nil
+        if whitelist.length > 0
+          whitelist.each do |pattern|
+            if Regexp.new(pattern, Regexp::IGNORECASE).match(url.to_s)
+              content = info[:content][0...max_length]
+              break
+            end
+          end
+        else
+          content = info[:content][0...max_length]
+        end
+
+        extra << "#{Bold}text#{Bold}: #{content}" if @bot.config['url.first_par'] and content
+      else
+        logopts[:extra] = String.new
+        logopts[:extra] << "Content Type: #{resp['content-type']}"
+        extra << "#{Bold}type#{Bold}: #{resp['content-type']}" unless title
+        if enc = resp['content-encoding']
+          logopts[:extra] << ", encoding: #{enc}"
+          extra << "#{Bold}encoding#{Bold}: #{enc}" if @bot.config['url.first_par'] or not title
+        end
+
+        size = resp['content-length'].first.gsub(/(\d)(?=\d{3}+(?:\.|$))(\d{3}\..*)?/,'\1,\2') rescue nil
+        if size
+          logopts[:extra] << ", size: #{size} bytes"
+          extra << "#{Bold}size#{Bold}: #{size} bytes" if @bot.config['url.first_par'] or not title
+        end
+      end
     rescue Exception => e
-      "Title: ^- Error: #{e.inspect}"
+      case e
+      when UrlLinkError
+        raise e
+      else
+        error e
+        raise "connecting to site/processing information (#{e.message})"
+      end
     end
+
+    call_event(:url_added, url.to_s, logopts)
+    if title
+      extra.unshift("#{Bold}title#{Bold}: #{title}")
+    end
+    return extra.join(", ") if title or not @bot.config['url.titles_only']
   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+|$)/
-        urlstr = $1
-        list = @registry[m.target]
-
-        if @bot.config['url.say_titles']
-          debug "Getting title for #{urlstr}..."
-          title = get_title_for_url urlstr
-          if title
-            m.reply title
-            debug "Title found!"
+  def handle_urls(m, params={})
+    opts = {
+      :display_info => @bot.config['url.display_link_info'],
+      :channels => @bot.config['url.only_on_channels'],
+      :ignore => @bot.config['url.ignore']
+    }.merge params
+    urls = opts[:urls]
+    display_info= opts[:display_info]
+    channels = opts[:channels]
+    ignore = opts[:ignore]
+
+    unless channels.empty?
+      return unless channels.map { |c| c.downcase }.include?(m.channel.downcase)
+    end
+
+    ignore.each { |u| return if m.source.matches?(u) }
+
+    return if urls.empty?
+    debug "found urls #{urls.inspect}"
+    list = m.public? ? @registry[m.target] : nil
+    debug "display link info: #{display_info}"
+    urls_displayed = 0
+    urls.each do |urlstr|
+      debug "working on #{urlstr}"
+      next unless urlstr =~ /^https?:\/\/./
+      if @bot.config['url.auto_shorten'] == true and
+         urlstr.length >= @bot.config['url.auto_shorten_min_length']
+        m.reply(bot.plugins['shortenurls'].shorten(nil, {:url=>urlstr, :called=>true}))
+        next
+      end
+      title = nil
+      debug "Getting title for #{urlstr}..."
+      reply = nil
+      begin
+        title = get_title_for_url(urlstr,
+                                  :always_reply => m.address?,
+                                  :nick => m.source.nick,
+                                  :channel => m.channel,
+                                  :ircline => m.message)
+        debug "Title #{title ? '' : 'not '} found"
+        reply = "#{LINK_INFO} #{title}" if title
+      rescue => e
+        debug e
+        # we might get a 404 because of trailing punctuation, so we try again
+        # with the last character stripped. this might generate invalid URIs
+        # (e.g. because "some.url" gets chopped to some.url%2, so catch that too
+        if e.message =~ /\(404 - Not Found\)/i or e.kind_of?(URI::InvalidURIError)
+          # chop off last non-word character from the unescaped version of
+          # the URL, and retry if we still have enough string to look like a
+          # minimal URL
+          unescaped = URI.unescape(urlstr)
+          debug "Unescaped: #{unescaped}"
+          if unescaped.sub!(/\W$/,'') and unescaped =~ /^https?:\/\/./
+            urlstr.replace URI.escape(unescaped, OUR_UNSAFE)
+            retry
           else
-            debug "Title not found!"
-          end        
+            debug "Not retrying #{unescaped}"
+          end
+        end
+        reply = "Error #{e.message}"
+      end
+
+      if display_info > urls_displayed
+        if reply
+          m.reply reply, :overlong => :truncate, :to => :public,
+            :nick => (m.address? ? :auto : false)
+          urls_displayed += 1
         end
-    
-        # check to see if this url is already listed
-        return if list.find {|u| u.url == urlstr }
-        
-        url = Url.new(m.target, m.sourcenick, Time.new, urlstr)
-        debug "#{list.length} urls so far"
-        if list.length > @bot.config['url.max_urls']
-          list.pop
+      end
+
+      next unless list
+
+      # check to see if this url is already listed
+      next if list.find {|u| u.url == urlstr }
+
+      url = Url.new(m.target, m.sourcenick, Time.new, urlstr, title)
+      debug "#{list.length} urls so far"
+      list.pop if list.length > @bot.config['url.max_urls']
+      debug "storing url #{url.url}"
+      list.unshift url
+      debug "#{list.length} urls now"
+    end
+    @registry[m.target] = list
+  end
+
+  def info(m, params)
+    escaped = URI.escape(params[:urls].to_s, OUR_UNSAFE)
+    urls = URI.extract(escaped)
+    Thread.new do
+      handle_urls(m,
+                  :urls => urls,
+                  :display_info => params[:urls].length,
+                  :channels => [])
+    end
+  end
+
+  def message(m)
+    return if m.address?
+
+    escaped = URI.escape(m.message, OUR_UNSAFE)
+    urls = URI.extract(escaped, ['http', 'https'])
+    return if urls.empty?
+    Thread.new { handle_urls(m, :urls => urls) }
+  end
+
+  def reply_urls(opts={})
+    list = opts[:list]
+    max = opts[:max]
+    channel = opts[:channel]
+    m = opts[:msg]
+    return unless list and max and m
+    list[0..(max-1)].each do |url|
+      disp = "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
+      if @bot.config['url.info_on_list']
+        title = url.info ||
+          get_title_for_url(url.url,
+                            :nick => url.nick, :channel => channel) rescue nil
+        # If the url info was missing and we now have some, try to upgrade it
+        if channel and title and not url.info
+          ll = @registry[channel]
+          debug ll
+          if el = ll.find { |u| u.url == url.url }
+            el.info = title
+            @registry[channel] = ll
+          end
         end
-        debug "storing url #{url.url}"
-        list.unshift url
-        debug "#{list.length} urls now"
-        @registry[m.target] = list
+        disp << " --> #{title}" if title
       end
+      m.reply disp, :overlong => :truncate
     end
   end
 
@@ -82,9 +296,7 @@ class UrlPlugin < Plugin
     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
+      reply_urls :msg => m, :channel => channel, :list => list, :max => max
     end
   end
 
@@ -94,20 +306,22 @@ class UrlPlugin < Plugin
     string = params[:string]
     max = 10 if max > 10
     max = 1 if max < 1
-    regex = Regexp.new(string)
+    regex = Regexp.new(string, Regexp::IGNORECASE)
     list = @registry[channel].find_all {|url|
-      regex.match(url.url) || regex.match(url.nick)
+      regex.match(url.url) || regex.match(url.nick) ||
+        (@bot.config['url.info_on_list'] && regex.match(url.info))
     }
     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
+      reply_urls :msg => m, :channel => channel, :list => list, :max => max
     end
   end
 end
+
 plugin = UrlPlugin.new
+plugin.map 'urls info *urls', :action => 'info'
+plugin.map 'url info *urls', :action => 'info'
 plugin.map 'urls search :channel :limit :string', :action => 'search',
                           :defaults => {:limit => 4},
                           :requirements => {:limit => /^\d+$/},