arafa 0.1.0
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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/Rakefile +12 -0
- data/implementation_plan.md +610 -0
- data/lib/arafa/airtime/africas_talking.rb +107 -0
- data/lib/arafa/airtime/base.rb +107 -0
- data/lib/arafa/airtime/wasiliana.rb +95 -0
- data/lib/arafa/configuration.rb +33 -0
- data/lib/arafa/contracts/africas_talking_airtime_contract.rb +29 -0
- data/lib/arafa/contracts/africas_talking_contract.rb +27 -0
- data/lib/arafa/contracts/airtime_contract.rb +52 -0
- data/lib/arafa/contracts/base_contract.rb +21 -0
- data/lib/arafa/contracts/mobile_data_contract.rb +60 -0
- data/lib/arafa/contracts/wasiliana_contract.rb +30 -0
- data/lib/arafa/data/africas_talking.rb +157 -0
- data/lib/arafa/errors.rb +23 -0
- data/lib/arafa/message.rb +17 -0
- data/lib/arafa/phone_number.rb +25 -0
- data/lib/arafa/providers/africas_talking.rb +116 -0
- data/lib/arafa/providers/base.rb +118 -0
- data/lib/arafa/providers/wasiliana.rb +98 -0
- data/lib/arafa/schemas/airtime_request_schema.rb +28 -0
- data/lib/arafa/schemas/delivery_report_schema.rb +15 -0
- data/lib/arafa/schemas/ussd_callback_schema.rb +17 -0
- data/lib/arafa/send_result.rb +37 -0
- data/lib/arafa/types.rb +21 -0
- data/lib/arafa/ussd/africas_talking.rb +26 -0
- data/lib/arafa/ussd/request.rb +41 -0
- data/lib/arafa/ussd/response.rb +25 -0
- data/lib/arafa/ussd/wasiliana.rb +26 -0
- data/lib/arafa/version.rb +5 -0
- data/lib/arafa/whatsapp/africas_talking.rb +174 -0
- data/lib/arafa/whatsapp/message_body.rb +146 -0
- data/lib/arafa.rb +68 -0
- data/sig/arafa.rbs +4 -0
- metadata +209 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/initializer"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
|
|
6
|
+
require_relative "../types"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
require_relative "../phone_number"
|
|
9
|
+
require_relative "../send_result"
|
|
10
|
+
require_relative "../providers/base"
|
|
11
|
+
require_relative "../contracts/mobile_data_contract"
|
|
12
|
+
|
|
13
|
+
module Arafa
|
|
14
|
+
module Data
|
|
15
|
+
# Africa's Talking Mobile Data adapter (§1.12), AT-only — Wasiliana has no
|
|
16
|
+
# equivalent. Doesn't extend `Airtime::Base`/`Providers::Base`: each
|
|
17
|
+
# recipient carries its own `quantity`/`unit`/`validity`/`metadata`
|
|
18
|
+
# rather than one shape applied uniformly, so there's nothing to share.
|
|
19
|
+
# Still follows the same ctor/contract/HTTP/`Result` conventions, reusing
|
|
20
|
+
# `Providers::Base`'s Faraday connection.
|
|
21
|
+
class AfricasTalking
|
|
22
|
+
extend Dry::Initializer
|
|
23
|
+
include Dry::Monads[:result]
|
|
24
|
+
|
|
25
|
+
CONTRACT = Contracts::MobileDataContract
|
|
26
|
+
|
|
27
|
+
# Symbolizes recipient hash keys and normalizes `phone_number`
|
|
28
|
+
# (local/international -> `254...`) up front, so `#build_payload` and
|
|
29
|
+
# the contract both see a consistent shape.
|
|
30
|
+
RECIPIENTS_TYPE = Types::Array.of(Types::Hash).constructor do |value|
|
|
31
|
+
Array(value).map do |recipient|
|
|
32
|
+
normalized = recipient.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
33
|
+
phone = normalized[:phone_number]
|
|
34
|
+
normalized[:phone_number] = Arafa::PhoneNumber.normalize(phone) if phone
|
|
35
|
+
normalized
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
option :product_name, type: Types::String
|
|
40
|
+
option :recipients, type: RECIPIENTS_TYPE
|
|
41
|
+
option :username, optional: true, type: Types::String.optional, default: -> { Arafa.config.africas_talking.username }
|
|
42
|
+
option :idempotency_key, optional: true, type: Types::String.optional, default: -> { nil }
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def send(*args, **kwargs)
|
|
46
|
+
return super if args.any? || kwargs.empty?
|
|
47
|
+
|
|
48
|
+
new(**kwargs).send
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Reuses `Providers::Base`'s connection (retry, JSON, apiKey-redacting
|
|
52
|
+
# logger) rather than building a second one.
|
|
53
|
+
def connection
|
|
54
|
+
Providers::Base.connection
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Contract → HTTP call → response parsing. Returns a
|
|
59
|
+
# `Dry::Monads::Result`: `Success(SendResult)` or
|
|
60
|
+
# `Failure(Arafa::Error subclass)`.
|
|
61
|
+
def send(*args, **kwargs)
|
|
62
|
+
return super if args.any? || kwargs.any?
|
|
63
|
+
|
|
64
|
+
validation = validate_contract
|
|
65
|
+
return validation if validation.failure?
|
|
66
|
+
|
|
67
|
+
response = connection.post(endpoint, build_payload, headers)
|
|
68
|
+
parse_response(response)
|
|
69
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
70
|
+
Failure(Arafa::NetworkError.new(e.message))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def connection
|
|
74
|
+
self.class.connection
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def endpoint
|
|
78
|
+
"https://bundles.africastalking.com/mobile/data/request"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def headers
|
|
82
|
+
base = { "apiKey" => Arafa.config.africas_talking.api_key,
|
|
83
|
+
"Content-Type" => "application/json", "Accept" => "application/json" }
|
|
84
|
+
idempotency_key.nil? ? base : base.merge("Idempotency-Key" => idempotency_key)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_payload
|
|
88
|
+
{ username: username, productName: product_name, recipients: recipients.map { |r| build_recipient_payload(r) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_response(response)
|
|
92
|
+
return build_failure(response) unless response.status.between?(200, 299)
|
|
93
|
+
|
|
94
|
+
body = response.body
|
|
95
|
+
entries = Array(body["entries"])
|
|
96
|
+
return Failure(Arafa::InvalidRequestError.new(body["errorMessage"] || body.to_s)) if entries.empty?
|
|
97
|
+
|
|
98
|
+
build_success(body, entries)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def build_recipient_payload(recipient)
|
|
104
|
+
recipient.slice(:quantity, :unit, :validity, :metadata).merge(phoneNumber: recipient[:phone_number])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_success(body, entries)
|
|
108
|
+
recipients = entries.map { |e| build_recipient(e) }
|
|
109
|
+
Success(SendResult.new(provider: :mobile_data_africas_talking, recipients: recipients, raw: body))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_recipient(entry)
|
|
113
|
+
success = entry["status"] == "Queued"
|
|
114
|
+
SendResult::Recipient.new(
|
|
115
|
+
number: entry["phoneNumber"], success: success, message_id: entry["transactionId"],
|
|
116
|
+
status: entry["status"], cost: entry["value"], error: success ? nil : entry["status"]
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
ERROR_CLASSES_BY_STATUS = {
|
|
121
|
+
401 => Arafa::AuthenticationError,
|
|
122
|
+
400 => Arafa::InvalidRequestError,
|
|
123
|
+
403 => Arafa::InvalidRequestError,
|
|
124
|
+
406 => Arafa::InvalidRequestError,
|
|
125
|
+
429 => Arafa::RateLimitError,
|
|
126
|
+
500 => Arafa::ProviderServerError,
|
|
127
|
+
501 => Arafa::ProviderServerError,
|
|
128
|
+
502 => Arafa::ProviderServerError,
|
|
129
|
+
503 => Arafa::ProviderServerError
|
|
130
|
+
}.freeze
|
|
131
|
+
|
|
132
|
+
def build_failure(response)
|
|
133
|
+
error_class = ERROR_CLASSES_BY_STATUS.fetch(response.status, Arafa::InvalidRequestError)
|
|
134
|
+
Failure(error_class.new(error_message(response)))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def error_message(response)
|
|
138
|
+
body = response.body
|
|
139
|
+
body.is_a?(Hash) ? (body["errorMessage"] || body.to_s) : body.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_contract
|
|
143
|
+
result = self.class::CONTRACT.new.call(contract_params)
|
|
144
|
+
return Failure(Arafa::ValidationError.new(result.errors.to_h)) if result.failure?
|
|
145
|
+
|
|
146
|
+
Success(result)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def contract_params
|
|
150
|
+
keys = %i[phone_number quantity unit validity metadata]
|
|
151
|
+
{ product_name: product_name, recipients: recipients.map { |r| r.slice(*keys) } }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
Arafa.register(:mobile_data_africas_talking, Arafa::Data::AfricasTalking)
|
data/lib/arafa/errors.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arafa
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Payload failed a dry-schema/dry-validation check; no HTTP call was made.
|
|
7
|
+
class ValidationError < Error; end
|
|
8
|
+
|
|
9
|
+
# Provider rejected the request due to invalid/missing credentials (HTTP 401).
|
|
10
|
+
class AuthenticationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Provider rejected the request as malformed (HTTP 400/403/406).
|
|
13
|
+
class InvalidRequestError < Error; end
|
|
14
|
+
|
|
15
|
+
# Provider throttled the request (HTTP 429).
|
|
16
|
+
class RateLimitError < Error; end
|
|
17
|
+
|
|
18
|
+
# Provider failed on its own end (HTTP 500/503).
|
|
19
|
+
class ProviderServerError < Error; end
|
|
20
|
+
|
|
21
|
+
# The request never reached the provider (connection/timeout failure).
|
|
22
|
+
class NetworkError < Error; end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/struct"
|
|
4
|
+
|
|
5
|
+
require_relative "types"
|
|
6
|
+
|
|
7
|
+
module Arafa
|
|
8
|
+
# Normalized outbound message description shared by every provider adapter's
|
|
9
|
+
# `#send`. Coercion/validation of `to`/`text`/`from` happens via the leaf
|
|
10
|
+
# types (`Types::MsisdnList`, `Types::MessageText`, `Types::SenderId`) the
|
|
11
|
+
# moment the struct is built.
|
|
12
|
+
class Message < Dry::Struct
|
|
13
|
+
attribute :to, Types::MsisdnList
|
|
14
|
+
attribute :text, Types::MessageText
|
|
15
|
+
attribute? :from, Types::SenderId.optional
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arafa
|
|
4
|
+
# Normalizes Kenyan MSISDNs into the `2547XXXXXXXX` / `2541XXXXXXXX` form
|
|
5
|
+
# that both provider APIs expect, accepting local (`07...`/`01...`),
|
|
6
|
+
# international with a leading `+` (`+254...`), and already-normalized
|
|
7
|
+
# (`254...`) input.
|
|
8
|
+
module PhoneNumber
|
|
9
|
+
LOCAL_FORMAT = /\A0(7|1)\d{8}\z/
|
|
10
|
+
INTERNATIONAL_FORMAT = /\A\+?254(7|1)\d{8}\z/
|
|
11
|
+
|
|
12
|
+
def self.normalize(value)
|
|
13
|
+
digits = value.to_s.strip
|
|
14
|
+
|
|
15
|
+
case digits
|
|
16
|
+
when LOCAL_FORMAT
|
|
17
|
+
"254#{digits[1..]}"
|
|
18
|
+
when INTERNATIONAL_FORMAT
|
|
19
|
+
digits.delete_prefix("+")
|
|
20
|
+
else
|
|
21
|
+
digits
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../contracts/africas_talking_contract"
|
|
5
|
+
|
|
6
|
+
module Arafa
|
|
7
|
+
# Africa's Talking Bulk SMS adapter (§1.1). A single HTTP 200 can still
|
|
8
|
+
# carry a mix of successful and failed recipients, so success/failure is
|
|
9
|
+
# read out of each entry's `statusCode` in `SMSMessageData.Recipients`,
|
|
10
|
+
# never assumed from the HTTP status alone.
|
|
11
|
+
class AfricasTalking < Providers::Base
|
|
12
|
+
CONTRACT = Contracts::AfricasTalkingContract
|
|
13
|
+
|
|
14
|
+
# 100 Processed, 101 Sent, 102 Queued.
|
|
15
|
+
SUCCESS_STATUS_CODES = [100, 101, 102].freeze
|
|
16
|
+
|
|
17
|
+
option :username, optional: true, type: Types::String.optional, default: -> { Arafa.config.africas_talking.username }
|
|
18
|
+
option :enqueue, optional: true, type: Types::Integer.optional, default: -> { nil }
|
|
19
|
+
|
|
20
|
+
def endpoint
|
|
21
|
+
if Arafa.config.africas_talking.sandbox
|
|
22
|
+
"https://api.sandbox.africastalking.com/version1/messaging"
|
|
23
|
+
else
|
|
24
|
+
"https://api.africastalking.com/version1/messaging/bulk"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def headers
|
|
29
|
+
{
|
|
30
|
+
"apiKey" => Arafa.config.africas_talking.api_key,
|
|
31
|
+
"Content-Type" => "application/json",
|
|
32
|
+
"Accept" => "application/json"
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_payload
|
|
37
|
+
payload = {
|
|
38
|
+
username: username,
|
|
39
|
+
phoneNumbers: to,
|
|
40
|
+
message: text,
|
|
41
|
+
senderId: from
|
|
42
|
+
}
|
|
43
|
+
payload[:enqueue] = enqueue unless enqueue.nil?
|
|
44
|
+
payload
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_response(response)
|
|
48
|
+
return build_result(response.body) if response.status.between?(200, 299)
|
|
49
|
+
|
|
50
|
+
Failure(error_class_for(response.status).new(error_message(response)))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
ERROR_CLASSES_BY_STATUS = {
|
|
56
|
+
401 => Arafa::AuthenticationError,
|
|
57
|
+
400 => Arafa::InvalidRequestError,
|
|
58
|
+
403 => Arafa::InvalidRequestError,
|
|
59
|
+
406 => Arafa::InvalidRequestError,
|
|
60
|
+
429 => Arafa::RateLimitError,
|
|
61
|
+
500 => Arafa::ProviderServerError,
|
|
62
|
+
501 => Arafa::ProviderServerError,
|
|
63
|
+
502 => Arafa::ProviderServerError,
|
|
64
|
+
503 => Arafa::ProviderServerError
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
def error_class_for(status)
|
|
68
|
+
ERROR_CLASSES_BY_STATUS.fetch(status, Arafa::InvalidRequestError)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_result(body)
|
|
72
|
+
recipients = Array(body.dig("SMSMessageData", "Recipients"))
|
|
73
|
+
|
|
74
|
+
return Failure(Arafa::InvalidRequestError.new(error_message_for_empty_recipients(body))) if recipients.empty?
|
|
75
|
+
|
|
76
|
+
Success(
|
|
77
|
+
SendResult.new(
|
|
78
|
+
provider: :africas_talking,
|
|
79
|
+
recipients: recipients.map { |entry| build_recipient(entry) },
|
|
80
|
+
raw: body
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_recipient(entry)
|
|
86
|
+
success = SUCCESS_STATUS_CODES.include?(entry["statusCode"])
|
|
87
|
+
|
|
88
|
+
SendResult::Recipient.new(
|
|
89
|
+
number: entry["number"],
|
|
90
|
+
success: success,
|
|
91
|
+
message_id: entry["messageId"],
|
|
92
|
+
status: entry["status"],
|
|
93
|
+
cost: entry["cost"],
|
|
94
|
+
error: success ? nil : entry["status"]
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def error_message(response)
|
|
99
|
+
body = response.body
|
|
100
|
+
return body.to_s unless body.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
body["errorMessage"] || body.dig("SMSMessageData", "Message") || body.to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# AT can return HTTP 2xx with an empty `Recipients` array (e.g. sandbox
|
|
106
|
+
# routing rejects the number before it reaches SendResult's min_size: 1
|
|
107
|
+
# invariant) — fall back to `SMSMessageData.Message` for why.
|
|
108
|
+
def error_message_for_empty_recipients(body)
|
|
109
|
+
return body.to_s unless body.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
body.dig("SMSMessageData", "Message") || body.to_s
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Arafa.register(:africas_talking, Arafa::AfricasTalking)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/initializer"
|
|
4
|
+
require "dry/monads"
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/retry"
|
|
7
|
+
|
|
8
|
+
require_relative "../types"
|
|
9
|
+
require_relative "../errors"
|
|
10
|
+
require_relative "../version"
|
|
11
|
+
|
|
12
|
+
module Arafa
|
|
13
|
+
module Providers
|
|
14
|
+
# Abstract superclass for provider adapters (`AfricasTalking`, `Wasiliana`, ...).
|
|
15
|
+
#
|
|
16
|
+
# Subclasses implement `#endpoint`, `#headers`, `#build_payload`, `#parse_response`,
|
|
17
|
+
# and set a `CONTRACT` constant (a `Dry::Validation::Contract`) applied to `to:`/
|
|
18
|
+
# `text:`/`from:` before any HTTP call is made.
|
|
19
|
+
class Base
|
|
20
|
+
extend Dry::Initializer
|
|
21
|
+
include Dry::Monads[:result]
|
|
22
|
+
|
|
23
|
+
# Wraps a scalar `to:` into an Array before per-element PhoneNumber coercion,
|
|
24
|
+
# so callers can pass either a single number or a list.
|
|
25
|
+
TO_TYPE = Types::Array.of(Types::PhoneNumber).constructor { |value| Array(value) }
|
|
26
|
+
|
|
27
|
+
option :text, type: Types::MessageText
|
|
28
|
+
option :to, type: TO_TYPE
|
|
29
|
+
option :from, optional: true, type: Types::SenderId.optional, default: -> { Arafa.config.default_sender }
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Accepts either provider keywords (`text:`, `to:`, `from:`) or a single
|
|
33
|
+
# positional object responding to `#to_arafa` (duck-typed message support).
|
|
34
|
+
def new(*args, **kwargs)
|
|
35
|
+
if args.size == 1 && args.first.respond_to?(:to_arafa)
|
|
36
|
+
kwargs = args.first.to_arafa.merge(kwargs)
|
|
37
|
+
args = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
super(*args, **kwargs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Shortcut for `new(**kwargs).send`. Falls back to `Kernel#send`'s
|
|
44
|
+
# dynamic-dispatch behavior for any other call shape (positional
|
|
45
|
+
# method-name args), since dry-initializer and friends rely on
|
|
46
|
+
# `klass.send(:some_method, ...)` internally.
|
|
47
|
+
def send(*args, **kwargs)
|
|
48
|
+
return super if args.any? || kwargs.empty?
|
|
49
|
+
|
|
50
|
+
new(**kwargs).send
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def connection
|
|
54
|
+
@connection ||= build_connection
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_connection
|
|
60
|
+
Faraday.new do |f|
|
|
61
|
+
f.request :json
|
|
62
|
+
f.response :json, content_type: /\bjson$/
|
|
63
|
+
f.request :retry, retry_statuses: [429, 500, 502, 503, 504]
|
|
64
|
+
f.response :logger, Arafa.logger, headers: true do |logger|
|
|
65
|
+
logger.filter(/(apiKey["']?\s*[:=]\s*["']?)([^"'&\s]+)/i, '\1[FILTERED]')
|
|
66
|
+
end
|
|
67
|
+
f.headers["User-Agent"] = "arafa/#{Arafa::VERSION}"
|
|
68
|
+
f.adapter Faraday.default_adapter
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Contract → HTTP call → response parsing. Returns a `Dry::Monads::Result`:
|
|
74
|
+
# `Success(SendResult)` or `Failure(Arafa::Error subclass)`. Falls back to
|
|
75
|
+
# `Kernel#send`'s dynamic-dispatch behavior if called with arguments.
|
|
76
|
+
def send(*args, **kwargs)
|
|
77
|
+
return super if args.any? || kwargs.any?
|
|
78
|
+
|
|
79
|
+
validation = validate_contract
|
|
80
|
+
return validation if validation.failure?
|
|
81
|
+
|
|
82
|
+
response = connection.post(endpoint, build_payload, headers)
|
|
83
|
+
parse_response(response)
|
|
84
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
85
|
+
Failure(Arafa::NetworkError.new(e.message))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def connection
|
|
89
|
+
self.class.connection
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def endpoint
|
|
93
|
+
raise NotImplementedError, "#{self.class} must implement #endpoint"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def headers
|
|
97
|
+
raise NotImplementedError, "#{self.class} must implement #headers"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_payload
|
|
101
|
+
raise NotImplementedError, "#{self.class} must implement #build_payload"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_response(_response)
|
|
105
|
+
raise NotImplementedError, "#{self.class} must implement #parse_response"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def validate_contract
|
|
111
|
+
result = self.class::CONTRACT.new.call(to: to, text: text, from: from)
|
|
112
|
+
return Failure(Arafa::ValidationError.new(result.errors.to_h)) if result.failure?
|
|
113
|
+
|
|
114
|
+
Success(result)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../contracts/wasiliana_contract"
|
|
5
|
+
|
|
6
|
+
module Arafa
|
|
7
|
+
# Wasiliana SMS send adapter (§1.4). Unlike Africa's Talking, Wasiliana's
|
|
8
|
+
# response carries no per-recipient breakdown — just an overall
|
|
9
|
+
# `status`/`data` pair — so success/failure is decided once for the whole
|
|
10
|
+
# request rather than per number.
|
|
11
|
+
class Wasiliana < Providers::Base
|
|
12
|
+
CONTRACT = Contracts::WasilianaContract
|
|
13
|
+
|
|
14
|
+
option :linkid, optional: true, type: Types::String.optional, default: -> { nil }
|
|
15
|
+
option :message_uid, optional: true, type: Types::String.optional, default: -> { nil }
|
|
16
|
+
option :is_otp, optional: true, type: Types::Any.optional, default: -> { nil }
|
|
17
|
+
|
|
18
|
+
def endpoint
|
|
19
|
+
"https://api.wasiliana.com/api/v1/send/sms"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def headers
|
|
23
|
+
{
|
|
24
|
+
"Content-Type" => "application/json",
|
|
25
|
+
"apiKey" => Arafa.config.wasiliana.api_key
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_payload
|
|
30
|
+
payload = {
|
|
31
|
+
recipients: to,
|
|
32
|
+
from: from,
|
|
33
|
+
message: text
|
|
34
|
+
}
|
|
35
|
+
payload[:linkid] = linkid unless linkid.nil?
|
|
36
|
+
payload[:message_uid] = message_uid unless message_uid.nil?
|
|
37
|
+
payload[:is_otp] = is_otp unless is_otp.nil?
|
|
38
|
+
payload
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_response(response)
|
|
42
|
+
body = response.body
|
|
43
|
+
|
|
44
|
+
if response.status.between?(200, 299) && body.is_a?(Hash) && body["status"] == "success"
|
|
45
|
+
build_success(body)
|
|
46
|
+
else
|
|
47
|
+
build_failure(response)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def build_success(body)
|
|
54
|
+
Success(
|
|
55
|
+
SendResult.new(
|
|
56
|
+
provider: :wasiliana,
|
|
57
|
+
recipients: to.map { |number| build_recipient(number, body) },
|
|
58
|
+
raw: body
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_recipient(number, body)
|
|
64
|
+
SendResult::Recipient.new(
|
|
65
|
+
number: number,
|
|
66
|
+
success: true,
|
|
67
|
+
message_id: message_uid,
|
|
68
|
+
status: body["data"]
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ERROR_CLASSES_BY_STATUS = {
|
|
73
|
+
401 => Arafa::AuthenticationError,
|
|
74
|
+
400 => Arafa::InvalidRequestError,
|
|
75
|
+
403 => Arafa::InvalidRequestError,
|
|
76
|
+
404 => Arafa::InvalidRequestError,
|
|
77
|
+
405 => Arafa::InvalidRequestError,
|
|
78
|
+
406 => Arafa::InvalidRequestError,
|
|
79
|
+
429 => Arafa::RateLimitError,
|
|
80
|
+
500 => Arafa::ProviderServerError,
|
|
81
|
+
503 => Arafa::ProviderServerError
|
|
82
|
+
}.freeze
|
|
83
|
+
|
|
84
|
+
def build_failure(response)
|
|
85
|
+
error_class = ERROR_CLASSES_BY_STATUS.fetch(response.status, Arafa::InvalidRequestError)
|
|
86
|
+
Failure(error_class.new(error_message(response)))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def error_message(response)
|
|
90
|
+
body = response.body
|
|
91
|
+
return body.to_s unless body.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
body["message"] || body["error"] || body["data"] || body.to_s
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Arafa.register(:wasiliana, Arafa::Wasiliana)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/schema"
|
|
4
|
+
require "dry/types"
|
|
5
|
+
|
|
6
|
+
module Arafa
|
|
7
|
+
module Schemas
|
|
8
|
+
# Leaf types used only for structural coercion within `Schemas::*`.
|
|
9
|
+
module Types
|
|
10
|
+
include Dry.Types()
|
|
11
|
+
|
|
12
|
+
# Wasiliana's docs show `phone_number` array-wrapped but the field also
|
|
13
|
+
# appears sent as a bare string — accept either and normalize to an array.
|
|
14
|
+
PhoneNumberList = (Types::Array.of(Types::String) |
|
|
15
|
+
Types::String.constructor { |value| [value] })
|
|
16
|
+
.constrained(min_size: 1)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Wasiliana airtime request params.
|
|
20
|
+
AirtimeRequestSchema = Dry::Schema.JSON do
|
|
21
|
+
required(:phone_number).value(Schemas::Types::PhoneNumberList)
|
|
22
|
+
required(:currency_code).filled(:string)
|
|
23
|
+
required(:amount).filled(:string)
|
|
24
|
+
required(:callback).filled(:string)
|
|
25
|
+
optional(:airtime_uid).filled(:string)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/schema"
|
|
4
|
+
|
|
5
|
+
module Arafa
|
|
6
|
+
module Schemas
|
|
7
|
+
# Wasiliana delivery-report callback body, e.g.:
|
|
8
|
+
# {"phone": ["25472xxxxxxx"], "correlator": "message_...", "deliveryStatus": "0"}
|
|
9
|
+
DeliveryReportSchema = Dry::Schema.JSON do
|
|
10
|
+
required(:phone).array(:string)
|
|
11
|
+
required(:correlator).filled(:string)
|
|
12
|
+
required(:deliveryStatus).filled(:string)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/schema"
|
|
4
|
+
|
|
5
|
+
module Arafa
|
|
6
|
+
module Schemas
|
|
7
|
+
# Shared shape for inbound USSD webhook params, after each provider's
|
|
8
|
+
# `Ussd::<Provider>.parse` remaps its own callback keys onto this
|
|
9
|
+
# canonical set (session_id, phone_number, service_code, text).
|
|
10
|
+
UssdCallbackSchema = Dry::Schema.Params do
|
|
11
|
+
required(:session_id).filled(:string)
|
|
12
|
+
required(:phone_number).filled(:string)
|
|
13
|
+
required(:service_code).filled(:string)
|
|
14
|
+
required(:text).maybe(:string)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/struct"
|
|
4
|
+
|
|
5
|
+
require_relative "types"
|
|
6
|
+
|
|
7
|
+
module Arafa
|
|
8
|
+
# Normalized outcome of a provider `#send` call. Wraps the per-recipient
|
|
9
|
+
# success/failure detail returned by both AT and Wasiliana into one shape,
|
|
10
|
+
# since a single HTTP 200 response (AT bulk SMS/airtime) can carry a mix of
|
|
11
|
+
# successful and failed recipients that must be surfaced individually
|
|
12
|
+
# rather than collapsed into one overall success flag.
|
|
13
|
+
class SendResult < Dry::Struct
|
|
14
|
+
# Per-recipient outcome, normalized from AT's numeric `statusCode` /
|
|
15
|
+
# Wasiliana's generic `status` string.
|
|
16
|
+
class Recipient < Dry::Struct
|
|
17
|
+
attribute :number, Types::PhoneNumber
|
|
18
|
+
attribute :success, Types::Bool
|
|
19
|
+
attribute? :message_id, Types::String.optional.default(nil)
|
|
20
|
+
attribute? :status, Types::String.optional.default(nil)
|
|
21
|
+
attribute? :cost, Types::String.optional.default(nil)
|
|
22
|
+
attribute? :error, Types::String.optional.default(nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attribute :provider, Types::Symbol
|
|
26
|
+
attribute :recipients, Types::Array.of(Recipient).constrained(min_size: 1)
|
|
27
|
+
attribute? :raw, Types::Any.optional.default(nil)
|
|
28
|
+
|
|
29
|
+
def success?
|
|
30
|
+
recipients.all?(&:success)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def message_id
|
|
34
|
+
recipients.first&.message_id
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/arafa/types.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/types"
|
|
4
|
+
|
|
5
|
+
require_relative "phone_number"
|
|
6
|
+
|
|
7
|
+
module Arafa
|
|
8
|
+
# Leaf value types (coercion + constraints) shared across providers.
|
|
9
|
+
module Types
|
|
10
|
+
include Dry.Types()
|
|
11
|
+
|
|
12
|
+
PhoneNumber = Types::String
|
|
13
|
+
.constructor { |value| Arafa::PhoneNumber.normalize(value) }
|
|
14
|
+
.constrained(format: /\A254(7|1)\d{8}\z/)
|
|
15
|
+
|
|
16
|
+
MsisdnList = Types::Array.of(PhoneNumber).constrained(min_size: 1)
|
|
17
|
+
|
|
18
|
+
SenderId = Types::String.constrained(min_size: 1, max_size: 11)
|
|
19
|
+
MessageText = Types::String.constrained(min_size: 1, max_size: 918)
|
|
20
|
+
end
|
|
21
|
+
end
|