]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/keywords.rb
keywords plugin: inform the user when a keyword couldn't be deleted because it wasn...
[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.has_key?(key))
447       @keywords.delete(key)
448       m.okay
449     else
450       m.reply _("couldn't find keyword %{key}" % { :key => key })
451     end
452   end
453
454   # export keywords to factoids file
455   def keyword_factoids_export
456     ar = Array.new
457
458     debug @keywords.keys
459
460     @keywords.each { |k, val|
461       next unless val
462       kw = Keyword.restore(val)
463       ar |= kw.to_factoids(k)
464     }
465
466     # TODO check factoids config
467     # also TODO: runtime export
468     dir = File.join(@bot.botclass,"factoids")
469     fname = File.join(dir,"keyword_factoids.rbot")
470
471     Dir.mkdir(dir) unless FileTest.directory?(dir)
472     Utils.safe_save(fname) do |file|
473       file.puts ar
474     end
475   end
476
477   # privmsg handler
478   def privmsg(m)
479     case m.plugin
480     when "keyword"
481       case m.params
482       when /^export$/
483         begin
484           keyword_factoids_export
485           m.okay
486         rescue
487           m.reply _("failed to export keywords as factoids (%{err})" % {:err => $!})
488         end
489       when /^set\s+(.+?)\s+(is|are)\s+(.+)$/
490         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
491       when /^forget\s+(.+)$/
492         keyword_forget(m, $1) if @bot.auth.allow?('keycmd', m.source, m.replyto)
493       when /^lookup\s+(.+)$/
494         keyword_lookup(m, $1) if @bot.auth.allow?('keyword', m.source, m.replyto)
495       when /^stats\s*$/
496         keyword_stats(m) if @bot.auth.allow?('keyword', m.source, m.replyto)
497       when /^search\s+(.+)$/
498         key = $1
499         full = key.sub!('--full ', '')
500         all = key.sub!('--all ', '')
501         if key.sub!(/--from (\d+) /, '')
502           from = $1.to_i
503         else
504           from = 1
505         end
506         from = 1 unless from > 0
507         keyword_search(m, key, full, all, from) if @bot.auth.allow?('keyword', m.source, m.replyto)
508       when /^tell\s+(\S+)\s+about\s+(.+)$/
509         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
510       else
511         keyword_lookup(m, m.params) if @bot.auth.allow?('keyword', m.source, m.replyto)
512       end
513     when "forget"
514       keyword_forget(m, m.params) if @bot.auth.allow?('keycmd', m.source, m.replyto)
515     when "tell"
516       if m.params =~ /(\S+)\s+about\s+(.+)$/
517         keyword_tell(m, $1, $2) if @bot.auth.allow?('keyword', m.source, m.replyto)
518       else
519         m.reply "wrong 'tell' syntax"
520       end
521     when "learn"
522       if m.params =~ /^that\s+(.+?)\s+(is|are)\s+(.+)$/
523         keyword_command(m, $1, $2, $3) if @bot.auth.allow?('keycmd', m.source, m.replyto)
524       else
525         m.reply "wrong 'learn' syntax"
526       end
527     end
528   end
529
530   def unreplied(m)
531     # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
532     # keyword lookup.
533     if m.message =~ /^(.*\S)\s*\?\s*$/ and (m.address? or not @bot.config["keyword.address"])
534       keyword_lookup m, $1, true if @bot.auth.allow?("keyword", m.source)
535     elsif @bot.config["keyword.listen"] && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
536       # TODO MUCH more selective on what's allowed here
537       keyword_command m, $1, $2, $3, true if @bot.auth.allow?("keycmd", m.source)
538     end
539   end
540 end
541
542 plugin = Keywords.new
543 plugin.register 'keyword'
544 plugin.register 'forget' rescue nil
545 plugin.register 'tell' rescue nil
546 plugin.register 'learn' rescue nil
547