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 # describe the keyword (show all values without interpolation)
36 # return the keyword in a stringified form ready for storage
38 @type + "/" + Keyword.unescape(@values.join("<=or=>"))
41 # deserialize the stringified form to an object
42 def Keyword.restore(str)
43 if str =~ /^(\S+?)\/(.*)$/
45 vals = $2.split("<=or=>")
46 return Keyword.new(type, vals)
51 # values:: array of values to add
52 # add values to a keyword
54 if(@values.length > 1 || values.length > 1)
59 @values[0] += " or " + values[0]
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")
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")
76 # Handles all that stuff like "bot: foo is bar", "bot: foo?"
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',
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',
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',
90 :desc => "How many search results to display at a time")
92 # create a new KeywordPlugin instance, associated to bot +bot+
96 @statickeywords = Hash.new
97 @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
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*(.*)$/)
110 mhs = "is" unless mhs
111 rhs = Keyword.escape rhs
112 values = rhs.split("<=or=>")
113 @keywords[lhs] = Keyword.new(mhs, values).dump
116 File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
120 # drop static keywords and reload them from files, picking up any new
121 # keyword files that have been added
123 @statickeywords = Hash.new
127 # load static keywords from files, picking up any new keyword files that
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,
137 new = BDB::CIBtree.open(newname, nil,
138 BDB::CREATE | BDB::EXCL,
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
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$/
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
166 IO.foreach(f) {|line|
167 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
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
180 @statickeywords[key].flush
184 # upgrade data files found in old rbot formats to current
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,
195 File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
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,
207 File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
211 # save dynamic keywords to file
217 File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
218 @keywords.each do |key, value|
219 file.puts "#{key}<=#{value.type}=>#{value.dump}"
224 # lookup keyword +key+, return it or nil
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])
231 # key name order for the lookup through these
232 @statickeywords.keys.sort.each {|k|
233 v = @statickeywords[k]
235 return Keyword.restore(v[key])
242 # does +key+ exist as a keyword?
244 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
247 @statickeywords.each {|k,v|
248 if v.has_key?(key) && Keyword.restore(v[key]) != nil
255 # m:: PrivMessage containing message info
256 # key:: key being queried
257 # quiet:: optional, if false, complain if +key+ is not found
259 # handle a message asking about a keyword
260 def keyword_lookup(m, key, quiet = false)
262 unless(kw = self[key])
263 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
268 response.gsub!(/<who>/, m.sourcenick)
270 if(response =~ /^<reply>\s*(.*)/)
272 elsif(response =~ /^<action>\s*(.*)/)
274 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
275 @bot.topic m.target, $1
277 m.reply "#{key} #{kw.type} #{response}"
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?
289 overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
291 also = true if(rhs.gsub!(/^also\s+/, ""))
293 values = rhs.split(/\s+\|\s+/)
294 lhs = Keyword.unescape lhs
296 if(overwrite || also || !has_key?(lhs))
297 if(also && has_key?(lhs))
300 @keywords[lhs] = kw.dump
302 @keywords[lhs] = Keyword.new(mhs, values).dump
307 m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
311 # return help string for Keywords with option topic +topic+
312 def help(plugin, topic = '')
317 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
319 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
321 'keyword forget <keyword> => forget a keyword'
323 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
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.'
327 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
329 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
331 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
333 '<action> => makes keyword respond with "/me <definition>"'
335 '<who> => replaced with questioner in reply'
337 '<topic> => respond by setting the topic to the rest of the definition'
339 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
342 'forget <keyword> => forget a keyword'
344 'tell <nick> about <keyword> => tell somebody about a keyword'
346 'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
348 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
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
358 if target == @bot.nick
359 m.reply "very funny, trying to make me tell something to myself"
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"
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}"
377 # return the number of known keywords
380 @statickeywords.each {|k,v|
383 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
386 # search for keywords, optionally also the definition and the static keywords
387 def keyword_search(m, key, full = false, all = false, from = 1)
389 if key =~ /^\/(.+)\/$/
390 re = Regexp.new($1, Regexp::IGNORECASE)
392 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
396 @keywords.each {|k,v|
397 kw = Keyword.restore(v)
398 if re.match(k) || (full && re.match(kw.desc))
403 @statickeywords.each {|k,v|
405 kw = Keyword.restore(vv)
406 if re.match(kk) || (full && re.match(kw.desc))
413 if matches.length == 1
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}"
423 m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
425 break if i == from+@bot.config['keyword.search_results']
428 m.reply "no keywords match #{key}"
430 rescue RegexpError => e
431 m.reply "no keywords match #{key}: #{e}"
434 m.reply "no keywords match #{key}: an error occurred"
438 # forget one of the dynamic keywords
439 def keyword_forget(m, key)
440 if(@keywords.has_key?(key))
441 @keywords.delete(key)
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)
458 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
459 when /^search\s+(.+)$/
461 full = key.sub!('--full ', '')
462 all = key.sub!('--all ', '')
463 if key.sub!(/--from (\d+) /, '')
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)
473 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
476 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
478 if m.params =~ /(\S+)\s+about\s+(.+)$/
479 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
481 m.reply "wrong 'tell' syntax"
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)
487 m.reply "wrong 'learn' syntax"
493 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
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)
504 plugin = Keywords.new
505 plugin.register 'keyword'
506 plugin.register 'forget'
507 plugin.register 'tell'
508 plugin.register 'learn'