]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/keywords.rb
da32078096152ccc33fe71f6064eb48fe0764a83
[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 'export'
329         'keyword export => exports definitions to keyword_factoids.rbot'
330       when 'stats'
331         'keyword stats => show statistics about static facts'
332       when 'wipe'
333         'keyword wipe <keyword> => forgets everything about a keyword'
334       when 'lookup'
335         'keyword [lookup] <keyword> => look up the definition for a keyword; writing "lookup" is optional'
336       when 'set'
337         'keyword set <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
338       when 'forget'
339         'keyword forget <keyword> => forget a keyword'
340       when 'tell'
341         'keyword tell <nick> about <keyword> => tell somebody about a keyword'
342       when 'search'
343         '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.'
344       when 'listen'
345         'when the config option "keyword.listen" is set to false, rbot will try to extract keyword definitions from regular channel messages'
346       when 'address'
347         'when the config option "keyword.address" is set to true, rbot will try to answer channel questions of the form "<keyword>?"'
348       when '<reply>'
349         '<reply> => normal response is "<keyword> is <definition>", but if <definition> begins with <reply>, the response will be "<definition>"'
350       when '<action>'
351         '<action> => makes keyword respond with "/me <definition>"'
352       when '<who>'
353         '<who> => replaced with questioner in reply'
354       when '<topic>'
355         '<topic> => respond by setting the topic to the rest of the definition'
356       else
357         'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, stats, export, wipe, <reply>, <action>, <who>, <topic>'
358       end
359     when "forget"
360       'forget <keyword> => forget a keyword'
361     when "tell"
362       'tell <nick> about <keyword> => tell somebody about a keyword'
363     when "learn"
364       'learn that <keyword> is/are <definition> => define a keyword, definition can contain "|" to separate multiple randomly chosen replies'
365     else
366       'keyword module (fact learning and regurgitation) topics: lookup, set, forget, tell, search, listen, address, <reply>, <action>, <who>, <topic>'
367     end
368   end
369
370   # handle a message asking the bot to tell someone about a keyword
371   def keyword_tell(m, target, key)
372     unless(kw = self[key])
373       m.reply @bot.lang.get("dunno_about_X") % key
374       return
375     end
376     if target == @bot.nick
377       m.reply "very funny, trying to make me tell something to myself"
378       return
379     end
380
381     response = kw.to_s
382     response.gsub!(/<who>/, m.sourcenick)
383     if(response =~ /^<reply>\s*(.*)/)
384       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
385       m.reply "okay, I told #{target}: (#{key}) #$1"
386     elsif(response =~ /^<action>\s*(.*)/)
387       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
388       m.reply "okay, I told #{target}: * #$1"
389     else
390       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
391       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
392     end
393   end
394
395   # return the number of known keywords
396   def keyword_stats(m)
397     length = 0
398     @statickeywords.each {|k,v|
399       length += v.length
400     }
401     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
402   end
403
404   # search for keywords, optionally also the definition and the static keywords
405   def keyword_search(m, key, full = false, all = false, from = 1)
406     begin
407       if key =~ /^\/(.+)\/$/
408         re = Regexp.new($1, Regexp::IGNORECASE)
409       else
410         re = Regexp.new(Regexp.escape(key), Regexp::IGNORECASE)
411       end
412
413       matches = Array.new
414       @keywords.each {|k,v|
415         kw = Keyword.restore(v)
416         if re.match(k) || (full && re.match(kw.desc))
417           matches << [k,kw]
418         end
419       }
420       if all
421         @statickeywords.each {|k,v|
422           v.each {|kk,vv|
423             kw = Keyword.restore(vv)
424             if re.match(kk) || (full && re.match(kw.desc))
425               matches << [kk,kw]
426             end
427           }
428         }
429       end
430
431       if matches.length == 1
432         rkw = matches[0]
433         m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
434       elsif matches.length > 0
435         if from > matches.length
436           m.reply "#{matches.length} found, can't tell you about #{from}"
437           return
438         end
439         i = 1
440         matches.each {|rkw|
441           m.reply "[#{i}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}" if i >= from
442           i += 1
443           break if i == from+@bot.config['keyword.search_results']
444         }
445       else
446         m.reply "no keywords match #{key}"
447       end
448     rescue RegexpError => e
449       m.reply "no keywords match #{key}: #{e}"
450     rescue
451       debug e.inspect
452       m.reply "no keywords match #{key}: an error occurred"
453     end
454   end
455
456   # forget one of the dynamic keywords
457   def keyword_forget(m, key)
458     if @keywords.delete(key)
459       m.okay
460     else
461       m.reply _("couldn't find keyword %{key}" % { :key => key })
462     end
463   end
464
465   # low-level keyword wipe command for when forget doesn't work
466   def keyword_wipe(m, key)
467     reg = @keywords.registry
468     reg.env.begin(reg) { |t, b|
469       b.delete_if { |k, v|
470         (k == key) && (m.reply "wiping keyword #{key} with stored value #{Marshal.restore(v)}")
471       }
472       t.commit
473     }
474     m.reply "done"
475   end
476
477   # export keywords to factoids file
478   def keyword_factoids_export
479     ar = Array.new
480
481     debug @keywords.keys
482
483     @keywords.each { |k, val|
484       next unless val
485       kw = Keyword.restore(val)
486       ar |= kw.to_factoids(k)
487     }
488
489     # TODO check factoids config
490     # also TODO: runtime export
491     dir = @bot.path 'factoids'
492     fname = File.join(dir,"keyword_factoids.rbot")
493
494     Dir.mkdir(dir) unless FileTest.directory?(dir)
495     Utils.safe_save(fname) do |file|
496       file.puts ar
497     end
498   end
499
500   # privmsg handler
501   def privmsg(m)
502     case m.plugin
503     when "keyword"
504       case m.params
505       when /^export$/
506         begin
507           keyword_factoids_export
508           m.okay
509         rescue
510           m.reply _("failed to export keywords as factoids (%{err})" % {:err => $!})
511         end
512       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
513         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
514       when /^forget\s+(.+)$/
515         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
516       when /^wipe\s(.+)$/ # note that only one space is stripped, allowing removal of space-prefixed keywords
517         keyword_wipe(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
518       when /^lookup\s+(.+)$/
519         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
520       when /^stats\s*$/
521         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
522       when /^search\s+(.+)$/
523         key = $1
524         full = key.sub!('--full ', '')
525         all = key.sub!('--all ', '')
526         if key.sub!(/--from (\d+) /, '')
527           from = $1.to_i
528         else
529           from = 1
530         end
531         from = 1 unless from > 0
532         keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
533       when /^tell\s+(\S+)\s+about\s+(.+)$/
534         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
535       else
536         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
537       end
538     when "forget"
539       keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
540     when "tell"
541       if m.params =~ /(\S+)\s+about\s+(.+)$/
542         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
543       else
544         m.reply "wrong 'tell' syntax"
545       end
546     when "learn"
547       if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
548         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
549       else
550         m.reply "wrong 'learn' syntax"
551       end
552     end
553   end
554
555   def unreplied(m)
556     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
557     # keyword lookup.
558     if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
559       keyword_lookup m, $1, true if !ignored_word?($1) && @bot.auth.allow?("keyword", m.source)
560     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
561       # TODO MUCH more selective on what's allowed here
562       keyword_command m, $1, $2, $3, true if !ignored_word?($1) && @bot.auth.allow?("keycmd", m.source)
563     end
564   end
565 end
566
567 plugin = Keywords.new
568 plugin.register 'keyword'
569 plugin.register 'forget' rescue nil
570 plugin.register 'tell' rescue nil
571 plugin.register 'learn' rescue nil
572