babysms 0.5.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.
- 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
|