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 # load static keywords from files, picking up any new keyword files that
123 # first scan for old DBHash files, and convert them
124 Dir["#{@bot.botclass}/keywords/*"].each {|f|
125 next unless f =~ /\.db$/
126 log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
127 newname = f.gsub(/\.db$/, ".kdb")
128 old = BDB::Hash.open f, nil,
130 new = BDB::CIBtree.open(newname, nil,
131 BDB::CREATE | BDB::EXCL,
141 # then scan for current DBTree files, and load them
142 Dir["#{@bot.botclass}/keywords/*"].each {|f|
143 next unless f =~ /\.kdb$/
144 hsh = DBTree.new @bot, f, true
145 key = File.basename(f).gsub(/\.kdb$/, "")
146 debug "keywords module: loading DBTree file #{f}, key #{key}"
147 @statickeywords[key] = hsh
150 # then scan for non DB files, and convert/import them and delete
151 Dir["#{@bot.botclass}/keywords/*"].each {|f|
152 next if f =~ /\.kdb$/
154 log "auto converting keywords from #{f}"
155 key = File.basename(f)
156 unless @statickeywords.has_key?(key)
157 @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
159 IO.foreach(f) {|line|
160 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
164 # support infobot style factfiles, by fixing them up here
165 rhs.gsub!(/\$who/, "<who>")
166 mhs = "is" unless mhs
167 rhs = Keyword.escape rhs
168 values = rhs.split("<=or=>")
169 @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
173 @statickeywords[key].flush
177 # upgrade data files found in old rbot formats to current
179 if File.exist?("#{@bot.botclass}/keywords.db")
180 log "upgrading old keywords (rbot 0.9.5 or prior) database format"
181 old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil,
188 File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
191 if File.exist?("#{@bot.botclass}/keyword.db")
192 log "upgrading old keywords (rbot 0.9.9 or prior) database format"
193 old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil,
200 File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
204 # save dynamic keywords to file
210 File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
211 @keywords.each do |key, value|
212 file.puts "#{key}<=#{value.type}=>#{value.dump}"
217 # lookup keyword +key+, return it or nil
219 return nil if key.nil?
220 debug "keywords module: looking up key #{key}"
221 if(@keywords.has_key?(key))
222 return Keyword.restore(@keywords[key])
224 # key name order for the lookup through these
225 @statickeywords.keys.sort.each {|k|
226 v = @statickeywords[k]
228 return Keyword.restore(v[key])
235 # does +key+ exist as a keyword?
237 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
240 @statickeywords.each {|k,v|
241 if v.has_key?(key) && Keyword.restore(v[key]) != nil
248 # m:: PrivMessage containing message info
249 # key:: key being queried
250 # quiet:: optional, if false, complain if +key+ is not found
252 # handle a message asking about a keyword
253 def keyword_lookup(m, key, quiet = false)
255 unless(kw = self[key])
256 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
261 response.gsub!(/<who>/, m.sourcenick)
263 if(response =~ /^<reply>\s*(.*)/)
265 elsif(response =~ /^<action>\s*(.*)/)
267 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
268 @bot.topic m.target, $1
270 m.reply "#{key} #{kw.type} #{response}"
275 # handle a message which alters a keyword
276 # like "foo is bar" or "foo is also qux"
277 def keyword_command(m, lhs, mhs, rhs, quiet = false)
278 debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
279 return if lhs.strip.empty?
282 overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
284 also = true if(rhs.gsub!(/^also\s+/, ""))
286 values = rhs.split(/\s+\|\s+/)
287 lhs = Keyword.unescape lhs
289 if(overwrite || also || !has_key?(lhs))
290 if(also && has_key?(lhs))
293 @keywords[lhs] = kw.dump
295 @keywords[lhs] = Keyword.new(mhs, values).dump
300 m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
304 # return help string for Keywords with option topic +topic+
305 def help(plugin, topic = '')
310 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
312 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
314 'keyword forget <keyword> => forget a keyword'
316 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
318 '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.'
320 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
322 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
324 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
326 '<action> => makes keyword respond with "/me <definition>"'
328 '<who> => replaced with questioner in reply'
330 '<topic> => respond by setting the topic to the rest of the definition'
332 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
335 'forget <keyword> => forget a keyword'
337 'tell <nick> about <keyword> => tell somebody about a keyword'
339 'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
341 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
345 # handle a message asking the bot to tell someone about a keyword
346 def keyword_tell(m, target, key)
347 unless(kw = self[key])
348 m.reply @bot.lang.get("dunno_about_X") % key
351 if target == @bot.nick
352 m.reply "very funny, trying to make me tell something to myself"
357 response.gsub!(/<who>/, m.sourcenick)
358 if(response =~ /^<reply>\s*(.*)/)
359 @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
360 m.reply "okay, I told #{target}: (#{key}) #$1"
361 elsif(response =~ /^<action>\s*(.*)/)
362 @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
363 m.reply "okay, I told #{target}: * #$1"
365 @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
366 m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
370 # return the number of known keywords
373 @statickeywords.each {|k,v|
376 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
379 # search for keywords, optionally also the definition and the static keywords
380 def keyword_search(m, key, full = false, all = false, from = 1)
382 if key =~ /^\/(.+)\/$/
383 re = Regexp.new($1, Regexp::IGNORECASE)
385 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
389 @keywords.each {|k,v|
390 kw = Keyword.restore(v)
391 if re.match(k) || (full && re.match(kw.desc))
396 @statickeywords.each {|k,v|
398 kw = Keyword.restore(vv)
399 if re.match(kk) || (full && re.match(kw.desc))
406 if matches.length == 1
408 m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
409 elsif matches.length > 0
410 if from > matches.length
411 m.reply "#{matches.length} found, can't tell you about #{from}"
416 m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
418 break if i == from+@bot.config['keyword.search_results']
421 m.reply "no keywords match #{key}"
423 rescue RegexpError => e
424 m.reply "no keywords match #{key}: #{e}"
427 m.reply "no keywords match #{key}: an error occurred"
431 # forget one of the dynamic keywords
432 def keyword_forget(m, key)
433 if(@keywords.has_key?(key))
434 @keywords.delete(key)
444 when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
445 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
446 when /^forget\s+(.+)$/
447 keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
448 when /^lookup\s+(.+)$/
449 keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
451 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
452 when /^search\s+(.+)$/
454 full = key.sub!('--full ', '')
455 all = key.sub!('--all ', '')
456 if key.sub!(/--from (\d+) /, '')
461 from = 1 unless from > 0
462 keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
463 when /^tell\s+(\S+)\s+about\s+(.+)$/
464 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
466 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
469 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
471 if m.params =~ /(\S+)\s+about\s+(.+)$/
472 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
474 m.reply "wrong 'tell' syntax"
477 if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
478 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
480 m.reply "wrong 'learn' syntax"
486 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
488 if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
489 keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
490 elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
491 # TODO MUCH more selective on what's allowed here
492 keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
497 plugin = Keywords.new
498 plugin.register 'keyword'
499 plugin.register 'forget'
500 plugin.register 'tell'
501 plugin.register 'learn'