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")
105 # create a new KeywordPlugin instance, associated to bot +bot+
109 @statickeywords = Hash.new
110 @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
115 # import old format keywords into DBHash
116 if(File.exist?("#{@bot.botclass}/keywords.rbot"))
117 log "auto importing old keywords.rbot"
118 IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
119 if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
123 mhs = "is" unless mhs
124 rhs = Keyword.escape rhs
125 values = rhs.split("<=or=>")
126 @keywords[lhs] = Keyword.new(mhs, values).dump
129 File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
133 # load static keywords from files, picking up any new keyword files that
136 # first scan for old DBHash files, and convert them
137 Dir["#{@bot.botclass}/keywords/*"].each {|f|
138 next unless f =~ /\.db$/
139 log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
140 newname = f.gsub(/\.db$/, ".kdb")
141 old = BDB::Hash.open f, nil,
143 new = BDB::CIBtree.open(newname, nil,
144 BDB::CREATE | BDB::EXCL,
154 # then scan for current DBTree files, and load them
155 Dir["#{@bot.botclass}/keywords/*"].each {|f|
156 next unless f =~ /\.kdb$/
157 hsh = DBTree.new @bot, f, true
158 key = File.basename(f).gsub(/\.kdb$/, "")
159 debug "keywords module: loading DBTree file #{f}, key #{key}"
160 @statickeywords[key] = hsh
163 # then scan for non DB files, and convert/import them and delete
164 Dir["#{@bot.botclass}/keywords/*"].each {|f|
165 next if f =~ /\.kdb$/
167 log "auto converting keywords from #{f}"
168 key = File.basename(f)
169 unless @statickeywords.has_key?(key)
170 @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
172 IO.foreach(f) {|line|
173 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
177 # support infobot style factfiles, by fixing them up here
178 rhs.gsub!(/\$who/, "<who>")
179 mhs = "is" unless mhs
180 rhs = Keyword.escape rhs
181 values = rhs.split("<=or=>")
182 @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
186 @statickeywords[key].flush
190 # upgrade data files found in old rbot formats to current
192 if File.exist?("#{@bot.botclass}/keywords.db")
193 log "upgrading old keywords (rbot 0.9.5 or prior) database format"
194 old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil,
201 File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
204 if File.exist?("#{@bot.botclass}/keyword.db")
205 log "upgrading old keywords (rbot 0.9.9 or prior) database format"
206 old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil,
213 File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
217 # save dynamic keywords to file
223 File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
224 @keywords.each do |key, value|
225 file.puts "#{key}<=#{value.type}=>#{value.dump}"
230 # lookup keyword +key+, return it or nil
232 return nil if key.nil?
233 debug "keywords module: looking up key #{key}"
234 if(@keywords.has_key?(key))
235 return Keyword.restore(@keywords[key])
237 # key name order for the lookup through these
238 @statickeywords.keys.sort.each {|k|
239 v = @statickeywords[k]
241 return Keyword.restore(v[key])
248 # does +key+ exist as a keyword?
250 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
253 @statickeywords.each {|k,v|
254 if v.has_key?(key) && Keyword.restore(v[key]) != nil
261 # m:: PrivMessage containing message info
262 # key:: key being queried
263 # quiet:: optional, if false, complain if +key+ is not found
265 # handle a message asking about a keyword
266 def keyword_lookup(m, key, quiet = false)
268 unless(kw = self[key])
269 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
274 response.gsub!(/<who>/, m.sourcenick)
276 if(response =~ /^<reply>\s*(.*)/)
278 elsif(response =~ /^<action>\s*(.*)/)
280 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
281 @bot.topic m.target, $1
283 m.reply "#{key} #{kw.type} #{response}"
288 # handle a message which alters a keyword
289 # like "foo is bar" or "foo is also qux"
290 def keyword_command(m, lhs, mhs, rhs, quiet = false)
291 debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
292 return if lhs.strip.empty?
295 overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
297 also = true if(rhs.gsub!(/^also\s+/, ""))
299 values = rhs.split(/\s+\|\s+/)
300 lhs = Keyword.unescape lhs
302 if(overwrite || also || !has_key?(lhs))
303 if(also && has_key?(lhs))
306 @keywords[lhs] = kw.dump
308 @keywords[lhs] = Keyword.new(mhs, values).dump
313 m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
317 # return help string for Keywords with option topic +topic+
318 def help(plugin, topic = '')
323 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
325 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
327 'keyword forget <keyword> => forget a keyword'
329 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
331 '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.'
333 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
335 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
337 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
339 '<action> => makes keyword respond with "/me <definition>"'
341 '<who> => replaced with questioner in reply'
343 '<topic> => respond by setting the topic to the rest of the definition'
345 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
348 'forget <keyword> => forget a keyword'
350 'tell <nick> about <keyword> => tell somebody about a keyword'
352 'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
354 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
358 # handle a message asking the bot to tell someone about a keyword
359 def keyword_tell(m, target, key)
360 unless(kw = self[key])
361 m.reply @bot.lang.get("dunno_about_X") % key
364 if target == @bot.nick
365 m.reply "very funny, trying to make me tell something to myself"
370 response.gsub!(/<who>/, m.sourcenick)
371 if(response =~ /^<reply>\s*(.*)/)
372 @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
373 m.reply "okay, I told #{target}: (#{key}) #$1"
374 elsif(response =~ /^<action>\s*(.*)/)
375 @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
376 m.reply "okay, I told #{target}: * #$1"
378 @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
379 m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
383 # return the number of known keywords
386 @statickeywords.each {|k,v|
389 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
392 # search for keywords, optionally also the definition and the static keywords
393 def keyword_search(m, key, full = false, all = false, from = 1)
395 if key =~ /^\/(.+)\/$/
396 re = Regexp.new($1, Regexp::IGNORECASE)
398 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
402 @keywords.each {|k,v|
403 kw = Keyword.restore(v)
404 if re.match(k) || (full && re.match(kw.desc))
409 @statickeywords.each {|k,v|
411 kw = Keyword.restore(vv)
412 if re.match(kk) || (full && re.match(kw.desc))
419 if matches.length == 1
421 m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
422 elsif matches.length > 0
423 if from > matches.length
424 m.reply "#{matches.length} found, can't tell you about #{from}"
429 m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
431 break if i == from+@bot.config['keyword.search_results']
434 m.reply "no keywords match #{key}"
436 rescue RegexpError => e
437 m.reply "no keywords match #{key}: #{e}"
440 m.reply "no keywords match #{key}: an error occurred"
444 # forget one of the dynamic keywords
445 def keyword_forget(m, key)
446 if @keywords.delete(key)
449 m.reply _("couldn't find keyword %{key}" % { :key => key })
453 # low-level keyword wipe command for when forget doesn't work
454 def keyword_wipe(m, key)
455 reg = @keywords.registry
456 reg.env.begin(reg) { |t, b|
458 (k == key) && (m.reply "wiping keyword #{key} with stored value #{Marshal.restore(v)}")
465 # export keywords to factoids file
466 def keyword_factoids_export
471 @keywords.each { |k, val|
473 kw = Keyword.restore(val)
474 ar |= kw.to_factoids(k)
477 # TODO check factoids config
478 # also TODO: runtime export
479 dir = File.join(@bot.botclass,"factoids")
480 fname = File.join(dir,"keyword_factoids.rbot")
482 Dir.mkdir(dir) unless FileTest.directory?(dir)
483 Utils.safe_save(fname) do |file|
495 keyword_factoids_export
498 m.reply _("failed to export keywords as factoids (%{err})" % {:err => $!})
500 when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
501 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
502 when /^forget\s+(.+)$/
503 keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
504 when /^wipe\s(.+)$/ # note that only one space is stripped, allowing removal of space-prefixed keywords
505 keyword_wipe(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
506 when /^lookup\s+(.+)$/
507 keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
509 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
510 when /^search\s+(.+)$/
512 full = key.sub!('--full ', '')
513 all = key.sub!('--all ', '')
514 if key.sub!(/--from (\d+) /, '')
519 from = 1 unless from > 0
520 keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
521 when /^tell\s+(\S+)\s+about\s+(.+)$/
522 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
524 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
527 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
529 if m.params =~ /(\S+)\s+about\s+(.+)$/
530 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
532 m.reply "wrong 'tell' syntax"
535 if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
536 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
538 m.reply "wrong 'learn' syntax"
544 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
546 if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
547 keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
548 elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
549 # TODO MUCH more selective on what's allowed here
550 keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
555 plugin = Keywords.new
556 plugin.register 'keyword'
557 plugin.register 'forget' rescue nil
558 plugin.register 'tell' rescue nil
559 plugin.register 'learn' rescue nil