wazzapi 0.1.1__tar.gz → 0.1.2__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.
Files changed (32) hide show
  1. {wazzapi-0.1.1 → wazzapi-0.1.2}/PKG-INFO +2 -1
  2. {wazzapi-0.1.1 → wazzapi-0.1.2}/pyproject.toml +4 -1
  3. {wazzapi-0.1.1 → wazzapi-0.1.2}/tests/smoke_test.py +1 -1
  4. wazzapi-0.1.2/tests/test_media.py +170 -0
  5. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/__init__.py +6 -2
  6. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/errors.py +4 -0
  7. wazzapi-0.1.2/wazzapi/media.py +147 -0
  8. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/__init__.py +3 -1
  9. wazzapi-0.1.2/wazzapi/models/media.py +13 -0
  10. {wazzapi-0.1.1 → wazzapi-0.1.2}/.gitignore +0 -0
  11. {wazzapi-0.1.1 → wazzapi-0.1.2}/README.md +0 -0
  12. {wazzapi-0.1.1 → wazzapi-0.1.2}/examples/create_template.py +0 -0
  13. {wazzapi-0.1.1 → wazzapi-0.1.2}/examples/list_contacts.py +0 -0
  14. {wazzapi-0.1.1 → wazzapi-0.1.2}/examples/preview_template.py +0 -0
  15. {wazzapi-0.1.1 → wazzapi-0.1.2}/examples/send_message.py +0 -0
  16. {wazzapi-0.1.1 → wazzapi-0.1.2}/examples/verify_webhook.py +0 -0
  17. {wazzapi-0.1.1 → wazzapi-0.1.2}/tests/test_client.py +0 -0
  18. {wazzapi-0.1.1 → wazzapi-0.1.2}/tests/test_resources.py +0 -0
  19. {wazzapi-0.1.1 → wazzapi-0.1.2}/tests/test_webhooks.py +0 -0
  20. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/client.py +0 -0
  21. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/base.py +0 -0
  22. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/contacts.py +0 -0
  23. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/messages.py +0 -0
  24. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/templates.py +0 -0
  25. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/models/webhooks.py +0 -0
  26. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/py.typed +0 -0
  27. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/resources/__init__.py +0 -0
  28. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/resources/base.py +0 -0
  29. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/resources/contacts.py +0 -0
  30. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/resources/messages.py +0 -0
  31. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/resources/templates.py +0 -0
  32. {wazzapi-0.1.1 → wazzapi-0.1.2}/wazzapi/webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wazzapi
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Typed Python SDK for WazzAPI
5
5
  Project-URL: Homepage, https://github.com/wazzapihq/wazzapi-py
6
6
  Project-URL: API, https://api.wazzapi.com
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.10
20
+ Requires-Dist: cryptography<45.0,>=44.0
20
21
  Requires-Dist: httpx<1.0,>=0.28
21
22
  Requires-Dist: pydantic<3.0,>=2.11
22
23
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "wazzapi"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Typed Python SDK for WazzAPI"
5
5
  readme = { file = "README.md", content-type = "text/markdown" }
6
6
  requires-python = ">=3.10"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Typing :: Typed",
20
20
  ]
21
21
  dependencies = [
22
+ "cryptography>=44.0,<45.0",
22
23
  "httpx>=0.28,<1.0",
23
24
  "pydantic>=2.11,<3.0",
24
25
  ]
@@ -30,6 +31,7 @@ API = "https://api.wazzapi.com"
30
31
  [dependency-groups]
31
32
  dev = [
32
33
  "pytest>=8.3,<9.0",
34
+ "pytest-asyncio>=0.25,<1.0",
33
35
  "twine>=6.1,<7.0",
34
36
  ]
35
37
 
