rss: protect against nil field
[rbot] / data / rbot / plugins / rss.rb
1 #-- vim:sw=2:et
2 #++
3 #
4 # :title: RSS feed plugin for rbot
5 #
6 # Author:: Stanislav Karchebny <berkus@madfire.net>
7 # Author:: Ian Monroe <ian@monroe.nu>
8 # Author:: Mark Kretschmann <markey@web.de>
9 # Author:: Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
10 #
11 # Copyright:: (C) 2004 Stanislav Karchebny
12 # Copyright:: (C) 2005 Ian Monroe, Mark Kretschmann
13 # Copyright:: (C) 2006-2007 Giuseppe Bilotta
14 #
15 # License:: MIT license
16
17 require 'rss'
18
19 # Try to load rss/content/2.0 so we can access the data in <content:encoded>
20 # tags.
21 begin
22   require 'rss/content/2.0'
23 rescue LoadError
24 end
25
26 module ::RSS
27
28   # Add support for Slashdot namespace in RDF. The code is just an adaptation
29   # of the DublinCore code.
30   unless defined?(SLASH_PREFIX)
31     SLASH_PREFIX = 'slash'
32     SLASH_URI = "http://purl.org/rss/1.0/modules/slash/"
33
34     RDF.install_ns(SLASH_PREFIX, SLASH_URI)
35
36     module BaseSlashModel
37       def append_features(klass)
38         super
39
40         return if klass.instance_of?(Module)
41         SlashModel::ELEMENT_NAME_INFOS.each do |name, plural_name|
42           plural = plural_name || "#{name}s"
43           full_name = "#{SLASH_PREFIX}_#{name}"
44           full_plural_name = "#{SLASH_PREFIX}_#{plural}"
45           klass_name = "Slash#{Utils.to_class_name(name)}"
46
47           # This will fail with older version of the Ruby RSS module
48           begin
49             klass.install_have_children_element(name, SLASH_URI, "*",
50                                                 full_name, full_plural_name)
51             klass.install_must_call_validator(SLASH_PREFIX, SLASH_URI)
52           rescue ArgumentError
53             klass.module_eval("install_have_children_element(#{full_name.dump}, #{full_plural_name.dump})")
54           end
55
56           klass.module_eval(<<-EOC, *get_file_and_line_from_caller(0))
57           remove_method :#{full_name}     if method_defined? :#{full_name}
58           remove_method :#{full_name}=    if method_defined? :#{full_name}=
59           remove_method :set_#{full_name} if method_defined? :set_#{full_name}
60
61           def #{full_name}
62             @#{full_name}.first and @#{full_name}.first.value
63           end
64
65           def #{full_name}=(new_value)
66             @#{full_name}[0] = Utils.new_with_value_if_need(#{klass_name}, new_value)
67           end
68           alias set_#{full_name} #{full_name}=
69         EOC
70         end
71       end
72     end
73
74     module SlashModel
75       extend BaseModel
76       extend BaseSlashModel
77
78       TEXT_ELEMENTS = {
79       "department" => nil,
80       "section" => nil,
81       "comments" =>  nil,
82       "hit_parade" => nil
83       }
84
85       ELEMENT_NAME_INFOS = SlashModel::TEXT_ELEMENTS.to_a
86
87       ELEMENTS = TEXT_ELEMENTS.keys
88
89       ELEMENTS.each do |name, plural_name|
90         module_eval(<<-EOC, *get_file_and_line_from_caller(0))
91         class Slash#{Utils.to_class_name(name)} < Element
92           include RSS10
93
94           content_setup
95
96           class << self
97             def required_prefix
98               SLASH_PREFIX
99             end
100
101             def required_uri
102               SLASH_URI
103             end
104           end
105
106           @tag_name = #{name.dump}
107
108           alias_method(:value, :content)
109           alias_method(:value=, :content=)
110
111           def initialize(*args)
112             begin
113               if Utils.element_initialize_arguments?(args)
114                 super
115               else
116                 super()
117                 self.content = args[0]
118               end
119             # Older Ruby RSS module
120             rescue NoMethodError
121               super()
122               self.content = args[0]
123             end
124           end
125
126           def full_name
127             tag_name_with_prefix(SLASH_PREFIX)
128           end
129
130           def maker_target(target)
131             target.new_#{name}
132           end
133
134           def setup_maker_attributes(#{name})
135             #{name}.content = content
136           end
137         end
138       EOC
139       end
140     end
141
142     class RDF
143       class Item; include SlashModel; end
144     end
145
146     SlashModel::ELEMENTS.each do |name|
147       class_name = Utils.to_class_name(name)
148       BaseListener.install_class_name(SLASH_URI, name, "Slash#{class_name}")
149     end
150
151     SlashModel::ELEMENTS.collect! {|name| "#{SLASH_PREFIX}_#{name}"}
152   end
153
154   if self.const_defined? :Atom
155     # There are improper Atom feeds around that use the non-standard
156     # 'modified' element instead of the correct 'updated' one. Let's
157     # support it too.
158     module Atom
159       class Feed
160         class Modified < RSS::Element
161           include CommonModel
162           include DateConstruct
163         end
164         __send__("install_have_child_element",
165                  "modified", URI, nil, "modified", :content)
166
167         class Entry
168           Modified = Feed::Modified
169           __send__("install_have_child_element",
170                    "modified", URI, nil, "modified", :content)
171         end
172       end
173     end
174   end
175
176   class Element
177     class << self
178       def def_bang(name, chain)
179         class_eval %<
180           def #{name}!
181             blank2nil { #{chain.join(' rescue ')} rescue nil }
182           end
183         >, *get_file_and_line_from_caller(0)
184       end
185     end
186
187     # Atom categories are squashed to their label only
188     {
189       :link => %w{link.href link},
190       :guid => %w{guid.content guid},
191       :content => %w{content.content content},
192       :description => %w{description.content description},
193       :title => %w{title.content title},
194       :category => %w{category.content category.label category},
195       :dc_subject => %w{dc_subject},
196       :author => %w{author.name.content author.name author},
197       :dc_creator => %w{dc_creator}
198     }.each { |name, chain| def_bang name, chain }
199
200     def categories!
201       return nil unless self.respond_to? :categories
202       cats = categories.map do |c|
203         blank2nil { c.content rescue c.label rescue c rescue nil }
204       end.compact
205       cats.empty? ? nil : cats
206     end
207
208     protected
209     def blank2nil(&block)
210       x = yield
211       (x && !x.empty?) ? x : nil
212     end
213   end
214 end
215
216
217 class ::RssBlob
218   attr_accessor :url, :handle, :type, :refresh_rate, :xml, :title, :items,
219     :mutex, :watchers, :last_fetched, :http_cache, :last_success
220
221   def initialize(url,handle=nil,type=nil,watchers=[], xml=nil, lf = nil)
222     @url = url
223     if handle
224       @handle = handle
225     else
226       @handle = url
227     end
228     @type = type
229     @watchers=[]
230     @refresh_rate = nil
231     @http_cache = false
232     @xml = xml
233     @title = nil
234     @items = nil
235     @mutex = Mutex.new
236     @last_fetched = lf
237     @last_success = nil
238     sanitize_watchers(watchers)
239   end
240
241   def dup
242     @mutex.synchronize do
243       self.class.new(@url,
244                      @handle,
245                      @type ? @type.dup : nil,
246                      @watchers.dup,
247                      @xml ? @xml.dup : nil,
248                      @last_fetched)
249     end
250   end
251
252   # Downcase all watchers, possibly turning them into Strings if they weren't
253   def sanitize_watchers(list=@watchers)
254     ls = list.dup
255     @watchers.clear
256     ls.each { |w|
257       add_watch(w)
258     }
259   end
260
261   def watched?
262     !@watchers.empty?
263   end
264
265   def watched_by?(who)
266     @watchers.include?(who.downcase)
267   end
268
269   def add_watch(who)
270     if watched_by?(who)
271       return nil
272     end
273     @mutex.synchronize do
274       @watchers << who.downcase
275     end
276     return who
277   end
278
279   def rm_watch(who)
280     @mutex.synchronize do
281       @watchers.delete(who.downcase)
282     end
283   end
284
285   def to_a
286     [@handle,@url,@type,@refresh_rate,@watchers]
287   end
288
289   def to_s(watchers=false)
290     if watchers
291       a = self.to_a.flatten
292     else
293       a = self.to_a[0,3]
294     end
295     a.compact.join(" | ")
296   end
297 end
298
299 class RSSFeedsPlugin < Plugin
300   Config.register Config::IntegerValue.new('rss.head_max',
301     :default => 100, :validate => Proc.new{|v| v > 0 && v < 200},
302     :desc => "How many characters to use of a RSS item header")
303
304   Config.register Config::IntegerValue.new('rss.text_max',
305     :default => 200, :validate => Proc.new{|v| v > 0 && v < 400},
306     :desc => "How many characters to use of a RSS item text")
307
308   Config.register Config::IntegerValue.new('rss.thread_sleep',
309     :default => 300, :validate => Proc.new{|v| v > 30},
310     :desc => "How many seconds to sleep before checking RSS feeds again")
311
312   Config.register Config::IntegerValue.new('rss.announce_timeout',
313     :default => 0,
314     :desc => "Don't announce watched feed if these many seconds elapsed since the last successful update")
315
316   Config.register Config::IntegerValue.new('rss.announce_max',
317     :default => 3,
318     :desc => "Maximum number of new items to announce when a watched feed is updated")
319
320   Config.register Config::BooleanValue.new('rss.show_updated',
321     :default => true,
322     :desc => "Whether feed items for which the description was changed should be shown as new")
323
324   Config.register Config::BooleanValue.new('rss.show_links',
325     :default => true,
326     :desc => "Whether to display links from the text of a feed item.")
327
328   Config.register Config::EnumValue.new('rss.announce_method',
329     :values => ['say', 'notice'],
330     :default => 'say',
331     :desc => "Whether to display links from the text of a feed item.")
332
333   # Make an  'unique' ID for a given item, based on appropriate bot options
334   # Currently only supported is bot.config['rss.show_updated']: when false,
335   # only the guid/link is accounted for.
336
337   def make_uid(item)
338     uid = [item.guid! || item.link!]
339     if @bot.config['rss.show_updated']
340       uid.push(item.content! || item.description!)
341       uid.unshift item.title!
342     end
343     # debug "taking hash of #{uid.inspect}"
344     uid.hash
345   end
346
347
348   # We used to save the Mutex with the RssBlob, which was idiotic. And
349   # since Mutexes dumped in one version might not be restorable in another,
350   # we need a few tricks to be able to restore data from other versions of Ruby
351   #
352   # When migrating 1.8.6 => 1.8.5, all we need to do is define an empty
353   # #marshal_load() method for Mutex. For 1.8.5 => 1.8.6 we need something
354   # dirtier, as seen later on in the initialization code.
355   unless Mutex.new.respond_to?(:marshal_load)
356     class ::Mutex
357       def marshal_load(str)
358         return
359       end
360     end
361   end
362
363   # Auxiliary method used to collect two lines for rss output filters,
364   # running substitutions against DataStream _s_ optionally joined
365   # with hash _h_.
366   #
367   # For substitutions, *_wrap keys can be used to alter the content of
368   # other nonempty keys. If the value of *_wrap is a String, it will be
369   # put before and after the corresponding key; if it's an Array, the first
370   # and second elements will be used for wrapping; if it's nil, no wrapping
371   # will be done (useful to override a default wrapping).
372   #
373   # For example:
374   # :handle_wrap => '::'::
375   #   will wrap s[:handle] by prefixing and postfixing it with '::'
376   # :date_wrap => [nil, ' :: ']::
377   #   will put ' :: ' after s[:date]
378   def make_stream(line1, line2, s, h={})
379     ss = s.merge(h)
380     subs = {}
381     wraps = {}
382     ss.each do |k, v|
383       kk = k.to_s.chomp!('_wrap')
384       if kk
385         nk = kk.intern
386         case v
387         when String
388           wraps[nk] = ss[nk].wrap_nonempty(v, v)
389         when Array
390           wraps[nk] = ss[nk].wrap_nonempty(*v)
391         when nil
392           # do nothing
393         else
394           warning "ignoring #{v.inspect} wrapping of unknown class"
395         end unless ss[nk].nil?
396       else
397         subs[k] = v
398       end
399     end
400     subs.merge! wraps
401     DataStream.new([line1, line2].compact.join("\n") % subs, ss)
402   end
403
404   # Auxiliary method used to define rss output filters
405   def rss_type(key, &block)
406     @bot.register_filter(key, @outkey, &block)
407   end
408
409   # Define default output filters (rss types), and load custom ones.
410   # Custom filters are looked for in the plugin's default filter locations
411   # and in rss/types.rb under botclass.
412   # Preferably, the rss_type method should be used in these files, e.g.:
413   #   rss_type :my_type do |s|
414   #     line1 = "%{handle} and some %{author} info"
415   #     make_stream(line1, nil, s)
416   #   end
417   # to define the new type 'my_type'. The keys available in the DataStream
418   # are:
419   # item::
420   #   the actual rss item
421   # handle::
422   #   the item handle
423   # date::
424   #   the item date
425   # title::
426   #   the item title
427   # desc, link, category, author::
428   #   the item description, link, category, author
429   # at::
430   #   the string ' @ ' if the item has both an title and a link
431   # handle_wrap, date_wrap, title_wrap, ...::
432   #   these keys can be defined to wrap the corresponding elements if they
433   #   are nonempty. By default handle is wrapped with '::', date has a ' ::'
434   #   appended and title is enbolden
435   #
436   def define_filters
437     @outkey ||= :"rss.out"
438
439     # Define an HTML info filter
440     @bot.register_filter(:rss, :htmlinfo) { |s| htmlinfo_filter(s) }
441     # This is the output format used by the input filter
442     rss_type :htmlinfo do |s|
443       line1 = "%{title}%{at}%{link}"
444       make_stream(line1, nil, s)
445     end
446
447     # the default filter
448     rss_type :default do |s|
449       line1 = "%{handle}%{date}%{title}%{at}%{link}"
450       line1 << " (by %{author})" if s[:author]
451       make_stream(line1, nil, s)
452     end
453
454     @user_types ||= datafile 'types.rb'
455     load_filters
456     load_filters :path => @user_types
457   end
458
459   FEED_NS = %r{xmlns.*http://(purl\.org/rss|www.w3c.org/1999/02/22-rdf)}
460   def htmlinfo_filter(s)
461     return nil unless s[:headers] and s[:headers]['x-rbot-location']
462     return nil unless s[:headers]['content-type'].first.match(/xml|rss|atom|rdf/i) or
463       (s[:text].include?("<rdf:RDF") and s[:text].include?("<channel")) or
464       s[:text].include?("<rss") or s[:text].include?("<feed") or
465       s[:text].match(FEED_NS)
466     blob = RssBlob.new(s[:headers]['x-rbot-location'],"", :htmlinfo)
467     unless (fetchRss(blob, nil) and parseRss(blob, nil) rescue nil)
468       debug "#{s.pretty_inspect} is not an RSS feed, despite the appearances"
469       return nil
470     end
471     output = []
472     blob.items.each { |it|
473       output << printFormattedRss(blob, it)[:text]
474     }
475     return {:title => blob.title, :content => output.join(" | ")}
476   end
477
478   # Display the known rss types
479   def rss_types(m, params)
480     ar = @bot.filter_names(@outkey)
481     ar.delete(:default)
482     m.reply ar.map { |k| k.to_s }.sort!.join(", ")
483   end
484
485   attr_reader :feeds
486
487   def initialize
488     super
489
490     define_filters
491
492     if @registry.has_key?(:feeds)
493       # When migrating from Ruby 1.8.5 to 1.8.6, dumped Mutexes may render the
494       # data unrestorable. If this happens, we patch the data, thus allowing
495       # the restore to work.
496       #
497       # This is actually pretty safe for a number of reasons:
498       # * the code is only called if standard marshalling fails
499       # * the string we look for is quite unlikely to appear randomly
500       # * if the string appears somewhere and the patched string isn't recoverable
501       #   either, we'll get another (unrecoverable) error, which makes the rss
502       #   plugin unsable, just like it was if no recovery was attempted
503       # * if the string appears somewhere and the patched string is recoverable,
504       #   we may get a b0rked feed, which is eventually overwritten by a clean
505       #   one, so the worst thing that can happen is that a feed update spams
506       #   the watchers once
507       @registry.recovery = Proc.new { |val|
508         patched = val.sub(":\v@mutexo:\nMutex", ":\v@mutexo:\vObject")
509         ret = Marshal.restore(patched)
510         ret.each_value { |blob|
511           blob.mutex = nil
512           blob
513         }
514       }
515
516       @feeds = @registry[:feeds]
517       raise LoadError, "corrupted feed database" unless @feeds
518
519       @registry.recovery = nil
520
521       @feeds.keys.grep(/[A-Z]/) { |k|
522         @feeds[k.downcase] = @feeds[k]
523         @feeds.delete(k)
524       }
525       @feeds.each { |k, f|
526         f.mutex = Mutex.new
527         f.sanitize_watchers
528         parseRss(f) if f.xml
529       }
530     else
531       @feeds = Hash.new
532     end
533     @watch = Hash.new
534     rewatch_rss
535   end
536
537   def name
538     "rss"
539   end
540
541   def watchlist
542     @feeds.select { |h, f| f.watched? }
543   end
544
545   def cleanup
546     stop_watches
547     super
548   end
549
550   def save
551     unparsed = Hash.new()
552     @feeds.each { |k, f|
553       unparsed[k] = f.dup
554       # we don't want to save the mutex
555       unparsed[k].mutex = nil
556     }
557     @registry[:feeds] = unparsed
558   end
559
560   def stop_watch(handle)
561     if @watch.has_key?(handle)
562       begin
563         debug "Stopping watch #{handle}"
564         @bot.timer.remove(@watch[handle])
565         @watch.delete(handle)
566       rescue Exception => e
567         report_problem("Failed to stop watch for #{handle}", e, nil)
568       end
569     end
570   end
571
572   def stop_watches
573     @watch.each_key { |k|
574       stop_watch(k)
575     }
576   end
577
578   def help(plugin,topic="")
579     case topic
580     when "show"
581       "rss show #{Bold}handle#{Bold} [#{Bold}limit#{Bold}] : show #{Bold}limit#{Bold} (default: 5, max: 15) entries from rss #{Bold}handle#{Bold}; #{Bold}limit#{Bold} can also be in the form a..b, to display a specific range of items"
582     when "list"
583       "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})"
584     when "watched"
585       "rss watched [#{Bold}handle#{Bold}] [in #{Bold}chan#{Bold}]: list all watched rss feeds (matching #{Bold}handle#{Bold}) (in channel #{Bold}chan#{Bold})"
586     when "who", "watches", "who watches"
587       "rss who watches [#{Bold}handle#{Bold}]]: list all watchers for rss feeds (matching #{Bold}handle#{Bold})"
588     when "add"
589       "rss add #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : add a new rss called #{Bold}handle#{Bold} from url #{Bold}url#{Bold} (of type #{Bold}type#{Bold})"
590     when "change"
591       "rss change #{Bold}what#{Bold} of #{Bold}handle#{Bold} to #{Bold}new#{Bold} : change the #{Underline}handle#{Underline}, #{Underline}url#{Underline}, #{Underline}type#{Underline} or #{Underline}refresh#{Underline} rate of rss called #{Bold}handle#{Bold} to value #{Bold}new#{Bold}"
592     when /^(del(ete)?|rm)$/
593       "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}"
594     when "replace"
595       "rss replace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : try to replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold}); only works if nobody else is watching it"
596     when "forcereplace"
597       "rss forcereplace #{Bold}handle#{Bold} #{Bold}url#{Bold} [#{Bold}type#{Bold}] : replace the url of rss called #{Bold}handle#{Bold} with #{Bold}url#{Bold} (of type #{Bold}type#{Bold})"
598     when "watch"
599       "rss watch #{Bold}handle#{Bold} [#{Bold}url#{Bold} [#{Bold}type#{Bold}]]  [in #{Bold}chan#{Bold}]: watch rss #{Bold}handle#{Bold} for changes (in channel #{Bold}chan#{Bold}); when the other parameters are present, the feed will be created if it doesn't exist yet"
600     when /(un|rm)watch/
601       "rss unwatch|rmwatch #{Bold}handle#{Bold} [in #{Bold}chan#{Bold}]: stop watching rss #{Bold}handle#{Bold} (in channel #{Bold}chan#{Bold}) for changes"
602     when  /who(?: watche?s?)?/
603       "rss who watches #{Bold}handle#{Bold}: lists watches for rss #{Bold}handle#{Bold}"
604     when "rewatch"
605       "rss rewatch : restart threads that watch for changes in watched rss"
606     when "types"
607       "rss types : show the rss types for which an output format exist (all other types will use the default one)"
608     else
609       "manage RSS feeds: rss types|show|list|watched|add|change|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch|who watches"
610     end
611   end
612
613   def report_problem(report, e=nil, m=nil)
614     if m && m.respond_to?(:reply)
615       m.reply report
616     else
617       warning report
618     end
619     if e
620       debug e.inspect
621       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
622     end
623   end
624
625   def show_rss(m, params)
626     handle = params[:handle]
627     lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/)
628     debug lims.to_a.inspect
629     if lims[2]
630       ll = [[lims[1].to_i-1,lims[2].to_i-1].min,  0].max
631       ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min
632       rev = lims[1].to_i > lims[2].to_i
633     else
634       ll = 0
635       ul = [[lims[1].to_i-1, 0].max, 14].min
636       rev = false
637     end
638
639     feed = @feeds.fetch(handle.downcase, nil)
640     unless feed
641       m.reply "I don't know any feeds named #{handle}"
642       return
643     end
644
645     m.reply "lemme fetch it..."
646     title = items = nil
647     we_were_watching = false
648
649     if @watch.key?(feed.handle)
650       # If a feed is being watched, we run the watcher thread
651       # so that all watchers can be informed of changes to
652       # the feed. Before we do that, though, we remove the
653       # show requester from the watchlist, if present, lest
654       # he gets the update twice.
655       if feed.watched_by?(m.replyto)
656         we_were_watching = true
657         feed.rm_watch(m.replyto)
658       end
659       @bot.timer.reschedule(@watch[feed.handle], 0)
660       if we_were_watching
661         feed.add_watch(m.replyto)
662       end
663     else
664       fetched = fetchRss(feed, m, false)
665     end
666     return unless fetched or feed.xml
667     if fetched or not feed.items
668       parsed = parseRss(feed, m)
669     end
670     return unless feed.items
671     m.reply "using old data" unless fetched and parsed and parsed > 0
672
673     title = feed.title
674     items = feed.items
675
676     # We sort the feeds in freshness order (newer ones first)
677     items = freshness_sort(items)
678     disp = items[ll..ul]
679     disp.reverse! if rev
680
681     m.reply "Channel : #{title}"
682     disp.each do |item|
683       printFormattedRss(feed, item, {
684         :places => [m.replyto],
685         :handle => nil,
686         :date => true,
687         :announce_method => :say
688       })
689     end
690   end
691
692   def itemDate(item,ex=nil)
693     return item.pubDate if item.respond_to?(:pubDate) and item.pubDate
694     return item.date if item.respond_to?(:date) and item.date
695     return ex
696   end
697
698   def freshness_sort(items)
699     notime = Time.at(0)
700     items.sort { |a, b|
701       itemDate(b, notime) <=> itemDate(a, notime)
702     }
703   end
704
705   def list_rss(m, params)
706     wanted = params[:handle]
707     listed = @feeds.keys
708     if wanted
709       wanted_rx = Regexp.new(wanted, true)
710       listed.reject! { |handle| !handle.match(wanted_rx) }
711     end
712     listed.sort!
713     debug listed
714     if @bot.config['send.max_lines'] > 0 and listed.size > @bot.config['send.max_lines']
715       reply = listed.inject([]) do |ar, handle|
716         feed = @feeds[handle]
717         string = handle.dup
718         (string << " (#{feed.type})") if feed.type
719         (string << " (watched)") if feed.watched_by?(m.replyto)
720         ar << string
721       end.join(', ')
722     elsif listed.size > 0
723       reply = listed.inject([]) do |ar, handle|
724         feed = @feeds[handle]
725         string = "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
726         (string << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
727         (string << " (watched)") if feed.watched_by?(m.replyto)
728         ar << string
729       end.join("\n")
730     else
731       reply = "no feeds found"
732       reply << " matching #{wanted}" if wanted
733     end
734     m.reply reply, :max_lines => 0
735   end
736
737   def watched_rss(m, params)
738     wanted = params[:handle]
739     chan = params[:chan] || m.replyto
740     reply = String.new
741     watchlist.each { |handle, feed|
742       next if wanted and !handle.match(/#{wanted}/i)
743       next unless feed.watched_by?(chan)
744       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
745       (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
746       reply << "\n"
747     }
748     if reply.empty?
749       reply = "no watched feeds"
750       reply << " matching #{wanted}" if wanted
751     end
752     m.reply reply
753   end
754
755   def who_watches(m, params)
756     wanted = params[:handle]
757     reply = String.new
758     watchlist.each { |handle, feed|
759       next if wanted and !handle.match(/#{wanted}/i)
760       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
761       (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
762       reply << ": watched by #{feed.watchers.join(', ')}"
763       reply << "\n"
764     }
765     if reply.empty?
766       reply = "no watched feeds"
767       reply << " matching #{wanted}" if wanted
768     end
769     m.reply reply
770   end
771
772   def add_rss(m, params, force=false)
773     handle = params[:handle]
774     url = params[:url]
775     unless url.match(/https?/)
776       m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)"
777       return
778     end
779     type = params[:type]
780     if @feeds.fetch(handle.downcase, nil) && !force
781       m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})"
782       return
783     end
784     unless url
785       m.reply "You must specify both a handle and an url to add an RSS feed"
786       return
787     end
788     @feeds[handle.downcase] = RssBlob.new(url,handle,type)
789     reply = "Added RSS #{url} named #{handle}"
790     if type
791       reply << " (format: #{type})"
792     end
793     m.reply reply
794     return handle
795   end
796
797   def change_rss(m, params)
798     handle = params[:handle].downcase
799     feed = @feeds.fetch(handle, nil)
800     unless feed
801       m.reply "No such feed with handle #{handle}"
802       return
803     end
804     case params[:what].intern
805     when :handle
806       # preserve rename case, but beware of key
807       realnew = params[:new]
808       new = realnew.downcase
809       if feed.handle.downcase == new
810         if feed.handle == realnew
811           m.reply _("You want me to rename %{handle} to itself?") % {
812             :handle => feed.handle
813           }
814           return false
815         else
816           feed.mutex.synchronize do
817             feed.handle = realnew
818           end
819         end
820       elsif @feeds.key?(new) and @feeds[new]
821         m.reply "There already is a feed with handle #{new}"
822         return
823       else
824         feed.mutex.synchronize do
825           @feeds[new] = feed
826           @feeds.delete(handle)
827           feed.handle = realnew
828         end
829         handle = new
830       end
831     when :url
832       new = params[:new]
833       feed.mutex.synchronize do
834         feed.url = new
835       end
836     when :format, :type
837       new = params[:new]
838       new = nil if new == 'default'
839       feed.mutex.synchronize do
840         feed.type = new
841       end
842     when :refresh
843       new = params[:new].to_i
844       new = nil if new == 0
845       feed.mutex.synchronize do
846         feed.refresh_rate = new
847       end
848     else
849       m.reply "Don't know how to change #{params[:what]} for feeds"
850       return
851     end
852     m.reply "Feed changed:"
853     list_rss(m, {:handle => handle})
854   end
855
856   def del_rss(m, params, pass=false)
857     feed = unwatch_rss(m, params, true)
858     return unless feed
859     if feed.watched?
860       m.reply "someone else is watching #{feed.handle}, I won't remove it from my list"
861       return
862     end
863     @feeds.delete(feed.handle.downcase)
864     m.okay unless pass
865     return
866   end
867
868   def replace_rss(m, params)
869     handle = params[:handle]
870     if @feeds.key?(handle.downcase)
871       del_rss(m, {:handle => handle}, true)
872     end
873     if @feeds.key?(handle.downcase)
874       m.reply "can't replace #{feed.handle}"
875     else
876       add_rss(m, params, true)
877     end
878   end
879
880   def forcereplace_rss(m, params)
881     add_rss(m, params, true)
882   end
883
884   def watch_rss(m, params)
885     handle = params[:handle]
886     chan = params[:chan] || m.replyto
887     url = params[:url]
888     type = params[:type]
889     if url
890       add_rss(m, params)
891     end
892     feed = @feeds.fetch(handle.downcase, nil)
893     if feed
894       if feed.add_watch(chan)
895         watchRss(feed, m)
896         m.okay
897       else
898         m.reply "Already watching #{feed.handle} in #{chan}"
899       end
900     else
901       m.reply "Couldn't watch feed #{handle} (no such feed found)"
902     end
903   end
904
905   def unwatch_rss(m, params, pass=false)
906     handle = params[:handle].downcase
907     chan = params[:chan] || m.replyto
908     unless @feeds.has_key?(handle)
909       m.reply("dunno that feed")
910       return
911     end
912     feed = @feeds[handle]
913     if feed.rm_watch(chan)
914       m.reply "#{chan} has been removed from the watchlist for #{feed.handle}"
915     else
916       m.reply("#{chan} wasn't watching #{feed.handle}") unless pass
917     end
918     if !feed.watched?
919       stop_watch(handle)
920     end
921     return feed
922   end
923
924   def rewatch_rss(m=nil, params=nil)
925     if params and handle = params[:handle]
926       feed = @feeds.fetch(handle.downcase, nil)
927       if feed
928         feed.http_cache = false
929         @bot.timer.reschedule(@watch[feed.handle], (params[:delay] || 0).to_f)
930         m.okay if m
931       else
932         m.reply _("no such feed %{handle}") % { :handle => handle } if m
933       end
934     else
935       stop_watches
936
937       # Read watches from list.
938       watchlist.each{ |hndl, fd|
939         watchRss(fd, m)
940       }
941       m.okay if m
942     end
943   end
944
945   private
946   def watchRss(feed, m=nil)
947     if @watch.has_key?(feed.handle)
948       # report_problem("watcher thread for #{feed.handle} is already running", nil, m)
949       return
950     end
951     status = Hash.new
952     status[:failures] = 0
953     tmout = 0
954     if feed.last_fetched
955       tmout = feed.last_fetched + calculate_timeout(feed) - Time.now
956       tmout = 0 if tmout < 0
957     end
958     debug "scheduling a watcher for #{feed} in #{tmout} seconds"
959     @watch[feed.handle] = @bot.timer.add(tmout) {
960       debug "watcher for #{feed} wakes up"
961       failures = status[:failures]
962       begin
963         debug "fetching #{feed}"
964
965         first_run = !feed.last_success
966         if (!first_run && @bot.config['rss.announce_timeout'] > 0 &&
967            (Time.now - feed.last_success > @bot.config['rss.announce_timeout']))
968           debug "#{feed} wasn't polled for too long, supressing output"
969           first_run = true
970         end
971         oldxml = feed.xml ? feed.xml.dup : nil
972         unless fetchRss(feed, nil, feed.http_cache)
973           failures += 1
974         else
975           feed.http_cache = true
976           if first_run
977             debug "first run for #{feed}, getting items"
978             parseRss(feed)
979           elsif oldxml and oldxml == feed.xml
980             debug "xml for #{feed} didn't change"
981             failures -= 1 if failures > 0
982           else
983             # This one is used for debugging
984             otxt = []
985
986             if feed.items.nil?
987               oids = []
988             else
989               # These are used for checking new items vs old ones
990               oids = Set.new feed.items.map { |item|
991                 uid = make_uid item
992                 otxt << item.to_s
993                 debug [uid, item].inspect
994                 debug [uid, otxt.last].inspect
995                 uid
996               }
997             end
998
999               nitems = parseRss(feed)
1000               if nitems.nil?
1001                 failures += 1
1002               elsif nitems == 0
1003                 debug "no items in feed #{feed}"
1004               else
1005                 debug "Checking if new items are available for #{feed}"
1006                 failures -= 1 if failures > 0
1007                 # debug "Old:"
1008                 # debug oldxml
1009                 # debug "New:"
1010                 # debug feed.xml
1011
1012                 dispItems = feed.items.reject { |item|
1013                   uid = make_uid item
1014                   txt = item.to_s
1015                   if oids.include?(uid)
1016                     debug "rejecting old #{uid} #{item.inspect}"
1017                     debug [uid, txt].inspect
1018                     true
1019                   else
1020                     debug "accepting new #{uid} #{item.inspect}"
1021                     debug [uid, txt].inspect
1022                     warning "same text! #{txt}" if otxt.include?(txt)
1023                     false
1024                   end
1025                 }
1026
1027                 if dispItems.length > 0
1028                   max = @bot.config['rss.announce_max']
1029                   debug "Found #{dispItems.length} new items in #{feed}"
1030                   if max > 0 and dispItems.length > max
1031                     debug "showing only the latest #{dispItems.length}"
1032                     feed.watchers.each do |loc|
1033                       @bot.say loc, (_("feed %{feed} had %{num} updates, showing the latest %{max}") % {
1034                         :feed => feed.handle,
1035                         :num => dispItems.length,
1036                         :max => max
1037                       })
1038                     end
1039                     dispItems.slice!(max..-1)
1040                   end
1041                   # When displaying watched feeds, publish them from older to newer
1042                   dispItems.reverse.each { |item|
1043                     printFormattedRss(feed, item)
1044                   }
1045                 else
1046                   debug "No new items found in #{feed}"
1047                 end
1048               end
1049           end
1050         end
1051       rescue Exception => e
1052         error "Error watching #{feed}: #{e.inspect}"
1053         debug e.backtrace.join("\n")
1054         failures += 1
1055       end
1056
1057       status[:failures] = failures
1058
1059       seconds = calculate_timeout(feed, failures)
1060       debug "watcher for #{feed} going to sleep #{seconds} seconds.."
1061       begin
1062         @bot.timer.reschedule(@watch[feed.handle], seconds)
1063       rescue
1064         warning "watcher for #{feed} failed to reschedule: #{$!.inspect}"
1065       end
1066     }
1067     debug "watcher for #{feed} added"
1068   end
1069
1070   def calculate_timeout(feed, failures = 0)
1071       seconds = @bot.config['rss.thread_sleep']
1072       feed.mutex.synchronize do
1073         seconds = feed.refresh_rate if feed.refresh_rate
1074       end
1075       seconds *= failures + 1
1076       seconds += seconds * (rand(100)-50)/100
1077       return seconds
1078   end
1079
1080   def make_date(obj)
1081     if obj.kind_of? Time
1082       obj.strftime("%Y/%m/%d %H:%M")
1083     else
1084       obj.to_s
1085     end
1086   end
1087
1088   def printFormattedRss(feed, item, options={})
1089     # debug item
1090     opts = {
1091       :places => feed.watchers,
1092       :handle => feed.handle,
1093       :date => false,
1094       :announce_method => @bot.config['rss.announce_method']
1095     }.merge options
1096
1097     places = opts[:places]
1098     announce_method = opts[:announce_method]
1099
1100     handle = opts[:handle].to_s
1101
1102     date = \
1103     if opts[:date]
1104       if item.respond_to?(:updated) and item.updated
1105         make_date(item.updated.content)
1106       elsif item.respond_to?(:modified) and item.modified
1107         make_date(item.modified.content)
1108       elsif item.respond_to?(:source) and item.source.respond_to?(:updated)
1109         make_date(item.source.updated.content)
1110       elsif item.respond_to?(:pubDate)
1111         make_date(item.pubDate)
1112       elsif item.respond_to?(:date)
1113         make_date(item.date)
1114       else
1115         "(no date)"
1116       end
1117     else
1118       String.new
1119     end
1120
1121     tit_opt = {}
1122     # Twitters don't need a cap on the title length since they have a hard
1123     # limit to 160 characters, and most of them are under 140 characters
1124     tit_opt[:limit] = @bot.config['rss.head_max'] unless feed.type == 'twitter'
1125
1126     if item.title
1127       base_title = item.title.to_s.dup
1128       # git changesets are SHA1 hashes (40 hex digits), way too long, get rid of them, as they are
1129       # visible in the URL anyway
1130       # TODO make this optional?
1131       base_title.sub!(/^Changeset \[([\da-f]{40})\]:/) { |c| "(git commit)"} if feed.type == 'trac'
1132       title = base_title.ircify_html(tit_opt)
1133     end
1134
1135     desc_opt = {}
1136     desc_opt[:limit] = @bot.config['rss.text_max']
1137     desc_opt[:a_href] = :link_out if @bot.config['rss.show_links']
1138
1139     # We prefer content_encoded here as it tends to provide more html formatting
1140     # for use with ircify_html.
1141     if item.respond_to?(:content_encoded) && item.content_encoded
1142       desc = item.content_encoded.ircify_html(desc_opt)
1143     elsif item.respond_to?(:description) && item.description
1144       desc = item.description.ircify_html(desc_opt)
1145     elsif item.respond_to?(:content) && item.content
1146       if item.content.type == "html"
1147         desc = item.content.content.ircify_html(desc_opt)
1148       else
1149         desc = item.content.content
1150         if desc.size > desc_opt[:limit]
1151           desc = desc.slice(0, desc_opt[:limit]) + "#{Reverse}...#{Reverse}"
1152         end
1153       end
1154     else
1155       desc = "(?)"
1156     end
1157
1158     link = item.link!
1159     link.strip! if link
1160
1161     categories = item.categories!
1162     category = item.category! || item.dc_subject!
1163     category.strip! if category
1164     author = item.dc_creator! || item.author!
1165     author.strip! if author
1166
1167     line1 = nil
1168     line2 = nil
1169
1170     at = ((item.title && item.link) ? ' @ ' : '')
1171
1172     key = @bot.global_filter_name(feed.type, @outkey)
1173     key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
1174
1175     stream_hash = {
1176       :item => item,
1177       :handle => handle,
1178       :handle_wrap => ['::', ':: '],
1179       :date => date,
1180       :date_wrap => [nil, ' :: '],
1181       :title => title,
1182       :title_wrap => Bold,
1183       :desc => desc, :link => link,
1184       :categories => categories,
1185       :category => category, :author => author, :at => at
1186     }
1187     output = @bot.filter(key, stream_hash)
1188
1189     return output if places.empty?
1190
1191     places.each { |loc|
1192       output.to_s.each_line { |line|
1193         @bot.__send__(announce_method, loc, line, :overlong => :truncate)
1194       }
1195     }
1196   end
1197
1198   def fetchRss(feed, m=nil, cache=true)
1199     feed.last_fetched = Time.now
1200     begin
1201       # Use 60 sec timeout, cause the default is too low
1202       xml = @bot.httputil.get(feed.url,
1203                               :read_timeout => 60,
1204                               :open_timeout => 60,
1205                               :cache => cache)
1206     rescue URI::InvalidURIError, URI::BadURIError => e
1207       report_problem("invalid rss feed #{feed.url}", e, m)
1208       return nil
1209     rescue => e
1210       report_problem("error getting #{feed.url}", e, m)
1211       return nil
1212     end
1213     debug "fetched #{feed}"
1214     unless xml
1215       report_problem("reading feed #{feed} failed", nil, m)
1216       return nil
1217     end
1218     # Ok, 0.9 feeds are not supported, maybe because
1219     # Netscape happily removed the DTD. So what we do is just to
1220     # reassign the 0.9 RDFs to 1.0, and hope it goes right.
1221     xml.gsub!("xmlns=\"http://my.netscape.com/rdf/simple/0.9/\"",
1222               "xmlns=\"http://purl.org/rss/1.0/\"")
1223     # make sure the parser doesn't double-convert in case the feed is not UTF-8
1224     xml.sub!(/<\?xml (.*?)\?>/) do |match|
1225       if /\bencoding=(['"])(.*?)\1/.match(match)
1226         match.sub!(/\bencoding=(['"])(?:.*?)\1/,'encoding="UTF-8"')
1227       end
1228       match
1229     end
1230     feed.mutex.synchronize do
1231       feed.xml = xml
1232       feed.last_success = Time.now
1233     end
1234     return true
1235   end
1236
1237   def parseRss(feed, m=nil)
1238     return nil unless feed.xml
1239     feed.mutex.synchronize do
1240       xml = feed.xml
1241       rss = nil
1242       errors = []
1243       RSS::AVAILABLE_PARSERS.each do |parser|
1244         begin
1245           ## do validate parse
1246           rss = RSS::Parser.parse(xml, true, true, parser)
1247           debug "parsed and validated #{feed} with #{parser}"
1248           break
1249         rescue RSS::InvalidRSSError
1250           begin
1251             ## do non validate parse for invalid RSS 1.0
1252             rss = RSS::Parser.parse(xml, false, true, parser)
1253             debug "parsed but not validated #{feed} with #{parser}"
1254             break
1255           rescue RSS::Error => e
1256             errors << [parser, e, "parsing rss stream failed, whoops =("]
1257           end
1258         rescue RSS::Error => e
1259           errors << [parser, e, "parsing rss stream failed, oioi"]
1260         rescue => e
1261           errors << [parser, e, "processing error occured, sorry =("]
1262         end
1263       end
1264       unless errors.empty?
1265         debug errors
1266         self.send(:report_problem, errors.last[2], errors.last[1], m)
1267         return nil unless rss
1268       end
1269       items = []
1270       if rss.nil?
1271         if xml.match(/xmlns\s*=\s*(['"])http:\/\/www.w3.org\/2005\/Atom\1/) and not defined?(RSS::Atom)
1272           report_problem("#{feed.handle} @ #{feed.url} looks like an Atom feed, but your Ruby/RSS library doesn't seem to support it. Consider getting the latest version from http://raa.ruby-lang.org/project/rss/", nil, m)
1273         else
1274           report_problem("#{feed.handle} @ #{feed.url} doesn't seem to contain an RSS or Atom feed I can read", nil, m)
1275         end
1276         return nil
1277       else
1278         begin
1279           rss.output_encoding = 'UTF-8'
1280         rescue RSS::UnknownConvertMethod => e
1281           report_problem("bah! something went wrong =(", e, m)
1282           return nil
1283         end
1284         if rss.respond_to? :channel
1285           rss.channel.title ||= "(?)"
1286           title = rss.channel.title
1287         else
1288           title = rss.title.content
1289         end
1290         rss.items.each do |item|
1291           item.title ||= "(?)"
1292           items << item
1293         end
1294       end
1295
1296       if items.empty?
1297         report_problem("no items found in the feed, maybe try weed?", e, m)
1298       else
1299         feed.title = title.strip
1300         feed.items = items
1301       end
1302       return items.length
1303     end
1304   end
1305 end
1306
1307 plugin = RSSFeedsPlugin.new
1308
1309 plugin.default_auth( 'edit', false )
1310 plugin.default_auth( 'edit:add', true)
1311
1312 plugin.map 'rss show :handle :limit',
1313   :action => 'show_rss',
1314   :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},
1315   :defaults => {:limit => 5}
1316 plugin.map 'rss list :handle',
1317   :action => 'list_rss',
1318   :defaults => {:handle => nil}
1319 plugin.map 'rss watched :handle [in :chan]',
1320   :action => 'watched_rss',
1321   :defaults => {:handle => nil}
1322 plugin.map 'rss who watches :handle',
1323   :action => 'who_watches',
1324   :defaults => {:handle => nil}
1325 plugin.map 'rss add :handle :url :type',
1326   :action => 'add_rss',
1327   :auth_path => 'edit',
1328   :defaults => {:type => nil}
1329 plugin.map 'rss change :what of :handle to :new',
1330   :action => 'change_rss',
1331   :auth_path => 'edit',
1332   :requirements => { :what => /handle|url|format|type|refresh/ }
1333 plugin.map 'rss change :what for :handle to :new',
1334   :action => 'change_rss',
1335   :auth_path => 'edit',
1336   :requirements => { :what => /handle|url|format|type|refesh/ }
1337 plugin.map 'rss del :handle',
1338   :auth_path => 'edit:rm!',
1339   :action => 'del_rss'
1340 plugin.map 'rss delete :handle',
1341   :auth_path => 'edit:rm!',
1342   :action => 'del_rss'
1343 plugin.map 'rss rm :handle',
1344   :auth_path => 'edit:rm!',
1345   :action => 'del_rss'
1346 plugin.map 'rss replace :handle :url :type',
1347   :auth_path => 'edit',
1348   :action => 'replace_rss',
1349   :defaults => {:type => nil}
1350 plugin.map 'rss forcereplace :handle :url :type',
1351   :auth_path => 'edit',
1352   :action => 'forcereplace_rss',
1353   :defaults => {:type => nil}
1354 plugin.map 'rss watch :handle [in :chan]',
1355   :action => 'watch_rss',
1356   :defaults => {:url => nil, :type => nil}
1357 plugin.map 'rss watch :handle :url :type [in :chan]',
1358   :action => 'watch_rss',
1359   :defaults => {:url => nil, :type => nil}
1360 plugin.map 'rss unwatch :handle [in :chan]',
1361   :action => 'unwatch_rss'
1362 plugin.map 'rss rmwatch :handle [in :chan]',
1363   :action => 'unwatch_rss'
1364 plugin.map 'rss rewatch [:handle] [:delay]',
1365   :action => 'rewatch_rss'
1366 plugin.map 'rss types',
1367   :action => 'rss_types'