retjilp 0.2 → 0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,51 @@
1
- require_relative 'options'
1
+ require 'json/pure'
2
+ require 'optparse'
3
+
4
+ require_relative 'log'
2
5
  require_relative 'retweeter'
6
+ require_relative 'twitter'
3
7
 
4
8
  module Retjilp
9
+ DATA_DIR = File.expand_path("~/.retjilp")
10
+
5
11
  class Runner
6
12
  def initialize(argv)
7
- @options = Options.new(argv)
13
+ # Parse command-line options
14
+ OptionParser.new do |opts|
15
+ opts.banner = "Usage: retjilp [ --help ] [ --verbose | --debug ]"
16
+ opts.on("--verbose", "Run with verbose output") { Retjilp::log.level = Logger::INFO }
17
+ opts.on("--debug", "Run with debug output") { Retjilp::log.level = Logger::DEBUG }
18
+ opts.on_tail("-h", "--help", "Show this help") { puts opts ; exit }
19
+ begin
20
+ opts.parse!(argv)
21
+ rescue => e
22
+ fatal_error("Invalid option(s): #{e.message}\n" + opts.to_s)
23
+ end
24
+ end
25
+
26
+ # Parse config file
27
+ config_filename = File.join(DATA_DIR, "config")
28
+ begin
29
+ @options = File.open(config_filename) { |f| JSON.load(f) }
30
+ rescue => e
31
+ fatal_error("Error parsing configuration file #{config_filename}: #{e.message}")
32
+ end
33
+
34
+ # Convert keys to symbols
35
+ @options = @options.inject({}){|m,(k,v)| m[k.to_sym] = v; m}
8
36
  end
9
37
 
10
38
  def run
11
- Retweeter.new(@options).run
39
+ consumer_key = (@options[:consumer_key] or fatal_error("Consumer key missing"))
40
+ consumer_secret = (@options[:consumer_secret] or fatal_error("Consumer secret missing"))
41
+ twitter = Twitter.new(@options[:consumer_key], @options[:consumer_secret])
42
+ Retweeter.new(twitter, @options).run
12
43
  end
44
+
45
+ private
46
+ def fatal_error(msg)
47
+ STDERR.puts msg
48
+ exit(-1)
49
+ end
13
50
  end
14
51
  end
