]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/url.rb
rss plugin: fix watcher for empty feed
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / url.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Url plugin
5
6 define_structure :Url, :channel, :nick, :time, :url, :info
7
8 class UrlPlugin < Plugin
9   LINK_INFO = "[Link Info]"
10   OUR_UNSAFE = Regexp.new("[^#{URI::PATTERN::UNRESERVED}#{URI::PATTERN::RESERVED}%# ]", false, 'N')
11
12   Config.register Config::IntegerValue.new('url.max_urls',
13     :default => 100, :validate => Proc.new{|v| v > 0},
14     :desc => "Maximum number of urls to store. New urls replace oldest ones.")
15   Config.register Config::IntegerValue.new('url.display_link_info',
16     :default => 0,
17     :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)")
18   Config.register Config::BooleanValue.new('url.titles_only',
19     :default => false,
20     :desc => "Only show info for links that have <title> tags (in other words, don't display info for jpegs, mpegs, etc.)")
21   Config.register Config::BooleanValue.new('url.first_par',
22     :default => false,
23     :desc => "Also try to get the first paragraph of a web page")
24   Config.register Config::BooleanValue.new('url.info_on_list',
25     :default => false,
26     :desc => "Show link info when listing/searching for urls")
27   Config.register Config::ArrayValue.new('url.no_info_hosts',
28     :default => ['localhost', '^192\.168\.', '^10\.', '^127\.', '^172\.(1[6-9]|2\d|31)\.'],
29     :on_change => Proc.new { |bot, v| bot.plugins['url'].reset_no_info_hosts },
30     :desc => "A list of regular expressions matching hosts for which no info should be provided")
31   Config.register Config::ArrayValue.new('url.only_on_channels',
32     :desc => "Show link info only on these channels",
33     :default => [])
34
35   def initialize
36     super
37     @registry.set_default(Array.new)
38     unless @bot.config['url.display_link_info'].kind_of?(Integer)
39       @bot.config.items[:'url.display_link_info'].set_string(@bot.config['url.display_link_info'].to_s)
40     end
41     reset_no_info_hosts
42   end
43
44   def reset_no_info_hosts
45     @no_info_hosts = Regexp.new(@bot.config['url.no_info_hosts'].join('|'), true)
46     debug "no info hosts regexp set to #{@no_info_hosts}"
47   end
48
49   def help(plugin, topic="")
50     "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>"
51   end
52
53   def get_title_from_html(pagedata)
54     return pagedata.ircify_html_title
55   end
56
57   def get_title_for_url(uri_str, opts = {})
58
59     url = uri_str.kind_of?(URI) ? uri_str : URI.parse(uri_str)
60     return if url.scheme !~ /https?/
61
62     # also check the ip, the canonical name and the aliases
63     begin
64       checks = TCPSocket.gethostbyname(url.host)
65       checks.delete_at(-2)
66     rescue => e
67       return "Unable to retrieve info for #{url.host}: #{e.message}"
68     end
69
70     checks << url.host
71     checks.flatten!
72
73     unless checks.grep(@no_info_hosts).empty?
74       return "Sorry, info retrieval for #{url.host} (#{checks.first}) is disabled"
75     end
76
77     logopts = opts.dup
78
79     title = nil
80     extra = []
81
82     begin
83       debug "+ getting info for #{url.request_uri}"
84       info = @bot.filter(:htmlinfo, url)
85       debug info
86       resp = info[:headers]
87
88       logopts[:title] = title = info[:title]
89
90       if info[:content]
91         logopts[:extra] = info[:content]
92         extra << "#{Bold}text#{Bold}: #{info[:content]}" if @bot.config['url.first_par']
93       else
94         logopts[:extra] = String.new
95         logopts[:extra] << "Content Type: #{resp['content-type']}"
96         extra << "#{Bold}type#{Bold}: #{resp['content-type']}" unless title
97         if enc = resp['content-encoding']
98           logopts[:extra] << ", encoding: #{enc}"
99           extra << "#{Bold}encoding#{Bold}: #{enc}" if @bot.config['url.first_par'] or not title
100         end
101
102         size = resp['content-length'].first.gsub(/(\d)(?=\d{3}+(?:\.|$))(\d{3}\..*)?/,'\1,\2') rescue nil
103         if size
104           logopts[:extra] << ", size: #{size} bytes"
105           extra << "#{Bold}size#{Bold}: #{size} bytes" if @bot.config['url.first_par'] or not title
106         end
107       end
108     rescue Exception => e
109       case e
110       when UrlLinkError
111         raise e
112       else
113         error e
114         raise "connecting to site/processing information (#{e.message})"
115       end
116     end
117
118     call_event(:url_added, url.to_s, logopts)
119     if title
120       extra.unshift("#{Bold}title#{Bold}: #{title}")
121     end
122     return extra.join(", ") if title or not @bot.config['url.titles_only']
123   end
124
125   def handle_urls(m, urls, display_info=@bot.config['url.display_link_info'])
126     unless (channels = @bot.config['url.only_on_channels']).empty?
127       return unless channels.map { |c| c.downcase }.include?(m.channel.downcase)
128     end
129
130     return if urls.empty?
131     debug "found urls #{urls.inspect}"
132     list = m.public? ? @registry[m.target] : nil
133     debug "display link info: #{display_info}"
134     urls_displayed = 0
135     urls.each do |urlstr|
136       debug "working on #{urlstr}"
137       next unless urlstr =~ /^https?:\/\/./
138       title = nil
139       debug "Getting title for #{urlstr}..."
140       reply = nil
141       begin
142         title = get_title_for_url(urlstr,
143                                   :nick => m.source.nick,
144                                   :channel => m.channel,
145                                   :ircline => m.message)
146         debug "Title #{title ? '' : 'not '} found"
147         reply = "#{LINK_INFO} #{title}" if title
148       rescue => e
149         debug e
150         # we might get a 404 because of trailing punctuation, so we try again
151         # with the last character stripped. this might generate invalid URIs
152         # (e.g. because "some.url" gets chopped to some.url%2, so catch that too
153         if e.message =~ /\(404 - Not Found\)/i or e.kind_of?(URI::InvalidURIError)
154           # chop off last character, and retry if we still have enough string to
155           # look like a minimal URL
156           retry if urlstr.chop! and urlstr =~ /^https?:\/\/./
157         end
158         reply = "Error #{e.message}"
159       end
160
161       if display_info > urls_displayed
162         if reply
163           m.reply reply, :overlong => :truncate, :to => :public,
164             :nick => (m.address? ? :auto : false)
165           urls_displayed += 1
166         end
167       end
168
169       next unless list
170
171       # check to see if this url is already listed
172       next if list.find {|u| u.url == urlstr }
173
174       url = Url.new(m.target, m.sourcenick, Time.new, urlstr, title)
175       debug "#{list.length} urls so far"
176       list.pop if list.length > @bot.config['url.max_urls']
177       debug "storing url #{url.url}"
178       list.unshift url
179       debug "#{list.length} urls now"
180     end
181     @registry[m.target] = list
182   end
183
184   def info(m, params)
185     escaped = URI.escape(params[:urls].to_s, OUR_UNSAFE)
186     urls = URI.extract(escaped)
187     Thread.new { handle_urls(m, urls, params[:urls].length) }
188   end
189
190   def message(m)
191     return if m.address?
192
193     escaped = URI.escape(m.message, OUR_UNSAFE)
194     urls = URI.extract(escaped, ['http', 'https'])
195     return if urls.empty?
196     Thread.new { handle_urls(m, urls) }
197   end
198
199   def reply_urls(opts={})
200     list = opts[:list]
201     max = opts[:max]
202     channel = opts[:channel]
203     m = opts[:msg]
204     return unless list and max and m
205     list[0..(max-1)].each do |url|
206       disp = "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
207       if @bot.config['url.info_on_list']
208         title = url.info ||
209           get_title_for_url(url.url,
210                             :nick => url.nick, :channel => channel) rescue nil
211         # If the url info was missing and we now have some, try to upgrade it
212         if channel and title and not url.info
213           ll = @registry[channel]
214           debug ll
215           if el = ll.find { |u| u.url == url.url }
216             el.info = title
217             @registry[channel] = ll
218           end
219         end
220         disp << " --> #{title}" if title
221       end
222       m.reply disp, :overlong => :truncate
223     end
224   end
225
226   def urls(m, params)
227     channel = params[:channel] ? params[:channel] : m.target
228     max = params[:limit].to_i
229     max = 10 if max > 10
230     max = 1 if max < 1
231     list = @registry[channel]
232     if list.empty?
233       m.reply "no urls seen yet for channel #{channel}"
234     else
235       reply_urls :msg => m, :channel => channel, :list => list, :max => max
236     end
237   end
238
239   def search(m, params)
240     channel = params[:channel] ? params[:channel] : m.target
241     max = params[:limit].to_i
242     string = params[:string]
243     max = 10 if max > 10
244     max = 1 if max < 1
245     regex = Regexp.new(string, Regexp::IGNORECASE)
246     list = @registry[channel].find_all {|url|
247       regex.match(url.url) || regex.match(url.nick) ||
248         (@bot.config['url.info_on_list'] && regex.match(url.info))
249     }
250     if list.empty?
251       m.reply "no matches for channel #{channel}"
252     else
253       reply_urls :msg => m, :channel => channel, :list => list, :max => max
254     end
255   end
256 end
257
258 plugin = UrlPlugin.new
259 plugin.map 'urls info *urls', :action => 'info'
260 plugin.map 'url info *urls', :action => 'info'
261 plugin.map 'urls search :channel :limit :string', :action => 'search',
262                           :defaults => {:limit => 4},
263                           :requirements => {:limit => /^\d+$/},
264                           :public => false
265 plugin.map 'urls search :limit :string', :action => 'search',
266                           :defaults => {:limit => 4},
267                           :requirements => {:limit => /^\d+$/},
268                           :private => false
269 plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
270                           :requirements => {:limit => /^\d+$/},
271                           :public => false
272 plugin.map 'urls :limit', :defaults => {:limit => 4},
273                           :requirements => {:limit => /^\d+$/},
274                           :private => false