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