@@ -0,0 +1,118 @@
1
+ require_relative 'log'
2
+
3
+ module Retjilp
4
+ class Twitter
5
+ TWITTER_URI = "http://api.twitter.com"
6
+ API_VERSION = "1.1"
7
+ ACCESS_TOKEN_FILENAME = File.join(File.expand_path("~/.retjilp"), "access_token")
8
+
9
+ def initialize(consumer_key, consumer_secret)
10
+ @consumer_key = consumer_key
11
+ @consumer_secret = consumer_secret
12
+ end
13
+
14
+ def user_timeline
15
+ Retjilp::log.info("Fetching own tweets")
16
+ get "/statuses/user_timeline.json?trim_user=true&include_rts=true"
17
+ end
18
+
19
+ def list_statuses(list, options)
20
+ Retjilp::log.info("Fetching list tweets of #{list}")
21
+ since_id = options[:since_id] ? "&since_id=#{options[:since_id]}" : ""
22
+ get "/lists/statuses.json?slug=#{list}&owner_screen_name=#{@user_info['screen_name']}&include_rts=true" + since_id
23
+ end
24
+
25
+ def home_timeline(options)
26
+ since_id = options[:since_id] ? "&since_id=#{options[:since_id]}" : ""
27
+ get "/statuses/home_timeline.json?trim_user=true" + since_id
28
+ end
29
+
30
+ def retweet(id)
31
+ result = @access_token.post("/#{API_VERSION}/statuses/retweet/#{id}.json")
32
+ result.class == Net::HTTPOK or Retjilp::log.error("Error retweeting #{result.body}")
33
+ end
34
+
35
+ def login
36
+ # Request the token if the cached access token does not exist
37
+ @access_token, @user_info = cached_access_token
38
+ unless @access_token
39
+ STDIN.tty? or raise "This script must be run interactively the first time to be able to authenticate."
40
+ Retjilp::log.info("Requesting new access token")
41
+ consumer = OAuth::Consumer.new(
42
+ @consumer_key,
43
+ @consumer_secret,
44
+ :site => TWITTER_URI,
45
+ :scheme => :header,
46
+ :request_token_path => "/oauth/request_token",
47
+ :authorize_path => "/oauth/authorize",
48
+ :@access_token_path => "/oauth/@access_token",
49
+ :http_method => :post)
50
+ request_token = consumer.get_request_token(:oauth_callback => "oob")
51
+
52
+ puts "Please open #{request_token.authorize_url} in your browser, authorize Retjilp, and enter the PIN code below:"
53
+ verifier = STDIN.gets.chomp
54
+
55
+ begin
56
+ @access_token = request_token.get_access_token(:oauth_verifier => verifier)
57
+ rescue OAuth::Unauthorized
58
+ raise "Invalid PIN verification!"
59
+ end
60
+ @user_info = verify_token(@access_token) or raise "Access token not authorized!"
61
+ cached_access_token = @access_token
62
+ end
63
+ Retjilp::log.info("Logged in as #{@user_info["screen_name"]}")
64
+ end
65
+
66
+ protected
67
+ def cached_access_token
68
+ access_token = nil
69
+ user_info = nil
70
+ if File.exist?(ACCESS_TOKEN_FILENAME)
71
+ Retjilp::log.info("Loading cached access token from #{ACCESS_TOKEN_FILENAME}")
72
+ File.open(ACCESS_TOKEN_FILENAME) do |f|
73
+ begin
74
+ access_token_data = JSON.load(f)
75
+ consumer = OAuth::Consumer.new(@consumer_key, @consumer_secret, { :site => TWITTER_URI })
76
+ access_token = OAuth::AccessToken.new(consumer, access_token_data["token"], access_token_data["secret"])
77
+ unless user_info = verify_token(access_token)
78
+ Retjilp::log.warn("Cached token not authorized")
79
+ access_token = nil
80
+ end
81
+ rescue JSON::ParserError
82
+ Retjilp::log.warn("Cached token does not parse")
83
+ end
84
+ end
85
+ end
86
+ [access_token, user_info]
87
+ end
88
+
89
+ def cached_access_token=(access_token)
90
+ Retjilp::log.info("Caching token in #{ACCESS_TOKEN_FILENAME}")
91
+ File.open(ACCESS_TOKEN_FILENAME, 'w+') do |f|
92
+ access_token_data = {
93
+ "token" => access_token.token,
94
+ "secret" => access_token.secret
95
+ }
96
+ JSON.dump(access_token_data, f)
97
+ end
98
+ end
99
+
100
+ private
101
+ # Helper method to verify the validity of an access token.
102
+ # Returns the user info if the token verified correctly.
103
+ def verify_token(token)
104
+ response = token.get("/#{API_VERSION}/account/verify_credentials.json")
105
+ response.class == Net::HTTPOK ? JSON.parse(response.body) : nil
106
+ end
107
+
108
+ private
109
+ def get(url)
110
+ full_url = "/#{API_VERSION}#{url}"
111
+ Retjilp::log.debug("-> " + full_url)
112
+ result = JSON.parse(@access_token.get("/#{API_VERSION}/#{url}").body)
113
+ Retjilp::log.debug("<- " + JSON.pretty_generate(result))
114
+ raise "Error fetching result: #{result.to_s}" if result.include? "errors"
115
+ result
116
+ end
117
+ end
118
+ end
@@ -5,18 +5,24 @@ Gem::Specification.new do |s|
5
5
  s.summary = 'Automatically retweet tweets'
6
6
  s.description = 'Retjilp logs into your account, scans all the tweets from your following list or another defined list for a set of matching words, and retweets the ones that match (using the native retweet API).'
7
7
  s.requirements = ['']
8
- s.version = '0.2'
8
+ s.version = '0.3'
9
9
  s.author = 'Remko Tronçon'
10
10
  s.email = 'remko@el-tramo.be'
11
11
  s.homepage = 'http://el-tramo.be/blog/retjilp'
12
12
  s.platform = Gem::Platform::RUBY
