]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/rss.rb
Use the bot timer instead of Threads for periodic rss retrievals
[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(@bot.config['rss.thread_sleep'], 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   end\r
441 \r
442   def printFormattedRss(feed, item, opts=nil)\r
443     places = feed.watchers\r
444     handle = "::#{feed.handle}:: "\r
445     date = String.new\r
446     if opts\r
447       places = opts[:places] if opts.key?(:places)\r
448       handle = opts[:handle].to_s if opts.key?(:handle)\r
449       if opts.key?(:date) && opts[:date]\r
450         if item.respond_to?(:pubDate) \r
451           if item.pubDate.class <= Time\r
452             date = item.pubDate.strftime("%Y/%m/%d %H.%M.%S")\r
453           else\r
454             date = item.pubDate.to_s\r
455           end\r
456         elsif  item.respond_to?(:date)\r
457           if item.date.class <= Time\r
458             date = item.date.strftime("%Y/%m/%d %H.%M.%S")\r
459           else\r
460             date = item.date.to_s\r
461           end\r
462         else\r
463           date = "(no date)"\r
464         end\r
465         date += " :: "\r
466       end\r
467     end\r
468     title = "#{Bold}#{item.title.chomp.riphtml}#{Bold}" if item.title\r
469     desc = item.description.gsub(/\s+/,' ').strip.riphtml.shorten(@bot.config['rss.text_max']) if item.description\r
470     link = item.link.chomp if item.link\r
471     places.each { |loc|\r
472       case feed.type\r
473       when 'blog'\r
474         @bot.say loc, "#{handle}#{date}#{item.category.content} blogged at #{link}"\r
475         @bot.say loc, "#{handle}#{title} - #{desc}"\r
476       when 'forum'\r
477         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
478       when 'wiki'\r
479         @bot.say loc, "#{handle}#{date}#{item.title} has been edited by #{item.dc_creator}. #{desc} #{link}"\r
480       when 'gmame'\r
481         @bot.say loc, "#{handle}#{date}Message #{title} sent by #{item.dc_creator}. #{desc}"\r
482       when 'trac'\r
483         @bot.say loc, "#{handle}#{date}#{title} @ #{link}"\r
484         unless item.title =~ /^Changeset \[(\d+)\]/\r
485           @bot.say loc, "#{handle}#{date}#{desc}"\r
486         end\r
487       else\r
488         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
489       end\r
490     }\r
491   end\r
492 \r
493   def fetchRss(feed, m=nil)\r
494     begin\r
495       # Use 60 sec timeout, cause the default is too low\r
496       xml = @bot.httputil.get_cached(feed.url, 60, 60)\r
497     rescue URI::InvalidURIError, URI::BadURIError => e\r
498       report_problem("invalid rss feed #{feed.url}", e, m)\r
499       return\r
500     rescue => e\r
501       report_problem("error getting #{feed.url}", e, m)\r
502       return\r
503     end\r
504     debug "fetched #{feed}"\r
505     unless xml\r
506       report_problem("reading feed #{feed} failed", nil, m)\r
507       return\r
508     end\r
509 \r
510     begin\r
511       ## do validate parse\r
512       rss = RSS::Parser.parse(xml)\r
513       debug "parsed #{feed}"\r
514     rescue RSS::InvalidRSSError\r
515       ## do non validate parse for invalid RSS 1.0\r
516       begin\r
517         rss = RSS::Parser.parse(xml, false)\r
518       rescue RSS::Error => e\r
519         report_problem("parsing rss stream failed, whoops =(", e, m)\r
520         return\r
521       end\r
522     rescue RSS::Error => e\r
523       report_problem("parsing rss stream failed, oioi", e, m)\r
524       return\r
525     rescue => e\r
526       report_problem("processing error occured, sorry =(", e, m)\r
527       return\r
528     end\r
529     items = []\r
530     if rss.nil?\r
531       report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m)\r
532     else\r
533       begin\r
534         rss.output_encoding = 'UTF-8'\r
535       rescue RSS::UnknownConvertMethod => e\r
536         report_problem("bah! something went wrong =(", e, m)\r
537         return\r
538       end\r
539       rss.channel.title ||= "Unknown"\r
540       title = rss.channel.title\r
541       rss.items.each do |item|\r
542         item.title ||= "Unknown"\r
543         items << item\r
544       end\r
545     end\r
546 \r
547     if items.empty?\r
548       report_problem("no items found in the feed, maybe try weed?", e, m)\r
549       return\r
550     end\r
551     return [title, items]\r
552   end\r
553 end\r
554 \r
555 plugin = RSSFeedsPlugin.new\r
556 \r
557 plugin.map 'rss show :handle :limit',\r
558   :action => 'show_rss',\r
559   :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},\r
560   :defaults => {:limit => 5}\r
561 plugin.map 'rss list :handle',\r
562   :action => 'list_rss',\r
563   :defaults =>  {:handle => nil}\r
564 plugin.map 'rss watched :handle',\r
565   :action => 'watched_rss',\r
566   :defaults =>  {:handle => nil}\r
567 plugin.map 'rss add :handle :url :type',\r
568   :action => 'add_rss',\r
569   :defaults => {:type => nil}\r
570 plugin.map 'rss del :handle',\r
571   :action => 'del_rss'\r
572 plugin.map 'rss delete :handle',\r
573   :action => 'del_rss'\r
574 plugin.map 'rss rm :handle',\r
575   :action => 'del_rss'\r
576 plugin.map 'rss replace :handle :url :type',\r
577   :action => 'replace_rss',\r
578   :defaults => {:type => nil}\r
579 plugin.map 'rss forcereplace :handle :url :type',\r
580   :action => 'forcereplace_rss',\r
581   :defaults => {:type => nil}\r
582 plugin.map 'rss watch :handle :url :type',\r
583   :action => 'watch_rss',\r
584   :defaults => {:url => nil, :type => nil}\r
585 plugin.map 'rss unwatch :handle',\r
586   :action => 'unwatch_rss'\r
587 plugin.map 'rss rmwatch :handle',\r
588   :action => 'unwatch_rss'\r
589 plugin.map 'rss rewatch',\r
590   :action => 'rewatch_rss'\r
591 \r