tweetwine 0.2.5 → 0.2.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/CHANGELOG.rdoc +30 -17
  2. data/README.rdoc +16 -17
  3. data/Rakefile +2 -0
  4. data/bin/tweetwine +3 -68
  5. data/example/example_helper.rb +31 -41
  6. data/example/fixtures/{statuses.json → home.json} +0 -0
  7. data/example/fixtures/mentions.json +1 -0
  8. data/example/fixtures/search.json +1 -0
  9. data/example/fixtures/update.json +1 -0
  10. data/example/fixtures/user.json +1 -0
  11. data/example/fixtures/users.json +1 -0
  12. data/example/search_statuses_example.rb +36 -0
  13. data/example/show_followers_example.rb +23 -0
  14. data/example/show_friends_example.rb +23 -0
  15. data/example/show_home_example.rb +51 -0
  16. data/example/show_mentions_example.rb +23 -0
  17. data/example/show_metadata_example.rb +54 -7
  18. data/example/show_user_example.rb +37 -0
  19. data/example/update_status_example.rb +65 -0
  20. data/lib/tweetwine/cli.rb +241 -0
  21. data/lib/tweetwine/client.rb +94 -57
  22. data/lib/tweetwine/io.rb +39 -28
  23. data/lib/tweetwine/meta.rb +1 -1
  24. data/lib/tweetwine/retrying_http.rb +93 -0
  25. data/lib/tweetwine/startup_config.rb +14 -15
  26. data/lib/tweetwine/url_shortener.rb +13 -8
  27. data/lib/tweetwine/util.rb +14 -0
  28. data/lib/tweetwine.rb +2 -1
  29. data/test/cli_test.rb +16 -0
  30. data/test/client_test.rb +275 -205
  31. data/test/fixtures/test_config.yaml +2 -1
  32. data/test/io_test.rb +89 -62
  33. data/test/retrying_http_test.rb +127 -0
  34. data/test/startup_config_test.rb +52 -27
  35. data/test/test_helper.rb +32 -17
  36. data/test/url_shortener_test.rb +18 -18
  37. data/test/util_test.rb +145 -47
  38. metadata +20 -7
  39. data/example/show_latest_statuses_example.rb +0 -45
  40. data/lib/tweetwine/rest_client_wrapper.rb +0 -37
  41. data/test/rest_client_wrapper_test.rb +0 -68
