twat 0.6.3 → 0.9.2

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.
Files changed (71) hide show
  1. data/.rvmrc +1 -1
  2. data/.travis.yml +6 -0
  3. data/Gemfile +1 -4
  4. data/Gemfile.lock +43 -23
  5. data/Gemfile.travis +12 -0
  6. data/Rakefile +9 -0
  7. data/TODO +4 -1
  8. data/lib/twat.rb +30 -26
  9. data/lib/twat/argparse.rb +36 -79
  10. data/lib/twat/config.rb +22 -34
  11. data/lib/twat/endpoint.rb +11 -5
  12. data/lib/twat/endpoints/base.rb +39 -0
  13. data/lib/twat/endpoints/identica.rb +5 -1
  14. data/lib/twat/endpoints/twitter.rb +5 -1
  15. data/lib/twat/exceptions.rb +93 -46
  16. data/lib/twat/follow_mixin.rb +134 -0
  17. data/lib/twat/options.rb +28 -12
  18. data/lib/twat/subcommand.rb +34 -0
  19. data/lib/twat/subcommands/add.rb +17 -0
  20. data/lib/twat/subcommands/base.rb +122 -0
  21. data/lib/twat/subcommands/config.rb +15 -0
  22. data/lib/twat/subcommands/delete.rb +24 -0
  23. data/lib/twat/subcommands/finger.rb +23 -0
  24. data/lib/twat/subcommands/follow.rb +18 -0
  25. data/lib/twat/subcommands/follow_tag.rb +19 -0
  26. data/lib/twat/subcommands/list.rb +17 -0
  27. data/lib/twat/subcommands/mentions.rb +20 -0
  28. data/lib/twat/subcommands/set.rb +19 -0
  29. data/lib/twat/subcommands/track.rb +33 -0
  30. data/lib/twat/subcommands/update.rb +21 -0
  31. data/lib/twat/subcommands/update_config.rb +16 -0
  32. data/lib/twat/subcommands/version.rb +14 -0
  33. data/lib/twat/twatopt.rb +0 -0
  34. data/lib/twat/tweetstack.rb +38 -0
  35. data/lib/twat/version.rb +7 -0
  36. data/man/twat.1 +8 -5
  37. data/spec/argparse_spec.rb +48 -0
  38. data/spec/helpers/environment.rb +47 -0
  39. data/spec/helpers/fileutils.rb +18 -0
  40. data/spec/helpers/fixtures/core.rb +30 -0
  41. data/spec/helpers/fixtures/migrations.rb +13 -0
  42. data/spec/helpers/oauth.rb +15 -0
  43. data/spec/spec_helper.rb +13 -0
  44. data/spec/subcommands/add_spec.rb +61 -0
  45. data/spec/subcommands/config_spec.rb +13 -0
  46. data/spec/subcommands/delete_spec.rb +12 -0
  47. data/spec/subcommands/finger_spec.rb +41 -0
  48. data/spec/subcommands/follow_spec.rb +12 -0
  49. data/spec/subcommands/follow_tag_spec.rb +52 -0
  50. data/spec/subcommands/list_spec.rb +8 -0
  51. data/spec/subcommands/mentions_spec.rb +24 -0
  52. data/spec/subcommands/set_spec.rb +126 -0
  53. data/spec/subcommands/track_spec.rb +23 -0
  54. data/spec/subcommands/update_config_spec.rb +23 -0
  55. data/spec/subcommands/update_spec.rb +12 -0
  56. data/spec/subcommands/version_spec.rb +12 -0
  57. data/spec/twat_cli_spec.rb +102 -0
  58. data/spec/twat_spec.rb +12 -0
  59. data/twat.gemspec +6 -2
  60. metadata +135 -22
  61. data/lib/twat/actions.rb +0 -85
  62. data/lib/twat/actions/add.rb +0 -36
  63. data/lib/twat/actions/delete.rb +0 -14
  64. data/lib/twat/actions/follow.rb +0 -148
  65. data/lib/twat/actions/follow_user.rb +0 -11
  66. data/lib/twat/actions/setoption.rb +0 -14
  67. data/lib/twat/actions/show.rb +0 -12
  68. data/lib/twat/actions/tweet.rb +0 -14
  69. data/lib/twat/actions/updateconfig.rb +0 -9
  70. data/lib/twat/actions/user_feed.rb +0 -17
  71. data/lib/twat/actions/version.rb +0 -9
