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,610 @@
|
|
|
1
|
+
# Arafa Implementation Plan
|
|
2
|
+
|
|
3
|
+
`arafa` is a Ruby gem giving a single, uniform interface to multiple SMS/USSD/Airtime
|
|
4
|
+
gateways used in Kenya. This document is the reference for implementing the gem,
|
|
5
|
+
starting with two providers: **Africa's Talking** and **Wasiliana**. Additional
|
|
6
|
+
providers plug into the same interface later without changing calling code.
|
|
7
|
+
|
|
8
|
+
Africa's Talking exposes more than SMS/USSD — §1.10–§1.13 cover its Airtime, Voice,
|
|
9
|
+
Mobile Data, and WhatsApp APIs, since Wasiliana only offers SMS/USSD/Airtime and this gem
|
|
10
|
+
otherwise undersells what AT alone can do.
|
|
11
|
+
|
|
12
|
+
Target usage:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
Arafa.configure do |config|
|
|
16
|
+
config.wasiliana.api_key = ENV["WASILIANA_API_KEY"]
|
|
17
|
+
config.africas_talking.api_key = ENV["AT_API_KEY"]
|
|
18
|
+
config.africas_talking.username = ENV["AT_USERNAME"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
provider = Arafa::Wasiliana.new(text: "Hello", to: "0728833100")
|
|
22
|
+
result = provider.send
|
|
23
|
+
result.success? # => true
|
|
24
|
+
result.value!.message_id
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 1. Provider API reference
|
|
28
|
+
|
|
29
|
+
These fields were captured directly from each provider's live documentation
|
|
30
|
+
(`developers.africastalking.com`, `docs.wasiliana.ke`) — not guessed.
|
|
31
|
+
|
|
32
|
+
### 1.1 Africa's Talking — Bulk SMS
|
|
33
|
+
|
|
34
|
+
| | |
|
|
35
|
+
|---|---|
|
|
36
|
+
| Endpoint (live) | `POST https://api.africastalking.com/version1/messaging/bulk` |
|
|
37
|
+
| Endpoint (sandbox) | `POST https://api.sandbox.africastalking.com/version1/messaging/bulk` — marked "coming soon" in AT's own docs, treat as unavailable for now |
|
|
38
|
+
| Headers | `apiKey: <API_KEY>`, `Content-Type: application/json`, `Accept: application/json` |
|
|
39
|
+
|
|
40
|
+
Request body (JSON):
|
|
41
|
+
|
|
42
|
+
| Field | Type | Required | Notes |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `username` | String | yes | AT application username |
|
|
45
|
+
| `phoneNumbers` | Array\<String\> | yes | E.164 numbers |
|
|
46
|
+
| `message` | String | yes | |
|
|
47
|
+
| `senderId` | String | yes | Registered short code or alphanumeric |
|
|
48
|
+
| `enqueue` | Integer | no | 1 = async queue (default), 0 = sync |
|
|
49
|
+
|
|
50
|
+
Response:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"SMSMessageData": {
|
|
55
|
+
"Message": "Sent to 1/1 Total Cost: KES 0.8000",
|
|
56
|
+
"Recipients": [
|
|
57
|
+
{"statusCode": 101, "number": "+254711XXXYYY", "status": "Success", "cost": "KES 0.8000", "messageId": "ATPid_SampleTxnId123"}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`statusCode` values: `100` Processed, `101` Sent, `102` Queued (all treated as success);
|
|
64
|
+
`401` RiskHold, `402` InvalidSenderId, `403` InvalidPhoneNumber, `404` UnsupportedNumberType,
|
|
65
|
+
`405` InsufficientBalance, `406` UserInBlacklist, `407` CouldNotRoute, `409` DoNotDisturbRejection,
|
|
66
|
+
`500` InternalServerError, `501` GatewayError, `502` RejectedByGateway (all treated as
|
|
67
|
+
per-recipient failures — the HTTP call itself can still return 200 with some recipients
|
|
68
|
+
failed, so failures must be read out of `Recipients`, not just the HTTP status).
|
|
69
|
+
|
|
70
|
+
### 1.2 Africa's Talking — USSD (inbound only)
|
|
71
|
+
|
|
72
|
+
AT does not expose an outbound "send USSD" call. Instead:
|
|
73
|
+
|
|
74
|
+
1. You register a service code and a callback URL with AT.
|
|
75
|
+
2. AT `POST`s to your callback URL as `application/x-www-form-urlencoded` with:
|
|
76
|
+
`sessionId`, `phoneNumber`, `serviceCode`, `text`, `networkCode`.
|
|
77
|
+
3. You must respond within 10 seconds with `Content-Type: text/plain`, body prefixed
|
|
78
|
+
`CON ` (session continues, more input expected) or `END ` (session terminates).
|
|
79
|
+
|
|
80
|
+
### 1.3 Wasiliana — Authentication
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| Header | `apiKey: <API_KEY>` (obtained from the Wasiliana account dashboard) |
|
|
85
|
+
| Content type | `application/json` for all requests |
|
|
86
|
+
|
|
87
|
+
### 1.4 Wasiliana — SMS send
|
|
88
|
+
|
|
89
|
+
Same endpoint is documented under both "Sender ID" and "ShortCode" pages — it is one
|
|
90
|
+
unified send endpoint; `from` carries either a registered Sender ID or a ShortCode.
|
|
91
|
+
|
|
92
|
+
| | |
|
|
93
|
+
|---|---|
|
|
94
|
+
| Endpoint | `POST https://api.wasiliana.com/api/v1/send/sms` |
|
|
95
|
+
| Headers | `Content-Type: application/json`, `apiKey: <API_KEY>` |
|
|
96
|
+
|
|
97
|
+
Request body:
|
|
98
|
+
|
|
99
|
+
| Field | Type | Required | Notes |
|
|
100
|
+
|---|---|---|---|
|
|
101
|
+
| `recipients` | Array\<String\> | yes | Phone numbers |
|
|
102
|
+
| `from` | String | yes | Sender ID or ShortCode |
|
|
103
|
+
| `message` | String | yes | |
|
|
104
|
+
| `linkid` | String | no | Returned on inbound-triggered replies |
|
|
105
|
+
| `message_uid` | String | no | Your own idempotency/tracking id, echoed to delivery-report callback |
|
|
106
|
+
| `is_otp` | String/Bool | no | Marks message as an OTP type |
|
|
107
|
+
|
|
108
|
+
Response (success):
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{"status": "success", "data": "Successfully Dispatched the sms to process"}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Failure shape is not documented beyond the HTTP status code table (see 1.7) — implement
|
|
115
|
+
defensively: any non-2xx, or a 2xx body where `status != "success"`, is a failure.
|
|
116
|
+
|
|
117
|
+
### 1.5 Wasiliana — USSD (inbound only)
|
|
118
|
+
|
|
119
|
+
Same inbound-webhook model as AT. Wasiliana `POST`s JSON to your registered callback:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{"sessionId": "453720679", "phoneNumber": "25479XXX2541", "serviceCode": "665", "text": "3", "ussdId": "158"}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Not documented**: the expected response contract (whether it follows the same
|
|
126
|
+
`CON`/`END` text convention as AT, a different prefix, or a JSON response). Treat as
|
|
127
|
+
`CON`/`END` by default (industry-standard convention shared by AT-compatible gateways)
|
|
128
|
+
but flag this explicitly to be verified against a live Wasiliana sandbox/service before
|
|
129
|
+
relying on it in production.
|
|
130
|
+
|
|
131
|
+
### 1.6 Wasiliana — Airtime
|
|
132
|
+
|
|
133
|
+
| | |
|
|
134
|
+
|---|---|
|
|
135
|
+
| Endpoint | `POST https://api.wasiliana.com/api/v1/airtime/request` |
|
|
136
|
+
| Headers | `Content-Type: application/json`, `apiKey: <API_KEY>` |
|
|
137
|
+
|
|
138
|
+
| Field | Type | Required |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `phone_number` | String (docs show it array-wrapped — verify empirically) | yes |
|
|
141
|
+
| `currency_code` | String, e.g. `"KES"` | yes |
|
|
142
|
+
| `amount` | String/Number | yes |
|
|
143
|
+
| `callback` | String (URL) | yes |
|
|
144
|
+
| `airtime_uid` | String | no |
|
|
145
|
+
|
|
146
|
+
Response: `{"status": "success", "message": "Request has been sent successfully"}`.
|
|
147
|
+
|
|
148
|
+
### 1.7 Wasiliana — Delivery reports (inbound callback)
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{"phone": ["25472x xxx xxx"], "correlator": "message_15883801465eacc1f2643a9", "deliveryStatus": "0"}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 1.8 Wasiliana — HTTP error codes
|
|
155
|
+
|
|
156
|
+
| Code | Meaning |
|
|
157
|
+
|---|---|
|
|
158
|
+
| 400 | Bad Request — invalid request |
|
|
159
|
+
| 401 | Unauthorized — wrong API key |
|
|
160
|
+
| 403 | Forbidden — escalated access |
|
|
161
|
+
| 404 | Not Found |
|
|
162
|
+
| 405 | Method Not Allowed |
|
|
163
|
+
| 406 | Not Acceptable — non-JSON format requested |
|
|
164
|
+
| 429 | Too Many Requests |
|
|
165
|
+
| 500 | Internal Server Error |
|
|
166
|
+
| 503 | Service Unavailable |
|
|
167
|
+
|
|
168
|
+
Error body shape: `{"message": "...", "error": "...", "code": "401"}`.
|
|
169
|
+
|
|
170
|
+
### 1.9 Phone number normalization
|
|
171
|
+
|
|
172
|
+
Neither provider documents accepting local Kenyan formats (`07...`, `01...`). The gem
|
|
173
|
+
must normalize `to:`/`recipients` before sending: `07XXXXXXXX` / `01XXXXXXXX` → `2547XXXXXXXX`
|
|
174
|
+
/ `2541XXXXXXXX`; `+254...` → `254...` (strip leading `+`); reject anything that doesn't
|
|
175
|
+
match a valid Kenyan MSISDN after normalization.
|
|
176
|
+
|
|
177
|
+
### 1.10 Africa's Talking — Airtime
|
|
178
|
+
|
|
179
|
+
Contrary to §8's original open item, AT does document an outbound Airtime API — implement
|
|
180
|
+
it alongside Wasiliana's.
|
|
181
|
+
|
|
182
|
+
| | |
|
|
183
|
+
|---|---|
|
|
184
|
+
| Endpoint (live) | `POST https://api.africastalking.com/version1/airtime/send` |
|
|
185
|
+
| Endpoint (sandbox) | `POST https://api.sandbox.africastalking.com/version1/airtime/send` |
|
|
186
|
+
| Headers | `apiKey`, `Content-Type: application/json`, `Accept: application/json`, optional `Idempotency-Key` |
|
|
187
|
+
|
|
188
|
+
Request body:
|
|
189
|
+
|
|
190
|
+
| Field | Type | Required | Notes |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| `username` | String | yes | |
|
|
193
|
+
| `recipients` | Array\<Hash\> | yes | `[{"phoneNumber": "+254711XXXYYY", "amount": "KES 100.50"}]` — amount is `<3-letter currency> <decimal>` as one string |
|
|
194
|
+
| `maxNumRetry` | Integer | no | Retry window in hours, default 8, retried every 60s |
|
|
195
|
+
| `requestMetadata` | Hash\<String,String\> | no | Free-form string metadata echoed back |
|
|
196
|
+
|
|
197
|
+
Response: `numSent`, `totalAmount`, `totalDiscount`, `responses` (per-recipient `phoneNumber`,
|
|
198
|
+
`amount`, `discount`, `status` [`Sent`/`Failed`], `requestId` or `None`, `errorMessage` or
|
|
199
|
+
`None`), top-level `errorMessage` if the whole request was rejected. Max 1,000 recipients per
|
|
200
|
+
request. Duplicate requests within 5 minutes are rejected unless an `Idempotency-Key` header
|
|
201
|
+
is supplied (shared mechanism, also used by Mobile Data — see §1.12).
|
|
202
|
+
|
|
203
|
+
### 1.11 Africa's Talking — Voice
|
|
204
|
+
|
|
205
|
+
Outbound calling, unlike USSD this *does* have a send call, plus an inbound callback model
|
|
206
|
+
for handling the call once connected.
|
|
207
|
+
|
|
208
|
+
| | |
|
|
209
|
+
|---|---|
|
|
210
|
+
| Endpoint (live) | `POST https://voice.africastalking.com/call` |
|
|
211
|
+
| Headers | `apiKey`, `Content-Type: application/x-www-form-urlencoded` (or `multipart/form-data`), `Accept: application/json` |
|
|
212
|
+
|
|
213
|
+
Request body (form-encoded, not JSON — differs from every other AT product):
|
|
214
|
+
|
|
215
|
+
| Field | Type | Required | Notes |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| `username` | String | yes | |
|
|
218
|
+
| `from` | String | yes | AT phone number, `+XXXYYYYYY` |
|
|
219
|
+
| `to` | String | yes | Comma-separated recipient numbers |
|
|
220
|
+
| `clientRequestId` | String | no | Echoed to the events callback URL |
|
|
221
|
+
|
|
222
|
+
Response is XML (not JSON, another outlier): `entries` (list of `{phoneNumber, status,
|
|
223
|
+
sessionId}`, `status` one of `Queued`/`InvalidPhoneNumber`/`DestinationNotSupported`/
|
|
224
|
+
`InsufficientCredit`) and optional top-level `errorMessage`. Response header
|
|
225
|
+
`X-Current-Queue-Size` reports queue depth.
|
|
226
|
+
|
|
227
|
+
Inbound: AT calls the registered callback URL as the call progresses (call connected, digits
|
|
228
|
+
gathered, call ended); the callback response body is XML built from a small DSL of verbs
|
|
229
|
+
(`Say`, `Play`, `GetDigits`, `Dial`, `Record`, `Enqueue`/`Call Queue`, `Redirect`, `Reject`,
|
|
230
|
+
`Conference`) — same shape as the USSD callback model in §1.2 but XML-bodied rather than
|
|
231
|
+
`CON`/`END` text. Treat as a second callback-only surface, out of scope for the first pass
|
|
232
|
+
(flagged in §8) given its size; land Voice *sending* (`#send`-shaped, matches existing
|
|
233
|
+
providers) before the call-control DSL.
|
|
234
|
+
|
|
235
|
+
### 1.12 Africa's Talking — Mobile Data
|
|
236
|
+
|
|
237
|
+
| | |
|
|
238
|
+
|---|---|
|
|
239
|
+
| Endpoint (live) | `POST https://bundles.africastalking.com/mobile/data/request` |
|
|
240
|
+
| Endpoint (sandbox) | `POST https://bundles.sandbox.africastalking.com/mobile/data/request` |
|
|
241
|
+
| Headers | `apiKey`, `Content-Type: application/json`, `Accept: application/json`, optional `Idempotency-Key` |
|
|
242
|
+
|
|
243
|
+
Request body:
|
|
244
|
+
|
|
245
|
+
| Field | Type | Required | Notes |
|
|
246
|
+
|---|---|---|---|
|
|
247
|
+
| `username` | String | yes | |
|
|
248
|
+
| `productName` | String | yes | AT application product name |
|
|
249
|
+
| `recipients` | Array\<Hash\> | yes | `{phoneNumber, quantity, unit ("MB"/"GB"), validity ("Day"/"Week"/"BiWeek"/"Month"/"Quarterly"), metadata: Hash<String,String>}` |
|
|
250
|
+
|
|
251
|
+
Response: `entries` (per-recipient `phoneNumber`, `provider`, `status` [`Queued`], `transactionId`,
|
|
252
|
+
`value`). Same idempotency window/header as Airtime (§1.10).
|
|
253
|
+
|
|
254
|
+
### 1.13 Africa's Talking — WhatsApp
|
|
255
|
+
|
|
256
|
+
| | |
|
|
257
|
+
|---|---|
|
|
258
|
+
| Endpoint (live) | `POST https://chat.africastalking.com/whatsapp/message/send` |
|
|
259
|
+
| Endpoint (sandbox) | marked "coming soon" — production only for now, same caveat as §1.1's sandbox note |
|
|
260
|
+
| Headers | `apikey` (lowercase, unlike every other AT product's `apiKey`), `Content-Type: application/json` |
|
|
261
|
+
|
|
262
|
+
Request body: `username`, `waNumber` (the registered AT WhatsApp number), `phoneNumber`
|
|
263
|
+
(recipient), `body` — a polymorphic object depending on message type:
|
|
264
|
+
|
|
265
|
+
| Message type | `body` shape |
|
|
266
|
+
|---|---|
|
|
267
|
+
| Text | `{message}` |
|
|
268
|
+
| Media | `{mediaType: Image\|Video\|Audio\|Sticker\|Document\|Voice, url, caption}` |
|
|
269
|
+
| Template | `{templateId, headerValue, bodyValues: Array<String>}` |
|
|
270
|
+
| Interactive buttons | `{action: {buttons: [{id, title}]}, body: {text}, header: {text}}` |
|
|
271
|
+
| Interactive list | `{action: {button, sections: [{title, rows: [{id, title, description}], product_items}]}, body: {text}, header: {text}, footer: {text}}` |
|
|
272
|
+
|
|
273
|
+
Response: `status` (`SENT`/`DELIVERED`/`FAILED`/`READ`/`DELETED`), `phoneNumber`, `messageId`
|
|
274
|
+
(only present on success). Given the five message shapes, `body` is modeled as a
|
|
275
|
+
`Dry::Struct` per message kind (`MessageBody::Text`/`Media`/`Template`, plus
|
|
276
|
+
`MessageBody::Interactive::Buttons`/`List` for the two nested interactive shapes) rather
|
|
277
|
+
than one contract covering all fields as optional (mirrors how `dry-types` sum types are
|
|
278
|
+
already used for the Wasiliana airtime `phone_number` ambiguity in §5.2).
|
|
279
|
+
|
|
280
|
+
## 2. Gem dependencies
|
|
281
|
+
|
|
282
|
+
Runtime (`arafa.gemspec`):
|
|
283
|
+
|
|
284
|
+
| Gem | Purpose |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `faraday` (~> 2.0) | HTTP client |
|
|
287
|
+
| `faraday-retry` | Retry transient 5xx/429 responses |
|
|
288
|
+
| `dry-configurable` (~> 1.0) | `Arafa.configure` block, per-provider settings |
|
|
289
|
+
| `dry-initializer` (~> 3.0) | Keyword-arg constructors (`Wasiliana.new(text:, to:, from: nil)`) |
|
|
290
|
+
| `dry-types` (~> 1.8) | Leaf value types with coercion/constraints (`PhoneNumber`, `SenderId`, `MessageText`) |
|
|
291
|
+
| `dry-struct` (~> 1.0) | Typed value objects built from `dry-types` (`Message`, `SendResult`) |
|
|
292
|
+
| `dry-schema` (~> 1.14) | Structural coercion/validation of *raw* input hashes: inbound USSD/delivery-report webhook params, airtime request params |
|
|
293
|
+
| `dry-validation` (~> 1.11) | Per-provider `Contract`s applying business rules to the *outgoing* send/airtime payload before any HTTP call is made |
|
|
294
|
+
| `dry-monads` (~> 1.0) | `Success`/`Failure` results from `#send` instead of raising on expected provider errors |
|
|
295
|
+
| `ox` or stdlib `rexml` | Parse Voice's XML response body (§1.11) — only AT product that isn't JSON; stdlib `rexml` avoids a new dependency and Voice's response shape is small enough not to need a faster parser |
|
|
296
|
+
|
|
297
|
+
Dev (`Gemfile`, existing minitest/rubocop setup): add `webmock` for HTTP stubbing in tests,
|
|
298
|
+
and `vcr` for recorded-cassette integration tests (see §8).
|
|
299
|
+
|
|
300
|
+
## 3. File layout
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
lib/arafa.rb # requires everything, top-level module
|
|
304
|
+
lib/arafa/version.rb # existing
|
|
305
|
+
lib/arafa/configuration.rb # Dry::Configurable settings, nested per provider
|
|
306
|
+
lib/arafa/types.rb # Dry::Types module (PhoneNumber, SenderId, MessageText, ...)
|
|
307
|
+
lib/arafa/errors.rb # Error hierarchy
|
|
308
|
+
lib/arafa/phone_number.rb # Kenyan MSISDN normalizer (used by the PhoneNumber type's constructor)
|
|
309
|
+
lib/arafa/message.rb # Dry::Struct: to (Array<PhoneNumber>), text, from
|
|
310
|
+
lib/arafa/send_result.rb # Dry::Struct: normalized success/failure result
|
|
311
|
+
lib/arafa/schemas/ussd_callback_schema.rb # Dry::Schema::Params — shared shape for inbound USSD webhook params
|
|
312
|
+
lib/arafa/schemas/delivery_report_schema.rb # Dry::Schema::JSON — Wasiliana delivery-report callback body
|
|
313
|
+
lib/arafa/schemas/airtime_request_schema.rb # Dry::Schema::JSON — Wasiliana airtime request params
|
|
314
|
+
lib/arafa/contracts/base_contract.rb # Dry::Validation::Contract — shared to:/text: rules
|
|
315
|
+
lib/arafa/contracts/africas_talking_contract.rb # + senderId/username rules
|
|
316
|
+
lib/arafa/contracts/wasiliana_contract.rb # + from/is_otp rules
|
|
317
|
+
lib/arafa/contracts/airtime_contract.rb # amount/currency_code/callback URL rules
|
|
318
|
+
lib/arafa/providers/base.rb # dry-initializer ctor, Faraday builder, contract validation, #send template method
|
|
319
|
+
lib/arafa/providers/africas_talking.rb # Arafa::AfricasTalking < Base
|
|
320
|
+
lib/arafa/providers/wasiliana.rb # Arafa::Wasiliana < Base
|
|
321
|
+
lib/arafa/ussd/request.rb # Dry::Struct: normalized inbound USSD request
|
|
322
|
+
lib/arafa/ussd/response.rb # .continue(text) / .end(text) -> "CON "/"END " string
|
|
323
|
+
lib/arafa/ussd/africas_talking.rb # parses AT's form-encoded callback params via UssdCallbackSchema
|
|
324
|
+
lib/arafa/ussd/wasiliana.rb # parses Wasiliana's JSON callback params via UssdCallbackSchema
|
|
325
|
+
lib/arafa/airtime/wasiliana.rb # Arafa::Airtime::Wasiliana — airtime request wrapper
|
|
326
|
+
lib/arafa/airtime/africas_talking.rb # Arafa::Airtime::AfricasTalking per §1.10
|
|
327
|
+
lib/arafa/contracts/mobile_data_contract.rb # quantity/unit/validity rules, §1.12
|
|
328
|
+
lib/arafa/data/africas_talking.rb # Arafa::Data::AfricasTalking — mobile data send, §1.12
|
|
329
|
+
lib/arafa/voice/africas_talking.rb # Arafa::Voice::AfricasTalking — outbound call, §1.11 (send only, no call-control DSL yet)
|
|
330
|
+
lib/arafa/whatsapp/africas_talking.rb # Arafa::Whatsapp::AfricasTalking — text/media/template/interactive send, §1.13
|
|
331
|
+
lib/arafa/whatsapp/message_body.rb # Dry::Struct sum type covering all five body shapes, §1.13
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`Arafa::AfricasTalking` and `Arafa::Wasiliana` are the public entry points matching the
|
|
335
|
+
requested call shape; everything else (USSD parsing, airtime) lives under sub-namespaces
|
|
336
|
+
since they aren't the `#send` SMS flow.
|
|
337
|
+
|
|
338
|
+
## 4. Architecture
|
|
339
|
+
|
|
340
|
+
- **Configuration** — `Arafa.configure { |c| c.wasiliana.api_key = ...; c.africas_talking.api_key = ...; c.africas_talking.username = ... }` via `dry-configurable`, so provider instances don't need credentials passed on every call.
|
|
341
|
+
- **`Providers::Base`** — `dry-initializer` constructor accepting `text:`, `to:` (String or Array, run through `PhoneNumber` normalization), optional `from:` (falls back to configured default sender). Defines the template method:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
def send
|
|
345
|
+
response = connection.post(endpoint, build_payload, headers)
|
|
346
|
+
parse_response(response)
|
|
347
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
348
|
+
Dry::Monads::Failure(Arafa::NetworkError.new(e.message))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def self.send(**kwargs) = new(**kwargs).send
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
Subclasses implement `#endpoint`, `#headers`, `#build_payload`, `#parse_response`.
|
|
355
|
+
`#send` returns a `Dry::Monads::Result` — `Success(SendResult)` or `Failure(Arafa::Error subclass)`.
|
|
356
|
+
The class-level `.send(text:, to:)` shortcut (borrowed from `sms_kit`'s `.deliver`) avoids
|
|
357
|
+
the `new(...).send` two-step for the common one-off case.
|
|
358
|
+
|
|
359
|
+
- **Duck-typed messages** — `Base#initialize` accepts either the `text:`/`to:`/`from:`
|
|
360
|
+
keywords directly, or a single positional object responding to `#to_arafa` returning a
|
|
361
|
+
hash of the same keywords (mirrors `sms_kit`'s `#to_sms` convention) — lets a calling
|
|
362
|
+
app pass a domain object (e.g. `OtpMessage#to_arafa`) straight into any provider.
|
|
363
|
+
|
|
364
|
+
- **Provider registry for dynamic dispatch** — `Arafa.register(:wasiliana, Arafa::Wasiliana)`
|
|
365
|
+
(providers self-register on load) backing `Arafa.send(:wasiliana, text:, to:)`, so the
|
|
366
|
+
provider can be chosen at runtime from config/env rather than hardcoding the class name —
|
|
367
|
+
same idea as `sms_kit`'s `SmsKit::Delivery#deliver(provider, options)`.
|
|
368
|
+
|
|
369
|
+
- **Shared Faraday connection** — built once in `Base` with JSON request/response
|
|
370
|
+
middleware and `faraday-retry` (retries 429/5xx with backoff); timeouts pulled from
|
|
371
|
+
`Arafa.configure`; a `User-Agent: arafa/<VERSION>` header is set on every request
|
|
372
|
+
(borrowed from `sms_kit`'s `HTTP::USER_AGENT`, useful for providers' own request logs).
|
|
373
|
+
|
|
374
|
+
- **Logging** — `Arafa.logger` (configurable, defaults to `Logger.new($stdout)`, same
|
|
375
|
+
pattern as `sms_kit`'s `Logging` module) wired into the Faraday connection as a
|
|
376
|
+
`f.response :logger, Arafa.logger` middleware so every request/response is logged at
|
|
377
|
+
`debug`, without each adapter needing to log manually. API keys must be redacted from
|
|
378
|
+
logged headers.
|
|
379
|
+
|
|
380
|
+
- **Error hierarchy** — `Arafa::Error` (base) →
|
|
381
|
+
`Arafa::ValidationError` (payload failed a `dry-schema`/`dry-validation` check, no HTTP
|
|
382
|
+
call made) · `Arafa::AuthenticationError` (401) · `Arafa::InvalidRequestError` (400/403/406) ·
|
|
383
|
+
`Arafa::RateLimitError` (429) · `Arafa::ProviderServerError` (500/503) ·
|
|
384
|
+
`Arafa::NetworkError` (connection-level). Each adapter's `#parse_response` maps the
|
|
385
|
+
documented status codes/response bodies (§1.1, §1.8) onto these.
|
|
386
|
+
|
|
387
|
+
- **USSD** — no outbound HTTP call. `Ussd::Request.from_africas_talking(params)` /
|
|
388
|
+
`Ussd::Request.from_wasiliana(params)` run the raw webhook params through
|
|
389
|
+
`Schemas::UssdCallbackSchema` (§5) then normalize into one shape (`session_id`,
|
|
390
|
+
`phone_number`, `service_code`, `text`); `Ussd::Response.continue(text)` /
|
|
391
|
+
`Ussd::Response.end(text)` build the `CON `/`END `-prefixed plain-text reply. This is
|
|
392
|
+
documented clearly as a parse/build helper, not a network client, so it isn't confused
|
|
393
|
+
with `#send`.
|
|
394
|
+
|
|
395
|
+
- **Airtime** — implemented for both Wasiliana (§1.6) and Africa's Talking (§1.10); both
|
|
396
|
+
ride the same `Airtime::Base` shape (`recipients`, per-recipient `amount` string,
|
|
397
|
+
`Dry::Monads::Result` return) even though the wire formats differ, same pattern as
|
|
398
|
+
`Providers::Base` unifying SMS.
|
|
399
|
+
|
|
400
|
+
- **Mobile Data** (`Data::AfricasTalking`, §1.12) and **WhatsApp**
|
|
401
|
+
(`Whatsapp::AfricasTalking`, §1.13) are AT-only for now — Wasiliana doesn't offer either
|
|
402
|
+
product. They don't extend `Providers::Base` (different payload/response shapes entirely)
|
|
403
|
+
but follow the same conventions: `dry-initializer` constructor, contract-then-HTTP-call
|
|
404
|
+
`#send`, `Dry::Monads::Result` return, shared Faraday connection/logger/error hierarchy.
|
|
405
|
+
|
|
406
|
+
- **Voice** (`Voice::AfricasTalking`, §1.11) ships *send* only in this pass — `POST
|
|
407
|
+
/call` is a `#send`-shaped call that fits the same `Result`-returning pattern, but it is
|
|
408
|
+
form-urlencoded and XML-responding rather than JSON like every other AT product, so its
|
|
409
|
+
adapter needs its own request/response encoding (no reuse of the JSON Faraday builder
|
|
410
|
+
used elsewhere) and its own XML parser for the `entries`/`errorMessage` response. The
|
|
411
|
+
inbound call-control callback (the `Say`/`Play`/`GetDigits`/`Dial`/`Record`/`Enqueue`/
|
|
412
|
+
`Redirect`/`Reject`/`Conference` XML-response DSL) is out of scope for this pass — it's
|
|
413
|
+
effectively a second product (a full IVR builder) and deserves its own implementation
|
|
414
|
+
plan once outbound calling and the rest of this plan have shipped.
|
|
415
|
+
|
|
416
|
+
## 5. Validation with `dry-types`, `dry-schema`, `dry-validation`
|
|
417
|
+
|
|
418
|
+
These three libraries cover three different jobs and are used in that order, each one
|
|
419
|
+
building on the previous — they are not interchangeable:
|
|
420
|
+
|
|
421
|
+
| Library | Job | Used for |
|
|
422
|
+
|---|---|---|
|
|
423
|
+
| `dry-types` | Define a single **leaf value's** coercion + constraint | `PhoneNumber`, `SenderId`, `MessageText`, `MsisdnList` |
|
|
424
|
+
| `dry-schema` | Validate the **shape** of a raw hash (presence, type-coerce every key) | Inbound webhook params (USSD callbacks, delivery reports) and the Wasiliana airtime request, where input arrives as an untyped `Hash` (HTTP params/JSON body) |
|
|
425
|
+
| `dry-validation` | Layer **business rules** on top of a schema, with access to config/state | The outgoing SMS/airtime payload, right before a provider adapter makes its HTTP call |
|
|
426
|
+
|
|
427
|
+
### 5.1 `dry-types` — leaf types (`lib/arafa/types.rb`)
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
module Arafa
|
|
431
|
+
module Types
|
|
432
|
+
include Dry.Types()
|
|
433
|
+
|
|
434
|
+
PhoneNumber = Types::String
|
|
435
|
+
.constructor { |value| Arafa::PhoneNumber.normalize(value) }
|
|
436
|
+
.constrained(format: /\A254(7|1)\d{8}\z/)
|
|
437
|
+
|
|
438
|
+
MsisdnList = Types::Array.of(PhoneNumber).constrained(min_size: 1)
|
|
439
|
+
|
|
440
|
+
SenderId = Types::String.constrained(min_size: 1, max_size: 11)
|
|
441
|
+
MessageText = Types::String.constrained(min_size: 1, max_size: 918) # 6 concatenated SMS parts
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`Message`/`SendResult` (`dry-struct`) attributes are declared against these types, so a
|
|
447
|
+
malformed phone number or empty message raises `Dry::Struct::Error` the moment the value
|
|
448
|
+
object is built — this is the same coercion mechanism already in §3/§4, just spelled out.
|
|
449
|
+
|
|
450
|
+
### 5.2 `dry-schema` — raw webhook/param shapes (`lib/arafa/schemas/*.rb`)
|
|
451
|
+
|
|
452
|
+
Both providers POST inbound webhooks as loosely-typed params (form-encoded for AT USSD,
|
|
453
|
+
JSON for Wasiliana USSD/delivery-reports) — `dry-schema` coerces and validates that shape
|
|
454
|
+
before it's touched by domain code:
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
module Arafa
|
|
458
|
+
module Schemas
|
|
459
|
+
UssdCallbackSchema = Dry::Schema.Params do
|
|
460
|
+
required(:session_id).filled(:string)
|
|
461
|
+
required(:phone_number).filled(:string)
|
|
462
|
+
required(:service_code).filled(:string)
|
|
463
|
+
required(:text).maybe(:string) # empty text on the very first USSD screen
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Since AT and Wasiliana use different key names (`sessionId`/`ussdId` vs `sessionId`/
|
|
470
|
+
`ussdId` — actually identical here, but `networkCode` only exists for AT), each
|
|
471
|
+
`Ussd::<Provider>.parse(params)` remaps provider-specific keys to the schema's canonical
|
|
472
|
+
keys first, then calls `UssdCallbackSchema.call(remapped_params)` and reads
|
|
473
|
+
`result.errors.to_h` / `result.to_h`. Same pattern for `DeliveryReportSchema` (§1.7) and
|
|
474
|
+
`AirtimeRequestSchema` (§1.6, including the array-vs-string `phone_number` ambiguity —
|
|
475
|
+
the schema accepts either shape via a sum type and normalizes to an array).
|
|
476
|
+
|
|
477
|
+
### 5.3 `dry-validation` — per-provider send contracts (`lib/arafa/contracts/*.rb`)
|
|
478
|
+
|
|
479
|
+
A `Contract` wraps a `params` schema (structural checks) with `rule` blocks (business
|
|
480
|
+
checks that need config/state, e.g. "is a sender ID configured at all"). Adapters call
|
|
481
|
+
their contract **before** touching the network, so bad input never burns an HTTP call:
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
module Arafa
|
|
485
|
+
module Contracts
|
|
486
|
+
class BaseContract < Dry::Validation::Contract
|
|
487
|
+
params do
|
|
488
|
+
required(:to).array(:string)
|
|
489
|
+
required(:text).filled(:string)
|
|
490
|
+
optional(:from).filled(:string)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
rule(:to) do
|
|
494
|
+
key.failure("must include at least one recipient") if values[:to].empty?
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
class AfricasTalkingContract < BaseContract
|
|
499
|
+
option :username, default: -> { Arafa.config.africas_talking.username }
|
|
500
|
+
|
|
501
|
+
rule do
|
|
502
|
+
key(:from).failure("senderId is required for Africa's Talking") if values[:from].to_s.empty?
|
|
503
|
+
base.failure("Africa's Talking username is not configured") if username.to_s.empty?
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
class WasilianaContract < BaseContract
|
|
508
|
+
rule(:text) do
|
|
509
|
+
key.failure("must be 918 characters or fewer") if values[:text].to_s.length > 918
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
`Providers::Base#send` calls `self.class::CONTRACT.new.call(to:, text:, from:)` first; on
|
|
517
|
+
failure it short-circuits with `Dry::Monads::Failure(Arafa::ValidationError.new(result.errors.to_h))`
|
|
518
|
+
— matching the `Dry::Monads::Result` return shape used for network-level failures, so
|
|
519
|
+
callers handle both the same way:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
result = Arafa::Wasiliana.new(text: "", to: "0712345678").send
|
|
523
|
+
result.failure? # => true, no HTTP request was ever sent
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
## 6. Implementation steps
|
|
527
|
+
|
|
528
|
+
1. Fill in gemspec `summary`/`description`/`homepage`/`source_code_uri` (currently `TODO`
|
|
529
|
+
placeholders — blocks clean `bundle install`); add the runtime dependencies from §2.
|
|
530
|
+
2. Core types/errors: `types.rb` (§5.1), `errors.rb` (incl. `Arafa::ValidationError`),
|
|
531
|
+
`phone_number.rb` (+ unit tests covering `07...`, `01...`, `+254...`, `254...`, invalid
|
|
532
|
+
input), `message.rb`, `send_result.rb`.
|
|
533
|
+
3. `configuration.rb` with nested `africas_talking`/`wasiliana` settings blocks.
|
|
534
|
+
4. Schemas (§5.2): `UssdCallbackSchema`, `DeliveryReportSchema`, `AirtimeRequestSchema`.
|
|
535
|
+
5. Contracts (§5.3): `BaseContract`, `AfricasTalkingContract`, `WasilianaContract`, `AirtimeContract`
|
|
536
|
+
(+ unit tests: each documented required/optional field, each business rule, valid/invalid cases).
|
|
537
|
+
6. `Providers::Base` per §4, wired to run its contract before building the HTTP request.
|
|
538
|
+
7. `Providers::AfricasTalking` per §1.1.
|
|
539
|
+
8. `Providers::Wasiliana` per §1.4.
|
|
540
|
+
9. `Ussd::Request`/`Ussd::Response` + per-provider parsers per §1.2/§1.5, built on `UssdCallbackSchema`.
|
|
541
|
+
10. `Airtime::Wasiliana` per §1.6, built on `AirtimeRequestSchema`/`AirtimeContract`.
|
|
542
|
+
11. Minitest coverage (stub HTTP with WebMock, fixtures built from the exact bodies in
|
|
543
|
+
§1): success + every documented failure status code for AT SMS and Wasiliana SMS;
|
|
544
|
+
`PhoneNumber` edge cases; contract validation failures short-circuiting before any
|
|
545
|
+
HTTP stub is hit; USSD parse/build round trips; the `.send` class shortcut,
|
|
546
|
+
`#to_arafa` duck-typing, and `Arafa.send(:provider, ...)` registry dispatch from §7.
|
|
547
|
+
Add VCR cassettes (§7) for one recorded success/failure pair per provider.
|
|
548
|
+
12. README: `Arafa::Wasiliana.new(text:, to:).send` example, `Arafa.configure` example,
|
|
549
|
+
a note that USSD is callback-only (no `#send`-style call), and one example of reading
|
|
550
|
+
a validation failure from `result.failure`.
|
|
551
|
+
13. `Airtime::AfricasTalking` per §1.10, sharing `Airtime::Base`/`AirtimeContract` shape
|
|
552
|
+
with the existing Wasiliana adapter; extend `AirtimeContract` for AT's
|
|
553
|
+
`"<currency> <amount>"` string format and 1,000-recipient cap.
|
|
554
|
+
14. `Data::AfricasTalking` per §1.12: `MobileDataContract` (quantity/unit/validity rules),
|
|
555
|
+
adapter, tests (success + per-recipient `Queued` status, `Idempotency-Key` header
|
|
556
|
+
passthrough).
|
|
557
|
+
15. `Whatsapp::AfricasTalking` per §1.13, all five message kinds (text, media, template,
|
|
558
|
+
interactive buttons, interactive list); `Whatsapp::MessageBody` sum type; tests per
|
|
559
|
+
message kind plus the `status`/`messageId` response parse.
|
|
560
|
+
16. `Voice::AfricasTalking` per §1.11, outbound `#send` only: form-urlencoded request
|
|
561
|
+
builder (distinct from the shared JSON Faraday connection), XML response parser,
|
|
562
|
+
`entries`/`errorMessage`/`X-Current-Queue-Size` mapped onto `SendResult`/error
|
|
563
|
+
hierarchy. Inbound call-control DSL explicitly deferred — do not start it in this pass.
|
|
564
|
+
17. README: add one example each for Airtime, Mobile Data, and WhatsApp sends, and a note
|
|
565
|
+
that Voice inbound call handling isn't implemented yet.
|
|
566
|
+
|
|
567
|
+
## 7. Prior art: `sms_kit` review
|
|
568
|
+
|
|
569
|
+
[`jamii-Technologies/sms_kit`](https://github.com/jamii-Technologies/sms_kit) is an older,
|
|
570
|
+
similarly-scoped Ruby gem (one interface over multiple SMS gateway adapters, config +
|
|
571
|
+
Faraday-based). It doesn't cover Kenyan providers or USSD/airtime, but several of its
|
|
572
|
+
patterns are directly reusable and are folded into §4 above:
|
|
573
|
+
|
|
574
|
+
| Idea from `sms_kit` | Where it lands in this plan |
|
|
575
|
+
|---|---|
|
|
576
|
+
| `Provider.deliver(options)` class-method shortcut, skipping `new(...).deliver` | `Base.send(**kwargs)` class method (§4) |
|
|
577
|
+
| `#to_sms` duck-typing so a domain object can be sent directly | `#to_arafa` duck-typing on `Base#initialize` (§4) |
|
|
578
|
+
| `SmsKit::Delivery#deliver(provider_symbol, options)` registry lookup | `Arafa.register` / `Arafa.send(:provider, ...)` registry (§4) |
|
|
579
|
+
| `HTTP::USER_AGENT` constant sent on every request | `User-Agent: arafa/<VERSION>` header on the shared Faraday connection (§4) |
|
|
580
|
+
| `SmsKit::Logging` configurable logger + Faraday `:logger` middleware | `Arafa.logger` + `f.response :logger, Arafa.logger` (§4) |
|
|
581
|
+
| VCR cassettes per provider (`test/fixtures/vcr_cassettes/<provider>/{success,failure}.yml`) alongside plain stub tests | Add `vcr` dev dependency (§2); one recorded cassette pair per provider as a belt-and-suspenders check against the WebMock-stubbed unit tests (step 9) |
|
|
582
|
+
| `Railtie` auto-wiring a Rails log file | Not adopted — out of scope until there's a Rails-integration request; note as a future extension only |
|
|
583
|
+
|
|
584
|
+
Not adopted: `sms_kit`'s open `Config::Store` (a `Hash` with `method_missing`, untyped) —
|
|
585
|
+
`dry-configurable` already covers this with proper typing/defaults, which is why it was
|
|
586
|
+
chosen over hand-rolling config.
|
|
587
|
+
|
|
588
|
+
## 8. Open items — needs verification against a live account before shipping
|
|
589
|
+
|
|
590
|
+
- Wasiliana's USSD callback response contract (`CON`/`END` assumed, unconfirmed).
|
|
591
|
+
- Wasiliana airtime `phone_number` — single string vs. array (docs show it array-wrapped
|
|
592
|
+
inconsistently with the rest of their examples).
|
|
593
|
+
- Wasiliana failure response body shape for SMS send (only the generic HTTP error-code
|
|
594
|
+
table is documented, not a send-specific error payload).
|
|
595
|
+
- AT sandbox bulk-SMS endpoint is marked "coming soon" — only production is usable today.
|
|
596
|
+
- AT sandbox WhatsApp endpoint is likewise marked "coming soon" (§1.13) — production only.
|
|
597
|
+
- Gemspec `homepage`/`allowed_push_host`/`source_code_uri` need a real repo URL before
|
|
598
|
+
this can be published to RubyGems. *(Resolved — see `arafa.gemspec`, points at
|
|
599
|
+
`github.com/kamalogudah/arafa`.)*
|
|
600
|
+
- Voice's inbound call-control response DSL (§1.11) isn't scoped by this plan at all —
|
|
601
|
+
needs its own design pass covering the `Say`/`Play`/`GetDigits`/`Dial`/`Record`/
|
|
602
|
+
`Enqueue`/`Redirect`/`Reject`/`Conference` verbs before implementation starts.
|
|
603
|
+
## 9. Adding a third provider later
|
|
604
|
+
|
|
605
|
+
Implement `Providers::Base` subclass with `#endpoint`, `#headers`, `#build_payload`,
|
|
606
|
+
`#parse_response`; add a `dry-configurable` settings block for its credentials; a
|
|
607
|
+
`Contracts::<Provider>Contract < Contracts::BaseContract` for any provider-specific rules;
|
|
608
|
+
reuse `PhoneNumber`, `Message`, `SendResult`, and the shared error hierarchy unchanged. If
|
|
609
|
+
the provider has inbound USSD, add a `Ussd::Request.from_<provider>(params)` parser reusing
|
|
610
|
+
the existing `UssdCallbackSchema`/`Ussd::Request`/`Ussd::Response` shapes.
|