]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/url.rb
[257] was moot, this is the propert way to prevent Structs from being created twice
[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) unless defined?(Struct::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=10)
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}"
350     url = 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       url.path = '/' if url.path == ''
359
360       http.request_get(url.path, "User-Agent" => "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)") { |response|
361         
362         case response
363           when Net::HTTPRedirection, Net::HTTPMovedPermanently then
364             # call self recursively if this is a redirect
365             redirect_to = response['location']  || './'
366             debug "+ redirect location: #{redirect_to.inspect}"
367             url = URI.join url.to_s, redirect_to
368             debug "+ whee, redirecting to #{url.to_s}!"
369             return get_title_for_url(url.to_s, depth-1)
370           when Net::HTTPSuccess then
371             if response['content-type'] =~ /^text\//
372               # since the content is 'text/*' and is small enough to
373               # be a webpage, retrieve the title from the page
374               debug "+ getting #{url.request_uri}"
375               data = read_data_from_response(response, 50000)
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               return "[Link Info] type: #{response['content-type']}#{size ? ", size: #{size} bytes" : ""}"
381             end
382           when Net::HTTPClientError then
383             return "[Link Info] Error getting link (#{response.code} - #{response.message})"
384           when Net::HTTPServerError then
385             return "[Link Info] Error getting link (#{response.code} - #{response.message})"
386           else
387             return nil
388         end # end of "case response"
389           
390       } # end of request block
391     } # end of http start block
392
393     return title
394     
395   rescue SocketError => e
396     return "[Link Info] Error connecting to site (#{e.message})"
397   end
398
399   def listen(m)
400     return unless m.kind_of?(PrivMessage)
401     return if m.address?
402     # TODO support multiple urls in one line
403     if m.message =~ /(f|ht)tps?:\/\//
404       if m.message =~ /((f|ht)tps?:\/\/.*?)(?:\s+|$)/
405         urlstr = $1
406         list = @registry[m.target]
407
408         if @bot.config['url.display_link_info']
409           debug "Getting title for #{urlstr}..."
410           title = get_title_for_url urlstr
411           if title
412             m.reply title
413             debug "Title found!"
414           else
415             debug "Title not found!"
416           end        
417         end
418     
419         # check to see if this url is already listed
420         return if list.find {|u| u.url == urlstr }
421         
422         url = Url.new(m.target, m.sourcenick, Time.new, urlstr)
423         debug "#{list.length} urls so far"
424         if list.length > @bot.config['url.max_urls']
425           list.pop
426         end
427         debug "storing url #{url.url}"
428         list.unshift url
429         debug "#{list.length} urls now"
430         @registry[m.target] = list
431       end
432     end
433   end
434
435   def urls(m, params)
436     channel = params[:channel] ? params[:channel] : m.target
437     max = params[:limit].to_i
438     max = 10 if max > 10
439     max = 1 if max < 1
440     list = @registry[channel]
441     if list.empty?
442       m.reply "no urls seen yet for channel #{channel}"
443     else
444       list[0..(max-1)].each do |url|
445         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
446       end
447     end
448   end
449
450   def search(m, params)
451     channel = params[:channel] ? params[:channel] : m.target
452     max = params[:limit].to_i
453     string = params[:string]
454     max = 10 if max > 10
455     max = 1 if max < 1
456     regex = Regexp.new(string, Regexp::IGNORECASE)
457     list = @registry[channel].find_all {|url|
458       regex.match(url.url) || regex.match(url.nick)
459     }
460     if list.empty?
461       m.reply "no matches for channel #{channel}"
462     else
463       list[0..(max-1)].each do |url|
464         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
465       end
466     end
467   end
468 end
469 plugin = UrlPlugin.new
470 plugin.map 'urls search :channel :limit :string', :action => 'search',
471                           :defaults => {:limit => 4},
472                           :requirements => {:limit => /^\d+$/},
473                           :public => false
474 plugin.map 'urls search :limit :string', :action => 'search',
475                           :defaults => {:limit => 4},
476                           :requirements => {:limit => /^\d+$/},
477                           :private => false
478 plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
479                           :requirements => {:limit => /^\d+$/},
480                           :public => false
481 plugin.map 'urls :limit', :defaults => {:limit => 4},
482                           :requirements => {:limit => /^\d+$/},
483                           :private => false