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