]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/factoids.rb
plugin(factoids): use registry for storage see #42
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / factoids.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: Factoids pluing
5 #
6 # Author:: Giuseppe "Oblomov" Bilotta <giuseppe.bilotta@gmail.com>
7 # Copyright:: (C) 2007 Giuseppe Bilotta
8 # License:: GPLv2
9 #
10 # Store (and retrieve) unstructured one-sentence factoids
11
12
13 class ::Factoid
14   def initialize(hash)
15     @hash = hash.reject { |k, val| val.nil? or val.empty? rescue false }
16     raise ArgumentError, "no fact!" unless @hash[:fact]
17     if String === @hash[:when]
18       @hash[:when] = Time.parse @hash[:when]
19     end
20   end
21
22   def to_s(opts={})
23     show_meta = opts[:meta]
24     fact = @hash[:fact]
25     if !show_meta
26       return fact
27     end
28     meta = ""
29     metadata = []
30     if @hash[:who]
31       metadata << _("from %{who}" % @hash)
32     end
33     if @hash[:when]
34       metadata << _("on %{when}" % @hash)
35     end
36     if @hash[:where]
37       metadata << _("in %{where}" % @hash)
38     end
39     unless metadata.empty?
40       meta << _(" [%{data}]" % {:data => metadata.join(" ")})
41     end
42     return fact+meta
43   end
44
45   def [](*args)
46     @hash[*args]
47   end
48
49   def []=(*args)
50     @hash.send(:[]=,*args)
51   end
52
53   def to_hsh
54     return @hash
55   end
56   alias :to_hash :to_hsh
57 end
58
59 class ::FactoidList < ArrayOf
60   def initialize(ar=[])
61     super(Factoid, ar)
62   end
63
64   def index(f)
65     fact = f.to_s
66     return if fact.empty?
67     self.map { |fs| fs[:fact] }.index(fact)
68   end
69
70   def delete(f)
71     idx = index(f)
72     return unless idx
73     self.delete_at(idx)
74   end
75
76   def grep(x)
77     self.find_all { |f|
78       x === f[:fact]
79     }
80   end
81 end
82
83 class FactoidsPlugin < Plugin
84   # TODO default should be language-specific
85   Config.register Config::ArrayValue.new('factoids.trigger_pattern',
86     :default => [
87       "(this|that|a|the|an|all|both)\\s+(.*)\\s+(is|are|has|have|does|do)\\s+.*:2",
88       "(this|that|a|the|an|all|both)\\s+(.*?)\\s+(is|are|has|have|does|do)\\s+.*:2",
89       "(.*)\\s+(is|are|has|have|does|do)\\s+.*",
90       "(.*?)\\s+(is|are|has|have|does|do)\\s+.*",
91     ],
92     :on_change => Proc.new { |bot, v| bot.plugins['factoids'].reset_triggers },
93     :desc => "A list of regular expressions matching factoids where keywords can be identified. append ':n' if the keyword is defined by the n-th group instead of the first. if the list is empty, any word will be considered a keyword")
94   Config.register Config::ArrayValue.new('factoids.not_triggers',
95     :default => [
96       "this","that","the","a","right","who","what","why"
97     ],
98     :on_change => Proc.new { |bot, v| bot.plugins['factoids'].reset_triggers },
99     :desc => "A list of words that won't be set as keywords")
100   Config.register Config::BooleanValue.new('factoids.address',
101     :default => true,
102     :desc => "Should the bot reply with relevant factoids only when addressed with a direct question? If not, the bot will attempt to lookup foo if someone says 'foo?' in channel")
103   Config.register Config::ArrayValue.new('factoids.learn_pattern',
104     :default => [
105       ".*\\s+(is|are|has|have)\\s+.*"
106     ],
107     :on_change => Proc.new { |bot, v| bot.plugins['factoids'].reset_learn_patterns },
108     :desc => "A list of regular expressions matching factoids that the bot can learn. append ':n' if the factoid is defined by the n-th group instead of the whole match.")
109   Config.register Config::BooleanValue.new('factoids.listen_and_learn',
110     :default => false,
111     :desc => "Should the bot learn factoids from what is being said in chat? if true, phrases matching patterns in factoids.learn_pattern will tell the bot when a phrase can be learned")
112   Config.register Config::BooleanValue.new('factoids.silent_listen_and_learn',
113     :default => true,
114     :desc => "Should the bot be silent about the factoids he learns from the chat? If true, the bot will not declare what he learned every time he learns something from factoids.listen_and_learn being true")
115   Config.register Config::IntegerValue.new('factoids.search_results',
116     :default => 5,
117     :desc => "How many factoids to display at a time")
118
119   def initialize
120     super
121
122     @triggers = Set.new
123     @learn_patterns = []
124
125     @factoids = @registry[:factoids]
126     unless @factoids
127       @factoids = FactoidList.new
128
129       @dir = datafile
130       @filename = "factoids.rbot"
131       debug "migrate from existing factoids #{@dir}/#{@filename}"
132       reset_learn_patterns
133       begin
134         read_factfile
135       rescue
136         debug $!
137       end
138       @changed = true
139     else
140       reset_learn_patterns
141       reset_triggers
142       @changed = false
143     end
144   end
145
146   def read_factfile(name=@filename,dir=@dir)
147     fname = File.join(dir,name)
148
149     expf = File.expand_path(fname)
150     expd = File.expand_path(dir)
151     raise ArgumentError, _("%{name} (%{fname}) must be under %{dir}" % {
152       :name => name,
153       :fname => expf,
154       :dir => dir
155     }) unless expf.index(expd) == 0
156
157     if File.exist?(fname)
158       raise ArgumentError, _("%{name} is not a file" % {
159         :name => name
160       }) unless File.file?(fname)
161       factoids = File.readlines(fname)
162       return if factoids.empty?
163       firstline = factoids.shift
164       pattern = firstline.chomp.split(" | ")
165       if pattern.length == 1 and pattern.first != "fact"
166         factoids.unshift(firstline)
167         factoids.each { |f|
168           @factoids << Factoid.new( :fact => f.chomp )
169         }
170       else
171         pattern.map! { |p| p.intern }
172         raise ArgumentError, _("fact must be the last field") unless pattern.last == :fact
173         factoids.each { |f|
174           ar = f.chomp.split(" | ", pattern.length)
175           @factoids << Factoid.new(Hash[*([pattern, ar].transpose.flatten)])
176         }
177       end
178     else
179       raise ArgumentError, _("%{name} (%{fname}) doesn't exist" % {
180         :name => name,
181         :fname => fname
182       })
183     end
184     reset_triggers
185   end
186
187   def save
188     return unless @changed
189     @registry[:factoids] = @factoids
190     @registry.flush
191     @changed = false
192   end
193
194   def trigger_patterns_to_rx
195     return [] if @bot.config['factoids.trigger_pattern'].empty?
196     @bot.config['factoids.trigger_pattern'].inject([]) { |list, str|
197       s = str.dup
198       if s =~ /:(\d+)$/
199         idx = $1.to_i
200         s.sub!(/:\d+$/,'')
201       else
202         idx = 1
203       end
204       list << [/^#{s}$/iu, idx]
205     }
206   end
207
208   def learn_patterns_to_rx
209     return [] if @bot.config['factoids.learn_pattern'].empty?
210     @bot.config['factoids.learn_pattern'].inject([]) { |list, str|
211       s = str.dup
212       if s =~ /:(\d+)$/
213         idx = $1.to_i
214         s.sub!(/:\d+$/,'')
215       else
216         idx = 0
217       end
218       list << [/^#{s}$/iu, idx]
219     }
220   end
221
222   def parse_for_trigger(f, rx=nil)
223     if !rx
224       regs = trigger_patterns_to_rx
225     else
226       regs = rx
227     end
228     if regs.empty?
229       f.to_s.scan(/\w+/u)
230     else
231       regs.inject([]) { |list, a|
232         r = a.first
233         i = a.last
234         m = r.match(f.to_s)
235         if m
236           list << m[i].downcase
237         else
238           list
239         end
240       }
241     end
242   end
243
244   def reset_triggers
245     return unless @factoids
246     start_time = Time.now
247     rx = trigger_patterns_to_rx
248     triggers = @factoids.inject(Set.new) { |set, f|
249       found = parse_for_trigger(f, rx)
250       if found.empty?
251         set
252       else
253         set | found
254       end
255     }
256     debug "Triggers done in #{Time.now - start_time}"
257     @triggers.replace(triggers - @bot.config['factoids.not_triggers'])
258   end
259
260   def reset_learn_patterns
261     @learn_patterns.replace(learn_patterns_to_rx)
262   end
263
264   def help(plugin, topic="")
265     case plugin
266     when 'learn'
267       _("learn that <factoid> => learn a factoid")
268     when 'forget'
269       _("forget fact <#num> => forget factoid number #num ; forget about <factoid> => forget a factoid")
270     else
271       _("factoids plugin: learn that <factoid>, forget that <factoid>, facts about <words>")
272     end
273   end
274
275   def learn(m, params)
276     factoid = Factoid.new(
277       :fact => params[:stuff].to_s,
278       :when => Time.now,
279       :who => m.source.fullform,
280       :where => m.channel.to_s
281     )
282     if idx = @factoids.index(factoid)
283       m.reply _("I already know that %{factoid} [#%{idx}]" % {
284         :factoid => factoid,
285         :idx => idx
286       }) unless params[:silent]
287     else
288       @factoids << factoid
289       @changed = true
290       m.reply _("okay, learned fact #%{num}: %{fact}" % { :num => @factoids.length, :fact => @factoids.last}) unless params[:silent]
291       trigs = parse_for_trigger(factoid)
292       @triggers |= trigs unless trigs.empty?
293     end
294   end
295
296   def forget(m, params)
297     if params[:index]
298       idx = params[:index].scan(/\d+/).first.to_i
299       total = @factoids.length
300       if idx <= 0 or idx > total
301         m.reply _("please select a fact number between 1 and %{total}" % { :total => total })
302         return
303       end
304       if factoid = @factoids.delete_at(idx-1)
305         m.reply _("I forgot that %{factoid}" % { :factoid => factoid })
306         @changed = true
307       else
308         m.reply _("I couldn't delete factoid %{idx}" % { :idx => idx })
309       end
310     else
311       factoid = params[:stuff].to_s
312       if @factoids.delete(factoid)
313         @changed = true
314         m.okay
315       else
316         m.reply _("I didn't know that %{factoid}" % { :factoid => factoid })
317       end
318     end
319   end
320
321   def short_fact(fact,index=nil,total=@factoids.length)
322     idx = index || @factoids.index(fact)+1
323     _("[%{idx}/%{total}] %{fact}" % {
324       :idx => idx,
325       :total => total,
326       :fact => fact.to_s(:meta => false)
327     })
328   end
329
330   def long_fact(fact,index=nil,total=@factoids.length)
331     idx = index || @factoids.index(fact)+1
332     _("fact #%{idx} of %{total}: %{fact}" % {
333       :idx => idx,
334       :total => total,
335       :fact => fact.to_s(:meta => true)
336     })
337   end
338
339   def words2rx(words)
340     # When looking for words we separate them with
341     # arbitrary whitespace, not whatever they came with
342     pre = words.map { |w| Regexp.escape(w)}.join("\\s+")
343     pre << '\b' if pre.match(/\b$/)
344     pre = '\b' + pre if pre.match(/^\b/)
345     return Regexp.new(pre, true)
346   end
347
348   def facts(m, params)
349     total = @factoids.length
350     if params[:words].nil_or_empty? and params[:rx].nil_or_empty?
351       m.reply _("I know %{total} facts" % { :total => total })
352     else
353       unless params.key? :words and not params[:words].empty?
354         rx = Regexp.new(params[:rx].to_s, true)
355       else
356         rx = words2rx(params[:words])
357       end
358       known = @factoids.grep(rx)
359       reply = []
360       if known.empty?
361         if params.key? :words
362           reply << _("I know nothing about %{words}" % params)
363         else params.key? :rx
364           reply << _("I know nothing matching %{rx}" % params)
365         end
366       else
367         max_facts = @bot.config['factoids.search_results']
368         len = known.length
369         if len > max_facts
370           m.reply _("%{len} out of %{total} facts refer to %{words}, I'll only show %{max}" % {
371             :len => len,
372             :total => total,
373             :words => params[:words].to_s,
374             :max => max_facts
375           })
376           while known.length > max_facts
377             known.delete_one
378           end
379         end
380         known.each { |f|
381           reply << short_fact(f)
382         }
383       end
384       m.reply reply.join(". "), :split_at => /\[\d+\/\d+\] /, :purge_split => false
385     end
386   end
387
388   def unreplied(m)
389     if m.message =~ /^(.*)\?\s*$/
390       return if @bot.config['factoids.address'] and !m.address?
391       return if @factoids.empty?
392       return if @triggers.empty?
393       query = $1.strip.downcase
394       if @triggers.include?(query)
395         words = query.split
396         words.instance_variable_set(:@string_value, query)
397         def words.to_s
398           @string_value
399         end
400         facts(m, :words => words)
401       end
402     else
403       return if m.address? # we don't learn stuff directed at us which is not an explicit learn command
404       return if !@bot.config['factoids.listen_and_learn'] or @learn_patterns.empty?
405       @learn_patterns.each do |pat, i|
406         g = pat.match(m.message)
407         if g and g[i]
408           learn(m, :stuff => g[i], :silent => @bot.config['factoids.silent_listen_and_learn'])
409           break
410         end
411       end
412     end
413   end
414
415   def fact(m, params)
416     fact = nil
417     idx = 0
418     total = @factoids.length
419     if params[:index]
420       idx = params[:index].scan(/\d+/).first.to_i
421       if idx <= 0 or idx > total
422         m.reply _("please select a fact number between 1 and %{total}" % { :total => total })
423         return
424       end
425       fact = @factoids[idx-1]
426     else
427       known = nil
428       if params[:words].empty?
429         if @factoids.empty?
430           m.reply _("I know nothing")
431           return
432         end
433         known = @factoids
434       else
435         rx = words2rx(params[:words])
436         known = @factoids.grep(rx)
437         if known.empty?
438           m.reply _("I know nothing about %{words}" % params)
439           return
440         end
441       end
442       fact = known.pick_one
443       idx = @factoids.index(fact)+1
444     end
445     m.reply long_fact(fact, idx, total)
446   end
447
448   def edit_fact(m, params)
449     fact = nil
450     idx = 0
451     total = @factoids.length
452     idx = params[:index].scan(/\d+/).first.to_i
453     if idx <= 0 or idx > total
454       m.reply _("please select a fact number between 1 and %{total}" % { :total => total })
455       return
456     end
457     fact = @factoids[idx-1]
458     begin
459       if params[:who]
460         who = params[:who].to_s.sub(/^me$/, m.source.fullform)
461         fact[:who] = who
462         @changed = true
463       end
464       if params[:when]
465         dstr = params[:when].to_s
466         begin
467           fact[:when] = Time.parse(dstr, "")
468           @changed = true
469         rescue
470           raise ArgumentError, _("not a date '%{dstr}'" % { :dstr => dstr })
471         end
472       end
473       if params[:where]
474         fact[:where] = params[:where].to_s
475         @changed = true
476       end
477     rescue Exception
478       m.reply _("couldn't change learn data for fact %{fact}: %{err}" % {
479         :fact => fact,
480         :err => $!
481       })
482       return
483     end
484     m.okay
485   end
486
487   def import(m, params)
488     fname = params[:filename].to_s
489     oldlen = @factoids.length
490     begin
491       read_factfile(fname)
492     rescue
493       m.reply _("failed to import facts from %{fname}: %{err}" % {
494         :fname => fname,
495         :err => $!
496       })
497     end
498     m.reply _("%{len} facts loaded from %{fname}" % {
499       :fname => fname,
500       :len => @factoids.length - oldlen
501     })
502     @changed = true
503   end
504
505 end
506
507 plugin = FactoidsPlugin.new
508
509 plugin.default_auth('edit', false)
510 plugin.default_auth('import', false)
511
512 plugin.map 'learn that *stuff'
513 plugin.map 'forget that *stuff', :auth_path => 'edit'
514 plugin.map 'forget fact :index', :requirements => { :index => /^#?\d+$/ }, :auth_path => 'edit'
515 plugin.map 'facts [about *words]'
516 plugin.map 'facts search *rx'
517 plugin.map 'fact [about *words]'
518 plugin.map 'fact :index', :requirements => { :index => /^#?\d+$/ }
519
520 plugin.map 'fact :index :learn from *who', :action => :edit_fact, :requirements => { :learn => /^((?:is|was)\s+)?learn(ed|t)$/, :index => /^#?\d+$/ }, :auth_path => 'edit'
521 plugin.map 'fact :index :learn on *when',  :action => :edit_fact, :requirements => { :learn => /^((?:is|was)\s+)?learn(ed|t)$/, :index => /^#?\d+$/ }, :auth_path => 'edit'
522 plugin.map 'fact :index :learn in *where', :action => :edit_fact, :requirements => { :learn => /^((?:is|was)\s+)?learn(ed|t)$/, :index => /^#?\d+$/ }, :auth_path => 'edit'
523
524 plugin.map 'facts import [from] *filename', :action => :import, :auth_path => 'import'