przypominamy 2.0.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.
- przypominamy-2.0.0/.gitignore +4 -0
- przypominamy-2.0.0/PKG-INFO +119 -0
- przypominamy-2.0.0/README.md +97 -0
- przypominamy-2.0.0/pyproject.toml +35 -0
- przypominamy-2.0.0/src/przypominamy/__init__.py +15 -0
- przypominamy-2.0.0/src/przypominamy/client.py +194 -0
- przypominamy-2.0.0/tests/test_client.py +92 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: przypominamy
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Klient API SMS przypominamy.com — wysyłka SMS z Pythona. Polska bramka SMS, REST, Bearer, webhooki HMAC. Zero zależności.
|
|
5
|
+
Project-URL: Homepage, https://przypominamy.com/api
|
|
6
|
+
Project-URL: Documentation, https://przypominamy.com/api/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/pawelmamcarz/przypominamy-com
|
|
8
|
+
Project-URL: OpenAPI, https://przypominamy.com/openapi.json
|
|
9
|
+
Author-email: "Przypominamy.com" <support@przypominamy.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: 2fa,api sms polska,bramka sms,otp,przypominamy,sms,sms api,wysylka sms
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Natural Language :: Polish
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Communications :: Telephony
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# przypominamy
|
|
24
|
+
|
|
25
|
+
Oficjalny klient [API SMS przypominamy.com](https://przypominamy.com/api) dla Pythona ≥ 3.9. Zero zależności (standardowa biblioteka).
|
|
26
|
+
|
|
27
|
+
Polska bramka SMS: jeden endpoint do wysyłki, historia, saldo, własny nadpis, webhooki podpisane HMAC. Pay-as-you-go, bez abonamentu.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install przypominamy
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Szybki start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from przypominamy import Przypominamy
|
|
38
|
+
|
|
39
|
+
sms = Przypominamy(os.environ["PRZYPOMINAMY_API_KEY"]) # pk_live_…
|
|
40
|
+
|
|
41
|
+
msg = sms.send(
|
|
42
|
+
"+48600100200",
|
|
43
|
+
"Przypominamy o wizycie jutro o 10:00.",
|
|
44
|
+
reference="wizyta-4711", # Twój identyfikator, wraca w webhookach
|
|
45
|
+
idempotency_key="wizyta-4711-1", # retry nie wyśle SMS-a drugi raz
|
|
46
|
+
)
|
|
47
|
+
print(msg["id"], msg["status"], msg["cost_grosze"]) # msg_… queued 9
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Klucz API dostaniesz po założeniu konta na [przypominamy.com/register](https://przypominamy.com/register) (weryfikacja w 24 h).
|
|
51
|
+
|
|
52
|
+
## Wysyłka masowa i odroczona
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
|
|
57
|
+
batch = sms.send(["+48600100200", "+48600100201"], "Promocja -20% do niedzieli. Kod: SMS20", sender="SKLEP")
|
|
58
|
+
print(batch["accepted"], "z", batch["count"], "przyjęto, koszt", batch["total_cost_grosze"] / 100, "zł")
|
|
59
|
+
|
|
60
|
+
sms.send("+48600100200", "Wizyta dziś o 14:00.", send_at=datetime(2026, 9, 18, 8, 0, tzinfo=timezone.utc))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Status, historia, konto
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
m = sms.get("msg_qY08hZ2mmDXAazdRTytS")
|
|
67
|
+
page = sms.list(status="delivered", limit=50) # page["data"], page["next_cursor"]
|
|
68
|
+
for m in sms.iter_messages(reference="wizyta-4711"): # cała historia, strona po stronie
|
|
69
|
+
print(m["id"], m["status"])
|
|
70
|
+
acc = sms.account() # acc["balance_grosze"], acc["price_per_part_grosze"]
|
|
71
|
+
print(sms.senders()["data"])
|
|
72
|
+
sms.set_sender("ZDROWKO")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Webhooki
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
secret = sms.set_webhook("https://twojadomena.pl/webhooks/sms")["webhook_secret"]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Handler we Flasku:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from flask import Flask, request, abort
|
|
85
|
+
from przypominamy import verify_webhook
|
|
86
|
+
|
|
87
|
+
app = Flask(__name__)
|
|
88
|
+
|
|
89
|
+
@app.post("/webhooks/sms")
|
|
90
|
+
def sms_webhook():
|
|
91
|
+
try:
|
|
92
|
+
event = verify_webhook(request.get_data(), request.headers.get("X-Przypominamy-Signature"), WEBHOOK_SECRET)
|
|
93
|
+
except ValueError:
|
|
94
|
+
abort(403)
|
|
95
|
+
# event["type"]: message.sent | message.delivered | message.undelivered | message.failed | message.expired
|
|
96
|
+
msg = event["data"]["message"]
|
|
97
|
+
print(event["type"], msg["id"], msg["reference"])
|
|
98
|
+
return "", 200
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Błędy
|
|
102
|
+
|
|
103
|
+
Każdy błąd API to `PrzypominamyError` z polami `status`, `code` (`invalid_request`, `unauthorized`, `insufficient_funds`, `rate_limited`, `idempotency_conflict`, `provider_error`…), `param`, `request_id` i `retry_after`.
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from przypominamy import PrzypominamyError
|
|
107
|
+
try:
|
|
108
|
+
sms.send("+48600100200", "Hej")
|
|
109
|
+
except PrzypominamyError as e:
|
|
110
|
+
if e.code == "rate_limited":
|
|
111
|
+
time.sleep(e.retry_after or 5)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Dokumentacja
|
|
115
|
+
|
|
116
|
+
- [Strona API](https://przypominamy.com/api) · [Redoc](https://przypominamy.com/api/docs) · [OpenAPI 3.1](https://przypominamy.com/openapi.json)
|
|
117
|
+
- Wsparcie: support@przypominamy.com
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# przypominamy
|
|
2
|
+
|
|
3
|
+
Oficjalny klient [API SMS przypominamy.com](https://przypominamy.com/api) dla Pythona ≥ 3.9. Zero zależności (standardowa biblioteka).
|
|
4
|
+
|
|
5
|
+
Polska bramka SMS: jeden endpoint do wysyłki, historia, saldo, własny nadpis, webhooki podpisane HMAC. Pay-as-you-go, bez abonamentu.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install przypominamy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Szybki start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from przypominamy import Przypominamy
|
|
16
|
+
|
|
17
|
+
sms = Przypominamy(os.environ["PRZYPOMINAMY_API_KEY"]) # pk_live_…
|
|
18
|
+
|
|
19
|
+
msg = sms.send(
|
|
20
|
+
"+48600100200",
|
|
21
|
+
"Przypominamy o wizycie jutro o 10:00.",
|
|
22
|
+
reference="wizyta-4711", # Twój identyfikator, wraca w webhookach
|
|
23
|
+
idempotency_key="wizyta-4711-1", # retry nie wyśle SMS-a drugi raz
|
|
24
|
+
)
|
|
25
|
+
print(msg["id"], msg["status"], msg["cost_grosze"]) # msg_… queued 9
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Klucz API dostaniesz po założeniu konta na [przypominamy.com/register](https://przypominamy.com/register) (weryfikacja w 24 h).
|
|
29
|
+
|
|
30
|
+
## Wysyłka masowa i odroczona
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
|
|
35
|
+
batch = sms.send(["+48600100200", "+48600100201"], "Promocja -20% do niedzieli. Kod: SMS20", sender="SKLEP")
|
|
36
|
+
print(batch["accepted"], "z", batch["count"], "przyjęto, koszt", batch["total_cost_grosze"] / 100, "zł")
|
|
37
|
+
|
|
38
|
+
sms.send("+48600100200", "Wizyta dziś o 14:00.", send_at=datetime(2026, 9, 18, 8, 0, tzinfo=timezone.utc))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Status, historia, konto
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
m = sms.get("msg_qY08hZ2mmDXAazdRTytS")
|
|
45
|
+
page = sms.list(status="delivered", limit=50) # page["data"], page["next_cursor"]
|
|
46
|
+
for m in sms.iter_messages(reference="wizyta-4711"): # cała historia, strona po stronie
|
|
47
|
+
print(m["id"], m["status"])
|
|
48
|
+
acc = sms.account() # acc["balance_grosze"], acc["price_per_part_grosze"]
|
|
49
|
+
print(sms.senders()["data"])
|
|
50
|
+
sms.set_sender("ZDROWKO")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Webhooki
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
secret = sms.set_webhook("https://twojadomena.pl/webhooks/sms")["webhook_secret"]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Handler we Flasku:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from flask import Flask, request, abort
|
|
63
|
+
from przypominamy import verify_webhook
|
|
64
|
+
|
|
65
|
+
app = Flask(__name__)
|
|
66
|
+
|
|
67
|
+
@app.post("/webhooks/sms")
|
|
68
|
+
def sms_webhook():
|
|
69
|
+
try:
|
|
70
|
+
event = verify_webhook(request.get_data(), request.headers.get("X-Przypominamy-Signature"), WEBHOOK_SECRET)
|
|
71
|
+
except ValueError:
|
|
72
|
+
abort(403)
|
|
73
|
+
# event["type"]: message.sent | message.delivered | message.undelivered | message.failed | message.expired
|
|
74
|
+
msg = event["data"]["message"]
|
|
75
|
+
print(event["type"], msg["id"], msg["reference"])
|
|
76
|
+
return "", 200
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Błędy
|
|
80
|
+
|
|
81
|
+
Każdy błąd API to `PrzypominamyError` z polami `status`, `code` (`invalid_request`, `unauthorized`, `insufficient_funds`, `rate_limited`, `idempotency_conflict`, `provider_error`…), `param`, `request_id` i `retry_after`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from przypominamy import PrzypominamyError
|
|
85
|
+
try:
|
|
86
|
+
sms.send("+48600100200", "Hej")
|
|
87
|
+
except PrzypominamyError as e:
|
|
88
|
+
if e.code == "rate_limited":
|
|
89
|
+
time.sleep(e.retry_after or 5)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Dokumentacja
|
|
93
|
+
|
|
94
|
+
- [Strona API](https://przypominamy.com/api) · [Redoc](https://przypominamy.com/api/docs) · [OpenAPI 3.1](https://przypominamy.com/openapi.json)
|
|
95
|
+
- Wsparcie: support@przypominamy.com
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "przypominamy"
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Klient API SMS przypominamy.com — wysyłka SMS z Pythona. Polska bramka SMS, REST, Bearer, webhooki HMAC. Zero zależności."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Przypominamy.com", email = "support@przypominamy.com" }]
|
|
13
|
+
keywords = ["sms", "sms api", "bramka sms", "api sms polska", "przypominamy", "wysylka sms", "otp", "2fa"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Natural Language :: Polish",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Communications :: Telephony",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://przypominamy.com/api"
|
|
27
|
+
Documentation = "https://przypominamy.com/api/docs"
|
|
28
|
+
Repository = "https://github.com/pawelmamcarz/przypominamy-com"
|
|
29
|
+
"OpenAPI" = "https://przypominamy.com/openapi.json"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/przypominamy"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
include = ["src/przypominamy", "README.md", "tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Oficjalny klient API SMS przypominamy.com.
|
|
2
|
+
|
|
3
|
+
from przypominamy import Przypominamy
|
|
4
|
+
|
|
5
|
+
sms = Przypominamy(os.environ["PRZYPOMINAMY_API_KEY"])
|
|
6
|
+
msg = sms.send(to="+48600100200", text="Przypominamy o wizycie jutro o 10:00.")
|
|
7
|
+
print(msg["id"], msg["status"])
|
|
8
|
+
|
|
9
|
+
Dokumentacja: https://przypominamy.com/api/docs
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .client import Przypominamy, PrzypominamyError, verify_webhook
|
|
13
|
+
|
|
14
|
+
__all__ = ["Przypominamy", "PrzypominamyError", "verify_webhook"]
|
|
15
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Klient HTTP API SMS przypominamy.com. Zero zależności (urllib ze standardowej biblioteki)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
|
15
|
+
|
|
16
|
+
__all__ = ["Przypominamy", "PrzypominamyError", "verify_webhook"]
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.przypominamy.com"
|
|
19
|
+
USER_AGENT = "przypominamy-python/2.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PrzypominamyError(Exception):
|
|
23
|
+
"""Błąd API: kod HTTP, kod błędu (`insufficient_funds`, `rate_limited`…), pole i request_id do supportu."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
status: int,
|
|
28
|
+
code: str,
|
|
29
|
+
message: str,
|
|
30
|
+
param: Optional[str] = None,
|
|
31
|
+
request_id: Optional[str] = None,
|
|
32
|
+
retry_after: Optional[int] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(f"[{status} {code}] {message}")
|
|
35
|
+
self.status = status
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
self.param = param
|
|
39
|
+
self.request_id = request_id
|
|
40
|
+
self.retry_after = retry_after
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Przypominamy:
|
|
44
|
+
"""Klient API. Wszystkie metody zwracają słowniki zgodne z https://przypominamy.com/openapi.json."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 10.0) -> None:
|
|
47
|
+
if not api_key:
|
|
48
|
+
raise ValueError("Brak klucza API (pk_live_…)")
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.base_url = base_url.rstrip("/")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
|
|
53
|
+
# ── Wiadomości ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def send(
|
|
56
|
+
self,
|
|
57
|
+
to: Union[str, Sequence[str]],
|
|
58
|
+
text: str,
|
|
59
|
+
*,
|
|
60
|
+
sender: Optional[str] = None,
|
|
61
|
+
send_at: Union[datetime, str, None] = None,
|
|
62
|
+
reference: Optional[str] = None,
|
|
63
|
+
idempotency_key: Optional[str] = None,
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Wyślij SMS do jednego numeru (str) lub wielu (lista do 500). Zwraca Message albo wynik zbiorczy."""
|
|
66
|
+
body: Dict[str, Any] = {"to": list(to) if not isinstance(to, str) else to, "text": text}
|
|
67
|
+
if sender:
|
|
68
|
+
body["from"] = sender
|
|
69
|
+
if reference:
|
|
70
|
+
body["reference"] = reference
|
|
71
|
+
if send_at is not None:
|
|
72
|
+
if isinstance(send_at, datetime):
|
|
73
|
+
if send_at.tzinfo is None:
|
|
74
|
+
send_at = send_at.replace(tzinfo=timezone.utc)
|
|
75
|
+
send_at = send_at.isoformat()
|
|
76
|
+
body["send_at"] = send_at
|
|
77
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
78
|
+
return self._request("POST", "/v1/messages", body, headers)
|
|
79
|
+
|
|
80
|
+
def get(self, message_id: str) -> Dict[str, Any]:
|
|
81
|
+
"""Pobierz wiadomość po id (`msg_…`)."""
|
|
82
|
+
return self._request("GET", f"/v1/messages/{urllib.parse.quote(message_id)}")
|
|
83
|
+
|
|
84
|
+
def list(
|
|
85
|
+
self,
|
|
86
|
+
*,
|
|
87
|
+
limit: Optional[int] = None,
|
|
88
|
+
status: Optional[str] = None,
|
|
89
|
+
reference: Optional[str] = None,
|
|
90
|
+
to: Optional[str] = None,
|
|
91
|
+
cursor: Optional[str] = None,
|
|
92
|
+
) -> Dict[str, Any]:
|
|
93
|
+
"""Historia wiadomości: {"data": [...], "next_cursor": ...}."""
|
|
94
|
+
params = {k: v for k, v in dict(limit=limit, status=status, reference=reference, to=to, cursor=cursor).items() if v is not None}
|
|
95
|
+
qs = f"?{urllib.parse.urlencode(params)}" if params else ""
|
|
96
|
+
return self._request("GET", f"/v1/messages{qs}")
|
|
97
|
+
|
|
98
|
+
def iter_messages(self, **filters: Any):
|
|
99
|
+
"""Iteruje po całej historii, strona po stronie."""
|
|
100
|
+
cursor = None
|
|
101
|
+
while True:
|
|
102
|
+
page = self.list(cursor=cursor, **filters)
|
|
103
|
+
yield from page["data"]
|
|
104
|
+
cursor = page.get("next_cursor")
|
|
105
|
+
if not cursor:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# ── Konto ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def account(self) -> Dict[str, Any]:
|
|
111
|
+
"""Saldo (balance_grosze), cena za część, nadpis, webhook."""
|
|
112
|
+
return self._request("GET", "/v1/account")
|
|
113
|
+
|
|
114
|
+
def senders(self) -> Dict[str, Any]:
|
|
115
|
+
"""Aktywne nadpisy: {"data": [{"name", "is_default"}], "current": ...}."""
|
|
116
|
+
return self._request("GET", "/v1/senders")
|
|
117
|
+
|
|
118
|
+
def set_sender(self, sender_name: Optional[str]) -> Dict[str, Any]:
|
|
119
|
+
"""Ustaw domyślny nadpis (None przywraca domyślny)."""
|
|
120
|
+
return self._request("PATCH", "/v1/account", {"sender_name": sender_name})
|
|
121
|
+
|
|
122
|
+
def set_webhook(self, url: Optional[str], *, rotate_secret: bool = False) -> Dict[str, Any]:
|
|
123
|
+
"""Ustaw adres webhooka; zwraca webhook_secret. None wyłącza webhooki."""
|
|
124
|
+
return self._request("PUT", "/v1/account/webhook", {"url": url, "rotate_secret": rotate_secret})
|
|
125
|
+
|
|
126
|
+
# ── HTTP ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def _request(self, method: str, path: str, body: Any = None, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
129
|
+
headers = {
|
|
130
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
131
|
+
"Accept": "application/json",
|
|
132
|
+
"User-Agent": USER_AGENT,
|
|
133
|
+
}
|
|
134
|
+
if extra_headers:
|
|
135
|
+
headers.update(extra_headers)
|
|
136
|
+
data = None
|
|
137
|
+
if body is not None:
|
|
138
|
+
headers["Content-Type"] = "application/json"
|
|
139
|
+
data = json.dumps(body).encode("utf-8")
|
|
140
|
+
req = urllib.request.Request(f"{self.base_url}{path}", data=data, method=method, headers=headers)
|
|
141
|
+
try:
|
|
142
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
143
|
+
raw = res.read()
|
|
144
|
+
return json.loads(raw) if raw else {}
|
|
145
|
+
except urllib.error.HTTPError as e:
|
|
146
|
+
raw = e.read()
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(raw) if raw else {}
|
|
149
|
+
except ValueError:
|
|
150
|
+
payload = {}
|
|
151
|
+
err = payload.get("error") or {}
|
|
152
|
+
retry_after = e.headers.get("Retry-After")
|
|
153
|
+
raise PrzypominamyError(
|
|
154
|
+
e.code,
|
|
155
|
+
err.get("code", "internal_error"),
|
|
156
|
+
err.get("message", f"HTTP {e.code}"),
|
|
157
|
+
err.get("param"),
|
|
158
|
+
payload.get("request_id") or e.headers.get("X-Request-Id"),
|
|
159
|
+
int(retry_after) if retry_after and retry_after.isdigit() else None,
|
|
160
|
+
) from None
|
|
161
|
+
except urllib.error.URLError as e:
|
|
162
|
+
raise PrzypominamyError(0, "network_error", str(e.reason)) from None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Webhooki ────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
_SIG_RE = re.compile(r"t=(\d+),v1=([0-9a-f]{64})")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def verify_webhook(
|
|
171
|
+
raw_body: Union[bytes, str],
|
|
172
|
+
signature_header: Optional[str],
|
|
173
|
+
secret: str,
|
|
174
|
+
*,
|
|
175
|
+
tolerance_seconds: int = 300,
|
|
176
|
+
now: Optional[float] = None,
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
"""Weryfikuje nagłówek X-Przypominamy-Signature i zwraca zdarzenie. Rzuca ValueError przy złym podpisie."""
|
|
179
|
+
m = _SIG_RE.search(signature_header or "")
|
|
180
|
+
if not m:
|
|
181
|
+
raise ValueError("Brak lub nieprawidłowy nagłówek X-Przypominamy-Signature")
|
|
182
|
+
t, sig = int(m.group(1)), m.group(2)
|
|
183
|
+
if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
|
|
184
|
+
raise ValueError("Podpis webhooka jest przeterminowany")
|
|
185
|
+
body_bytes = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
|
|
186
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + body_bytes, hashlib.sha256).hexdigest()
|
|
187
|
+
if not hmac.compare_digest(expected, sig):
|
|
188
|
+
raise ValueError("Nieprawidłowy podpis webhooka")
|
|
189
|
+
return json.loads(body_bytes)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Typy pomocnicze dla użytkowników type hintów
|
|
193
|
+
Message = Dict[str, Any]
|
|
194
|
+
Messages = List[Message]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
import urllib.error
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
from przypominamy import Przypominamy, PrzypominamyError, verify_webhook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeResponse(io.BytesIO):
|
|
14
|
+
def __init__(self, payload, headers=None):
|
|
15
|
+
super().__init__(json.dumps(payload).encode())
|
|
16
|
+
self.headers = headers or {}
|
|
17
|
+
|
|
18
|
+
def __enter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __exit__(self, *a):
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def http_error(status, payload, headers=None):
|
|
26
|
+
return urllib.error.HTTPError("https://api.przypominamy.com/v1/messages", status, "err", headers or {}, io.BytesIO(json.dumps(payload).encode()))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClientTests(unittest.TestCase):
|
|
30
|
+
def setUp(self):
|
|
31
|
+
self.sms = Przypominamy("pk_live_test")
|
|
32
|
+
|
|
33
|
+
def test_send_single_builds_request(self):
|
|
34
|
+
with mock.patch("urllib.request.urlopen", return_value=FakeResponse({"id": "msg_1", "status": "queued"})) as m:
|
|
35
|
+
msg = self.sms.send(
|
|
36
|
+
"+48600100200", "Hej", sender="FIRMA", reference="r1",
|
|
37
|
+
idempotency_key="k1", send_at=datetime(2026, 9, 10, 8, 0, tzinfo=timezone.utc),
|
|
38
|
+
)
|
|
39
|
+
self.assertEqual(msg["id"], "msg_1")
|
|
40
|
+
req = m.call_args[0][0]
|
|
41
|
+
self.assertEqual(req.full_url, "https://api.przypominamy.com/v1/messages")
|
|
42
|
+
self.assertEqual(req.get_method(), "POST")
|
|
43
|
+
self.assertEqual(req.get_header("Authorization"), "Bearer pk_live_test")
|
|
44
|
+
self.assertEqual(req.get_header("Idempotency-key"), "k1")
|
|
45
|
+
self.assertEqual(json.loads(req.data), {"to": "+48600100200", "text": "Hej", "from": "FIRMA", "reference": "r1", "send_at": "2026-09-10T08:00:00+00:00"})
|
|
46
|
+
|
|
47
|
+
def test_send_many(self):
|
|
48
|
+
with mock.patch("urllib.request.urlopen", return_value=FakeResponse({"count": 2, "accepted": 2, "total_cost_grosze": 18, "messages": []})) as m:
|
|
49
|
+
res = self.sms.send(["+48600100200", "+48600100201"], "Hej")
|
|
50
|
+
self.assertEqual(res["accepted"], 2)
|
|
51
|
+
self.assertEqual(json.loads(m.call_args[0][0].data)["to"], ["+48600100200", "+48600100201"])
|
|
52
|
+
|
|
53
|
+
def test_api_error(self):
|
|
54
|
+
err = http_error(402, {"error": {"code": "insufficient_funds", "message": "Brak środków"}, "request_id": "req_x"}, {"Retry-After": "30"})
|
|
55
|
+
with mock.patch("urllib.request.urlopen", side_effect=err):
|
|
56
|
+
with self.assertRaises(PrzypominamyError) as cm:
|
|
57
|
+
self.sms.send("+48600100200", "Hej")
|
|
58
|
+
self.assertEqual(cm.exception.status, 402)
|
|
59
|
+
self.assertEqual(cm.exception.code, "insufficient_funds")
|
|
60
|
+
self.assertEqual(cm.exception.request_id, "req_x")
|
|
61
|
+
self.assertEqual(cm.exception.retry_after, 30)
|
|
62
|
+
|
|
63
|
+
def test_list_query_and_iteration(self):
|
|
64
|
+
pages = [FakeResponse({"data": [{"id": "a"}], "next_cursor": "c1"}), FakeResponse({"data": [{"id": "b"}], "next_cursor": None})]
|
|
65
|
+
with mock.patch("urllib.request.urlopen", side_effect=pages) as m:
|
|
66
|
+
ids = [x["id"] for x in self.sms.iter_messages(status="delivered", limit=1)]
|
|
67
|
+
self.assertEqual(ids, ["a", "b"])
|
|
68
|
+
self.assertIn("status=delivered", m.call_args_list[0][0][0].full_url)
|
|
69
|
+
self.assertIn("cursor=c1", m.call_args_list[1][0][0].full_url)
|
|
70
|
+
|
|
71
|
+
def test_account_and_webhook(self):
|
|
72
|
+
with mock.patch("urllib.request.urlopen", side_effect=[FakeResponse({"balance_grosze": 100}), FakeResponse({"webhook_secret": "whsec_1"})]) as m:
|
|
73
|
+
self.assertEqual(self.sms.account()["balance_grosze"], 100)
|
|
74
|
+
self.assertEqual(self.sms.set_webhook("https://x")["webhook_secret"], "whsec_1")
|
|
75
|
+
self.assertEqual(m.call_args_list[1][0][0].get_method(), "PUT")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class WebhookTests(unittest.TestCase):
|
|
79
|
+
def test_verify(self):
|
|
80
|
+
secret, t = "whsec_test", 1_757_230_000
|
|
81
|
+
body = json.dumps({"id": "evt_1", "type": "message.delivered", "data": {"message": {"id": "msg_1"}}})
|
|
82
|
+
sig = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
83
|
+
ev = verify_webhook(body, f"t={t},v1={sig}", secret, now=t + 10)
|
|
84
|
+
self.assertEqual(ev["data"]["message"]["id"], "msg_1")
|
|
85
|
+
with self.assertRaises(ValueError):
|
|
86
|
+
verify_webhook(body, f"t={t},v1={'0' * 64}", secret, now=t + 10)
|
|
87
|
+
with self.assertRaises(ValueError):
|
|
88
|
+
verify_webhook(body, f"t={t},v1={sig}", secret, now=t + 1000)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|