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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:08 +08:00
commit 4ce4204b6c
927 changed files with 172802 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg
*.egg-info/
.pytest_cache/
build/
dist/
.venv/
+93
View File
@@ -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
+49
View File
@@ -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",
]
+118
View File
@@ -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
+179
View File
@@ -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()
+99
View File
@@ -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)
View File
+37
View File
@@ -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",
]
+58
View File
@@ -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
)
+41
View File
@@ -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)}")
+46
View File
@@ -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)
+53
View File
@@ -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")
+87
View File
@@ -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"
)
+27
View File
@@ -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")
+40
View File
@@ -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)}"
)
+109
View File
@@ -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"
)
+24
View File
@@ -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)
+56
View File
@@ -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")
+38
View File
@@ -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)}")
+34
View File
@@ -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)}")
+41
View File
@@ -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"
)
+750
View File
@@ -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`` (05; 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
+40
View File
@@ -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"]
+81
View File
@@ -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())
+578
View File
@@ -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