chore: import upstream snapshot with attribution
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
@@ -0,0 +1,93 @@
|
||||
# rmyndharis-openwa
|
||||
|
||||
Official Python SDK for the [OpenWA](https://github.com/rmyndharis/OpenWA) WhatsApp API Gateway.
|
||||
|
||||
A synchronous client built on [httpx](https://www.python-httpx.org/), with bundled type hints (PEP 561).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install rmyndharis-openwa
|
||||
```
|
||||
|
||||
Requires Python 3.9+. The importable module is `openwa`.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
from openwa import OpenWAClient
|
||||
|
||||
client = OpenWAClient(
|
||||
base_url="https://your-gateway.example.com",
|
||||
api_key="owa_k1_…",
|
||||
)
|
||||
|
||||
client.sessions.start("my-session")
|
||||
|
||||
result = client.messages.send_text("my-session", {
|
||||
"chatId": "628123456789@c.us",
|
||||
"text": "Hello from the OpenWA Python SDK!",
|
||||
})
|
||||
print(result["messageId"])
|
||||
```
|
||||
|
||||
The client is also a context manager (it closes the underlying connection pool on exit):
|
||||
|
||||
```python
|
||||
with OpenWAClient(base_url="…", api_key="…") as client:
|
||||
client.messages.send_text("my-session", {"chatId": "…@c.us", "text": "hi"})
|
||||
```
|
||||
|
||||
For tests, pass an httpx transport — no global monkey-patching required:
|
||||
|
||||
```python
|
||||
import httpx
|
||||
client = OpenWAClient(base_url="…", api_key="…", transport=httpx.MockTransport(handler))
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
`GET /search` is wrapped as `client.search.search(params)`. Only `q` is required;
|
||||
the rest (`sessionId`, `chatId`, `direction`, `type`, `from`, `dateFrom`,
|
||||
`dateTo`, `limit`, `offset`) are optional. `dateFrom` / `dateTo` are epoch-ms.
|
||||
The active search provider (built-in DB full-text, or a plugin) answers; if none
|
||||
is configured the server returns 501.
|
||||
|
||||
```python
|
||||
res = client.search.search({"q": "invoice", "sessionId": "my-session", "limit": 20})
|
||||
for hit in res["hits"]:
|
||||
print(hit["snippet"], hit["score"])
|
||||
```
|
||||
|
||||
## Messaging
|
||||
|
||||
> Voice notes: pass `ptt=True` inside the body dict to `send_audio` to send a real WhatsApp voice note (PTT). Supply `audio/ogg; codecs=opus` audio for reliable playback; the server defaults the mimetype to that when `ptt` is set without one.
|
||||
|
||||
## Errors
|
||||
|
||||
A non-2xx response raises a typed `OpenWAApiError` subclass — `OpenWAAuthError` (401),
|
||||
`OpenWAForbiddenError` (403), `OpenWANotFoundError` (404), `OpenWAConflictError` (409),
|
||||
`OpenWARateLimitError` (429), `OpenWANotImplementedError` (501) — each carrying `.status`
|
||||
and the parsed `.body`. A timeout raises `OpenWATimeoutError`.
|
||||
|
||||
```python
|
||||
from openwa import OpenWANotFoundError
|
||||
|
||||
try:
|
||||
client.sessions.get("missing")
|
||||
except OpenWANotFoundError as e:
|
||||
print(e.status) # 404
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Use HTTPS in production** — the API key is sent as `X-API-Key` and is bearer-equivalent.
|
||||
- The SDK does **not** retry, and **never follows redirects** (so the key is never re-sent to
|
||||
a redirect target). Path segments are percent-encoded; a base-URL path prefix (e.g. behind a
|
||||
reverse proxy) is preserved.
|
||||
- Escape hatch for endpoints the SDK does not wrap:
|
||||
`client.request(method, path, query=…, body=…)`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
OpenWA Python SDK.
|
||||
|
||||
Official client library for the OpenWA WhatsApp API Gateway.
|
||||
|
||||
Example usage::
|
||||
|
||||
from openwa import OpenWAClient
|
||||
|
||||
client = OpenWAClient(
|
||||
base_url="http://localhost:2785",
|
||||
api_key="owa_k1_…",
|
||||
)
|
||||
|
||||
client.sessions.start("my-session")
|
||||
result = client.messages.send_text("my-session", {
|
||||
"chatId": "628123456789@c.us",
|
||||
"text": "Hello from the OpenWA Python SDK!",
|
||||
})
|
||||
print(result["messageId"])
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .client import OpenWAClient
|
||||
from .errors import (
|
||||
OpenWAApiError,
|
||||
OpenWAAuthError,
|
||||
OpenWAConflictError,
|
||||
OpenWAError,
|
||||
OpenWAForbiddenError,
|
||||
OpenWANotFoundError,
|
||||
OpenWANotImplementedError,
|
||||
OpenWARateLimitError,
|
||||
OpenWATimeoutError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OpenWAClient",
|
||||
"OpenWAError",
|
||||
"OpenWAApiError",
|
||||
"OpenWAAuthError",
|
||||
"OpenWAForbiddenError",
|
||||
"OpenWANotFoundError",
|
||||
"OpenWAConflictError",
|
||||
"OpenWARateLimitError",
|
||||
"OpenWANotImplementedError",
|
||||
"OpenWATimeoutError",
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""HTTP transport wrapper for the OpenWA Python SDK.
|
||||
|
||||
The client never builds a bare :class:`httpx.Client` with a hard-coded
|
||||
transport. Instead it accepts an optional ``transport`` (an ``httpx.BaseTransport``)
|
||||
that overrides the default. This makes the SDK trivially testable — a test
|
||||
passes ``httpx.MockTransport(handler)`` instead of monkey-patching — and lets
|
||||
consumers intercept/observability-wrap outbound calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import OpenWAApiError, OpenWATimeoutError, classify
|
||||
|
||||
HttpMethod = str # "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
|
||||
|
||||
|
||||
def quote_segment(segment: Any) -> str:
|
||||
"""Percent-encode a single path segment so a value containing ``/``, ``#`` or
|
||||
``?`` can't break out of its path position. WhatsApp-id characters that are
|
||||
already path-safe (``@``, ``:``, ``+``) are kept readable.
|
||||
"""
|
||||
return quote(str(segment), safe="@:+")
|
||||
|
||||
|
||||
def build_url(base_url: str, path: str, query: Mapping[str, Any] | None = None) -> str:
|
||||
"""Build a URL, serializing query params and skipping ``None`` values."""
|
||||
url = f"{base_url.rstrip('/')}{path}"
|
||||
if not query:
|
||||
return url
|
||||
|
||||
def _serialize(v: Any) -> str:
|
||||
# Booleans must be lowercase: the backend reads query flags as `=== 'true'`,
|
||||
# so Python's default str(True) == 'True' would be silently ignored.
|
||||
if v is True:
|
||||
return "true"
|
||||
if v is False:
|
||||
return "false"
|
||||
return str(v)
|
||||
|
||||
params = {k: _serialize(v) for k, v in query.items() if v is not None}
|
||||
if not params:
|
||||
return url
|
||||
req = httpx.Request("GET", url, params=params)
|
||||
# httpx.Request already encoded params into the URL string.
|
||||
return str(req.url)
|
||||
|
||||
|
||||
class HttpExecutor:
|
||||
"""Owns the :class:`httpx.Client` and performs JSON requests.
|
||||
|
||||
Constructed once per :class:`OpenWAClient`; the transport is taken from
|
||||
the client config so all requests share one connection pool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
timeout: float = 30.0,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
# Caller-supplied default headers are applied FIRST so the auth/JSON
|
||||
# headers below always win and can never be clobbered (mirrors the JS SDK).
|
||||
headers: dict[str, str] = {}
|
||||
if default_headers:
|
||||
headers.update(default_headers)
|
||||
headers["Content-Type"] = "application/json"
|
||||
headers["X-API-Key"] = api_key
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"base_url": base_url.rstrip("/"),
|
||||
"headers": headers,
|
||||
"timeout": timeout,
|
||||
# Never auto-follow redirects: doing so would re-send the X-API-Key
|
||||
# header to the redirect target (potentially a different origin).
|
||||
# (This is also httpx's default; set explicitly so it can't regress.)
|
||||
"follow_redirects": False,
|
||||
}
|
||||
if transport is not None:
|
||||
client_kwargs["transport"] = transport
|
||||
self._client = httpx.Client(**client_kwargs)
|
||||
self._timeout = timeout
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "HttpExecutor":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def request(self, method: HttpMethod, path: str, *, query: Mapping[str, Any] | None = None, body: Any = None) -> Any:
|
||||
"""Perform one request and return the parsed JSON (or ``None`` for 204)."""
|
||||
url = build_url("", path, query)
|
||||
try:
|
||||
res = self._client.request(method, url, json=body if body is not None else None)
|
||||
except httpx.TimeoutException as e:
|
||||
raise OpenWATimeoutError(self._timeout) from e
|
||||
# Treat any non-2xx as an error, including 3xx: redirects are deliberately not followed
|
||||
# (so the API key is never re-sent to the target), which makes an unfollowed 3xx unusable
|
||||
# rather than a success. Matches the JS transport's `!res.ok`.
|
||||
if res.status_code >= 300:
|
||||
context = f"{method} {path}"
|
||||
raise OpenWAApiError.from_response(res.status_code, res.text, context)
|
||||
if res.status_code == 204 or not res.content:
|
||||
return None
|
||||
try:
|
||||
return res.json()
|
||||
except ValueError:
|
||||
# A 2xx body that isn't JSON surfaces as text rather than a raw
|
||||
# JSONDecodeError (mirrors the JS and PHP transports).
|
||||
return res.text
|
||||
@@ -0,0 +1,179 @@
|
||||
"""OpenWA Python SDK — client core.
|
||||
|
||||
The :class:`OpenWAClient` is the single entry point. It owns an
|
||||
:class:`HttpExecutor` (which wraps :class:`httpx.Client` with an injectable
|
||||
transport) and exposes domain resources as properties::
|
||||
|
||||
from openwa import OpenWAClient
|
||||
|
||||
client = OpenWAClient(
|
||||
base_url="http://localhost:2785",
|
||||
api_key="owa_k1_…",
|
||||
)
|
||||
|
||||
client.sessions.start("my-session")
|
||||
client.messages.send_text("my-session", {
|
||||
"chatId": "628123456789@c.us",
|
||||
"text": "Hello from the OpenWA SDK!",
|
||||
})
|
||||
|
||||
Pass ``transport=httpx.MockTransport(handler)`` for testability — no global
|
||||
monkey-patching required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from types import TracebackType
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ._http import HttpExecutor, HttpMethod
|
||||
from .resources import (
|
||||
CatalogResource,
|
||||
ChannelsResource,
|
||||
ChatsResource,
|
||||
ContactsResource,
|
||||
GroupsResource,
|
||||
HealthResource,
|
||||
LabelsResource,
|
||||
MessagesResource,
|
||||
SearchResource,
|
||||
SessionsResource,
|
||||
StatusResource,
|
||||
TemplatesResource,
|
||||
WebhooksResource,
|
||||
)
|
||||
from .types import AuthValidateResponse
|
||||
|
||||
|
||||
_LOCALHOST_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _warn_if_insecure_http(url: str) -> None:
|
||||
"""Warn (not raise) when base_url is http:// and the host is not localhost.
|
||||
|
||||
The API key is sent as an X-API-Key header on every request — over plaintext http
|
||||
to a non-local host that's cleartext on the wire. Warning (not refusing) keeps local
|
||||
dev and TLS-terminating-proxy topologies working.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
host = (parsed.hostname or "").strip("[]")
|
||||
if parsed.scheme == "http" and host not in _LOCALHOST_HOSTS:
|
||||
warnings.warn(
|
||||
f"OpenWAClient: base_url uses an insecure http:// URL (host: {host}). "
|
||||
"The API key will be sent in cleartext. Use https:// in production.",
|
||||
stacklevel=3,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class OpenWAClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
if not base_url:
|
||||
raise ValueError("OpenWAClient: base_url is required")
|
||||
if not api_key:
|
||||
raise ValueError("OpenWAClient: api_key is required")
|
||||
_warn_if_insecure_http(base_url)
|
||||
self._http = HttpExecutor(
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
default_headers=default_headers,
|
||||
transport=transport,
|
||||
)
|
||||
|
||||
# ── Resources ────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def sessions(self) -> SessionsResource:
|
||||
return SessionsResource(self._http)
|
||||
|
||||
@property
|
||||
def messages(self) -> MessagesResource:
|
||||
return MessagesResource(self._http)
|
||||
|
||||
@property
|
||||
def contacts(self) -> ContactsResource:
|
||||
return ContactsResource(self._http)
|
||||
|
||||
@property
|
||||
def groups(self) -> GroupsResource:
|
||||
return GroupsResource(self._http)
|
||||
|
||||
@property
|
||||
def webhooks(self) -> WebhooksResource:
|
||||
return WebhooksResource(self._http)
|
||||
|
||||
@property
|
||||
def chats(self) -> ChatsResource:
|
||||
return ChatsResource(self._http)
|
||||
|
||||
@property
|
||||
def status(self) -> StatusResource:
|
||||
return StatusResource(self._http)
|
||||
|
||||
@property
|
||||
def health(self) -> HealthResource:
|
||||
return HealthResource(self._http)
|
||||
|
||||
@property
|
||||
def labels(self) -> LabelsResource:
|
||||
return LabelsResource(self._http)
|
||||
|
||||
@property
|
||||
def channels(self) -> ChannelsResource:
|
||||
return ChannelsResource(self._http)
|
||||
|
||||
@property
|
||||
def catalog(self) -> CatalogResource:
|
||||
return CatalogResource(self._http)
|
||||
|
||||
@property
|
||||
def templates(self) -> TemplatesResource:
|
||||
return TemplatesResource(self._http)
|
||||
|
||||
@property
|
||||
def search(self) -> SearchResource:
|
||||
return SearchResource(self._http)
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────
|
||||
|
||||
def auth(self) -> AuthValidateResponse:
|
||||
return self._http.request("POST", "/api/auth/validate")
|
||||
|
||||
# ── Raw request escape hatch ─────────────────────────────────────
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
query: Mapping[str, Any] | None = None,
|
||||
body: Any = None,
|
||||
) -> Any:
|
||||
"""Issue a raw request against the API (advanced use). ``path`` begins with ``/``."""
|
||||
return self._http.request(method, path, query=query, body=body)
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
def close(self) -> None:
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> "OpenWAClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: Any) -> None:
|
||||
self.close()
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Typed error hierarchy for the OpenWA Python SDK.
|
||||
|
||||
The OpenWA API returns NestJS-default errors of the shape::
|
||||
|
||||
{"statusCode": int, "message": str | list[str], "error": str}
|
||||
|
||||
This module maps that to a typed, ergonomic error tree so callers can
|
||||
``isinstance``-check or branch on ``.status``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class OpenWAError(Exception):
|
||||
"""Base class for every error raised by the SDK."""
|
||||
|
||||
|
||||
class OpenWAApiError(OpenWAError):
|
||||
"""Raised when the API responds with a non-2xx status.
|
||||
|
||||
Attributes:
|
||||
status: HTTP status code.
|
||||
body: Parsed JSON body if available, otherwise the raw text.
|
||||
error_kind: Value of the ``error`` field in the NestJS envelope.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int, body: Any = None, error_kind: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.error_kind = error_kind
|
||||
|
||||
@classmethod
|
||||
def from_response(cls, status_code: int, text: str, context: str) -> "OpenWAApiError":
|
||||
import json
|
||||
|
||||
body: Any = None
|
||||
if text:
|
||||
try:
|
||||
body = json.loads(text)
|
||||
except ValueError:
|
||||
body = text
|
||||
envelope = body if isinstance(body, dict) and "statusCode" in body else None
|
||||
raw_message = envelope.get("message") if envelope else body
|
||||
if isinstance(raw_message, list):
|
||||
message_text = ", ".join(str(m) for m in raw_message)
|
||||
elif isinstance(raw_message, str):
|
||||
message_text = raw_message
|
||||
else:
|
||||
message_text = str(raw_message)
|
||||
message = f"OpenWA API {status_code} — {context}: {message_text}"
|
||||
return classify(status_code, message, body, envelope.get("error") if envelope else None)
|
||||
|
||||
|
||||
class OpenWAAuthError(OpenWAApiError):
|
||||
"""401 Unauthorized — missing or invalid API key."""
|
||||
|
||||
|
||||
class OpenWAForbiddenError(OpenWAApiError):
|
||||
"""403 Forbidden — insufficient role."""
|
||||
|
||||
|
||||
class OpenWANotFoundError(OpenWAApiError):
|
||||
"""404 Not Found."""
|
||||
|
||||
|
||||
class OpenWAConflictError(OpenWAApiError):
|
||||
"""409 Conflict — typically an engine-not-ready condition."""
|
||||
|
||||
|
||||
class OpenWARateLimitError(OpenWAApiError):
|
||||
"""429 Too Many Requests."""
|
||||
|
||||
|
||||
class OpenWANotImplementedError(OpenWAApiError):
|
||||
"""501 Not Implemented — the active engine does not support this operation."""
|
||||
|
||||
|
||||
class OpenWATimeoutError(OpenWAError):
|
||||
"""Raised when a request exceeds the configured timeout."""
|
||||
|
||||
def __init__(self, timeout: float) -> None:
|
||||
super().__init__(f"Request timed out after {timeout}s")
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def classify(status: int, message: str, body: Any, error_kind: str | None) -> OpenWAApiError:
|
||||
"""Pick the most specific :class:`OpenWAApiError` subclass for a status."""
|
||||
cls = {
|
||||
401: OpenWAAuthError,
|
||||
403: OpenWAForbiddenError,
|
||||
404: OpenWANotFoundError,
|
||||
409: OpenWAConflictError,
|
||||
429: OpenWARateLimitError,
|
||||
501: OpenWANotImplementedError,
|
||||
}.get(status, OpenWAApiError)
|
||||
return cls(message, status, body, error_kind)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Resource modules for the OpenWA Python SDK.
|
||||
|
||||
Each module defines a small ``_*Resource`` class whose methods map 1:1 to an
|
||||
API path group. They are constructed by :class:`openwa.client.OpenWAClient`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .catalog import CatalogResource
|
||||
from .channels import ChannelsResource
|
||||
from .chats import ChatsResource
|
||||
from .contacts import ContactsResource
|
||||
from .groups import GroupsResource
|
||||
from .health import HealthResource
|
||||
from .labels import LabelsResource
|
||||
from .messages import MessagesResource
|
||||
from .search import SearchResource
|
||||
from .sessions import SessionsResource
|
||||
from .status import StatusResource
|
||||
from .templates import TemplatesResource
|
||||
from .webhooks import WebhooksResource
|
||||
|
||||
__all__ = [
|
||||
"CatalogResource",
|
||||
"ChannelsResource",
|
||||
"ChatsResource",
|
||||
"ContactsResource",
|
||||
"GroupsResource",
|
||||
"HealthResource",
|
||||
"LabelsResource",
|
||||
"MessagesResource",
|
||||
"SearchResource",
|
||||
"SessionsResource",
|
||||
"StatusResource",
|
||||
"TemplatesResource",
|
||||
"WebhooksResource",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Catalog resource — WhatsApp Business catalog, products, and product/catalog sends.
|
||||
|
||||
Backed by ``src/modules/catalog/catalog.controller.ts``
|
||||
(``@Controller('sessions/:sessionId')``). NOTE: the catalog controller is
|
||||
mounted under the session root, so catalog reads are ``/catalog...`` while
|
||||
product/catalog SENDS share the messages namespace
|
||||
(``/messages/send-product``, ``/messages/send-catalog``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
CatalogInfo,
|
||||
CatalogProduct,
|
||||
CatalogProductsQuery,
|
||||
MessageResponse,
|
||||
PaginatedProducts,
|
||||
SendCatalogRequest,
|
||||
SendProductRequest,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class CatalogResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def info(self, session_id: str) -> CatalogInfo:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/catalog")
|
||||
|
||||
def products(
|
||||
self, session_id: str, query: CatalogProductsQuery | None = None
|
||||
) -> PaginatedProducts:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/catalog/products", query=query
|
||||
)
|
||||
|
||||
def product(self, session_id: str, product_id: str) -> CatalogProduct:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/catalog/products/{quote_segment(product_id)}"
|
||||
)
|
||||
|
||||
def send_product(self, session_id: str, body: SendProductRequest) -> MessageResponse:
|
||||
"""Send a product message. Requires an OPERATOR-level key. Shares the messages path."""
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-product", body=body
|
||||
)
|
||||
|
||||
def send_catalog(self, session_id: str, body: SendCatalogRequest) -> MessageResponse:
|
||||
"""Send a catalog link message. Requires an OPERATOR-level key. Shares the messages path."""
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-catalog", body=body
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Channels resource — WhatsApp Channels / Newsletters.
|
||||
|
||||
Backed by ``src/modules/channel/channel.controller.ts``
|
||||
(``@Controller('sessions/:sessionId/channels')``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import ChannelMessageQuery, ChannelRecord, MessageRecord, SubscribeChannelRequest, SuccessResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class ChannelsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str) -> list[ChannelRecord]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/channels")
|
||||
|
||||
def get(self, session_id: str, channel_id: str) -> ChannelRecord:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/channels/{quote_segment(channel_id)}")
|
||||
|
||||
def messages(
|
||||
self, session_id: str, channel_id: str, query: ChannelMessageQuery | None = None
|
||||
) -> list[MessageRecord]:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/channels/{quote_segment(channel_id)}/messages", query=query
|
||||
)
|
||||
|
||||
def subscribe(self, session_id: str, body: SubscribeChannelRequest) -> ChannelRecord:
|
||||
"""Subscribe to a channel using its invite code. Requires an OPERATOR-level key."""
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/channels/subscribe", body=body)
|
||||
|
||||
def unsubscribe(self, session_id: str, channel_id: str) -> SuccessResult:
|
||||
"""Unsubscribe from a channel. Requires an OPERATOR-level key."""
|
||||
return self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}/channels/{quote_segment(channel_id)}")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Chats resource — chat-list operations (read/unread/delete/typing state).
|
||||
|
||||
These endpoints live under the session controller (``/api/sessions/:id/chats/*``)
|
||||
but are surfaced here as a dedicated resource for clarity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
ChatSummary,
|
||||
DeleteChatRequest,
|
||||
MarkChatRequest,
|
||||
SendChatStateRequest,
|
||||
SuccessResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class ListChatsQuery(TypedDict, total=False):
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ChatsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str, query: ListChatsQuery | None = None) -> list[ChatSummary]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/chats", query=query)
|
||||
|
||||
def mark_read(self, session_id: str, body: MarkChatRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/chats/read", body=body)
|
||||
|
||||
def mark_unread(self, session_id: str, body: MarkChatRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/chats/unread", body=body)
|
||||
|
||||
def delete(self, session_id: str, body: DeleteChatRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/chats/delete", body=body)
|
||||
|
||||
def send_state(self, session_id: str, body: SendChatStateRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/chats/typing", body=body)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Contacts resource — contact lookup and management.
|
||||
|
||||
Backed by ``src/modules/contact/contact.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
CheckNumberResponse,
|
||||
ContactPhoneResponse,
|
||||
ContactRecord,
|
||||
ProfilePictureResponse,
|
||||
SuccessResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class ListContactsQuery(TypedDict, total=False):
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ContactsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str, query: ListContactsQuery | None = None) -> list[ContactRecord]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/contacts", query=query)
|
||||
|
||||
def get(self, session_id: str, contact_id: str) -> ContactRecord:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/contacts/{quote_segment(contact_id)}")
|
||||
|
||||
def check(self, session_id: str, number: str) -> CheckNumberResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/contacts/check/{quote_segment(number)}")
|
||||
|
||||
def profile_picture(self, session_id: str, contact_id: str) -> ProfilePictureResponse:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/contacts/{quote_segment(contact_id)}/profile-picture"
|
||||
)
|
||||
|
||||
def phone(self, session_id: str, contact_id: str) -> ContactPhoneResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/contacts/{quote_segment(contact_id)}/phone")
|
||||
|
||||
def block(self, session_id: str, contact_id: str) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/contacts/{quote_segment(contact_id)}/block")
|
||||
|
||||
def unblock(self, session_id: str, contact_id: str) -> SuccessResult:
|
||||
return self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}/contacts/{quote_segment(contact_id)}/block")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Groups resource — WhatsApp group management.
|
||||
|
||||
Backed by ``src/modules/group/group.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
CreateGroupRequest,
|
||||
GroupInfo,
|
||||
GroupSummary,
|
||||
InviteCodeResponse,
|
||||
SuccessResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class ListGroupsQuery(TypedDict, total=False):
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class GroupsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str, query: ListGroupsQuery | None = None) -> list[GroupSummary]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/groups", query=query)
|
||||
|
||||
def get(self, session_id: str, group_id: str) -> GroupInfo:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}")
|
||||
|
||||
def create(self, session_id: str, body: CreateGroupRequest) -> GroupInfo:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/groups", body=body)
|
||||
|
||||
def add_participants(self, session_id: str, group_id: str, participants: list[str]) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/participants",
|
||||
body={"participants": participants},
|
||||
)
|
||||
|
||||
def remove_participants(self, session_id: str, group_id: str, participants: list[str]) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"DELETE", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/participants",
|
||||
body={"participants": participants},
|
||||
)
|
||||
|
||||
def promote_participants(self, session_id: str, group_id: str, participants: list[str]) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/participants/promote",
|
||||
body={"participants": participants},
|
||||
)
|
||||
|
||||
def demote_participants(self, session_id: str, group_id: str, participants: list[str]) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/participants/demote",
|
||||
body={"participants": participants},
|
||||
)
|
||||
|
||||
def set_subject(self, session_id: str, group_id: str, subject: str) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"PUT", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/subject", body={"subject": subject}
|
||||
)
|
||||
|
||||
def set_description(self, session_id: str, group_id: str, description: str) -> SuccessResult:
|
||||
return self._http.request(
|
||||
"PUT", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/description",
|
||||
body={"description": description},
|
||||
)
|
||||
|
||||
def leave(self, session_id: str, group_id: str) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/leave")
|
||||
|
||||
def invite_code(self, session_id: str, group_id: str) -> InviteCodeResponse:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/invite-code"
|
||||
)
|
||||
|
||||
def revoke_invite_code(self, session_id: str, group_id: str) -> InviteCodeResponse:
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/groups/{quote_segment(group_id)}/invite-code/revoke"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Health resource — connectivity and readiness probes.
|
||||
|
||||
Backed by ``src/modules/health/health.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..types import HealthReadyResponse, HealthResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class HealthResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def check(self) -> HealthResponse:
|
||||
return self._http.request("GET", "/api/health")
|
||||
|
||||
def live(self) -> dict[str, str]:
|
||||
return self._http.request("GET", "/api/health/live")
|
||||
|
||||
def ready(self) -> HealthReadyResponse:
|
||||
return self._http.request("GET", "/api/health/ready")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Labels resource — WhatsApp Business chat labels.
|
||||
|
||||
Backed by ``src/modules/label/label.controller.ts``
|
||||
(``@Controller('sessions/:sessionId/labels')``). Labels are a WhatsApp Business
|
||||
feature; the session must be a business account.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import AddLabelRequest, LabelRecord, SuccessResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class LabelsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str) -> list[LabelRecord]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/labels")
|
||||
|
||||
def get(self, session_id: str, label_id: str) -> LabelRecord:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/labels/{quote_segment(label_id)}")
|
||||
|
||||
def for_chat(self, session_id: str, chat_id: str) -> list[LabelRecord]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/labels/chat/{quote_segment(chat_id)}")
|
||||
|
||||
def add_to_chat(self, session_id: str, chat_id: str, body: AddLabelRequest) -> SuccessResult:
|
||||
"""Add a label to a chat. Requires an OPERATOR-level key."""
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/labels/chat/{quote_segment(chat_id)}", body=body)
|
||||
|
||||
def remove_from_chat(self, session_id: str, chat_id: str, label_id: str) -> SuccessResult:
|
||||
"""Remove a label from a chat. Requires an OPERATOR-level key."""
|
||||
return self._http.request(
|
||||
"DELETE", f"/api/sessions/{quote_segment(session_id)}/labels/chat/{quote_segment(chat_id)}/{quote_segment(label_id)}"
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Messages resource — sending and querying messages.
|
||||
|
||||
Backed by ``src/modules/message/message.controller.ts``.
|
||||
NOTE: the real paths use the ``/send-`` prefix, e.g. ``/messages/send-text``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
BatchStatusResponse,
|
||||
BulkMessageResponse,
|
||||
ChatHistoryMessage,
|
||||
DeleteMessageRequest,
|
||||
ForwardMessageRequest,
|
||||
ListMessagesQuery,
|
||||
MessageHistoryQuery,
|
||||
MessageListResponse,
|
||||
MessageResponse,
|
||||
ReactionRecord,
|
||||
ReactMessageRequest,
|
||||
ReplyMessageRequest,
|
||||
SendBulkRequest,
|
||||
SendContactRequest,
|
||||
SendLocationRequest,
|
||||
SendMediaRequest,
|
||||
SendTemplateRequest,
|
||||
SendTextRequest,
|
||||
SuccessResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class MessagesResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str, query: ListMessagesQuery | None = None) -> MessageListResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/messages", query=query)
|
||||
|
||||
def send_text(self, session_id: str, body: SendTextRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-text", body=body)
|
||||
|
||||
def send_image(self, session_id: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._send_media(session_id, "send-image", body)
|
||||
|
||||
def send_video(self, session_id: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._send_media(session_id, "send-video", body)
|
||||
|
||||
def send_audio(self, session_id: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._send_media(session_id, "send-audio", body)
|
||||
|
||||
def send_document(self, session_id: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._send_media(session_id, "send-document", body)
|
||||
|
||||
def send_sticker(self, session_id: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._send_media(session_id, "send-sticker", body)
|
||||
|
||||
def _send_media(self, session_id: str, segment: str, body: SendMediaRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/{quote_segment(segment)}", body=body)
|
||||
|
||||
def send_location(self, session_id: str, body: SendLocationRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-location", body=body)
|
||||
|
||||
def send_contact(self, session_id: str, body: SendContactRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-contact", body=body)
|
||||
|
||||
def send_template(self, session_id: str, body: SendTemplateRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-template", body=body)
|
||||
|
||||
def reply(self, session_id: str, body: ReplyMessageRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/reply", body=body)
|
||||
|
||||
def forward(self, session_id: str, body: ForwardMessageRequest) -> MessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/forward", body=body)
|
||||
|
||||
def react(self, session_id: str, body: ReactMessageRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/react", body=body)
|
||||
|
||||
def delete(self, session_id: str, body: DeleteMessageRequest) -> SuccessResult:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/delete", body=body)
|
||||
|
||||
def history(
|
||||
self, session_id: str, chat_id: str, query: MessageHistoryQuery | None = None
|
||||
) -> list[ChatHistoryMessage]:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/messages/{quote_segment(chat_id)}/history", query=query
|
||||
)
|
||||
|
||||
def reactions(self, session_id: str, chat_id: str, message_id: str) -> list[ReactionRecord]:
|
||||
return self._http.request(
|
||||
"GET", f"/api/sessions/{quote_segment(session_id)}/messages/{quote_segment(chat_id)}/{quote_segment(message_id)}/reactions"
|
||||
)
|
||||
|
||||
def send_bulk(self, session_id: str, body: SendBulkRequest) -> BulkMessageResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/messages/send-bulk", body=body)
|
||||
|
||||
def batch_status(self, session_id: str, batch_id: str) -> BatchStatusResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/messages/batch/{quote_segment(batch_id)}")
|
||||
|
||||
def cancel_batch(self, session_id: str, batch_id: str) -> BatchStatusResponse:
|
||||
"""Cancel a running batch. Requires an OPERATOR-level key."""
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/messages/batch/{quote_segment(batch_id)}/cancel"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Search resource — full-text message search across sessions.
|
||||
|
||||
Backed by ``src/modules/search/search.controller.ts`` (``GET /search``).
|
||||
The active search provider (built-in DB full-text or a plugin) answers; if none
|
||||
is configured the server returns 501.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..types import SearchQueryParams, SearchResults
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class SearchResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def search(self, params: SearchQueryParams) -> SearchResults:
|
||||
"""Search messages across sessions. ``q`` is required; all other fields optional."""
|
||||
return self._http.request("GET", "/api/search", query=params)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Sessions resource — lifecycle management for WhatsApp sessions.
|
||||
|
||||
Backed by ``src/modules/session/session.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import (
|
||||
CreateSessionRequest,
|
||||
PairingCodeResponse,
|
||||
QrCodeResponse,
|
||||
RequestPairingCodeRequest,
|
||||
SessionResponse,
|
||||
SessionStatsOverview,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class SessionsResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self) -> list[SessionResponse]:
|
||||
return self._http.request("GET", "/api/sessions")
|
||||
|
||||
def get(self, session_id: str) -> SessionResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}")
|
||||
|
||||
def create(self, body: CreateSessionRequest) -> SessionResponse:
|
||||
return self._http.request("POST", "/api/sessions", body=body)
|
||||
|
||||
def delete(self, session_id: str) -> None:
|
||||
self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}")
|
||||
|
||||
def start(self, session_id: str) -> SessionResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/start")
|
||||
|
||||
def stop(self, session_id: str) -> SessionResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/stop")
|
||||
|
||||
def force_kill(self, session_id: str) -> SessionResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/force-kill")
|
||||
|
||||
def get_qr_code(self, session_id: str) -> QrCodeResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/qr")
|
||||
|
||||
def request_pairing_code(self, session_id: str, body: RequestPairingCodeRequest) -> PairingCodeResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/pairing-code", body=body)
|
||||
|
||||
def stats(self) -> SessionStatsOverview:
|
||||
return self._http.request("GET", "/api/sessions/stats/overview")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Status (Stories) resource — WhatsApp status updates.
|
||||
|
||||
Backed by ``src/modules/status/status.controller.ts``.
|
||||
NOTE: this is WhatsApp "Status/Stories", distinct from session lifecycle status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import SendImageStatusRequest, SendTextStatusRequest, SendVideoStatusRequest, StatusRecord
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class StatusResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str) -> dict[str, list[StatusRecord]]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/status")
|
||||
|
||||
def from_contact(self, session_id: str, contact_id: str) -> dict[str, list[StatusRecord]]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/status/{quote_segment(contact_id)}")
|
||||
|
||||
def send_text(self, session_id: str, body: SendTextStatusRequest) -> StatusRecord:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/status/send-text", body=body)
|
||||
|
||||
def send_image(self, session_id: str, body: SendImageStatusRequest) -> StatusRecord:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/status/send-image", body=body)
|
||||
|
||||
def send_video(self, session_id: str, body: SendVideoStatusRequest) -> StatusRecord:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/status/send-video", body=body)
|
||||
|
||||
def delete(self, session_id: str, status_id: str) -> None:
|
||||
self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}/status/{quote_segment(status_id)}")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Templates resource — stored message templates with ``{{variable}}`` placeholders.
|
||||
|
||||
Backed by ``src/modules/template/template.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import CreateTemplateRequest, TemplateRecord, UpdateTemplateRequest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class TemplatesResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str) -> list[TemplateRecord]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/templates")
|
||||
|
||||
def get(self, session_id: str, template_id: str) -> TemplateRecord:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/templates/{quote_segment(template_id)}")
|
||||
|
||||
def create(self, session_id: str, body: CreateTemplateRequest) -> TemplateRecord:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/templates", body=body)
|
||||
|
||||
def update(self, session_id: str, template_id: str, body: UpdateTemplateRequest) -> TemplateRecord:
|
||||
return self._http.request("PUT", f"/api/sessions/{quote_segment(session_id)}/templates/{quote_segment(template_id)}", body=body)
|
||||
|
||||
def delete(self, session_id: str, template_id: str) -> None:
|
||||
self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}/templates/{quote_segment(template_id)}")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Webhooks resource — configure event delivery to external HTTP endpoints.
|
||||
|
||||
Backed by ``src/modules/webhook/webhook.controller.ts``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._http import quote_segment
|
||||
from ..types import CreateWebhookRequest, UpdateWebhookRequest, WebhookResponse, WebhookTestResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._http import HttpExecutor
|
||||
|
||||
|
||||
class WebhooksResource:
|
||||
def __init__(self, http: "HttpExecutor") -> None:
|
||||
self._http = http
|
||||
|
||||
def list(self, session_id: str) -> list[WebhookResponse]:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/webhooks")
|
||||
|
||||
def get(self, session_id: str, webhook_id: str) -> WebhookResponse:
|
||||
return self._http.request("GET", f"/api/sessions/{quote_segment(session_id)}/webhooks/{quote_segment(webhook_id)}")
|
||||
|
||||
def create(self, session_id: str, body: CreateWebhookRequest) -> WebhookResponse:
|
||||
return self._http.request("POST", f"/api/sessions/{quote_segment(session_id)}/webhooks", body=body)
|
||||
|
||||
def update(self, session_id: str, webhook_id: str, body: UpdateWebhookRequest) -> WebhookResponse:
|
||||
return self._http.request(
|
||||
"PUT", f"/api/sessions/{quote_segment(session_id)}/webhooks/{quote_segment(webhook_id)}", body=body
|
||||
)
|
||||
|
||||
def delete(self, session_id: str, webhook_id: str) -> None:
|
||||
self._http.request("DELETE", f"/api/sessions/{quote_segment(session_id)}/webhooks/{quote_segment(webhook_id)}")
|
||||
|
||||
def test(self, session_id: str, webhook_id: str) -> WebhookTestResult:
|
||||
return self._http.request(
|
||||
"POST", f"/api/sessions/{quote_segment(session_id)}/webhooks/{quote_segment(webhook_id)}/test"
|
||||
)
|
||||
@@ -0,0 +1,750 @@
|
||||
"""Wire type definitions for the OpenWA API.
|
||||
|
||||
Hybrid codegen strategy: this module is the single source of truth for wire
|
||||
types — the part most prone to drift with the backend. It is structured so it
|
||||
can be regenerated by an OpenAPI codegen pass later without touching the
|
||||
hand-written resource methods (paths + DX live elsewhere).
|
||||
|
||||
Field names mirror the backend DTOs exactly (camelCase JSON).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional, TypedDict
|
||||
|
||||
Jid = str
|
||||
SessionStatus = Literal[
|
||||
"created", "initializing", "qr_ready", "authenticating", "ready", "disconnected", "failed"
|
||||
]
|
||||
ChatState = Literal["typing", "recording", "paused"]
|
||||
MessageDirection = Literal["incoming", "outgoing"]
|
||||
DeliveryStatus = Literal["pending", "sent", "delivered", "read", "failed"]
|
||||
BulkMessageType = Literal["text", "image", "video", "audio", "document"]
|
||||
WebhookEvent = Literal[
|
||||
"message.received", "message.sent", "message.ack", "message.failed", "message.revoked",
|
||||
"message.reaction", "session.status", "session.qr", "session.authenticated",
|
||||
"session.disconnected",
|
||||
# Reserved: accepted on subscribe but not dispatched yet.
|
||||
"group.join", "group.leave", "group.update",
|
||||
"*",
|
||||
]
|
||||
|
||||
|
||||
class SuccessResult(TypedDict, total=False):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ── Session ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SessionResponse(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
status: SessionStatus
|
||||
phone: str | None
|
||||
pushName: str | None
|
||||
connectedAt: str | None
|
||||
lastActive: str | None
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
lastError: str | None
|
||||
|
||||
|
||||
class CreateSessionRequest(TypedDict, total=False):
|
||||
name: str
|
||||
config: dict[str, Any]
|
||||
proxyUrl: str
|
||||
proxyType: Literal["http", "https", "socks4", "socks5"]
|
||||
|
||||
|
||||
class QrCodeResponse(TypedDict):
|
||||
qrCode: str
|
||||
status: SessionStatus
|
||||
|
||||
|
||||
class PairingCodeResponse(TypedDict):
|
||||
pairingCode: str
|
||||
status: str
|
||||
|
||||
|
||||
class RequestPairingCodeRequest(TypedDict):
|
||||
phoneNumber: str
|
||||
|
||||
|
||||
class MemoryUsage(TypedDict):
|
||||
heapUsed: int
|
||||
heapTotal: int
|
||||
rss: int
|
||||
|
||||
|
||||
class SessionStatsOverview(TypedDict, total=False):
|
||||
total: int
|
||||
active: int
|
||||
ready: int
|
||||
disconnected: int
|
||||
byStatus: dict[str, int]
|
||||
memoryUsage: MemoryUsage
|
||||
|
||||
|
||||
# ── Message ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class MessageResponse(TypedDict):
|
||||
messageId: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class SendTextRequest(TypedDict):
|
||||
chatId: Jid
|
||||
text: str
|
||||
|
||||
|
||||
class SendMediaRequest(TypedDict, total=False):
|
||||
chatId: Jid
|
||||
url: str
|
||||
base64: str
|
||||
mimetype: str
|
||||
filename: str
|
||||
caption: str
|
||||
ptt: bool # audio only: send as a WhatsApp voice note (PTT)
|
||||
|
||||
|
||||
class SendLocationRequest(TypedDict, total=False):
|
||||
# chatId/latitude/longitude required; description/address optional.
|
||||
chatId: Jid
|
||||
latitude: float
|
||||
longitude: float
|
||||
description: str
|
||||
address: str
|
||||
|
||||
|
||||
class SendContactRequest(TypedDict):
|
||||
chatId: Jid
|
||||
contactName: str
|
||||
contactNumber: str
|
||||
|
||||
|
||||
class ReplyMessageRequest(TypedDict):
|
||||
chatId: Jid
|
||||
quotedMessageId: str
|
||||
text: str
|
||||
|
||||
|
||||
class ForwardMessageRequest(TypedDict):
|
||||
fromChatId: Jid
|
||||
toChatId: Jid
|
||||
messageId: str
|
||||
|
||||
|
||||
class ReactMessageRequest(TypedDict):
|
||||
chatId: Jid
|
||||
messageId: str
|
||||
emoji: str
|
||||
|
||||
|
||||
class DeleteMessageRequest(TypedDict, total=False):
|
||||
# chatId/messageId required; forEveryone optional (default true).
|
||||
chatId: Jid
|
||||
messageId: str
|
||||
forEveryone: bool
|
||||
|
||||
|
||||
class SendTemplateRequest(TypedDict, total=False):
|
||||
# chatId required; provide exactly one of templateId / templateName.
|
||||
# Modeled total=False (callers pass plain dicts); the backend validates.
|
||||
chatId: Jid
|
||||
templateId: str
|
||||
templateName: str
|
||||
vars: dict[str, str]
|
||||
|
||||
|
||||
# ``from`` is a Python keyword, so use the functional TypedDict form.
|
||||
ListMessagesQuery = TypedDict(
|
||||
"ListMessagesQuery",
|
||||
{"chatId": Jid, "from": Jid, "limit": int, "offset": int},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class MessageHistoryQuery(TypedDict, total=False):
|
||||
limit: int
|
||||
includeMedia: bool
|
||||
deep: bool
|
||||
|
||||
|
||||
# ``from`` is a Python keyword, so use the functional TypedDict form (and
|
||||
# Optional[...] rather than ``X | None`` so the runtime values stay 3.9-safe).
|
||||
MessageRecord = TypedDict(
|
||||
"MessageRecord",
|
||||
{
|
||||
"id": str,
|
||||
"sessionId": str,
|
||||
"waMessageId": Optional[str],
|
||||
"chatId": Jid,
|
||||
"from": Jid,
|
||||
"to": Jid,
|
||||
"body": Optional[str],
|
||||
"type": str,
|
||||
"direction": MessageDirection,
|
||||
"timestamp": Optional[int],
|
||||
"metadata": dict,
|
||||
"status": DeliveryStatus,
|
||||
"createdAt": str,
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryMedia(TypedDict, total=False):
|
||||
mimetype: str
|
||||
filename: str
|
||||
data: str # base64; absent when the payload was omitted (too large)
|
||||
omitted: bool
|
||||
sizeBytes: int
|
||||
|
||||
|
||||
class QuotedMessage(TypedDict, total=False):
|
||||
id: str
|
||||
body: str
|
||||
|
||||
|
||||
class MessageLocation(TypedDict, total=False):
|
||||
latitude: float
|
||||
longitude: float
|
||||
description: str
|
||||
address: str
|
||||
url: str
|
||||
|
||||
|
||||
# A message read live from WhatsApp by ``messages.history()`` — the engine
|
||||
# payload, richer and differently shaped than the persisted MessageRecord.
|
||||
ChatHistoryMessage = TypedDict(
|
||||
"ChatHistoryMessage",
|
||||
{
|
||||
"id": str,
|
||||
"from": Jid,
|
||||
"to": Jid,
|
||||
"chatId": Jid,
|
||||
"body": str,
|
||||
"type": str,
|
||||
"timestamp": int,
|
||||
"fromMe": bool,
|
||||
"isGroup": bool,
|
||||
"isStatusBroadcast": bool,
|
||||
"author": Jid,
|
||||
"mentionedIds": list,
|
||||
"isLidSender": bool,
|
||||
"senderPhone": Optional[str],
|
||||
"media": ChatHistoryMedia,
|
||||
"quotedMessage": QuotedMessage,
|
||||
"location": MessageLocation,
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class MessageListResponse(TypedDict):
|
||||
"""Paginated payload returned by ``GET /sessions/:id/messages``."""
|
||||
|
||||
messages: list[MessageRecord]
|
||||
total: int
|
||||
|
||||
|
||||
class ReactionSender(TypedDict, total=False):
|
||||
senderId: Jid
|
||||
emoji: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class ReactionRecord(TypedDict, total=False):
|
||||
"""One emoji and everyone who reacted with it (server returns MessageReaction[])."""
|
||||
|
||||
emoji: str
|
||||
senders: list[ReactionSender]
|
||||
|
||||
|
||||
# ── Bulk ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BulkMessageContent(TypedDict, total=False):
|
||||
text: str
|
||||
image: SendMediaRequest
|
||||
video: SendMediaRequest
|
||||
audio: SendMediaRequest
|
||||
document: SendMediaRequest
|
||||
caption: str
|
||||
|
||||
|
||||
class BulkMessageItem(TypedDict, total=False):
|
||||
# chatId/type/content required; variables optional.
|
||||
chatId: Jid
|
||||
type: BulkMessageType
|
||||
content: BulkMessageContent
|
||||
variables: dict[str, str]
|
||||
|
||||
|
||||
class BulkOptions(TypedDict, total=False):
|
||||
delayBetweenMessages: int
|
||||
randomizeDelay: bool
|
||||
stopOnError: bool
|
||||
|
||||
|
||||
class _SendBulkRequired(TypedDict):
|
||||
messages: list[BulkMessageItem]
|
||||
|
||||
|
||||
class SendBulkRequest(_SendBulkRequired, total=False):
|
||||
# `options` and `batchId` are optional; the backend applies defaults.
|
||||
options: BulkOptions
|
||||
batchId: str
|
||||
|
||||
|
||||
class BulkMessageResponse(TypedDict):
|
||||
batchId: str
|
||||
status: str
|
||||
totalMessages: int
|
||||
estimatedCompletionTime: str
|
||||
statusUrl: str
|
||||
|
||||
|
||||
class BatchError(TypedDict, total=False):
|
||||
code: str
|
||||
message: str
|
||||
|
||||
|
||||
class BatchMessageResult(TypedDict, total=False):
|
||||
chatId: Jid
|
||||
status: str
|
||||
messageId: str
|
||||
sentAt: str
|
||||
error: BatchError
|
||||
|
||||
|
||||
class BatchProgress(TypedDict, total=False):
|
||||
total: int
|
||||
sent: int
|
||||
failed: int
|
||||
pending: int
|
||||
cancelled: int
|
||||
|
||||
|
||||
class BatchStatusResponse(TypedDict, total=False):
|
||||
"""Response from ``GET /messages/batch/:batchId`` and the cancel endpoint.
|
||||
|
||||
Distinct from :class:`BulkMessageResponse` (the send-bulk acknowledgement).
|
||||
"""
|
||||
|
||||
batchId: str
|
||||
status: str
|
||||
progress: BatchProgress
|
||||
results: list[BatchMessageResult]
|
||||
startedAt: str
|
||||
completedAt: str
|
||||
|
||||
|
||||
# ── Contact ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ContactRecord(TypedDict, total=False):
|
||||
id: Jid
|
||||
name: str | None
|
||||
number: str | None
|
||||
pushname: str | None
|
||||
isBusiness: bool
|
||||
isMyContact: bool
|
||||
|
||||
|
||||
class CheckNumberResponse(TypedDict):
|
||||
number: str
|
||||
exists: bool
|
||||
whatsappId: str | None
|
||||
|
||||
|
||||
class ProfilePictureResponse(TypedDict):
|
||||
url: str | None
|
||||
|
||||
|
||||
class ContactPhoneResponse(TypedDict):
|
||||
contactId: Jid
|
||||
phone: str | None
|
||||
|
||||
|
||||
# ── Group ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GroupParticipant(TypedDict, total=False):
|
||||
id: Jid
|
||||
number: str
|
||||
name: str
|
||||
isAdmin: bool
|
||||
isSuperAdmin: bool
|
||||
|
||||
|
||||
class GroupSummary(TypedDict, total=False):
|
||||
"""Item returned by ``GET /sessions/:id/groups`` (the slim list shape)."""
|
||||
|
||||
id: Jid
|
||||
name: str
|
||||
participantsCount: int
|
||||
isAdmin: bool
|
||||
linkedParentJID: str | None
|
||||
|
||||
|
||||
class GroupInfo(TypedDict, total=False):
|
||||
"""Full detail returned by ``GET /sessions/:id/groups/:groupId``."""
|
||||
|
||||
id: Jid
|
||||
name: str
|
||||
description: str | None
|
||||
owner: Jid | None
|
||||
createdAt: int
|
||||
participants: list[GroupParticipant]
|
||||
isReadOnly: bool
|
||||
isAnnounce: bool
|
||||
linkedParentJID: str | None
|
||||
|
||||
|
||||
class CreateGroupRequest(TypedDict):
|
||||
name: str
|
||||
participants: list[Jid]
|
||||
|
||||
|
||||
class InviteCodeResponse(TypedDict, total=False):
|
||||
inviteCode: str
|
||||
inviteLink: str
|
||||
message: str
|
||||
|
||||
|
||||
# ── Webhook ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WebhookFilterCondition(TypedDict):
|
||||
field: str
|
||||
operator: str
|
||||
value: list[str]
|
||||
|
||||
|
||||
class WebhookFilters(TypedDict):
|
||||
conditions: list[WebhookFilterCondition]
|
||||
|
||||
|
||||
class CreateWebhookRequest(TypedDict, total=False):
|
||||
url: str
|
||||
events: list[WebhookEvent]
|
||||
secret: str
|
||||
headers: dict[str, str]
|
||||
filters: WebhookFilters | None
|
||||
# Server DTO field is ``retryCount`` (0–5; default 3).
|
||||
retryCount: int
|
||||
|
||||
|
||||
class UpdateWebhookRequest(CreateWebhookRequest, total=False):
|
||||
active: bool
|
||||
|
||||
|
||||
class WebhookResponse(TypedDict, total=False):
|
||||
id: str
|
||||
sessionId: str
|
||||
url: str
|
||||
events: list[WebhookEvent]
|
||||
active: bool
|
||||
filters: WebhookFilters | None
|
||||
retryCount: int
|
||||
# ISO timestamp of the last delivery attempt, or None if never triggered.
|
||||
lastTriggeredAt: str | None
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
# NOTE: the server deliberately omits ``secret`` and ``headers`` from reads.
|
||||
|
||||
|
||||
class WebhookTestResult(TypedDict, total=False):
|
||||
success: bool
|
||||
statusCode: int
|
||||
error: str
|
||||
|
||||
|
||||
# ── Chat ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChatSummary(TypedDict, total=False):
|
||||
id: Jid
|
||||
name: str | None
|
||||
isGroup: bool
|
||||
unreadCount: int
|
||||
# Server returns a plain preview string, not a message object.
|
||||
lastMessage: str
|
||||
timestamp: str | int
|
||||
|
||||
|
||||
class MarkChatRequest(TypedDict):
|
||||
chatId: Jid
|
||||
|
||||
|
||||
class SendChatStateRequest(TypedDict):
|
||||
chatId: Jid
|
||||
state: ChatState
|
||||
|
||||
|
||||
class DeleteChatRequest(TypedDict):
|
||||
chatId: Jid
|
||||
|
||||
|
||||
# ── Status / Stories ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class StatusRecord(TypedDict, total=False):
|
||||
id: str
|
||||
statusId: str
|
||||
type: str
|
||||
body: str | None
|
||||
timestamp: str | int
|
||||
|
||||
|
||||
class SendTextStatusRequest(TypedDict, total=False):
|
||||
# text and recipients required; backgroundColor (hex, e.g. #25D366) and font optional.
|
||||
text: str
|
||||
# Recipient JIDs the status is addressed to (required by the server; empty -> 400).
|
||||
recipients: list[str]
|
||||
backgroundColor: str
|
||||
font: int
|
||||
|
||||
|
||||
class StatusMediaInput(TypedDict, total=False):
|
||||
"""Media payload for a status post: provide ``url`` OR ``base64``."""
|
||||
|
||||
url: str
|
||||
base64: str
|
||||
mimetype: str
|
||||
|
||||
|
||||
class SendImageStatusRequest(TypedDict, total=False):
|
||||
"""Server expects a nested ``{ image: { url|base64 } }`` body."""
|
||||
|
||||
image: StatusMediaInput
|
||||
# Recipient JIDs the status is addressed to (required by the server; empty -> 400).
|
||||
recipients: list[str]
|
||||
caption: str
|
||||
|
||||
|
||||
class SendVideoStatusRequest(TypedDict, total=False):
|
||||
"""Server expects a nested ``{ video: { url|base64 } }`` body."""
|
||||
|
||||
video: StatusMediaInput
|
||||
# Recipient JIDs the status is addressed to (required by the server; empty -> 400).
|
||||
recipients: list[str]
|
||||
caption: str
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class HealthResponse(TypedDict, total=False):
|
||||
status: str
|
||||
timestamp: str
|
||||
version: str
|
||||
|
||||
|
||||
class HealthReadyResponse(TypedDict, total=False):
|
||||
status: str
|
||||
details: dict[str, str]
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AuthValidateResponse(TypedDict, total=False):
|
||||
valid: bool
|
||||
role: str
|
||||
|
||||
|
||||
# ── Template ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TemplateRecord(TypedDict, total=False):
|
||||
id: str
|
||||
sessionId: str
|
||||
name: str
|
||||
body: str # template body with {{variable}} placeholders
|
||||
header: str | None
|
||||
footer: str | None
|
||||
createdAt: str
|
||||
updatedAt: str
|
||||
|
||||
|
||||
class CreateTemplateRequest(TypedDict, total=False):
|
||||
# name + body required; header/footer optional. Modeled total=False
|
||||
# (callers pass plain dicts); the backend validates the required fields.
|
||||
name: str
|
||||
body: str
|
||||
header: str
|
||||
footer: str
|
||||
|
||||
|
||||
class UpdateTemplateRequest(TypedDict, total=False):
|
||||
name: str
|
||||
body: str
|
||||
header: str
|
||||
footer: str
|
||||
|
||||
|
||||
# ── Label (WhatsApp Business) ─────────────────────────────────────
|
||||
|
||||
|
||||
class LabelRecord(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
colorHex: str
|
||||
|
||||
|
||||
class AddLabelRequest(TypedDict):
|
||||
labelId: str
|
||||
|
||||
|
||||
# ── Channel / Newsletter ──────────────────────────────────────────
|
||||
|
||||
|
||||
class ChannelRecord(TypedDict, total=False):
|
||||
id: Jid
|
||||
name: str
|
||||
description: str | None
|
||||
subscriberCount: int
|
||||
pictureUrl: str | None
|
||||
role: str
|
||||
|
||||
|
||||
class ChannelMessageQuery(TypedDict, total=False):
|
||||
"""Max messages to return (default 50)."""
|
||||
|
||||
limit: int
|
||||
|
||||
|
||||
class SubscribeChannelRequest(TypedDict):
|
||||
inviteCode: str
|
||||
|
||||
|
||||
# ── Catalog (Business) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class CatalogInfo(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
productCount: int
|
||||
url: str
|
||||
|
||||
|
||||
class CatalogProductsQuery(TypedDict, total=False):
|
||||
page: int
|
||||
limit: int
|
||||
|
||||
|
||||
class CatalogProduct(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
price: float
|
||||
currency: str
|
||||
priceFormatted: str
|
||||
imageUrl: str | None
|
||||
url: str
|
||||
isAvailable: bool
|
||||
retailerId: str
|
||||
|
||||
|
||||
class ProductPagination(TypedDict):
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
totalPages: int
|
||||
|
||||
|
||||
class PaginatedProducts(TypedDict):
|
||||
"""Paginated payload returned by ``GET /sessions/:id/catalog/products``."""
|
||||
|
||||
products: list[CatalogProduct]
|
||||
pagination: ProductPagination
|
||||
|
||||
|
||||
# chatId + productId required; body optional. Modeled total=False for 3.9 compat
|
||||
# (callers pass plain dicts); the backend validates the required fields.
|
||||
class SendProductRequest(TypedDict, total=False):
|
||||
chatId: Jid
|
||||
productId: str
|
||||
body: str
|
||||
|
||||
|
||||
# chatId required; body optional. See SendProductRequest note.
|
||||
class SendCatalogRequest(TypedDict, total=False):
|
||||
chatId: Jid
|
||||
body: str
|
||||
|
||||
|
||||
# ── Search ────────────────────────────────────────────────────────
|
||||
|
||||
# `q` is required; the remaining fields are optional. `from` is a Python
|
||||
# keyword, so the optional keys are declared via the functional TypedDict form
|
||||
# (mirrors ListMessagesQuery / MessageRecord). `dateFrom` / `dateTo` are
|
||||
# epoch-ms; the backend binds them against messages.timestamp (epoch-seconds),
|
||||
# dividing by 1000 internally.
|
||||
class _SearchQueryRequired(TypedDict):
|
||||
q: str
|
||||
|
||||
|
||||
_SearchQueryOptional = TypedDict(
|
||||
"_SearchQueryOptional",
|
||||
{
|
||||
"sessionId": str,
|
||||
"chatId": Jid,
|
||||
"direction": MessageDirection,
|
||||
"type": str,
|
||||
"from": Jid,
|
||||
"dateFrom": int,
|
||||
"dateTo": int,
|
||||
"limit": int,
|
||||
"offset": int,
|
||||
},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class SearchQueryParams(_SearchQueryRequired, _SearchQueryOptional):
|
||||
"""Query for ``GET /search``. ``q`` is required; every other field optional."""
|
||||
|
||||
|
||||
# `from` is a Python keyword → functional form for the required keys. The builtin
|
||||
# provider returns waMessageId/snippet as "" when absent (always str, never null);
|
||||
# `score` is provider-dependent (builtin always returns it) → optional.
|
||||
_SearchHitRequired = TypedDict(
|
||||
"_SearchHitRequired",
|
||||
{
|
||||
"messageId": str,
|
||||
"waMessageId": str,
|
||||
"sessionId": str,
|
||||
"chatId": Jid,
|
||||
"body": str,
|
||||
"snippet": str,
|
||||
"timestamp": int,
|
||||
"type": str,
|
||||
"direction": MessageDirection,
|
||||
"from": Jid,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SearchHit(_SearchHitRequired, total=False):
|
||||
score: float
|
||||
|
||||
|
||||
class SearchResults(TypedDict):
|
||||
"""Payload returned by ``GET /search``.
|
||||
|
||||
``total`` is a bounded exact count for pagination; ``provider`` is the id of
|
||||
the search provider that answered (e.g. ``builtin-fts``).
|
||||
"""
|
||||
|
||||
hits: list[SearchHit]
|
||||
total: int
|
||||
tookMs: int
|
||||
provider: str
|
||||
@@ -0,0 +1,40 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rmyndharis-openwa"
|
||||
version = "0.1.0"
|
||||
description = "Official Python SDK for OpenWA WhatsApp API Gateway"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
{ name = "OpenWA Contributors" }
|
||||
]
|
||||
keywords = ["whatsapp", "api", "sdk", "openwa", "gateway"]
|
||||
|
||||
dependencies = [
|
||||
"httpx>=0.25.0,<1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/rmyndharis/OpenWA"
|
||||
Repository = "https://github.com/rmyndharis/OpenWA"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["openwa*"]
|
||||
|
||||
# Ship type information (PEP 561) so consumers get the bundled type hints.
|
||||
[tool.setuptools.package-data]
|
||||
openwa = ["py.typed"]
|
||||
"openwa.resources" = ["py.typed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Test helpers — an httpx.MockTransport-based recorder.
|
||||
|
||||
Tests assert on exact method/url/body/headers without any real network or
|
||||
global monkey-patching. ``httpx.MockTransport`` is the idiomatic, native way
|
||||
to test an httpx-based client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordedCall:
|
||||
method: str
|
||||
url: str
|
||||
headers: dict[str, str]
|
||||
body: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockBackend:
|
||||
"""A scripted backend. Raises if a call doesn't match a route."""
|
||||
calls: list[RecordedCall] = field(default_factory=list)
|
||||
routes: list[tuple[str, str, Callable[[RecordedCall], httpx.Response]]] = field(default_factory=list)
|
||||
fallback: Callable[[RecordedCall], httpx.Response] | None = None
|
||||
|
||||
def on(
|
||||
self,
|
||||
method: str,
|
||||
path_prefix: str,
|
||||
status: int = 200,
|
||||
body: Any = None,
|
||||
) -> "MockBackend":
|
||||
def responder(_: RecordedCall) -> httpx.Response:
|
||||
if status in (204, 100, 304) or body is None:
|
||||
return httpx.Response(status, content=b"")
|
||||
return httpx.Response(status, content=json.dumps(body).encode(), headers={"content-type": "application/json"})
|
||||
|
||||
self.routes.append((method.upper(), path_prefix, responder))
|
||||
return self
|
||||
|
||||
def as_transport(self) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
method = request.method.upper()
|
||||
url = str(request.url)
|
||||
body: Any = None
|
||||
if request.content:
|
||||
try:
|
||||
body = json.loads(request.content)
|
||||
except ValueError:
|
||||
body = request.content.decode(errors="replace")
|
||||
call = RecordedCall(method=method, url=url, headers=dict(request.headers), body=body)
|
||||
self.calls.append(call)
|
||||
|
||||
# Most-specific (longest) matching prefix wins, so a nested route like
|
||||
# "/catalog/products" is preferred over "/catalog" regardless of
|
||||
# registration order.
|
||||
matches = [(pp, resp) for m, pp, resp in self.routes if m == method and pp in url]
|
||||
if matches:
|
||||
_, responder = max(matches, key=lambda pair: len(pair[0]))
|
||||
return responder(call)
|
||||
if self.fallback is not None:
|
||||
return self.fallback(call)
|
||||
raise AssertionError(f"MockBackend: no route for {method} {url}")
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
@property
|
||||
def last_call(self) -> RecordedCall:
|
||||
return self.calls[-1]
|
||||
|
||||
|
||||
def make_client(backend: MockBackend, base_url: str = "http://localhost:2785", api_key: str = "owa_k1_test"):
|
||||
from openwa import OpenWAClient
|
||||
|
||||
return OpenWAClient(base_url=base_url, api_key=api_key, transport=backend.as_transport())
|
||||
@@ -0,0 +1,578 @@
|
||||
"""Unit tests for the OpenWA Python SDK — assert exact paths and bodies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openwa import OpenWAClient, OpenWAApiError, OpenWANotFoundError
|
||||
|
||||
from conftest import MockBackend, make_client
|
||||
|
||||
|
||||
# ── Client core ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClientCore:
|
||||
def test_requires_base_url_and_api_key(self):
|
||||
with pytest.raises(ValueError):
|
||||
OpenWAClient(base_url="", api_key="k")
|
||||
with pytest.raises(ValueError):
|
||||
OpenWAClient(base_url="http://x", api_key="")
|
||||
|
||||
def test_sends_api_key_header(self):
|
||||
backend = MockBackend().on("GET", "/api/sessions", body=[])
|
||||
client = make_client(backend)
|
||||
client.sessions.list()
|
||||
assert backend.last_call.headers["x-api-key"] == "owa_k1_test"
|
||||
assert backend.last_call.headers["content-type"] == "application/json"
|
||||
|
||||
def test_default_headers_cannot_override_api_key(self):
|
||||
# A caller-supplied default header must NEVER clobber the auth/JSON headers.
|
||||
backend = MockBackend().on("GET", "/api/sessions", body=[])
|
||||
client = OpenWAClient(
|
||||
base_url="http://x",
|
||||
api_key="REAL_KEY",
|
||||
default_headers={"X-API-Key": "EVIL", "Content-Type": "text/plain", "X-Trace": "keep"},
|
||||
transport=backend.as_transport(),
|
||||
)
|
||||
client.sessions.list()
|
||||
assert backend.last_call.headers["x-api-key"] == "REAL_KEY"
|
||||
assert backend.last_call.headers["content-type"] == "application/json"
|
||||
assert backend.last_call.headers["x-trace"] == "keep" # benign custom headers still pass through
|
||||
|
||||
def test_non_json_2xx_body_returns_text(self):
|
||||
import httpx
|
||||
|
||||
def handler(_req: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, content=b"plain text", headers={"content-type": "text/plain"})
|
||||
|
||||
client = OpenWAClient(base_url="http://x", api_key="k", transport=httpx.MockTransport(handler))
|
||||
# A non-JSON 2xx body must surface as text, not raise a raw JSONDecodeError.
|
||||
assert client.sessions.list() == "plain text"
|
||||
|
||||
def test_path_segments_are_encoded(self):
|
||||
backend = MockBackend().on("GET", "/history", body=[])
|
||||
make_client(backend).messages.history("s", "weird/id#x")
|
||||
assert "weird%2Fid%23x" in backend.last_call.url
|
||||
backend2 = MockBackend().on("GET", "/history", body=[])
|
||||
make_client(backend2).messages.history("s", "a@c.us")
|
||||
assert "/messages/a@c.us/history" in backend2.last_call.url # @ preserved
|
||||
|
||||
def test_raw_request_escape_hatch(self):
|
||||
backend = MockBackend().on("GET", "/api/anything", body={"ok": True})
|
||||
result = make_client(backend).request("GET", "/api/anything", query={"a": 1})
|
||||
assert result == {"ok": True}
|
||||
assert "a=1" in backend.last_call.url
|
||||
|
||||
def test_treats_unfollowed_redirect_as_error(self):
|
||||
import httpx
|
||||
|
||||
calls: list[httpx.URL] = []
|
||||
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
calls.append(req.url)
|
||||
return httpx.Response(
|
||||
302,
|
||||
headers={"location": "http://evil.example/x", "content-type": "application/json"},
|
||||
content=b'{"redirected": true}',
|
||||
)
|
||||
|
||||
client = OpenWAClient(base_url="http://x", api_key="k", transport=httpx.MockTransport(handler))
|
||||
# A redirect is NOT followed (which would re-send X-API-Key to the target). An unfollowed 3xx
|
||||
# is not a usable response, so it surfaces as an API error — matching the JS transport.
|
||||
with pytest.raises(OpenWAApiError):
|
||||
client.sessions.list()
|
||||
assert len(calls) == 1 # the redirect target was never requested
|
||||
|
||||
def test_strips_trailing_slash(self):
|
||||
backend = MockBackend().on("GET", "/api/sessions", body=[])
|
||||
client = OpenWAClient(base_url="http://localhost:2785/", api_key="k", transport=backend.as_transport())
|
||||
client.sessions.list()
|
||||
assert backend.last_call.url == "http://localhost:2785/api/sessions"
|
||||
|
||||
def test_query_params_skip_none(self):
|
||||
backend = MockBackend().on("GET", "/messages", body=[])
|
||||
make_client(backend).messages.list("s1", {"chatId": "a@c.us", "limit": 10})
|
||||
url = backend.last_call.url
|
||||
assert "chatId=a%40c.us" in url
|
||||
assert "limit=10" in url
|
||||
|
||||
def test_204_is_none(self):
|
||||
backend = MockBackend().on("DELETE", "/api/sessions", status=204)
|
||||
assert make_client(backend).sessions.delete("x") is None
|
||||
|
||||
def test_404_maps_to_not_found_error(self):
|
||||
backend = MockBackend().on("GET", "/api/sessions/missing", status=404, body={
|
||||
"statusCode": 404, "message": "Session not found", "error": "Not Found"
|
||||
})
|
||||
with pytest.raises(OpenWANotFoundError):
|
||||
make_client(backend).sessions.get("missing")
|
||||
|
||||
def test_exposes_all_resources(self):
|
||||
client = make_client(MockBackend())
|
||||
for r in ["sessions", "messages", "contacts", "groups", "webhooks", "chats", "status", "health", "search"]:
|
||||
assert hasattr(client, r)
|
||||
|
||||
|
||||
# ── Messages (the critical send-text fix) ──────────────────────────
|
||||
|
||||
|
||||
class TestMessages:
|
||||
def test_send_text_uses_send_text_path(self):
|
||||
backend = MockBackend().on("POST", "/send-text", body={"messageId": "m1", "timestamp": 1})
|
||||
make_client(backend).messages.send_text("s1", {"chatId": "a@c.us", "text": "hi"})
|
||||
assert backend.last_call.url == "http://localhost:2785/api/sessions/s1/messages/send-text"
|
||||
assert backend.last_call.body == {"chatId": "a@c.us", "text": "hi"}
|
||||
|
||||
@pytest.mark.parametrize("method,segment", [
|
||||
("send_image", "send-image"),
|
||||
("send_video", "send-video"),
|
||||
("send_audio", "send-audio"),
|
||||
("send_document", "send-document"),
|
||||
("send_sticker", "send-sticker"),
|
||||
])
|
||||
def test_media_segments(self, method, segment):
|
||||
backend = MockBackend().on("POST", f"/{segment}", body={"messageId": "m", "timestamp": 2})
|
||||
fn = getattr(make_client(backend).messages, method)
|
||||
fn("s", {"chatId": "a@c.us", "url": "u"})
|
||||
assert f"/messages/{segment}" in backend.last_call.url
|
||||
|
||||
def test_send_location_contact_template(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/send-location", body={"messageId": "m", "timestamp": 1})
|
||||
backend.on("POST", "/send-contact", body={"messageId": "m", "timestamp": 1})
|
||||
backend.on("POST", "/send-template", body={"messageId": "m", "timestamp": 1})
|
||||
client = make_client(backend)
|
||||
client.messages.send_location("s", {"chatId": "a@c.us", "latitude": -6.2, "longitude": 106.8})
|
||||
assert "/messages/send-location" in backend.last_call.url
|
||||
client.messages.send_contact("s", {"chatId": "a@c.us", "contactName": "A", "contactNumber": "628"})
|
||||
assert "/messages/send-contact" in backend.last_call.url
|
||||
# Server DTO field is `vars` (NOT `variables`); body forwarded verbatim.
|
||||
client.messages.send_template("s", {"chatId": "a@c.us", "templateId": "t", "vars": {"name": "Sam"}})
|
||||
assert "/messages/send-template" in backend.last_call.url
|
||||
assert backend.last_call.body == {"chatId": "a@c.us", "templateId": "t", "vars": {"name": "Sam"}}
|
||||
|
||||
def test_send_template_accepts_template_name(self):
|
||||
backend = MockBackend().on("POST", "/send-template", body={"messageId": "m", "timestamp": 1})
|
||||
make_client(backend).messages.send_template("s", {"chatId": "a@c.us", "templateName": "welcome"})
|
||||
assert backend.last_call.body == {"chatId": "a@c.us", "templateName": "welcome"}
|
||||
|
||||
def test_list_returns_messages_total_wrapper(self):
|
||||
backend = MockBackend().on("GET", "/messages", body={"messages": [{"id": "1"}], "total": 1})
|
||||
res = make_client(backend).messages.list("s")
|
||||
assert res["total"] == 1
|
||||
assert len(res["messages"]) == 1
|
||||
|
||||
def test_reply_forwardReactDelete(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/reply", body={"messageId": "m", "timestamp": 1})
|
||||
backend.on("POST", "/forward", body={"messageId": "m", "timestamp": 1})
|
||||
backend.on("POST", "/react", body={"success": True})
|
||||
backend.on("POST", "/delete", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.messages.reply("s", {"chatId": "a@c.us", "quotedMessageId": "q", "text": "r"})
|
||||
client.messages.forward("s", {"fromChatId": "a@c.us", "toChatId": "b@c.us", "messageId": "m"})
|
||||
client.messages.react("s", {"chatId": "a@c.us", "messageId": "m", "emoji": "👍"})
|
||||
client.messages.delete("s", {"chatId": "a@c.us", "messageId": "m"})
|
||||
assert "/messages/reply" in backend.calls[-4].url
|
||||
assert "/messages/forward" in backend.calls[-3].url
|
||||
assert "/messages/react" in backend.calls[-2].url
|
||||
assert "/messages/delete" in backend.calls[-1].url
|
||||
|
||||
def test_history_and_reactions_path(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/history", body=[])
|
||||
backend.on("GET", "/reactions", body=[])
|
||||
client = make_client(backend)
|
||||
client.messages.history("s", "a@c.us", {"limit": 5})
|
||||
assert "/messages/a@c.us/history" in backend.calls[-1].url
|
||||
assert "limit=5" in backend.calls[-1].url
|
||||
client.messages.reactions("s", "a@c.us", "m1")
|
||||
assert "/messages/a@c.us/m1/reactions" in backend.calls[-1].url
|
||||
|
||||
def test_history_boolean_query_serializes_lowercase(self):
|
||||
# Server reads `includeMedia === 'true'`; Python str(True) == 'True' would be ignored.
|
||||
backend = MockBackend().on("GET", "/history", body=[])
|
||||
make_client(backend).messages.history("s", "a@c.us", {"limit": 5, "includeMedia": True, "deep": False})
|
||||
url = backend.last_call.url
|
||||
assert "includeMedia=true" in url
|
||||
assert "deep=false" in url
|
||||
|
||||
def test_bulk_and_batch_status(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/send-bulk", body={
|
||||
"batchId": "b", "status": "queued", "totalMessages": 1,
|
||||
"estimatedCompletionTime": "t", "statusUrl": "/u",
|
||||
})
|
||||
backend.on("GET", "/batch/b", body={
|
||||
"batchId": "b", "status": "done",
|
||||
"progress": {"total": 1, "sent": 1, "failed": 0, "pending": 0, "cancelled": 0},
|
||||
"results": [], "startedAt": "s", "completedAt": "c",
|
||||
})
|
||||
backend.on("POST", "/cancel", body={
|
||||
"batchId": "b", "status": "cancelled",
|
||||
"progress": {"total": 1, "sent": 0, "failed": 0, "pending": 0, "cancelled": 1},
|
||||
})
|
||||
client = make_client(backend)
|
||||
client.messages.send_bulk("s", {"messages": [{"chatId": "a@c.us", "type": "text", "content": {"text": "x"}}]})
|
||||
assert "/messages/send-bulk" in backend.calls[-1].url
|
||||
status = client.messages.batch_status("s", "b")
|
||||
assert status["progress"]["sent"] == 1
|
||||
assert "/messages/batch/b" in backend.calls[-1].url
|
||||
cancelled = client.messages.cancel_batch("s", "b")
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert "/messages/batch/b/cancel" in backend.calls[-1].url
|
||||
assert backend.calls[-1].method == "POST"
|
||||
|
||||
|
||||
# ── Sessions ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_lifecycle_paths(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/api/sessions", body=[])
|
||||
backend.on("GET", "/sessions/s1", body={"id": "s1", "name": "n", "status": "ready"})
|
||||
backend.on("POST", "/api/sessions", body={"id": "s1", "name": "n", "status": "created"})
|
||||
backend.on("DELETE", "/sessions/s1", status=204)
|
||||
backend.on("POST", "/start", body={"id": "s1", "status": "initializing"})
|
||||
backend.on("POST", "/stop", body={"id": "s1", "status": "disconnected"})
|
||||
backend.on("POST", "/force-kill", body={"id": "s1", "status": "disconnected"})
|
||||
client = make_client(backend)
|
||||
client.sessions.list()
|
||||
assert backend.calls[-1].url == "http://localhost:2785/api/sessions"
|
||||
client.sessions.get("s1")
|
||||
assert "/sessions/s1" in backend.calls[-1].url
|
||||
client.sessions.create({"name": "n"})
|
||||
assert backend.calls[-1].body == {"name": "n"}
|
||||
client.sessions.start("s1")
|
||||
assert "/sessions/s1/start" in backend.calls[-1].url
|
||||
client.sessions.stop("s1")
|
||||
client.sessions.force_kill("s1")
|
||||
assert "/sessions/s1/force-kill" in backend.calls[-1].url
|
||||
client.sessions.delete("s1")
|
||||
assert backend.calls[-1].method == "DELETE"
|
||||
|
||||
def test_qr_pairing_stats(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/qr", body={"qrCode": "data:image/png;base64,xxx", "status": "qr_ready"})
|
||||
backend.on("POST", "/pairing-code", body={"pairingCode": "ABCD1234", "status": "qr_ready"})
|
||||
backend.on("GET", "/stats/overview", body={"total": 1, "active": 1, "ready": 1, "disconnected": 0, "byStatus": {"ready": 1}})
|
||||
client = make_client(backend)
|
||||
client.sessions.get_qr_code("s1")
|
||||
assert "/sessions/s1/qr" in backend.calls[-1].url
|
||||
client.sessions.request_pairing_code("s1", {"phoneNumber": "628123456789"})
|
||||
assert backend.calls[-1].body == {"phoneNumber": "628123456789"}
|
||||
client.sessions.stats()
|
||||
assert "/sessions/stats/overview" in backend.calls[-1].url
|
||||
|
||||
|
||||
# ── Groups, Contacts, Webhooks, Chats, Health ──────────────────────
|
||||
|
||||
|
||||
class TestGroups:
|
||||
def test_list_get_create(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/groups", body=[])
|
||||
backend.on("GET", "/groups/g1@g.us", body={"id": "g1@g.us", "subject": "G", "participants": []})
|
||||
backend.on("POST", "/groups", body={"id": "g1@g.us", "subject": "G", "participants": []})
|
||||
client = make_client(backend)
|
||||
client.groups.list("s")
|
||||
client.groups.get("s", "g1@g.us")
|
||||
assert "/groups/g1@g.us" in backend.calls[-1].url
|
||||
client.groups.create("s", {"name": "G", "participants": ["a@c.us"]})
|
||||
assert backend.calls[-1].body == {"name": "G", "participants": ["a@c.us"]}
|
||||
|
||||
def test_participant_ops(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/participants", body={"success": True})
|
||||
backend.on("DELETE", "/participants", body={"success": True})
|
||||
backend.on("POST", "/promote", body={"success": True})
|
||||
backend.on("POST", "/demote", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.groups.add_participants("s", "g", ["a@c.us", "b@c.us"])
|
||||
assert backend.calls[-1].body == {"participants": ["a@c.us", "b@c.us"]}
|
||||
assert backend.calls[-1].method == "POST"
|
||||
client.groups.remove_participants("s", "g", ["a@c.us"])
|
||||
assert backend.calls[-1].method == "DELETE"
|
||||
client.groups.promote_participants("s", "g", ["a@c.us"])
|
||||
assert "/promote" in backend.calls[-1].url
|
||||
client.groups.demote_participants("s", "g", ["a@c.us"])
|
||||
assert "/demote" in backend.calls[-1].url
|
||||
|
||||
def test_subject_description_invite(self):
|
||||
backend = MockBackend()
|
||||
backend.on("PUT", "/subject", body={"success": True})
|
||||
backend.on("PUT", "/description", body={"success": True})
|
||||
backend.on("POST", "/leave", body={"success": True})
|
||||
backend.on("GET", "/invite-code", body={"inviteCode": "c", "inviteLink": "l"})
|
||||
backend.on("POST", "/revoke", body={"inviteCode": "c2", "inviteLink": "l2"})
|
||||
client = make_client(backend)
|
||||
client.groups.set_subject("s", "g", "New")
|
||||
assert backend.calls[-1].body == {"subject": "New"}
|
||||
assert backend.calls[-1].method == "PUT"
|
||||
client.groups.set_description("s", "g", "desc")
|
||||
assert backend.calls[-1].body == {"description": "desc"}
|
||||
client.groups.leave("s", "g")
|
||||
client.groups.invite_code("s", "g")
|
||||
client.groups.revoke_invite_code("s", "g")
|
||||
assert "/revoke" in backend.calls[-1].url
|
||||
|
||||
|
||||
class TestContacts:
|
||||
def test_paths(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/contacts", body=[])
|
||||
backend.on("GET", "/contacts/a@c.us", body={"id": "a@c.us"})
|
||||
backend.on("GET", "/check/628123", body={"number": "628123", "exists": True, "whatsappId": "628123@c.us"})
|
||||
backend.on("GET", "/profile-picture", body={"url": "http://p"})
|
||||
backend.on("GET", "/phone", body={"contactId": "x@lid", "phone": "628123"})
|
||||
client = make_client(backend)
|
||||
client.contacts.list("s", {"limit": 10})
|
||||
assert "limit=10" in backend.calls[-1].url
|
||||
client.contacts.get("s", "a@c.us")
|
||||
client.contacts.check("s", "628123")
|
||||
assert "/check/628123" in backend.calls[-1].url
|
||||
client.contacts.profile_picture("s", "a@c.us")
|
||||
client.contacts.phone("s", "x@lid")
|
||||
|
||||
def test_block_unblock(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/block", body={"success": True})
|
||||
backend.on("DELETE", "/block", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.contacts.block("s", "a@c.us")
|
||||
assert backend.calls[-1].method == "POST"
|
||||
client.contacts.unblock("s", "a@c.us")
|
||||
assert backend.calls[-1].method == "DELETE"
|
||||
|
||||
|
||||
class TestWebhooks:
|
||||
def test_crud_test(self):
|
||||
wh = {"id": "w1", "sessionId": "s", "url": "u", "events": ["*"], "active": True, "createdAt": "", "updatedAt": ""}
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/webhooks", body=[wh])
|
||||
backend.on("GET", "/webhooks/w1", body=wh)
|
||||
backend.on("POST", "/webhooks", body=wh)
|
||||
backend.on("PUT", "/webhooks/w1", body={**wh, "active": False})
|
||||
backend.on("DELETE", "/webhooks/w1", status=204)
|
||||
backend.on("POST", "/test", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.webhooks.list("s")
|
||||
client.webhooks.get("s", "w1")
|
||||
# Server DTO field is `retryCount` (NOT `retries`); body forwarded verbatim.
|
||||
client.webhooks.create("s", {"url": "u", "events": ["*"], "retryCount": 5})
|
||||
assert backend.calls[-1].body == {"url": "u", "events": ["*"], "retryCount": 5}
|
||||
client.webhooks.update("s", "w1", {"active": False})
|
||||
assert backend.calls[-1].method == "PUT"
|
||||
client.webhooks.delete("s", "w1")
|
||||
client.webhooks.test("s", "w1")
|
||||
assert "/webhooks/w1/test" in backend.calls[-1].url
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_send_image_video_forward_nested_media_body(self):
|
||||
backend = MockBackend()
|
||||
backend.on("POST", "/status/send-image", body={"statusId": "s1"})
|
||||
backend.on("POST", "/status/send-video", body={"statusId": "s2"})
|
||||
client = make_client(backend)
|
||||
# Server requires a nested {image|video:{...}} body, not flat media fields,
|
||||
# plus a required recipients list.
|
||||
client.status.send_image("s", {"image": {"url": "http://img"}, "recipients": ["a@c.us"], "caption": "hi"})
|
||||
assert backend.calls[-1].body == {"image": {"url": "http://img"}, "recipients": ["a@c.us"], "caption": "hi"}
|
||||
client.status.send_video("s", {"video": {"url": "http://vid"}, "recipients": ["a@c.us"]})
|
||||
assert backend.calls[-1].body == {"video": {"url": "http://vid"}, "recipients": ["a@c.us"]}
|
||||
|
||||
|
||||
class TestChatsAndHealth:
|
||||
def test_chats(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/chats", body=[])
|
||||
backend.on("POST", "/read", body={"success": True})
|
||||
backend.on("POST", "/unread", body={"success": True})
|
||||
backend.on("POST", "/delete", body={"success": True})
|
||||
backend.on("POST", "/typing", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.chats.list("s")
|
||||
client.chats.mark_read("s", {"chatId": "a@c.us"})
|
||||
assert "/chats/read" in backend.calls[-1].url
|
||||
client.chats.mark_unread("s", {"chatId": "a@c.us"})
|
||||
client.chats.delete("s", {"chatId": "a@c.us"})
|
||||
client.chats.send_state("s", {"chatId": "a@c.us", "state": "typing"})
|
||||
assert "/chats/typing" in backend.calls[-1].url
|
||||
|
||||
def test_health_and_auth(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/api/health", body={"status": "ok", "version": "0.7.2"})
|
||||
backend.on("GET", "/live", body={"status": "ok"})
|
||||
backend.on("GET", "/ready", body={"status": "ok", "details": {}})
|
||||
backend.on("POST", "/validate", body={"valid": True, "role": "admin"})
|
||||
client = make_client(backend)
|
||||
client.health.check()
|
||||
assert backend.calls[-1].url == "http://localhost:2785/api/health"
|
||||
client.health.live()
|
||||
client.health.ready()
|
||||
client.auth()
|
||||
assert backend.calls[-1].method == "POST"
|
||||
assert "/auth/validate" in backend.calls[-1].url
|
||||
|
||||
|
||||
class TestLabelsChannelsCatalog:
|
||||
def test_labels(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/labels", body=[{"id": "l1", "name": "VIP"}])
|
||||
backend.on("GET", "/labels/l1", body={"id": "l1", "name": "VIP"})
|
||||
backend.on("GET", "/labels/chat/a@c.us", body=[{"id": "l1", "name": "VIP"}])
|
||||
backend.on("POST", "/labels/chat/a@c.us", body={"success": True})
|
||||
backend.on("DELETE", "/labels/chat/a@c.us/l1", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.labels.list("s")
|
||||
assert "/sessions/s/labels" in backend.calls[-1].url
|
||||
client.labels.get("s", "l1")
|
||||
client.labels.for_chat("s", "a@c.us")
|
||||
client.labels.add_to_chat("s", "a@c.us", {"labelId": "l1"})
|
||||
assert backend.calls[-1].method == "POST"
|
||||
assert backend.calls[-1].body == {"labelId": "l1"}
|
||||
client.labels.remove_from_chat("s", "a@c.us", "l1")
|
||||
assert backend.calls[-1].method == "DELETE"
|
||||
|
||||
def test_channels(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/channels", body=[{"id": "123@newsletter", "name": "News"}])
|
||||
backend.on("GET", "/channels/123@newsletter", body={"id": "123@newsletter", "name": "News"})
|
||||
backend.on("GET", "/channels/123@newsletter/messages", body=[])
|
||||
backend.on("POST", "/channels/subscribe", body={"id": "123@newsletter", "name": "News"})
|
||||
backend.on("DELETE", "/channels/123@newsletter", body={"success": True})
|
||||
client = make_client(backend)
|
||||
client.channels.list("s")
|
||||
assert "/sessions/s/channels" in backend.calls[-1].url
|
||||
client.channels.get("s", "123@newsletter")
|
||||
client.channels.messages("s", "123@newsletter", {"limit": 10})
|
||||
assert "limit=10" in backend.calls[-1].url
|
||||
client.channels.subscribe("s", {"inviteCode": "ABCxyz"})
|
||||
assert backend.calls[-1].method == "POST"
|
||||
assert backend.calls[-1].body == {"inviteCode": "ABCxyz"}
|
||||
client.channels.unsubscribe("s", "123@newsletter")
|
||||
assert backend.calls[-1].method == "DELETE"
|
||||
|
||||
def test_catalog(self):
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/catalog", body={"id": "c1", "name": "My Shop", "productCount": 5, "url": "http://shop"})
|
||||
backend.on("GET", "/catalog/products", body={
|
||||
"products": [{"id": "p1", "name": "Widget"}],
|
||||
"pagination": {"page": 1, "limit": 20, "total": 1, "totalPages": 1},
|
||||
})
|
||||
backend.on("GET", "/catalog/products/p1", body={"id": "p1", "name": "Widget"})
|
||||
backend.on("POST", "/messages/send-product", body={"messageId": "m", "timestamp": 1})
|
||||
backend.on("POST", "/messages/send-catalog", body={"messageId": "m", "timestamp": 1})
|
||||
client = make_client(backend)
|
||||
client.catalog.info("s")
|
||||
assert "/sessions/s/catalog" in backend.calls[-1].url
|
||||
page = client.catalog.products("s", {"page": 1, "limit": 20})
|
||||
assert page["pagination"]["total"] == 1
|
||||
assert "page=1" in backend.calls[-1].url
|
||||
client.catalog.product("s", "p1")
|
||||
client.catalog.send_product("s", {"chatId": "a@c.us", "productId": "p1", "body": "x"})
|
||||
assert "/messages/send-product" in backend.calls[-1].url
|
||||
assert backend.calls[-1].body == {"chatId": "a@c.us", "productId": "p1", "body": "x"}
|
||||
client.catalog.send_catalog("s", {"chatId": "a@c.us", "body": "cat"})
|
||||
assert "/messages/send-catalog" in backend.calls[-1].url
|
||||
|
||||
def test_templates_crud(self):
|
||||
tpl = {"id": "t1", "sessionId": "s", "name": "welcome", "body": "Hi {{name}}", "createdAt": "", "updatedAt": ""}
|
||||
backend = MockBackend()
|
||||
backend.on("GET", "/templates/t1", body=tpl)
|
||||
backend.on("GET", "/templates", body=[tpl])
|
||||
backend.on("POST", "/templates", body=tpl)
|
||||
backend.on("PUT", "/templates/t1", body={**tpl, "body": "Hello {{name}}"})
|
||||
backend.on("DELETE", "/templates/t1", status=204)
|
||||
client = make_client(backend)
|
||||
client.templates.list("s")
|
||||
assert "/api/sessions/s/templates" in backend.last_call.url
|
||||
client.templates.get("s", "t1")
|
||||
assert backend.last_call.url.endswith("/api/sessions/s/templates/t1")
|
||||
client.templates.create("s", {"name": "welcome", "body": "Hi {{name}}"})
|
||||
assert backend.last_call.method == "POST"
|
||||
assert backend.last_call.body == {"name": "welcome", "body": "Hi {{name}}"}
|
||||
client.templates.update("s", "t1", {"body": "Hello {{name}}"})
|
||||
assert backend.last_call.method == "PUT"
|
||||
assert backend.last_call.body == {"body": "Hello {{name}}"}
|
||||
client.templates.delete("s", "t1")
|
||||
assert backend.last_call.method == "DELETE"
|
||||
|
||||
def test_client_exposes_all_resources(self):
|
||||
client = make_client(MockBackend())
|
||||
for r in ["sessions", "messages", "contacts", "groups", "webhooks", "chats", "status", "health", "labels", "channels", "catalog", "templates", "search"]:
|
||||
assert hasattr(client, r)
|
||||
|
||||
|
||||
# ── Search ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearch:
|
||||
def test_search_minimal_required_q(self):
|
||||
backend = MockBackend().on("GET", "/api/search", body={
|
||||
"hits": [], "total": 0, "tookMs": 3, "provider": "builtin-fts",
|
||||
})
|
||||
res = make_client(backend).search.search({"q": "hello"})
|
||||
assert res["total"] == 0
|
||||
assert res["provider"] == "builtin-fts"
|
||||
assert backend.last_call.url == "http://localhost:2785/api/search?q=hello"
|
||||
|
||||
def test_search_forwards_all_optional_filters(self):
|
||||
backend = MockBackend().on("GET", "/api/search", body={
|
||||
"hits": [], "total": 0, "tookMs": 1, "provider": "builtin-fts",
|
||||
})
|
||||
make_client(backend).search.search({
|
||||
"q": "invoice",
|
||||
"sessionId": "s1",
|
||||
"chatId": "628123@c.us",
|
||||
"direction": "incoming",
|
||||
"type": "chat",
|
||||
"from": "628123456789@c.us",
|
||||
"dateFrom": 1720000000000,
|
||||
"dateTo": 1720100000000,
|
||||
"limit": 20,
|
||||
"offset": 40,
|
||||
})
|
||||
url = backend.last_call.url
|
||||
assert url.startswith("http://localhost:2785/api/search?")
|
||||
assert "q=invoice" in url
|
||||
assert "sessionId=s1" in url
|
||||
assert "chatId=628123%40c.us" in url # @ percent-encoded in query
|
||||
assert "direction=incoming" in url
|
||||
assert "type=chat" in url
|
||||
assert "from=628123456789%40c.us" in url
|
||||
assert "dateFrom=1720000000000" in url
|
||||
assert "dateTo=1720100000000" in url
|
||||
assert "limit=20" in url
|
||||
assert "offset=40" in url
|
||||
|
||||
def test_search_returns_hits_shape(self):
|
||||
hit = {
|
||||
"messageId": "m1", "waMessageId": "wam1", "sessionId": "s1",
|
||||
"chatId": "628123@c.us", "body": "please send the invoice",
|
||||
"snippet": "please send the <mark>invoice</mark>", "timestamp": 1720000000,
|
||||
"type": "chat", "direction": "incoming", "from": "628123456789@c.us",
|
||||
"score": 0.42,
|
||||
}
|
||||
backend = MockBackend().on("GET", "/api/search", body={
|
||||
"hits": [hit], "total": 1, "tookMs": 7, "provider": "builtin-fts",
|
||||
})
|
||||
res = make_client(backend).search.search({"q": "invoice"})
|
||||
assert res["total"] == 1
|
||||
assert res["hits"][0]["messageId"] == "m1"
|
||||
assert res["hits"][0]["snippet"] == "please send the <mark>invoice</mark>"
|
||||
assert res["hits"][0]["score"] == 0.42
|
||||
assert res["hits"][0]["direction"] == "incoming"
|
||||
|
||||
def test_search_none_values_skipped_in_query(self):
|
||||
# None-typed optional filters must be omitted, never sent as the literal "None".
|
||||
backend = MockBackend().on("GET", "/api/search", body={
|
||||
"hits": [], "total": 0, "tookMs": 0, "provider": "builtin-fts",
|
||||
})
|
||||
make_client(backend).search.search({"q": "x", "sessionId": None, "limit": 5})
|
||||
url = backend.last_call.url
|
||||
assert "q=x" in url
|
||||
assert "limit=5" in url
|
||||
assert "sessionId" not in url
|
||||
Reference in New Issue
Block a user