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