]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/rss.rb
When adding a feed watcher, let it fire instantly so that it can do the initial rss...
[user/henk/code/ruby/rbot.git] / data / rbot / plugins / rss.rb
1 #-- vim:sw=2:et\r
2 #++\r
3 #\r
4 # RSS feed plugin for RubyBot\r
5 # (c) 2004 Stanislav Karchebny <berkus@madfire.net>\r
6 # (c) 2005 Ian Monroe <ian@monroe.nu>\r
7 # (c) 2005 Mark Kretschmann <markey@web.de>\r
8 # (c) 2006 Giuseppe Bilotta <giuseppe.bilotta@gmail.com>\r
9 #\r
10 # Licensed under MIT License.\r
11 \r
12 require 'rss/parser'\r
13 require 'rss/1.0'\r
14 require 'rss/2.0'\r
15 require 'rss/dublincore'\r
16 # begin\r
17 #   require 'rss/dublincore/2.0'\r
18 # rescue\r
19 #   warning "Unable to load RSS libraries, RSS plugin functionality crippled"\r
20 # end\r
21 \r
22 class ::String\r
23   def shorten(limit)\r
24     if self.length > limit\r
25       self+". " =~ /^(.{#{limit}}[^.!;?]*[.!;?])/mi\r
26       return $1\r
27     end\r
28     self\r
29   end\r
30 \r
31   def riphtml\r
32     self.gsub(/<[^>]+>/, '').gsub(/&amp;/,'&').gsub(/&quot;/,'"').gsub(/&lt;/,'<').gsub(/&gt;/,'>').gsub(/&ellip;/,'...').gsub(/&apos;/, "'").gsub("\n",'')\r
33   end\r
34 end\r
35 \r
36 class ::RssBlob\r
37   attr :url\r
38   attr :handle\r
39   attr :type\r
40   attr :watchers\r
41 \r
42   def initialize(url,handle=nil,type=nil,watchers=[])\r
43     @url = url\r
44     if handle\r
45       @handle = handle\r
46     else\r
47       @handle = url\r
48     end\r
49     @type = type\r
50     @watchers=[]\r
51     sanitize_watchers(watchers)\r
52   end\r
53 \r
54   # Downcase all watchers, possibly turning them into Strings if they weren't\r
55   def sanitize_watchers(list=@watchers)\r
56     ls = list.dup\r
57     @watchers.clear\r
58     ls.each { |w|\r
59       add_watch(w)\r
60     }\r
61   end\r
62 \r
63   def watched?\r
64     !@watchers.empty?\r
65   end\r
66 \r
67   def watched_by?(who)\r
68     @watchers.include?(who.downcase)\r
69   end\r
70 \r
71   def add_watch(who)\r
72     if watched_by?(who)\r
73       return nil\r
74     end\r
75     @watchers << who.downcase\r
76     return who\r
77   end\r
78 \r
79   def rm_watch(who)\r
80     @watchers.delete(who.downcase)\r
81   end\r
82 \r
83   def to_a\r
84     [@handle,@url,@type,@watchers]\r
85   end\r
86 \r
87   def to_s(watchers=false)\r
88     if watchers\r
89       a = self.to_a.flatten\r
90     else\r
91       a = self.to_a[0,3]\r
92     end\r
93     a.compact.join(" | ")\r
94   end\r
95 end\r
96 \r
97 class RSSFeedsPlugin < Plugin\r
98   BotConfig.register BotConfigIntegerValue.new('rss.head_max',\r
99     :default => 30, :validate => Proc.new{|v| v > 0 && v < 200},\r
100     :desc => "How many characters to use of a RSS item header")\r
101 \r
102   BotConfig.register BotConfigIntegerValue.new('rss.text_max',\r
103     :default => 90, :validate => Proc.new{|v| v > 0 && v < 400},\r
104     :desc => "How many characters to use of a RSS item text")\r
105 \r
106   BotConfig.register BotConfigIntegerValue.new('rss.thread_sleep',\r
107     :default => 300, :validate => Proc.new{|v| v > 30},\r
108     :desc => "How many seconds to sleep before checking RSS feeds again")\r
109 \r
110   def initialize\r
111     super\r
112     if @registry.has_key?(:feeds)\r
113       @feeds = @registry[:feeds]\r
114       @feeds.keys.grep(/[A-Z]/) { |k|\r
115         @feeds[k.downcase] = @feeds[k]\r
116         @feeds.delete(k)\r
117       }\r
118       @feeds.each { |k, f|\r
119         f.sanitize_watchers\r
120       }\r
121     else\r
122       @feeds = Hash.new\r
123     end\r
124     @watch = Hash.new\r
125     rewatch_rss\r
126   end\r
127 \r
128   def name\r
129     "rss"\r
130   end\r
131 \r
132   def watchlist\r
133     @feeds.select { |h, f| f.watched? }\r
134   end\r
135 \r
136   def cleanup\r
137     stop_watches\r
138   end\r
139 \r
140   def save\r
141     @registry[:feeds] = @feeds\r
142   end\r
143 \r
144   def stop_watch(handle)\r
145     if @watch.has_key?(handle)\r
146       begin\r
147         debug "Stopping watch #{handle}"\r
148         @bot.timer.remove(@watch[handle])\r
149         @watch.delete(handle)\r
150       rescue => e\r
151         report_problem("Failed to stop watch for #{handle}", e, nil)\r
152       end\r
153     end\r
154   end\r
155 \r
156   def stop_watches\r
157     @watch.each_key { |k|\r
158       stop_watch(k)\r
159     }\r
160   end\r
161 \r
162   def help(plugin,topic="")\r
163     case topic\r
164     when "show"\r
165       "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"\r
166     when "list"\r
167       "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})"\r
168     when "watched"\r
169       "rss watched [#{Bold}handle#{Bold}] : list all watched rss feeds (matching #{Bold}handle#{Bold})"\r
170     when "add"\r
171       "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})"\r
172     when /^(del(ete)?|rm)$/\r
173       "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}"\r
174     when "replace"\r
175       "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"\r
176     when "forcereplace"\r
177       "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})"\r
178     when "watch"\r
179       "rss watch #{Bold}handle#{Bold} [#{Bold}url#{Bold} [#{Bold}type#{Bold}]] : watch rss #{Bold}handle#{Bold} for changes; when the other parameters are present, it will be created if it doesn't exist yet"\r
180     when /(un|rm)watch/\r
181       "rss unwatch|rmwatch #{Bold}handle#{Bold} : stop watching rss #{Bold}handle#{Bold} for changes"\r
182     when "rewatch"\r
183       "rss rewatch : restart threads that watch for changes in watched rss"\r
184     else\r
185       "manage RSS feeds: rss show|list|watched|add|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch"\r
186     end\r
187   end\r
188 \r
189   def report_problem(report, e=nil, m=nil)\r
190     if m && m.respond_to?(:reply)\r
191       m.reply report\r
192     else\r
193       warning report\r
194     end\r
195     if e\r
196       debug e.inspect\r
197       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)\r
198     end\r
199   end\r
200 \r
201   def show_rss(m, params)\r
202     handle = params[:handle]\r
203     lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/)\r
204     debug lims.to_a.inspect\r
205     if lims[2]\r
206       ll = [[lims[1].to_i-1,lims[2].to_i-1].min,  0].max\r
207       ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min\r
208       rev = lims[1].to_i > lims[2].to_i\r
209     else\r
210       ll = 0\r
211       ul = [[lims[1].to_i-1, 0].max, 14].min\r
212       rev = false\r
213     end\r
214 \r
215     feed = @feeds.fetch(handle.downcase, nil)\r
216     unless feed\r
217       m.reply "I don't know any feeds named #{handle}"\r
218       return\r
219     end\r
220 \r
221     m.reply "lemme fetch it..."\r
222     title = items = nil\r
223     title, items = fetchRss(feed, m)\r
224     return unless items\r
225 \r
226     # We sort the feeds in freshness order (newer ones first)\r
227     items = freshness_sort(items)\r
228     disp = items[ll..ul]\r
229     disp.reverse! if rev\r
230 \r
231     m.reply "Channel : #{title}"\r
232     disp.each do |item|\r
233       printFormattedRss(feed, item, {:places=>[m.replyto],:handle=>nil,:date=>true})\r
234     end\r
235   end\r
236 \r
237   def itemDate(item,ex=nil)\r
238     return item.pubDate if item.respond_to?(:pubDate) and item.pubDate\r
239     return item.date if item.respond_to?(:date) and item.date\r
240     return ex\r
241   end\r
242 \r
243   def freshness_sort(items)\r
244     notime = Time.at(0)\r
245     items.sort { |a, b|\r
246       itemDate(b, notime) <=> itemDate(a, notime)\r
247     }\r
248   end\r
249 \r
250   def list_rss(m, params)\r
251     wanted = params[:handle]\r
252     reply = String.new\r
253     @feeds.each { |handle, feed|\r
254       next if wanted and !handle.match(/#{wanted}/i)\r
255       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"\r
256       (reply << " (watched)") if feed.watched_by?(m.replyto)\r
257       reply << "\n"\r
258     }\r
259     if reply.empty?\r
260       reply = "no feeds found"\r
261       reply << " matching #{wanted}" if wanted\r
262     end\r
263     m.reply reply\r
264   end\r
265 \r
266   def watched_rss(m, params)\r
267     wanted = params[:handle]\r
268     reply = String.new\r
269     watchlist.each { |handle, feed|\r
270       next if wanted and !handle.match(/#{wanted}/i)\r
271       next unless feed.watched_by?(m.replyto)\r
272       reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})\n"\r
273     }\r
274     if reply.empty?\r
275       reply = "no watched feeds"\r
276       reply << " matching #{wanted}" if wanted\r
277     end\r
278     m.reply reply\r
279   end\r
280 \r
281   def add_rss(m, params, force=false)\r
282     handle = params[:handle]\r
283     url = params[:url]\r
284     unless url.match(/https?/)\r
285       m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)"\r
286       return\r
287     end\r
288     type = params[:type]\r
289     if @feeds.fetch(handle.downcase, nil) && !force\r
290       m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})"\r
291       return\r
292     end\r
293     unless url\r
294       m.reply "You must specify both a handle and an url to add an RSS feed"\r
295       return\r
296     end\r
297     @feeds[handle.downcase] = RssBlob.new(url,handle,type)\r
298     reply = "Added RSS #{url} named #{handle}"\r
299     if type\r
300       reply << " (format: #{type})"\r
301     end\r
302     m.reply reply\r
303     return handle\r
304   end\r
305 \r
306   def del_rss(m, params, pass=false)\r
307     feed = unwatch_rss(m, params, true)\r
308     if feed.watched?\r
309       m.reply "someone else is watching #{feed.handle}, I won't remove it from my list"\r
310       return\r
311     end\r
312     @feeds.delete(feed.handle.downcase)\r
313     m.okay unless pass\r
314     return\r
315   end\r
316 \r
317   def replace_rss(m, params)\r
318     handle = params[:handle]\r
319     if @feeds.key?(handle.downcase)\r
320       del_rss(m, {:handle => handle}, true)\r
321     end\r
322     if @feeds.key?(handle.downcase)\r
323       m.reply "can't replace #{feed.handle}"\r
324     else\r
325       add_rss(m, params, true)\r
326     end\r
327   end\r
328 \r
329   def forcereplace_rss(m, params)\r
330     add_rss(m, params, true)\r
331   end\r
332 \r
333   def watch_rss(m, params)\r
334     handle = params[:handle]\r
335     url = params[:url]\r
336     type = params[:type]\r
337     if url\r
338       add_rss(m, params)\r
339     end\r
340     feed = @feeds.fetch(handle.downcase, nil)\r
341     if feed\r
342       if feed.add_watch(m.replyto)\r
343         watchRss(feed, m)\r
344         m.okay\r
345       else\r
346         m.reply "Already watching #{feed.handle}"\r
347       end\r
348     else\r
349       m.reply "Couldn't watch feed #{handle} (no such feed found)"\r
350     end\r
351   end\r
352 \r
353   def unwatch_rss(m, params, pass=false)\r
354     handle = params[:handle].downcase\r
355     unless @feeds.has_key?(handle)\r
356       m.reply("dunno that feed")\r
357       return\r
358     end\r
359     feed = @feeds[handle]\r
360     if feed.rm_watch(m.replyto)\r
361       m.reply "#{m.replyto} has been removed from the watchlist for #{feed.handle}"\r
362     else\r
363       m.reply("#{m.replyto} wasn't watching #{feed.handle}") unless pass\r
364     end\r
365     if !feed.watched?\r
366       stop_watch(handle)\r
367     end\r
368     return feed\r
369   end\r
370 \r
371   def rewatch_rss(m=nil, params=nil)\r
372     stop_watches\r
373 \r
374     # Read watches from list.\r
375     watchlist.each{ |handle, feed|\r
376       watchRss(feed, m)\r
377     }\r
378     m.okay if m\r
379   end\r
380 \r
381   private\r
382   def watchRss(feed, m=nil)\r
383     if @watch.has_key?(feed.handle)\r
384       report_problem("watcher thread for #{feed.handle} is already running", nil, m)\r
385       return\r
386     end\r
387     status = Hash.new\r
388     status[:oldItems] = []\r
389     status[:firstRun] = true\r
390     status[:failures] = 0\r
391     @watch[feed.handle] = @bot.timer.add(0, status) {\r
392       debug "watcher for #{feed} started"\r
393       oldItems = status[:oldItems]\r
394       firstRun = status[:firstRun]\r
395       failures = status[:failures]\r
396       begin\r
397         debug "fetching #{feed}"\r
398         title = newItems = nil\r
399         title, newItems = fetchRss(feed)\r
400         unless newItems\r
401           debug "no items in feed #{feed}"\r
402           failures +=1\r
403         else\r
404           debug "Checking if new items are available for #{feed}"\r
405           if firstRun\r
406             debug "First run, we'll see next time"\r
407             firstRun = false\r
408           else\r
409             otxt = oldItems.map { |item| item.to_s }\r
410             dispItems = newItems.reject { |item|\r
411               otxt.include?(item.to_s)\r
412             }\r
413             if dispItems.length > 0\r
414               debug "Found #{dispItems.length} new items in #{feed}"\r
415               # When displaying watched feeds, publish them from older to newer\r
416               dispItems.reverse.each { |item|\r
417                 printFormattedRss(feed, item)\r
418               }\r
419             else\r
420               debug "No new items found in #{feed}"\r
421             end\r
422           end\r
423           oldItems = newItems.dup\r
424         end\r
425       rescue Exception => e\r
426         error "Error watching #{feed}: #{e.inspect}"\r
427         debug e.backtrace.join("\n")\r
428         failures += 1\r
429       end\r
430 \r
431       status[:oldItems] = oldItems\r
432       status[:firstRun] = firstRun\r
433       status[:failures] = failures\r
434 \r
435       seconds = @bot.config['rss.thread_sleep'] * (failures + 1)\r
436       seconds += seconds * (rand(100)-50)/100\r
437       debug "watcher for #{feed} going to sleep #{seconds} seconds.."\r
438       @bot.timer.reschedule(@watch[feed.handle], seconds)\r
439     }\r
440     debug "watcher for #{feed} added"\r
441   end\r
442 \r
443   def printFormattedRss(feed, item, opts=nil)\r
444     places = feed.watchers\r
445     handle = "::#{feed.handle}:: "\r
446     date = String.new\r
447     if opts\r
448       places = opts[:places] if opts.key?(:places)\r
449       handle = opts[:handle].to_s if opts.key?(:handle)\r
450       if opts.key?(:date) && opts[:date]\r
451         if item.respond_to?(:pubDate) \r
452           if item.pubDate.class <= Time\r
453             date = item.pubDate.strftime("%Y/%m/%d %H.%M.%S")\r
454           else\r
455             date = item.pubDate.to_s\r
456           end\r
457         elsif  item.respond_to?(:date)\r
458           if item.date.class <= Time\r
459             date = item.date.strftime("%Y/%m/%d %H.%M.%S")\r
460           else\r
461             date = item.date.to_s\r
462           end\r
463         else\r
464           date = "(no date)"\r
465         end\r
466         date += " :: "\r
467       end\r
468     end\r
469     title = "#{Bold}#{item.title.chomp.riphtml}#{Bold}" if item.title\r
470     desc = item.description.gsub(/\s+/,' ').strip.riphtml.shorten(@bot.config['rss.text_max']) if item.description\r
471     link = item.link.chomp if item.link\r
472     places.each { |loc|\r
473       case feed.type\r
474       when 'blog'\r
475         @bot.say loc, "#{handle}#{date}#{item.category.content} blogged at #{link}"\r
476         @bot.say loc, "#{handle}#{title} - #{desc}"\r
477       when 'forum'\r
478         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
479       when 'wiki'\r
480         @bot.say loc, "#{handle}#{date}#{item.title} has been edited by #{item.dc_creator}. #{desc} #{link}"\r
481       when 'gmame'\r
482         @bot.say loc, "#{handle}#{date}Message #{title} sent by #{item.dc_creator}. #{desc}"\r
483       when 'trac'\r
484         @bot.say loc, "#{handle}#{date}#{title} @ #{link}"\r
485         unless item.title =~ /^Changeset \[(\d+)\]/\r
486           @bot.say loc, "#{handle}#{date}#{desc}"\r
487         end\r
488       else\r
489         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
490       end\r
491     }\r
492   end\r
493 \r
494   def fetchRss(feed, m=nil)\r
495     begin\r
496       # Use 60 sec timeout, cause the default is too low\r
497       xml = @bot.httputil.get_cached(feed.url, 60, 60)\r
498     rescue URI::InvalidURIError, URI::BadURIError => e\r
499       report_problem("invalid rss feed #{feed.url}", e, m)\r
500       return\r
501     rescue => e\r
502       report_problem("error getting #{feed.url}", e, m)\r
503       return\r
504     end\r
505     debug "fetched #{feed}"\r
506     unless xml\r
507       report_problem("reading feed #{feed} failed", nil, m)\r
508       return\r
509     end\r
510 \r
511     begin\r
512       ## do validate parse\r
513       rss = RSS::Parser.parse(xml)\r
514       debug "parsed #{feed}"\r
515     rescue RSS::InvalidRSSError\r
516       ## do non validate parse for invalid RSS 1.0\r
517       begin\r
518         rss = RSS::Parser.parse(xml, false)\r
519       rescue RSS::Error => e\r
520         report_problem("parsing rss stream failed, whoops =(", e, m)\r
521         return\r
522       end\r
523     rescue RSS::Error => e\r
524       report_problem("parsing rss stream failed, oioi", e, m)\r
525       return\r
526     rescue => e\r
527       report_problem("processing error occured, sorry =(", e, m)\r
528       return\r
529     end\r
530     items = []\r
531     if rss.nil?\r
532       report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m)\r
533     else\r
534       begin\r
535         rss.output_encoding = 'UTF-8'\r
536       rescue RSS::UnknownConvertMethod => e\r
537         report_problem("bah! something went wrong =(", e, m)\r
538         return\r
539       end\r
540       rss.channel.title ||= "Unknown"\r
541       title = rss.channel.title\r
542       rss.items.each do |item|\r
543         item.title ||= "Unknown"\r
544         items << item\r
545       end\r
546     end\r
547 \r
548     if items.empty?\r
549       report_problem("no items found in the feed, maybe try weed?", e, m)\r
550       return\r
551     end\r
552     return [title, items]\r
553   end\r
554 end\r
555 \r
556 plugin = RSSFeedsPlugin.new\r
557 \r
558 plugin.map 'rss show :handle :limit',\r
559   :action => 'show_rss',\r
560   :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},\r
561   :defaults => {:limit => 5}\r
562 plugin.map 'rss list :handle',\r
563   :action => 'list_rss',\r
564   :defaults =>  {:handle => nil}\r
565 plugin.map 'rss watched :handle',\r
566   :action => 'watched_rss',\r
567   :defaults =>  {:handle => nil}\r
568 plugin.map 'rss add :handle :url :type',\r
569   :action => 'add_rss',\r
570   :defaults => {:type => nil}\r
571 plugin.map 'rss del :handle',\r
572   :action => 'del_rss'\r
573 plugin.map 'rss delete :handle',\r
574   :action => 'del_rss'\r
575 plugin.map 'rss rm :handle',\r
576   :action => 'del_rss'\r
577 plugin.map 'rss replace :handle :url :type',\r
578   :action => 'replace_rss',\r
579   :defaults => {:type => nil}\r
580 plugin.map 'rss forcereplace :handle :url :type',\r
581   :action => 'forcereplace_rss',\r
582   :defaults => {:type => nil}\r
583 plugin.map 'rss watch :handle :url :type',\r
584   :action => 'watch_rss',\r
585   :defaults => {:url => nil, :type => nil}\r
586 plugin.map 'rss unwatch :handle',\r
587   :action => 'unwatch_rss'\r
588 plugin.map 'rss rmwatch :handle',\r
589   :action => 'unwatch_rss'\r
590 plugin.map 'rss rewatch',\r
591   :action => 'rewatch_rss'\r
592 \r