@@ -1,16 +1,22 @@
1
1
  module Twat
2
2
 
3
- %w[twitter identica].each do |filename|
3
+ ENDPOINTS = ["identi.ca", "twitter"]
4
+
5
+ %w[base twitter identica].each do |filename|
4
6
  require File.join(File.expand_path("../endpoints", __FILE__), filename)
5
7
  end
6
8
 
7
9
  # Proxies to the actual endpoint classes
8
10
  class Endpoint
9
11
  def self.new(endpoint)
10
- @implementation = {
11
- :twitter => Endpoints::Twitter,
12
- :"identi.ca" => Endpoints::Identica
13
- }[endpoint].new
12
+ begin
13
+ @implementation = {
14
+ :twitter => Endpoints::Twitter,
15
+ :"identi.ca" => Endpoints::Identica
16
+ }[endpoint].new
17
+ rescue NoMethodError
18
+ raise Exceptions::NoSuchEndpoint
19
+ end
14
20
  end
15
21
  end
16
22
  end
@@ -0,0 +1,39 @@
1
+ module Twat::Endpoints
2
+ class Base
3
+
4
+ @@endpoint_set=nil
5
+
6
+ def authorize_account(name)
7
+ oauth_conf = oauth_options.merge ({ :site => url })
8
+
9
+ oauth = OAuth::Consumer.new(consumer_info[:consumer_key], consumer_info[:consumer_secret], oauth_conf)
10
+ token_request = oauth.get_request_token()
11
+ puts "Please authenticate the application at #{token_request.authorize_url}, then enter pin"
12
+ pin = STDIN.gets.chomp
13
+ begin
14
+ access_token = token_request.get_access_token(oauth_verifier: pin)
15
+ account_settings = {
16
+ oauth_token: access_token.token,
17
+ oauth_token_secret: access_token.secret,
18
+ endpoint: endpoint_name
19
+ }
20
+ config.accounts[name.to_sym] = account_settings
21
+ config.save!
22
+ rescue OAuth::Unauthorized
23
+ puts "Couldn't authenticate you, did you enter the pin correctly?"
24
+ end
25
+ end
26
+
27
+ def config
28
+ return @config if @config
29
+ @config = ::Twat::Config.new
30
+ @config.create! unless @config.exists?
31
+ return @config
32
+ end
33
+
34
+ def endpoint_name
35
+ raise "Endpoint not set! Naughty programmer"
36
+ end
37
+
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Twat::Endpoints
2
- class Identica
2
+ class Identica < Base
3
3
 
4
4
  def initialize
5
5
  unless @@endpoint_set
@@ -38,5 +38,9 @@ module Twat::Endpoints
38
38
  }
39
39
  end
40
40
 
41
+ def endpoint_name
42
+ :"identi.ca"
43
+ end
44
+
41
45
  end
42
46
  end
@@ -1,5 +1,5 @@
1
1
  module Twat::Endpoints
2
- class Twitter
2
+ class Twitter < Base
3
3
 
4
4
  def url
5
5
  "https://api.twitter.com/"
@@ -16,6 +16,10 @@ module Twat::Endpoints
16
16
  {}
17
17
  end
18
18
 
19
+ def endpoint_name
20
+ :twitter
21
+ end
22
+
19
23
  end
20
24
  end
21
25
 
@@ -1,53 +1,100 @@
1
1
  module Twat
