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,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schemas/ussd_callback_schema"
4
+
5
+ module Arafa
6
+ module Ussd
7
+ # Parses Africa's Talking' form-encoded USSD callback params (`sessionId`,
8
+ # `phoneNumber`, `serviceCode`, `text`, `networkCode`) into the shared
9
+ # `Schemas::UssdCallbackSchema` shape. `networkCode` has no equivalent in
10
+ # the canonical shape and is dropped.
11
+ module AfricasTalking
12
+ module_function
13
+
14
+ def parse(params)
15
+ params = params.transform_keys(&:to_s)
16
+
17
+ Schemas::UssdCallbackSchema.call(
18
+ session_id: params["sessionId"],
19
+ phone_number: params["phoneNumber"],
20
+ service_code: params["serviceCode"],
21
+ text: params["text"]
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/struct"
4
+
5
+ require_relative "../types"
6
+ require_relative "../errors"
7
+ require_relative "africas_talking"
8
+ require_relative "wasiliana"
9
+
10
+ module Arafa
11
+ module Ussd
12
+ # Normalized inbound USSD request, built after a provider's raw webhook
13
+ # callback params have been remapped onto the shared
14
+ # `Schemas::UssdCallbackSchema` keys and validated. Parse/build only —
15
+ # no outbound HTTP call.
16
+ class Request < Dry::Struct
17
+ attribute :session_id, Types::String
18
+ attribute :phone_number, Types::String
19
+ attribute :service_code, Types::String
20
+ attribute :text, Types::String.optional
21
+
22
+ class << self
23
+ def from_africas_talking(params)
24
+ build(Ussd::AfricasTalking.parse(params))
25
+ end
26
+
27
+ def from_wasiliana(params)
28
+ build(Ussd::Wasiliana.parse(params))
29
+ end
30
+
31
+ private
32
+
33
+ def build(result)
34
+ raise Arafa::ValidationError, result.errors.to_h if result.failure?
35
+
36
+ new(**result.to_h)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arafa
4
+ module Ussd
5
+ # Builds the `CON `/`END `-prefixed plain-text reply body both providers'
6
+ # USSD callback contract expects within the 10-second response window
7
+ # (§1.2, §1.5). Not a network client — the adapter's own web framework is
8
+ # responsible for actually returning this body with `Content-Type:
9
+ # text/plain`.
10
+ module Response
11
+ module_function
12
+
13
+ # Session continues; the gateway will prompt the subscriber again and
14
+ # POST their next input as a new callback.
15
+ def continue(text)
16
+ "CON #{text}"
17
+ end
18
+
19
+ # Session terminates; this is the final screen shown to the subscriber.
20
+ def end(text)
21
+ "END #{text}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../schemas/ussd_callback_schema"
4
+
5
+ module Arafa
6
+ module Ussd
7
+ # Parses Wasiliana's JSON USSD callback params (`sessionId`,
8
+ # `phoneNumber`, `serviceCode`, `text`, `ussdId`) into the shared
9
+ # `Schemas::UssdCallbackSchema` shape. `ussdId` has no equivalent in the
10
+ # canonical shape and is dropped.
11
+ module Wasiliana
12
+ module_function
13
+
14
+ def parse(params)
15
+ params = params.transform_keys(&:to_s)
16
+
17
+ Schemas::UssdCallbackSchema.call(
18
+ session_id: params["sessionId"],
19
+ phone_number: params["phoneNumber"],
20
+ service_code: params["serviceCode"],
21
+ text: params["text"]
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arafa
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+ require "dry/monads"
5
+ require "dry/validation"
6
+
7
+ require_relative "../types"
8
+ require_relative "../errors"
9
+ require_relative "../send_result"
10
+ require_relative "../providers/base"
11
+ require_relative "message_body"
12
+
13
+ module Arafa
14
+ module Contracts
15
+ # Structural + business rules for an outgoing WhatsApp send (§1.13).
16
+ # `body`'s own shape is already validated by `Whatsapp::MessageBody`
17
+ # (a `Dry::Struct` raises on construction), so this only covers the
18
+ # envelope fields shared by every message kind.
19
+ class WhatsappContract < Dry::Validation::Contract
20
+ option :username, default: -> { Arafa.config.africas_talking.username }
21
+ option :wa_number, default: -> { Arafa.config.africas_talking.wa_number }
22
+
23
+ params do
24
+ required(:phone_number).filled(:string)
25
+ end
26
+
27
+ rule do
28
+ base.failure("Africa's Talking username is not configured") if username.to_s.empty?
29
+ base.failure("Africa's Talking WhatsApp number is not configured") if wa_number.to_s.empty?
30
+ end
31
+ end
32
+ end
33
+
34
+ module Whatsapp
35
+ # Africa's Talking WhatsApp adapter (§1.13). Unlike bulk SMS/airtime/
36
+ # mobile data, AT's WhatsApp API sends to one recipient per request
37
+ # (`phoneNumber` is a single string, not an array), so `SendResult` here
38
+ # always carries exactly one recipient. All five documented message
39
+ # kinds are supported — see `Whatsapp::MessageBody`.
40
+ class AfricasTalking
41
+ extend Dry::Initializer
42
+ include Dry::Monads[:result]
43
+
44
+ CONTRACT = Contracts::WhatsappContract
45
+ BODY_TYPE = Types.Instance(MessageBody::Text) | Types.Instance(MessageBody::Media) |
46
+ Types.Instance(MessageBody::Template) | Types.Instance(MessageBody::Interactive::Buttons) |
47
+ Types.Instance(MessageBody::Interactive::List)
48
+
49
+ option :phone_number, type: Types::PhoneNumber
50
+ option :body, type: BODY_TYPE
51
+ option :username, optional: true, type: Types::String.optional, default: -> { Arafa.config.africas_talking.username }
52
+ option :wa_number, optional: true, type: Types::String.optional, default: -> { Arafa.config.africas_talking.wa_number }
53
+ option :idempotency_key, optional: true, type: Types::String.optional, default: -> { nil }
54
+
55
+ class << self
56
+ def send(*args, **kwargs)
57
+ return super if args.any? || kwargs.empty?
58
+
59
+ new(**kwargs).send
60
+ end
61
+
62
+ # Reuses `Providers::Base`'s connection (retry, JSON, apiKey-redacting
63
+ # logger) rather than building a second one.
64
+ def connection
65
+ Providers::Base.connection
66
+ end
67
+ end
68
+
69
+ # Contract → HTTP call → response parsing. Returns a
70
+ # `Dry::Monads::Result`: `Success(SendResult)` or
71
+ # `Failure(Arafa::Error subclass)`.
72
+ def send(*args, **kwargs)
73
+ return super if args.any? || kwargs.any?
74
+
75
+ validation = validate_contract
76
+ return validation if validation.failure?
77
+
78
+ response = connection.post(endpoint, build_payload, headers)
79
+ parse_response(response)
80
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
81
+ Failure(Arafa::NetworkError.new(e.message))
82
+ end
83
+
84
+ def connection
85
+ self.class.connection
86
+ end
87
+
88
+ def endpoint
89
+ "https://chat.africastalking.com/whatsapp/message/send"
90
+ end
91
+
92
+ # AT's WhatsApp API is the one product that spells this header
93
+ # lowercase (`apikey`) instead of `apiKey`.
94
+ def headers
95
+ base = {
96
+ "apikey" => Arafa.config.africas_talking.api_key,
97
+ "Content-Type" => "application/json",
98
+ "Accept" => "application/json"
99
+ }
100
+ idempotency_key.nil? ? base : base.merge("Idempotency-Key" => idempotency_key)
101
+ end
102
+
103
+ def build_payload
104
+ { username: username, waNumber: wa_number, phoneNumber: phone_number, body: body.to_h }
105
+ end
106
+
107
+ def parse_response(response)
108
+ return build_failure(response) unless response.status.between?(200, 299)
109
+
110
+ body = response.body
111
+ status = body["status"]
112
+ return Failure(Arafa::InvalidRequestError.new(body["errorMessage"] || body.to_s)) if status.nil?
113
+
114
+ build_success(body, status)
115
+ end
116
+
117
+ private
118
+
119
+ def build_success(body, status)
120
+ recipients = [build_recipient(body, status)]
121
+ Success(SendResult.new(provider: :whatsapp_africas_talking, recipients: recipients, raw: body))
122
+ end
123
+
124
+ def build_recipient(body, status)
125
+ success = status != "FAILED"
126
+
127
+ SendResult::Recipient.new(
128
+ number: body["phoneNumber"] || phone_number,
129
+ success: success,
130
+ message_id: body["messageId"],
131
+ status: status,
132
+ error: success ? nil : (body["errorMessage"] || status)
133
+ )
134
+ end
135
+
136
+ ERROR_CLASSES_BY_STATUS = {
137
+ 401 => Arafa::AuthenticationError,
138
+ 400 => Arafa::InvalidRequestError,
139
+ 403 => Arafa::InvalidRequestError,
140
+ 406 => Arafa::InvalidRequestError,
141
+ 429 => Arafa::RateLimitError,
142
+ 500 => Arafa::ProviderServerError,
143
+ 501 => Arafa::ProviderServerError,
144
+ 502 => Arafa::ProviderServerError,
145
+ 503 => Arafa::ProviderServerError
146
+ }.freeze
147
+
148
+ def build_failure(response)
149
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.status, Arafa::InvalidRequestError)
150
+ Failure(error_class.new(error_message(response)))
151
+ end
152
+
153
+ def error_message(response)
154
+ body = response.body
155
+ return body.to_s unless body.is_a?(Hash)
156
+
157
+ body["errorMessage"] || body.to_s
158
+ end
159
+
160
+ def validate_contract
161
+ result = self.class::CONTRACT.new(username: username, wa_number: wa_number).call(contract_params)
162
+ return Failure(Arafa::ValidationError.new(result.errors.to_h)) if result.failure?
163
+
164
+ Success(result)
165
+ end
166
+
167
+ def contract_params
168
+ { phone_number: phone_number }
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ Arafa.register(:whatsapp_africas_talking, Arafa::Whatsapp::AfricasTalking)
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/struct"
4
+
5
+ require_relative "../types"
6
+
7
+ module Arafa
8
+ module Whatsapp
9
+ # Sum type over WhatsApp's polymorphic `body` field (§1.13). Each message
10
+ # kind gets its own `Dry::Struct` (coerced/validated the moment it's
11
+ # built) instead of one contract with every field across every kind
12
+ # marked optional — `Whatsapp::AfricasTalking#body` accepts any of these
13
+ # and calls `#to_h` to get the wire shape.
14
+ module MessageBody
15
+ # Plain-text WhatsApp message body: `{message}`.
16
+ class Text < Dry::Struct
17
+ attribute :message, Types::String.constrained(min_size: 1)
18
+
19
+ def to_h
20
+ { message: message }
21
+ end
22
+ end
23
+
24
+ # Media WhatsApp message body: `{mediaType, url, caption}`.
25
+ class Media < Dry::Struct
26
+ MEDIA_TYPES = %w[Image Video Audio Sticker Document Voice].freeze
27
+
28
+ attribute :media_type, Types::String.enum(*MEDIA_TYPES)
29
+ attribute :url, Types::String.constrained(min_size: 1)
30
+ attribute? :caption, Types::String.optional.default(nil)
31
+
32
+ def to_h
33
+ body = { mediaType: media_type, url: url }
34
+ body[:caption] = caption unless caption.nil?
35
+ body
36
+ end
37
+ end
38
+
39
+ # Template WhatsApp message body: `{templateId, headerValue, bodyValues}`.
40
+ class Template < Dry::Struct
41
+ attribute :template_id, Types::String.constrained(min_size: 1)
42
+ attribute :body_values, Types::Array.of(Types::String)
43
+ attribute? :header_value, Types::String.optional.default(nil)
44
+
45
+ def to_h
46
+ body = { templateId: template_id, bodyValues: body_values }
47
+ body[:headerValue] = header_value unless header_value.nil?
48
+ body
49
+ end
50
+ end
51
+
52
+ # Nested types shared by the two interactive message kinds.
53
+ module Interactive
54
+ # One tappable button: `{id, title}`.
55
+ class Button < Dry::Struct
56
+ attribute :id, Types::String.constrained(min_size: 1)
57
+ attribute :title, Types::String.constrained(min_size: 1)
58
+
59
+ def to_h
60
+ { id: id, title: title }
61
+ end
62
+ end
63
+
64
+ # One row of an interactive list section: `{id, title, description}`.
65
+ class Row < Dry::Struct
66
+ attribute :id, Types::String.constrained(min_size: 1)
67
+ attribute :title, Types::String.constrained(min_size: 1)
68
+ attribute? :description, Types::String.optional.default(nil)
69
+
70
+ def to_h
71
+ body = { id: id, title: title }
72
+ body[:description] = description unless description.nil?
73
+ body
74
+ end
75
+ end
76
+
77
+ # One section of an interactive list: `{title, rows, product_items}`.
78
+ class Section < Dry::Struct
79
+ attribute :title, Types::String.constrained(min_size: 1)
80
+ attribute :rows, Types::Array.of(Row)
81
+ attribute? :product_items, Types::Array.optional.default(nil)
82
+
83
+ def to_h
84
+ body = { title: title, rows: rows.map(&:to_h) }
85
+ body[:product_items] = product_items unless product_items.nil?
86
+ body
87
+ end
88
+ end
89
+
90
+ # Interactive buttons WhatsApp message body:
91
+ # `{action: {buttons}, body: {text}, header: {text}}`.
92
+ class Buttons < Dry::Struct
93
+ attribute :buttons, Types::Array.of(Button)
94
+ attribute :body_text, Types::String.constrained(min_size: 1)
95
+ attribute? :header_text, Types::String.optional.default(nil)
96
+
97
+ def to_h
98
+ body = { action: { buttons: buttons.map(&:to_h) }, body: { text: body_text } }
99
+ body[:header] = { text: header_text } unless header_text.nil?
100
+ body
101
+ end
102
+ end
103
+
104
+ # Interactive list WhatsApp message body:
105
+ # `{action: {button, sections}, body: {text}, header: {text}, footer: {text}}`.
106
+ class List < Dry::Struct
107
+ attribute :button, Types::String.constrained(min_size: 1)
108
+ attribute :sections, Types::Array.of(Section)
109
+ attribute :body_text, Types::String.constrained(min_size: 1)
110
+ attribute? :header_text, Types::String.optional.default(nil)
111
+ attribute? :footer_text, Types::String.optional.default(nil)
112
+
113
+ def to_h
114
+ body = { action: { button: button, sections: sections.map(&:to_h) }, body: { text: body_text } }
115
+ body[:header] = { text: header_text } unless header_text.nil?
116
+ body[:footer] = { text: footer_text } unless footer_text.nil?
117
+ body
118
+ end
119
+ end
120
+ end
121
+
122
+ class << self
123
+ def text(message:)
124
+ Text.new(message: message)
125
+ end
126
+
127
+ def media(media_type:, url:, caption: nil)
128
+ Media.new(media_type: media_type, url: url, caption: caption)
129
+ end
130
+
131
+ def template(template_id:, body_values:, header_value: nil)
132
+ Template.new(template_id: template_id, body_values: body_values, header_value: header_value)
133
+ end
134
+
135
+ def interactive_buttons(buttons:, body_text:, header_text: nil)
136
+ Interactive::Buttons.new(buttons: buttons, body_text: body_text, header_text: header_text)
137
+ end
138
+
139
+ def interactive_list(button:, sections:, body_text:, header_text: nil, footer_text: nil)
140
+ Interactive::List.new(button: button, sections: sections, body_text: body_text,
141
+ header_text: header_text, footer_text: footer_text)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
data/lib/arafa.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "arafa/version"
4
+ require_relative "arafa/errors"
5
+ require_relative "arafa/phone_number"
6
+ require_relative "arafa/types"
7
+ require_relative "arafa/message"
8
+ require_relative "arafa/send_result"
9
+ require_relative "arafa/configuration"
10
+ require_relative "arafa/contracts/base_contract"
11
+ require_relative "arafa/contracts/africas_talking_contract"
12
+ require_relative "arafa/contracts/wasiliana_contract"
13
+ require_relative "arafa/contracts/airtime_contract"
14
+ require_relative "arafa/contracts/africas_talking_airtime_contract"
15
+ require_relative "arafa/contracts/mobile_data_contract"
16
+ require_relative "arafa/schemas/ussd_callback_schema"
17
+ require_relative "arafa/schemas/delivery_report_schema"
18
+ require_relative "arafa/schemas/airtime_request_schema"
19
+ require_relative "arafa/providers/base"
20
+ require_relative "arafa/airtime/base"
21
+ require_relative "arafa/ussd/africas_talking"
22
+ require_relative "arafa/ussd/wasiliana"
23
+ require_relative "arafa/ussd/response"
24
+ require_relative "arafa/ussd/request"
25
+
26
+ # A single, uniform interface to Kenyan SMS/USSD/Airtime gateways.
27
+ module Arafa
28
+ class << self
29
+ def configure(&block)
30
+ Configuration.configure(&block)
31
+ end
32
+
33
+ def config
34
+ Configuration.config
35
+ end
36
+
37
+ def logger
38
+ config.logger
39
+ end
40
+
41
+ # Registers a provider class under a name so it can be dispatched to at
42
+ # runtime, e.g. `Arafa.register(:wasiliana, Arafa::Wasiliana)`.
43
+ def register(name, klass)
44
+ registry[name.to_sym] = klass
45
+ end
46
+
47
+ # Dispatches to a registered provider by name, e.g.
48
+ # `Arafa.send(:wasiliana, text: "Hello", to: "0712345678")`.
49
+ def send(name, **kwargs)
50
+ provider = registry.fetch(name.to_sym) { raise ArgumentError, "unknown provider: #{name.inspect}" }
51
+ provider.send(**kwargs)
52
+ end
53
+
54
+ private
55
+
56
+ def registry
57
+ @registry ||= {}
58
+ end
59
+ end
60
+ end
61
+
62
+ require_relative "arafa/providers/africas_talking"
63
+ require_relative "arafa/providers/wasiliana"
64
+ require_relative "arafa/airtime/wasiliana"
65
+ require_relative "arafa/airtime/africas_talking"
66
+ require_relative "arafa/data/africas_talking"
67
+ require_relative "arafa/whatsapp/message_body"
68
+ require_relative "arafa/whatsapp/africas_talking"
data/sig/arafa.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Arafa
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end