]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/keywords.rb
Restore tell and forget behaviour without 'keyword' before them
[user/henk/code/ruby/rbot.git] / 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
346     response = kw.to_s
347     response.gsub!(/<who>/, m.sourcenick)
348     if(response =~ /^<reply>\s*(.*)/)
349       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
350       m.reply "okay, I told #{target}: (#{key}) #$1"
351     elsif(response =~ /^<action>\s*(.*)/)
352       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
353       m.reply "okay, I told #{target}: * #$1"
354     else
355       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
356       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
357     end
358   end
359
360   # return the number of known keywords
361   def keyword_stats(m)
362     length = 0
363     @statickeywords.each {|k,v|
364       length += v.length
365     }
366     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
367   end
368
369   # search for keywords, optionally also the definition and the static keywords
370   def keyword_search(m, key, full = false, all = false)    
371     begin
372       if key =~ /^\/(.+)\/$/
373         re = Regexp.new($1, Regexp::IGNORECASE)
374       else
375         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
376       end
377       
378       matches = Array.new
379       @keywords.each {|k,v|
380         kw = Keyword.restore(v)
381         if re.match(k) || (full && re.match(kw.desc))
382           matches << [k,kw]
383         end
384       }
385       if all
386         @statickeywords.each {|k,v|
387           v.each {|kk,vv|
388             kw = Keyword.restore(vv)
389             if re.match(kk) || (full && re.match(kw.desc))
390               matches << [kk,kw]
391             end
392           }
393         }
394       end
395       
396       if matches.length == 1
397         rkw = matches[0]
398         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
399       elsif matches.length > 0
400         i = 0
401         matches.each {|rkw|
402           m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
403           i += 1
404           break if i == 4
405         }
406       else
407         m.reply "no keywords match #{key}"
408       end
409     rescue RegexpError => e
410       m.reply "no keywords match #{key}: #{e}"
411     rescue
412       debug e.inspect
413       m.reply "no keywords match #{key}: an error occurred"
414     end
415   end
416
417   # forget one of the dynamic keywords
418   def keyword_forget(m, key)
419     if(@keywords.has_key?(key))
420       @keywords.delete(key)
421       @bot.okay m.replyto
422     end
423   end
424
425   # privmsg handler
426   def privmsg(m)
427     case m.plugin
428     when "keyword"
429       case m.params
430       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
431         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
432       when /^forget\s+(.+)$/
433         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
434       when /^lookup\s+(.+)$/
435         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
436       when /^stats\s*$/
437         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
438       when /^search\s+(.+)$/
439         key = $1
440         full = key.sub!('--full ', '')
441         all = key.sub!('--all ', '')
442         keyword_search(m, key, full, all) if @bot.auth.allow?('keyword', m.source, m.replyto)
443       when /^tell\s+(\S+)\s+about\s+(.+)$/
444         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
445       else
446         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
447       end
448     when "forget"
449       keyword_forget(m, params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
450     when "tell"
451       if m.params =~ /(\S+)\s+about\s+(.+)$/
452         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
453       else
454         m.reply "wrong 'tell' syntax"
455       end
456     end
457   end
458
459   def listen(m)
460     return if m.address?    
461     # in channel message, not to me
462     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
463     # keyword lookup.
464     if !@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/
465       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
466     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
467       # TODO MUCH more selective on what's allowed here
468       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
469     end
470   end
471 end
472
473 plugin = Keywords.new
474 plugin.register 'keyword'
475 plugin.register 'forget'
476 plugin.register 'tell'
477