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