fameen-messaging 0.1.0__tar.gz
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.
- fameen_messaging-0.1.0/.gitignore +31 -0
- fameen_messaging-0.1.0/LICENSE +21 -0
- fameen_messaging-0.1.0/PKG-INFO +287 -0
- fameen_messaging-0.1.0/README.md +256 -0
- fameen_messaging-0.1.0/fameen_messaging/__init__.py +65 -0
- fameen_messaging-0.1.0/fameen_messaging/client.py +830 -0
- fameen_messaging-0.1.0/fameen_messaging/errors.py +69 -0
- fameen_messaging-0.1.0/fameen_messaging/py.typed +0 -0
- fameen_messaging-0.1.0/fameen_messaging/types.py +236 -0
- fameen_messaging-0.1.0/fameen_messaging/webhooks.py +99 -0
- fameen_messaging-0.1.0/pyproject.toml +48 -0
- fameen_messaging-0.1.0/tests/test_async_client.py +107 -0
- fameen_messaging-0.1.0/tests/test_client.py +495 -0
- fameen_messaging-0.1.0/tests/test_webhooks.py +98 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.eggs/
|
|
7
|
+
|
|
8
|
+
# Environnements virtuels
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
env/
|
|
12
|
+
|
|
13
|
+
# Build / distribution
|
|
14
|
+
build/
|
|
15
|
+
dist/
|
|
16
|
+
wheels/
|
|
17
|
+
|
|
18
|
+
# Outils
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
|
22
|
+
.coverage
|
|
23
|
+
coverage.xml
|
|
24
|
+
htmlcov/
|
|
25
|
+
.tox/
|
|
26
|
+
|
|
27
|
+
# IDE / OS
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fameen Groupe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fameen-messaging
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK Python officiel de l'API Fameen Messaging — SMS, WhatsApp et Email (envoi, suivi, webhooks).
|
|
5
|
+
Project-URL: Homepage, https://business.fameengroupe.com
|
|
6
|
+
Author: Fameen Groupe
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: email,fameen,messaging,notifications,sms,whatsapp
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Communications
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Fameen Messaging — SDK Python officiel
|
|
33
|
+
|
|
34
|
+
SDK Python officiel de l'API **Fameen Messaging** : envoyez des **SMS**, des messages **WhatsApp** et des **emails** depuis vos applications Python, suivez leur statut et recevez des webhooks signés.
|
|
35
|
+
|
|
36
|
+
- Paquet PyPI : `fameen-messaging` (module `fameen_messaging`) — version 0.1.0
|
|
37
|
+
- Python ≥ 3.9 — dépendance unique : [`httpx`](https://www.python-httpx.org/)
|
|
38
|
+
- Client synchrone (`FameenMessaging`) **et** asynchrone (`AsyncFameenMessaging`)
|
|
39
|
+
- Réessais automatiques (réseau, 429, 5xx idempotents), erreurs typées, vérification de webhooks en temps constant
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install fameen-messaging
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Démarrage rapide
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import os
|
|
51
|
+
from fameen_messaging import FameenMessaging
|
|
52
|
+
|
|
53
|
+
client = FameenMessaging(api_key=os.environ["FAMEEN_API_KEY"]) # clé "fam_…", jamais en dur
|
|
54
|
+
|
|
55
|
+
# SMS
|
|
56
|
+
message = client.sms.send("+224620000000", "Bonjour {prenom} !")
|
|
57
|
+
print(message.sid, message.status) # msg_… queued
|
|
58
|
+
|
|
59
|
+
# WhatsApp
|
|
60
|
+
client.whatsapp.send("+224620000000", "Votre commande est prête ✅")
|
|
61
|
+
|
|
62
|
+
# Email
|
|
63
|
+
client.email.send("client@exemple.com", "Corps du message", subject="Bienvenue !")
|
|
64
|
+
|
|
65
|
+
# Envoi unifié — canal explicite ou déduit (« @ » dans `to` → email, sinon SMS)
|
|
66
|
+
client.messages.create("client@exemple.com", "Bonjour !", subject="Info")
|
|
67
|
+
client.messages.create("+224620000000", "Bonjour !", channel="whatsapp")
|
|
68
|
+
|
|
69
|
+
# Suivi
|
|
70
|
+
statut = client.messages.get(message.sid)
|
|
71
|
+
page = client.messages.list(channel="sms", status="delivered", page=1, limit=30)
|
|
72
|
+
for m in page.data:
|
|
73
|
+
print(m.sid, m.status, m.delivered_at)
|
|
74
|
+
|
|
75
|
+
# Solde du portefeuille
|
|
76
|
+
solde = client.wallet.balance()
|
|
77
|
+
print(solde.sms_credits, solde.wa_credits, solde.email_credits, solde.billing.mode)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Le contenu accepte les variables de personnalisation `{prenom}`, `{nom}`, `{email}`, `{phone}` (max 5 000 caractères ; `subject` ≤ 255).
|
|
81
|
+
|
|
82
|
+
## Client asynchrone
|
|
83
|
+
|
|
84
|
+
Mêmes méthodes, en `async`, sur `httpx.AsyncClient` :
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
import asyncio
|
|
88
|
+
import os
|
|
89
|
+
from fameen_messaging import AsyncFameenMessaging
|
|
90
|
+
|
|
91
|
+
async def main():
|
|
92
|
+
async with AsyncFameenMessaging(api_key=os.environ["FAMEEN_API_KEY"]) as client:
|
|
93
|
+
message = await client.sms.send("+224620000000", "Bonjour !")
|
|
94
|
+
solde = await client.wallet.balance()
|
|
95
|
+
print(message.sid, solde.sms_credits)
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Idempotence
|
|
101
|
+
|
|
102
|
+
Passez une `idempotency_key` (en-tête `Idempotency-Key`, fenêtre de 24 h côté serveur) : tout réessai renvoie la réponse d'origine au lieu de créer un doublon — et cela rend les réessais automatiques du SDK **sûrs sur les POST** :
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
client.sms.send("+224620000000", "Commande confirmée", idempotency_key="commande-42-confirmation")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Erreurs
|
|
109
|
+
|
|
110
|
+
Toutes les erreurs du SDK héritent de `FameenError` :
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fameen_messaging import FameenAPIError, FameenConnectionError
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
client.sms.send("+224620000000", "Bonjour !")
|
|
117
|
+
except FameenAPIError as err:
|
|
118
|
+
print(err.status, err.code, err) # ex. 402 insufficient_credits Crédits insuffisants…
|
|
119
|
+
if err.code == "insufficient_credits":
|
|
120
|
+
... # rechargez le portefeuille
|
|
121
|
+
if err.retry_after is not None:
|
|
122
|
+
... # 429 : attendez err.retry_after secondes
|
|
123
|
+
except FameenConnectionError:
|
|
124
|
+
... # l'API n'a pas pu être jointe (DNS, timeout, coupure réseau)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Exception | Quand | Attributs |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `FameenError` | classe mère | `str(err)` = message |
|
|
130
|
+
| `FameenAPIError` | réponse HTTP non-2xx | `status`, `code`, `retry_after`, `rate_limit` |
|
|
131
|
+
| `FameenConnectionError` | API injoignable après épuisement des réessais | — |
|
|
132
|
+
| `WebhookVerificationError` | signature/corps de webhook invalide | — |
|
|
133
|
+
|
|
134
|
+
Codes stables (`err.code`) : `bad_request` (400), `unauthorized` (401), `insufficient_credits` (402), `channel_not_allowed` (403), `not_found` (404), `rate_limited` (429), `internal_error` (5xx), `unknown_error`. Si le corps d'erreur est illisible, le code est déduit du statut HTTP.
|
|
135
|
+
|
|
136
|
+
Une validation locale est faite **avant** tout appel réseau (lève `TypeError`) : `to` et `message` non vides ; `to` contenant « @ » refusé si le canal explicite n'est pas `email`.
|
|
137
|
+
|
|
138
|
+
## Réessais automatiques
|
|
139
|
+
|
|
140
|
+
`max_retries` = 2 par défaut ; backoff exponentiel `retry_base × 2^tentative + aléa(0..retry_base)` (base 0,5 s).
|
|
141
|
+
|
|
142
|
+
| Situation | Réessayé ? |
|
|
143
|
+
|---|---|
|
|
144
|
+
| Erreur réseau (DNS, timeout, coupure) | ✅ toutes méthodes |
|
|
145
|
+
| HTTP 429 | ✅ en respectant `Retry-After` (secondes) si présent |
|
|
146
|
+
| HTTP 5xx sur **GET** | ✅ |
|
|
147
|
+
| HTTP 5xx sur **POST avec `idempotency_key`** | ✅ |
|
|
148
|
+
| HTTP 5xx sur POST **sans** clé d'idempotence | ❌ (la requête a pu être traitée côté serveur) |
|
|
149
|
+
| HTTP 4xx (400, 401, 402, 403, 404…) | ❌ |
|
|
150
|
+
|
|
151
|
+
## Limite de débit
|
|
152
|
+
|
|
153
|
+
60 requêtes/minute/clé. Chaque réponse expose `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch secondes) ; le SDK les mémorise :
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
info = client.last_rate_limit # RateLimitInfo(limit=60, remaining=42, reset=1770000000) ou None
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Webhooks
|
|
160
|
+
|
|
161
|
+
L'API notifie votre `status_callback` à chaque changement de statut (`event` ∈ `queued | sent | delivered | failed`). Chaque requête est signée : **HMAC-SHA256 (hex) du corps brut** avec le secret `whsec_…` du compte, dans l'en-tête `X-Fameen-Signature` (en-tête informatif : `X-Fameen-Event`).
|
|
162
|
+
|
|
163
|
+
⚠️ Vérifiez toujours la signature sur le **corps brut** (les octets reçus, avant tout parsing JSON). La comparaison est faite en temps constant.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from fameen_messaging import verify_webhook_signature, construct_webhook_event, WebhookVerificationError
|
|
167
|
+
|
|
168
|
+
ok = verify_webhook_signature(corps_brut, signature, secret) # -> bool
|
|
169
|
+
event = construct_webhook_event(corps_brut, signature, secret) # -> WebhookEvent (vérifie PUIS parse)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Django
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
# views.py
|
|
176
|
+
import os
|
|
177
|
+
from django.http import HttpResponse
|
|
178
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
179
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
180
|
+
|
|
181
|
+
@csrf_exempt
|
|
182
|
+
def fameen_webhook(request):
|
|
183
|
+
try:
|
|
184
|
+
event = construct_webhook_event(
|
|
185
|
+
request.body, # corps brut : request.body, PAS request.POST
|
|
186
|
+
request.headers.get("X-Fameen-Signature"),
|
|
187
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
188
|
+
)
|
|
189
|
+
except WebhookVerificationError:
|
|
190
|
+
return HttpResponse(status=401)
|
|
191
|
+
|
|
192
|
+
if event.event == "delivered":
|
|
193
|
+
... # mettez à jour votre base avec event.sid
|
|
194
|
+
return HttpResponse(status=200)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Flask
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
import os
|
|
201
|
+
from flask import Flask, request
|
|
202
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
203
|
+
|
|
204
|
+
app = Flask(__name__)
|
|
205
|
+
|
|
206
|
+
@app.post("/webhooks/fameen")
|
|
207
|
+
def fameen_webhook():
|
|
208
|
+
try:
|
|
209
|
+
event = construct_webhook_event(
|
|
210
|
+
request.get_data(), # corps brut : get_data(), PAS request.json
|
|
211
|
+
request.headers.get("X-Fameen-Signature"),
|
|
212
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
213
|
+
)
|
|
214
|
+
except WebhookVerificationError:
|
|
215
|
+
return "", 401
|
|
216
|
+
|
|
217
|
+
print(event.sid, event.status)
|
|
218
|
+
return "", 200
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### FastAPI
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
import os
|
|
225
|
+
from fastapi import FastAPI, Request, Response
|
|
226
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
227
|
+
|
|
228
|
+
app = FastAPI()
|
|
229
|
+
|
|
230
|
+
@app.post("/webhooks/fameen")
|
|
231
|
+
async def fameen_webhook(request: Request):
|
|
232
|
+
payload = await request.body() # corps brut, avant tout parsing
|
|
233
|
+
try:
|
|
234
|
+
event = construct_webhook_event(
|
|
235
|
+
payload,
|
|
236
|
+
request.headers.get("x-fameen-signature"),
|
|
237
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
238
|
+
)
|
|
239
|
+
except WebhookVerificationError:
|
|
240
|
+
return Response(status_code=401)
|
|
241
|
+
|
|
242
|
+
print(event.sid, event.status)
|
|
243
|
+
return Response(status_code=200)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Configuration
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
FameenMessaging(
|
|
250
|
+
api_key="fam_…", # requis
|
|
251
|
+
base_url="https://business.fameengroupe.com/api/v1", # défaut ; « / » finaux retirés
|
|
252
|
+
timeout=30.0, # timeout httpx par tentative, en secondes
|
|
253
|
+
max_retries=2, # réessais automatiques
|
|
254
|
+
retry_base=0.5, # base du backoff exponentiel (s) — utile en test
|
|
255
|
+
transport=None, # transport httpx injectable (httpx.MockTransport en test)
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`AsyncFameenMessaging` accepte exactement les mêmes options.
|
|
260
|
+
|
|
261
|
+
## Modèles de données
|
|
262
|
+
|
|
263
|
+
Les retours sont des **dataclasses gelées**, construites de manière tolérante depuis le JSON (champs inconnus ignorés, champs manquants → `None`/`0`). Les clés camelCase deviennent snake_case (`externalId` → `external_id`) ; la clé `from` (mot réservé Python) devient `from_`.
|
|
264
|
+
|
|
265
|
+
| Dataclass | Renvoyée par |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `MessageResource` | `sms/whatsapp/email.send`, `messages.create`, `messages.get` |
|
|
268
|
+
| `MessageList` | `messages.list` (`.data` = liste de `MessageResource`) |
|
|
269
|
+
| `WalletBalance` (+ `WalletBilling`) | `wallet.balance` |
|
|
270
|
+
| `WebhookEvent` | `construct_webhook_event` |
|
|
271
|
+
| `RateLimitInfo` | `client.last_rate_limit`, `err.rate_limit` |
|
|
272
|
+
|
|
273
|
+
`messages.history()` est **déprécié** (lignes brutes, `DeprecationWarning`) — préférez `messages.list()`.
|
|
274
|
+
|
|
275
|
+
## Tests (développement)
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
python -m venv .venv
|
|
279
|
+
.venv/Scripts/python -m pip install httpx pytest pytest-asyncio # (Linux/macOS : .venv/bin/python)
|
|
280
|
+
.venv/Scripts/python -m pytest -q
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Les tests utilisent `httpx.MockTransport` : aucun appel réseau.
|
|
284
|
+
|
|
285
|
+
## Licence
|
|
286
|
+
|
|
287
|
+
MIT — © Fameen Groupe.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# Fameen Messaging — SDK Python officiel
|
|
2
|
+
|
|
3
|
+
SDK Python officiel de l'API **Fameen Messaging** : envoyez des **SMS**, des messages **WhatsApp** et des **emails** depuis vos applications Python, suivez leur statut et recevez des webhooks signés.
|
|
4
|
+
|
|
5
|
+
- Paquet PyPI : `fameen-messaging` (module `fameen_messaging`) — version 0.1.0
|
|
6
|
+
- Python ≥ 3.9 — dépendance unique : [`httpx`](https://www.python-httpx.org/)
|
|
7
|
+
- Client synchrone (`FameenMessaging`) **et** asynchrone (`AsyncFameenMessaging`)
|
|
8
|
+
- Réessais automatiques (réseau, 429, 5xx idempotents), erreurs typées, vérification de webhooks en temps constant
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install fameen-messaging
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Démarrage rapide
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import os
|
|
20
|
+
from fameen_messaging import FameenMessaging
|
|
21
|
+
|
|
22
|
+
client = FameenMessaging(api_key=os.environ["FAMEEN_API_KEY"]) # clé "fam_…", jamais en dur
|
|
23
|
+
|
|
24
|
+
# SMS
|
|
25
|
+
message = client.sms.send("+224620000000", "Bonjour {prenom} !")
|
|
26
|
+
print(message.sid, message.status) # msg_… queued
|
|
27
|
+
|
|
28
|
+
# WhatsApp
|
|
29
|
+
client.whatsapp.send("+224620000000", "Votre commande est prête ✅")
|
|
30
|
+
|
|
31
|
+
# Email
|
|
32
|
+
client.email.send("client@exemple.com", "Corps du message", subject="Bienvenue !")
|
|
33
|
+
|
|
34
|
+
# Envoi unifié — canal explicite ou déduit (« @ » dans `to` → email, sinon SMS)
|
|
35
|
+
client.messages.create("client@exemple.com", "Bonjour !", subject="Info")
|
|
36
|
+
client.messages.create("+224620000000", "Bonjour !", channel="whatsapp")
|
|
37
|
+
|
|
38
|
+
# Suivi
|
|
39
|
+
statut = client.messages.get(message.sid)
|
|
40
|
+
page = client.messages.list(channel="sms", status="delivered", page=1, limit=30)
|
|
41
|
+
for m in page.data:
|
|
42
|
+
print(m.sid, m.status, m.delivered_at)
|
|
43
|
+
|
|
44
|
+
# Solde du portefeuille
|
|
45
|
+
solde = client.wallet.balance()
|
|
46
|
+
print(solde.sms_credits, solde.wa_credits, solde.email_credits, solde.billing.mode)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Le contenu accepte les variables de personnalisation `{prenom}`, `{nom}`, `{email}`, `{phone}` (max 5 000 caractères ; `subject` ≤ 255).
|
|
50
|
+
|
|
51
|
+
## Client asynchrone
|
|
52
|
+
|
|
53
|
+
Mêmes méthodes, en `async`, sur `httpx.AsyncClient` :
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import asyncio
|
|
57
|
+
import os
|
|
58
|
+
from fameen_messaging import AsyncFameenMessaging
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
async with AsyncFameenMessaging(api_key=os.environ["FAMEEN_API_KEY"]) as client:
|
|
62
|
+
message = await client.sms.send("+224620000000", "Bonjour !")
|
|
63
|
+
solde = await client.wallet.balance()
|
|
64
|
+
print(message.sid, solde.sms_credits)
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Idempotence
|
|
70
|
+
|
|
71
|
+
Passez une `idempotency_key` (en-tête `Idempotency-Key`, fenêtre de 24 h côté serveur) : tout réessai renvoie la réponse d'origine au lieu de créer un doublon — et cela rend les réessais automatiques du SDK **sûrs sur les POST** :
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client.sms.send("+224620000000", "Commande confirmée", idempotency_key="commande-42-confirmation")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Erreurs
|
|
78
|
+
|
|
79
|
+
Toutes les erreurs du SDK héritent de `FameenError` :
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from fameen_messaging import FameenAPIError, FameenConnectionError
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
client.sms.send("+224620000000", "Bonjour !")
|
|
86
|
+
except FameenAPIError as err:
|
|
87
|
+
print(err.status, err.code, err) # ex. 402 insufficient_credits Crédits insuffisants…
|
|
88
|
+
if err.code == "insufficient_credits":
|
|
89
|
+
... # rechargez le portefeuille
|
|
90
|
+
if err.retry_after is not None:
|
|
91
|
+
... # 429 : attendez err.retry_after secondes
|
|
92
|
+
except FameenConnectionError:
|
|
93
|
+
... # l'API n'a pas pu être jointe (DNS, timeout, coupure réseau)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Exception | Quand | Attributs |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `FameenError` | classe mère | `str(err)` = message |
|
|
99
|
+
| `FameenAPIError` | réponse HTTP non-2xx | `status`, `code`, `retry_after`, `rate_limit` |
|
|
100
|
+
| `FameenConnectionError` | API injoignable après épuisement des réessais | — |
|
|
101
|
+
| `WebhookVerificationError` | signature/corps de webhook invalide | — |
|
|
102
|
+
|
|
103
|
+
Codes stables (`err.code`) : `bad_request` (400), `unauthorized` (401), `insufficient_credits` (402), `channel_not_allowed` (403), `not_found` (404), `rate_limited` (429), `internal_error` (5xx), `unknown_error`. Si le corps d'erreur est illisible, le code est déduit du statut HTTP.
|
|
104
|
+
|
|
105
|
+
Une validation locale est faite **avant** tout appel réseau (lève `TypeError`) : `to` et `message` non vides ; `to` contenant « @ » refusé si le canal explicite n'est pas `email`.
|
|
106
|
+
|
|
107
|
+
## Réessais automatiques
|
|
108
|
+
|
|
109
|
+
`max_retries` = 2 par défaut ; backoff exponentiel `retry_base × 2^tentative + aléa(0..retry_base)` (base 0,5 s).
|
|
110
|
+
|
|
111
|
+
| Situation | Réessayé ? |
|
|
112
|
+
|---|---|
|
|
113
|
+
| Erreur réseau (DNS, timeout, coupure) | ✅ toutes méthodes |
|
|
114
|
+
| HTTP 429 | ✅ en respectant `Retry-After` (secondes) si présent |
|
|
115
|
+
| HTTP 5xx sur **GET** | ✅ |
|
|
116
|
+
| HTTP 5xx sur **POST avec `idempotency_key`** | ✅ |
|
|
117
|
+
| HTTP 5xx sur POST **sans** clé d'idempotence | ❌ (la requête a pu être traitée côté serveur) |
|
|
118
|
+
| HTTP 4xx (400, 401, 402, 403, 404…) | ❌ |
|
|
119
|
+
|
|
120
|
+
## Limite de débit
|
|
121
|
+
|
|
122
|
+
60 requêtes/minute/clé. Chaque réponse expose `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (epoch secondes) ; le SDK les mémorise :
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
info = client.last_rate_limit # RateLimitInfo(limit=60, remaining=42, reset=1770000000) ou None
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Webhooks
|
|
129
|
+
|
|
130
|
+
L'API notifie votre `status_callback` à chaque changement de statut (`event` ∈ `queued | sent | delivered | failed`). Chaque requête est signée : **HMAC-SHA256 (hex) du corps brut** avec le secret `whsec_…` du compte, dans l'en-tête `X-Fameen-Signature` (en-tête informatif : `X-Fameen-Event`).
|
|
131
|
+
|
|
132
|
+
⚠️ Vérifiez toujours la signature sur le **corps brut** (les octets reçus, avant tout parsing JSON). La comparaison est faite en temps constant.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from fameen_messaging import verify_webhook_signature, construct_webhook_event, WebhookVerificationError
|
|
136
|
+
|
|
137
|
+
ok = verify_webhook_signature(corps_brut, signature, secret) # -> bool
|
|
138
|
+
event = construct_webhook_event(corps_brut, signature, secret) # -> WebhookEvent (vérifie PUIS parse)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Django
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
# views.py
|
|
145
|
+
import os
|
|
146
|
+
from django.http import HttpResponse
|
|
147
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
148
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
149
|
+
|
|
150
|
+
@csrf_exempt
|
|
151
|
+
def fameen_webhook(request):
|
|
152
|
+
try:
|
|
153
|
+
event = construct_webhook_event(
|
|
154
|
+
request.body, # corps brut : request.body, PAS request.POST
|
|
155
|
+
request.headers.get("X-Fameen-Signature"),
|
|
156
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
157
|
+
)
|
|
158
|
+
except WebhookVerificationError:
|
|
159
|
+
return HttpResponse(status=401)
|
|
160
|
+
|
|
161
|
+
if event.event == "delivered":
|
|
162
|
+
... # mettez à jour votre base avec event.sid
|
|
163
|
+
return HttpResponse(status=200)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Flask
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import os
|
|
170
|
+
from flask import Flask, request
|
|
171
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
172
|
+
|
|
173
|
+
app = Flask(__name__)
|
|
174
|
+
|
|
175
|
+
@app.post("/webhooks/fameen")
|
|
176
|
+
def fameen_webhook():
|
|
177
|
+
try:
|
|
178
|
+
event = construct_webhook_event(
|
|
179
|
+
request.get_data(), # corps brut : get_data(), PAS request.json
|
|
180
|
+
request.headers.get("X-Fameen-Signature"),
|
|
181
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
182
|
+
)
|
|
183
|
+
except WebhookVerificationError:
|
|
184
|
+
return "", 401
|
|
185
|
+
|
|
186
|
+
print(event.sid, event.status)
|
|
187
|
+
return "", 200
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### FastAPI
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
import os
|
|
194
|
+
from fastapi import FastAPI, Request, Response
|
|
195
|
+
from fameen_messaging import construct_webhook_event, WebhookVerificationError
|
|
196
|
+
|
|
197
|
+
app = FastAPI()
|
|
198
|
+
|
|
199
|
+
@app.post("/webhooks/fameen")
|
|
200
|
+
async def fameen_webhook(request: Request):
|
|
201
|
+
payload = await request.body() # corps brut, avant tout parsing
|
|
202
|
+
try:
|
|
203
|
+
event = construct_webhook_event(
|
|
204
|
+
payload,
|
|
205
|
+
request.headers.get("x-fameen-signature"),
|
|
206
|
+
os.environ["FAMEEN_WEBHOOK_SECRET"],
|
|
207
|
+
)
|
|
208
|
+
except WebhookVerificationError:
|
|
209
|
+
return Response(status_code=401)
|
|
210
|
+
|
|
211
|
+
print(event.sid, event.status)
|
|
212
|
+
return Response(status_code=200)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Configuration
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
FameenMessaging(
|
|
219
|
+
api_key="fam_…", # requis
|
|
220
|
+
base_url="https://business.fameengroupe.com/api/v1", # défaut ; « / » finaux retirés
|
|
221
|
+
timeout=30.0, # timeout httpx par tentative, en secondes
|
|
222
|
+
max_retries=2, # réessais automatiques
|
|
223
|
+
retry_base=0.5, # base du backoff exponentiel (s) — utile en test
|
|
224
|
+
transport=None, # transport httpx injectable (httpx.MockTransport en test)
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`AsyncFameenMessaging` accepte exactement les mêmes options.
|
|
229
|
+
|
|
230
|
+
## Modèles de données
|
|
231
|
+
|
|
232
|
+
Les retours sont des **dataclasses gelées**, construites de manière tolérante depuis le JSON (champs inconnus ignorés, champs manquants → `None`/`0`). Les clés camelCase deviennent snake_case (`externalId` → `external_id`) ; la clé `from` (mot réservé Python) devient `from_`.
|
|
233
|
+
|
|
234
|
+
| Dataclass | Renvoyée par |
|
|
235
|
+
|---|---|
|
|
236
|
+
| `MessageResource` | `sms/whatsapp/email.send`, `messages.create`, `messages.get` |
|
|
237
|
+
| `MessageList` | `messages.list` (`.data` = liste de `MessageResource`) |
|
|
238
|
+
| `WalletBalance` (+ `WalletBilling`) | `wallet.balance` |
|
|
239
|
+
| `WebhookEvent` | `construct_webhook_event` |
|
|
240
|
+
| `RateLimitInfo` | `client.last_rate_limit`, `err.rate_limit` |
|
|
241
|
+
|
|
242
|
+
`messages.history()` est **déprécié** (lignes brutes, `DeprecationWarning`) — préférez `messages.list()`.
|
|
243
|
+
|
|
244
|
+
## Tests (développement)
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
python -m venv .venv
|
|
248
|
+
.venv/Scripts/python -m pip install httpx pytest pytest-asyncio # (Linux/macOS : .venv/bin/python)
|
|
249
|
+
.venv/Scripts/python -m pytest -q
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Les tests utilisent `httpx.MockTransport` : aucun appel réseau.
|
|
253
|
+
|
|
254
|
+
## Licence
|
|
255
|
+
|
|
256
|
+
MIT — © Fameen Groupe.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SDK Python officiel de l'API Fameen Messaging (SMS, WhatsApp, Email).
|
|
2
|
+
|
|
3
|
+
Exemple minimal::
|
|
4
|
+
|
|
5
|
+
from fameen_messaging import FameenMessaging
|
|
6
|
+
|
|
7
|
+
client = FameenMessaging(api_key="fam_…")
|
|
8
|
+
message = client.sms.send("+224620000000", "Bonjour {prenom} !")
|
|
9
|
+
print(message.sid, message.status)
|
|
10
|
+
|
|
11
|
+
Webhooks::
|
|
12
|
+
|
|
13
|
+
from fameen_messaging import construct_webhook_event
|
|
14
|
+
|
|
15
|
+
event = construct_webhook_event(corps_brut, signature, secret)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .client import (
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
VERSION,
|
|
21
|
+
AsyncFameenMessaging,
|
|
22
|
+
FameenMessaging,
|
|
23
|
+
)
|
|
24
|
+
from .errors import (
|
|
25
|
+
FameenAPIError,
|
|
26
|
+
FameenConnectionError,
|
|
27
|
+
FameenError,
|
|
28
|
+
WebhookVerificationError,
|
|
29
|
+
)
|
|
30
|
+
from .types import (
|
|
31
|
+
MessageList,
|
|
32
|
+
MessageResource,
|
|
33
|
+
RateLimitInfo,
|
|
34
|
+
WalletBalance,
|
|
35
|
+
WalletBilling,
|
|
36
|
+
WebhookEvent,
|
|
37
|
+
)
|
|
38
|
+
from .webhooks import construct_webhook_event, verify_webhook_signature
|
|
39
|
+
|
|
40
|
+
__version__ = VERSION
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
# Clients
|
|
44
|
+
"FameenMessaging",
|
|
45
|
+
"AsyncFameenMessaging",
|
|
46
|
+
"DEFAULT_BASE_URL",
|
|
47
|
+
# Erreurs
|
|
48
|
+
"FameenError",
|
|
49
|
+
"FameenAPIError",
|
|
50
|
+
"FameenConnectionError",
|
|
51
|
+
"WebhookVerificationError",
|
|
52
|
+
# Modèles
|
|
53
|
+
"MessageResource",
|
|
54
|
+
"MessageList",
|
|
55
|
+
"WalletBalance",
|
|
56
|
+
"WalletBilling",
|
|
57
|
+
"WebhookEvent",
|
|
58
|
+
"RateLimitInfo",
|
|
59
|
+
# Webhooks
|
|
60
|
+
"verify_webhook_signature",
|
|
61
|
+
"construct_webhook_event",
|
|
62
|
+
# Divers
|
|
63
|
+
"VERSION",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|