]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/url.rb
ca24f072d4257943fe2bb09bb0b955a09b96a27e
[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 => true, 
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 \r
316   def read_data_from_response(response, amount)\r
317     \r
318     amount_read = 0\r
319     chunks = []\r
320     \r
321     response.read_body do |chunk|   # read body now\r
322       \r
323       amount_read += chunk.length\r
324       \r
325       if amount_read > amount\r
326         amount_of_overflow = amount_read - amount\r
327         chunk = chunk[0...-amount_of_overflow]\r
328       end\r
329       \r
330       chunks << chunk\r
331 \r
332       break if amount_read >= amount\r
333       \r
334     end\r
335     \r
336     chunks.join('')\r
337     \r
338   end\r
339 \r
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     puts "+ Getting #{uri_str}"
350     url = URI.parse(uri_str)
351     return if url.scheme !~ /https?/
352     
353     puts "+ connecting to #{url.host}:#{url.port}"
354     http = @bot.httputil.get_proxy(url)
355     title = http.start { |http|
356       url.path = '/' if url.path == ''\r
357 \r
358       http.request_get(url.path) { |response|\r
359         
360         case response
361           when Net::HTTPRedirection then
362             # call self recursively if this is a redirect
363             redirect_to = response['location']  || './'
364             puts "+ redirect location: #{redirect_to.inspect}"
365             url = URI.join url.to_s, redirect_to
366             puts "+ whee, redirecting to #{url.to_s}!"
367             title = get_title_for_url(url.to_s, depth-1)
368           when Net::HTTPSuccess then
369             if response['content-type'] =~ /^text\//
370               # since the content is 'text/*' and is small enough to
371               # be a webpage, retrieve the title from the page
372               puts "+ getting #{url.request_uri}"\r
373               data = read_data_from_response(response, 50000)\r
374               return get_title_from_html(data)
375             else
376               # content doesn't have title, just display info.
377               size = response['content-length'].gsub(/(\d)(?=\d{3}+(?:\.|$))(\d{3}\..*)?/,'\1,\2')
378               return "[Link Info] type: #{response['content-type']}#{size ? ", size: #{size} bytes" : ""}"
379             end
380           when Net::HTTPClientError then
381             return "[Link Info] Error getting link (#{response.code} - #{response.message})"
382           when Net::HTTPServerError then
383             return "[Link Info] Error getting link (#{response.code} - #{response.message})"
384         end # end of "case response"\r
385           \r
386       } # end of request block
387     } # end of http start block\r
388     
389   rescue SocketError => e
390     return "[Link Info] Error connecting to site (#{e.message})"
391   end
392
393   def listen(m)
394     return unless m.kind_of?(PrivMessage)
395     return if m.address?
396     # TODO support multiple urls in one line
397     if m.message =~ /(f|ht)tps?:\/\//
398       if m.message =~ /((f|ht)tps?:\/\/.*?)(?:\s+|$)/
399         urlstr = $1
400         list = @registry[m.target]
401
402         if @bot.config['url.display_link_info']
403           debug "Getting title for #{urlstr}..."
404           title = get_title_for_url urlstr
405           if title
406             m.reply title
407             debug "Title found!"
408           else
409             debug "Title not found!"
410           end        
411         end
412     
413         # check to see if this url is already listed
414         return if list.find {|u| u.url == urlstr }
415         
416         url = Url.new(m.target, m.sourcenick, Time.new, urlstr)
417         debug "#{list.length} urls so far"
418         if list.length > @bot.config['url.max_urls']
419           list.pop
420         end
421         debug "storing url #{url.url}"
422         list.unshift url
423         debug "#{list.length} urls now"
424         @registry[m.target] = list
425       end
426     end
427   end
428
429   def urls(m, params)
430     channel = params[:channel] ? params[:channel] : m.target
431     max = params[:limit].to_i
432     max = 10 if max > 10
433     max = 1 if max < 1
434     list = @registry[channel]
435     if list.empty?
436       m.reply "no urls seen yet for channel #{channel}"
437     else
438       list[0..(max-1)].each do |url|
439         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
440       end
441     end
442   end
443
444   def search(m, params)
445     channel = params[:channel] ? params[:channel] : m.target
446     max = params[:limit].to_i
447     string = params[:string]
448     max = 10 if max > 10
449     max = 1 if max < 1
450     regex = Regexp.new(string, Regexp::IGNORECASE)
451     list = @registry[channel].find_all {|url|
452       regex.match(url.url) || regex.match(url.nick)
453     }
454     if list.empty?
455       m.reply "no matches for channel #{channel}"
456     else
457       list[0..(max-1)].each do |url|
458         m.reply "[#{url.time.strftime('%Y/%m/%d %H:%M:%S')}] <#{url.nick}> #{url.url}"
459       end
460     end
461   end
462 end
463 plugin = UrlPlugin.new
464 plugin.map 'urls search :channel :limit :string', :action => 'search',
465                           :defaults => {:limit => 4},
466                           :requirements => {:limit => /^\d+$/},
467                           :public => false
468 plugin.map 'urls search :limit :string', :action => 'search',
469                           :defaults => {:limit => 4},
470                           :requirements => {:limit => /^\d+$/},
471                           :private => false
472 plugin.map 'urls :channel :limit', :defaults => {:limit => 4},
473                           :requirements => {:limit => /^\d+$/},
474                           :public => false
475 plugin.map 'urls :limit', :defaults => {:limit => 4},
476                           :requirements => {:limit => /^\d+$/},
477                           :private => false