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
10 # type of keyword (e.g. "is" or "are")
13 # type:: type of keyword (e.g "is" or "are")
14 # values:: array of values
16 # create a keyword of type +type+ with values +values+
17 def initialize(type, values)
22 # pick a random value for this keyword and return it
24 if(@values.length > 1)
25 Keyword.unescape(@values[rand(@values.length)])
27 Keyword.unescape(@values[0])
31 # return an array of all the possible values
35 debug "key #{key}, value #{val}"
36 vals = val.split(" or ")
38 ar << "%s %s %s" % [key, @type, v]
44 # describe the keyword (show all values without interpolation)
49 # return the keyword in a stringified form ready for storage
51 @type + "/" + Keyword.unescape(@values.join("<=or=>"))
54 # deserialize the stringified form to an object
55 def Keyword.restore(str)
56 if str =~ /^(\S+?)\/(.*)$/
58 vals = $2.split("<=or=>")
59 return Keyword.new(type, vals)
64 # values:: array of values to add
65 # add values to a keyword
67 if(@values.length > 1 || values.length > 1)
72 @values[0] += " or " + values[0]
76 # unescape special words/characters in a keyword
77 def Keyword.unescape(str)
78 str.gsub(/\\\|/, "|").gsub(/ \\is /, " is ").gsub(/ \\are /, " are ").gsub(/\\\?(\s*)$/, "?\1")
81 # escape special words/characters in a keyword
82 def Keyword.escape(str)
83 str.gsub(/\|/, "\\|").gsub(/ is /, " \\is ").gsub(/ are /, " \\are ").gsub(/\?(\s*)$/, "\\?\1")
89 # Handles all that stuff like "bot: foo is bar", "bot: foo?"
91 # Fallback after core and auth have had a look at a message and refused to
92 # handle it, checks for a keyword command or lookup, otherwise the message
93 # is delegated to plugins
94 class Keywords < Plugin
95 Config.register Config::BooleanValue.new('keyword.listen',
97 :desc => "Should the bot listen to all chat and attempt to automatically detect keywords? (e.g. by spotting someone say 'foo is bar')")
98 Config.register Config::BooleanValue.new('keyword.address',
100 :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")
101 Config.register Config::IntegerValue.new('keyword.search_results',
103 :desc => "How many search results to display at a time")
104 Config.register Config::ArrayValue.new('keyword.ignore_words',
105 :default => ["how", "that", "these", "they", "this", "what", "when", "where", "who", "why", "you"],
106 :desc => "A list of words that the bot should passively ignore.")
108 # create a new KeywordPlugin instance, associated to bot +bot+
112 @statickeywords = Hash.new
113 @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
118 # import old format keywords into DBHash
119 olds = @bot.path 'keywords.rbot'
121 log "auto importing old keywords.rbot"
122 IO.foreach(olds) do |line|
123 if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
127 mhs = "is" unless mhs
128 rhs = Keyword.escape rhs
129 values = rhs.split("<=or=>")
130 @keywords[lhs] = Keyword.new(mhs, values).dump
133 File.rename(olds, olds + ".old")
137 # load static keywords from files, picking up any new keyword files that
140 # first scan for old DBHash files, and convert them
141 Dir[datafile '*'].each {|f|
142 next unless f =~ /\.db$/
143 log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
144 newname = f.gsub(/\.db$/, ".kdb")
145 old = BDB::Hash.open f, nil, "r+", 0600
146 new = BDB::CIBtree.open(newname, nil, BDB::CREATE | BDB::EXCL, 0600)
155 # then scan for current DBTree files, and load them
156 Dir[@bot.path 'keywords', '*'].each {|f|
157 next unless f =~ /\.kdb$/
158 hsh = DBTree.new @bot, f, true
159 key = File.basename(f).gsub(/\.kdb$/, "")
160 debug "keywords module: loading DBTree file #{f}, key #{key}"
161 @statickeywords[key] = hsh
164 # then scan for non DB files, and convert/import them and delete
165 Dir[@bot.path 'keywords', '*'].each {|f|
166 next if f =~ /\.kdb$/
168 log "auto converting keywords from #{f}"
169 key = File.basename(f)
170 unless @statickeywords.has_key?(key)
171 @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
173 IO.foreach(f) {|line|
174 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
178 # support infobot style factfiles, by fixing them up here
179 rhs.gsub!(/\$who/, "<who>")
180 mhs = "is" unless mhs
181 rhs = Keyword.escape rhs
182 values = rhs.split("<=or=>")
183 @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
187 @statickeywords[key].flush
191 # upgrade data files found in old rbot formats to current
193 olds = @bot.path 'keywords.db'
195 log "upgrading old keywords (rbot 0.9.5 or prior) database format"
196 old = BDB::Hash.open olds, nil, "r+", 0600
202 File.rename(olds, olds + ".old")
205 olds.replace(@bot.path 'keyword.db')
207 log "upgrading old keywords (rbot 0.9.9 or prior) database format"
208 old = BDB::CIBtree.open olds, nil, "r+", 0600
214 File.rename(olds, olds + ".old")
218 # save dynamic keywords to file
224 File.open(@bot.path "keywords.rbot", "w") do |file|
225 @keywords.each do |key, value|
226 file.puts "#{key}<=#{value.type}=>#{value.dump}"
231 # lookup keyword +key+, return it or nil
233 return nil if key.nil?
234 debug "keywords module: looking up key #{key}"
235 if(@keywords.has_key?(key))
236 return Keyword.restore(@keywords[key])
238 # key name order for the lookup through these
239 @statickeywords.keys.sort.each {|k|
240 v = @statickeywords[k]
242 return Keyword.restore(v[key])
249 # does +key+ exist as a keyword?
251 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
254 @statickeywords.each {|k,v|
255 if v.has_key?(key) && Keyword.restore(v[key]) != nil
262 # is +word+ a passively ignored keyword?
263 def ignored_word?(word)
264 @bot.config["keyword.ignore_words"].include?(word)
267 # m:: PrivMessage containing message info
268 # key:: key being queried
269 # quiet:: optional, if false, complain if +key+ is not found
271 # handle a message asking about a keyword
272 def keyword_lookup(m, key, quiet = false)
274 unless(kw = self[key])
275 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
280 response.gsub!(/<who>/, m.sourcenick)
282 if(response =~ /^<reply>\s*(.*)/)
284 elsif(response =~ /^<action>\s*(.*)/)
286 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
287 @bot.topic m.target, $1
289 m.reply "#{key} #{kw.type} #{response}"
294 # handle a message which alters a keyword
295 # like "foo is bar" or "foo is also qux"
296 def keyword_command(m, lhs, mhs, rhs, quiet = false)
297 debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
298 return if lhs.strip.empty?
301 overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
303 also = true if(rhs.gsub!(/^also\s+/, ""))
305 values = rhs.split(/\s+\|\s+/)
306 lhs = Keyword.unescape lhs
308 if(overwrite || also || !has_key?(lhs))
309 if(also && has_key?(lhs))
312 @keywords[lhs] = kw.dump
314 @keywords[lhs] = Keyword.new(mhs, values).dump
319 m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
323 # return help string for Keywords with option topic +topic+
324 def help(plugin, topic = '')
329 'keyword export => exports definitions to keyword_factoids.rbot'
331 'keyword stats => show statistics about static facts'
333 'keyword wipe <keyword> => forgets everything about a keyword'
335 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
337 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
339 'keyword forget <keyword> => forget a keyword'
341 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
343 '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.'
345 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
347 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
349 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
351 '<action> => makes keyword respond with "/me <definition>"'
353 '<who> => replaced with questioner in reply'
355 '<topic> => respond by setting the topic to the rest of the definition'
357 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, stats, export, wipe, <reply>, <action>, <who>, <topic>'
360 'forget <keyword> => forget a keyword'
362 'tell <nick> about <keyword> => tell somebody about a keyword'
364 'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
366 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
370 # handle a message asking the bot to tell someone about a keyword
371 def keyword_tell(m, target, key)
372 unless(kw = self[key])
373 m.reply @bot.lang.get("dunno_about_X") % key
376 if target == @bot.nick
377 m.reply "very funny, trying to make me tell something to myself"
382 response.gsub!(/<who>/, m.sourcenick)
383 if(response =~ /^<reply>\s*(.*)/)
384 @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
385 m.reply "okay, I told #{target}: (#{key}) #$1"
386 elsif(response =~ /^<action>\s*(.*)/)
387 @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
388 m.reply "okay, I told #{target}: * #$1"
390 @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
391 m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
395 # return the number of known keywords
398 @statickeywords.each {|k,v|
401 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
404 # search for keywords, optionally also the definition and the static keywords
405 def keyword_search(m, key, full = false, all = false, from = 1)
407 if key =~ /^\/(.+)\/$/
408 re = Regexp.new($1, Regexp::IGNORECASE)
410 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
414 @keywords.each {|k,v|
415 kw = Keyword.restore(v)
416 if re.match(k) || (full && re.match(kw.desc))
421 @statickeywords.each {|k,v|
423 kw = Keyword.restore(vv)
424 if re.match(kk) || (full && re.match(kw.desc))
431 if matches.length == 1
433 m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
434 elsif matches.length > 0
435 if from > matches.length
436 m.reply "#{matches.length} found, can't tell you about #{from}"
441 m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
443 break if i == from+@bot.config['keyword.search_results']
446 m.reply "no keywords match #{key}"
448 rescue RegexpError => e
449 m.reply "no keywords match #{key}: #{e}"
452 m.reply "no keywords match #{key}: an error occurred"
456 # forget one of the dynamic keywords
457 def keyword_forget(m, key)
458 if @keywords.delete(key)
461 m.reply _("couldn't find keyword %{key}" % { :key => key })
465 # low-level keyword wipe command for when forget doesn't work
466 def keyword_wipe(m, key)
467 reg = @keywords.registry
468 reg.env.begin(reg) { |t, b|
470 (k == key) && (m.reply "wiping keyword #{key} with stored value #{Marshal.restore(v)}")
477 # export keywords to factoids file
478 def keyword_factoids_export
483 @keywords.each { |k, val|
485 kw = Keyword.restore(val)
486 ar |= kw.to_factoids(k)
489 # TODO check factoids config
490 # also TODO: runtime export
491 dir = @bot.path 'factoids'
492 fname = File.join(dir,"keyword_factoids.rbot")
494 Dir.mkdir(dir) unless FileTest.directory?(dir)
495 Utils.safe_save(fname) do |file|
507 keyword_factoids_export
510 m.reply _("failed to export keywords as factoids (%{err})" % {:err => $!})
512 when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
513 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
514 when /^forget\s+(.+)$/
515 keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
516 when /^wipe\s(.+)$/ # note that only one space is stripped, allowing removal of space-prefixed keywords
517 keyword_wipe(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
518 when /^lookup\s+(.+)$/
519 keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
521 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
522 when /^search\s+(.+)$/
524 full = key.sub!('--full ', '')
525 all = key.sub!('--all ', '')
526 if key.sub!(/--from (\d+) /, '')
531 from = 1 unless from > 0
532 keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
533 when /^tell\s+(\S+)\s+about\s+(.+)$/
534 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
536 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
539 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
541 if m.params =~ /(\S+)\s+about\s+(.+)$/
542 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
544 m.reply "wrong 'tell' syntax"
547 if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
548 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
550 m.reply "wrong 'learn' syntax"
556 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
558 if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
559 keyword_lookup m, $1, true if !ignored_word?($1) && @bot.auth.allow?("keyword", m.source)
560 elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
561 # TODO MUCH more selective on what's allowed here
562 keyword_command m, $1, $2, $3, true if !ignored_word?($1) && @bot.auth.allow?("keycmd", m.source)
567 plugin = Keywords.new
568 plugin.register 'keyword'
569 plugin.register 'forget' rescue nil
570 plugin.register 'tell' rescue nil
571 plugin.register 'learn' rescue nil