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