]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/url.rb
0b0f87c7a2a9e931ce00382e0e90eba10295fe4f
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / url.rb
1 require 'net/http'
2 require 'uri'
3 require 'cgi'
4
5 Url = Struct.new("Url", :channel, :nick, :time, :url)
6 TITLE_RE = /<\s*?title\s*?>(.+?)<\s*?\/title\s*?>/im
7
8 UNESCAPE_TABLE = {
9     'raquo' => '>>',
10     'quot' => '"',
11     'micro' => 'u',
12     'copy' => '(c)',
13     'trade' => '(tm)',
14     'reg' => '(R)',
15     '#174' => '(R)',
16     '#8220' => '"',
17     '#8221' => '"',
18     '#8212' => '--',
19     '#39' => '\'',
20 =begin
21     # extras codes, for future use...
22     'zwnj' => '&#8204;',
23     'aring' => '\xe5',
24     'gt' => '>',
25     'yen' => '\xa5',
26     'ograve' => '\xf2',
27     'Chi' => '&#935;',
28     'bull' => '&#8226;',
29     'Egrave' => '\xc8',
30     'Ntilde' => '\xd1',
31     'upsih' => '&#978;',
32     'Yacute' => '\xdd',
33     'asymp' => '&#8776;',
34     'radic' => '&#8730;',
35     'otimes' => '&#8855;',
36     'nabla' => '&#8711;',
37     'aelig' => '\xe6',
38     'oelig' => '&#339;',
39     'equiv' => '&#8801;',
40     'Psi' => '&#936;',
41     'auml' => '\xe4',
42     'circ' => '&#710;',
43     'Acirc' => '\xc2',
44     'Epsilon' => '&#917;',
45     'Yuml' => '&#376;',
46     'Eta' => '&#919;',
47     'lt' => '<',
48     'Icirc' => '\xce',
49     'Upsilon' => '&#933;',
50     'ndash' => '&#8211;',
51     'there4' => '&#8756;',
52     'Prime' => '&#8243;',
53     'prime' => '&#8242;',
54     'psi' => '&#968;',
55     'Kappa' => '&#922;',
56     'rsaquo' => '&#8250;',
57     'Tau' => '&#932;',
58     'darr' => '&#8595;',
59     'ocirc' => '\xf4',
60     'lrm' => '&#8206;',
61     'zwj' => '&#8205;',
62     'cedil' => '\xb8',
63     'Ecirc' => '\xca',
64     'not' => '\xac',
65     'amp' => '&',
66     'AElig' => '\xc6',
67     'oslash' => '\xf8',
68     'acute' => '\xb4',
69     'lceil' => '&#8968;',
70     'laquo' => '\xab',
71     'shy' => '\xad',
72     'rdquo' => '&#8221;',
73     'ge' => '&#8805;',
74     'Igrave' => '\xcc',
75     'Ograve' => '\xd2',
76     'euro' => '&#8364;',
77     'dArr' => '&#8659;',
78     'sdot' => '&#8901;',
79     'nbsp' => '\xa0',
80     'lfloor' => '&#8970;',
81     'lArr' => '&#8656;',
82     'Auml' => '\xc4',
83     'larr' => '&#8592;',
84     'Atilde' => '\xc3',
85     'Otilde' => '\xd5',
86     'szlig' => '\xdf',
87     'clubs' => '&#9827;',
88     'diams' => '&#9830;',
89     'agrave' => '\xe0',
90     'Ocirc' => '\xd4',
91     'Iota' => '&#921;',
92     'Theta' => '&#920;',
93     'Pi' => '&#928;',
94     'OElig' => '&#338;',
95     'Scaron' => '&#352;',
96     'frac14' => '\xbc',
97     'egrave' => '\xe8',
98     'sub' => '&#8834;',
99     'iexcl' => '\xa1',
100     'frac12' => '\xbd',
101     'sbquo' => '&#8218;',
102     'ordf' => '\xaa',
103     'sum' => '&#8721;',
104     'prop' => '&#8733;',
105     'Uuml' => '\xdc',
106     'ntilde' => '\xf1',
107     'sup' => '&#8835;',
108     'theta' => '&#952;',
109     'prod' => '&#8719;',
110     'nsub' => '&#8836;',
111     'hArr' => '&#8660;',
112     'rlm' => '&#8207;',
113     'THORN' => '\xde',
114     'infin' => '&#8734;',
115     'yuml' => '\xff',
116     'Mu' => '&#924;',
117     'le' => '&#8804;',
118     'Eacute' => '\xc9',
119     'thinsp' => '&#8201;',
120     'ecirc' => '\xea',
121     'bdquo' => '&#8222;',
122     'Sigma' => '&#931;',
123     'fnof' => '&#402;',
124     'Aring' => '\xc5',
125     'tilde' => '&#732;',
126     'frac34' => '\xbe',
127     'emsp' => '&#8195;',
128     'mdash' => '&#8212;',
129     'uarr' => '&#8593;',
130     'permil' => '&#8240;',
131     'Ugrave' => '\xd9',
132     'rarr' => '&#8594;',
133     'Agrave' => '\xc0',
134     'chi' => '&#967;',
135     'forall' => '&#8704;',
136     'eth' => '\xf0',
137     'rceil' => '&#8969;',
138     'iuml' => '\xef',
139     'gamma' => '&#947;',
140     'lambda' => '&#955;',
141     'harr' => '&#8596;',
142     'rang' => '&#9002;',
143     'xi' => '&#958;',
144     'dagger' => '&#8224;',
145     'divide' => '\xf7',
146     'Ouml' => '\xd6',
147     'image' => '&#8465;',
148     'alefsym' => '&#8501;',
149     'igrave' => '\xec',
150     'otilde' => '\xf5',
151     'Oacute' => '\xd3',
152     'sube' => '&#8838;',
153     'alpha' => '&#945;',
154     'frasl' => '&#8260;',
155     'ETH' => '\xd0',
156     'lowast' => '&#8727;',
157     'Nu' => '&#925;',
158     'plusmn' => '\xb1',
159     'Euml' => '\xcb',
160     'real' => '&#8476;',
161     'sup1' => '\xb9',
162     'sup2' => '\xb2',
163     'sup3' => '\xb3',
164     'Oslash' => '\xd8',
165     'Aacute' => '\xc1',
166     'cent' => '\xa2',
167     'oline' => '&#8254;',
168     'Beta' => '&#914;',
169     'perp' => '&#8869;',
170     'Delta' => '&#916;',
171     'loz' => '&#9674;',
172     'pi' => '&#960;',
173     'iota' => '&#953;',
174     'empty' => '&#8709;',
175     'euml' => '\xeb',
176     'brvbar' => '\xa6',
177     'iacute' => '\xed',
178     'para' => '\xb6',
179     'micro' => '\xb5',
180     'cup' => '&#8746;',
181     'weierp' => '&#8472;',
182     'uuml' => '\xfc',
183     'part' => '&#8706;',
184     'icirc' => '\xee',
185     'delta' => '&#948;',
186     'omicron' => '&#959;',
187     'upsilon' => '&#965;',
188     'Iuml' => '\xcf',
189     'Lambda' => '&#923;',
190     'Xi' => '&#926;',
191     'kappa' => '&#954;',
192     'ccedil' => '\xe7',
193     'Ucirc' => '\xdb',
194     'cap' => '&#8745;',
195     'mu' => '&#956;',
196     'scaron' => '&#353;',
197     'lsquo' => '&#8216;',
198     'isin' => '&#8712;',
199     'Zeta' => '&#918;',
200     'supe' => '&#8839;',
201     'deg' => '\xb0',
202     'and' => '&#8743;',
203     'tau' => '&#964;',
204     'pound' => '\xa3',
205     'hellip' => '&#8230;',
206     'curren' => '\xa4',
207     'int' => '&#8747;',
208     'ucirc' => '\xfb',
209     'rfloor' => '&#8971;',
210     'ensp' => '&#8194;',
211     'crarr' => '&#8629;',
212     'ugrave' => '\xf9',
213     'notin' => '&#8713;',
214     'exist' => '&#8707;',
215     'uArr' => '&#8657;',
216     'cong' => '&#8773;',
217     'Dagger' => '&#8225;',
218     'oplus' => '&#8853;',
219     'times' => '\xd7',
220     'atilde' => '\xe3',
221     'piv' => '&#982;',
222     'ni' => '&#8715;',
223     'Phi' => '&#934;',
224     'lsaquo' => '&#8249;',
225     'Uacute' => '\xda',
226     'Omicron' => '&#927;',
227     'ang' => '&#8736;',
228     'ne' => '&#8800;',
229     'iquest' => '\xbf',
230     'eta' => '&#951;',
231     'yacute' => '\xfd',
232     'Rho' => '&#929;',
233     'uacute' => '\xfa',
234     'Alpha' => '&#913;',
235     'zeta' => '&#950;',
236     'Omega' => '&#937;',
237     'nu' => '&#957;',
238     'sim' => '&#8764;',
239     'sect' => '\xa7',
240     'phi' => '&#966;',
241     'sigmaf' => '&#962;',
242     'macr' => '\xaf',
243     'minus' => '&#8722;',
244     'Ccedil' => '\xc7',
245     'ordm' => '\xba',
246     'epsilon' => '&#949;',
247     'beta' => '&#946;',
248     'rArr' => '&#8658;',
249     'rho' => '&#961;',
250     'aacute' => '\xe1',
251     'eacute' => '\xe9',
252     'omega' => '&#969;',
253     'middot' => '\xb7',
254     'Gamma' => '&#915;',
255     'Iacute' => '\xcd',
256     'lang' => '&#9001;',
257     'spades' => '&#9824;',
258     'rsquo' => '&#8217;',
259     'uml' => '\xa8',
260     'thorn' => '\xfe',
261     'ouml' => '\xf6',
262     'thetasym' => '&#977;',
263     'or' => '&#8744;',
264     'raquo' => '\xbb',
265     'acirc' => '\xe2',
266     'ldquo' => '&#8220;',
267     'hearts' => '&#9829;',
268     'sigma' => '&#963;',
269     'oacute' => '\xf3',
270 =end
271 }
272
273 class UrlPlugin < Plugin
274   BotConfig.register BotConfigIntegerValue.new('url.max_urls',
275     :default => 100, :validate => Proc.new{|v| v > 0},
276     :desc => "Maximum number of urls to store. New urls replace oldest ones.")
277   BotConfig.register BotConfigBooleanValue.new('url.display_link_info',
278     :default => false, 
279     :desc => "Get the title of any links pasted to the channel and display it (also tells if the link is broken or the site is down)")
280   
281   def initialize
282     super
283     @registry.set_default(Array.new)
284   end
285
286   def help(plugin, topic="")
287     "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>"
288   end
289
290   def unescape_title(htmldata)
291     # first pass -- let CGI try to attack it...
292     htmldata = CGI::unescapeHTML htmldata
293     
294     # second pass -- destroy the remaining bits...
295     htmldata.gsub(/(&(.+?);)/) {
296         symbol = $2
297         
298         # remove the 0-paddng from unicode integers
299         if symbol =~ /#(.+)/
300             symbol = "##{$1.to_i.to_s}"
301         end
302         
303         # output the symbol's irc-translated character, or a * if it's unknown
304         UNESCAPE_TABLE[symbol] || '*'
305     }
306   end
307
308   def get_title_from_html(pagedata)
309     return unless TITLE_RE.match(pagedata)
310     title = $1.strip.gsub(/\s*\n+\s*/, " ")
311     title = unescape_title title
312     title = title[0..255] if title.length > 255
313     "[Link Info] title: #{title}"
314   end
315
316   def read_data_from_response(response, amount)
317     
318     amount_read = 0
319     chunks = []
320     
321     response.read_body do |chunk|   # read body now
322       
323       amount_read += chunk.length
324       
325       if amount_read > amount
326         amount_of_overflow = amount_read - amount
327         chunk = chunk[0...-amount_of_overflow]
328       end
329       
330       chunks << chunk
331
332       break if amount_read >= amount
333       
334     end
335     
336     chunks.join('')
337     
338   end
339
340
341   def get_title_for_url(uri_str, depth=@bot.config['http.max_redir'])
342     # This god-awful mess is what the ruby http library has reduced me to.
343     # Python's HTTP lib is so much nicer. :~(
344     
345     if depth == 0
346         raise "Error: Maximum redirects hit."
347     end
348     
349     debug "+ Getting #{uri_str.to_s}"
350     url = uri_str.kind_of?(URI) ? uri_str : URI.parse(uri_str)
351     return if url.scheme !~ /https?/
352
353     title = nil
354     
355     debug "+ connecting to #{url.host}:#{url.port}"
356     http = @bot.httputil.get_proxy(url)
357     http.start { |http|
358
359       http.request_get(url.request_uri(), @bot.httputil.headers) { |response|
360         
361         case response
362           when Net::HTTPRedirection
363             # call self recursively if this is a redirect
364             redirect_to = response['location']  || '/'
365             debug "+ redirect location: #{redirect_to.inspect}"
366             url = URI.join(url.to_s, redirect_to)
367             debug "+ whee, redirecting to #{url.to_s}!"
368             return get_title_for_url(url, depth-1)
369           when Net::HTTPSuccess
370             if response['content-type'] =~ /^text\//
371               # since the content is 'text/*' and is small enough to
372               # be a webpage, retrieve the title from the page
373               debug "+ getting #{url.request_uri}"
374               # was 5*10^4 ... seems to much to me ... 4k should be enough for everybody ;)
375               data = read_data_from_response(response, 4096)
376               return get_title_from_html(data)
377             else
378               # content doesn't have title, just display info.
379               size = response['content-length'].gsub(/(\d)(?=\d{3}+(?:\.|$))(\d{3}\..*)?/,'\1,\2')
380               size = size ? ", size: #{size} bytes" : ""
381               return "[Link Info] type: #{response['content-type']}#{size}"
382             end
383           else
384             return "[Link Info] Error getting link (#{response.code} - #{response.message})"
385           end # end of "case response"
386           
387       } # end of request block
388     } # end of http start block
389
390     return title
391     
392   rescue SocketError => e
393     return "[Link Info] Error connecting to site (#{e.message})"
394   end
395
396   def listen(m)
397     return unless m.kind_of?(PrivMessage)
398     return if m.address?
399     # TODO support multiple urls in one line
400     if m.message =~ /(f|ht)tps?:\/\//
401       if m.message =~ /((f|ht)tps?:\/\/.*?)(?:\s+|$)/
402         urlstr = $1
403         list = @registry[m.target]
404
405         if @bot.config['url.display_link_info']
406           debug "Getting title for #{urlstr}..."
407           title = get_title_for_url urlstr
408           if title
409             m.reply title
410             debug "Title found!"
411           else
412             debug "Title not found!"
413           end        
414         end
415     
416         # check to see if this url is already listed
417         return if list.find {|u| u.url == urlstr }
418         
419         url = Url.new(m.target, m.sourcenick, Time.new, urlstr)
420         debug "#{list.length} urls so far"
421         if list.length > @bot.config['url.max_urls']
422           list.pop
423         end
424         debug "storing url #{url.url}"
425         list.unshift url
426         debug "#{list.length} urls now"
427         @registry[m.target] = list
428       end
429     end
430   end
431
432   def urls(m, params)
433     channel = params[:channel] ? params[:channel] : m.target
434     max = params[:limit].to_i
435     max = 10 if max > 10
436     max = 1 if max < 1
437     list = @registry[channel]
438     if list.empty?
439       m.reply "no urls seen yet for channel #{channel}"
440     else
441       list[0..(max-1)].each do |url|
442         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
443       end
444     end
445   end
446
447   def search(m, params)
448     channel = params[:channel] ? params[:channel] : m.target
449     max = params[:limit].to_i
450     string = params[:string]
451     max = 10 if max > 10
452     max = 1 if max < 1
453     regex = Regexp.new(string, Regexp::IGNORECASE)
454     list = @registry[channel].find_all {|url|
455       regex.match(url.url) || regex.match(url.nick)
456     }
457     if list.empty?
458       m.reply "no matches for channel #{channel}"
459     else
460       list[0..(max-1)].each do |url|
461         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
462       end
463     end
464   end
465 end
466 plugin = UrlPlugin.new
467 plugin.map 'urls search :channel :limit :string', :action => 'search',
468                           :defaults => {:limit => 4},
469                           :requirements => {:limit => /^\d+$/},
470                           :public => false
471 plugin.map 'urls search :limit :string', :action => 'search',
472                           :defaults => {:limit => 4},
473                           :requirements => {:limit => /^\d+$/},
474                           :private => false
475 plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
476                           :requirements => {:limit => /^\d+$/},
477                           :public => false
478 plugin.map 'urls :limit', :defaults => {:limit => 4},
479                           :requirements => {:limit => /^\d+$/},
480                           :private => false