]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/keywords.rb
plugin(keywords): remove export to file, see #42
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / keywords.rb
1 require 'pp'
2
3 # Keyword class
4 #
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
8 class Keyword
9
10   # type of keyword (e.g. "is" or "are")
11   attr_reader :type
12
13   # type::   type of keyword (e.g "is" or "are")
14   # values:: array of values
15   #
16   # create a keyword of type +type+ with values +values+
17   def initialize(type, values)
18     @type = type.downcase
19     @values = values
20   end
21
22   # pick a random value for this keyword and return it
23   def to_s
24     if(@values.length > 1)
25       Keyword.unescape(@values[rand(@values.length)])
26     else
27       Keyword.unescape(@values[0])
28     end
29   end
30
31   # return an array of all the possible values
32   def to_factoids(key)
33     ar = Array.new
34     @values.each { |val|
35       debug "key #{key}, value #{val}"
36       vals = val.split(" or ")
37       vals.each { |v|
38         ar << "%s %s %s" % [key, @type, v]
39       }
40     }
41     return ar
42   end
43
44   # describe the keyword (show all values without interpolation)
45   def desc
46     @values.join(" | ")
47   end
48
49   # return the keyword in a stringified form ready for storage
50   def dump
51     @type + "/" + Keyword.unescape(@values.join("<=or=>"))
52   end
53
54   # deserialize the stringified form to an object
55   def Keyword.restore(str)
56     if str =~ /^(\S+?)\/(.*)$/
57       type = $1
58       vals = $2.split("<=or=>")
59       return Keyword.new(type, vals)
60     end
61     return nil
62   end
63
64   # values:: array of values to add
65   # add values to a keyword
66   def <<(values)
67     if(@values.length > 1 || values.length > 1)
68       values.each {|v|
69         @values << v
70       }
71     else
72       @values[0] += " or " + values[0]
73     end
74   end
75
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")
79   end
80
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")
84   end
85 end
86
87 # keywords class.
88 #
89 # Handles all that stuff like "bot: foo is bar", "bot: foo?"
90 #
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',
96     :default => false,
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',
99     :default => true,
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',
102     :default => 3,
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.")
107
108   # create a new KeywordPlugin instance, associated to bot +bot+
109   def initialize
110     super
111
112     @statickeywords = Hash.new
113     @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
114     upgrade_data
115
116     scan
117
118     # import old format keywords into DBHash
119     olds = @bot.path 'keywords.rbot'
120     if File.exist? olds
121       log "auto importing old keywords.rbot"
122       IO.foreach(olds) do |line|
123         if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
124           lhs = $1
125           mhs = $2
126           rhs = $3
127           mhs = "is" unless mhs
128           rhs = Keyword.escape rhs
129           values = rhs.split("<=or=>")
130           @keywords[lhs] = Keyword.new(mhs, values).dump
131         end
132       end
133       File.rename(olds, olds + ".old")
134     end
135   end
136
137   # load static keywords from files, picking up any new keyword files that
138   # have been added
139   def scan
140     # first scan for old DBHash files, and convert them
141     Dir[datafile('*')].each {|f|
142       next unless f =~ /\.db$/
143       log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
144       newname = f.gsub(/\.db$/, ".kdb")
145       old = BDB::Hash.open f, nil, "r+", 0600
146       new = BDB::CIBtree.open(newname, nil, BDB::CREATE | BDB::EXCL, 0600)
147       old.each {|k,v|
148         new[k] = v
149       }
150       old.close
151       new.close
152       File.delete(f)
153     }
154
155     # then scan for current DBTree files, and load them
156     Dir[@bot.path('keywords', '*')].each {|f|
157       next unless f =~ /\.kdb$/
158       hsh = DBTree.new @bot, f, true
159       key = File.basename(f).gsub(/\.kdb$/, "")
160       debug "keywords module: loading DBTree file #{f}, key #{key}"
161       @statickeywords[key] = hsh
162     }
163
164     # then scan for non DB files, and convert/import them and delete
165     Dir[@bot.path('keywords', '*')].each {|f|
166       next if f =~ /\.kdb$/
167       next if f =~ /CVS$/
168       log "auto converting keywords from #{f}"
169       key = File.basename(f)
170       unless @statickeywords.has_key?(key)
171         @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
172       end
173       IO.foreach(f) {|line|
174         if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
175           lhs = $1
176           mhs = $2
177           rhs = $3
178           # support infobot style factfiles, by fixing them up here
179           rhs.gsub!(/\$who/, "<who>")
180           mhs = "is" unless mhs
181           rhs = Keyword.escape rhs
182           values = rhs.split("<=or=>")
183           @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
184         end
185       }
186       File.delete(f)
187       @statickeywords[key].flush
188     }
189   end
190
191   # upgrade data files found in old rbot formats to current
192   def upgrade_data
193     olds = @bot.path 'keywords.db'
194     if File.exist? olds
195       log "upgrading old keywords (rbot 0.9.5 or prior) database format"
196       old = BDB::Hash.open olds, nil, "r+", 0600
197       old.each {|k,v|
198         @keywords[k] = v
199       }
200       old.close
201       @keywords.flush
202       File.rename(olds, olds + ".old")
203     end
204
205     olds.replace(@bot.path('keyword.db'))
206     if File.exist? olds
207       log "upgrading old keywords (rbot 0.9.9 or prior) database format"
208       old = BDB::CIBtree.open olds, nil, "r+", 0600
209       old.each {|k,v|
210         @keywords[k] = v
211       }
212       old.close
213       @keywords.flush
214       File.rename(olds, olds + ".old")
215     end
216   end
217
218   # save dynamic keywords to file
219   def save
220     @keywords.flush
221   end
222
223   def oldsave
224     File.open(@bot.path("keywords.rbot"), "w") do |file|
225       @keywords.each do |key, value|
226         file.puts "#{key}<=#{value.type}=>#{value.dump}"
227       end
228     end
229   end
230
231   # lookup keyword +key+, return it or nil
232   def [](key)
233     return nil if key.nil?
234     debug "keywords module: looking up key #{key}"
235     if(@keywords.has_key?(key))
236       return Keyword.restore(@keywords[key])
237     else
238       # key name order for the lookup through these
239       @statickeywords.keys.sort.each {|k|
240         v = @statickeywords[k]
241         if v.has_key?(key)
242           return Keyword.restore(v[key])
243         end
244       }
245     end
246     return nil
247   end
248
249   # does +key+ exist as a keyword?
250   def has_key?(key)
251     if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
252       return true
253     end
254     @statickeywords.each {|k,v|
255       if v.has_key?(key) && Keyword.restore(v[key]) != nil
256         return true
257       end
258     }
259     return false
260   end
261
262   # is +word+ a passively ignored keyword?
263   def ignored_word?(word)
264     @bot.config["keyword.ignore_words"].include?(word)
265   end
266
267   # m::     PrivMessage containing message info
268   # key::   key being queried
269   # quiet:: optional, if false, complain if +key+ is not found
270   #
271   # handle a message asking about a keyword
272   def keyword_lookup(m, key, quiet = false)
273     return if key.nil?
274     unless(kw = self[key])
275       m.reply "sorry, I don't know about \"#{key}\"" unless quiet
276       return
277     end
278
279     response = kw.to_s
280     response.gsub!(/<who>/, m.sourcenick)
281
282     if(response =~ /^<reply>\s*(.*)/)
283       m.reply $1
284     elsif(response =~ /^<action>\s*(.*)/)
285       m.act $1
286     elsif(m.public? && response =~ /^<topic>\s*(.*)/)
287       @bot.topic m.target, $1
288     else
289       m.reply "#{key} #{kw.type} #{response}"
290     end
291   end
292
293
294   # handle a message which alters a keyword
295   # like "foo is bar" or "foo is also qux"
296   def keyword_command(m, lhs, mhs, rhs, quiet = false)
297     debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
298     return if lhs.strip.empty?
299
300     overwrite = false
301     overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
302     also = false
303     also = true if(rhs.gsub!(/^also\s+/, ""))
304
305     values = rhs.split(/\s+\|\s+/)
306     lhs = Keyword.unescape lhs
307
308     if(overwrite || also || !has_key?(lhs))
309       if(also && has_key?(lhs))
310         kw = self[lhs]
311         kw << values
312         @keywords[lhs] = kw.dump
313       else
314         @keywords[lhs] = Keyword.new(mhs, values).dump
315       end
316       m.okay if !quiet
317     elsif(has_key?(lhs))
318       kw = self[lhs]
319       m.reply "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
320     end
321   end
322
323   # return help string for Keywords with option topic +topic+
324   def help(plugin, topic = '')
325     case plugin
326     when /keyword/
327       case topic
328       when 'stats'
329         'keyword stats => show statistics about static facts'
330       when 'wipe'
331         'keyword wipe <keyword> => forgets everything about a keyword'
332       when 'lookup'
333         'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
334       when 'set'
335         'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
336       when 'forget'
337         'keyword forget <keyword> => forget a keyword'
338       when 'tell'
339         'keyword tell <nick> about <keyword> => tell somebody about a keyword'
340       when 'search'
341         '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.'
342       when 'listen'
343         'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
344       when 'address'
345         'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
346       when '<reply>'
347         '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
348       when '<action>'
349         '<action> => makes keyword respond with "/me <definition>"'
350       when '<who>'
351         '<who> => replaced with questioner in reply'
352       when '<topic>'
353         '<topic> => respond by setting the topic to the rest of the definition'
354       else
355         'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, stats, export, wipe, <reply>, <action>, <who>, <topic>'
356       end
357     when "forget"
358       'forget <keyword> => forget a keyword'
359     when "tell"
360       'tell <nick> about <keyword> => tell somebody about a keyword'
361     when "learn"
362       'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
363     else
364       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
365     end
366   end
367
368   # handle a message asking the bot to tell someone about a keyword
369   def keyword_tell(m, target, key)
370     unless(kw = self[key])
371       m.reply @bot.lang.get("dunno_about_X") % key
372       return
373     end
374     if target == @bot.nick
375       m.reply "very funny, trying to make me tell something to myself"
376       return
377     end
378
379     response = kw.to_s
380     response.gsub!(/<who>/, m.sourcenick)
381     if(response =~ /^<reply>\s*(.*)/)
382       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
383       m.reply "okay, I told #{target}: (#{key}) #$1"
384     elsif(response =~ /^<action>\s*(.*)/)
385       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
386       m.reply "okay, I told #{target}: * #$1"
387     else
388       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
389       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
390     end
391   end
392
393   # return the number of known keywords
394   def keyword_stats(m)
395     length = 0
396     @statickeywords.each {|k,v|
397       length += v.length
398     }
399     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
400   end
401
402   # search for keywords, optionally also the definition and the static keywords
403   def keyword_search(m, key, full = false, all = false, from = 1)
404     begin
405       if key =~ /^\/(.+)\/$/
406         re = Regexp.new($1, Regexp::IGNORECASE)
407       else
408         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
409       end
410
411       matches = Array.new
412       @keywords.each {|k,v|
413         kw = Keyword.restore(v)
414         if re.match(k) || (full && re.match(kw.desc))
415           matches << [k,kw]
416         end
417       }
418       if all
419         @statickeywords.each {|k,v|
420           v.each {|kk,vv|
421             kw = Keyword.restore(vv)
422             if re.match(kk) || (full && re.match(kw.desc))
423               matches << [kk,kw]
424             end
425           }
426         }
427       end
428
429       if matches.length == 1
430         rkw = matches[0]
431         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
432       elsif matches.length > 0
433         if from > matches.length
434           m.reply "#{matches.length} found, can't tell you about #{from}"
435           return
436         end
437         i = 1
438         matches.each {|rkw|
439           m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
440           i += 1
441           break if i == from+@bot.config['keyword.search_results']
442         }
443       else
444         m.reply "no keywords match #{key}"
445       end
446     rescue RegexpError => e
447       m.reply "no keywords match #{key}: #{e}"
448     rescue
449       debug e.inspect
450       m.reply "no keywords match #{key}: an error occurred"
451     end
452   end
453
454   # forget one of the dynamic keywords
455   def keyword_forget(m, key)
456     if @keywords.delete(key)
457       m.okay
458     else
459       m.reply _("couldn't find keyword %{key}" % { :key => key })
460     end
461   end
462
463   # low-level keyword wipe command for when forget doesn't work
464   def keyword_wipe(m, key)
465     reg = @keywords.registry
466     reg.env.begin(reg) { |t, b|
467       b.delete_if { |k, v|
468         (k == key) && (m.reply "wiping keyword #{key} with stored value #{Marshal.restore(v)}")
469       }
470       t.commit
471     }
472     m.reply "done"
473   end
474
475   # privmsg handler
476   def privmsg(m)
477     case m.plugin
478     when "keyword"
479       case m.params
480       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
481         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
482       when /^forget\s+(.+)$/
483         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
484       when /^wipe\s(.+)$/ # note that only one space is stripped, allowing removal of space-prefixed keywords
485         keyword_wipe(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
486       when /^lookup\s+(.+)$/
487         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
488       when /^stats\s*$/
489         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
490       when /^search\s+(.+)$/
491         key = $1
492         full = key.sub!('--full ', '')
493         all = key.sub!('--all ', '')
494         if key.sub!(/--from (\d+) /, '')
495           from = $1.to_i
496         else
497           from = 1
498         end
499         from = 1 unless from > 0
500         keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
501       when /^tell\s+(\S+)\s+about\s+(.+)$/
502         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
503       else
504         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
505       end
506     when "forget"
507       keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
508     when "tell"
509       if m.params =~ /(\S+)\s+about\s+(.+)$/
510         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
511       else
512         m.reply "wrong 'tell' syntax"
513       end
514     when "learn"
515       if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
516         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
517       else
518         m.reply "wrong 'learn' syntax"
519       end
520     end
521   end
522
523   def unreplied(m)
524     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
525     # keyword lookup.
526     if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
527       keyword_lookup m, $1, true if !ignored_word?($1) && @bot.auth.allow?("keyword", m.source)
528     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
529       # TODO MUCH more selective on what's allowed here
530       keyword_command m, $1, $2, $3, true if !ignored_word?($1) && @bot.auth.allow?("keycmd", m.source)
531     end
532   end
533 end
534
535 plugin = Keywords.new
536 plugin.register 'keyword'
537 plugin.register 'forget' rescue nil
538 plugin.register 'tell' rescue nil
539 plugin.register 'learn' rescue nil
540