Make sure the bot doesn't tell to itself
[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 plugin
303     when /keyword/
304       case topic
305       when 'lookup'
306         'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
307       when 'set'
308         'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
309       when 'forget'
310         'keyword forget <keyword> => forget a keyword'
311       when 'tell'
312         'keyword tell <nick> about <keyword> => tell somebody about a keyword'
313       when 'search'
314         '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.'
315       when 'listen'
316         'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
317       when 'address'
318         'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
319       when '<reply>'
320         '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
321       when '<action>'
322         '<action> => makes keyword respond with "/me <definition>"'
323       when '<who>'
324         '<who> => replaced with questioner in reply'
325       when '<topic>'
326         '<topic> => respond by setting the topic to the rest of the definition'
327       else
328         'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
329       end
330     when "forget"
331       'forget <keyword> => forget a keyword'
332     when "tell"
333       'tell <nick> about <keyword> => tell somebody about a keyword'
334     else
335       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
336     end
337   end
338
339   # handle a message asking the bot to tell someone about a keyword
340   def keyword_tell(m, target, key)
341     unless(kw = self[key])
342       m.reply @bot.lang.get("dunno_about_X") % key
343       return
344     end
345     if target == @bot.nick
346       m.reply "very funny, trying to make me tell something to myself"
347       return
348     end
349
350     response = kw.to_s
351     response.gsub!(/<who>/, m.sourcenick)
352     if(response =~ /^<reply>\s*(.*)/)
353       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
354       m.reply "okay, I told #{target}: (#{key}) #$1"
355     elsif(response =~ /^<action>\s*(.*)/)
356       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
357       m.reply "okay, I told #{target}: * #$1"
358     else
359       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
360       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
361     end
362   end
363
364   # return the number of known keywords
365   def keyword_stats(m)
366     length = 0
367     @statickeywords.each {|k,v|
368       length += v.length
369     }
370     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
371   end
372
373   # search for keywords, optionally also the definition and the static keywords
374   def keyword_search(m, key, full = false, all = false)    
375     begin
376       if key =~ /^\/(.+)\/$/
377         re = Regexp.new($1, Regexp::IGNORECASE)
378       else
379         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
380       end
381       
382       matches = Array.new
383       @keywords.each {|k,v|
384         kw = Keyword.restore(v)
385         if re.match(k) || (full && re.match(kw.desc))
386           matches << [k,kw]
387         end
388       }
389       if all
390         @statickeywords.each {|k,v|
391           v.each {|kk,vv|
392             kw = Keyword.restore(vv)
393             if re.match(kk) || (full && re.match(kw.desc))
394               matches << [kk,kw]
395             end
396           }
397         }
398       end
399       
400       if matches.length == 1
401         rkw = matches[0]
402         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
403       elsif matches.length > 0
404         i = 0
405         matches.each {|rkw|
406           m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
407           i += 1
408           break if i == 4
409         }
410       else
411         m.reply "no keywords match #{key}"
412       end
413     rescue RegexpError => e
414       m.reply "no keywords match #{key}: #{e}"
415     rescue
416       debug e.inspect
417       m.reply "no keywords match #{key}: an error occurred"
418     end
419   end
420
421   # forget one of the dynamic keywords
422   def keyword_forget(m, key)
423     if(@keywords.has_key?(key))
424       @keywords.delete(key)
425       @bot.okay m.replyto
426     end
427   end
428
429   # privmsg handler
430   def privmsg(m)
431     case m.plugin
432     when "keyword"
433       case m.params
434       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
435         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
436       when /^forget\s+(.+)$/
437         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
438       when /^lookup\s+(.+)$/
439         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
440       when /^stats\s*$/
441         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
442       when /^search\s+(.+)$/
443         key = $1
444         full = key.sub!('--full ', '')
445         all = key.sub!('--all ', '')
446         keyword_search(m, key, full, all) if @bot.auth.allow?('keyword', m.source, m.replyto)
447       when /^tell\s+(\S+)\s+about\s+(.+)$/
448         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
449       else
450         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
451       end
452     when "forget"
453       keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
454     when "tell"
455       if m.params =~ /(\S+)\s+about\s+(.+)$/
456         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
457       else
458         m.reply "wrong 'tell' syntax"
459       end
460     end
461   end
462
463   def listen(m)
464     return if m.address?    
465     # in channel message, not to me
466     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
467     # keyword lookup.
468     if !@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/
469       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
470     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
471       # TODO MUCH more selective on what's allowed here
472       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
473     end
474   end
475 end
476
477 plugin = Keywords.new
478 plugin.register 'keyword'
479 plugin.register 'forget'
480 plugin.register 'tell'
481