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 if(File.exist?("#{@bot.botclass}/keywords.rbot"))
120 log "auto importing old keywords.rbot"
121 IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
122 if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
126 mhs = "is" unless mhs
127 rhs = Keyword.escape rhs
128 values = rhs.split("<=or=>")
129 @keywords[lhs] = Keyword.new(mhs, values).dump
132 File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
136 # load static keywords from files, picking up any new keyword files that
139 # first scan for old DBHash files, and convert them
140 Dir["#{@bot.botclass}/keywords/*"].each {|f|
141 next unless f =~ /\.db$/
142 log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
143 newname = f.gsub(/\.db$/, ".kdb")
144 old = BDB::Hash.open f, nil,
146 new = BDB::CIBtree.open(newname, nil,
147 BDB::CREATE | BDB::EXCL,
157 # then scan for current DBTree files, and load them
158 Dir["#{@bot.botclass}/keywords/*"].each {|f|
159 next unless f =~ /\.kdb$/
160 hsh = DBTree.new @bot, f, true
161 key = File.basename(f).gsub(/\.kdb$/, "")
162 debug "keywords module: loading DBTree file #{f}, key #{key}"
163 @statickeywords[key] = hsh
166 # then scan for non DB files, and convert/import them and delete
167 Dir["#{@bot.botclass}/keywords/*"].each {|f|
168 next if f =~ /\.kdb$/
170 log "auto converting keywords from #{f}"
171 key = File.basename(f)
172 unless @statickeywords.has_key?(key)
173 @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
175 IO.foreach(f) {|line|
176 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
180 # support infobot style factfiles, by fixing them up here
181 rhs.gsub!(/\$who/, "<who>")
182 mhs = "is" unless mhs
183 rhs = Keyword.escape rhs
184 values = rhs.split("<=or=>")
185 @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
189 @statickeywords[key].flush
193 # upgrade data files found in old rbot formats to current
195 if File.exist?("#{@bot.botclass}/keywords.db")
196 log "upgrading old keywords (rbot 0.9.5 or prior) database format"
197 old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil,
204 File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
207 if File.exist?("#{@bot.botclass}/keyword.db")
208 log "upgrading old keywords (rbot 0.9.9 or prior) database format"
209 old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil,
216 File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
220 # save dynamic keywords to file
226 File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
227 @keywords.each do |key, value|
228 file.puts "#{key}<=#{value.type}=>#{value.dump}"
233 # lookup keyword +key+, return it or nil
235 return nil if key.nil?
236 debug "keywords module: looking up key #{key}"
237 if(@keywords.has_key?(key))
238 return Keyword.restore(@keywords[key])
240 # key name order for the lookup through these
241 @statickeywords.keys.sort.each {|k|
242 v = @statickeywords[k]
244 return Keyword.restore(v[key])
251 # does +key+ exist as a keyword?
253 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
256 @statickeywords.each {|k,v|
257 if v.has_key?(key) && Keyword.restore(v[key]) != nil
264 # is +word+ a passively ignored keyword?
265 def ignored_word?(word)
266 @bot.config["keyword.ignore_words"].include?(word)
269 # m:: PrivMessage containing message info
270 # key:: key being queried
271 # quiet:: optional, if false, complain if +key+ is not found
273 # handle a message asking about a keyword
274 def keyword_lookup(m, key, quiet = false)
276 unless(kw = self[key])
277 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
282 response.gsub!(/<who>/, m.sourcenick)
284 if(response =~ /^<reply>\s*(.*)/)
286 elsif(response =~ /^<action>\s*(.*)/)
288 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
289 @bot.topic m.target, $1
291 m.reply "#{key} #{kw.type} #{response}"
296 # handle a message which alters a keyword
297 # like "foo is bar" or "foo is also qux"
298 def keyword_command(m, lhs, mhs, rhs, quiet = false)
299 debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
300 return if lhs.strip.empty?
303 overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
305 also = true if(rhs.gsub!(/^also\s+/, ""))
307 values = rhs.split(/\s+\|\s+/)
308 lhs = Keyword.unescape lhs
310 if(overwrite || also || !has_key?(lhs))
311 if(also && has_key?(lhs))
314 @keywords[lhs] = kw.dump
316 @keywords[lhs] = Keyword.new(mhs, values).dump
321 m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
325 # return help string for Keywords with option topic +topic+
326 def help(plugin, topic = '')
331 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
333 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
335 'keyword forget <keyword> => forget a keyword'
337 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
339 '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.'
341 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
343 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
345 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
347 '<action> => makes keyword respond with "/me <definition>"'
349 '<who> => replaced with questioner in reply'
351 '<topic> => respond by setting the topic to the rest of the definition'
353 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
356 'forget <keyword> => forget a keyword'
358 'tell <nick> about <keyword> => tell somebody about a keyword'
360 'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
362 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
366 # handle a message asking the bot to tell someone about a keyword
367 def keyword_tell(m, target, key)
368 unless(kw = self[key])
369 m.reply @bot.lang.get("dunno_about_X") % key
372 if target == @bot.nick
373 m.reply "very funny, trying to make me tell something to myself"
378 response.gsub!(/<who>/, m.sourcenick)
379 if(response =~ /^<reply>\s*(.*)/)
380 @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
381 m.reply "okay, I told #{target}: (#{key}) #$1"
382 elsif(response =~ /^<action>\s*(.*)/)
383 @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
384 m.reply "okay, I told #{target}: * #$1"
386 @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
387 m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
391 # return the number of known keywords
394 @statickeywords.each {|k,v|
397 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
400 # search for keywords, optionally also the definition and the static keywords
401 def keyword_search(m, key, full = false, all = false, from = 1)
403 if key =~ /^\/(.+)\/$/
404 re = Regexp.new($1, Regexp::IGNORECASE)
406 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
410 @keywords.each {|k,v|
411 kw = Keyword.restore(v)
412 if re.match(k) || (full && re.match(kw.desc))
417 @statickeywords.each {|k,v|
419 kw = Keyword.restore(vv)
420 if re.match(kk) || (full && re.match(kw.desc))
427 if matches.length == 1
429 m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
430 elsif matches.length > 0
431 if from > matches.length
432 m.reply "#{matches.length} found, can't tell you about #{from}"
437 m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
439 break if i == from+@bot.config['keyword.search_results']
442 m.reply "no keywords match #{key}"
444 rescue RegexpError => e
445 m.reply "no keywords match #{key}: #{e}"
448 m.reply "no keywords match #{key}: an error occurred"
452 # forget one of the dynamic keywords
453 def keyword_forget(m, key)
454 if @keywords.delete(key)
457 m.reply _("couldn't find keyword %{key}" % { :key => key })
461 # low-level keyword wipe command for when forget doesn't work
462 def keyword_wipe(m, key)
463 reg = @keywords.registry
464 reg.env.begin(reg) { |t, b|
466 (k == key) && (m.reply "wiping keyword #{key} with stored value #{Marshal.restore(v)}")
473 # export keywords to factoids file
474 def keyword_factoids_export
479 @keywords.each { |k, val|
481 kw = Keyword.restore(val)
482 ar |= kw.to_factoids(k)
485 # TODO check factoids config
486 # also TODO: runtime export
487 dir = File.join(@bot.botclass,"factoids")
488 fname = File.join(dir,"keyword_factoids.rbot")
490 Dir.mkdir(dir) unless FileTest.directory?(dir)
491 Utils.safe_save(fname) do |file|
503 keyword_factoids_export
506 m.reply _("failed to export keywords as factoids (%{err})" % {:err => $!})
508 when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
509 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
510 when /^forget\s+(.+)$/
511 keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
512 when /^wipe\s(.+)$/ # note that only one space is stripped, allowing removal of space-prefixed keywords
513 keyword_wipe(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
514 when /^lookup\s+(.+)$/
515 keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
517 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
518 when /^search\s+(.+)$/
520 full = key.sub!('--full ', '')
521 all = key.sub!('--all ', '')
522 if key.sub!(/--from (\d+) /, '')
527 from = 1 unless from > 0
528 keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
529 when /^tell\s+(\S+)\s+about\s+(.+)$/
530 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
532 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
535 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
537 if m.params =~ /(\S+)\s+about\s+(.+)$/
538 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
540 m.reply "wrong 'tell' syntax"
543 if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
544 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
546 m.reply "wrong 'learn' syntax"
552 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
554 if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
555 keyword_lookup m, $1, true if !ignored_word?($1) && @bot.auth.allow?("keyword", m.source)
556 elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
557 # TODO MUCH more selective on what's allowed here
558 keyword_command m, $1, $2, $3, true if !ignored_word?($1) && @bot.auth.allow?("keycmd", m.source)
563 plugin = Keywords.new
564 plugin.register 'keyword'
565 plugin.register 'forget' rescue nil
566 plugin.register 'tell' rescue nil
567 plugin.register 'learn' rescue nil