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