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.
@@ -0,0 +1,4 @@
1
+ dist
2
+ *.egg-info
3
+ __pycache__
4
+ .venv
@@ -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()