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