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