]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/keywords.rb
Make keywords.rb into a plugin and sync with 0.9.10
[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   # describe the keyword (show all values without interpolation)
32   def desc
33     @values.join(" | ")
34   end
35
36   # return the keyword in a stringified form ready for storage
37   def dump
38     @type + "/" + Keyword.unescape(@values.join("<=or=>"))
39   end
40
41   # deserialize the stringified form to an object
42   def Keyword.restore(str)
43     if str =~ /^(\S+?)\/(.*)$/
44       type = $1
45       vals = $2.split("<=or=>")
46       return Keyword.new(type, vals)
47     end
48     return nil
49   end
50
51   # values:: array of values to add
52   # add values to a keyword
53   def <<(values)
54     if(@values.length > 1 || values.length > 1)
55       values.each {|v|
56         @values << v
57       }
58     else
59       @values[0] += " or " + values[0]
60     end
61   end
62
63   # unescape special words/characters in a keyword
64   def Keyword.unescape(str)
65     str.gsub(/\\\|/, "|").gsub(/ \\is /, " is ").gsub(/ \\are /, " are ").gsub(/\\\?(\s*)$/, "?\1")
66   end
67
68   # escape special words/characters in a keyword
69   def Keyword.escape(str)
70     str.gsub(/\|/, "\\|").gsub(/ is /, " \\is ").gsub(/ are /, " \\are ").gsub(/\?(\s*)$/, "\\?\1")
71   end
72 end
73
74 # keywords class. 
75 #
76 # Handles all that stuff like "bot: foo is bar", "bot: foo?"
77 #
78 # Fallback after core and auth have had a look at a message and refused to
79 # handle it, checks for a keyword command or lookup, otherwise the message
80 # is delegated to plugins
81 class Keywords < Plugin
82   BotConfig.register BotConfigBooleanValue.new('keyword.listen',
83     :default => false,
84     :desc => "Should the bot listen to all chat and attempt to automatically detect keywords? (e.g. by spotting someone say 'foo is bar')")
85   BotConfig.register BotConfigBooleanValue.new('keyword.address',
86     :default => true,
87     :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")
88   
89   # create a new Keywords instance, associated to bot +bot+
90   def initialize
91     super
92
93     @statickeywords = Hash.new
94     @keywords = @registry.sub_registry('keywords') # DBTree.new bot, "keyword"
95     upgrade_data
96
97     scan
98     
99     # import old format keywords into DBHash
100     if(File.exist?("#{@bot.botclass}/keywords.rbot"))
101       log "auto importing old keywords.rbot"
102       IO.foreach("#{@bot.botclass}/keywords.rbot") do |line|
103         if(line =~ /^(.*?)\s*<=(is|are)?=?>\s*(.*)$/)
104           lhs = $1
105           mhs = $2
106           rhs = $3
107           mhs = "is" unless mhs
108           rhs = Keyword.escape rhs
109           values = rhs.split("<=or=>")
110           @keywords[lhs] = Keyword.new(mhs, values).dump
111         end
112       end
113       File.rename("#{@bot.botclass}/keywords.rbot", "#{@bot.botclass}/keywords.rbot.old")
114     end
115   end
116   
117   # drop static keywords and reload them from files, picking up any new
118   # keyword files that have been added
119   def rescan
120     @statickeywords = Hash.new
121     scan
122   end
123
124   # load static keywords from files, picking up any new keyword files that
125   # have been added
126   def scan
127     # first scan for old DBHash files, and convert them
128     Dir["#{@bot.botclass}/keywords/*"].each {|f|
129       next unless f =~ /\.db$/
130       log "upgrading keyword db #{f} (rbot 0.9.5 or prior) database format"
131       newname = f.gsub(/\.db$/, ".kdb")
132       old = BDB::Hash.open f, nil, 
133                            "r+", 0600
134       new = BDB::CIBtree.open(newname, nil, 
135                               BDB::CREATE | BDB::EXCL,
136                               0600)
137       old.each {|k,v|
138         new[k] = v
139       }
140       old.close
141       new.close
142       File.delete(f)
143     }
144     
145     # then scan for current DBTree files, and load them
146     Dir["#{@bot.botclass}/keywords/*"].each {|f|
147       next unless f =~ /\.kdb$/
148       hsh = DBTree.new @bot, f, true
149       key = File.basename(f).gsub(/\.kdb$/, "")
150       debug "keywords module: loading DBTree file #{f}, key #{key}"
151       @statickeywords[key] = hsh
152     }
153     
154     # then scan for non DB files, and convert/import them and delete
155     Dir["#{@bot.botclass}/keywords/*"].each {|f|
156       next if f =~ /\.kdb$/
157       next if f =~ /CVS$/
158       log "auto converting keywords from #{f}"
159       key = File.basename(f)
160       unless @statickeywords.has_key?(key)
161         @statickeywords[key] = DBHash.new @bot, "#{f}.db", true
162       end
163       IO.foreach(f) {|line|
164         if(line =~ /^(.*?)\s*<?=(is|are)?=?>\s*(.*)$/)
165           lhs = $1
166           mhs = $2
167           rhs = $3
168           # support infobot style factfiles, by fixing them up here
169           rhs.gsub!(/\$who/, "<who>")
170           mhs = "is" unless mhs
171           rhs = Keyword.escape rhs
172           values = rhs.split("<=or=>")
173           @statickeywords[key][lhs] = Keyword.new(mhs, values).dump
174         end
175       }
176       File.delete(f)
177       @statickeywords[key].flush
178     }
179   end
180
181   # upgrade data files found in old rbot formats to current
182   def upgrade_data
183     if File.exist?("#{@bot.botclass}/keywords.db")
184       log "upgrading old keywords (rbot 0.9.5 or prior) database format"
185       old = BDB::Hash.open "#{@bot.botclass}/keywords.db", nil, 
186                            "r+", 0600
187       old.each {|k,v|
188         @keywords[k] = v
189       }
190       old.close
191       @keywords.flush
192       File.rename("#{@bot.botclass}/keywords.db", "#{@bot.botclass}/keywords.db.old")
193     end
194   
195     if File.exist?("#{@bot.botclass}/keyword.db")
196       log "upgrading old keywords (rbot 0.9.9 or prior) database format"
197       old = BDB::CIBtree.open "#{@bot.botclass}/keyword.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}/keyword.db", "#{@bot.botclass}/keyword.db.old")
205     end
206   end
207
208   # save dynamic keywords to file
209   def save
210     @keywords.flush
211   end
212   def oldsave
213     File.open("#{@bot.botclass}/keywords.rbot", "w") do |file|
214       @keywords.each do |key, value|
215         file.puts "#{key}<=#{value.type}=>#{value.dump}"
216       end
217     end
218   end
219   
220   # lookup keyword +key+, return it or nil
221   def [](key)
222     return nil if key.nil?
223     debug "keywords module: looking up key #{key}"
224     if(@keywords.has_key?(key))
225       return Keyword.restore(@keywords[key])
226     else
227       # key name order for the lookup through these
228       @statickeywords.keys.sort.each {|k|
229         v = @statickeywords[k]
230         if v.has_key?(key)
231           return Keyword.restore(v[key])
232         end
233       }
234     end
235     return nil
236   end
237
238   # does +key+ exist as a keyword?
239   def has_key?(key)
240     if @keywords.has_key?(key) && Keyword.restore(@keywords[key]) != nil
241       return true
242     end
243     @statickeywords.each {|k,v|
244       if v.has_key?(key) && Keyword.restore(v[key]) != nil
245         return true
246       end
247     }
248     return false
249   end
250
251   # m::     PrivMessage containing message info
252   # key::   key being queried
253   # dunno:: optional, if true, reply "dunno" if +key+ not found
254   # 
255   # handle a message asking about a keyword
256   def keyword(m, key, dunno=true)
257     return if key.nil?
258      unless(kw = self[key])
259        m.reply @bot.lang.get("dunno") if (dunno)
260        return
261      end
262      response = kw.to_s
263      response.gsub!(/<who>/, m.sourcenick)
264      if(response =~ /^<reply>\s*(.*)/)
265        m.reply "#$1"
266      elsif(response =~ /^<action>\s*(.*)/)
267        @bot.action m.replyto, "#$1"
268      elsif(m.public? && response =~ /^<topic>\s*(.*)/)
269        topic = $1
270        @bot.topic m.target, topic
271      else
272        m.reply "#{key} #{kw.type} #{response}"
273      end
274   end
275
276   
277   # handle a message which alters a keyword
278   # like "foo is bar", or "no, foo is baz", or "foo is also qux"
279   def keyword_command(sourcenick, target, lhs, mhs, rhs, quiet=false)
280     debug "got keyword command #{lhs}, #{mhs}, #{rhs}"
281     overwrite = false
282     overwrite = true if(lhs.gsub!(/^no,\s*/, ""))
283     also = true if(rhs.gsub!(/^also\s+/, ""))
284     values = rhs.split(/\s+\|\s+/)
285     lhs = Keyword.unescape lhs
286     if(overwrite || also || !has_key?(lhs))
287       if(also && has_key?(lhs))
288         kw = self[lhs]
289         kw << values
290         @keywords[lhs] = kw.dump
291       else
292         @keywords[lhs] = Keyword.new(mhs, values).dump
293       end
294       @bot.okay target if !quiet
295     elsif(has_key?(lhs))
296       kw = self[lhs]
297       @bot.say target, "but #{lhs} #{kw.type} #{kw.desc}" if kw && !quiet
298     end
299   end
300
301   # return help string for Keywords with option topic +topic+
302   def help(plugin, topic="")
303     case topic
304       when "overview"
305         return "set: <keyword> is <definition>, overide: no, <keyword> is <definition>, add to definition: <keyword> is also <definition>, random responses: <keyword> is <definition> | <definition> [| ...], plurals: <keyword> are <definition>, escaping: \\is, \\are, \\|, specials: <reply>, <action>, <who>"
306       when "set"
307         return "set => <keyword> is <definition>"
308       when "plurals"
309         return "plurals => <keywords> are <definition>"
310       when "override"
311         return "overide => no, <keyword> is <definition>"
312       when "also"
313         return "also => <keyword> is also <definition>"
314       when "random"
315         return "random responses => <keyword> is <definition> | <definition> [| ...]"
316       when "get"
317         return "asking for keywords => (with addressing) \"<keyword>?\", (without addressing) \"'<keyword>\""
318       when "tell"
319         return "tell <nick> about <keyword> => if <keyword> is known, tell <nick>, via /msg, its definition"
320       when "forget"
321         return "forget <keyword> => forget fact <keyword>"
322       when "keywords"
323         return "keywords => show current keyword counts"
324       when "<reply>"
325         return "<reply> => normal response is \"<keyword> is <definition>\", but if <definition> begins with <reply>, the response will be \"<definition>\""
326       when "<action>"
327         return "<action> => makes keyword respnse \"/me <definition>\""
328       when "<who>"
329         return "<who> => replaced with questioner in reply"
330       when "<topic>"
331         return "<topic> => respond by setting the topic to the rest of the definition"
332       when "search"
333         return "keywords search [--all] [--full] <regexp> => search keywords for <regexp>. If --all is set, search static keywords too, if --full is set, search definitions too."
334       else
335         return "Keyword module (Fact learning and regurgitation) topics: overview, set, plurals, override, also, random, get, tell, forget, keywords, keywords search, <reply>, <action>, <who>, <topic>"
336     end
337   end
338
339   # handle a message asking the bot to tell someone about a keyword
340   def keyword_tell(m, param)
341     target = param[:target]
342     key = nil
343
344     # extract the keyword from the message, because unfortunately
345     # the message mapper doesn't preserve whtiespace
346     if m.message =~ /about\s+(.+)$/
347       key = $1
348     end
349
350     unless(kw = self[key])
351       m.reply @bot.lang.get("dunno_about_X") % key
352       return
353     end
354     
355     response = kw.to_s
356     response.gsub!(/<who>/, m.sourcenick)
357     if(response =~ /^<reply>\s*(.*)/)
358       @bot.say target, "#{m.sourcenick} wanted me to tell you: (#{key}) #$1"
359       m.reply "okay, I told #{target}: (#{key}) #$1"
360     elsif(response =~ /^<action>\s*(.*)/)
361       @bot.action target, "#$1 (#{m.sourcenick} wanted me to tell you)"
362       m.reply "okay, I told #{target}: * #$1"
363     else
364       @bot.say target, "#{m.sourcenick} wanted me to tell you that #{key} #{kw.type} #{response}"
365       m.reply "okay, I told #{target} that #{key} #{kw.type} #{response}"
366     end
367   end
368
369   # return the number of known keywords
370   def keyword_stats(m, param)
371     length = 0
372     @statickeywords.each {|k,v|
373       length += v.length
374     }
375     m.reply "There are currently #{@keywords.length} keywords, #{length} static facts defined."
376   end
377
378   # search for keywords, optionally also the definition and the static keywords
379   def keyword_search(m, param)
380     str = param[:pattern]
381     all = (param[:all] == '--all')
382     full = (param[:full] == '--full')
383     
384     begin
385       re = Regexp.new(str, Regexp::IGNORECASE)
386       if(@bot.auth.allow?("keyword", m.source, m.replyto))
387         matches = Array.new
388         @keywords.each {|k,v|
389           kw = Keyword.restore(v)
390           if re.match(k) || (full && re.match(kw.desc))
391             matches << [k,kw]
392           end
393         }
394         if all
395           @statickeywords.each {|k,v|
396             v.each {|kk,vv|
397               kw = Keyword.restore(vv)
398               if re.match(kk) || (full && re.match(kw.desc))
399                 matches << [kk,kw]
400               end
401             }
402           }
403         end
404         if matches.length == 1
405           rkw = matches[0]
406           m.reply "#{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
407         elsif matches.length > 0
408           i = 0
409           matches.each {|rkw|
410             m.reply "[#{i+1}/#{matches.length}] #{rkw[0]} #{rkw[1].type} #{rkw[1].desc}"
411             i += 1
412             break if i == 3
413           }
414         else
415           m.reply "no keywords match #{str}"
416         end
417       end
418     rescue RegexpError => e
419       m.reply "no keywords match #{str}: #{e}"
420     rescue
421       debug e.inspect
422       m.reply "no keywords match #{str}: an error occurred"
423     end
424   end
425
426   # forget one of the dynamic keywords
427   def keyword_forget(m, param)
428     key = param[:key]
429     if(@keywords.has_key?(key))
430       @keywords.delete(key)
431       @bot.okay m.replyto
432     end
433   end
434
435   # privmsg handler
436   def listen(m)
437     return if m.replied?
438     if(m.address?)
439       if(!(m.message =~ /\\\?\s*$/) && m.message =~ /^(.*\S)\s*\?\s*$/)
440         keyword m, $1 if(@bot.auth.allow?("keyword", m.source, m.replyto))
441       elsif(m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/)
442         keyword_command(m.sourcenick, m.replyto, $1, $2, $3) if(@bot.auth.allow?("keycmd", m.source, m.replyto))
443       end
444     else
445       # in channel message, not to me
446       # TODO option to do if(m.message =~ /^(.*)$/, ie try any line as a
447       # keyword lookup.
448       if(m.message =~ /^'(.*)$/ || (!@bot.config["keyword.address"] && m.message =~ /^(.*\S)\s*\?\s*$/))
449         keyword m, $1, false if(@bot.auth.allow?("keyword", m.source))
450       elsif(@bot.config["keyword.listen"] == true && (m.message =~ /^(.*?)\s+(is|are)\s+(.*)$/))
451         # TODO MUCH more selective on what's allowed here
452         keyword_command(m.sourcenick, m.replyto, $1, $2, $3, true) if(@bot.auth.allow?("keycmd", m.source))
453       end
454     end
455   end
456 end
457
458 plugin = Keywords.new
459
460 plugin.map 'keyword stats', :action => 'keyword_stats'
461
462 plugin.map 'keyword search :all :full :pattern', :action => 'keyword_search',
463            :defaults => {:all => '', :full => ''},
464            :requirements => {:all => '--all', :full => '--full'}
465            
466 plugin.map 'keyword forget :key', :action => 'keyword_forget'
467 plugin.map 'forget :key', :action => 'keyword_forget', :auth => 'keycmd'
468
469 plugin.map 'keyword tell :target about *keyword', :action => 'keyword_tell'
470 plugin.map 'tell :target about *keyword', :action => 'keyword_tell', :auth => 'keyword'