2
- class NoSuchAccount < Exception; end
3
- class NoDefaultAccount < Exception; end
4
- class NoSuchCommand < Exception; end
5
- class NoConfigFile < Exception; end
6
- class RequiresOptVal < Exception; end
7
- class Usage < Exception; end
8
- class InvalidCredentials < Exception; end
9
- class ConfigVersionIncorrect < Exception; end
10
- class InvalidSetOpt < Exception; end
11
- class InvalidBool < Exception; end
12
- class InvalidInt < Exception; end
13
- class TweetTooLong < Exception; end
2
+ module Exceptions
14
3
 
15
- module HandledExceptions
16
- def with_handled_exceptions(opts)
4
+ class TwatException < Exception; end
5
+
6
+ class Usage < TwatException; end
7
+
8
+ class AlreadyConfigured < TwatException
9
+ def msg
10
+ "Already configured, use add to add more accounts"
11
+ end
12
+ end
13
+
14
+ class ArgumentRequired < TwatException
15
+ def msg
16
+ "This command requires an argument"
17
+ end
18
+ end
19
+ class NoSuchAccount < TwatException
20
+ def msg
21
+ "No such account"
22
+ end
23
+ end
24
+ class NoDefaultAccount < TwatException
25
+ def msg
26
+ "No default account configured (try twat set default [account])."
27
+ end
28
+ end
29
+ class NoSuchCommand < TwatException
30
+ def msg
31
+ "No such command"
32
+ end
33
+ end
34
+ class NoSuchEndpoint < TwatException
35
+ def msg
36
+ "Available endpoints are #{::Twat::ENDPOINTS.join(", ")}"
37
+ end
38
+ end
39
+ class NoConfigFile < TwatException
40
+ def msg
41
+ "No config file, create one with twat -a [user|nick]"
42
+ end
43
+ end
44
+ class RequiresOptVal < TwatException
45
+ def msg
46
+ "--set must take an option=value pair as arguments"
47
+ end
48
+ end
49
+ class InvalidCredentials < TwatException
50
+ def msg
51
+ ["Invalid credentials, try reauthenticating with",
52
+ "twat -a ACCOUNT"]
53
+ end
54
+ end
55
+ class ConfigVersionIncorrect < TwatException
56
+ def msg
57
+ "Your config file is out of date. Run with --update-config to rememdy"
58
+ end
59
+ end
60
+ class InvalidSetOpt < TwatException
61
+ def msg
62
+ "There is no such configurable option"
63
+ end
64
+ end
65
+ class InvalidBool < TwatException
66
+ def msg
67
+ "Invalid value, valid values are #{Options::BOOL_VALS.join("|")}"
68
+ end
69
+ end
70
+ class InvalidInt < TwatException
71
+ def msg
72
+ "Invalid value, must be an integer"
73
+ end
74
+ end
75
+ class TweetTooLong < TwatException
76
+ def msg
77
+ "Twitter enforces a maximum status length of 140 characters"
78
+ end
79
+ end
80
+ class NoSuchTweet < TwatException
81
+ def msg
82
+ "Specified tweet was not found"
83
+ end
84
+ end
85
+ class NoInternetConnection < TwatException
86
+ def msg
87
+ "Couldn't connect to the endpoint"
88
+ end
89
+ end
90
+
91
+ def with_handled_exceptions(opts=nil)
17
92
  begin
18
- # FIXME
19
- yield opts
93
+ yield
20
94
  rescue Usage
