]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/url.rb
slashdot plugin: fix filter for multiple articles
[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, params={})
126     opts = {
127       :display_info => @bot.config['url.display_link_info'],
128       :channels => @bot.config['url.only_on_channels']
129     }.merge params
130     urls = opts[:urls]
131     display_info= opts[:display_info]
132     channels = opts[:channels]
133     unless channels.empty?
134       return unless channels.map { |c| c.downcase }.include?(m.channel.downcase)
135     end
136
137     return if urls.empty?
138     debug "found urls #{urls.inspect}"
139     list = m.public? ? @registry[m.target] : nil
140     debug "display link info: #{display_info}"
141     urls_displayed = 0
142     urls.each do |urlstr|
143       debug "working on #{urlstr}"
144       next unless urlstr =~ /^https?:\/\/./
145       title = nil
146       debug "Getting title for #{urlstr}..."
147       reply = nil
148       begin
149         title = get_title_for_url(urlstr,
150                                   :nick => m.source.nick,
151                                   :channel => m.channel,
152                                   :ircline => m.message)
153         debug "Title #{title ? '' : 'not '} found"
154         reply = "#{LINK_INFO} #{title}" if title
155       rescue => e
156         debug e
157         # we might get a 404 because of trailing punctuation, so we try again
158         # with the last character stripped. this might generate invalid URIs
159         # (e.g. because "some.url" gets chopped to some.url%2, so catch that too
160         if e.message =~ /\(404 - Not Found\)/i or e.kind_of?(URI::InvalidURIError)
161           # chop off last character, and retry if we still have enough string to
162           # look like a minimal URL
163           retry if urlstr.chop! and urlstr =~ /^https?:\/\/./
164         end
165         reply = "Error #{e.message}"
166       end
167
168       if display_info > urls_displayed
169         if reply
170           m.reply reply, :overlong => :truncate, :to => :public,
171             :nick => (m.address? ? :auto : false)
172           urls_displayed += 1
173         end
174       end
175
176       next unless list
177
178       # check to see if this url is already listed
179       next if list.find {|u| u.url == urlstr }
180
181       url = Url.new(m.target, m.sourcenick, Time.new, urlstr, title)
182       debug "#{list.length} urls so far"
183       list.pop if list.length > @bot.config['url.max_urls']
184       debug "storing url #{url.url}"
185       list.unshift url
186       debug "#{list.length} urls now"
187     end
188     @registry[m.target] = list
189   end
190
191   def info(m, params)
192     escaped = URI.escape(params[:urls].to_s, OUR_UNSAFE)
193     urls = URI.extract(escaped)
194     Thread.new do
195       handle_urls(m,
196                   :urls => urls,
197                   :display_info => params[:urls].length,
198                   :channels => [])
199     end
200   end
201
202   def message(m)
203     return if m.address?
204
205     escaped = URI.escape(m.message, OUR_UNSAFE)
206     urls = URI.extract(escaped, ['http', 'https'])
207     return if urls.empty?
208     Thread.new { handle_urls(m, :urls => urls) }
209   end
210
211   def reply_urls(opts={})
212     list = opts[:list]
213     max = opts[:max]
214     channel = opts[:channel]
215     m = opts[:msg]
216     return unless list and max and m
217     list[0..(max-1)].each do |url|
218       disp = "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
219       if @bot.config['url.info_on_list']
220         title = url.info ||
221           get_title_for_url(url.url,
222                             :nick => url.nick, :channel => channel) rescue nil
223         # If the url info was missing and we now have some, try to upgrade it
224         if channel and title and not url.info
225           ll = @registry[channel]
226           debug ll
227           if el = ll.find { |u| u.url == url.url }
228             el.info = title
229             @registry[channel] = ll
230           end
231         end
232         disp << " --> #{title}" if title
233       end
234       m.reply disp, :overlong => :truncate
235     end
236   end
237
238   def urls(m, params)
239     channel = params[:channel] ? params[:channel] : m.target
240     max = params[:limit].to_i
241     max = 10 if max > 10
242     max = 1 if max < 1
243     list = @registry[channel]
244     if list.empty?
245       m.reply "no urls seen yet for channel #{channel}"
246     else
247       reply_urls :msg => m, :channel => channel, :list => list, :max => max
248     end
249   end
250
251   def search(m, params)
252     channel = params[:channel] ? params[:channel] : m.target
253     max = params[:limit].to_i
254     string = params[:string]
255     max = 10 if max > 10
256     max = 1 if max < 1
257     regex = Regexp.new(string, Regexp::IGNORECASE)
258     list = @registry[channel].find_all {|url|
259       regex.match(url.url) || regex.match(url.nick) ||
260         (@bot.config['url.info_on_list'] && regex.match(url.info))
261     }
262     if list.empty?
263       m.reply "no matches for channel #{channel}"
264     else
265       reply_urls :msg => m, :channel => channel, :list => list, :max => max
266     end
267   end
268 end
269
270 plugin = UrlPlugin.new
271 plugin.map 'urls info *urls', :action => 'info'
272 plugin.map 'url info *urls', :action => 'info'
273 plugin.map 'urls search :channel :limit :string', :action => 'search',
274                           :defaults => {:limit => 4},
275                           :requirements => {:limit => /^\d+$/},
276                           :public => false
277 plugin.map 'urls search :limit :string', :action => 'search',
278                           :defaults => {:limit => 4},
279                           :requirements => {:limit => /^\d+$/},
280                           :private => false
281 plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
282                           :requirements => {:limit => /^\d+$/},
283                           :public => false
284 plugin.map 'urls :limit', :defaults => {:limit => 4},
285                           :requirements => {:limit => /^\d+$/},
286                           :private => false