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")
89 # create a new KeywordPlugin instance, associated to bot +bot+
93 @statickeywords = Hash.new
94 @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
99 # import old format keywords into DBHash
100 if(File.exist?("#{@bot.botclass}/keywords.rbot"))
101 log "auto importing old keywords.rbot"
102 IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
103 if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
107 mhs = "is" unless mhs
108 rhs = Keyword.escape rhs
109 values = rhs.split("<=or=>")
110 @keywords[lhs] = Keyword.new(mhs, values).dump
113 File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
117 # drop static keywords and reload them from files, picking up any new
118 # keyword files that have been added
120 @statickeywords = Hash.new
124 # load static keywords from files, picking up any new keyword files that
127 # first scan for old DBHash files, and convert them
128 Dir["#{@bot.botclass}/keywords/*"].each {|f|
129 next unless f =~ /\.db$/
130 log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
131 newname = f.gsub(/\.db$/, ".kdb")
132 old = BDB::Hash.open f, nil,
134 new = BDB::CIBtree.open(newname, nil,
135 BDB::CREATE | BDB::EXCL,
145 # then scan for current DBTree files, and load them
146 Dir["#{@bot.botclass}/keywords/*"].each {|f|
147 next unless f =~ /\.kdb$/
148 hsh = DBTree.new @bot, f, true
149 key = File.basename(f).gsub(/\.kdb$/, "")
150 debug "keywords module: loading DBTree file #{f}, key #{key}"
151 @statickeywords[key] = hsh
154 # then scan for non DB files, and convert/import them and delete
155 Dir["#{@bot.botclass}/keywords/*"].each {|f|
156 next if f =~ /\.kdb$/
158 log "auto converting keywords from #{f}"
159 key = File.basename(f)
160 unless @statickeywords.has_key?(key)
161 @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
163 IO.foreach(f) {|line|
164 if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
168 # support infobot style factfiles, by fixing them up here
169 rhs.gsub!(/\$who/, "<who>")
170 mhs = "is" unless mhs
171 rhs = Keyword.escape rhs
172 values = rhs.split("<=or=>")
173 @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
177 @statickeywords[key].flush
181 # upgrade data files found in old rbot formats to current
183 if File.exist?("#{@bot.botclass}/keywords.db")
184 log "upgrading old keywords (rbot 0.9.5 or prior) database format"
185 old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil,
192 File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
195 if File.exist?("#{@bot.botclass}/keyword.db")
196 log "upgrading old keywords (rbot 0.9.9 or prior) database format"
197 old = BDB::CIBtree.open "#{@bot.botclass}/keyword.db", nil,
204 File.rename("#{@bot.botclass}/keyword.db", "#{@bot.botclass}/keyword.db.old")
208 # save dynamic keywords to file
214 File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
215 @keywords.each do |key, value|
216 file.puts "#{key}<=#{value.type}=>#{value.dump}"
221 # lookup keyword +key+, return it or nil
223 return nil if key.nil?
224 debug "keywords module: looking up key #{key}"
225 if(@keywords.has_key?(key))
226 return Keyword.restore(@keywords[key])
228 # key name order for the lookup through these
229 @statickeywords.keys.sort.each {|k|
230 v = @statickeywords[k]
232 return Keyword.restore(v[key])
239 # does +key+ exist as a keyword?
241 if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
244 @statickeywords.each {|k,v|
245 if v.has_key?(key) && Keyword.restore(v[key]) != nil
252 # m:: PrivMessage containing message info
253 # key:: key being queried
254 # quiet:: optional, if false, complain if +key+ is not found
256 # handle a message asking about a keyword
257 def keyword_lookup(m, key, quiet = false)
259 unless(kw = self[key])
260 m.reply "sorry, I don't know about \"#{key}\"" unless quiet
265 response.gsub!(/<who>/, m.sourcenick)
267 if(response =~ /^<reply>\s*(.*)/)
269 elsif(response =~ /^<action>\s*(.*)/)
271 elsif(m.public? && response =~ /^<topic>\s*(.*)/)
272 @bot.topic m.target, $1
274 m.reply "#{key} #{kw.type} #{response}"
279 # handle a message which alters a keyword
280 # like "foo is bar" or "foo is also qux"
281 def keyword_command(m, lhs, mhs, rhs, quiet = false)
282 debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
284 also = true if(rhs.gsub!(/^also\s+/, ""))
286 values = rhs.split(/\s+\|\s+/)
287 lhs = Keyword.unescape lhs
289 if(also && has_key?(lhs))
292 @keywords[lhs] = kw.dump
294 @keywords[lhs] = Keyword.new(mhs, values).dump
297 @bot.okay m.target if !quiet
300 # return help string for Keywords with option topic +topic+
301 def help(plugin, topic = '')
306 'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
308 'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
310 'keyword forget <keyword> => forget a keyword'
312 'keyword tell <nick> about <keyword> => tell somebody about a keyword'
314 '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.'
316 'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
318 'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
320 '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
322 '<action> => makes keyword respond with "/me <definition>"'
324 '<who> => replaced with questioner in reply'
326 '<topic> => respond by setting the topic to the rest of the definition'
328 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
331 'forget <keyword> => forget a keyword'
333 'tell <nick> about <keyword> => tell somebody about a keyword'
335 'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
339 # handle a message asking the bot to tell someone about a keyword
340 def keyword_tell(m, target, key)
341 unless(kw = self[key])
342 m.reply @bot.lang.get("dunno_about_X") % key
345 if target == @bot.nick
346 m.reply "very funny, trying to make me tell something to myself"
351 response.gsub!(/<who>/, m.sourcenick)
352 if(response =~ /^<reply>\s*(.*)/)
353 @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
354 m.reply "okay, I told #{target}: (#{key}) #$1"
355 elsif(response =~ /^<action>\s*(.*)/)
356 @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
357 m.reply "okay, I told #{target}: * #$1"
359 @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
360 m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
364 # return the number of known keywords
367 @statickeywords.each {|k,v|
370 m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
373 # search for keywords, optionally also the definition and the static keywords
374 def keyword_search(m, key, full = false, all = false)
376 if key =~ /^\/(.+)\/$/
377 re = Regexp.new($1, Regexp::IGNORECASE)
379 re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
383 @keywords.each {|k,v|
384 kw = Keyword.restore(v)
385 if re.match(k) || (full && re.match(kw.desc))
390 @statickeywords.each {|k,v|
392 kw = Keyword.restore(vv)
393 if re.match(kk) || (full && re.match(kw.desc))
400 if matches.length == 1
402 m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
403 elsif matches.length > 0
406 m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
411 m.reply "no keywords match #{key}"
413 rescue RegexpError => e
414 m.reply "no keywords match #{key}: #{e}"
417 m.reply "no keywords match #{key}: an error occurred"
421 # forget one of the dynamic keywords
422 def keyword_forget(m, key)
423 if(@keywords.has_key?(key))
424 @keywords.delete(key)
434 when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
435 keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
436 when /^forget\s+(.+)$/
437 keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
438 when /^lookup\s+(.+)$/
439 keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
441 keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
442 when /^search\s+(.+)$/
444 full = key.sub!('--full ', '')
445 all = key.sub!('--all ', '')
446 keyword_search(m, key, full, all) if @bot.auth.allow?('keyword', m.source, m.replyto)
447 when /^tell\s+(\S+)\s+about\s+(.+)$/
448 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
450 keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
453 keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
455 if m.params =~ /(\S+)\s+about\s+(.+)$/
456 keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
458 m.reply "wrong 'tell' syntax"
465 # in channel message, not to me
466 # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
468 if !@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/
469 keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
470 elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
471 # TODO MUCH more selective on what's allowed here
472 keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
477 plugin = Keywords.new
478 plugin.register 'keyword'
479 plugin.register 'forget'
480 plugin.register 'tell'