multiwa 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
multiwa/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """
2
+ MultiWA Python SDK
3
+
4
+ Official Python SDK for MultiWA - WhatsApp Business API Gateway.
5
+ """
6
+
7
+ from .client import MultiWA, AsyncMultiWA
8
+ from .webhooks import verify_webhook_signature
9
+ from .types import (
10
+ Message,
11
+ MessageResult,
12
+ Contact,
13
+ Broadcast,
14
+ Template,
15
+ Profile,
16
+ Webhook,
17
+ )
18
+
19
+ __version__ = "1.0.0"
20
+ __all__ = [
21
+ "MultiWA",
22
+ "AsyncMultiWA",
23
+ "Message",
24
+ "MessageResult",
25
+ "Contact",
26
+ "Broadcast",
27
+ "Template",
28
+ "Profile",
29
+ "Webhook",
30
+ "verify_webhook_signature",
31
+ ]
multiwa/broadcasts.py ADDED
@@ -0,0 +1,147 @@
1
+ """
2
+ MultiWA Python SDK - Broadcasts Client
3
+
4
+ Client for managing broadcast campaigns.
5
+ """
6
+
7
+ from typing import Optional, List, Dict, Any
8
+ import httpx
9
+
10
+ from .types import Broadcast
11
+
12
+
13
+ class BroadcastsClient:
14
+ """Synchronous Broadcasts API client"""
15
+
16
+ def __init__(self, http: httpx.Client):
17
+ self._http = http
18
+
19
+ def create(
20
+ self,
21
+ profile_id: str,
22
+ name: str,
23
+ recipients: List[str],
24
+ template_id: Optional[str] = None,
25
+ message: Optional[str] = None,
26
+ schedule_at: Optional[str] = None,
27
+ ) -> Broadcast:
28
+ """Create a new broadcast campaign"""
29
+ response = self._http.post("/broadcasts", json={
30
+ "profileId": profile_id,
31
+ "name": name,
32
+ "recipients": recipients,
33
+ "templateId": template_id,
34
+ "message": message,
35
+ "scheduleAt": schedule_at,
36
+ })
37
+ response.raise_for_status()
38
+ return Broadcast(**response.json())
39
+
40
+ def get(self, broadcast_id: str) -> Broadcast:
41
+ """Get a broadcast by ID"""
42
+ response = self._http.get(f"/broadcasts/{broadcast_id}")
43
+ response.raise_for_status()
44
+ return Broadcast(**response.json())
45
+
46
+ def list(
47
+ self,
48
+ profile_id: str,
49
+ limit: int = 50,
50
+ offset: int = 0,
51
+ status: Optional[str] = None,
52
+ ) -> List[Broadcast]:
53
+ """List broadcasts for a profile"""
54
+ params: Dict[str, Any] = {
55
+ "limit": limit,
56
+ "offset": offset,
57
+ }
58
+ if status:
59
+ params["status"] = status
60
+
61
+ response = self._http.get(f"/broadcasts/profile/{profile_id}", params=params)
62
+ response.raise_for_status()
63
+ return [Broadcast(**b) for b in response.json()]
64
+
65
+ def start(self, broadcast_id: str) -> Broadcast:
66
+ """Start a broadcast"""
67
+ response = self._http.post(f"/broadcasts/{broadcast_id}/start")
68
+ response.raise_for_status()
69
+ return Broadcast(**response.json())
70
+
71
+ def pause(self, broadcast_id: str) -> Broadcast:
72
+ """Pause a broadcast"""
73
+ response = self._http.post(f"/broadcasts/{broadcast_id}/pause")
74
+ response.raise_for_status()
75
+ return Broadcast(**response.json())
76
+
77
+ def cancel(self, broadcast_id: str) -> Broadcast:
78
+ """Cancel a broadcast"""
79
+ response = self._http.post(f"/broadcasts/{broadcast_id}/cancel")
80
+ response.raise_for_status()
81
+ return Broadcast(**response.json())
82
+
83
+ def delete(self, broadcast_id: str) -> Dict[str, bool]:
84
+ """Delete a broadcast"""
85
+ response = self._http.delete(f"/broadcasts/{broadcast_id}")
86
+ response.raise_for_status()
87
+ return response.json()
88
+
89
+ def stats(self, broadcast_id: str) -> Dict[str, Any]:
90
+ """Get broadcast statistics"""
91
+ response = self._http.get(f"/broadcasts/{broadcast_id}/stats")
92
+ response.raise_for_status()
93
+ return response.json()
94
+
95
+
96
+ class AsyncBroadcastsClient:
97
+ """Asynchronous Broadcasts API client"""
98
+
99
+ def __init__(self, http: httpx.AsyncClient):
100
+ self._http = http
101
+
102
+ async def create(
103
+ self,
104
+ profile_id: str,
105
+ name: str,
106
+ recipients: List[str],
107
+ template_id: Optional[str] = None,
108
+ message: Optional[str] = None,
109
+ schedule_at: Optional[str] = None,
110
+ ) -> Broadcast:
111
+ """Create a new broadcast campaign"""
112
+ response = await self._http.post("/broadcasts", json={
113
+ "profileId": profile_id,
114
+ "name": name,
115
+ "recipients": recipients,
116
+ "templateId": template_id,
117
+ "message": message,
118
+ "scheduleAt": schedule_at,
119
+ })
120
+ response.raise_for_status()
121
+ return Broadcast(**response.json())
122
+
123
+ async def get(self, broadcast_id: str) -> Broadcast:
124
+ """Get a broadcast by ID"""
125
+ response = await self._http.get(f"/broadcasts/{broadcast_id}")
126
+ response.raise_for_status()
127
+ return Broadcast(**response.json())
128
+
129
+ async def list(
130
+ self,
131
+ profile_id: str,
132
+ limit: int = 50,
133
+ offset: int = 0,
134
+ ) -> List[Broadcast]:
135
+ """List broadcasts for a profile"""
136
+ response = await self._http.get(
137
+ f"/broadcasts/profile/{profile_id}",
138
+ params={"limit": limit, "offset": offset}
139
+ )
140
+ response.raise_for_status()
141
+ return [Broadcast(**b) for b in response.json()]
142
+
143
+ async def start(self, broadcast_id: str) -> Broadcast:
144
+ """Start a broadcast"""
145
+ response = await self._http.post(f"/broadcasts/{broadcast_id}/start")
146
+ response.raise_for_status()
147
+ return Broadcast(**response.json())
multiwa/client.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ MultiWA Python SDK - Core Client
3
+
4
+ Main client class for synchronous and asynchronous API access.
5
+ """
6
+
7
+ from typing import Optional, Dict, Any
8
+ import httpx
9
+
10
+ from .messages import MessagesClient, AsyncMessagesClient
11
+ from .contacts import ContactsClient, AsyncContactsClient
12
+ from .broadcasts import BroadcastsClient, AsyncBroadcastsClient
13
+ from .profiles import ProfilesClient, AsyncProfilesClient
14
+ from .webhooks import WebhooksClient, AsyncWebhooksClient
15
+
16
+
17
+ def _normalize_base_url(base_url: str) -> str:
18
+ """Return ``base_url`` with exactly one trailing slash.
19
+
20
+ httpx joins ``base_url`` with the request path using URL semantics, so the
21
+ configured base must end with a slash. Request paths must be relative
22
+ (no leading slash) for the base path such as ``/api/v1`` to survive.
23
+ """
24
+ return base_url.rstrip("/") + "/"
25
+
26
+
27
+ class _MultiWAHttpClient(httpx.Client):
28
+ """httpx.Client variant that preserves the API base path.
29
+
30
+ A request path that begins with ``/`` is treated by URL semantics as
31
+ absolute and would drop the ``/api/v1`` prefix configured on the base
32
+ URL. Existing endpoint methods in this SDK call the HTTP client with
33
+ leading-slash paths (for example ``/messages/text``). To keep them
34
+ working while still preserving the configured base path, we trim the
35
+ leading slash before httpx builds the request.
36
+ """
37
+
38
+ def build_request(self, method, url, **kwargs): # type: ignore[override]
39
+ if isinstance(url, str) and url.startswith("/"):
40
+ url = url.lstrip("/")
41
+ return super().build_request(method, url, **kwargs)
42
+
43
+
44
+ class _MultiWAAsyncHttpClient(httpx.AsyncClient):
45
+ """Async variant of ``_MultiWAHttpClient`` with the same behavior."""
46
+
47
+ def build_request(self, method, url, **kwargs): # type: ignore[override]
48
+ if isinstance(url, str) and url.startswith("/"):
49
+ url = url.lstrip("/")
50
+ return super().build_request(method, url, **kwargs)
51
+
52
+
53
+ class MultiWA:
54
+ """
55
+ Synchronous MultiWA API Client
56
+
57
+ Example:
58
+ client = MultiWA(
59
+ base_url="https://your-instance.com/api/v1",
60
+ api_key="your-api-key",
61
+ )
62
+ result = client.messages.send_text(profile_id, to, "Hello!")
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ api_key: str,
69
+ timeout: float = 30.0,
70
+ ):
71
+ # Keep the externally-visible base_url unchanged (no trailing slash),
72
+ # but pass a trailing-slash version to httpx so the API prefix is
73
+ # preserved when endpoints use either absolute or relative paths.
74
+ self.base_url = base_url.rstrip("/")
75
+ self.api_key = api_key
76
+ self.timeout = timeout
77
+
78
+ self._http = _MultiWAHttpClient(
79
+ base_url=_normalize_base_url(base_url),
80
+ headers={
81
+ "X-API-Key": api_key,
82
+ "Content-Type": "application/json",
83
+ },
84
+ timeout=timeout,
85
+ )
86
+
87
+ # Initialize API clients
88
+ self.messages = MessagesClient(self._http)
89
+ self.contacts = ContactsClient(self._http)
90
+ self.broadcasts = BroadcastsClient(self._http)
91
+ self.profiles = ProfilesClient(self._http)
92
+ self.webhooks = WebhooksClient(self._http)
93
+
94
+ def close(self):
95
+ """Close the HTTP client"""
96
+ self._http.close()
97
+
98
+ def __enter__(self):
99
+ return self
100
+
101
+ def __exit__(self, *args):
102
+ self.close()
103
+
104
+ def request(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ **kwargs
109
+ ) -> Dict[str, Any]:
110
+ """Make a raw API request"""
111
+ response = self._http.request(method, path, **kwargs)
112
+ response.raise_for_status()
113
+ return response.json()
114
+
115
+
116
+ class AsyncMultiWA:
117
+ """
118
+ Asynchronous MultiWA API Client
119
+
120
+ Example:
121
+ async with AsyncMultiWA(
122
+ base_url="https://your-instance.com/api/v1",
123
+ api_key="your-api-key",
124
+ ) as client:
125
+ result = await client.messages.send_text(profile_id, to, "Hello!")
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ base_url: str,
131
+ api_key: str,
132
+ timeout: float = 30.0,
133
+ ):
134
+ self.base_url = base_url.rstrip("/")
135
+ self.api_key = api_key
136
+ self.timeout = timeout
137
+
138
+ self._http = _MultiWAAsyncHttpClient(
139
+ base_url=_normalize_base_url(base_url),
140
+ headers={
141
+ "X-API-Key": api_key,
142
+ "Content-Type": "application/json",
143
+ },
144
+ timeout=timeout,
145
+ )
146
+
147
+ # Initialize API clients
148
+ self.messages = AsyncMessagesClient(self._http)
149
+ self.contacts = AsyncContactsClient(self._http)
150
+ self.broadcasts = AsyncBroadcastsClient(self._http)
151
+ self.profiles = AsyncProfilesClient(self._http)
152
+ self.webhooks = AsyncWebhooksClient(self._http)
153
+
154
+ async def close(self):
155
+ """Close the HTTP client"""
156
+ await self._http.aclose()
157
+
158
+ async def __aenter__(self):
159
+ return self
160
+
161
+ async def __aexit__(self, *args):
162
+ await self.close()
163
+
164
+ async def request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ **kwargs
169
+ ) -> Dict[str, Any]:
170
+ """Make a raw API request"""
171
+ response = await self._http.request(method, path, **kwargs)
172
+ response.raise_for_status()
173
+ return response.json()
multiwa/contacts.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ MultiWA Python SDK - Contacts Client
3
+
4
+ Client for managing WhatsApp contacts.
5
+ """
6
+
7
+ from typing import Optional, List, Dict, Any
8
+ import httpx
9
+
10
+ from .types import Contact
11
+
12
+
13
+ class ContactsClient:
14
+ """Synchronous Contacts API client"""
15
+
16
+ def __init__(self, http: httpx.Client):
17
+ self._http = http
18
+
19
+ def create(
20
+ self,
21
+ profile_id: str,
22
+ phone: str,
23
+ name: Optional[str] = None,
24
+ email: Optional[str] = None,
25
+ tags: Optional[List[str]] = None,
26
+ metadata: Optional[Dict[str, Any]] = None,
27
+ ) -> Contact:
28
+ """Create a new contact"""
29
+ response = self._http.post("/contacts", json={
30
+ "profileId": profile_id,
31
+ "phone": phone,
32
+ "name": name,
33
+ "email": email,
34
+ "tags": tags or [],
35
+ "metadata": metadata or {},
36
+ })
37
+ response.raise_for_status()
38
+ return Contact(**response.json())
39
+
40
+ def get(self, contact_id: str) -> Contact:
41
+ """Get a contact by ID"""
42
+ response = self._http.get(f"/contacts/{contact_id}")
43
+ response.raise_for_status()
44
+ return Contact(**response.json())
45
+
46
+ def update(
47
+ self,
48
+ contact_id: str,
49
+ name: Optional[str] = None,
50
+ email: Optional[str] = None,
51
+ tags: Optional[List[str]] = None,
52
+ metadata: Optional[Dict[str, Any]] = None,
53
+ ) -> Contact:
54
+ """Update a contact"""
55
+ data = {}
56
+ if name is not None:
57
+ data["name"] = name
58
+ if email is not None:
59
+ data["email"] = email
60
+ if tags is not None:
61
+ data["tags"] = tags
62
+ if metadata is not None:
63
+ data["metadata"] = metadata
64
+
65
+ response = self._http.patch(f"/contacts/{contact_id}", json=data)
66
+ response.raise_for_status()
67
+ return Contact(**response.json())
68
+
69
+ def delete(self, contact_id: str) -> Dict[str, bool]:
70
+ """Delete a contact"""
71
+ response = self._http.delete(f"/contacts/{contact_id}")
72
+ response.raise_for_status()
73
+ return response.json()
74
+
75
+ def list(
76
+ self,
77
+ profile_id: str,
78
+ limit: int = 50,
79
+ offset: int = 0,
80
+ search: Optional[str] = None,
81
+ tags: Optional[List[str]] = None,
82
+ ) -> List[Contact]:
83
+ """List contacts for a profile"""
84
+ params: Dict[str, Any] = {
85
+ "limit": limit,
86
+ "offset": offset,
87
+ }
88
+ if search:
89
+ params["search"] = search
90
+ if tags:
91
+ params["tags"] = ",".join(tags)
92
+
93
+ response = self._http.get(f"/contacts/profile/{profile_id}", params=params)
94
+ response.raise_for_status()
95
+ return [Contact(**c) for c in response.json()]
96
+
97
+ def add_tags(
98
+ self,
99
+ contact_id: str,
100
+ tags: List[str],
101
+ ) -> Contact:
102
+ """Add tags to a contact"""
103
+ response = self._http.post(f"/contacts/{contact_id}/tags", json={
104
+ "tags": tags,
105
+ })
106
+ response.raise_for_status()
107
+ return Contact(**response.json())
108
+
109
+ def remove_tags(
110
+ self,
111
+ contact_id: str,
112
+ tags: List[str],
113
+ ) -> Contact:
114
+ """Remove tags from a contact"""
115
+ response = self._http.delete(f"/contacts/{contact_id}/tags", json={
116
+ "tags": tags,
117
+ })
118
+ response.raise_for_status()
119
+ return Contact(**response.json())
120
+
121
+
122
+ class AsyncContactsClient:
123
+ """Asynchronous Contacts API client"""
124
+
125
+ def __init__(self, http: httpx.AsyncClient):
126
+ self._http = http
127
+
128
+ async def create(
129
+ self,
130
+ profile_id: str,
131
+ phone: str,
132
+ name: Optional[str] = None,
133
+ email: Optional[str] = None,
134
+ tags: Optional[List[str]] = None,
135
+ metadata: Optional[Dict[str, Any]] = None,
136
+ ) -> Contact:
137
+ """Create a new contact"""
138
+ response = await self._http.post("/contacts", json={
139
+ "profileId": profile_id,
140
+ "phone": phone,
141
+ "name": name,
142
+ "email": email,
143
+ "tags": tags or [],
144
+ "metadata": metadata or {},
145
+ })
146
+ response.raise_for_status()
147
+ return Contact(**response.json())
148
+
149
+ async def get(self, contact_id: str) -> Contact:
150
+ """Get a contact by ID"""
151
+ response = await self._http.get(f"/contacts/{contact_id}")
152
+ response.raise_for_status()
153
+ return Contact(**response.json())
154
+
155
+ async def list(
156
+ self,
157
+ profile_id: str,
158
+ limit: int = 50,
159
+ offset: int = 0,
160
+ search: Optional[str] = None,
161
+ ) -> List[Contact]:
162
+ """List contacts for a profile"""
163
+ params: Dict[str, Any] = {
164
+ "limit": limit,
165
+ "offset": offset,
166
+ }
167
+ if search:
168
+ params["search"] = search
169
+
170
+ response = await self._http.get(f"/contacts/profile/{profile_id}", params=params)
171
+ response.raise_for_status()
172
+ return [Contact(**c) for c in response.json()]