]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/rss.rb
3b790bacc995ee8f71e79d8eee242b3ef0c7207e
[user/henk/code/ruby/rbot.git] / 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   # Make an  'unique' ID for a given item, based on appropriate bot options
29   # Currently only suppored is bot.config['rss.show_updated']: when true, the
30   # description is included in the uid hashing, otherwise it's not
31   #
32   def RSS.item_uid_for_bot(item, opts={})
33     options = { :show_updated => true}.merge(opts)
34     desc = nil
35     if options[:show_updated]
36       desc = item.content.content rescue item.description rescue nil
37     end
38     [(item.title.content rescue item.title rescue nil),
39      (item.link.href rescue item.link),
40      desc].hash
41   end
42
43   # Add support for Slashdot namespace in RDF. The code is just an adaptation
44   # of the DublinCore code.
45   unless defined?(SLASH_PREFIX)
46     SLASH_PREFIX = 'slash'
47     SLASH_URI = "http://purl.org/rss/1.0/modules/slash/"
48
49     RDF.install_ns(SLASH_PREFIX, SLASH_URI)
50
51     module BaseSlashModel
52       def append_features(klass)
53         super
54
55         return if klass.instance_of?(Module)
56         SlashModel::ELEMENT_NAME_INFOS.each do |name, plural_name|
57           plural = plural_name || "#{name}s"
58           full_name = "#{SLASH_PREFIX}_#{name}"
59           full_plural_name = "#{SLASH_PREFIX}_#{plural}"
60           klass_name = "Slash#{Utils.to_class_name(name)}"
61
62           # This will fail with older version of the Ruby RSS module
63           begin
64             klass.install_have_children_element(name, SLASH_URI, "*",
65                                                 full_name, full_plural_name)
66             klass.install_must_call_validator(SLASH_PREFIX, SLASH_URI)
67           rescue ArgumentError
68             klass.module_eval("install_have_children_element(#{full_name.dump}, #{full_plural_name.dump})")
69           end
70
71           klass.module_eval(<<-EOC, *get_file_and_line_from_caller(0))
72           remove_method :#{full_name}     if method_defined? :#{full_name}
73           remove_method :#{full_name}=    if method_defined? :#{full_name}=
74           remove_method :set_#{full_name} if method_defined? :set_#{full_name}
75
76           def #{full_name}
77             @#{full_name}.first and @#{full_name}.first.value
78           end
79
80           def #{full_name}=(new_value)
81             @#{full_name}[0] = Utils.new_with_value_if_need(#{klass_name}, new_value)
82           end
83           alias set_#{full_name} #{full_name}=
84         EOC
85         end
86       end
87     end
88
89     module SlashModel
90       extend BaseModel
91       extend BaseSlashModel
92
93       TEXT_ELEMENTS = {
94       "department" => nil,
95       "section" => nil,
96       "comments" =>  nil,
97       "hit_parade" => nil
98       }
99
100       ELEMENT_NAME_INFOS = SlashModel::TEXT_ELEMENTS.to_a
101
102       ELEMENTS = TEXT_ELEMENTS.keys
103
104       ELEMENTS.each do |name, plural_name|
105         module_eval(<<-EOC, *get_file_and_line_from_caller(0))
106         class Slash#{Utils.to_class_name(name)} < Element
107           include RSS10
108
109           content_setup
110
111           class << self
112             def required_prefix
113               SLASH_PREFIX
114             end
115
116             def required_uri
117               SLASH_URI
118             end
119           end
120
121           @tag_name = #{name.dump}
122
123           alias_method(:value, :content)
124           alias_method(:value=, :content=)
125
126           def initialize(*args)
127             begin
128               if Utils.element_initialize_arguments?(args)
129                 super
130               else
131                 super()
132                 self.content = args[0]
133               end
134             # Older Ruby RSS module
135             rescue NoMethodError
136               super()
137               self.content = args[0]
138             end
139           end
140
141           def full_name
142             tag_name_with_prefix(SLASH_PREFIX)
143           end
144
145           def maker_target(target)
146             target.new_#{name}
147           end
148
149           def setup_maker_attributes(#{name})
150             #{name}.content = content
151           end
152         end
153       EOC
154       end
155     end
156
157     class RDF
158       class Item; include SlashModel; end
159     end
160
161     SlashModel::ELEMENTS.each do |name|
162       class_name = Utils.to_class_name(name)
163       BaseListener.install_class_name(SLASH_URI, name, "Slash#{class_name}")
164     end
165
166     SlashModel::ELEMENTS.collect! {|name| "#{SLASH_PREFIX}_#{name}"}
167   end
168 end
169
170
171 class ::RssBlob
172   attr_accessor :url, :handle, :type, :refresh_rate, :xml, :title, :items,
173     :mutex, :watchers, :last_fetched
174
175   def initialize(url,handle=nil,type=nil,watchers=[], xml=nil, lf = nil)
176     @url = url
177     if handle
178       @handle = handle
179     else
180       @handle = url
181     end
182     @type = type
183     @watchers=[]
184     @refresh_rate = nil
185     @xml = xml
186     @title = nil
187     @items = nil
188     @mutex = Mutex.new
189     @last_fetched = lf
190     sanitize_watchers(watchers)
191   end
192
193   def dup
194     @mutex.synchronize do
195       self.class.new(@url,
196                      @handle,
197                      @type ? @type.dup : nil,
198                      @watchers.dup,
199                      @xml ? @xml.dup : nil,
200                      @last_fetched)
201     end
202   end
203
204   # Downcase all watchers, possibly turning them into Strings if they weren't
205   def sanitize_watchers(list=@watchers)
206     ls = list.dup
207     @watchers.clear
208     ls.each { |w|
209       add_watch(w)
210     }
211   end
212
213   def watched?
214     !@watchers.empty?
215   end
216
217   def watched_by?(who)
218     @watchers.include?(who.downcase)
219   end
220
221   def add_watch(who)
222     if watched_by?(who)
223       return nil
224     end
225     @mutex.synchronize do
226       @watchers << who.downcase
227     end
228     return who
229   end
230
231   def rm_watch(who)
232     @mutex.synchronize do
233       @watchers.delete(who.downcase)
234     end
235   end
236
237   def to_a
238     [@handle,@url,@type,@refresh_rate,@watchers]
239   end
240
241   def to_s(watchers=false)
242     if watchers
243       a = self.to_a.flatten
244     else
245       a = self.to_a[0,3]
246     end
247     a.compact.join(" | ")
248   end
249 end
250
251 class RSSFeedsPlugin < Plugin
252   Config.register Config::IntegerValue.new('rss.head_max',
253     :default => 100, :validate => Proc.new{|v| v > 0 && v < 200},
254     :desc => "How many characters to use of a RSS item header")
255
256   Config.register Config::IntegerValue.new('rss.text_max',
257     :default => 200, :validate => Proc.new{|v| v > 0 && v < 400},
258     :desc => "How many characters to use of a RSS item text")
259
260   Config.register Config::IntegerValue.new('rss.thread_sleep',
261     :default => 300, :validate => Proc.new{|v| v > 30},
262     :desc => "How many seconds to sleep before checking RSS feeds again")
263
264   Config.register Config::BooleanValue.new('rss.show_updated',
265     :default => true,
266     :desc => "Whether feed items for which the description was changed should be shown as new")
267
268   Config.register Config::BooleanValue.new('rss.show_links',
269     :default => true,
270     :desc => "Whether to display links from the text of a feed item.")
271
272   # We used to save the Mutex with the RssBlob, which was idiotic. And
273   # since Mutexes dumped in one version might not be resotrable in another,
274   # we need a few tricks to be able to restore data from other versions of Ruby
275   #
276   # When migrating 1.8.6 => 1.8.5, all we need to do is define an empty
277   # #marshal_load() method for Mutex. For 1.8.5 => 1.8.6 we need something
278   # dirtier, as seen later on in the initialization code.
279   unless Mutex.new.respond_to?(:marshal_load)
280     class ::Mutex
281       def marshal_load(str)
282         return
283       end
284     end
285   end
286
287   # Auxiliary method used to collect two lines for rss output filters,
288   # running substitutions against DataStream _s_ optionally joined
289   # with hash _h_
290   def make_stream(line1, line2, s, h)
291     DataStream.new([line1, line2].compact.join("\n") % s.merge(h))
292   end
293
294   # Define default RSS filters
295   #
296   # TODO: load personal ones
297   def define_filters
298     @outkey = :"rss.out"
299     @bot.register_filter(:blog, @outkey) { |s|
300       author = s[:author] ? (s[:author] + " ") : ""
301       abt = s[:category] ? "about #{s[:category]} " : ""
302       line1 = "%{handle}%{date}%{author}blogged %{abt}at %{link}"
303       line2 = "%{handle}%{title} - %{desc}"
304       make_stream(line1, line2, s, :author => author, :abt => abt)
305     }
306     @bot.register_filter(:photoblog, @outkey) { |s|
307       author = s[:author] ? (s[:author] + " ") : ""
308       abt = s[:category] ? "under #{s[:category]} " : ""
309       line1 = "%{handle}%{date}%{author}added an image %{abt}at %{link}"
310       line2 = "%{handle}%{title} - %{desc}"
311       make_stream(line1, line2, s, :author => author, :abt => abt)
312     }
313     @bot.register_filter(:news, @outkey) { |s|
314       line1 = "%{handle}%{date}%{title} @ %{link}" % s
315       line2 = "%{handle}%{date}%{desc}" % s
316       make_stream(line1, line2, s)
317     }
318     @bot.register_filter(:git, @outkey) { |s|
319       author = s[:author] ? (s[:author] + " ") : ""
320       line1 = "%{handle}%{date}%{author}committed %{title} @ %{link}"
321       make_stream(line1, nil, s, :author => author)
322     }
323     @bot.register_filter(:forum, @outkey) { |s|
324       line1 = "%{handle}%{date}%{title}%{at}%{link}"
325       make_stream(line1, nil, s)
326     }
327     @bot.register_filter(:wiki, @outkey) { |s|
328       line1 = "%{handle}%{date}%{title}%{at}%{link}"
329       line1 << "has been edited by %{author}. %{desc}"
330       make_stream(line1, nil, s)
331     }
332     @bot.register_filter(:gmane, @outkey) { |s|
333       line1 = "%{handle}%{date}Message %{title} sent by %{author}. %{desc}"
334       make_stream(line1, nil, s)
335     }
336     @bot.register_filter(:trac, @outkey) { |s|
337       author = s[:author].sub(/@\S+?\s*>/, "@...>") + ": " if s[:author]
338       line1 = "%{handle}%{date}%{author}%{title} @ %{link}"
339       line2 = nil
340       unless s[:item].title =~ /^(?:Changeset \[(?:[\da-f]+)\]|\(git commit\))/
341         line2 = "%{handle}%{date}%{desc}"
342       end
343       make_stream(line1, line2, s, :author => author)
344     }
345     @bot.register_filter(:"/.", @outkey) { |s|
346       dept = "(from the #{s[:item].slash_department} dept) " rescue nil
347       sec = " in section #{s[:item].slash_section}" rescue nil
348       line1 = "%{handle}%{date}%{dept}%{title}%{at}%{link} "
349       line1 << "(posted by %{author}%{sec})"
350       make_stream(line1, nil, s, :dept => dept, :sec => sec)
351     }
352     @bot.register_filter(:default, @outkey) { |s|
353       line1 = "%{handle}%{date}%{title}%{at}%{link}"
354       line1 << " (by %{author})" if s[:author]
355       make_stream(line1, nil, s)
356     }
357   end
358
359   # Display the known rss types
360   def rss_types(m, params)
361     ar = @bot.filter_names(@outkey)
362     ar.delete(:default)
363     m.reply ar.map { |k| k.to_s }.sort!.join(", ")
364   end
365
366   attr_reader :feeds
367
368   def initialize
369     super
370
371     define_filters
372
373     if @registry.has_key?(:feeds)
374       # When migrating from Ruby 1.8.5 to 1.8.6, dumped Mutexes may render the
375       # data unrestorable. If this happens, we patch the data, thus allowing
376       # the restore to work.
377       #
378       # This is actually pretty safe for a number of reasons:
379       # * the code is only called if standard marshalling fails
380       # * the string we look for is quite unlikely to appear randomly
381       # * if the string appears somewhere and the patched string isn't recoverable
382       #   either, we'll get another (unrecoverable) error, which makes the rss
383       #   plugin unsable, just like it was if no recovery was attempted
384       # * if the string appears somewhere and the patched string is recoverable,
385       #   we may get a b0rked feed, which is eventually overwritten by a clean
386       #   one, so the worst thing that can happen is that a feed update spams
387       #   the watchers once
388       @registry.recovery = Proc.new { |val|
389         patched = val.sub(":\v@mutexo:\nMutex", ":\v@mutexo:\vObject")
390         ret = Marshal.restore(patched)
391         ret.each_value { |blob|
392           blob.mutex = nil
393           blob
394         }
395       }
396
397       @feeds = @registry[:feeds]
398       raise unless @feeds
399
400       @registry.recovery = nil
401
402       @feeds.keys.grep(/[A-Z]/) { |k|
403         @feeds[k.downcase] = @feeds[k]
404         @feeds.delete(k)
405       }
406       @feeds.each { |k, f|
407         f.mutex = Mutex.new
408         f.sanitize_watchers
409         parseRss(f) if f.xml
410       }
411     else
412       @feeds = Hash.new
413     end
414     @watch = Hash.new
415     rewatch_rss
416   end
417
418   def name
419     "rss"
420   end
421
422   def watchlist
423     @feeds.select { |h, f| f.watched? }
424   end
425
426   def cleanup
427     stop_watches
428     super
429   end
430
431   def save
432     unparsed = Hash.new()
433     @feeds.each { |k, f|
434       unparsed[k] = f.dup
435       # we don't want to save the mutex
436       unparsed[k].mutex = nil
437     }
438     @registry[:feeds] = unparsed
439   end
440
441   def stop_watch(handle)
442     if @watch.has_key?(handle)
443       begin
444         debug "Stopping watch #{handle}"
445         @bot.timer.remove(@watch[handle])
446         @watch.delete(handle)
447       rescue Exception => e
448         report_problem("Failed to stop watch for #{handle}", e, nil)
449       end
450     end
451   end
452
453   def stop_watches
454     @watch.each_key { |k|
455       stop_watch(k)
456     }
457   end
458
459   def help(plugin,topic="")
460     case topic
461     when "show"
462       "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"
463     when "list"
464       "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})"
465     when "watched"
466       "rss watched [#{Bold}handle#{Bold}] [in #{Bold}chan#{Bold}]: list all watched rss feeds (matching #{Bold}handle#{Bold}) (in channel #{Bold}chan#{Bold})"
467     when "who", "watches", "who watches"
468       "rss who watches [#{Bold}handle#{Bold}]]: list all watchers for rss feeds (matching #{Bold}handle#{Bold})"
469     when "add"
470       "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})"
471     when "change"
472       "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}"
473     when /^(del(ete)?|rm)$/
474       "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}"
475     when "replace"
476       "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"
477     when "forcereplace"
478       "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})"
479     when "watch"
480       "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"
481     when /(un|rm)watch/
482       "rss unwatch|rmwatch #{Bold}handle#{Bold} [in #{Bold}chan#{Bold}]: stop watching rss #{Bold}handle#{Bold} (in channel #{Bold}chan#{Bold}) for changes"
483     when  /who(?: watche?s?)?/
484       "rss who watches #{Bold}handle#{Bold}: lists watches for rss #{Bold}handle#{Bold}"
485     when "rewatch"
486       "rss rewatch : restart threads that watch for changes in watched rss"
487     when "types"
488       "rss types : show the rss types for which an output format existi (all other types will use the default one)"
489     else
490       "manage RSS feeds: rss types|show|list|watched|add|change|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch|who watches"
491     end
492   end
493
494   def report_problem(report, e=nil, m=nil)
495     if m && m.respond_to?(:reply)
496       m.reply report
497     else
498       warning report
499     end
500     if e
501       debug e.inspect
502       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)
503     end
504   end
505
506   def show_rss(m, params)
507     handle = params[:handle]
508     lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/)
509     debug lims.to_a.inspect
510     if lims[2]
511       ll = [[lims[1].to_i-1,lims[2].to_i-1].min,  0].max
512       ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min
513       rev = lims[1].to_i > lims[2].to_i
514     else
515       ll = 0
516       ul = [[lims[1].to_i-1, 0].max, 14].min
517       rev = false
518     end
519
520     feed = @feeds.fetch(handle.downcase, nil)
521     unless feed
522       m.reply "I don't know any feeds named #{handle}"
523       return
524     end
525
526     m.reply "lemme fetch it..."
527     title = items = nil
528     we_were_watching = false
529
530     if @watch.key?(feed.handle)
531       # If a feed is being watched, we run the watcher thread
532       # so that all watchers can be informed of changes to
533       # the feed. Before we do that, though, we remove the
534       # show requester from the watchlist, if present, lest
535       # he gets the update twice.
536       if feed.watched_by?(m.replyto)
537         we_were_watching = true
538         feed.rm_watch(m.replyto)
539       end
540       @bot.timer.reschedule(@watch[feed.handle], 0)
541       if we_were_watching
542         feed.add_watch(m.replyto)
543       end
544     else
545       fetched = fetchRss(feed, m, false)
546     end
547     return unless fetched or feed.xml
548     if not fetched and feed.items
549       m.reply "using old data"
550     else
551       parsed = parseRss(feed, m)
552       m.reply "using old data" unless parsed
553     end
554     return unless feed.items
555     title = feed.title
556     items = feed.items
557
558     # We sort the feeds in freshness order (newer ones first)
559     items = freshness_sort(items)
560     disp = items[ll..ul]
561     disp.reverse! if rev
562
563     m.reply "Channel : #{title}"
564     disp.each do |item|
565       printFormattedRss(feed, item, {:places=>[m.replyto],:handle=>nil,:date=>true})
566     end
567   end
568
569   def itemDate(item,ex=nil)
570     return item.pubDate if item.respond_to?(:pubDate) and item.pubDate
571     return item.date if item.respond_to?(:date) and item.date
572     return ex
573   end
574
575   def freshness_sort(items)
576     notime = Time.at(0)
577     items.sort { |a, b|
578       itemDate(b, notime) <=> itemDate(a, notime)
579     }
580   end
581
582   def list_rss(m, params)
583     wanted = params[:handle]
584     reply = String.new
585     @feeds.each { |handle, feed|
586       next if wanted and !handle.match(/#{wanted}/i)
587       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
588       (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
589       (reply << " (watched)") if feed.watched_by?(m.replyto)
590       reply << "\n"
591     }
592     if reply.empty?
593       reply = "no feeds found"
594       reply << " matching #{wanted}" if wanted
595     end
596     m.reply reply, :max_lines => reply.length
597   end
598
599   def watched_rss(m, params)
600     wanted = params[:handle]
601     chan = params[:chan] || m.replyto
602     reply = String.new
603     watchlist.each { |handle, feed|
604       next if wanted and !handle.match(/#{wanted}/i)
605       next unless feed.watched_by?(chan)
606       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
607       (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
608       reply << "\n"
609     }
610     if reply.empty?
611       reply = "no watched feeds"
612       reply << " matching #{wanted}" if wanted
613     end
614     m.reply reply
615   end
616
617   def who_watches(m, params)
618     wanted = params[:handle]
619     reply = String.new
620     watchlist.each { |handle, feed|
621       next if wanted and !handle.match(/#{wanted}/i)
622       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"
623       (reply << " refreshing every #{Utils.secs_to_string(feed.refresh_rate)}") if feed.refresh_rate
624       reply << ": watched by #{feed.watchers.join(', ')}"
625       reply << "\n"
626     }
627     if reply.empty?
628       reply = "no watched feeds"
629       reply << " matching #{wanted}" if wanted
630     end
631     m.reply reply
632   end
633
634   def add_rss(m, params, force=false)
635     handle = params[:handle]
636     url = params[:url]
637     unless url.match(/https?/)
638       m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)"
639       return
640     end
641     type = params[:type]
642     if @feeds.fetch(handle.downcase, nil) && !force
643       m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})"
644       return
645     end
646     unless url
647       m.reply "You must specify both a handle and an url to add an RSS feed"
648       return
649     end
650     @feeds[handle.downcase] = RssBlob.new(url,handle,type)
651     reply = "Added RSS #{url} named #{handle}"
652     if type
653       reply << " (format: #{type})"
654     end
655     m.reply reply
656     return handle
657   end
658
659   def change_rss(m, params)
660     handle = params[:handle].downcase
661     feed = @feeds.fetch(handle, nil)
662     unless feed
663       m.reply "No such feed with handle #{handle}"
664       return
665     end
666     case params[:what].intern
667     when :handle
668       new = params[:new].downcase
669       if @feeds.key?(new) and @feeds[new]
670         m.reply "There already is a feed with handle #{new}"
671         return
672       else
673         feed.mutex.synchronize do
674           @feeds[new] = feed
675           @feeds.delete(handle)
676           feed.handle = new
677         end
678         handle = new
679       end
680     when :url
681       new = params[:new]
682       feed.mutex.synchronize do
683         feed.url = new
684       end
685     when :format, :type
686       new = params[:new]
687       new = nil if new == 'default'
688       feed.mutex.synchronize do
689         feed.type = new
690       end
691     when :refresh
692       new = params[:new].to_i
693       new = nil if new == 0
694       feed.mutex.synchronize do
695         feed.refresh_rate = new
696       end
697     else
698       m.reply "Don't know how to change #{params[:what]} for feeds"
699       return
700     end
701     m.reply "Feed changed:"
702     list_rss(m, {:handle => handle})
703   end
704
705   def del_rss(m, params, pass=false)
706     feed = unwatch_rss(m, params, true)
707     return unless feed
708     if feed.watched?
709       m.reply "someone else is watching #{feed.handle}, I won't remove it from my list"
710       return
711     end
712     @feeds.delete(feed.handle.downcase)
713     m.okay unless pass
714     return
715   end
716
717   def replace_rss(m, params)
718     handle = params[:handle]
719     if @feeds.key?(handle.downcase)
720       del_rss(m, {:handle => handle}, true)
721     end
722     if @feeds.key?(handle.downcase)
723       m.reply "can't replace #{feed.handle}"
724     else
725       add_rss(m, params, true)
726     end
727   end
728
729   def forcereplace_rss(m, params)
730     add_rss(m, params, true)
731   end
732
733   def watch_rss(m, params)
734     handle = params[:handle]
735     chan = params[:chan] || m.replyto
736     url = params[:url]
737     type = params[:type]
738     if url
739       add_rss(m, params)
740     end
741     feed = @feeds.fetch(handle.downcase, nil)
742     if feed
743       if feed.add_watch(chan)
744         watchRss(feed, m)
745         m.okay
746       else
747         m.reply "Already watching #{feed.handle} in #{chan}"
748       end
749     else
750       m.reply "Couldn't watch feed #{handle} (no such feed found)"
751     end
752   end
753
754   def unwatch_rss(m, params, pass=false)
755     handle = params[:handle].downcase
756     chan = params[:chan] || m.replyto
757     unless @feeds.has_key?(handle)
758       m.reply("dunno that feed")
759       return
760     end
761     feed = @feeds[handle]
762     if feed.rm_watch(chan)
763       m.reply "#{chan} has been removed from the watchlist for #{feed.handle}"
764     else
765       m.reply("#{chan} wasn't watching #{feed.handle}") unless pass
766     end
767     if !feed.watched?
768       stop_watch(handle)
769     end
770     return feed
771   end
772
773   def rewatch_rss(m=nil, params=nil)
774     if params and handle = params[:handle]
775       feed = @feeds.fetch(handle.downcase, nil)
776       if feed
777         @bot.timer.reschedule(@watch[feed.handle], 0)
778         m.okay if m
779       else
780         m.reply _("no such feed %{handle}") % { :handle => handle } if m
781       end
782     else
783       stop_watches
784
785       # Read watches from list.
786       watchlist.each{ |handle, feed|
787         watchRss(feed, m)
788       }
789       m.okay if m
790     end
791   end
792
793   private
794   def watchRss(feed, m=nil)
795     if @watch.has_key?(feed.handle)
796       report_problem("watcher thread for #{feed.handle} is already running", nil, m)
797       return
798     end
799     status = Hash.new
800     status[:failures] = 0
801     tmout = 0
802     if feed.last_fetched
803       tmout = feed.last_fetched + calculate_timeout(feed) - Time.now
804       tmout = 0 if tmout < 0
805     end
806     debug "scheduling a watcher for #{feed} in #{tmout} seconds"
807     @watch[feed.handle] = @bot.timer.add(tmout) {
808       debug "watcher for #{feed} wakes up"
809       failures = status[:failures]
810       begin
811         debug "fetching #{feed}"
812         first_run = !feed.last_fetched
813         oldxml = feed.xml ? feed.xml.dup : nil
814         unless fetchRss(feed)
815           failures += 1
816         else
817           if first_run
818             debug "first run for #{feed}, getting items"
819             parseRss(feed)
820           elsif oldxml and oldxml == feed.xml
821             debug "xml for #{feed} didn't change"
822             failures -= 1 if failures > 0
823           else
824             if not feed.items
825               debug "no previous items in feed #{feed}"
826               parseRss(feed)
827               failures -= 1 if failures > 0
828             else
829               # This one is used for debugging
830               otxt = []
831
832               # These are used for checking new items vs old ones
833               uid_opts = { :show_updated => @bot.config['rss.show_updated'] }
834               oids = Set.new feed.items.map { |item|
835                 uid = RSS.item_uid_for_bot(item, uid_opts)
836                 otxt << item.to_s
837                 debug [uid, item].inspect
838                 debug [uid, otxt.last].inspect
839                 uid
840               }
841
842               unless parseRss(feed)
843                 debug "no items in feed #{feed}"
844                 failures += 1
845               else
846                 debug "Checking if new items are available for #{feed}"
847                 failures -= 1 if failures > 0
848                 # debug "Old:"
849                 # debug oldxml
850                 # debug "New:"
851                 # debug feed.xml
852
853                 dispItems = feed.items.reject { |item|
854                   uid = RSS.item_uid_for_bot(item, uid_opts)
855                   txt = item.to_s
856                   if oids.include?(uid)
857                     debug "rejecting old #{uid} #{item.inspect}"
858                     debug [uid, txt].inspect
859                     true
860                   else
861                     debug "accepting new #{uid} #{item.inspect}"
862                     debug [uid, txt].inspect
863                     warning "same text! #{txt}" if otxt.include?(txt)
864                     false
865                   end
866                 }
867
868                 if dispItems.length > 0
869                   debug "Found #{dispItems.length} new items in #{feed}"
870                   # When displaying watched feeds, publish them from older to newer
871                   dispItems.reverse.each { |item|
872                     printFormattedRss(feed, item)
873                   }
874                 else
875                   debug "No new items found in #{feed}"
876                 end
877               end
878             end
879           end
880         end
881       rescue Exception => e
882         error "Error watching #{feed}: #{e.inspect}"
883         debug e.backtrace.join("\n")
884         failures += 1
885       end
886
887       status[:failures] = failures
888
889       seconds = calculate_timeout(feed, failures)
890       debug "watcher for #{feed} going to sleep #{seconds} seconds.."
891       begin
892         @bot.timer.reschedule(@watch[feed.handle], seconds)
893       rescue
894         warning "watcher for #{feed} failed to reschedule: #{$!.inspect}"
895       end
896     }
897     debug "watcher for #{feed} added"
898   end
899
900   def calculate_timeout(feed, failures = 0)
901       seconds = @bot.config['rss.thread_sleep']
902       feed.mutex.synchronize do
903         seconds = feed.refresh_rate if feed.refresh_rate
904       end
905       seconds *= failures + 1
906       seconds += seconds * (rand(100)-50)/100
907       return seconds
908   end
909
910   def select_nonempty(*ar)
911     debug ar
912     ret = ar.map { |i| (i && i.empty?) ? nil : i }.compact.first
913     (ret && ret.empty?) ? nil : ret
914   end
915
916   def printFormattedRss(feed, item, opts=nil)
917     debug item
918     places = feed.watchers
919     handle = "::#{feed.handle}:: "
920     date = String.new
921     if opts
922       places = opts[:places] if opts.key?(:places)
923       handle = opts[:handle].to_s if opts.key?(:handle)
924       if opts.key?(:date) && opts[:date]
925         if item.respond_to?(:updated)
926           if item.updated.content.class <= Time
927             date = item.updated.content.strftime("%Y/%m/%d %H:%M")
928           else
929             date = item.updated.content.to_s
930           end
931         elsif item.respond_to?(:source) and item.source.respond_to?(:updated)
932           if item.source.updated.content.class <= Time
933             date = item.source.updated.content.strftime("%Y/%m/%d %H:%M")
934           else
935             date = item.source.updated.content.to_s
936           end
937         elsif item.respond_to?(:pubDate) 
938           if item.pubDate.class <= Time
939             date = item.pubDate.strftime("%Y/%m/%d %H:%M")
940           else
941             date = item.pubDate.to_s
942           end
943         elsif item.respond_to?(:date)
944           if item.date.class <= Time
945             date = item.date.strftime("%Y/%m/%d %H:%M")
946           else
947             date = item.date.to_s
948           end
949         else
950           date = "(no date)"
951         end
952         date += " :: "
953       end
954     end
955
956     tit_opt = {}
957     # Twitters don't need a cap on the title length since they have a hard
958     # limit to 160 characters, and most of them are under 140 characters
959     tit_opt[:limit] = @bot.config['rss.head_max'] unless feed.type == 'twitter'
960
961     if item.title
962       base_title = item.title.to_s.dup
963       # git changesets are SHA1 hashes (40 hex digits), way too long, get rid of them, as they are
964       # visible in the URL anyway
965       # TODO make this optional?
966       base_title.sub!(/^Changeset \[([\da-f]{40})\]:/) { |c| "(git commit)"} if feed.type == 'trac'
967       title = "#{Bold}#{base_title.ircify_html(tit_opt)}#{Bold}"
968     end
969
970     desc_opt = {}
971     desc_opt[:limit] = @bot.config['rss.text_max']
972     desc_opt[:a_href] = :link_out if @bot.config['rss.show_links']
973
974     # We prefer content_encoded here as it tends to provide more html formatting 
975     # for use with ircify_html.
976     if item.respond_to?(:content_encoded) && item.content_encoded
977       desc = item.content_encoded.ircify_html(desc_opt)
978     elsif item.respond_to?(:description) && item.description
979       desc = item.description.ircify_html(desc_opt)
980     else
981       if item.content.type == "html"
982         desc = item.content.content.ircify_html(desc_opt)
983       else
984         desc = item.content.content
985         if desc.size > desc_opt[:limit]
986           desc = desc.slice(0, desc_opt[:limit]) + "#{Reverse}...#{Reverse}"
987         end
988       end
989     end
990
991     link = item.link.href rescue item.link.chomp rescue nil
992
993     category = select_nonempty((item.category.content rescue nil), (item.dc_subject rescue nil))
994     author = select_nonempty((item.author.name.content rescue nil), (item.dc_creator rescue nil), (item.author rescue nil))
995
996     line1 = nil
997     line2 = nil
998
999     at = ((item.title && item.link) ? ' @ ' : '')
1000
1001     key = @bot.global_filter_name(feed.type, @outkey)
1002     key = @bot.global_filter_name(:default, @outkey) unless @bot.has_filter?(key)
1003
1004     output = @bot.filter(key, :item => item, :handle => handle, :date => date,
1005                          :title => title, :desc => desc, :link => link,
1006                          :category => category, :author => author, :at => at)
1007
1008     places.each { |loc|
1009       output.to_s.each_line { |line|
1010         @bot.say loc, line, :overlong => :truncate
1011       }
1012     }
1013   end
1014
1015   def fetchRss(feed, m=nil, cache=true)
1016     feed.last_fetched = Time.now
1017     begin
1018       # Use 60 sec timeout, cause the default is too low
1019       xml = @bot.httputil.get(feed.url,
1020                               :read_timeout => 60,
1021                               :open_timeout => 60,
1022                               :cache => cache)
1023     rescue URI::InvalidURIError, URI::BadURIError => e
1024       report_problem("invalid rss feed #{feed.url}", e, m)
1025       return nil
1026     rescue => e
1027       report_problem("error getting #{feed.url}", e, m)
1028       return nil
1029     end
1030     debug "fetched #{feed}"
1031     unless xml
1032       report_problem("reading feed #{feed} failed", nil, m)
1033       return nil
1034     end
1035     # Ok, 0.9 feeds are not supported, maybe because
1036     # Netscape happily removed the DTD. So what we do is just to
1037     # reassign the 0.9 RDFs to 1.0, and hope it goes right.
1038     xml.gsub!("xmlns=\"http://my.netscape.com/rdf/simple/0.9/\"",
1039               "xmlns=\"http://purl.org/rss/1.0/\"")
1040     feed.mutex.synchronize do
1041       feed.xml = xml
1042     end
1043     return true
1044   end
1045
1046   def parseRss(feed, m=nil)
1047     return nil unless feed.xml
1048     feed.mutex.synchronize do
1049       xml = feed.xml
1050       begin
1051         ## do validate parse
1052         rss = RSS::Parser.parse(xml)
1053         debug "parsed and validated #{feed}"
1054       rescue RSS::InvalidRSSError
1055         ## do non validate parse for invalid RSS 1.0
1056         begin
1057           rss = RSS::Parser.parse(xml, false)
1058           debug "parsed but not validated #{feed}"
1059         rescue RSS::Error => e
1060           report_problem("parsing rss stream failed, whoops =(", e, m)
1061           return nil
1062         end
1063       rescue RSS::Error => e
1064         report_problem("parsing rss stream failed, oioi", e, m)
1065         return nil
1066       rescue => e
1067         report_problem("processing error occured, sorry =(", e, m)
1068         return nil
1069       end
1070       items = []
1071       if rss.nil?
1072         report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m)
1073       else
1074         begin
1075           rss.output_encoding = 'UTF-8'
1076         rescue RSS::UnknownConvertMethod => e
1077           report_problem("bah! something went wrong =(", e, m)
1078           return nil
1079         end
1080         if rss.respond_to? :channel
1081           rss.channel.title ||= "Unknown"
1082           title = rss.channel.title
1083         else
1084           title = rss.title.content
1085         end
1086         rss.items.each do |item|
1087           item.title ||= "Unknown"
1088           items << item
1089         end
1090       end
1091
1092       if items.empty?
1093         report_problem("no items found in the feed, maybe try weed?", e, m)
1094         return nil
1095       end
1096       feed.title = title
1097       feed.items = items
1098       return true
1099     end
1100   end
1101 end
1102
1103 plugin = RSSFeedsPlugin.new
1104
1105 plugin.default_auth( 'edit', false )
1106 plugin.default_auth( 'edit:add', true)
1107
1108 plugin.map 'rss show :handle :limit',
1109   :action => 'show_rss',
1110   :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},
1111   :defaults => {:limit => 5}
1112 plugin.map 'rss list :handle',
1113   :action => 'list_rss',
1114   :defaults => {:handle => nil}
1115 plugin.map 'rss watched :handle [in :chan]',
1116   :action => 'watched_rss',
1117   :defaults => {:handle => nil}
1118 plugin.map 'rss who watches :handle',
1119   :action => 'who_watches',
1120   :defaults => {:handle => nil}
1121 plugin.map 'rss add :handle :url :type',
1122   :action => 'add_rss',
1123   :auth_path => 'edit',
1124   :defaults => {:type => nil}
1125 plugin.map 'rss change :what of :handle to :new',
1126   :action => 'change_rss',
1127   :auth_path => 'edit',
1128   :requirements => { :what => /handle|url|format|type|refresh/ }
1129 plugin.map 'rss change :what for :handle to :new',
1130   :action => 'change_rss',
1131   :auth_path => 'edit',
1132   :requirements => { :what => /handle|url|format|type|refesh/ }
1133 plugin.map 'rss del :handle',
1134   :auth_path => 'edit:rm!',
1135   :action => 'del_rss'
1136 plugin.map 'rss delete :handle',
1137   :auth_path => 'edit:rm!',
1138   :action => 'del_rss'
1139 plugin.map 'rss rm :handle',
1140   :auth_path => 'edit:rm!',
1141   :action => 'del_rss'
1142 plugin.map 'rss replace :handle :url :type',
1143   :auth_path => 'edit',
1144   :action => 'replace_rss',
1145   :defaults => {:type => nil}
1146 plugin.map 'rss forcereplace :handle :url :type',
1147   :auth_path => 'edit',
1148   :action => 'forcereplace_rss',
1149   :defaults => {:type => nil}
1150 plugin.map 'rss watch :handle [in :chan]',
1151   :action => 'watch_rss',
1152   :defaults => {:url => nil, :type => nil}
1153 plugin.map 'rss watch :handle :url :type [in :chan]',
1154   :action => 'watch_rss',
1155   :defaults => {:url => nil, :type => nil}
1156 plugin.map 'rss unwatch :handle [in :chan]',
1157   :action => 'unwatch_rss'
1158 plugin.map 'rss rmwatch :handle [in :chan]',
1159   :action => 'unwatch_rss'
1160 plugin.map 'rss rewatch [:handle]',
1161   :action => 'rewatch_rss'
1162 plugin.map 'rss types',
1163   :action => 'rss_types'