@@ -0,0 +1,37 @@
1
+ require "example_helper"
2
+
3
+ FakeWeb.register_uri(:get, "https://#{TEST_AUTH}@twitter.com/statuses/user_timeline/#{TEST_USER}.json?count=20&page=1", :body => fixture("user.json"))
4
+
5
+ Feature "show a specific user's latest statuses" do
6
+ in_order_to "to see what is going on with a specific user"
7
+ as_a "authenticated user"
8
+ i_want_to "see the latest statuses of a specific user"
9
+
10
+ Scenario "see my latest statuses" do
11
+ When "application is launched 'user' command and without extra arguments" do
12
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors user})
13
+ end
14
+
15
+ Then "my the latest statuses are shown" do
16
+ @output[0].should == "jillv, in reply to chris, 9 hours ago:"
17
+ @output[1].should == "@chris wait me until the garden"
18
+ @output[2].should == ""
19
+ @output[3].should == "jillv, 3 days ago:"
20
+ @output[4].should == "so boring to wait"
21
+ end
22
+ end
23
+
24
+ Scenario "see the latest statuses of another user" do
25
+ When "application is launched 'user' command and the user as an extra argument" do
26
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors user #{TEST_USER}})
27
+ end
28
+
29
+ Then "the latest statuses of the user are shown" do
30
+ @output[0].should == "jillv, in reply to chris, 9 hours ago:"
31
+ @output[1].should == "@chris wait me until the garden"
32
+ @output[2].should == ""
33
+ @output[3].should == "jillv, 3 days ago:"
34
+ @output[4].should == "so boring to wait"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,65 @@
1
+ require "example_helper"
2
+
3
+ FakeWeb.register_uri(:post, "https://#{TEST_AUTH}@twitter.com/statuses/update.json", :body => fixture("update.json"))
4
+
5
+ Feature "update my status" do
6
+ in_order_to "tell something about me to the world"
7
+ as_a "authenticated user"
8
+ i_want_to "update my status"
9
+
10
+ STATUS = "bored. going to sleep."
11
+
12
+ Scenario "update my status from command line with colorization disabled" do
13
+ When "application is launched 'update' command" do
14
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update '#{STATUS}'}, "y")
15
+ end
16
+
17
+ Then "the status sent is shown" do
18
+ @output[5].should == "#{TEST_USER}, 9 hours ago:"
19
+ @output[6].should == "#{STATUS}"
20
+ end
21
+ end
22
+
23
+ Scenario "update my status from command line with colorization enabled" do
24
+ When "application is launched 'update' command" do
25
+ @output = launch_cli(%W{-a #{TEST_AUTH} --colors update '#{STATUS}'}, "y")
26
+ end
27
+
28
+ Then "the status sent is shown" do
29
+ @output[5].should == "\e[32m#{TEST_USER}\e[0m, 9 hours ago:"
30
+ @output[6].should == "#{STATUS}"
31
+ end
32
+ end
33
+
34
+ Scenario "cancel a status from command line" do
35
+ When "application is launched 'update' command" do
36
+ @output = launch_cli(%W{-a #{TEST_AUTH} --colors update '#{STATUS}'}, "n")
37
+ end
38
+
39
+ Then "a cancellation message is shown" do
40
+ @output[3].should =~ /Cancelled./
41
+ end
42
+ end
43
+
44
+ Scenario "update my status from STDIN" do
45
+ When "application is launched 'update' command" do
46
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update}, STATUS, "y")
47
+ end
48
+
49
+ Then "the status sent is shown" do
50
+ @output[0].should == "Status update: "
51
+ @output[5].should == "#{TEST_USER}, 9 hours ago:"
52
+ @output[6].should == "#{STATUS}"
53
+ end
54
+ end
55
+
56
+ Scenario "cancel a status update from STDIN" do
57
+ When "application is launched 'update' command" do
58
+ @output = launch_cli(%W{-a #{TEST_AUTH} --no-colors update}, STATUS, "n")
59
+ end
60
+
61
+ Then "a cancellation message is shown" do
62
+ @output[3].should =~ /Cancelled./
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,241 @@
1
+ require "optparse"
2
+
3
+ module Tweetwine
4
+ class CLI
5
+ EXIT_HELP = 1
6
+ EXIT_VERSION = 2
7
+ EXIT_ERROR = 255
8
+
9
+ def self.launch(args, exec_name, config_file, extra_opts = {})
10
+ new(args, exec_name, config_file, extra_opts, &default_dependencies).execute(args)
11
+ rescue ArgumentError, HttpError => e
12
+ puts "Error: #{e.message}"
13
+ exit(EXIT_ERROR)
14
+ end
15
+
16
+ def execute(args)
17
+ if @config.command != :help
18
+ cmd_options = parse_command_options(@config.command, args)
19
+ @client.send(@config.command, args, cmd_options)
20
+ else
21
+ show_help_command_and_exit(args)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def self.default_dependencies
28
+ lambda do |options|
29
+ io = Tweetwine::IO.new(options)
30
+ http_client = RetryingHttp::Client.new(io)
31
+ url_shortener = lambda { |opts| UrlShortener.new(http_client, opts) }
32
+ Client::Dependencies.new(io, http_client, url_shortener)
33
+ end
34
+ end
35
+
36
+ def initialize(args, exec_name, config_file, extra_opts = {}, &dependencies_blk)
37
+ @global_option_parser = create_global_option_parser(exec_name)
38
+ @config = StartupConfig.new(Client::COMMANDS + [:help], Client::DEFAULT_COMMAND, extra_opts)
39
+ @config.parse(args, config_file, &@global_option_parser)
40
+ @client = Client.new(dependencies_blk.call(@config.options), @config.options) if @config.command != :help
41
+ end
42
+
43
+ def show_help_command_and_exit(args)
44
+ help_about_cmd = args.shift
45
+ if help_about_cmd
46
+ help_about_cmd = help_about_cmd.to_sym
47
+ parse_command_options(help_about_cmd, ["-h"]) if Client::COMMANDS.include?(help_about_cmd)
48
+ end
49
+ @global_option_parser.call(["-h"])
50
+ end
51
+
52
+ def self.create_option_parser
53
+ lambda do |args|
54
+ parsed_options = {}
55
+ begin
56
+ parser = OptionParser.new do |opt|
57
+ opt.on_tail("-h", "--help", "Show this help message and exit") {
58
+ puts opt
59
+ exit(EXIT_HELP)
60
+ }
61
+ schema = yield parsed_options
62
+ opt.banner = schema[:help]
63
+ schema[:opts].each do |opt_schema|
64
+ opt.on(*option_schema_to_ary(opt_schema), &opt_schema[:action])
65
+ end if schema[:opts]
66
+ end.order!(args)
67
+ rescue OptionParser::ParseError => e
68
+ raise ArgumentError, e.message
69
+ end
70
+ parsed_options
71
+ end
72
+ end
73
+
74
+ def self.option_schema_to_ary(opt_schema)
75
+ [:short, :long, :type, :desc].inject([]) do |result, key|
76
+ result << opt_schema[key] if opt_schema[key]
77
+ result
78
+ end
79
+ end
80
+
81
+ def create_global_option_parser(exec_name)
82
+ self.class.create_option_parser do |parsed|
83
+ {
84
+ :help => \
85
+ "A simple but tasty Twitter agent for command line use, made for fun.
86
+
87
+ Usage: #{exec_name} [global_options...] [command] [command_options...]
88
+
89
+ [command] is one of
90
+ * #{Client::COMMANDS[0...-1].join(",\n * ")}, or
91
+ * #{Client::COMMANDS.last}.
92
+
93
+ The default command is #{Client::DEFAULT_COMMAND}.
94
+
95
+ [global_options]:
96
+ ",
97
+ :opts => [
98
+ {
99
+ :short => "-a",
100
+ :long => "--auth USERNAME:PASSWORD",
101
+ :desc => "Authentication",
102
+ :action => lambda { |arg| parsed[:username], parsed[:password] = arg.split(":", 2) }
103
+ },
104
+ {
105
+ :short => "-c",
106
+ :long => "--colors",
107
+ :desc => "Colorize output with ANSI escape codes",
108
+ :action => lambda { |arg| parsed[:colors] = true }
109
+ },
110
+ {
111
+ :short => "-n",
112
+ :long => "--num N",
113
+ :type => Integer,
114
+ :desc => "The number of statuses in page, default #{Client::DEFAULT_NUM_STATUSES}",
115
+ :action => lambda { |arg| parsed[:num_statuses] = arg }
116
+ },
117
+ {
118
+ :long => "--no-colors",
119
+ :desc => "Do not use ANSI colors",
120
+ :action => lambda { |arg| parsed[:colors] = false }
121
+ },
122
+ {
123
+ :long => "--no-url-shorten",
124
+ :desc => "Do not shorten URLs for status update",
125
+ :action => lambda { |arg| parsed[:shorten_urls] = { :enable => false } }
126
+ },
127
+ {
128
+ :short => "-p",
129
+ :long => "--page N",
130
+ :type => Integer,
131
+ :desc => "The page number for statuses, default #{Client::DEFAULT_PAGE_NUM}",
132
+ :action => lambda { |arg| parsed[:page_num] = arg }
133
+ },
134
+ {
135
+ :short => "-v",
136
+ :long => "--version",
137
+ :desc => "Show version information and exit",
138
+ :action => lambda do |arg|
139
+ puts "#{exec_name} #{Tweetwine::VERSION}"
140
+ exit(EXIT_VERSION)
141
+ end
142
+ }
143
+ ]
144
+ }
145
+ end
146
+ end
147
+
148
+ def self.create_command_option_parser(command_name, schema)
149
+ create_option_parser do |parsed|
150
+ {
151
+ :help => \
152
+ "#{command_name} [command_options...] #{schema[:help][:rest_args]}
153
+
154
+ #{schema[:help][:desc]}
155
+
156
+ [command_options]:
157
+ ",
158
+ :opts => schema[:opts]
159
+ }
160
+ end
161
+ end
162
+
163
+ command_parser_schemas = {
164
+ :followers => {
165
+ :help => {
166
+ :desc => \
167
+ "Show the followers of the authenticated user, together with the latest status
168
+ of each follower."
169
+ }
170
+ },
171
+ :friends => {
172
+ :help => {
173
+ :desc => \
174
+ "Show the friends of the authenticated user, together with the latest status of
175
+ each friend."
176
+ }
177
+ },
178
+ :home => {
179
+ :help => {
180
+ :desc => \
181
+ "Show the latest statuses of friends and own tweets (the home timeline of the
182
+ authenticated user)."
183
+ }
184
+ },
185
+ :mentions => {
186
+ :help => {
187
+ :desc => \
188
+ "Show the latest statuses that mention the authenticated user."
189
+ }
190
+ },
191
+ :search => {
192
+ :help => {
193
+ :rest_args => "word_1 [word_2...]",
194
+ :desc => \
195
+ "Search the latest public statuses with one or more words."
196
+ },
197
+ :opts => [
198
+ {
199
+ :short => "-a",
200
+ :long => "--and",
201
+ :desc => "All words must match",
202
+ :action => lambda { |arg| parsed[:bin_op] = :and }
203
+ },
204
+ {
205
+ :short => "-o",
206
+ :long => "--or",
207
+ :desc => "Any word matches",
208
+ :action => lambda { |arg| parsed[:bin_op] = :or }
209
+ }
210
+ ]
211
+ },
212
+ :update => {
213
+ :help => {
214
+ :rest_args => "[status]",
215
+ :desc => \
216
+ "Send a status update, but confirm the action first before actually sending.
217
+ The status update can either be given as an argument or via STDIN if no
218
+ [status] is given."
219
+ }
220
+ },
221
+ :user => {
222
+ :help => {
223
+ :rest_args => "[username]",
224
+ :desc => \
225
+ "Show a specific user's latest statuses. The user is identified with [username]
226
+ argument; if the argument is absent, the authenticated user's statuses are
227
+ shown."
228
+ }
229
+ }
230
+ }
231
+
232
+ COMMAND_OPTION_PARSERS = Client::COMMANDS.inject({}) do |result, cmd|
233
+ result[cmd] = create_command_option_parser(cmd, command_parser_schemas[cmd])
234
+ result
235
+ end
236
+
237
+ def parse_command_options(command, args)
238
+ COMMAND_OPTION_PARSERS[command].call(args)
239
+ end
240
+ end
241
+ end
@@ -3,22 +3,23 @@ require "uri"
3
3
 
4
4
  module Tweetwine
5
5
  class Client
6
- Dependencies = Struct.new :io, :rest_client, :url_shortener
6
+ Dependencies = Struct.new :io, :http_client, :url_shortener
7
7
 
8
- attr_reader :num_statuses, :page_num
9
-
10
- COMMANDS = [:home, :mentions, :user, :update, :friends, :followers]
8
+ COMMANDS = [:followers, :friends, :home, :mentions, :search, :update, :user]
9
+ DEFAULT_COMMAND = :home
11
10
 
12
11
  DEFAULT_NUM_STATUSES = 20
13
12
  DEFAULT_PAGE_NUM = 1
14
13
  MAX_STATUS_LENGTH = 140
15
14
 
15
+ attr_reader :num_statuses, :page_num
16
+
16
17
  def initialize(dependencies, options)
17
18
  @io = dependencies.io
18
- @rest_client = dependencies.rest_client
19
19
  @username = options[:username].to_s
20
20
  raise ArgumentError, "No authentication data given" if @username.empty?
21
- @base_url = "https://#{@username}:#{options[:password]}@twitter.com/"
21
+ @http_client = dependencies.http_client
22
+ @http_resource = @http_client.as_resource("https://twitter.com", :user => @username, :password => options[:password])
22
23
  @num_statuses = Util.parse_int_gt(options[:num_statuses], DEFAULT_NUM_STATUSES, 1, "number of statuses_to_show")
23
24
  @page_num = Util.parse_int_gt(options[:page_num], DEFAULT_PAGE_NUM, 1, "page number")
24
25
  @url_shortener = if options[:shorten_urls] && options[:shorten_urls][:enable]
@@ -29,95 +30,131 @@ module Tweetwine
29
30
  @status_update_factory = StatusUpdateFactory.new(@io, @url_shortener)
30
31
  end
31
32
 
32
- def home
33
- show_statuses(get_response_as_json("statuses/friends_timeline", :num_statuses, :page))
33
+ def home(args = [], options = nil)
34
+ response = get_from_rest_api("statuses/home_timeline", :num_statuses, :page)
35
+ show_statuses_from_rest_api(*response)
34
36
  end
35
37
 
36
- def mentions
37
- show_statuses(get_response_as_json("statuses/mentions", :num_statuses, :page))
38
+ def mentions(args = [], options = nil)
39
+ response = get_from_rest_api("statuses/mentions", :num_statuses, :page)
40
+ show_statuses_from_rest_api(*response)
38
41
  end
39
42
 
40
- def user(user = @username)
41
- show_statuses(get_response_as_json("statuses/user_timeline/#{user}", :num_statuses, :page))
43
+ def user(args = [], options = nil)
44
+ user = if args.empty? then @username else args.first end
45
+ response = get_from_rest_api("statuses/user_timeline/#{user}", :num_statuses, :page)
46
+ show_statuses_from_rest_api(*response)
42
47
  end
43
48
 
44
- def update(new_status = nil)
45
- new_status = @status_update_factory.prepare(new_status)
49
+ def update(args = [], options = nil)
50
+ new_status = if args.empty? then nil else args.join(" ") end
51
+ new_status = @status_update_factory.create(new_status)
46
52
  completed = false
47
53
  unless new_status.empty?
48
54
  @io.show_status_preview(new_status)
49
55
  if @io.confirm("Really send?")
50
- status = JSON.parse(post("statuses/update.json", {:status => new_status.to_s}))
56
+ response = post_to_rest_api("statuses/update", :status => new_status.to_s)
51
57
  @io.info "Sent status update.\n\n"
52
- show_statuses([status])
58
+ show_statuses_from_rest_api(response)
53
59
  completed = true
54
60
  end
55
61
  end
56
62
  @io.info "Cancelled." unless completed
57
63
  end
58
64
 
59
- def friends
60
- show_users(get_response_as_json("statuses/friends/#{@username}", :page))
65
+ def friends(args = [], options = nil)
66
+ response = get_from_rest_api("statuses/friends/#{@username}")
67
+ show_users_from_rest_api(*response)
68
+ end
69
+
70
+ def followers(args = [], options = nil)
71
+ response = get_from_rest_api("statuses/followers/#{@username}")
72
+ show_users_from_rest_api(*response)
61
73
  end
62
74
 
63
- def followers
64
- show_users(get_response_as_json("statuses/followers/#{@username}", :page))
75
+ def search(args = [], options = nil)
76
+ raise ArgumentError, "No search word" if args.empty?
77
+ query = if options && options[:bin_op] == :or then args.join(" OR ") else args.join(" ") end
78
+ response = get_from_search_api(query, :num_statuses, :page)
79
+ show_statuses_from_search_api(*response["results"])
65
80
  end
66
81
 
67
82
  private
68
83
 
69
- def get_response_as_json(url_body, *query_opts)
70
- url = url_body + ".json?#{parse_query_options(query_opts)}"
71
- JSON.parse(get(url))
84
+ def get_from_rest_api(sub_url, *query_opts)
85
+ query_str = query_options_to_string(query_opts, :page => "page", :num_statuses => "count")
86
+ url_suffix = unless query_str.empty? then "?" << query_str else "" end
87
+ JSON.parse(@http_resource[sub_url + ".json" + url_suffix].get)
72
88
  end
73
89
 
74
- def parse_query_options(query_opts)
75
- str = []
90
+ def post_to_rest_api(sub_url, payload)
91
+ JSON.parse(@http_resource[sub_url + ".json"].post(payload))
92
+ end
93
+
94
+ def get_from_search_api(query, *query_opts)
95
+ query_str = "q=#{Util.percent_encode(query)}&" \
96
+ << query_options_to_string(query_opts, :page => "page", :num_statuses => "rpp")
97
+ JSON.parse(@http_client.get("http://search.twitter.com/search.json?#{query_str}"))
98
+ end
99
+
100
+ def query_options_to_string(query_opts, key_mappings)
101
+ pairs = []
76
102
  query_opts.each do |opt|
77
103
  case opt
78
104
  when :page
79
- str << "page=#{@page_num}"
105
+ pairs << "#{key_mappings[:page]}=#{@page_num}"
80
106
  when :num_statuses
81
- str << "count=#{@num_statuses}"
82
- # do nothing on else
107
+ pairs << "#{key_mappings[:num_statuses]}=#{@num_statuses}"
108
+ # else: ignore unknown query options
83
109
  end
84
110
  end
85
- str.join("&")
111
+ pairs.join("&")
86
112
  end
87
113
 
88
- def show_statuses(data)
89
- show_responses(data) { |entry| [entry["user"], entry] }
90
- end
91
-
92
- def show_users(data)
93
- show_responses(data) { |entry| [entry, entry["status"]] }
94
- end
95
-
96
- def show_responses(data)
97
- data.each do |entry|
98
- user_data, status_data = yield entry
99
- @io.show_record(parse_response(user_data, status_data))
100
- end
114
+ def show_statuses_from_rest_api(*responses)
115
+ show_records(
116
+ responses,
117
+ {
118
+ :from_user => ["user", "screen_name"],
119
+ :to_user => "in_reply_to_screen_name",
120
+ :created_at => "created_at",
121
+ :status => "text"
122
+ }
123
+ )
101
124
  end
102
125
 
103
- def parse_response(user_data, status_data)
104
- record = { :user => user_data["screen_name"] }
105
- if status_data
106
- record[:status] = {
107
- :created_at => status_data["created_at"],
108
- :in_reply_to => status_data["in_reply_to_screen_name"],
109
- :text => status_data["text"]
126
+ def show_users_from_rest_api(*responses)
127
+ show_records(
128
+ responses,
129
+ {
130
+ :from_user => "screen_name",
131
+ :to_user => ["status", "in_reply_to_screen_name"],
132
+ :created_at => ["status", "created_at"],
133
+ :status => ["status", "text"]
110
134
  }
111
- end
112
- record
135
+ )
113
136
  end
114
137
 
115
- def get(body_url)
116
- @rest_client.get @base_url + body_url
138
+ def show_statuses_from_search_api(*responses)
139
+ show_records(
140
+ responses,
141
+ {
142
+ :from_user => "from_user",
143
+ :to_user => "to_user",
144
+ :created_at => "created_at",
145
+ :status => "text"
146
+ }
147
+ )
117
148
  end
118
149
 
119
- def post(body_url, body)
120
- @rest_client.post @base_url + body_url, body
150
+ def show_records(twitter_records, paths)
151
+ twitter_records.each do |twitter_record|
152
+ internal_record = [ :from_user, :to_user, :created_at, :status ].inject({}) do |result, key|
153
+ result[key] = Util.find_hash_path(twitter_record, paths[key])
154
+ result
155
+ end
156
+ @io.show_record(internal_record)
157
+ end
121
158
  end
122
159
 
123
160
  class StatusUpdateFactory
@@ -126,7 +163,7 @@ module Tweetwine
126
163
  @url_shortener = url_shortener
127
164
  end
128
165
 
129
- def prepare(status)
166
+ def create(status)
130
167
  StatusUpdate.new(status, @io, @url_shortener).to_s
131
168
  end
132
169
  end
@@ -168,7 +205,7 @@ module Tweetwine
168
205
  url_pairs.reject { |pair| pair.last.nil? || pair.last.empty? }.each do |url_pair|
169
206
  status.gsub!(url_pair.first, url_pair.last)
170
207
  end
171
- rescue ClientError, LoadError => e
208
+ rescue HttpError, LoadError => e
172
209
  @io.warn "#{e}. Skipping URL shortening..."
173
210
  end
174
211
  end