Minor whitespace cleanup
[rbot] / data / rbot / plugins / keywords.rb
1 require 'pp'
2
3 # Keyword class
4 #
5 # Encapsulates a keyword ("foo is bar" is a keyword called foo, with type
6 # is, and has a single value of bar).
7 # Keywords can have multiple values, to_s() will choose one at random
8 class Keyword
9
10   # type of keyword (e.g. "is" or "are")
11   attr_reader :type
12   
13   # type::   type of keyword (e.g "is" or "are")
14   # values:: array of values
15   # 
16   # create a keyword of type +type+ with values +values+
17   def initialize(type, values)
18     @type = type.downcase
19     @values = values
20   end
21
22   # pick a random value for this keyword and return it
23   def to_s
24     if(@values.length > 1)
25       Keyword.unescape(@values[rand(@values.length)])
26     else
27       Keyword.unescape(@values[0])
28     end
29   end
30
31   # describe the keyword (show all values without interpolation)
32   def desc
33     @values.join(" | ")
34   end
35
36   # return the keyword in a stringified form ready for storage
37   def dump
38     @type + "/" + Keyword.unescape(@values.join("<=or=>"))
39   end
40
41   # deserialize the stringified form to an object
42   def Keyword.restore(str)
43     if str =~ /^(\S+?)\/(.*)$/
44       type = $1
45       vals = $2.split("<=or=>")
46       return Keyword.new(type, vals)
47     end
48     return nil
49   end
50
51   # values:: array of values to add
52   # add values to a keyword
53   def <<(values)
54     if(@values.length > 1 || values.length > 1)
55       values.each {|v|
56         @values << v
57       }
58     else
59       @values[0] += " or " + values[0]
60     end
61   end
62
63   # unescape special words/characters in a keyword
64   def Keyword.unescape(str)
65     str.gsub(/\\\|/, "|").gsub(/ \\is /, " is ").gsub(/ \\are /, " are ").gsub(/\\\?(\s*)$/, "?\1")
66   end
67
68   # escape special words/characters in a keyword
69   def Keyword.escape(str)
70     str.gsub(/\|/, "\\|").gsub(/ is /, " \\is ").gsub(/ are /, " \\are ").gsub(/\?(\s*)$/, "\\?\1")
71   end
72 end
73
74 # keywords class. 
75 #
76 # Handles all that stuff like "bot: foo is bar", "bot: foo?"
77 #
78 # Fallback after core and auth have had a look at a message and refused to
79 # handle it, checks for a keyword command or lookup, otherwise the message
80 # is delegated to plugins
81 class Keywords < Plugin
82   BotConfig.register BotConfigBooleanValue.new('keyword.listen',
83     :default => false,
84     :desc => "Should the bot listen to all chat and attempt to automatically detect keywords? (e.g. by spotting someone say 'foo is bar')")
85   BotConfig.register BotConfigBooleanValue.new('keyword.address',
86     :default => true,
87     :desc => "Should the bot require that keyword lookups are addressed to it? If not, the bot will attempt to lookup foo if someone says 'foo?' in channel")
88   
89   # create a new KeywordPlugin instance, associated to bot +bot+
90   def initialize
91     super
92
93     @statickeywords = Hash.new
94     @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
95     upgrade_data
96
97     scan
98     
99     # import old format keywords into DBHash
100     if(File.exist?("#{@bot.botclass}/keywords.rbot"))
101       log "auto importing old keywords.rbot"
102       IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
103         if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
104           lhs = $1
105           mhs = $2
106           rhs = $3
107           mhs = "is" unless mhs
108           rhs = Keyword.escape rhs
109           values = rhs.split("<=or=>")
110           @keywords[lhs] = Keyword.new(mhs, values).dump
111         end
112       end
113       File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
114     end
115   end
116   
117   # drop static keywords and reload them from files, picking up any new
118   # keyword files that have been added
119   def rescan
120     @statickeywords = Hash.new
121     scan
122   end
123
124   # load static keywords from files, picking up any new keyword files that
125   # have been added
126   def scan
127     # first scan for old DBHash files, and convert them
128     Dir["#{@bot.botclass}/keywords/*"].each {|f|
129       next unless f =~ /\.db$/
130       log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
131       newname = f.gsub(/\.db$/, ".kdb")
132       old = BDB::Hash.open f, nil, 
133                            "r+", 0600
134       new = BDB::CIBtree.open(newname, nil, 
135                               BDB::CREATE | BDB::EXCL,
136                               0600)
137       old.each {|k,v|
138         new[k] = v
139       }
140       old.close
141       new.close
142       File.delete(f)
143     }
144     
145     # then scan for current DBTree files, and load them
146     Dir["#{@bot.botclass}/keywords/*"].each {|f|
147       next unless f =~ /\.kdb$/
148       hsh = DBTree.new @bot, f, true
149       key = File.basename(f).gsub(/\.kdb$/, "")
150       debug "keywords module: loading DBTree file #{f}, key #{key}"
151       @statickeywords[key] = hsh
152     }
153     
154     # then scan for non DB files, and convert/import them and delete
155     Dir["#{@bot.botclass}/keywords/*"].each {|f|
156       next if f =~ /\.kdb$/
157       next if f =~ /CVS$/
158       log "auto converting keywords from #{f}"
159       key = File.basename(f)
160       unless @statickeywords.has_key?(key)
161         @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
162       end
163       IO.foreach(f) {|line|
164         if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
165           lhs = $1
166           mhs = $2
167           rhs = $3
168           # support infobot style factfiles, by fixing them up here
169           rhs.gsub!(/\$who/, "<who>")
170           mhs = "is" unless mhs
171           rhs = Keyword.escape rhs
172           values = rhs.split("<=or=>")
173           @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
174         end
175       }
176       File.delete(f)
177       @statickeywords[key].flush
178     }
179   end
180
181   # upgrade data files found in old rbot formats to current
182   def upgrade_data
183     if File.exist?("#{@bot.botclass}/keywords.db")
184       log "upgrading old keywords (rbot 0.9.5 or prior) database format"
185       old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil, 
186                            "r+", 0600
187       old.each {|k,v|
188         @keywords[k] = v
189       }
190       old.close
191       @keywords.flush
192       File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
193     end
194   
195     if File.exist?("#{@bot.botclass}/keyword.db")
196       log "upgrading old keywords (rbot 0.9.9 or prior) database format"
197       old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil, 
198                            "r+", 0600
199       old.each {|k,v|
200         @keywords[k] = v
201       }
202       old.close
203       @keywords.flush
204       File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
205     end
206   end
207
208   # save dynamic keywords to file
209   def save
210     @keywords.flush
211   end
212
213   def oldsave
214     File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
215       @keywords.each do |key, value|
216         file.puts "#{key}<=#{value.type}=>#{value.dump}"
217       end
218     end
219   end
220   
221   # lookup keyword +key+, return it or nil
222   def [](key)
223     return nil if key.nil?
224     debug "keywords module: looking up key #{key}"
225     if(@keywords.has_key?(key))
226       return Keyword.restore(@keywords[key])
227     else
228       # key name order for the lookup through these
229       @statickeywords.keys.sort.each {|k|
230         v = @statickeywords[k]
231         if v.has_key?(key)
232           return Keyword.restore(v[key])
233         end
234       }
235     end
236     return nil
237   end
238
239   # does +key+ exist as a keyword?
240   def has_key?(key)
241     if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
242       return true
243     end
244     @statickeywords.each {|k,v|
245       if v.has_key?(key) && Keyword.restore(v[key]) != nil
246         return true
247       end
248     }
249     return false
250   end
251
252   # m::     PrivMessage containing message info
253   # key::   key being queried
254   # quiet:: optional, if false, complain if +key+ is not found
255   # 
256   # handle a message asking about a keyword
257   def keyword_lookup(m, key, quiet = false)
258     return if key.nil?
259     unless(kw = self[key])
260       m.reply "sorry, I don't know about \"#{key}\"" unless quiet
261       return
262     end
263     
264     response = kw.to_s
265     response.gsub!(/<who>/, m.sourcenick)
266     
267     if(response =~ /^<reply>\s*(.*)/)
268       m.reply $1
269     elsif(response =~ /^<action>\s*(.*)/)
270       m.act $1
271     elsif(m.public? && response =~ /^<topic>\s*(.*)/)
272       @bot.topic m.target, $1
273     else
274       m.reply "#{key} #{kw.type} #{response}"
275     end
276   end
277
278   
279   # handle a message which alters a keyword
280   # like "foo is bar" or "foo is also qux"
281   def keyword_command(m, lhs, mhs, rhs, quiet = false)
282     debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
283     
284     also = true if(rhs.gsub!(/^also\s+/, ""))
285     
286     values = rhs.split(/\s+\|\s+/)
287     lhs = Keyword.unescape lhs
288     
289     if(also && has_key?(lhs))
290       kw = self[lhs]
291       kw << values
292       @keywords[lhs] = kw.dump
293     else
294       @keywords[lhs] = Keyword.new(mhs, values).dump
295     end
296     
297     @bot.okay m.target if !quiet
298   end
299
300   # return help string for Keywords with option topic +topic+
301   def help(plugin, topic = '')
302     case topic
303     when 'lookup'
304       'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
305     when 'set'
306       'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
307     when 'forget'
308       'keyword forget <keyword> => forget a keyword'
309     when 'tell'
310       'keyword tell <nick> about <keyword> => tell somebody about a keyword'
311     when 'search'
312       'keyword search [--all] [--full] <pattern> => search keywords for <pattern>, which can be a regular expression. If --all is set, search static keywords too, if --full is set, search definitions too.'
313     when 'listen'
314       'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
315     when 'address'
316       'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
317     when '<reply>'
318       '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
319     when '<action>'
320       '<action> => makes keyword respond with "/me <definition>"'
321     when '<who>'
322       '<who> => replaced with questioner in reply'
323     when '<topic>'
324       '<topic> => respond by setting the topic to the rest of the definition'
325     else
326       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
327     end
328   end
329
330   # handle a message asking the bot to tell someone about a keyword
331   def keyword_tell(m, target, key)
332     unless(kw = self[key])
333       m.reply @bot.lang.get("dunno_about_X") % key
334       return
335     end
336     
337     response = kw.to_s
338     response.gsub!(/<who>/, m.sourcenick)
339     if(response =~ /^<reply>\s*(.*)/)
340       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
341       m.reply "okay, I told #{target}: (#{key}) #$1"
342     elsif(response =~ /^<action>\s*(.*)/)
343       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
344       m.reply "okay, I told #{target}: * #$1"
345     else
346       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
347       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
348     end
349   end
350
351   # return the number of known keywords
352   def keyword_stats(m)
353     length = 0
354     @statickeywords.each {|k,v|
355       length += v.length
356     }
357     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
358   end
359
360   # search for keywords, optionally also the definition and the static keywords
361   def keyword_search(m, key, full = false, all = false)    
362     begin
363       if key =~ /^\/(.+)\/$/
364         re = Regexp.new($1, Regexp::IGNORECASE)
365       else
366         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
367       end
368       
369       matches = Array.new
370       @keywords.each {|k,v|
371         kw = Keyword.restore(v)
372         if re.match(k) || (full && re.match(kw.desc))
373           matches << [k,kw]
374         end
375       }
376       if all
377         @statickeywords.each {|k,v|
378           v.each {|kk,vv|
379             kw = Keyword.restore(vv)
380             if re.match(kk) || (full && re.match(kw.desc))
381               matches << [kk,kw]
382             end
383           }
384         }
385       end
386       
387       if matches.length == 1
388         rkw = matches[0]
389         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
390       elsif matches.length > 0
391         i = 0
392         matches.each {|rkw|
393           m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
394           i += 1
395           break if i == 4
396         }
397       else
398         m.reply "no keywords match #{key}"
399       end
400     rescue RegexpError => e
401       m.reply "no keywords match #{key}: #{e}"
402     rescue
403       debug e.inspect
404       m.reply "no keywords match #{key}: an error occurred"
405     end
406   end
407
408   # forget one of the dynamic keywords
409   def keyword_forget(m, key)
410     if(@keywords.has_key?(key))
411       @keywords.delete(key)
412       @bot.okay m.replyto
413     end
414   end
415
416   # privmsg handler
417   def privmsg(m)
418     case m.params
419     when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
420       keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
421     when /^forget\s+(.+)$/
422       keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
423     when /^lookup\s+(.+)$/
424       keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
425     when /^stats\s*$/
426       keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
427     when /^search\s+(.+)$/
428       key = $1
429       full = key.sub!('--full ', '')
430       all = key.sub!('--all ', '')
431       keyword_search(m, key, full, all) if @bot.auth.allow?('keyword', m.source, m.replyto)
432     when /^tell\s+(\S+)\s+about\s+(.+)$/
433       keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
434     else
435       keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
436     end
437   end
438
439   def listen(m)
440     return if m.address?    
441     # in channel message, not to me
442     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
443     # keyword lookup.
444     if !@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/
445       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
446     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
447       # TODO MUCH more selective on what's allowed here
448       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
449     end
450   end
451 end
452
453 plugin = Keywords.new
454 plugin.register 'keyword'