21
- opts.usage
22
- rescue NoSuchAccount
23
- puts "No such account"
24
- opts.usage
25
- rescue NoDefaultAccount
26
- puts "No default account configured."
27
- rescue NoSuchCommand
28
- puts "No such command"
29
- opts.usage
30
- rescue NoConfigFile
31
- puts "No config file, create one with twat -a [user|nick]"
32
- opts.usage
33
- rescue InvalidSetOpt
34
- puts "There is no such configurable option"
35
- opts.usage
36
- rescue RequiresOptVal
37
- puts "--set must take an option=value pair as arguments"
38
- rescue InvalidCredentials
39
- puts "Invalid credentials, try reauthenticating with"
40
- puts "twat -a #{opts[:account]}"
41
- rescue ConfigVersionIncorrect
42
- puts "Your config file is out of date. Run with --update-config to rememdy"
43
- rescue InvalidBool
44
- puts "Invalid value, valid values are #{Options::BOOL_VALID.join("|")}"
45
- rescue InvalidInt
46
- puts "Invalid value, must be an integer"
47
- rescue Errno::ECONNRESET
48
- puts "Connection was reset by third party."
49
- rescue TweetTooLong
50
- puts "Twitter enforces a maximum status length of 140 characters"
95
+ opts.usage if opts
96
+ rescue TwatException => e
97
+ puts e.msg
51
98
  end
52
99
  end
53
100
  end
