remind plugin: fix for repeated reminders failing after [717].
[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   BotConfig.register BotConfigIntegerValue.new('keyword.search_results',
89     :default => 3,
90     :desc => "How many search results to display at a time")
91
92   # create a new KeywordPlugin instance, associated to bot +bot+
93   def initialize
94     super
95
96     @statickeywords = Hash.new
97     @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
98     upgrade_data
99
100     scan
101
102     # import old format keywords into DBHash
103     if(File.exist?("#{@bot.botclass}/keywords.rbot"))
104       log "auto importing old keywords.rbot"
105       IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
106         if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
107           lhs = $1
108           mhs = $2
109           rhs = $3
110           mhs = "is" unless mhs
111           rhs = Keyword.escape rhs
112           values = rhs.split("<=or=>")
113           @keywords[lhs] = Keyword.new(mhs, values).dump
114         end
115       end
116       File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
117     end
118   end
119
120   # drop static keywords and reload them from files, picking up any new
121   # keyword files that have been added
122   def rescan
123     @statickeywords = Hash.new
124     scan
125   end
126
127   # load static keywords from files, picking up any new keyword files that
128   # have been added
129   def scan
130     # first scan for old DBHash files, and convert them
131     Dir["#{@bot.botclass}/keywords/*"].each {|f|
132       next unless f =~ /\.db$/
133       log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
134       newname = f.gsub(/\.db$/, ".kdb")
135       old = BDB::Hash.open f, nil,
136                            "r+", 0600
137       new = BDB::CIBtree.open(newname, nil,
138                               BDB::CREATE | BDB::EXCL,
139                               0600)
140       old.each {|k,v|
141         new[k] = v
142       }
143       old.close
144       new.close
145       File.delete(f)
146     }
147
148     # then scan for current DBTree files, and load them
149     Dir["#{@bot.botclass}/keywords/*"].each {|f|
150       next unless f =~ /\.kdb$/
151       hsh = DBTree.new @bot, f, true
152       key = File.basename(f).gsub(/\.kdb$/, "")
153       debug "keywords module: loading DBTree file #{f}, key #{key}"
154       @statickeywords[key] = hsh
155     }
156
157     # then scan for non DB files, and convert/import them and delete
158     Dir["#{@bot.botclass}/keywords/*"].each {|f|
159       next if f =~ /\.kdb$/
160       next if f =~ /CVS$/
161       log "auto converting keywords from #{f}"
162       key = File.basename(f)
163       unless @statickeywords.has_key?(key)
164         @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
165       end
166       IO.foreach(f) {|line|
167         if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
168           lhs = $1
169           mhs = $2
170           rhs = $3
171           # support infobot style factfiles, by fixing them up here
172           rhs.gsub!(/\$who/, "<who>")
173           mhs = "is" unless mhs
174           rhs = Keyword.escape rhs
175           values = rhs.split("<=or=>")
176           @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
177         end
178       }
179       File.delete(f)
180       @statickeywords[key].flush
181     }
182   end
183
184   # upgrade data files found in old rbot formats to current
185   def upgrade_data
186     if File.exist?("#{@bot.botclass}/keywords.db")
187       log "upgrading old keywords (rbot 0.9.5 or prior) database format"
188       old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil,
189                            "r+", 0600
190       old.each {|k,v|
191         @keywords[k] = v
192       }
193       old.close
194       @keywords.flush
195       File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
196     end
197
198     if File.exist?("#{@bot.botclass}/keyword.db")
199       log "upgrading old keywords (rbot 0.9.9 or prior) database format"
200       old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil,
201                            "r+", 0600
202       old.each {|k,v|
203         @keywords[k] = v
204       }
205       old.close
206       @keywords.flush
207       File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
208     end
209   end
210
211   # save dynamic keywords to file
212   def save
213     @keywords.flush
214   end
215
216   def oldsave
217     File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
218       @keywords.each do |key, value|
219         file.puts "#{key}<=#{value.type}=>#{value.dump}"
220       end
221     end
222   end
223
224   # lookup keyword +key+, return it or nil
225   def [](key)
226     return nil if key.nil?
227     debug "keywords module: looking up key #{key}"
228     if(@keywords.has_key?(key))
229       return Keyword.restore(@keywords[key])
230     else
231       # key name order for the lookup through these
232       @statickeywords.keys.sort.each {|k|
233         v = @statickeywords[k]
234         if v.has_key?(key)
235           return Keyword.restore(v[key])
236         end
237       }
238     end
239     return nil
240   end
241
242   # does +key+ exist as a keyword?
243   def has_key?(key)
244     if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
245       return true
246     end
247     @statickeywords.each {|k,v|
248       if v.has_key?(key) && Keyword.restore(v[key]) != nil
249         return true
250       end
251     }
252     return false
253   end
254
255   # m::     PrivMessage containing message info
256   # key::   key being queried
257   # quiet:: optional, if false, complain if +key+ is not found
258   #
259   # handle a message asking about a keyword
260   def keyword_lookup(m, key, quiet = false)
261     return if key.nil?
262     unless(kw = self[key])
263       m.reply "sorry, I don't know about \"#{key}\"" unless quiet
264       return
265     end
266
267     response = kw.to_s
268     response.gsub!(/<who>/, m.sourcenick)
269
270     if(response =~ /^<reply>\s*(.*)/)
271       m.reply $1
272     elsif(response =~ /^<action>\s*(.*)/)
273       m.act $1
274     elsif(m.public? && response =~ /^<topic>\s*(.*)/)
275       @bot.topic m.target, $1
276     else
277       m.reply "#{key} #{kw.type} #{response}"
278     end
279   end
280
281
282   # handle a message which alters a keyword
283   # like "foo is bar" or "foo is also qux"
284   def keyword_command(m, lhs, mhs, rhs, quiet = false)
285     debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
286     return if lhs.strip.empty?
287
288     overwrite = false
289     overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
290     also = false
291     also = true if(rhs.gsub!(/^also\s+/, ""))
292
293     values = rhs.split(/\s+\|\s+/)
294     lhs = Keyword.unescape lhs
295
296     if(overwrite || also || !has_key?(lhs))
297       if(also && has_key?(lhs))
298         kw = self[lhs]
299         kw << values
300         @keywords[lhs] = kw.dump
301       else
302         @keywords[lhs] = Keyword.new(mhs, values).dump
303       end
304       m.okay if !quiet
305     elsif(has_key?(lhs))
306       kw = self[lhs]
307       m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
308     end
309   end
310
311   # return help string for Keywords with option topic +topic+
312   def help(plugin, topic = '')
313     case plugin
314     when /keyword/
315       case topic
316       when 'lookup'
317         'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
318       when 'set'
319         'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
320       when 'forget'
321         'keyword forget <keyword> => forget a keyword'
322       when 'tell'
323         'keyword tell <nick> about <keyword> => tell somebody about a keyword'
324       when 'search'
325         '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.'
326       when 'listen'
327         'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
328       when 'address'
329         'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
330       when '<reply>'
331         '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
332       when '<action>'
333         '<action> => makes keyword respond with "/me <definition>"'
334       when '<who>'
335         '<who> => replaced with questioner in reply'
336       when '<topic>'
337         '<topic> => respond by setting the topic to the rest of the definition'
338       else
339         'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
340       end
341     when "forget"
342       'forget <keyword> => forget a keyword'
343     when "tell"
344       'tell <nick> about <keyword> => tell somebody about a keyword'
345     when "learn"
346       'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
347     else
348       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
349     end
350   end
351
352   # handle a message asking the bot to tell someone about a keyword
353   def keyword_tell(m, target, key)
354     unless(kw = self[key])
355       m.reply @bot.lang.get("dunno_about_X") % key
356       return
357     end
358     if target == @bot.nick
359       m.reply "very funny, trying to make me tell something to myself"
360       return
361     end
362
363     response = kw.to_s
364     response.gsub!(/<who>/, m.sourcenick)
365     if(response =~ /^<reply>\s*(.*)/)
366       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
367       m.reply "okay, I told #{target}: (#{key}) #$1"
368     elsif(response =~ /^<action>\s*(.*)/)
369       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
370       m.reply "okay, I told #{target}: * #$1"
371     else
372       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
373       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
374     end
375   end
376
377   # return the number of known keywords
378   def keyword_stats(m)
379     length = 0
380     @statickeywords.each {|k,v|
381       length += v.length
382     }
383     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
384   end
385
386   # search for keywords, optionally also the definition and the static keywords
387   def keyword_search(m, key, full = false, all = false, from = 1)
388     begin
389       if key =~ /^\/(.+)\/$/
390         re = Regexp.new($1, Regexp::IGNORECASE)
391       else
392         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
393       end
394
395       matches = Array.new
396       @keywords.each {|k,v|
397         kw = Keyword.restore(v)
398         if re.match(k) || (full && re.match(kw.desc))
399           matches << [k,kw]
400         end
401       }
402       if all
403         @statickeywords.each {|k,v|
404           v.each {|kk,vv|
405             kw = Keyword.restore(vv)
406             if re.match(kk) || (full && re.match(kw.desc))
407               matches << [kk,kw]
408             end
409           }
410         }
411       end
412
413       if matches.length == 1
414         rkw = matches[0]
415         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
416       elsif matches.length > 0
417         if from > matches.length
418           m.reply "#{matches.length} found, can't tell you about #{from}"
419           return
420         end
421         i = 1
422         matches.each {|rkw|
423           m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
424           i += 1
425           break if i == from+@bot.config['keyword.search_results']
426         }
427       else
428         m.reply "no keywords match #{key}"
429       end
430     rescue RegexpError => e
431       m.reply "no keywords match #{key}: #{e}"
432     rescue
433       debug e.inspect
434       m.reply "no keywords match #{key}: an error occurred"
435     end
436   end
437
438   # forget one of the dynamic keywords
439   def keyword_forget(m, key)
440     if(@keywords.has_key?(key))
441       @keywords.delete(key)
442       @bot.okay m.replyto
443     end
444   end
445
446   # privmsg handler
447   def privmsg(m)
448     case m.plugin
449     when "keyword"
450       case m.params
451       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
452         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
453       when /^forget\s+(.+)$/
454         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
455       when /^lookup\s+(.+)$/
456         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
457       when /^stats\s*$/
458         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
459       when /^search\s+(.+)$/
460         key = $1
461         full = key.sub!('--full ', '')
462         all = key.sub!('--all ', '')
463         if key.sub!(/--from (\d+) /, '')
464           from = $1.to_i
465         else
466           from = 1
467         end
468         from = 1 unless from > 0
469         keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
470       when /^tell\s+(\S+)\s+about\s+(.+)$/
471         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
472       else
473         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
474       end
475     when "forget"
476       keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
477     when "tell"
478       if m.params =~ /(\S+)\s+about\s+(.+)$/
479         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
480       else
481         m.reply "wrong 'tell' syntax"
482       end
483     when "learn"
484       if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
485         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
486       else
487         m.reply "wrong 'learn' syntax"
488       end
489     end
490   end
491
492   def unreplied(m)
493     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
494     # keyword lookup.
495     if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
496       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
497     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
498       # TODO MUCH more selective on what's allowed here
499       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
500     end
501   end
502 end
503
504 plugin = Keywords.new
505 plugin.register 'keyword'
506 plugin.register 'forget'
507 plugin.register 'tell'
508 plugin.register 'learn'
509