#-- vim:sw=2:et
#++
#
# :title: Url plugin
require 'socket'
define_structure :Url, :channel, :nick, :time, :url, :info
class UrlPlugin < Plugin
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.")
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
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="")
"url info => display link info for (set url.display_link_info > 0 if you want the bot to do it automatically when someone writes an url), urls [=4] => list last urls mentioned in current channel, urls search [=4] => search for matching urls. In a private message, you must specify the channel to query, eg. urls [max], urls search [max] "
end
def get_title_from_html(pagedata)
return pagedata.ircify_html_title
end
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 = Addrinfo.getaddrinfo(url.host, nil).map { |addr| addr.ip_address }
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
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
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 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 "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
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?
urls = URI.extract(m.message, ['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
disp << " --> #{title}" if title
end
m.reply disp, :overlong => :truncate
end
end
def urls(m, params)
channel = params[:channel] ? params[:channel] : m.target
max = params[:limit].to_i
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
reply_urls :msg => m, :channel => channel, :list => list, :max => max
end
end
def search(m, params)
channel = params[:channel] ? params[:channel] : m.target
max = params[:limit].to_i
string = params[:string]
max = 10 if max > 10
max = 1 if max < 1
regex = Regexp.new(string, Regexp::IGNORECASE)
list = @registry[channel].find_all {|url|
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
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+$/},
:public => false
plugin.map 'urls search :limit :string', :action => 'search',
:defaults => {:limit => 4},
:requirements => {:limit => /^\d+$/},
:private => false
plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
:requirements => {:limit => /^\d+$/},
:public => false
plugin.map 'urls :limit', :defaults => {:limit => 4},
:requirements => {:limit => /^\d+$/},
:private => false