@@ -0,0 +1,134 @@
1
+ module FollowMixin
2
+ POLLING_RESOLUTION = 20 # Readline scan time in hz
3
+ # Shim to bail out if we're in test
4
+ def untested
5
+ true
6
+ end
7
+
8
+ class PrintNothing < Exception; end
9
+
10
+ def run
11
+ auth!
12
+ enable_readline! if untested
13
+ @tweetstack = ::Twat::TweetStack.new
14
+
15
+ @failcount = 0
16
+
17
+ # Get 5 tweets
18
+ tweets = retrieve_tweets(:count => 5)
19
+ while untested do
20
+ begin
21
+ last_id = process_followed(tweets) if tweets.any?
22
+ (config.polling_interval * POLLING_RESOLUTION).times do
23
+ reader.tick
24
+ lines = 0
25
+ reader.each_line do |i|
26
+ lines += 1
27
+ handle_input(i)
28
+ end
29
+ sleep 1.0/POLLING_RESOLUTION if lines == 0
30
+ end
31
+ tweets = retrieve_tweets(:since_id => last_id)
32
+ @failcount = 0
33
+ rescue Interrupt
34
+ break
35
+ rescue Twitter::Error::ServiceUnavailable
36
+ break unless fail_or_bail
37
+ sleeptime = 60 * (@failcount + 1)
38
+ reader.puts_above "#{"(__-){".red}: the fail whale has been rolled out, sleeping for #{sleeptime} seconds"
39
+ reader.wait sleeptime
40
+ rescue Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError
41
+ break unless fail_or_bail
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def retrieve_tweets(opts)
49
+ new_tweets(opts)
50
+ rescue SocketError
51
+ raise ::Twat::Exceptions::NoInternetConnection
52
+ end
53
+
54
+ def handle_input(inp)
55
+ ret = process_input(inp)
56
+ return if ret.nil?
57
+
58
+ if ret === true
59
+ reader.puts_above(inp.green)
60
+ elsif ret === false
61
+ reader.puts_above(inp.red)
62
+ else
63
+ reader.puts_above(ret)
64
+ end
65
+ rescue PrintNothing
66
+ nil
67
+ end
68
+
69
+ def process_input(inp)
70
+ case inp
71
+ when /^[rR][tT] ([0-9]{1,2})/
72
+ begin
73
+ retweet($1.to_i)
74
+ return true
75
+ rescue ::Twat::Exceptions::NoSuchTweet
76
+ return "#{inp.red} #{":: No such tweet".bold.red}"
77
+ end
78
+ when /^follow (.*)/
79
+ Twitter.follow($1)
80
+ return true
81
+ when /^reply ([0-9]{1,2}) (.*)$/
82
+ # TODO This should be a method of the tweetstack
83
+ in_reply_to = get_tweet($1.to_i)
84
+ msg = "@#{in_reply_to.as_user} #{$2}"
85
+
86
+ if msg.length > 140
87
+ return "#{msg} #{":: Too long".bold.red}"
88
+ else
89
+ Twitter.update(msg, in_reply_to_status_id: in_reply_to.id)
90
+ return msg.green
91
+ end
92
+ else
93
+ # Assume they want to tweet something
94
+ if inp.length > 140
95
+ return "#{inp.red} #{":: Too long".bold.red}"
96
+ else
97
+ Twitter.update(inp)
98
+ return true
99
+ end
100
+ end
101
+ end
102
+
103
+ def get_tweet(idx)
104
+ raise ::Twat::Exceptions::NoSuchTweet unless @tweetstack.include?(idx)
105
+ @tweetstack[idx]
106
+ end
107
+
108
+ # Wrapper methods from the tweetstack implementation
109
+ def retweet(idx)
110
+ Twitter.retweet(get_tweet(idx).id)
111
+ end
112
+
113
+ def fail_or_bail
114
+ if @failcount > 2
115
+ puts "3 consecutive failures, giving up"
116
+ else
117
+ @failcount += 1
118
+ return true
119
+ end
120
+ end
121
+
122
+ def process_followed(tweets)
123
+ last_id = nil
124
+ tweets.reverse.each do |tweet|
125
+ id = @tweetstack << tweet
126
+ beep if config.beep? && tweet.text.mentions?(config.account_name)
127
+ reader.puts_above format(tweet, @tweetstack.last)
128
+ last_id = tweet.id
129
+ end
130
+
131
+ return last_id
132
+ end
133
+
134
+ end
@@ -1,5 +1,8 @@
1
1
  module Twat
2
2
  class Options
3
+
4
+ include ::Twat::Exceptions
5
+
3
6
  BOOL_TRUE=["yes", "true", "1", "on"]
4
7
  BOOL_FALSE=["no", "false", "0", "off"]
5
8
  BOOL_VALS = BOOL_TRUE + BOOL_FALSE
@@ -23,41 +26,54 @@ module Twat
23
26
  !!/^[0-9]+$/.match(val)
24
27
  end
25
28
 
29
+ def self.to_bool(value)
30
+ case
31
+ when bool_true?(value)
32
+ return true
33
+ when bool_false?(value)
34
+ return false
35
+ else
36
+ raise InvalidBool
37
+ end
38
+ end
39
+
26
40
  # A set of wrappers around the global config object to set given attributes
27
41
  # Catching failures is convenient because of the method_missing? hook
28
42
  def method_missing(sym, *args, &block)
29
- puts sym
30
- raise InvalidSetOpt
43
+ raise InvalidSetOpt, "Options doesn't know about #{sym}"
31
44
  end
32
45
 
33
46
  def config
34
- @config ||= Config.new
47
+ @config ||= Config.new.tap do |n|
48
+ raise NoConfigFile unless n.exists?
49
+ end
35
50
  end
36
51
 
37
52
  # This is deliberately not abstracted (it could be easily accessed from
38
53
  # withing the method_missing method, but that will just lead to nastiness
39
54
  # later when I implement colors, for example.
40
55
  def default=(value)
41
- val = value.to_sym
42
- unless config.accounts.include?(val)
56
+ value = value.to_sym
57
+ unless config.accounts.include?(value)
43
58
  raise NoSuchAccount
44
59
  end
45
60
 
46
- config[:default] = val
61
+ config[:default] = value
47
62
  config.save!
48
63
  end
49
64
 
50
65
  def colors=(value)
51
- val = value.to_sym
52
- raise InvalidBool unless Options.bool_valid?(val)
53
- config[:colors] = val
66
+ config[:colors] = Options.to_bool(value)
54
67
  config.save!
55
68
  end
56
69
 
57
70
  def beep=(value)
58
- val = value.to_sym
59
- raise InvalidBool unless Options.bool_valid?(val)
60
- config[:beep] = val
71
+ config[:beep] = Options.to_bool(value)
72
+ config.save!
73
+ end
74
+
75
+ def show_mentions=(value)
76
+ config[:show_mentions] = Options.to_bool(value)
61
77
  config.save!
62
78
  end
63
79