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