]> git.netwichtig.de Git - user/henk/code/ruby/rbot.git/blob - data/rbot/plugins/rss.rb
Remove unused code from 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   @@watchThreads = Hash.new\r
111   @@mutex = Mutex.new\r
112 \r
113   def initialize\r
114     super\r
115     kill_threads\r
116     if @registry.has_key?(:feeds)\r
117       @feeds = @registry[:feeds]\r
118       @feeds.keys.grep(/[A-Z]/) { |k|\r
119         @feeds[k.downcase] = @feeds[k]\r
120         @feeds.delete(k)\r
121       }\r
122       @feeds.each { |k, f|\r
123         f.sanitize_watchers\r
124       }\r
125     else\r
126       @feeds = Hash.new\r
127     end\r
128     rewatch_rss\r
129   end\r
130 \r
131   def name\r
132     "rss"\r
133   end\r
134 \r
135   def watchlist\r
136     @feeds.select { |h, f| f.watched? }\r
137   end\r
138 \r
139   def cleanup\r
140     kill_threads\r
141   end\r
142 \r
143   def save\r
144     @registry[:feeds] = @feeds\r
145   end\r
146 \r
147   def kill_threads\r
148     @@mutex.synchronize {\r
149       # Abort all running threads.\r
150       @@watchThreads.each { |url, thread|\r
151         debug "Killing thread for #{url}"\r
152         thread.kill\r
153       }\r
154       @@watchThreads = Hash.new\r
155     }\r
156   end\r
157 \r
158   def help(plugin,topic="")\r
159     case topic\r
160     when "show"\r
161       "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
162     when "list"\r
163       "rss list [#{Bold}handle#{Bold}] : list all rss feeds (matching #{Bold}handle#{Bold})"\r
164     when "watched"\r
165       "rss watched [#{Bold}handle#{Bold}] : list all watched rss feeds (matching #{Bold}handle#{Bold})"\r
166     when "add"\r
167       "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
168     when /^(del(ete)?|rm)$/\r
169       "rss del(ete)|rm #{Bold}handle#{Bold} : delete rss feed #{Bold}handle#{Bold}"\r
170     when "replace"\r
171       "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
172     when "forcereplace"\r
173       "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
174     when "watch"\r
175       "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
176     when /(un|rm)watch/\r
177       "rss unwatch|rmwatch #{Bold}handle#{Bold} : stop watching rss #{Bold}handle#{Bold} for changes"\r
178     when "rewatch"\r
179       "rss rewatch : restart threads that watch for changes in watched rss"\r
180     else\r
181       "manage RSS feeds: rss show|list|watched|add|del(ete)|rm|(force)replace|watch|unwatch|rmwatch|rewatch"\r
182     end\r
183   end\r
184 \r
185   def report_problem(report, e=nil, m=nil)\r
186     if m && m.respond_to?(:reply)\r
187       m.reply report\r
188     else\r
189       warning report\r
190     end\r
191     if e\r
192       debug e.inspect\r
193       debug e.backtrace.join("\n") if e.respond_to?(:backtrace)\r
194     end\r
195   end\r
196 \r
197   def show_rss(m, params)\r
198     handle = params[:handle]\r
199     lims = params[:limit].to_s.match(/(\d+)(?:..(\d+))?/)\r
200     debug lims.to_a.inspect\r
201     if lims[2]\r
202       ll = [[lims[1].to_i-1,lims[2].to_i-1].min,  0].max\r
203       ul = [[lims[1].to_i-1,lims[2].to_i-1].max, 14].min\r
204       rev = lims[1].to_i > lims[2].to_i\r
205     else\r
206       ll = 0\r
207       ul = [[lims[1].to_i-1, 0].max, 14].min\r
208       rev = false\r
209     end\r
210 \r
211     feed = @feeds.fetch(handle.downcase, nil)\r
212     unless feed\r
213       m.reply "I don't know any feeds named #{handle}"\r
214       return\r
215     end\r
216 \r
217     m.reply "lemme fetch it..."\r
218     title = items = nil\r
219     @@mutex.synchronize {\r
220       title, items = fetchRss(feed, m)\r
221     }\r
222     return unless items\r
223 \r
224     # We sort the feeds in freshness order (newer ones first)\r
225     items = freshness_sort(items)\r
226     disp = items[ll..ul]\r
227     disp.reverse! if rev\r
228 \r
229     m.reply "Channel : #{title}"\r
230     disp.each do |item|\r
231       printFormattedRss(feed, item, {:places=>[m.replyto],:handle=>nil,:date=>true})\r
232     end\r
233   end\r
234 \r
235   def itemDate(item,ex=nil)\r
236     return item.pubDate if item.respond_to?(:pubDate) and item.pubDate\r
237     return item.date if item.respond_to?(:date) and item.date\r
238     return ex\r
239   end\r
240 \r
241   def freshness_sort(items)\r
242     notime = Time.at(0)\r
243     items.sort { |a, b|\r
244       itemDate(b, notime) <=> itemDate(a, notime)\r
245     }\r
246   end\r
247 \r
248   def list_rss(m, params)\r
249     wanted = params[:handle]\r
250     reply = String.new\r
251     @@mutex.synchronize {\r
252       @feeds.each { |handle, feed|\r
253         next if wanted and !handle.match(/#{wanted}/i)\r
254         reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})"\r
255         (reply << " (watched)") if feed.watched_by?(m.replyto)\r
256         reply << "\n"\r
257       }\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     @@mutex.synchronize {\r
270       watchlist.each { |handle, feed|\r
271         next if wanted and !handle.match(/#{wanted}/i)\r
272         next unless feed.watched_by?(m.replyto)\r
273         reply << "#{feed.handle}: #{feed.url} (in format: #{feed.type ? feed.type : 'default'})\n"\r
274       }\r
275     }\r
276     if reply.empty?\r
277       reply = "no watched feeds"\r
278       reply << " matching #{wanted}" if wanted\r
279     end\r
280     m.reply reply\r
281   end\r
282 \r
283   def add_rss(m, params, force=false)\r
284     handle = params[:handle]\r
285     url = params[:url]\r
286     unless url.match(/https?/)\r
287       m.reply "I only deal with feeds from HTTP sources, so I can't use #{url} (maybe you forgot the handle?)"\r
288       return\r
289     end\r
290     type = params[:type]\r
291     if @feeds.fetch(handle.downcase, nil) && !force\r
292       m.reply "There is already a feed named #{handle} (URL: #{@feeds[handle.downcase].url})"\r
293       return\r
294     end\r
295     unless url\r
296       m.reply "You must specify both a handle and an url to add an RSS feed"\r
297       return\r
298     end\r
299     @@mutex.synchronize {\r
300       @feeds[handle.downcase] = RssBlob.new(url,handle,type)\r
301     }\r
302     reply = "Added RSS #{url} named #{handle}"\r
303     if type\r
304       reply << " (format: #{type})"\r
305     end\r
306     m.reply reply\r
307     return handle\r
308   end\r
309 \r
310   def del_rss(m, params, pass=false)\r
311     feed = unwatch_rss(m, params, true)\r
312     if feed.watched?\r
313       m.reply "someone else is watching #{feed.handle}, I won't remove it from my list"\r
314       return\r
315     end\r
316     @@mutex.synchronize {\r
317       @feeds.delete(feed.handle.downcase)\r
318     }\r
319     m.okay unless pass\r
320     return\r
321   end\r
322 \r
323   def replace_rss(m, params)\r
324     handle = params[:handle]\r
325     if @feeds.key?(handle.downcase)\r
326       del_rss(m, {:handle => handle}, true)\r
327     end\r
328     if @feeds.key?(handle.downcase)\r
329       m.reply "can't replace #{feed.handle}"\r
330     else\r
331       add_rss(m, params, true)\r
332     end\r
333   end\r
334 \r
335   def forcereplace_rss(m, params)\r
336     add_rss(m, params, true)\r
337   end\r
338 \r
339   def watch_rss(m, params)\r
340     handle = params[:handle]\r
341     url = params[:url]\r
342     type = params[:type]\r
343     if url\r
344       add_rss(m, params)\r
345     end\r
346     feed = nil\r
347     @@mutex.synchronize {\r
348       feed = @feeds.fetch(handle.downcase, nil)\r
349     }\r
350     if feed\r
351       @@mutex.synchronize {\r
352         if feed.add_watch(m.replyto)\r
353           watchRss(feed, m)\r
354           m.okay\r
355         else\r
356           m.reply "Already watching #{feed.handle}"\r
357         end\r
358       }\r
359     else\r
360       m.reply "Couldn't watch feed #{handle} (no such feed found)"\r
361     end\r
362   end\r
363 \r
364   def unwatch_rss(m, params, pass=false)\r
365     handle = params[:handle].downcase\r
366     unless @feeds.has_key?(handle)\r
367       m.reply("dunno that feed")\r
368       return\r
369     end\r
370     feed = @feeds[handle]\r
371     if feed.rm_watch(m.replyto)\r
372       m.reply "#{m.replyto} has been removed from the watchlist for #{feed.handle}"\r
373     else\r
374       m.reply("#{m.replyto} wasn't watching #{feed.handle}") unless pass\r
375     end\r
376     if !feed.watched?\r
377       @@mutex.synchronize {\r
378         if @@watchThreads[handle].kind_of? Thread\r
379           @@watchThreads[handle].kill\r
380           debug "rmwatch: Killed thread for #{handle}"\r
381           @@watchThreads.delete(handle)\r
382         end\r
383       }\r
384     end\r
385     return feed\r
386   end\r
387 \r
388   def rewatch_rss(m=nil, params=nil)\r
389     kill_threads\r
390 \r
391     # Read watches from list.\r
392     watchlist.each{ |handle, feed|\r
393       watchRss(feed, m)\r
394     }\r
395     m.okay if m\r
396   end\r
397 \r
398   private\r
399   def watchRss(feed, m=nil)\r
400     if @@watchThreads.has_key?(feed.handle)\r
401       report_problem("watcher thread for #{feed.handle} is already running", nil, m)\r
402       return\r
403     end\r
404     @@watchThreads[feed.handle] = Thread.new do\r
405       debug "watcher for #{feed} started"\r
406       oldItems = []\r
407       firstRun = true\r
408       failures = 0\r
409       loop do\r
410         begin\r
411           debug "fetching #{feed}"\r
412           title = newItems = nil\r
413           @@mutex.synchronize {\r
414             title, newItems = fetchRss(feed)\r
415           }\r
416           unless newItems\r
417             debug "no items in feed #{feed}"\r
418             failures +=1\r
419           else\r
420             debug "Checking if new items are available for #{feed}"\r
421             if firstRun\r
422               debug "First run, we'll see next time"\r
423               firstRun = false\r
424             else\r
425               otxt = oldItems.map { |item| item.to_s }\r
426               dispItems = newItems.reject { |item|\r
427                 otxt.include?(item.to_s)\r
428               }\r
429               if dispItems.length > 0\r
430                 debug "Found #{dispItems.length} new items in #{feed}"\r
431                 # When displaying watched feeds, publish them from older to newer\r
432                 dispItems.reverse.each { |item|\r
433                   @@mutex.synchronize {\r
434                     printFormattedRss(feed, item)\r
435                   }\r
436                 }\r
437               else\r
438                 debug "No new items found in #{feed}"\r
439               end\r
440             end\r
441             oldItems = newItems.dup\r
442           end\r
443         rescue Exception => e\r
444           error "Error watching #{feed}: #{e.inspect}"\r
445           debug e.backtrace.join("\n")\r
446           failures += 1\r
447         end\r
448 \r
449         seconds = @bot.config['rss.thread_sleep'] * (failures + 1)\r
450         seconds += seconds * (rand(100)-50)/100\r
451         debug "watcher for #{feed} going to sleep #{seconds} seconds.."\r
452         sleep seconds\r
453       end\r
454     end\r
455   end\r
456 \r
457   def printFormattedRss(feed, item, opts=nil)\r
458     places = feed.watchers\r
459     handle = "::#{feed.handle}:: "\r
460     date = String.new\r
461     if opts\r
462       places = opts[:places] if opts.key?(:places)\r
463       handle = opts[:handle].to_s if opts.key?(:handle)\r
464       if opts.key?(:date) && opts[:date]\r
465         if item.respond_to?(:pubDate) \r
466           if item.pubDate.class <= Time\r
467             date = item.pubDate.strftime("%Y/%m/%d %H.%M.%S")\r
468           else\r
469             date = item.pubDate.to_s\r
470           end\r
471         elsif  item.respond_to?(:date)\r
472           if item.date.class <= Time\r
473             date = item.date.strftime("%Y/%m/%d %H.%M.%S")\r
474           else\r
475             date = item.date.to_s\r
476           end\r
477         else\r
478           date = "(no date)"\r
479         end\r
480         date += " :: "\r
481       end\r
482     end\r
483     title = "#{Bold}#{item.title.chomp.riphtml}#{Bold}" if item.title\r
484     desc = item.description.gsub(/\s+/,' ').strip.riphtml.shorten(@bot.config['rss.text_max']) if item.description\r
485     link = item.link.chomp if item.link\r
486     places.each { |loc|\r
487       case feed.type\r
488       when 'blog'\r
489         @bot.say loc, "#{handle}#{date}#{item.category.content} blogged at #{link}"\r
490         @bot.say loc, "#{handle}#{title} - #{desc}"\r
491       when 'forum'\r
492         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
493       when 'wiki'\r
494         @bot.say loc, "#{handle}#{date}#{item.title} has been edited by #{item.dc_creator}. #{desc} #{link}"\r
495       when 'gmame'\r
496         @bot.say loc, "#{handle}#{date}Message #{title} sent by #{item.dc_creator}. #{desc}"\r
497       when 'trac'\r
498         @bot.say loc, "#{handle}#{date}#{title} @ #{link}"\r
499         unless item.title =~ /^Changeset \[(\d+)\]/\r
500           @bot.say loc, "#{handle}#{date}#{desc}"\r
501         end\r
502       else\r
503         @bot.say loc, "#{handle}#{date}#{title}#{' @ ' if item.title && item.link}#{link}"\r
504       end\r
505     }\r
506   end\r
507 \r
508   def fetchRss(feed, m=nil)\r
509     begin\r
510       # Use 60 sec timeout, cause the default is too low\r
511       # Do not use get_cached for RSS until we have proper cache handling\r
512       # xml = @bot.httputil.get_cached(feed.url,60,60)\r
513       xml = @bot.httputil.get_cached(feed.url, 60, 60)\r
514     rescue URI::InvalidURIError, URI::BadURIError => e\r
515       report_problem("invalid rss feed #{feed.url}", e, m)\r
516       return\r
517     rescue => e\r
518       report_problem("error getting #{feed.url}", e, m)\r
519       return\r
520     end\r
521     debug "fetched #{feed}"\r
522     unless xml\r
523       report_problem("reading feed #{feed} failed", nil, m)\r
524       return\r
525     end\r
526 \r
527     begin\r
528       ## do validate parse\r
529       rss = RSS::Parser.parse(xml)\r
530       debug "parsed #{feed}"\r
531     rescue RSS::InvalidRSSError\r
532       ## do non validate parse for invalid RSS 1.0\r
533       begin\r
534         rss = RSS::Parser.parse(xml, false)\r
535       rescue RSS::Error => e\r
536         report_problem("parsing rss stream failed, whoops =(", e, m)\r
537         return\r
538       end\r
539     rescue RSS::Error => e\r
540       report_problem("parsing rss stream failed, oioi", e, m)\r
541       return\r
542     rescue => e\r
543       report_problem("processing error occured, sorry =(", e, m)\r
544       return\r
545     end\r
546     items = []\r
547     if rss.nil?\r
548       report_problem("#{feed} does not include RSS 1.0 or 0.9x/2.0", nil, m)\r
549     else\r
550       begin\r
551         rss.output_encoding = 'UTF-8'\r
552       rescue RSS::UnknownConvertMethod => e\r
553         report_problem("bah! something went wrong =(", e, m)\r
554         return\r
555       end\r
556       rss.channel.title ||= "Unknown"\r
557       title = rss.channel.title\r
558       rss.items.each do |item|\r
559         item.title ||= "Unknown"\r
560         items << item\r
561       end\r
562     end\r
563 \r
564     if items.empty?\r
565       report_problem("no items found in the feed, maybe try weed?", e, m)\r
566       return\r
567     end\r
568     return [title, items]\r
569   end\r
570 end\r
571 \r
572 plugin = RSSFeedsPlugin.new\r
573 \r
574 plugin.map 'rss show :handle :limit',\r
575   :action => 'show_rss',\r
576   :requirements => {:limit => /^\d+(?:\.\.\d+)?$/},\r
577   :defaults => {:limit => 5}\r
578 plugin.map 'rss list :handle',\r
579   :action => 'list_rss',\r
580   :defaults =>  {:handle => nil}\r
581 plugin.map 'rss watched :handle',\r
582   :action => 'watched_rss',\r
583   :defaults =>  {:handle => nil}\r
584 plugin.map 'rss add :handle :url :type',\r
585   :action => 'add_rss',\r
586   :defaults => {:type => nil}\r
587 plugin.map 'rss del :handle',\r
588   :action => 'del_rss'\r
589 plugin.map 'rss delete :handle',\r
590   :action => 'del_rss'\r
591 plugin.map 'rss rm :handle',\r
592   :action => 'del_rss'\r
593 plugin.map 'rss replace :handle :url :type',\r
594   :action => 'replace_rss',\r
595   :defaults => {:type => nil}\r
596 plugin.map 'rss forcereplace :handle :url :type',\r
597   :action => 'forcereplace_rss',\r
598   :defaults => {:type => nil}\r
599 plugin.map 'rss watch :handle :url :type',\r
600   :action => 'watch_rss',\r
601   :defaults => {:url => nil, :type => nil}\r
602 plugin.map 'rss unwatch :handle',\r
603   :action => 'unwatch_rss'\r
604 plugin.map 'rss rmwatch :handle',\r
605   :action => 'unwatch_rss'\r
606 plugin.map 'rss rewatch',\r
607   :action => 'rewatch_rss'\r
608 \r