]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - lib/rbot/core/utils/extends.rb
extends: Array#delete_one takes an optional argument for the element to delete: if...
[user/henk/code/ruby/rbot.git] / lib / rbot / core / utils / extends.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Standard classes extensions
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 # Copyright:: (C) 2006,2007 Giuseppe Bilotta
8 # License:: GPL v2
9 #
10 # This file collects extensions to standard Ruby classes and to some core rbot
11 # classes to be used by the various plugins
12 #
13 # Please note that global symbols have to be prefixed by :: because this plugin
14 # will be read into an anonymous module
15
16 # Extensions to the Module class
17 #
18 class ::Module
19
20   # Many plugins define Struct objects to hold their data. On rescans, lots of
21   # warnings are echoed because of the redefinitions. Using this method solves
22   # the problem, by checking if the Struct already exists, and if it has the
23   # same attributes
24   #
25   def define_structure(name, *members)
26     sym = name.to_sym
27     if Struct.const_defined?(sym)
28       kl = Struct.const_get(sym)
29       if kl.new.members.map { |member| member.intern } == members.map
30         debug "Struct #{sym} previously defined, skipping"
31         const_set(sym, kl)
32         return
33       end
34     end
35     debug "Defining struct #{sym} with members #{members.inspect}"
36     const_set(sym, Struct.new(name.to_s, *members))
37   end
38 end
39
40
41 # DottedIndex mixin: extend a Hash or Array class with this module
42 # to achieve [] and []= methods that automatically split indices
43 # at dots (indices are automatically converted to symbols, too)
44 #
45 # You have to define the single_retrieve(_key_) and
46 # single_assign(_key_,_value_) methods (usually aliased at the
47 # original :[] and :[]= methods)
48 #
49 module ::DottedIndex
50   def rbot_index_split(*ar)
51     keys = ([] << ar).flatten
52     keys.map! { |k|
53       k.to_s.split('.').map { |kk| kk.to_sym rescue nil }.compact
54     }.flatten
55   end
56
57   def [](*ar)
58     keys = self.rbot_index_split(ar)
59     return self.single_retrieve(keys.first) if keys.length == 1
60     h = self
61     while keys.length > 1
62       k = keys.shift
63       h[k] ||= self.class.new
64       h = h[k]
65     end
66     h[keys.last]
67   end
68
69   def []=(*arr)
70     val = arr.last
71     ar = arr[0..-2]
72     keys = self.rbot_index_split(ar)
73     return self.single_assign(keys.first, val) if keys.length == 1
74     h = self
75     while keys.length > 1
76       k = keys.shift
77       h[k] ||= self.class.new
78       h = h[k]
79     end
80     h[keys.last] = val
81   end
82 end
83
84
85 # Extensions to the Array class
86 #
87 class ::Array
88
89   # This method returns a random element from the array, or nil if the array is
90   # empty
91   #
92   def pick_one
93     return nil if self.empty?
94     self[rand(self.length)]
95   end
96
97   # This method returns a given element from the array, deleting it from the
98   # array itself. The method returns nil if the element couldn't be found.
99   #
100   # If nil is specified, a random element is returned and deleted.
101   #
102   def delete_one(val=nil)
103     return nil if self.empty?
104     if val.nil?
105       index = rand(self.length)
106     else
107       index = self.index(val)
108       return nil unless index
109     end
110     self.delete_at(index)
111   end
112 end
113
114 # Extensions to the Range class
115 #
116 class ::Range
117
118   # This method returns a random number between the lower and upper bound
119   #
120   def pick_one
121     len = self.last - self.first
122     len += 1 unless self.exclude_end?
123     self.first + Kernel::rand(len)
124   end
125   alias :rand :pick_one
126 end
127
128 # Extensions for the Numeric classes
129 #
130 class ::Numeric
131
132   # This method forces a real number to be not more than a given positive
133   # number or not less than a given positive number, or between two any given
134   # numbers
135   #
136   def clip(left,right=0)
137     raise ArgumentError unless left.kind_of?(Numeric) and right.kind_of?(Numeric)
138     l = [left,right].min
139     u = [left,right].max
140     return l if self < l
141     return u if self > u
142     return self
143   end
144 end
145
146 # Extensions to the String class
147 #
148 # TODO make riphtml() just call ircify_html() with stronger purify options.
149 #
150 class ::String
151
152   # This method will return a purified version of the receiver, with all HTML
153   # stripped off and some of it converted to IRC formatting
154   #
155   def ircify_html(opts={})
156     txt = self.dup
157
158     # remove scripts
159     txt.gsub!(/<script(?:\s+[^>]*)?>.*?<\/script>/im, "")
160
161     # remove styles
162     txt.gsub!(/<style(?:\s+[^>]*)?>.*?<\/style>/im, "")
163
164     # bold and strong -> bold
165     txt.gsub!(/<\/?(?:b|strong)(?:\s+[^>]*)?>/im, "#{Bold}")
166
167     # italic, emphasis and underline -> underline
168     txt.gsub!(/<\/?(?:i|em|u)(?:\s+[^>]*)?>/im, "#{Underline}")
169
170     ## This would be a nice addition, but the results are horrible
171     ## Maybe make it configurable?
172     # txt.gsub!(/<\/?a( [^>]*)?>/, "#{Reverse}")
173     case val = opts[:a_href]
174     when Reverse, Bold, Underline
175       txt.gsub!(/<(?:\/a\s*|a (?:[^>]*\s+)?href\s*=\s*(?:[^>]*\s*)?)>/, val)
176     when :link_out
177       # Not good for nested links, but the best we can do without something like hpricot
178       txt.gsub!(/<a (?:[^>]*\s+)?href\s*=\s*(?:([^"'>][^\s>]*)\s+|"((?:[^"]|\\")*)"|'((?:[^']|\\')*)')(?:[^>]*\s+)?>(.*?)<\/a>/) { |match|
179         debug match
180         debug [$1, $2, $3, $4].inspect
181         link = $1 || $2 || $3
182         str = $4
183         str + ": " + link
184       }
185     else
186       warning "unknown :a_href option #{val} passed to ircify_html" if val
187     end
188
189     # Paragraph and br tags are converted to whitespace
190     txt.gsub!(/<\/?(p|br)(?:\s+[^>]*)?\s*\/?\s*>/i, ' ')
191     txt.gsub!("\n", ' ')
192     txt.gsub!("\r", ' ')
193
194     # Superscripts and subscripts are turned into ^{...} and _{...}
195     # where the {} are omitted for single characters
196     txt.gsub!(/<sup>(.*?)<\/sup>/, '^{\1}')
197     txt.gsub!(/<sub>(.*?)<\/sub>/, '_{\1}')
198     txt.gsub!(/(^|_)\{(.)\}/, '\1\2')
199
200     # List items are converted to *). We don't have special support for
201     # nested or ordered lists.
202     txt.gsub!(/<li>/, ' *) ')
203
204     # All other tags are just removed
205     txt.gsub!(/<[^>]+>/, '')
206
207     # Convert HTML entities. We do it now to be able to handle stuff
208     # such as &nbsp;
209     txt = Utils.decode_html_entities(txt)
210
211     # Keep unbreakable spaces or conver them to plain spaces?
212     case val = opts[:nbsp]
213     when :space, ' '
214       txt.gsub!([160].pack('U'), ' ')
215     else
216       warning "unknown :nbsp option #{val} passed to ircify_html" if val
217     end
218
219     # Remove double formatting options, since they only waste bytes
220     txt.gsub!(/#{Bold}(\s*)#{Bold}/, '\1')
221     txt.gsub!(/#{Underline}(\s*)#{Underline}/, '\1')
222
223     # Simplify whitespace that appears on both sides of a formatting option
224     txt.gsub!(/\s+(#{Bold}|#{Underline})\s+/, ' \1')
225     txt.sub!(/\s+(#{Bold}|#{Underline})\z/, '\1')
226     txt.sub!(/\A(#{Bold}|#{Underline})\s+/, '\1')
227
228     # And finally whitespace is squeezed
229     txt.gsub!(/\s+/, ' ')
230     txt.strip!
231
232     if opts[:limit] && txt.size > opts[:limit]
233       txt = txt.slice(0, opts[:limit]) + "#{Reverse}...#{Reverse}"
234     end
235
236     # Decode entities and strip whitespace
237     return txt
238   end
239
240   # As above, but modify the receiver
241   #
242   def ircify_html!(opts={})
243     old_hash = self.hash
244     replace self.ircify_html(opts)
245     return self unless self.hash == old_hash
246   end
247
248   # This method will strip all HTML crud from the receiver
249   #
250   def riphtml
251     self.gsub(/<[^>]+>/, '').gsub(/&amp;/,'&').gsub(/&quot;/,'"').gsub(/&lt;/,'<').gsub(/&gt;/,'>').gsub(/&ellip;/,'...').gsub(/&apos;/, "'").gsub("\n",'')
252   end
253
254   # This method tries to find an HTML title in the string,
255   # and returns it if found
256   def get_html_title
257     if defined? ::Hpricot
258       Hpricot(self).at("title").inner_html
259     else
260       return unless Irc::Utils::TITLE_REGEX.match(self)
261       $1
262     end
263   end
264
265   # This method returns the IRC-formatted version of an
266   # HTML title found in the string
267   def ircify_html_title
268     self.get_html_title.ircify_html rescue nil
269   end
270 end
271
272
273 # Extensions to the Regexp class, with some common and/or complex regular
274 # expressions.
275 #
276 class ::Regexp
277
278   # A method to build a regexp that matches a list of something separated by
279   # optional commas and/or the word "and", an optionally repeated prefix,
280   # and whitespace.
281   def Regexp.new_list(reg, pfx = "")
282     if pfx.kind_of?(String) and pfx.empty?
283       return %r(#{reg}(?:,?(?:\s+and)?\s+#{reg})*)
284     else
285       return %r(#{reg}(?:,?(?:\s+and)?(?:\s+#{pfx})?\s+#{reg})*)
286     end
287   end
288
289   IN_ON = /in|on/
290
291   module Irc
292     # Match a list of channel anmes separated by optional commas, whitespace
293     # and optionally the word "and"
294     CHAN_LIST = Regexp.new_list(GEN_CHAN)
295
296     # Match "in #channel" or "on #channel" and/or "in private" (optionally
297     # shortened to "in pvt"), returning the channel name or the word 'private'
298     # or 'pvt' as capture
299     IN_CHAN = /#{IN_ON}\s+(#{GEN_CHAN})|(here)|/
300     IN_CHAN_PVT = /#{IN_CHAN}|in\s+(private|pvt)/
301
302     # As above, but with channel lists
303     IN_CHAN_LIST_SFX = Regexp.new_list(/#{GEN_CHAN}|here/, IN_ON)
304     IN_CHAN_LIST = /#{IN_ON}\s+#{IN_CHAN_LIST_SFX}|anywhere|everywhere/
305     IN_CHAN_LIST_PVT_SFX = Regexp.new_list(/#{GEN_CHAN}|here|private|pvt/, IN_ON)
306     IN_CHAN_LIST_PVT = /#{IN_ON}\s+#{IN_CHAN_LIST_PVT_SFX}|anywhere|everywhere/
307
308     # Match a list of nicknames separated by optional commas, whitespace and
309     # optionally the word "and"
310     NICK_LIST = Regexp.new_list(GEN_NICK)
311
312   end
313
314 end
315
316
317 module ::Irc
318
319
320   class BasicUserMessage
321
322     # We extend the BasicUserMessage class with a method that parses a string
323     # which is a channel list as matched by IN_CHAN(_LIST) and co. The method
324     # returns an array of channel names, where 'private' or 'pvt' is replaced
325     # by the Symbol :"?", 'here' is replaced by the channel of the message or
326     # by :"?" (depending on whether the message target is the bot or a
327     # Channel), and 'anywhere' and 'everywhere' are replaced by Symbol :*
328     #
329     def parse_channel_list(string)
330       return [:*] if [:anywhere, :everywhere].include? string.to_sym
331       string.scan(
332       /(?:^|,?(?:\s+and)?\s+)(?:in|on\s+)?(#{Regexp::Irc::GEN_CHAN}|here|private|pvt)/
333                  ).map { |chan_ar|
334         chan = chan_ar.first
335         case chan.to_sym
336         when :private, :pvt
337           :"?"
338         when :here
339           case self.target
340           when Channel
341             self.target.name
342           else
343             :"?"
344           end
345         else
346           chan
347         end
348       }.uniq
349     end
350
351     # The recurse depth of a message, for fake messages. 0 means an original
352     # message
353     def recurse_depth
354       unless defined? @recurse_depth
355         @recurse_depth = 0
356       end
357       @recurse_depth
358     end
359
360     # Set the recurse depth of a message, for fake messages. 0 should only
361     # be used by original messages
362     def recurse_depth=(val)
363       @recurse_depth = val
364     end
365   end
366
367   class Bot
368     module Plugins
369
370       # Maximum fake message recursion
371       MAX_RECURSE_DEPTH = 10
372
373       class RecurseTooDeep < RuntimeError
374       end
375
376       class BotModule
377         # Sometimes plugins need to create a new fake message based on an existing
378         # message: for example, this is done by alias, linkbot, reaction and remotectl.
379         #
380         # This method simplifies the message creation, including a recursion depth
381         # check.
382         #
383         # In the options you can specify the :bot, the :server, the :source,
384         # the :target, the message :class and whether or not to :delegate. To
385         # initialize these entries from an existing message, you can use :from
386         #
387         # If you don't specify a :from you should specify a :source.
388         #
389         def fake_message(string, opts={})
390           if from = opts[:from]
391             o = {
392               :bot => from.bot, :server => from.server, :source => from.source,
393               :target => from.target, :class => from.class, :delegate => true,
394               :depth => from.recurse_depth + 1
395             }.merge(opts)
396           else
397             o = {
398               :bot => @bot, :server => @bot.server, :target => @bot.myself,
399               :class => PrivMessage, :delegate => true, :depth => 1
400             }.merge(opts)
401           end
402           raise RecurseTooDeep if o[:depth] > MAX_RECURSE_DEPTH
403           new_m = o[:class].new(o[:bot], o[:server], o[:source], o[:target], string)
404           new_m.recurse_depth = o[:depth]
405           return new_m unless o[:delegate]
406           method = o[:class].to_s.gsub(/^Irc::|Message$/,'').downcase
407           method = 'privmsg' if method == 'priv'
408           o[:bot].plugins.irc_delegate(method, new_m)
409         end
410       end
411     end
412   end
413 end