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.
@@ -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