Module\#define_structure method: define a new Struct only if doesn't exist already...
[rbot] / lib / rbot / core / utils / utils.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: rbot utilities provider
5 #
6 # Author:: Tom Gilbert <tom@linuxbrit.co.uk>
7 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
8 #
9 # Copyright:: (C) 2002-2006 Tom Gilbert
10 # Copyright:: (C) 2007 Giuseppe Bilotta
11 #
12 # TODO some of these Utils should be rewritten as extensions to the approriate
13 # standard Ruby classes and accordingly be moved to extends.rb
14
15 require 'tempfile'
16 require 'set'
17
18 begin
19   require 'htmlentities'
20   $we_have_html_entities_decoder = true
21 rescue LoadError
22   gems = nil
23   begin
24     gems = require 'rubygems'
25   rescue LoadError
26     gems = false
27   end
28   if gems
29     retry
30   else
31     $we_have_html_entities_decoder = false
32     module ::Irc
33       module Utils
34         UNESCAPE_TABLE = {
35     'laquo' => '<<',
36     'raquo' => '>>',
37     'quot' => '"',
38     'apos' => '\'',
39     'micro' => 'u',
40     'copy' => '(c)',
41     'trade' => '(tm)',
42     'reg' => '(R)',
43     '#174' => '(R)',
44     '#8220' => '"',
45     '#8221' => '"',
46     '#8212' => '--',
47     '#39' => '\'',
48     'amp' => '&',
49     'lt' => '<',
50     'gt' => '>',
51     'hellip' => '...',
52     'nbsp' => ' ',
53 =begin
54     # extras codes, for future use...
55     'zwnj' => '&#8204;',
56     'aring' => '\xe5',
57     'gt' => '>',
58     'yen' => '\xa5',
59     'ograve' => '\xf2',
60     'Chi' => '&#935;',
61     'bull' => '&#8226;',
62     'Egrave' => '\xc8',
63     'Ntilde' => '\xd1',
64     'upsih' => '&#978;',
65     'Yacute' => '\xdd',
66     'asymp' => '&#8776;',
67     'radic' => '&#8730;',
68     'otimes' => '&#8855;',
69     'nabla' => '&#8711;',
70     'aelig' => '\xe6',
71     'oelig' => '&#339;',
72     'equiv' => '&#8801;',
73     'Psi' => '&#936;',
74     'auml' => '\xe4',
75     'circ' => '&#710;',
76     'Acirc' => '\xc2',
77     'Epsilon' => '&#917;',
78     'Yuml' => '&#376;',
79     'Eta' => '&#919;',
80     'Icirc' => '\xce',
81     'Upsilon' => '&#933;',
82     'ndash' => '&#8211;',
83     'there4' => '&#8756;',
84     'Prime' => '&#8243;',
85     'prime' => '&#8242;',
86     'psi' => '&#968;',
87     'Kappa' => '&#922;',
88     'rsaquo' => '&#8250;',
89     'Tau' => '&#932;',
90     'darr' => '&#8595;',
91     'ocirc' => '\xf4',
92     'lrm' => '&#8206;',
93     'zwj' => '&#8205;',
94     'cedil' => '\xb8',
95     'Ecirc' => '\xca',
96     'not' => '\xac',
97     'AElig' => '\xc6',
98     'oslash' => '\xf8',
99     'acute' => '\xb4',
100     'lceil' => '&#8968;',
101     'shy' => '\xad',
102     'rdquo' => '&#8221;',
103     'ge' => '&#8805;',
104     'Igrave' => '\xcc',
105     'Ograve' => '\xd2',
106     'euro' => '&#8364;',
107     'dArr' => '&#8659;',
108     'sdot' => '&#8901;',
109     'nbsp' => '\xa0',
110     'lfloor' => '&#8970;',
111     'lArr' => '&#8656;',
112     'Auml' => '\xc4',
113     'larr' => '&#8592;',
114     'Atilde' => '\xc3',
115     'Otilde' => '\xd5',
116     'szlig' => '\xdf',
117     'clubs' => '&#9827;',
118     'diams' => '&#9830;',
119     'agrave' => '\xe0',
120     'Ocirc' => '\xd4',
121     'Iota' => '&#921;',
122     'Theta' => '&#920;',
123     'Pi' => '&#928;',
124     'OElig' => '&#338;',
125     'Scaron' => '&#352;',
126     'frac14' => '\xbc',
127     'egrave' => '\xe8',
128     'sub' => '&#8834;',
129     'iexcl' => '\xa1',
130     'frac12' => '\xbd',
131     'sbquo' => '&#8218;',
132     'ordf' => '\xaa',
133     'sum' => '&#8721;',
134     'prop' => '&#8733;',
135     'Uuml' => '\xdc',
136     'ntilde' => '\xf1',
137     'sup' => '&#8835;',
138     'theta' => '&#952;',
139     'prod' => '&#8719;',
140     'nsub' => '&#8836;',
141     'hArr' => '&#8660;',
142     'rlm' => '&#8207;',
143     'THORN' => '\xde',
144     'infin' => '&#8734;',
145     'yuml' => '\xff',
146     'Mu' => '&#924;',
147     'le' => '&#8804;',
148     'Eacute' => '\xc9',
149     'thinsp' => '&#8201;',
150     'ecirc' => '\xea',
151     'bdquo' => '&#8222;',
152     'Sigma' => '&#931;',
153     'fnof' => '&#402;',
154     'Aring' => '\xc5',
155     'tilde' => '&#732;',
156     'frac34' => '\xbe',
157     'emsp' => '&#8195;',
158     'mdash' => '&#8212;',
159     'uarr' => '&#8593;',
160     'permil' => '&#8240;',
161     'Ugrave' => '\xd9',
162     'rarr' => '&#8594;',
163     'Agrave' => '\xc0',
164     'chi' => '&#967;',
165     'forall' => '&#8704;',
166     'eth' => '\xf0',
167     'rceil' => '&#8969;',
168     'iuml' => '\xef',
169     'gamma' => '&#947;',
170     'lambda' => '&#955;',
171     'harr' => '&#8596;',
172     'rang' => '&#9002;',
173     'xi' => '&#958;',
174     'dagger' => '&#8224;',
175     'divide' => '\xf7',
176     'Ouml' => '\xd6',
177     'image' => '&#8465;',
178     'alefsym' => '&#8501;',
179     'igrave' => '\xec',
180     'otilde' => '\xf5',
181     'Oacute' => '\xd3',
182     'sube' => '&#8838;',
183     'alpha' => '&#945;',
184     'frasl' => '&#8260;',
185     'ETH' => '\xd0',
186     'lowast' => '&#8727;',
187     'Nu' => '&#925;',
188     'plusmn' => '\xb1',
189     'Euml' => '\xcb',
190     'real' => '&#8476;',
191     'sup1' => '\xb9',
192     'sup2' => '\xb2',
193     'sup3' => '\xb3',
194     'Oslash' => '\xd8',
195     'Aacute' => '\xc1',
196     'cent' => '\xa2',
197     'oline' => '&#8254;',
198     'Beta' => '&#914;',
199     'perp' => '&#8869;',
200     'Delta' => '&#916;',
201     'loz' => '&#9674;',
202     'pi' => '&#960;',
203     'iota' => '&#953;',
204     'empty' => '&#8709;',
205     'euml' => '\xeb',
206     'brvbar' => '\xa6',
207     'iacute' => '\xed',
208     'para' => '\xb6',
209     'micro' => '\xb5',
210     'cup' => '&#8746;',
211     'weierp' => '&#8472;',
212     'uuml' => '\xfc',
213     'part' => '&#8706;',
214     'icirc' => '\xee',
215     'delta' => '&#948;',
216     'omicron' => '&#959;',
217     'upsilon' => '&#965;',
218     'Iuml' => '\xcf',
219     'Lambda' => '&#923;',
220     'Xi' => '&#926;',
221     'kappa' => '&#954;',
222     'ccedil' => '\xe7',
223     'Ucirc' => '\xdb',
224     'cap' => '&#8745;',
225     'mu' => '&#956;',
226     'scaron' => '&#353;',
227     'lsquo' => '&#8216;',
228     'isin' => '&#8712;',
229     'Zeta' => '&#918;',
230     'supe' => '&#8839;',
231     'deg' => '\xb0',
232     'and' => '&#8743;',
233     'tau' => '&#964;',
234     'pound' => '\xa3',
235     'hellip' => '&#8230;',
236     'curren' => '\xa4',
237     'int' => '&#8747;',
238     'ucirc' => '\xfb',
239     'rfloor' => '&#8971;',
240     'ensp' => '&#8194;',
241     'crarr' => '&#8629;',
242     'ugrave' => '\xf9',
243     'notin' => '&#8713;',
244     'exist' => '&#8707;',
245     'uArr' => '&#8657;',
246     'cong' => '&#8773;',
247     'Dagger' => '&#8225;',
248     'oplus' => '&#8853;',
249     'times' => '\xd7',
250     'atilde' => '\xe3',
251     'piv' => '&#982;',
252     'ni' => '&#8715;',
253     'Phi' => '&#934;',
254     'lsaquo' => '&#8249;',
255     'Uacute' => '\xda',
256     'Omicron' => '&#927;',
257     'ang' => '&#8736;',
258     'ne' => '&#8800;',
259     'iquest' => '\xbf',
260     'eta' => '&#951;',
261     'yacute' => '\xfd',
262     'Rho' => '&#929;',
263     'uacute' => '\xfa',
264     'Alpha' => '&#913;',
265     'zeta' => '&#950;',
266     'Omega' => '&#937;',
267     'nu' => '&#957;',
268     'sim' => '&#8764;',
269     'sect' => '\xa7',
270     'phi' => '&#966;',
271     'sigmaf' => '&#962;',
272     'macr' => '\xaf',
273     'minus' => '&#8722;',
274     'Ccedil' => '\xc7',
275     'ordm' => '\xba',
276     'epsilon' => '&#949;',
277     'beta' => '&#946;',
278     'rArr' => '&#8658;',
279     'rho' => '&#961;',
280     'aacute' => '\xe1',
281     'eacute' => '\xe9',
282     'omega' => '&#969;',
283     'middot' => '\xb7',
284     'Gamma' => '&#915;',
285     'Iacute' => '\xcd',
286     'lang' => '&#9001;',
287     'spades' => '&#9824;',
288     'rsquo' => '&#8217;',
289     'uml' => '\xa8',
290     'thorn' => '\xfe',
291     'ouml' => '\xf6',
292     'thetasym' => '&#977;',
293     'or' => '&#8744;',
294     'raquo' => '\xbb',
295     'acirc' => '\xe2',
296     'ldquo' => '&#8220;',
297     'hearts' => '&#9829;',
298     'sigma' => '&#963;',
299     'oacute' => '\xf3',
300 =end
301         }
302       end
303     end
304   end
305 end
306
307
308 module ::Irc
309
310   # miscellaneous useful functions
311   module Utils
312     @@bot = nil unless defined? @@bot
313     @@safe_save_dir = nil unless defined?(@@safe_save_dir)
314
315     def Utils.bot
316       @@bot
317     end
318
319     def Utils.bot=(b)
320       debug "initializing utils"
321       @@bot = b
322       @@safe_save_dir = "#{@@bot.botclass}/safe_save"
323     end
324
325
326     SEC_PER_MIN = 60
327     SEC_PER_HR = SEC_PER_MIN * 60
328     SEC_PER_DAY = SEC_PER_HR * 24
329     SEC_PER_MNTH = SEC_PER_DAY * 30
330     SEC_PER_YR = SEC_PER_MNTH * 12
331
332     def Utils.secs_to_string_case(array, var, string, plural)
333       case var
334       when 1
335         array << "1 #{string}"
336       else
337         array << "#{var} #{plural}"
338       end
339     end
340
341     # turn a number of seconds into a human readable string, e.g
342     # 2 days, 3 hours, 18 minutes, 10 seconds
343     def Utils.secs_to_string(secs)
344       ret = []
345       years, secs = secs.divmod SEC_PER_YR
346       secs_to_string_case(ret, years, "year", "years") if years > 0
347       months, secs = secs.divmod SEC_PER_MNTH
348       secs_to_string_case(ret, months, "month", "months") if months > 0
349       days, secs = secs.divmod SEC_PER_DAY
350       secs_to_string_case(ret, days, "day", "days") if days > 0
351       hours, secs = secs.divmod SEC_PER_HR
352       secs_to_string_case(ret, hours, "hour", "hours") if hours > 0
353       mins, secs = secs.divmod SEC_PER_MIN
354       secs_to_string_case(ret, mins, "minute", "minutes") if mins > 0
355       secs = secs.to_i
356       secs_to_string_case(ret, secs, "second", "seconds") if secs > 0 or ret.empty?
357       case ret.length
358       when 0
359         raise "Empty ret array!"
360       when 1
361         return ret.to_s
362       else
363         return [ret[0, ret.length-1].join(", ") , ret[-1]].join(" and ")
364       end
365     end
366
367
368     def Utils.safe_exec(command, *args)
369       IO.popen("-") {|p|
370         if(p)
371           return p.readlines.join("\n")
372         else
373           begin
374             $stderr.reopen($stdout)
375             exec(command, *args)
376           rescue Exception => e
377             puts "exec of #{command} led to exception: #{e.pretty_inspect}"
378             Kernel::exit! 0
379           end
380           puts "exec of #{command} failed"
381           Kernel::exit! 0
382         end
383       }
384     end
385
386
387     def Utils.safe_save(file)
388       raise 'No safe save directory defined!' if @@safe_save_dir.nil?
389       basename = File.basename(file)
390       temp = Tempfile.new(basename,@@safe_save_dir)
391       temp.binmode
392       yield temp if block_given?
393       temp.close
394       File.rename(temp.path, file)
395     end
396
397
398     def Utils.decode_html_entities(str)
399       if $we_have_html_entities_decoder
400         return HTMLEntities.decode_entities(str)
401       else
402         str.gsub(/(&(.+?);)/) {
403           symbol = $2
404           # remove the 0-paddng from unicode integers
405           if symbol =~ /#(.+)/
406             symbol = "##{$1.to_i.to_s}"
407           end
408
409           # output the symbol's irc-translated character, or a * if it's unknown
410           UNESCAPE_TABLE[symbol] || [symbol[/\d+/].to_i].pack("U") rescue '*'
411         }
412       end
413     end
414
415     HX_REGEX = /<h(\d)(?:\s+[^>]*)?>(.*?)<\/h\1>/im
416     PAR_REGEX = /<p(?:\s+[^>]*)?>.*?<\/?(?:p|div|html|body|table|td|tr)(?:\s+[^>]*)?>/im
417
418     # Some blogging and forum platforms use spans or divs with a 'body' or 'message' or 'text' in their class
419     # to mark actual text
420     AFTER_PAR1_REGEX = /<\w+\s+[^>]*(?:body|message|text)[^>]*>.*?<\/?(?:p|div|html|body|table|td|tr)(?:\s+[^>]*)?>/im
421
422     # At worst, we can try stuff which is comprised between two <br>
423     AFTER_PAR2_REGEX = /<br(?:\s+[^>]*)?\/?>.*?<\/?(?:br|p|div|html|body|table|td|tr)(?:\s+[^>]*)?\/?>/im
424
425     # Try to grab and IRCify the first HTML par (<p> tag) in the given string.
426     # If possible, grab the one after the first heading
427     #
428     # It is possible to pass some options to determine how the stripping
429     # occurs. Currently supported options are
430     #   * :strip => Regex or String to strip at the beginning of the obtained
431     #               text
432     #   * :min_spaces => Minimum number of spaces a paragraph should have
433     #
434     def Utils.ircify_first_html_par(xml_org, opts={})
435       xml = xml_org.gsub(/<!--.*?-->/m, '').gsub(/<script(?:\s+[^>]*)?>.*?<\/script>/im, "").gsub(/<style(?:\s+[^>]*)?>.*?<\/style>/im, "")
436
437       strip = opts[:strip]
438       strip = Regexp.new(/^#{Regexp.escape(strip)}/) if strip.kind_of?(String)
439
440       min_spaces = opts[:min_spaces] || 8
441       min_spaces = 0 if min_spaces < 0
442
443       txt = String.new
444
445       while true
446         debug "Minimum number of spaces: #{min_spaces}"
447         header_found = xml.match(HX_REGEX)
448         if header_found
449           header_found = $'
450           while txt.empty? or txt.count(" ") < min_spaces
451             candidate = header_found[PAR_REGEX]
452             break unless candidate
453             txt = candidate.ircify_html
454             header_found = $'
455             txt.sub!(strip, '') if strip
456             debug "(Hx attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
457           end
458         end
459
460         return txt unless txt.empty? or txt.count(" ") < min_spaces
461
462         # If we haven't found a first par yet, try to get it from the whole
463         # document
464         header_found = xml
465         while txt.empty? or txt.count(" ") < min_spaces
466           candidate = header_found[PAR_REGEX]
467           break unless candidate
468           txt = candidate.ircify_html
469           header_found = $'
470           txt.sub!(strip, '') if strip
471           debug "(par attempt) #{txt.inspect} has #{txt.count(" ")} spaces"
472         end
473
474         return txt unless txt.empty? or txt.count(" ") < min_spaces
475
476         # Nothing yet ... let's get drastic: we look for non-par elements too,
477         # but only for those that match something that we know is likely to
478         # contain text
479
480         # Attempt #1
481         header_found = xml
482         while txt.empty? or txt.count(" ") < min_spaces
483           candidate = header_found[AFTER_PAR1_REGEX]
484           break unless candidate
485           txt = candidate.ircify_html
486           header_found = $'
487           txt.sub!(strip, '') if strip
488           debug "(other attempt \#1) #{txt.inspect} has #{txt.count(" ")} spaces"
489         end
490
491         return txt unless txt.empty? or txt.count(" ") < min_spaces
492
493         # Attempt #2
494         header_found = xml
495         while txt.empty? or txt.count(" ") < min_spaces
496           candidate = header_found[AFTER_PAR2_REGEX]
497           break unless candidate
498           txt = candidate.ircify_html
499           header_found = $'
500           txt.sub!(strip, '') if strip
501           debug "(other attempt \#2) #{txt.inspect} has #{txt.count(" ")} spaces"
502         end
503
504         debug "Last candidate #{txt.inspect} has #{txt.count(" ")} spaces"
505         return txt unless txt.count(" ") < min_spaces
506         min_spaces /= 2
507       end
508     end
509
510     # Get the first pars of the first _count_ _urls_.
511     # The pages are downloaded using the bot httputil service.
512     # Returns an array of the first paragraphs fetched.
513     # If (optional) _opts_ :message is specified, those paragraphs are
514     # echoed as replies to the IRC message passed as _opts_ :message
515     #
516     def Utils.get_first_pars(urls, count, opts={})
517       idx = 0
518       msg = opts[:message]
519       retval = Array.new
520       while count > 0 and urls.length > 0
521         url = urls.shift
522         idx += 1
523
524         # FIXME what happens if some big file is returned? We should share
525         # code with the url plugin to only retrieve partial file content!
526         xml = self.bot.httputil.get(url)
527         if xml.nil?
528           debug "Unable to retrieve #{url}"
529           next
530         end
531         par = Utils.ircify_first_html_par(xml, opts)
532         if par.empty?
533           debug "No first par found\n#{xml}"
534           # FIXME only do this if the 'url' plugin is loaded
535           # TODO even better, put the code here
536           # par = @bot.plugins['url'].get_title_from_html(xml)
537           if par.empty?
538             retval.push(nil)
539             next
540           end
541         end
542         msg.reply "[#{idx}] #{par}", :overlong => :truncate if msg
543         count -=1
544         retval.push(par)
545       end
546       return retval
547     end
548
549   end
550 end
551
552 Irc::Utils.bot = Irc::Plugins.manager.bot