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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +174 -0
  6. data/Rakefile +12 -0
  7. data/implementation_plan.md +610 -0
  8. data/lib/arafa/airtime/africas_talking.rb +107 -0
  9. data/lib/arafa/airtime/base.rb +107 -0
  10. data/lib/arafa/airtime/wasiliana.rb +95 -0
  11. data/lib/arafa/configuration.rb +33 -0
  12. data/lib/arafa/contracts/africas_talking_airtime_contract.rb +29 -0
  13. data/lib/arafa/contracts/africas_talking_contract.rb +27 -0
  14. data/lib/arafa/contracts/airtime_contract.rb +52 -0
  15. data/lib/arafa/contracts/base_contract.rb +21 -0
  16. data/lib/arafa/contracts/mobile_data_contract.rb +60 -0
  17. data/lib/arafa/contracts/wasiliana_contract.rb +30 -0
  18. data/lib/arafa/data/africas_talking.rb +157 -0
  19. data/lib/arafa/errors.rb +23 -0
  20. data/lib/arafa/message.rb +17 -0
  21. data/lib/arafa/phone_number.rb +25 -0
  22. data/lib/arafa/providers/africas_talking.rb +116 -0
  23. data/lib/arafa/providers/base.rb +118 -0
  24. data/lib/arafa/providers/wasiliana.rb +98 -0
  25. data/lib/arafa/schemas/airtime_request_schema.rb +28 -0
  26. data/lib/arafa/schemas/delivery_report_schema.rb +15 -0
  27. data/lib/arafa/schemas/ussd_callback_schema.rb +17 -0
  28. data/lib/arafa/send_result.rb +37 -0
  29. data/lib/arafa/types.rb +21 -0
  30. data/lib/arafa/ussd/africas_talking.rb +26 -0
  31. data/lib/arafa/ussd/request.rb +41 -0
  32. data/lib/arafa/ussd/response.rb +25 -0
  33. data/lib/arafa/ussd/wasiliana.rb +26 -0
  34. data/lib/arafa/version.rb +5 -0
  35. data/lib/arafa/whatsapp/africas_talking.rb +174 -0
  36. data/lib/arafa/whatsapp/message_body.rb +146 -0
  37. data/lib/arafa.rb +68 -0
  38. data/sig/arafa.rbs +4 -0
  39. 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)
@@ -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
@@ -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