13
13
  s.required_ruby_version = '>=1.8'
14
- s.files = Dir['**/**']
14
+ s.files = Dir['{bin,lib,doc}/**/*'] + Dir['[A-Z]*'] + ['retjilp.gemspec']
15
15
  s.executables = 'retjilp'
16
16
  s.require_paths = ['lib']
17
17
  s.has_rdoc = false
18
18
  s.license = 'BSD'
19
+ s.test_files = Dir.glob("test/**/*.rb")
19
20
 
20
21
  s.add_runtime_dependency('oauth')
21
22
  s.add_runtime_dependency('json_pure')
23
+
24
+ s.add_development_dependency('rake')
25
+ s.add_development_dependency('rspec-core')
26
+ s.add_development_dependency('rspec-mocks')
27
+ s.add_development_dependency('rspec-expectations')
22
28
  end
@@ -0,0 +1,82 @@
1
+ $LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__) + '/../lib'))
2
+
3
+ require 'retjilp/retweeter'
4
+ require 'retjilp/twitter'
5
+
6
+ module Retjilp
7
+ describe Retweeter do
8
+ let(:twitter) { double(Twitter) }
9
+
10
+ before (:each) do
11
+ twitter.stub(:login)
12
+ end
13
+
14
+ context 'match keyword' do
15
+ let(:retweeter) { Retweeter.new(twitter, :match => ['MatchingKeyword']) }
16
+
17
+ context 'empty user timeline' do
18
+ before(:each) do
19
+ twitter.stub(:user_timeline) {[]}
20
+ end
21
+
22
+ it 'retweets new matching items' do
23
+ twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1'}]}
24
+ twitter.should_receive(:retweet).with('id1')
25
+ retweeter.run
26
+ end
27
+
28
+ it 'does not retweet non-matching items' do
29
+ twitter.stub(:home_timeline) {[{'text' => 'OtherKeyword', 'id' => 'id1'}]}
30
+ twitter.should_not_receive(:retweet)
31
+ retweeter.run
32
+ end
33
+
34
+ it 'retweets retweets with their id' do
35
+ twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1', 'retweeted_status' => {'id' => 'id2'}}]}
36
+ twitter.should_receive(:retweet).with('id2')
37
+ retweeter.run
38
+ end
39
+ end
40
+
41
+ context 'user timeline with already retweeted items' do
42
+ before(:each) do
43
+ twitter.stub(:user_timeline) {[{}, {'retweeted_status' => {'id' => 'id1'}}]}
44
+ end
45
+
46
+ it 'does not retweet already tweeted items' do
47
+ twitter.stub(:home_timeline) {[{'text' => 'MatchingKeyword', 'id' => 'id1'}]}
48
+ twitter.should_not_receive(:retweet)
49
+ Retweeter.new(twitter, :match => ['MatchingKeyword']).run
50
+ end
51
+ end
52
+ end
53
+
54
+ context 'match is empty' do
55
+ let(:retweeter) { Retweeter.new(twitter, :match => []) }
56
+
57
+ it 'retweets all items' do
58
+ twitter.stub(:user_timeline) {[]}
59
+ twitter.stub(:home_timeline) {[{'text' => 'OtherKeyword', 'id' => 'id1'}]}
60
+
61
+ twitter.should_receive(:retweet).with('id1')
62
+
63
+ retweeter.run
64
+ end
65
+ end
66
+
67
+ context 'retweet from list' do
68
+ let(:retweeter) { Retweeter.new(twitter, :retweet_from_list => 'MyList', :match => ['MatchingKeyword']) }
69
+
70
+ it 'retweets from the right list' do
71
+ twitter.stub(:user_timeline) {[]}
72
+ twitter.should_receive(:list_statuses).with('MyList', anything()).and_return([
73
+ {'text' => 'MatchingKeyword', 'id' => 'id1'},
74
+ {'text' => 'OtherKeyword', 'id' => 'id2'}
75
+ ])
76
+ twitter.should_receive(:retweet).with('id1')
77
+ retweeter.run
78
+ end
79
+ end
80
+
81
+ end
82
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: retjilp
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Remko Tronçon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2013-03-02 00:00:00.000000000 Z
11
+ date: 2013-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: oauth
@@ -38,6 +38,62 @@ dependencies:
38
38
  - - '>='
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-core
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - '>='
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-mocks
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - '>='
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-expectations
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - '>='
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - '>='
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
41
97
  description: Retjilp logs into your account, scans all the tweets from your following
