twilio_contactable 0.7.1

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.
data/lib/controller.rb ADDED
@@ -0,0 +1,78 @@
1
+ module TwilioContactable
2
+ module Controller
3
+
4
+ def self.included(controller)
5
+ controller.instance_eval do
6
+ # the developer should specify which model(s) will be sought
7
+ # when the app receives incoming requests
8
+ protected
9
+ def twilio_contactable(*klasses)
10
+ @@contactable_classes = klasses
11
+ klasses.each do |klass|
12
+ klass.twilio_contactable.controller = self.class.name.underscore.chomp('_controller')
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ def receive_sms_message
19
+ unless defined?(@@contactable_classes) && !@@contactable_classes.blank?
20
+ raise RuntimeError, "Please define which model(s) this controller should receive for via the 'twilio_contactable' method"
21
+ end
22
+
23
+ request = params[:request]
24
+ render :text => 'unknown format', :status => 500 and return unless request
25
+ case request['type']
26
+ when 'BLOCK'
27
+ @contactable = find_contactable_by_phone_number(request[:block][:recipient][:id])
28
+ @contactable._TC_sms_blocked = true
29
+ @contactable.save!
30
+ when 'MESSAGE'
31
+ @contactable = find_contactable_by_phone_number(request[:message][:sender][:id])
32
+ if @contactable.respond_to?(:receive_sms)
33
+ @contactable.receive_sms(request[:message][:text])
34
+ else
35
+ warn "An SMS message was received but #{@contactable.class.name.inspect} doesn't have a receive_sms method!"
36
+ end
37
+ end
38
+ render :text => 'OK', :status => 200
39
+ end
40
+
41
+ def start_voice_confirmation
42
+ render :xml => (Twilio::Response.new.tap do |response|
43
+ response.addGather(
44
+ :action => url_for(
45
+ :action => 'receive_voice_confirmation',
46
+ :contactable_type => params[:contactable_type],
47
+ :contactable_id => params[:contactable_id]
48
+ )
49
+ ).tap do |gather|
50
+ gather.addSay "Please type the numbers that appear on your screen, followed by the pound sign"
51
+ end
52
+ end.respond)
53
+ end
54
+
55
+ def receive_voice_confirmation
56
+ @contactable = params[:contactable_type].constantize.find(params[:contactable_id])
57
+ @contactable.voice_confirm_with(params['Digits'])
58
+ end
59
+
60
+ protected
61
+
62
+ def find_contactable_by_phone_number(number)
63
+ [number, number.sub(/^\+/,''), number.sub(/^\+1/,'')].uniq.compact.each do |possible_phone_number|
64
+ @@contactable_classes.each do |klass|
65
+ found = klass.find(
66
+ :first,
67
+ :conditions =>
68
+ { klass.twilio_contactable.formatted_phone_number_column => possible_phone_number }
69
+ )
70
+ return found if found
71
+ end
72
+ end
73
+ nil
74
+ # rescue => error
75
+ # render :text => error.inspect
76
+ end
77
+ end
78
+ end
data/lib/gateway.rb ADDED
@@ -0,0 +1,105 @@
1
+ module TwilioContactable
2
+ module Gateway
3
+ class WebsiteAddressNotConfiguredError < ArgumentError
4
+ def initialize
5
+ super "Please define your `website_address` config parameter "+
6
+ "in the TwilioContactable initializer. "+
7
+ "It should be in the format of: \"http://example.com/\""
8
+
9
+ end
10
+ end
11
+ class ControllerNotConfiguredError < ArgumentError
12
+ def initialize(record)
13
+ super "You're initiating a voice call for a "+
14
+ record.class.name.inspect +
15
+ " but you have not specified this class "+
16
+ "as a possible recipient in a controller."
17
+ end
18
+ end
19
+ class Response
20
+ def initialize(*args)
21
+ @options = args.last
22
+ end
23
+
24
+ def success?
25
+ :success == @options[:status]
26
+ end
27
+ end
28
+ Success = Response.new(:status => :success)
29
+ Error = Response.new(:status => :error)
30
+
31
+ API_VERSION = '2010-04-01'
32
+
33
+ class << self
34
+
35
+ def initiate_voice_call(record, to, from = nil)
36
+
37
+ unless TwilioContactable.configuration.website_address
38
+ raise WebsiteAddressNotConfiguredError.new
39
+ end
40
+ unless controller = record.class.twilio_contactable.controller
41
+ raise ControllerNotConfiguredError.new(record)
42
+ end
43
+
44
+ url = TwilioContactable.configuration.website_address.chomp('/')
45
+ url = "#{url}/#{record.class.twilio_contactable.controller}"
46
+ url = "#{url}/start_voice_confirmation?contactable_type=#{record.class}&contactable_id=#{record.id}"
47
+
48
+ deliver :voice,
49
+ 'To' => to,
50
+ 'From' => from,
51
+ 'Url' => url
52
+ end
53
+
54
+ def deliver_sms(message, to, from = nil)
55
+ deliver :sms,
56
+ 'Message' => message,
57
+ 'To' => to,
58
+ 'From' => from
59
+ end
60
+
61
+ def account
62
+ @account ||= begin
63
+ if TwilioContactable.configuration.client_id.blank? ||
64
+ TwilioContactable.configuration.client_key.blank?
65
+ raise "Add your Twilio account id (as client_id) and token (as client_key) to the TwilioContactable.configure block"
66
+ end
67
+ Twilio::RestAccount.new(
68
+ TwilioContactable.configuration.client_id,
69
+ TwilioContactable.configuration.client_key
70
+ )
71
+ end
72
+ end
73
+
74
+ protected
75
+
76
+ def deliver(type, data = {})
77
+
78
+ data['From'] = TwilioContactable.configuration.default_from_phone_number if data['From'].blank?
79
+ raise "'From' number required for Twilio" unless data['From']
80
+
81
+ service = case type
82
+ when :sms
83
+ 'SMS/Messages'
84
+ when :voice
85
+ 'Calls'
86
+ end
87
+
88
+ response = post service, data
89
+
90
+ Net::HTTPCreated == response.code_type ?
91
+ TwilioContactable::Gateway::Success :
92
+ TwilioContactable::Gateway::Error
93
+ end
94
+
95
+ def post(service, data = {})
96
+ url = "/#{API_VERSION}/Accounts/#{TwilioContactable.configuration.client_id}/#{service}"
97
+ puts "putting data: #{data.inspect}"
98
+ puts "to url: #{url}"
99
+ account.request url,
100
+ "POST",
101
+ data
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,40 @@
1
+ module TwilioContactable
2
+ class << self
3
+ def numerize(numberish)
4
+ numberish.to_s.scan(/\d+/).join
5
+ end
6
+
7
+ def internationalize(given_number)
8
+ number = numerize(given_number)
9
+ case number.size
10
+ when 10
11
+ "+1#{number}"
12
+ when 11
13
+ "+#{number}"
14
+ when 12
15
+ number =~ /\+\d(11)/ ? number : nil
16
+ else
17
+ nil
18
+ end
19
+ end
20
+
21
+ def confirmation_message(confirmation_code)
22
+ "Code: #{confirmation_code} Enter code on web to verify phone. Msg&data rates may apply. Freq set by u. T&C & support on web site. Txt HELP for help"
23
+ end
24
+
25
+ def generate_confirmation_code
26
+ chars = (0..9).to_a + ('A'..'Z').to_a
27
+ (0...6).collect { chars[Kernel.rand(chars.length)] }.join
28
+ end
29
+ end
30
+ end
31
+
32
+ gem 'twiliolib'
33
+ require 'twiliolib'
34
+
35
+ require File.expand_path(File.join(File.dirname(__FILE__), 'configuration'))
36
+ require File.expand_path(File.join(File.dirname(__FILE__), 'gateway'))
37
+ require File.expand_path(File.join(File.dirname(__FILE__), 'contactable'))
38
+ require File.expand_path(File.join(File.dirname(__FILE__), 'controller'))
39
+
40
+ ActiveRecord::Base.send :include, TwilioContactable::Contactable
data/test/.gitignore ADDED
@@ -0,0 +1 @@
1
+ debug.log
data/test/database.yml ADDED
@@ -0,0 +1,18 @@
1
+ sqlite:
2
+ :adapter: sqlite
3
+ :database: plugin.sqlite.db
4
+ sqlite3:
5
+ :adapter: sqlite3
6
+ :database: ":memory:"
7
+ postgresql:
8
+ :adapter: postgresql
9
+ :username: postgres
10
+ :password: postgres
11
+ :database: plugin_test
12
+ :min_messages: ERROR
13
+ mysql:
14
+ :adapter: mysql
15
+ :host: localhost
16
+ :username: rails
17
+ :password:
18
+ :database: plugin_test
@@ -0,0 +1,63 @@
1
+ require 'rubygems'
2
+ gem 'test-unit'
3
+ require 'test/unit'
4
+ require 'mocha'
5
+ gem 'activesupport', '< 3.0.0'
6
+ require 'active_support'
7
+ require 'active_support/test_case'
8
+ gem 'activerecord', '< 3.0.0'
9
+ require 'active_record'
10
+ gem 'actionpack', '< 3.0.0'
11
+ require 'actionpack'
12
+ require 'action_controller'
13
+ require 'shoulda'
14
+ require 'shoulda/action_controller'
15
+ require 'shoulda/action_controller/macros'
16
+ require 'shoulda/action_controller/matchers'
17
+ require File.join(File.dirname(__FILE__), "..", 'lib', 'twilio_contactable')
18
+
19
+ # default test configuration
20
+ TwilioContactable.configure do |config|
21
+ config.client_id = '1'
22
+ config.client_key = 'ABCDEF'
23
+ config.website_address = 'http://example.com'
24
+ config.default_from_phone_number = '1 (206) 867-5309'
25
+ end
26
+
27
+ TwilioContactable.mode = :test
28
+ config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
29
+ require 'logger'
30
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
31
+ ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite3'])
32
+
33
+ ActiveRecord::Schema.define(:version => 1) do
34
+ create_table :users do |t|
35
+ t.column :phone_number, :string
36
+ t.column :formatted_phone_number, :string
37
+ t.column :sms_blocked, :boolean
38
+ t.column :sms_confirmation_code, :string
39
+ t.column :sms_confirmation_attempted, :datetime
40
+ t.column :sms_confirmed_phone_number, :string
41
+ t.column :voice_blocked, :boolean
42
+ t.column :voice_confirmation_code, :string
43
+ t.column :voice_confirmation_attempted, :datetime
44
+ t.column :voice_confirmed_phone_number, :string
45
+
46
+ t.column :custom_column, :string
47
+
48
+ t.column :type, :string
49
+ end
50
+ end
51
+
52
+ class User < ActiveRecord::Base
53
+ include TwilioContactable::Contactable
54
+ end
55
+
56
+ # kill all network access
57
+ module TwilioContactable
58
+ module Gateway
59
+ def post
60
+ raise "You forgot to stub out your net requests!"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,308 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper'))
2
+
3
+ class TwilioContactableContactableTest < ActiveSupport::TestCase
4
+
5
+ Success = TwilioContactable::Gateway::Response.new(:status => :success)
6
+ Error = TwilioContactable::Gateway::Response.new(:status => :error)
7
+
8
+ context "configuration" do
9
+ TwilioContactable::Contactable::Attributes.each do |attr|
10
+ context "attribute: #{attr}" do
11
+ should "begin with appropriate default for #{attr}_column and allow overwriting" do
12
+ klass = Class.new
13
+ klass.send :include, TwilioContactable::Contactable
14
+ assert_equal attr, klass.twilio_contactable.send("#{attr}_column")
15
+ klass.twilio_contactable do |config|
16
+ config.send("#{attr}_column=", :custom_column)
17
+ end
18
+ assert_equal :custom_column, klass.twilio_contactable.send("#{attr}_column")
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ context "contactable instance" do
25
+ setup {
26
+ User.delete_all
27
+ @user = User.create! :phone_number => '(555) 123-4567'
28
+ }
29
+
30
+ should "normalize phone number" do
31
+ assert_equal '+15551234567', @user.formatted_phone_number
32
+ end
33
+ context "when phone number is blank" do
34
+ setup { @user._TC_phone_number = nil}
35
+ context "confirming phone number" do
36
+ setup { @user.send_sms_confirmation! }
37
+ should_not_change "any attributes" do
38
+ @user.attributes.inspect
39
+ end
40
+ end
41
+ context "sending message" do
42
+ setup {
43
+ TwilioContactable::Gateway.stubs(:perform).returns(Success)
44
+ @worked = @user.send_sms!('message')
45
+ }
46
+ should "not work" do assert !@worked end
47
+ should_not_change "any attributes" do
48
+ @user.attributes.inspect
49
+ end
50
+ end
51
+ end
52
+
53
+ context "when phone number exists" do
54
+ setup { @user._TC_phone_number = "206-555-5555"}
55
+ context "confirming phone number" do
56
+ context "via sms" do
57
+ setup {
58
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success)
59
+ @worked = @user.send_sms_confirmation!
60
+ }
61
+ should "work" do assert @worked end
62
+ should "save confirmation number in proper attribute" do
63
+ assert @user._TC_sms_confirmation_code
64
+ end
65
+ should "set confirmation attempted time" do
66
+ assert @user._TC_sms_confirmation_attempted > 3.minutes.ago
67
+ end
68
+ should_change "stored code" do
69
+ @user._TC_sms_confirmation_code
70
+ end
71
+ should "not have number confirmed yet" do
72
+ assert !@user.sms_confirmed?
73
+ end
74
+ context "calling sms_confirm_with(right_code)" do
75
+ setup { @user.sms_confirm_with(@user._TC_sms_confirmation_code) }
76
+ should "work" do
77
+ assert @worked
78
+ end
79
+ should "save the phone number into the confirmed attribute" do
80
+ assert_equal @user._TC_phone_number,
81
+ @user._TC_sms_confirmed_phone_number,
82
+ @user.reload.inspect
83
+ end
84
+ should_change "confirmed phone number attribute" do
85
+ @user._TC_sms_confirmed_phone_number
86
+ end
87
+ context "and then attempting to confirm another number" do
88
+ setup {
89
+ @user._TC_phone_number = "206-555-8990"
90
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success).once
91
+ @user.send_sms_confirmation!
92
+ }
93
+ should "eliminate the previous confirmed phone number" do
94
+ assert @user._TC_sms_confirmed_phone_number.blank?
95
+ end
96
+ should "un-confirm the record" do
97
+ assert !@user.sms_confirmed?
98
+ end
99
+ end
100
+ end
101
+ context "calling sms_confirm_with(right code, wrong case)" do
102
+ setup {
103
+ @downcased_code = @user._TC_sms_confirmation_code.downcase
104
+ @worked = @user.sms_confirm_with(@downcased_code)
105
+ }
106
+ should "have good test data" do
107
+ assert_not_equal @downcased_code,
108
+ @user._TC_sms_confirmation_code
109
+ end
110
+ should "work" do
111
+ assert @worked
112
+ end
113
+ should "save the phone number into the confirmed attribute" do
114
+ assert_equal @user._TC_sms_confirmed_phone_number,
115
+ @user._TC_phone_number
116
+ end
117
+ end
118
+ context "calling sms_confirm_with(wrong_code)" do
119
+ setup { @worked = @user.sms_confirm_with('wrong_code') }
120
+ should "not work" do
121
+ assert !@worked
122
+ end
123
+ should "not save the phone number into the confirmed attribute" do
124
+ assert_not_equal @user._TC_sms_confirmed_phone_number,
125
+ @user._TC_phone_number
126
+ end
127
+ should_not_change "confirmed phone number attribute" do
128
+ @user.reload._TC_sms_confirmed_phone_number
129
+ end
130
+ end
131
+ end
132
+ context "confirming phone number with a custom short code" do
133
+ context "with expectations" do
134
+ setup {
135
+ message = "long message blah blah MYCODE blah"
136
+ TwilioContactable.expects(:generate_confirmation_code).returns('MYCODE').once
137
+ TwilioContactable.expects(:confirmation_message).returns(message).once
138
+ TwilioContactable::Gateway.expects(:deliver).with(message, @user._TC_phone_number).once
139
+ @user.send_sms_confirmation!
140
+ }
141
+ end
142
+ context "(normal)" do
143
+ setup {
144
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success)
145
+ @worked = @user.send_sms_confirmation!
146
+ }
147
+ should "work" do
148
+ assert @worked
149
+ end
150
+ end
151
+ end
152
+ context "confirming phone number when the confirmation fails for some reason" do
153
+ setup {
154
+ TwilioContactable::Gateway.stubs(:deliver).returns(Error)
155
+ @worked = @user.send_sms_confirmation!
156
+ }
157
+ should "not work" do assert !@worked end
158
+ should "not save confirmation number" do
159
+ assert @user._TC_sms_confirmation_code.blank?
160
+ end
161
+ should_not_change "stored code" do
162
+ @user._TC_sms_confirmation_code
163
+ end
164
+ end
165
+ context "via voice" do
166
+ setup {
167
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success)
168
+ # To start any voice call we'll need to first initialize
169
+ # this model in a controller
170
+ class TestController < ActionController::Base
171
+ include TwilioContactable::Controller
172
+ twilio_contactable User
173
+ self
174
+ end
175
+ @worked = @user.send_voice_confirmation!
176
+ }
177
+ should "work" do assert @worked end
178
+ should "save confirmation number in proper attribute" do
179
+ assert @user._TC_voice_confirmation_code
180
+ end
181
+ should "set confirmation attempted time" do
182
+ assert @user._TC_voice_confirmation_attempted > 3.minutes.ago
183
+ end
184
+ should_change "stored code" do
185
+ @user._TC_voice_confirmation_code
186
+ end
187
+ should "not have number confirmed yet" do
188
+ assert !@user.voice_confirmed?
189
+ end
190
+ context "calling voice_confirm_with(right_code)" do
191
+ setup { @user.voice_confirm_with(@user._TC_voice_confirmation_code) }
192
+ should "work" do
193
+ assert @worked
194
+ end
195
+ should "save the phone number into the confirmed attribute" do
196
+ assert_equal @user._TC_phone_number,
197
+ @user._TC_voice_confirmed_phone_number,
198
+ @user.reload.inspect
199
+ end
200
+ should_change "confirmed phone number attribute" do
201
+ @user._TC_voice_confirmed_phone_number
202
+ end
203
+ context "and then attempting to confirm another number" do
204
+ setup {
205
+ @user._TC_phone_number = "206-555-8990"
206
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success).once
207
+ @user.send_voice_confirmation!
208
+ }
209
+ should "eliminate the previous confirmed phone number" do
210
+ assert @user._TC_voice_confirmed_phone_number.blank?
211
+ end
212
+ should "un-confirm the record" do
213
+ assert !@user.voice_confirmed?
214
+ end
215
+ end
216
+ end
217
+ context "calling voice_confirm_with(right code, wrong case)" do
218
+ setup {
219
+ @downcased_code = @user._TC_voice_confirmation_code.downcase
220
+ @worked = @user.voice_confirm_with(@downcased_code)
221
+ }
222
+ should "have good test data" do
223
+ assert_not_equal @downcased_code,
224
+ @user._TC_voice_confirmation_code
225
+ end
226
+ should "work" do
227
+ assert @worked
228
+ end
229
+ should "save the phone number into the confirmed attribute" do
230
+ assert_equal @user._TC_voice_confirmed_phone_number,
231
+ @user._TC_phone_number
232
+ end
233
+ end
234
+ context "calling voice_confirm_with(wrong_code)" do
235
+ setup { @worked = @user.voice_confirm_with('wrong_code') }
236
+ should "not work" do
237
+ assert !@worked
238
+ end
239
+ should "not save the phone number into the confirmed attribute" do
240
+ assert_not_equal @user._TC_voice_confirmed_phone_number,
241
+ @user._TC_phone_number
242
+ end
243
+ should_not_change "confirmed phone number attribute" do
244
+ @user.reload._TC_voice_confirmed_phone_number
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+
251
+ context "when the number is not confirmed" do
252
+ context "sending a message" do
253
+ setup {
254
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success)
255
+ @result = @user.send_sms!('message')
256
+ }
257
+ should "send send no messages" do
258
+ assert_equal false, @result
259
+ end
260
+ end
261
+ end
262
+ context "when the number is blocked" do
263
+ setup {
264
+ @user._TC_sms_blocked = true
265
+ @user.save!
266
+ }
267
+ context "sending a message" do
268
+ setup { @result = @user.send_sms!('message') }
269
+ should "send nothing" do
270
+ assert_equal false, @result
271
+ end
272
+ end
273
+ end
274
+ context "when the number is confirmed" do
275
+ setup {
276
+ TwilioContactable::Gateway.stubs(:deliver).returns(Success)
277
+ @user.stubs(:sms_confirmed?).returns(true)
278
+ }
279
+ context "sending a message" do
280
+ setup { @result = @user.send_sms!('message') }
281
+ should "send send exactly one message" do
282
+ assert_equal [7], @result
283
+ end
284
+ end
285
+ context "sending a blank message" do
286
+ setup { @result = @user.send_sms!('') }
287
+ should "send send zero messages" do
288
+ assert_equal false, @result
289
+ end
290
+ end
291
+ context "sending a huge message" do
292
+ context "without the allow_multiple flag" do
293
+ should "raise an error" do
294
+ assert_raises ArgumentError do
295
+ @user.send_sms!("A"*200)
296
+ end
297
+ end
298
+ end
299
+ context "with the allow_multiple flag" do
300
+ setup { @result = @user.send_sms!("A"*200, true) }
301
+ should "send multiple messages" do
302
+ assert_equal [160, 40], @result
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end