Change some tabs to spaces.
[six.git] / plugins / feed.rb
blob592a678446a35ba54423dbf7d1baa82cc4acd951
1 #!/usr/bin/env ruby -wKU
3 require "net/http"
4 require 'rss'
5 require "uri"
6 require 'cgi'
7 require "yaml"
9 # $log = STDERR
11 module FeedStuff
12   class Resource
13     def initialize(uri_string, etag = nil)
14       @uri_string = uri_string
15       @etag       = etag
16     end
18     def request(uri_string, etag = nil)
19       uri  = URI.parse(uri_string)
20       path = uri.path.empty? ? '/' : uri.path
21       re   = Net::HTTP.start(uri.host, uri.port) { |http| http.get(path, etag.nil? ? nil : { 'If-None-Match' => etag }) }
23       if re.kind_of? Net::HTTPSuccess
24         @etag = re['ETag']
25         re
26       elsif re.kind_of? Net::HTTPFound
27         $log.puts "#{uri.host}: Found (redirect) → " + re['Location']
28         request(re['Location'], etag)
29       elsif re.kind_of? Net::HTTPMovedPermanently
30         $log.puts "#{uri.host}: Permanent redirect → " + re['Location']
31         request(re['Location'], etag)
32       elsif re.kind_of? Net::HTTPTemporaryRedirect
33         $log.puts "#{uri.host}: Temporary redirect → " + re['Location']
34         request(re['Location'], etag)
35       elsif re.kind_of? Net::HTTPNotModified
36         $log.puts "#{uri.host}: Not modified."
37         nil
38       else
39         raise "Unknown response (#{re.code}) from #{uri_string}"
40       end
41     end
43     def get
44       begin
45         if re = request(@uri_string, @etag)
46           return re.body 
47         end
48       rescue Exception => e
49         $log.puts "*** network error"
50         $log.puts "#{e.message}\n#{e.backtrace.join("\n")}"
51       end
52       nil
53     end
55     def save
56       res = { 'uri' => @uri_string }
57       res['etag'] = @etag unless @etag.nil?
58       res
59     end
60   end
62   class Feed
63     class Item
64       def initialize(channel, item, master)
65         @channel = channel
66         @item    = item
67         @master  = master
68       end
70       def read=(flag)
71         @master.did_read(self) if flag
72       end
74       def guid
75         if @item.respond_to? :guid
76           @item.guid.content 
77         else
78           @item.link
79         end
80       end
82       def title
83         @item.title
84       end
86       def summary(limit = 420)
87         prefix = "[#{@channel.title}] #{@item.title}: "
88         suffix = @item.link ? " — #{@item.link}" : ''
89         length = limit - prefix.length - suffix.length
91         body   = @item.description.gsub(/<.*?>/, ' ')
92         body   = CGI::unescapeHTML(body)
93         body   = body.gsub(/\s+/, ' ').gsub(/\A\s+|\s+\z/, '')
94         body   = body.sub(/(.{0,#{length}})(\s.+)?$/) { $1 + ($2.nil? ? '' : '…')}
96         prefix + body + suffix
97       end
98     end
100     def initialize(uri_string, etag = nil, last_check = nil, seen = [])
101       @ressource  = Resource.new(uri_string, etag)
102       @last_check = last_check
103       @seen       = seen
104       @unread     = []
105     end
107     def unread
108       if body = @ressource.get
109         if rss = RSS::Parser.parse(body, false)
110           new_items = rss.items.map { |e| Item.new(rss.channel, e, self) }
111           new_items.reject! { |item| @seen.include? item.guid }
112           @seen.concat(new_items.map { |item| item.guid })
113           @unread.concat(new_items)
114         else
115           $log.puts "Error parsing feed at " + @ressource.save['uri']
116         end
117       end
118       @last_check = Time.now
119       @unread.dup
120     end
122     def did_read(item)
123       @unread.reject! { |e| e.guid == item.guid }
124     end
126     def save
127       res = { 'seen' => @seen }
128       res['last_check'] = @last_check unless @last_check.nil?
129       res.merge(@ressource.save)
130     end
131   end
133   feeds = []
135   module_function
137   def load(filename)
138     uris = %w{
139       http://henrik.nyh.se/feed/
140       http://macromates.com/blog/feed/
141       http://macromates.com/blog/comments/feed/
142       http://macromates.com/svnlog/bundles.rss
143       http://macromates.com/textmate/screencast.rss
144       http://macromates.com/textmate/changelog.rss
145       http://alterslash.org/rss_full.xml
146       http://blog.grayproductions.net/xml/rss20/feed.xml
147       http://theocacao.com/index.rss
148       http://blog.circlesixdesign.com/feed/
149       http://kevin.sb.org/xml/rss/feed.xml
150     }
152     defaults = uris.map { |uri| Feed.new(uri) }
153     @feeds = open(filename) { |io| YAML.load(io).map { |e| Feed.new(e['uri'], e['etag'], e['last_check'], e['seen']) } } rescue defaults
154   end
155   
156   def save(filename)
157     open(filename, 'w') do |io|
158       io << "# Feeds we follow and their status.\n"
159       io << "# Last update: #{Time.now.strftime('%F %T')}.\n"
160       YAML.dump(@feeds.map { |e| e.save }, io)
161       io << "\n"
162     end
163   end
165   def run(out = STDOUT, filename = '/tmp/feeds.yaml', period = 30*60)
166     tr = Thread.new do
167       begin
168         load(filename)
169         @feeds.each do |feed|
170           feed.unread.each do |item|
171             $log.puts "Skip new item: #{item.title}"
172             item.read = true
173           end
174         end
176         while true
177           $log.puts Time.now.strftime('%H:%M:%S: Checking feeds…')
179           @feeds.each do |feed|
180             feed.unread.each do |item|
181               out.puts item.summary
182               item.read = true
183             end
184           end
186           save(filename)
188           $log.puts Time.now.strftime('%H:%M:%S: Done checking feeds!')
189           sleep(period)
190         end
191       rescue Exception => e
192         $log.puts "*** thread error"
193         $log.puts "#{e.message}\n#{e.backtrace.join("\n")}"
194       end
195     end
196     # tr.join
197   end
199   def add(uri)
200     @feeds << Feed.new(uri)
201   end
204 # FeedStuff.run
205 # sleep(10*60)
207 # ===================
208 # = Cybot Interface =
209 # ===================
211 class Feed < PluginBase
212   def initialize(*args)
213     @brief_help = 'Feeds the channel with hot news from various RSS channels'
214     @filename   = nil
215     @did_start  = false
216     super(*args)
217   end
219   # Checks whether the user is allowed to use this plugin
220   # Informs them and returns false if not 
221   def authed?(irc)
222     if !$user.caps(irc, 'phrases', 'op', 'owner').any?
223       irc.reply "You aren't allowed to use this command"
224       return false
225     end
226     true
227   end
229   # Called for all incoming channel messages
230   # We can’t start the thread before we have the irc object
231   # this method seems to be the quickest way to grab it
232   def hook_privmsg_chan(irc, msg)
233     return if @did_start
234     FeedStuff.run(irc, @filename)
235     @did_start = true
236   end
238   def cmd_subscribe(irc, line)
239     return unless authed?(irc)
240     irc.reply("Not yet implemented.")
241     # FeedStuff.add(line)
242   end
243   help :subscribe, "Subscribe to an RSS feed."
245   # We want to load in the thread, so we only store the filename
246   def load
247     @filename = File.expand_path(file_name('feeds.yml'))
248   end