@@ -52,3 +54,4 @@ include = [
52
54
  [tool.pytest]
53
55
  python_files = ["test_*.py"]
54
56
  testpaths = ["tests"]
57
+ asyncio_mode = "auto"
@@ -10,7 +10,7 @@ from wazzapi import (
10
10
 
11
11
 
12
12
  def main() -> None:
13
- assert __version__ == "0.1.1"
13
+ assert __version__ == "0.1.2"
14
14
 
15
15
  client = WazzapiClient(api_key="smoke-test-token")
16
16
  try:
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ from unittest.mock import AsyncMock
6
+
7
+ import httpx
8
+ import pytest
9
+ from cryptography.hazmat.primitives import hashes
10
+ from cryptography.hazmat.primitives import hmac as crypto_hmac
11
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
12
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
13
+
14
+ from wazzapi.errors import WazzapiMediaError
15
+ from wazzapi.media import _decrypt_wa_media, _media_info_bytes, download_media
16
+
17
+ PLAINTEXT = b"hello world this is a test file"
18
+ MIMETYPE = "application/pdf"
19
+ _MEDIA_KEY_BYTES = b"\x00" * 32
20
+ MEDIA_KEY = base64.b64encode(_MEDIA_KEY_BYTES).decode()
21
+
22
+
23
+ def _encrypt_wa_media(plaintext: bytes, media_key: bytes, mimetype: str) -> bytes:
24
+ info = _media_info_bytes(mimetype)
25
+ hkdf = HKDF(algorithm=hashes.SHA256(), length=112, salt=None, info=info)
26
+ expanded = hkdf.derive(media_key)
27
+ iv = expanded[:16]
28
+ aes_key = expanded[16:48]
29
+ mac_key = expanded[48:80]
30
+
31
+ pad_len = 16 - (len(plaintext) % 16)
32
+ padded = plaintext + bytes([pad_len] * pad_len)
33
+
34
+ cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
35
+ encryptor = cipher.encryptor()
36
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
37
+
38
+ h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
39
+ h.update(iv + ciphertext)
40
+ mac = h.finalize()[:10]
41
+
42
+ return ciphertext + mac
43
+
44
+
45
+ ENC_BLOB = _encrypt_wa_media(PLAINTEXT, _MEDIA_KEY_BYTES, MIMETYPE)
46
+ FILE_SHA256 = base64.b64encode(hashlib.sha256(PLAINTEXT).digest()).decode()
47
+ ENC_SHA256 = base64.b64encode(hashlib.sha256(ENC_BLOB).digest()).decode()
48
+
49
+
50
+ def _mock_client(enc_data: bytes, status_code: int = 200) -> AsyncMock:
51
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
52
+ request = httpx.Request("GET", "https://cdn.example.com/file")
53
+ mock_client.get.return_value = httpx.Response(status_code, content=enc_data, request=request)
54
+ mock_client.aclose = AsyncMock()
55
+ return mock_client
56
+
57
+
58
+ def test_decrypt_wa_media_success() -> None:
59
+ result = _decrypt_wa_media(MEDIA_KEY, ENC_BLOB, MIMETYPE)
60
+ assert result == PLAINTEXT
61
+
62
+
63
+ def test_decrypt_wa_media_mac_failure() -> None:
64
+ bad_blob = ENC_BLOB[:-10] + b"\x00" * 10
65
+ with pytest.raises(WazzapiMediaError, match="MAC verification failed"):
66
+ _decrypt_wa_media(MEDIA_KEY, bad_blob, MIMETYPE)
67
+
68
+
69
+ def test_decrypt_wa_media_short_data() -> None:
70
+ with pytest.raises(WazzapiMediaError, match="Encrypted data too short"):
71
+ _decrypt_wa_media(MEDIA_KEY, b"123", MIMETYPE)
72
+
73
+
74
+ def test_decrypt_wa_media_bad_base64_key() -> None:
75
+ with pytest.raises(WazzapiMediaError, match="Invalid media_key base64"):
76
+ _decrypt_wa_media("not-valid-b64!!!", ENC_BLOB, MIMETYPE)
77
+
78
+
79
+ def test_media_info_bytes_mapping() -> None:
80
+ assert _media_info_bytes("image/jpeg") == b"WhatsApp Image Keys"
81
+ assert _media_info_bytes("video/mp4") == b"WhatsApp Video Keys"
82
+ assert _media_info_bytes("audio/ogg") == b"WhatsApp Audio Keys"
83
+ assert _media_info_bytes("application/pdf") == b"WhatsApp Document Keys"
84
+
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_download_media_success() -> None:
88
+ mock_client = _mock_client(ENC_BLOB)
89
+ result = await download_media(
90
+ url="https://cdn.example.com/file",
91
+ media_key=MEDIA_KEY,
92
+ mimetype=MIMETYPE,
93
+ file_sha256=FILE_SHA256,
94
+ file_enc_sha256=ENC_SHA256,
95
+ file_name="test.pdf",
96
+ http_client=mock_client,
97
+ )
98
+ assert result.content == PLAINTEXT
99
+ assert result.mimetype == MIMETYPE
100
+ assert result.file_name == "test.pdf"
101
+ assert result.file_size == len(PLAINTEXT)
102
+ mock_client.get.assert_awaited_once_with("https://cdn.example.com/file")
103
+ mock_client.aclose.assert_not_awaited()
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_download_media_http_error() -> None:
108
+ mock_client = _mock_client(b"not found", status_code=404)
109
+ with pytest.raises(WazzapiMediaError, match="CDN fetch failed: 404"):
110
+ await download_media(
111
+ url="https://cdn.example.com/file",
112
+ media_key=MEDIA_KEY,
113
+ mimetype=MIMETYPE,
114
+ http_client=mock_client,
115
+ )
116
+
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_download_media_network_error() -> None:
120
+ mock_client = AsyncMock(spec=httpx.AsyncClient)
121
+ mock_client.get.side_effect = httpx.RequestError(
122
+ "connection refused", request=httpx.Request("GET", "https://cdn.example.com/file")
123
+ )
124
+ mock_client.aclose = AsyncMock()
125
+ with pytest.raises(WazzapiMediaError, match="CDN fetch error:"):
126
+ await download_media(
127
+ url="https://cdn.example.com/file",
128
+ media_key=MEDIA_KEY,
129
+ mimetype=MIMETYPE,
130
+ http_client=mock_client,
131
+ )
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_download_media_enc_sha_mismatch() -> None:
136
+ mock_client = _mock_client(ENC_BLOB)
137
+ with pytest.raises(WazzapiMediaError, match="file_enc_sha256 mismatch"):
138
+ await download_media(
139
+ url="https://cdn.example.com/file",
140
+ media_key=MEDIA_KEY,
141
+ mimetype=MIMETYPE,
142
+ file_enc_sha256=base64.b64encode(hashlib.sha256(b"wrong").digest()).decode(),
143
+ http_client=mock_client,
144
+ )
145
+
146
+
147
+ @pytest.mark.asyncio
148
+ async def test_download_media_plain_sha_mismatch() -> None:
149
+ mock_client = _mock_client(ENC_BLOB)
150
+ with pytest.raises(WazzapiMediaError, match="file_sha256 mismatch after decryption"):
151
+ await download_media(
152
+ url="https://cdn.example.com/file",
153
+ media_key=MEDIA_KEY,
154
+ mimetype=MIMETYPE,
155
+ file_sha256=base64.b64encode(hashlib.sha256(b"wrong").digest()).decode(),
156
+ http_client=mock_client,
157
+ )
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_download_media_no_checksums() -> None:
162
+ mock_client = _mock_client(ENC_BLOB)
163
+ result = await download_media(
164
+ url="https://cdn.example.com/file",
165
+ media_key=MEDIA_KEY,
166
+ mimetype=MIMETYPE,
167
+ http_client=mock_client,
168
+ )
169
+ assert result.content == PLAINTEXT
170
+ assert result.file_name == "media"
@@ -1,6 +1,7 @@
1
1
  from . import models
2
2
  from .client import WazzapiClient
3
- from .errors import WazzapiAPIError, WazzapiError
3
+ from .errors import WazzapiAPIError, WazzapiError, WazzapiMediaError
4
+ from .media import MediaDownloadResult, download_media
4
5
  from .models import *
5
6
  from .webhooks import (
6
7
  EVENT_HEADER,
@@ -15,18 +16,21 @@ from .webhooks import (
15
16
  verify_webhook_signature,
16
17
  )
17
18
 
18
- __version__ = "0.1.1"
19
+ __version__ = "0.1.2"
19
20
 
20
21
  __all__ = [
21
22
  "EVENT_HEADER",
22
23
  "EVENT_ID_HEADER",
23
24
  "SIGNATURE_HEADER",
24
25
  "__version__",
26
+ "download_media",
25
27
  "generate_webhook_signature",
26
28
  "parse_webhook",
29
+ "MediaDownloadResult",
27
30
  "WazzapiAPIError",
28
31
  "WazzapiClient",
29
32
  "WazzapiError",
33
+ "WazzapiMediaError",
30
34
  "WazzapiWebhookError",
31
35
  "WazzapiWebhookParseError",
32
36
  "WazzapiWebhookVerificationError",
@@ -58,3 +58,7 @@ class WazzapiAPIError(WazzapiError):
58
58
  details=details,
59
59
  response_text=response.text,
60
60
  )
61
+
62
+
63
+ class WazzapiMediaError(WazzapiError):
64
+ """Raised when media download or decryption fails."""
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import logging
6
+
7
+ import httpx
8
+ from cryptography.hazmat.primitives import hashes
9
+ from cryptography.hazmat.primitives import hmac as crypto_hmac
10
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
11
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
12
+
13
+ from .errors import WazzapiMediaError
14
+ from .models.media import MediaDownloadResult
15
+
16
+ logger = logging.getLogger("wazzapi.media")
17
+
18
+ # https://github.com/tgalal/python-axolotl/wiki/WhatsApp-media-encryption
19
+ _WA_MEDIA_INFO: dict[str, bytes] = {
20
+ "image": b"WhatsApp Image Keys",
21
+ "video": b"WhatsApp Video Keys",
22
+ "audio": b"WhatsApp Audio Keys",
23
+ "ptt": b"WhatsApp Audio Keys",
24
+ "document": b"WhatsApp Document Keys",
25
+ "sticker": b"WhatsApp Image Keys",
26
+ "history": b"WhatsApp History Keys",
27
+ }
28
+
29
+
30
+ def _media_info_bytes(mimetype: str) -> bytes:
31
+ mt = mimetype.lower()
32
+ if mt.startswith("image/"):
33
+ return _WA_MEDIA_INFO["image"]
34
+ if mt.startswith("video/"):
35
+ return _WA_MEDIA_INFO["video"]
36
+ if mt.startswith("audio/"):
37
+ return _WA_MEDIA_INFO["audio"]
38
+ return _WA_MEDIA_INFO["document"]
39
+
40
+
41
+ def _decrypt_wa_media(media_key_b64: str, enc_data: bytes, mimetype: str) -> bytes:
42
+ if len(enc_data) < 10:
43
+ raise WazzapiMediaError("Encrypted data too short")
44
+
45
+ try:
46
+ media_key = base64.b64decode(media_key_b64)
47
+ except Exception as exc:
48
+ raise WazzapiMediaError("Invalid media_key base64") from exc
49
+
50
+ info = _media_info_bytes(mimetype)
51
+
52
+ hkdf = HKDF(algorithm=hashes.SHA256(), length=112, salt=None, info=info)
53
+ expanded = hkdf.derive(media_key)
54
+
55
+ iv = expanded[:16]
56
+ aes_key = expanded[16:48]
57
+ mac_key = expanded[48:80]
58
+
59
+ ciphertext = enc_data[:-10]
60
+ mac = enc_data[-10:]
61
+
62
+ h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
63
+ h.update(iv + ciphertext)
64
+ computed_mac = h.finalize()[:10]
65
+ if computed_mac != mac:
66
+ raise WazzapiMediaError(
67
+ "MAC verification failed — media may be corrupted or mediaKey is wrong"
68
+ )
69
+
70
+ cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
71
+ decryptor = cipher.decryptor()
72
+ plaintext_padded = decryptor.update(ciphertext) + decryptor.finalize()
73
+
74
+ if not plaintext_padded:
75
+ raise WazzapiMediaError("Decrypted plaintext is empty")
76
+
77
+ pad_len = plaintext_padded[-1]
78
+ if pad_len > 16 or pad_len == 0 or pad_len > len(plaintext_padded):
79
+ raise WazzapiMediaError("Invalid PKCS7 padding")
80
+ if any(b != pad_len for b in plaintext_padded[-pad_len:]):
81
+ raise WazzapiMediaError("Invalid PKCS7 padding")
82
+
83
+ return plaintext_padded[:-pad_len]
84
+
85
+
86
+ async def download_media(
87
+ url: str,
88
+ media_key: str,
89
+ mimetype: str,
90
+ *,
91
+ file_sha256: str | None = None,
92
+ file_enc_sha256: str | None = None,
93
+ file_name: str | None = None,
94
+ http_client: httpx.AsyncClient | None = None,
95
+ ) -> MediaDownloadResult:
96
+ logger.info("Downloading media url=%s mimetype=%s", url, mimetype)
97
+
98
+ client = http_client or httpx.AsyncClient(follow_redirects=True, timeout=30)
99
+ owns_client = http_client is None
100
+
101
+ try:
102
+ resp = await client.get(url)
103
+ resp.raise_for_status()
104
+ except httpx.HTTPStatusError as exc:
105
+ logger.error("CDN fetch failed: %s", exc)
106
+ raise WazzapiMediaError(f"CDN fetch failed: {exc.response.status_code}") from exc
107
+ except httpx.RequestError as exc:
108
+ logger.error("CDN network error: %s", exc)
109
+ raise WazzapiMediaError(f"CDN fetch error: {exc}") from exc
110
+ finally:
111
+ if owns_client:
112
+ await client.aclose()
113
+
114
+ enc_data = resp.content
115
+
116
+ if file_enc_sha256:
117
+ expected_enc = base64.b64decode(file_enc_sha256)
118
+ actual_enc = hashlib.sha256(enc_data).digest()
119
+ if actual_enc != expected_enc:
120
+ raise WazzapiMediaError(
121
+ f"file_enc_sha256 mismatch: CDN returned wrong data (got {len(enc_data)} bytes)"
122
+ )
123
+
124
+ plaintext = _decrypt_wa_media(media_key, enc_data, mimetype)
125
+
126
+ if file_sha256:
127
+ expected = base64.b64decode(file_sha256)
128
+ actual = hashlib.sha256(plaintext).digest()
129
+ if actual != expected:
130
+ raise WazzapiMediaError("file_sha256 mismatch after decryption")
131
+
132
+ result = MediaDownloadResult(
133
+ content=plaintext,
134
+ mimetype=mimetype,
135
+ file_name=file_name or "media",
136
+ file_size=len(plaintext),
137
+ )
138
+ logger.info("Media download success url=%s size=%d", url, result.file_size)
139
+ return result
140
+
141
+
142
+ __all__ = [
143
+ "MediaDownloadResult",
144
+ "_decrypt_wa_media",
145
+ "_media_info_bytes",
146
+ "download_media",
147
+ ]
@@ -1,6 +1,8 @@
1
1
  from .base import WazzapiModel
2
2
  from .contacts import *
3
3
  from .contacts import __all__ as contacts_all
4
+ from .media import *
5
+ from .media import __all__ as media_all
4
6
  from .messages import *
5
7
  from .messages import __all__ as messages_all
6
8
  from .templates import *
@@ -8,4 +10,4 @@ from .templates import __all__ as templates_all
8
10
  from .webhooks import *
9
11
  from .webhooks import __all__ as webhooks_all
10
12
 
11
- __all__ = ["WazzapiModel", *contacts_all, *messages_all, *templates_all, *webhooks_all]
13
+ __all__ = ["WazzapiModel", *contacts_all, *media_all, *messages_all, *templates_all, *webhooks_all]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import WazzapiModel
4
+
5
+
6
+ class MediaDownloadResult(WazzapiModel):
7
+ content: bytes
8
+ mimetype: str
9
+ file_name: str
10
+ file_size: int
11
+
12
+
13
+ __all__ = ["MediaDownloadResult"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes