babysms 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop-airbnb.yml +1716 -0
- data/.rubocop.yml +75 -0
- data/.travis.yml +7 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +184 -0
- data/Rakefile +6 -0
- data/babysms.gemspec +51 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/config.ru +2 -0
- data/lib/babysms.rb +52 -0
- data/lib/babysms/adapter.rb +60 -0
- data/lib/babysms/adapters/bandwidth_adapter.rb +41 -0
- data/lib/babysms/adapters/nexmo_adapter.rb +39 -0
- data/lib/babysms/adapters/plivo_adapter.rb +34 -0
- data/lib/babysms/adapters/signalwire_adapter.rb +38 -0
- data/lib/babysms/adapters/test_adapter.rb +61 -0
- data/lib/babysms/adapters/twilio_adapter.rb +47 -0
- data/lib/babysms/errors.rb +43 -0
- data/lib/babysms/mail_man.rb +77 -0
- data/lib/babysms/message.rb +58 -0
- data/lib/babysms/receipt.rb +19 -0
- data/lib/babysms/report.rb +15 -0
- data/lib/babysms/version.rb +3 -0
- data/lib/babysms/web_application.rb +87 -0
- data/lib/babysms/web_hook.rb +21 -0
- metadata +243 -0
@@ -0,0 +1,41 @@
|
|
1
|
+
unless Object.const_defined?(:Bandwidth)
|
2
|
+
fail '`ruby-bandwidth` gem is required to use BandwidthAdapter'
|
3
|
+
end
|
4
|
+
|
5
|
+
module BabySMS
|
6
|
+
module Adapters
|
7
|
+
class BandwidthAdapter < BabySMS::Adapter
|
8
|
+
def initialize(user_id:, api_token:, api_secret:, from:)
|
9
|
+
super(from: from)
|
10
|
+
|
11
|
+
self.client = Bandwidth::Client.new(user_id: user_id,
|
12
|
+
api_token: api_token,
|
13
|
+
api_secret: api_secret)
|
14
|
+
end
|
15
|
+
|
16
|
+
def deliver(message)
|
17
|
+
response = Bandwidth::Message.create(client,
|
18
|
+
from: from,
|
19
|
+
to: message.to,
|
20
|
+
text: message.contents)
|
21
|
+
if response[:error]
|
22
|
+
raise BabySMS::FailedDelivery.new(response[:error].to_s, adapter: self)
|
23
|
+
end
|
24
|
+
|
25
|
+
response[:id]
|
26
|
+
end
|
27
|
+
|
28
|
+
class WebHook < BabySMS::WebHook
|
29
|
+
def process(app:, report:)
|
30
|
+
validate!(app.request)
|
31
|
+
message = BabySMS::Message.new(from: app.params['from'],
|
32
|
+
to: app.params['to'],
|
33
|
+
contents: app.params['text'],
|
34
|
+
uuid: app.params['id'])
|
35
|
+
report.incoming_message(message)
|
36
|
+
[200, { 'Content-Type' => 'text/plain' }, 'ok']
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
unless Object.const_defined?(:Nexmo)
|
2
|
+
fail '`nexmo` gem is required to use NexmoAdapter'
|
3
|
+
end
|
4
|
+
|
5
|
+
module BabySMS
|
6
|
+
module Adapters
|
7
|
+
class NexmoAdapter < BabySMS::Adapter
|
8
|
+
def initialize(api_key:, api_secret:, from:)
|
9
|
+
super(from: from)
|
10
|
+
|
11
|
+
self.client = Nexmo::Client.new(api_key: api_key, api_secret: api_secret)
|
12
|
+
end
|
13
|
+
|
14
|
+
def deliver(message)
|
15
|
+
# Thanks for being weird, Nexmo. Rejects numbers starting with "+"
|
16
|
+
response = client.sms.send(from: from.gsub(/\A\+/, ''),
|
17
|
+
to: message.to,
|
18
|
+
text: message.contents)
|
19
|
+
if response.messages.first.status != '0'
|
20
|
+
raise BabySMS::FailedDelivery.new(response.messages.first.error_text,
|
21
|
+
adapter: self)
|
22
|
+
end
|
23
|
+
|
24
|
+
response.messages.first.message_id
|
25
|
+
end
|
26
|
+
|
27
|
+
class WebHook < BabySMS::WebHook
|
28
|
+
def process(app:, report:)
|
29
|
+
message = BabySMS::Message.new(from: app.params['msisdn'],
|
30
|
+
to: app.params['to'],
|
31
|
+
contents: app.params['text'],
|
32
|
+
uuid: app.params['messageId'])
|
33
|
+
report.incoming_message(message)
|
34
|
+
[200, { 'Content-Type' => 'text/plain' }, 'ok']
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
unless Object.const_defined?(:Plivo)
|
2
|
+
fail '`plivo-ruby` gem is required to use PlivoAdapter'
|
3
|
+
end
|
4
|
+
|
5
|
+
module BabySMS
|
6
|
+
module Adapters
|
7
|
+
class PlivoAdapter < BabySMS::Adapter
|
8
|
+
def initialize(auth_id:, auth_token:, from:)
|
9
|
+
super(from: from)
|
10
|
+
|
11
|
+
self.client = Plivo::RestClient.new(auth_id, auth_token)
|
12
|
+
end
|
13
|
+
|
14
|
+
def deliver(message)
|
15
|
+
response = client.messages.create(from, [message.to], message.contents)
|
16
|
+
response.message_uuid
|
17
|
+
rescue PlivoRESTError => e
|
18
|
+
raise BabySMS::FailedDelivery.new(e.message, adapter: self)
|
19
|
+
end
|
20
|
+
|
21
|
+
class WebHook < BabySMS::WebHook
|
22
|
+
def process(app:, report:)
|
23
|
+
# 'Uuid' is the uuid. Also of note: NumMedia, and MediaContentType0, MediaUrl0
|
24
|
+
message = BabySMS::Message.new(from: app.params['From'],
|
25
|
+
to: app.params['To'],
|
26
|
+
contents: app.params['Text'],
|
27
|
+
uuid: app.params['Uuid'])
|
28
|
+
report.incoming_message(message)
|
29
|
+
[200, { 'Content-Type' => 'text/plain' }, 'ok']
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
unless Object.const_defined?(:Signalwire)
|
2
|
+
fail '`signalwire-ruby` gem is required to use SignalwireAdapter'
|
3
|
+
end
|
4
|
+
|
5
|
+
require 'signalwire/sdk'
|
6
|
+
|
7
|
+
module BabySMS
|
8
|
+
module Adapters
|
9
|
+
class SignalwireAdapter < BabySMS::Adapter
|
10
|
+
def initialize(from:, project:, token:, space_url:)
|
11
|
+
super(from: from)
|
12
|
+
self.client = Signalwire::REST::Client.new(project, token, signalwire_space_url: space_url)
|
13
|
+
end
|
14
|
+
|
15
|
+
def deliver(message)
|
16
|
+
result = client.messages.create(from: from,
|
17
|
+
to: message.to,
|
18
|
+
body: message.contents,
|
19
|
+
status_callback: web_hook.end_point)
|
20
|
+
result.sid
|
21
|
+
rescue Signalwire::REST::SignalwireError => e
|
22
|
+
raise BabySMS::FailedDelivery.new(e.message, adapter: self)
|
23
|
+
end
|
24
|
+
|
25
|
+
class WebHook < BabySMS::WebHook
|
26
|
+
def process(app:, report:)
|
27
|
+
# Of note: NumMedia, and MediaContentType0, MediaUrl0
|
28
|
+
message = BabySMS::Message.new(from: app.params['from'],
|
29
|
+
to: app.params['to'],
|
30
|
+
contents: app.params['body'],
|
31
|
+
uuid: app.params['id'])
|
32
|
+
report.incoming_message(message)
|
33
|
+
[200, { 'Content-Type' => 'application/xml' }, '<Response></Response>']
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'babysms/web_hook'
|
2
|
+
|
3
|
+
require 'rainbow/refinement'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
using Rainbow
|
7
|
+
|
8
|
+
module BabySMS
|
9
|
+
module Adapters
|
10
|
+
class TestAdapter < BabySMS::Adapter
|
11
|
+
attr_accessor :verbose
|
12
|
+
attr_accessor :fails
|
13
|
+
attr_accessor :outbox
|
14
|
+
|
15
|
+
def initialize(verbose: false, fails: false, from: '+1555-555-5555')
|
16
|
+
super(from: from)
|
17
|
+
|
18
|
+
self.verbose = verbose
|
19
|
+
self.fails = fails
|
20
|
+
self.outbox = []
|
21
|
+
end
|
22
|
+
|
23
|
+
def deliver(message)
|
24
|
+
if fails
|
25
|
+
raise BabySMS::FailedDelivery.new('intentional failure', adapter: self)
|
26
|
+
end
|
27
|
+
|
28
|
+
outbox.push(message)
|
29
|
+
if verbose
|
30
|
+
terminal_output = <<~"MSG"
|
31
|
+
#{"SMS:".bright.yellow} -> #{message.to.bright.yellow}:
|
32
|
+
>> #{message.contents.white}
|
33
|
+
MSG
|
34
|
+
$stderr.puts terminal_output
|
35
|
+
end
|
36
|
+
|
37
|
+
next_message_uuid
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def next_message_uuid
|
43
|
+
@message_uuid ||= 0
|
44
|
+
@message_uuid += 1
|
45
|
+
@message_uuid.to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
class WebHook < BabySMS::WebHook
|
49
|
+
def process(app:, report:)
|
50
|
+
json = JSON.parse(app.request.body.read)
|
51
|
+
message = BabySMS::Message.new(to: adapter.from,
|
52
|
+
from: json["from"],
|
53
|
+
contents: json["body"])
|
54
|
+
report.incoming_message(message)
|
55
|
+
|
56
|
+
[200, { "Content-Type" => "text/plain" }, 'ok']
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
unless Object.const_defined?(:Twilio)
|
2
|
+
fail '`twilio-ruby` gem is required to use TwilioAdapter'
|
3
|
+
end
|
4
|
+
|
5
|
+
module BabySMS
|
6
|
+
module Adapters
|
7
|
+
class TwilioAdapter < BabySMS::Adapter
|
8
|
+
def initialize(account_sid:, auth_token:, from:)
|
9
|
+
super(from: from)
|
10
|
+
|
11
|
+
self.client = Twilio::REST::Client.new(account_sid, auth_token)
|
12
|
+
end
|
13
|
+
|
14
|
+
def deliver(message)
|
15
|
+
result = client.api.account.messages.create(from: from,
|
16
|
+
to: message.to,
|
17
|
+
body: message.contents,
|
18
|
+
status_callback: web_hook.end_point)
|
19
|
+
result.sid
|
20
|
+
rescue Twilio::REST::TwilioError => e
|
21
|
+
raise BabySMS::FailedDelivery.new(e.message, adapter: self)
|
22
|
+
end
|
23
|
+
|
24
|
+
class WebHook < BabySMS::WebHook
|
25
|
+
def validate!(request)
|
26
|
+
validator = Twilio::Security::RequestValidator.new(adapter.client.auth_token)
|
27
|
+
unless validator.validate(end_point, request.params,
|
28
|
+
request.headers['X-Twilio-Signature'])
|
29
|
+
fail BabySMS::Unauthorized, 'twilio signature failed'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def process(app:, report:)
|
34
|
+
validate!(app.request)
|
35
|
+
|
36
|
+
# Of note: NumMedia, and MediaContentType0, MediaUrl0
|
37
|
+
message = BabySMS::Message.new(from: app.params['From'],
|
38
|
+
to: app.params['To'],
|
39
|
+
contents: app.params['Body'],
|
40
|
+
uuid: app.params['MessageSid'])
|
41
|
+
report.incoming_message(message)
|
42
|
+
[200, { 'Content-Type' => 'application/xml' }, '<Response></Response>']
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module BabySMS
|
2
|
+
class Error < StandardError
|
3
|
+
end
|
4
|
+
|
5
|
+
# A FailedDelivery being raised means your message didn't get sent. This can be because of a
|
6
|
+
# single error, or multiple failed attempts.
|
7
|
+
#
|
8
|
+
# A FailedDelivery may contain multiple "sub-exceptions" in #exception that can be inspected
|
9
|
+
# to determine what happened.
|
10
|
+
class FailedDelivery < BabySMS::Error
|
11
|
+
attr_reader :exceptions
|
12
|
+
attr_reader :adapter
|
13
|
+
|
14
|
+
def initialize(*args, adapter: nil)
|
15
|
+
super(*args)
|
16
|
+
|
17
|
+
@adapter = adapter
|
18
|
+
@exceptions = [self]
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.multiple(exceptions)
|
22
|
+
# We don't want to give a public interface to setting exceptions
|
23
|
+
BabySMS::FailedDelivery.new("multiple exceptions").itself do |result|
|
24
|
+
result.instance_variable_set(:@exceptions, exceptions)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def multiple?
|
29
|
+
exceptions.size > 1
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class WebHookError < BabySMS::Error
|
34
|
+
end
|
35
|
+
|
36
|
+
# Post from provider we don't understand
|
37
|
+
class Malformed < BabySMS::WebHookError
|
38
|
+
end
|
39
|
+
|
40
|
+
# Provider signature verification fails
|
41
|
+
class Unauthorized < BabySMS::WebHookError
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module BabySMS
|
2
|
+
|
3
|
+
# MailMan is the link between a Message and an Adapter. It basically has a strategy for
|
4
|
+
# choosing adapters, and attempting delivery until either the message is delivered, or
|
5
|
+
# all adapters have been tried.
|
6
|
+
#
|
7
|
+
# The boring strategy is :in_order, which is good for cases where you have a primary
|
8
|
+
# adapter, and one or more fallbacks.
|
9
|
+
#
|
10
|
+
# The alternative is :random, which sort of works in a load-balancing kind of way.
|
11
|
+
|
12
|
+
class MailMan
|
13
|
+
attr_reader :adapters
|
14
|
+
attr_reader :strategy
|
15
|
+
|
16
|
+
def initialize(adapters:, strategy:)
|
17
|
+
unless respond_to?(:"next_#{strategy}_adapter", true)
|
18
|
+
fail ArgumentError, "invalid strategy: #{strategy.inspect}"
|
19
|
+
end
|
20
|
+
|
21
|
+
@adapters = adapters.dup
|
22
|
+
@strategy = strategy
|
23
|
+
end
|
24
|
+
|
25
|
+
def deliver(message)
|
26
|
+
# If the message has a "from", we need to use that adapter, even if
|
27
|
+
# others are available
|
28
|
+
if message.from
|
29
|
+
specified = BabySMS::Adapter.for_number(message.from, pool: adapters)
|
30
|
+
|
31
|
+
if specified.nil?
|
32
|
+
fail BabySMS::Error, "`from:' not associated with an adapter: #{message.from}"
|
33
|
+
end
|
34
|
+
@adapters = [specified]
|
35
|
+
end
|
36
|
+
|
37
|
+
if @adapters.empty?
|
38
|
+
fail BabySMS::Error, 'no adapter configured'
|
39
|
+
end
|
40
|
+
|
41
|
+
# We collect and return all errors leading up to the (hopefully) successful delivery
|
42
|
+
failures = []
|
43
|
+
each_adapter do |adapter|
|
44
|
+
return BabySMS::Receipt.new(message_uuid: adapter.deliver(self),
|
45
|
+
message: message,
|
46
|
+
adapter: adapter,
|
47
|
+
exceptions: failures)
|
48
|
+
rescue BabySMS::FailedDelivery => e
|
49
|
+
failures.push(e)
|
50
|
+
end
|
51
|
+
|
52
|
+
raise BabySMS::FailedDelivery.multiple(failures)
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def next_in_order_adapter
|
58
|
+
adapters.shift
|
59
|
+
end
|
60
|
+
|
61
|
+
def next_random_adapter
|
62
|
+
adapters.delete(adapters.sample)
|
63
|
+
end
|
64
|
+
|
65
|
+
def next_adapter
|
66
|
+
send(:"next_#{strategy}_adapter")
|
67
|
+
end
|
68
|
+
|
69
|
+
# returns each adapter in the order specified by the strategy. Mutates adapters Array.
|
70
|
+
def each_adapter(&block)
|
71
|
+
loop do
|
72
|
+
raise StopIteration if adapters.empty?
|
73
|
+
yield next_adapter
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
module BabySMS
|
2
|
+
class InvalidMessage < BabySMS::Error
|
3
|
+
end
|
4
|
+
|
5
|
+
class Message
|
6
|
+
attr_accessor :to
|
7
|
+
attr_accessor :from
|
8
|
+
attr_accessor :contents
|
9
|
+
|
10
|
+
def initialize(to:, from: nil, contents:, uuid: nil)
|
11
|
+
@to = Phony.normalize(to)
|
12
|
+
@from = from
|
13
|
+
@contents = contents
|
14
|
+
end
|
15
|
+
|
16
|
+
def deliver(adapters: BabySMS.adapters, strategy: BabySMS.strategy)
|
17
|
+
validate!
|
18
|
+
BabySMS::MailMan.new(adapters: adapters, strategy: strategy).deliver(self)
|
19
|
+
end
|
20
|
+
|
21
|
+
# generates and delivers a reply to a message
|
22
|
+
def reply(contents:, adapters: BabySMS.adapters, strategy: BabySMS.strategy)
|
23
|
+
Message.new(to: from, from: to, contents: contents)
|
24
|
+
.deliver(adapters: adapters, strategy: strategy)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
MAX_CONTENTS_LENGTH = 1600
|
30
|
+
|
31
|
+
def validate!
|
32
|
+
validate_to!
|
33
|
+
validate_contents!
|
34
|
+
end
|
35
|
+
|
36
|
+
def validate_to!
|
37
|
+
if to.nil? || to.empty?
|
38
|
+
fail BabySMS::InvalidMessage, 'no to:'
|
39
|
+
end
|
40
|
+
|
41
|
+
unless Phony.plausible?(to)
|
42
|
+
fail BabySMS::InvalidMessage, "implausible to: #{to}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_contents!
|
47
|
+
if contents.nil? || contents.empty?
|
48
|
+
fail BabySMS::InvalidMessage, 'no contents'
|
49
|
+
end
|
50
|
+
|
51
|
+
if contents.size > MAX_CONTENTS_LENGTH
|
52
|
+
msg = "contents too long (#{contents.size} vs max of #{MAX_CONTENTS_LENGTH}); " \
|
53
|
+
"contents: `#{contents}`"
|
54
|
+
fail BabySMS::InvalidMessage, msg
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|