42
98
  list or another defined list for a set of matching words, and retweets the ones
43
99
  that match (using the native retweet API).
@@ -47,15 +103,37 @@ executables:
47
103
  extensions: []
48
104
  extra_rdoc_files: []
49
105
  files:
50
- - AUTHORS
51
106
  - bin/retjilp
52
- - config
53
- - COPYING
54
- - lib/retjilp/options.rb
107
+ - lib/retjilp/log.rb
55
108
  - lib/retjilp/retweeter.rb
56
109
  - lib/retjilp/runner.rb
110
+ - lib/retjilp/twitter.rb
111
+ - doc/_index.html
112
+ - doc/class_list.html
113
+ - doc/css/common.css
114
+ - doc/css/full_list.css
115
+ - doc/css/style.css
116
+ - doc/file.AUTHORS.html
117
+ - doc/file.COPYING.html
118
+ - doc/file.README.html
119
+ - doc/file_list.html
120
+ - doc/frames.html
121
+ - doc/index.html
122
+ - doc/js/app.js
123
+ - doc/js/full_list.js
124
+ - doc/js/jquery.js
125
+ - doc/method_list.html
126
+ - doc/Retjilp/Options.html
127
+ - doc/Retjilp/Retweeter.html
128
+ - doc/Retjilp/Runner.html
129
+ - doc/Retjilp.html
130
+ - doc/top-level-namespace.html
131
+ - AUTHORS
132
+ - COPYING
133
+ - Rakefile
57
134
  - README.markdown
58
135
  - retjilp.gemspec
136
+ - test/spec/retweeter_spec.rb
59
137
  homepage: http://el-tramo.be/blog/retjilp
60
138
  licenses:
61
139
  - BSD
@@ -81,4 +159,5 @@ rubygems_version: 2.0.0
81
159
  signing_key:
82
160
  specification_version: 4
83
161
  summary: Automatically retweet tweets
84
- test_files: []
162
+ test_files:
163
+ - test/spec/retweeter_spec.rb
data/config DELETED
@@ -1,27 +0,0 @@
1
- /*
2
- * Retjilp configuration file.
3
- *
4
- * Change this to reflect your setup, and put it in ~/.retjilp
5
- */
6
- {
7
- /*
8
- * Consumer key and secret.
9
- * Get this by registering a new (desktop) application at
10
- * http://twitter.com/apps
11
- */
12
- "consumer_key": "abcdeFghIjklMnOpQrStUv",
13
- "consumer_secret": "abcdefgh123456789abcdefgh123456789abcdefg",
14
-
15
- /*
16
- * The strings that a tweet should be matched against.
17
- * These strings are matched in lower case.
18
- */
19
- "match": ["#sometag", "#someothertag", "someword"]
20
-
21
- /*
22
- * List name from which statuses are retweeted.
23
- * Set this config value if you want to retweet only from
24
- * this list instead of your following list.
25
- */
26
- /* "retweet_from_list": "auto-retweet" */
27
- }
@@ -1,24 +0,0 @@
1
- require 'optparse'
2
- require 'logger'
3
-
4
- module Retjilp
5
- class Options
6
- attr_reader :log_level
7
-
8
- def initialize(argv)
9
- @log_level = Logger::WARN
10
- OptionParser.new do |opts|
11
- opts.banner = "Usage: retjilp [ --help ] [ --verbose | --debug ]"
12
- opts.on("--verbose", "Run with verbose output") { @log_level = Logger::INFO }
13
- opts.on("--debug", "Run with debug output") { @log_level = Logger::DEBUG }
14
- opts.on_tail("-h", "--help", "Show this help") { puts opts ; exit }
15
- begin
16
- opts.parse!(argv)
17
- rescue => e
18
- STDERR.puts e.message, "\n", opts
19
- exit(-1)
20
- end
21
- end.parse!(argv)
22
- end
23
- end
24
- end