reworked the keywords plugin, it uses a new and less messy syntax now
[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 # keyword plugin 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 KeywordPlugin < 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   def oldsave
213     File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
214       @keywords.each do |key, value|
215         file.puts "#{key}<=#{value.type}=>#{value.dump}"
216       end
217     end
218   end
219   
220   # lookup keyword +key+, return it or nil
221   def [](key)
222     return nil if key.nil?
223     debug "keywords module: looking up key #{key}"
224     if(@keywords.has_key?(key))
225       return Keyword.restore(@keywords[key])
226     else
227       # key name order for the lookup through these
228       @statickeywords.keys.sort.each {|k|
229         v = @statickeywords[k]
230         if v.has_key?(key)
231           return Keyword.restore(v[key])
232         end
233       }
234     end
235     return nil
236   end
237
238   # does +key+ exist as a keyword?
239   def has_key?(key)
240     if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
241       return true
242     end
243     @statickeywords.each {|k,v|
244       if v.has_key?(key) && Keyword.restore(v[key]) != nil
245         return true
246       end
247     }
248     return false
249   end
250
251   # m::     PrivMessage containing message info
252   # key::   key being queried
253   # quiet:: optional, if false, complain if +key+ is not found
254   # 
255   # handle a message asking about a keyword
256   def keyword_lookup(m, key, quiet = false)
257     return if key.nil?
258     unless(kw = self[key])
259       m.reply "sorry, I don't know about \"#{key}\"" unless quiet
260       return
261     end
262     
263     response = kw.to_s
264     response.gsub!(/<who>/, m.sourcenick)
265     
266     if(response =~ /^<reply>\s*(.*)/)
267       m.reply $1
268     elsif(response =~ /^<action>\s*(.*)/)
269       m.act $1
270     elsif(m.public? && response =~ /^<topic>\s*(.*)/)
271       @bot.topic m.target, $1
272     else
273       m.reply "#{key} #{kw.type} #{response}"
274     end
275   end
276
277   
278   # handle a message which alters a keyword
279   # like "foo is bar" or "foo is also qux"
280   def keyword_command(m, lhs, mhs, rhs, quiet = false)
281     debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
282     
283     also = true if(rhs.gsub!(/^also\s+/, ""))
284     
285     values = rhs.split(/\s+\|\s+/)
286     lhs = Keyword.unescape lhs
287     
288     if(also && has_key?(lhs))
289       kw = self[lhs]
290       kw << values
291       @keywords[lhs] = kw.dump
292     else
293       @keywords[lhs] = Keyword.new(mhs, values).dump
294     end
295     
296     @bot.okay m.target if !quiet
297   end
298
299   # return help string for Keywords with option topic +topic+
300   def help(plugin, topic = '')
301     case topic
302     when 'lookup'
303       'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
304     when 'set'
305       'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
306     when 'forget'
307       'keyword forget <keyword> => forget a keyword'
308     when 'tell'
309       'keyword tell <nick> about <keyword> => tell somebody about a keyword'
310     when 'search'
311       '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.'
312     when 'listen'
313       'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
314     when 'address'
315       'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
316     when '<reply>'
317       '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
318     when '<action>'
319       '<action> => makes keyword respond with "/me <definition>"'
320     when '<who>'
321       '<who> => replaced with questioner in reply'
322     when '<topic>'
323       '<topic> => respond by setting the topic to the rest of the definition'
324     else
325       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
326     end
327   end
328
329   # handle a message asking the bot to tell someone about a keyword
330   def keyword_tell(m, target, key)
331     unless(kw = self[key])
332       m.reply @bot.lang.get("dunno_about_X") % key
333       return
334     end
335     
336     response = kw.to_s
337     response.gsub!(/<who>/, m.sourcenick)
338     if(response =~ /^<reply>\s*(.*)/)
339       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
340       m.reply "okay, I told #{target}: (#{key}) #$1"
341     elsif(response =~ /^<action>\s*(.*)/)
342       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
343       m.reply "okay, I told #{target}: * #$1"
344     else
345       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
346       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
347     end
348   end
349
350   # return the number of known keywords
351   def keyword_stats(m)
352     length = 0
353     @statickeywords.each {|k,v|
354       length += v.length
355     }
356     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
357   end
358
359   # search for keywords, optionally also the definition and the static keywords
360   def keyword_search(m, key, full = false, all = false)    
361     begin
362       if key =~ /^\/(.+)\/$/
363         re = Regexp.new($1, Regexp::IGNORECASE)
364       else
365         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
366       end
367       
368       matches = Array.new
369       @keywords.each {|k,v|
370         kw = Keyword.restore(v)
371         if re.match(k) || (full && re.match(kw.desc))
372           matches << [k,kw]
373         end
374       }
375       if all
376         @statickeywords.each {|k,v|
377           v.each {|kk,vv|
378             kw = Keyword.restore(vv)
379             if re.match(kk) || (full && re.match(kw.desc))
380               matches << [kk,kw]
381             end
382           }
383         }
384       end
385       
386       if matches.length == 1
387         rkw = matches[0]
388         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
389       elsif matches.length > 0
390         i = 0
391         matches.each {|rkw|
392           m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
393           i += 1
394           break if i == 4
395         }
396       else
397         m.reply "no keywords match #{key}"
398       end
399     rescue RegexpError => e
400       m.reply "no keywords match #{key}: #{e}"
401     rescue
402       debug e.inspect
403       m.reply "no keywords match #{key}: an error occurred"
404     end
405   end
406
407   # forget one of the dynamic keywords
408   def keyword_forget(m, key)
409     if(@keywords.has_key?(key))
410       @keywords.delete(key)
411       @bot.okay m.replyto
412     end
413   end
414
415   # privmsg handler
416   def privmsg(m)
417     case m.params
418     when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
419       keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
420     when /^forget\s+(.+)$/
421       keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
422     when /^lookup\s+(.+)$/
423       keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
424     when /^stats\s*$/
425       keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
426     when /^search\s+(.+)$/
427       key = $1
428       full = key.sub!('--full ', '')
429       all = key.sub!('--all ', '')
430       keyword_search(m, key, full, all) if @bot.auth.allow?('keyword', m.source, m.replyto)
431     when /^tell\s+(\S+)\s+about\s+(.+)$/
432       keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
433     else
434       keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
435     end
436   end
437
438   def listen(m)
439     return if m.address?    
440     # in channel message, not to me
441     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
442     # keyword lookup.
443     if !@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/
444       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
445     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
446       # TODO MUCH more selective on what's allowed here
447       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
448     end
449   end
450 end
451
452 plugin = KeywordPlugin.new
453 plugin.register 'keyword'