pilot-status 0.0.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pilot_status-0.0.9/PKG-INFO +137 -0
- pilot_status-0.0.9/README.md +117 -0
- pilot_status-0.0.9/pyproject.toml +33 -0
- pilot_status-0.0.9/setup.cfg +4 -0
- pilot_status-0.0.9/src/pilot_status/__init__.py +76 -0
- pilot_status-0.0.9/src/pilot_status/client.py +36 -0
- pilot_status-0.0.9/src/pilot_status/errors.py +81 -0
- pilot_status-0.0.9/src/pilot_status/http.py +156 -0
- pilot_status-0.0.9/src/pilot_status/resources/__init__.py +13 -0
- pilot_status-0.0.9/src/pilot_status/resources/analytics.py +13 -0
- pilot_status-0.0.9/src/pilot_status/resources/api_keys.py +24 -0
- pilot_status-0.0.9/src/pilot_status/resources/messages.py +33 -0
- pilot_status-0.0.9/src/pilot_status/resources/numbers.py +37 -0
- pilot_status-0.0.9/src/pilot_status/resources/projects.py +20 -0
- pilot_status-0.0.9/src/pilot_status/types.py +180 -0
- pilot_status-0.0.9/src/pilot_status/webhooks/__init__.py +19 -0
- pilot_status-0.0.9/src/pilot_status/webhooks/parse.py +255 -0
- pilot_status-0.0.9/src/pilot_status/webhooks/types.py +245 -0
- pilot_status-0.0.9/src/pilot_status.egg-info/PKG-INFO +137 -0
- pilot_status-0.0.9/src/pilot_status.egg-info/SOURCES.txt +33 -0
- pilot_status-0.0.9/src/pilot_status.egg-info/dependency_links.txt +1 -0
- pilot_status-0.0.9/src/pilot_status.egg-info/requires.txt +1 -0
- pilot_status-0.0.9/src/pilot_status.egg-info/top_level.txt +2 -0
- pilot_status-0.0.9/src/pilot_status_sdk/__init__.py +56 -0
- pilot_status-0.0.9/src/pilot_status_sdk/client.py +36 -0
- pilot_status-0.0.9/src/pilot_status_sdk/errors.py +81 -0
- pilot_status-0.0.9/src/pilot_status_sdk/http.py +156 -0
- pilot_status-0.0.9/src/pilot_status_sdk/resources/__init__.py +5 -0
- pilot_status-0.0.9/src/pilot_status_sdk/resources/analytics.py +13 -0
- pilot_status-0.0.9/src/pilot_status_sdk/resources/messages.py +22 -0
- pilot_status-0.0.9/src/pilot_status_sdk/types.py +58 -0
- pilot_status-0.0.9/src/pilot_status_sdk/webhooks/__init__.py +19 -0
- pilot_status-0.0.9/src/pilot_status_sdk/webhooks/parse.py +241 -0
- pilot_status-0.0.9/src/pilot_status_sdk/webhooks/types.py +227 -0
- pilot_status-0.0.9/tests/test_webhooks.py +161 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pilot-status
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: Official Python SDK for the Pilot Status public API.
|
|
5
|
+
Author: Pilot Status
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://pilotstatus.online
|
|
8
|
+
Project-URL: Repository, https://github.com/pilot-status/pilot-status
|
|
9
|
+
Project-URL: Issues, https://github.com/pilot-status/pilot-status/issues
|
|
10
|
+
Keywords: pilot-status,whatsapp,api,sdk,python
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: typing-extensions>=4.8.0
|
|
20
|
+
|
|
21
|
+
# pilot-status (Python SDK)
|
|
22
|
+
|
|
23
|
+
Official Python SDK for the Pilot Status public API.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install pilot-status
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
Create an API key in the dashboard and use it only on the backend.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
from pilot_status import PilotStatusClient
|
|
39
|
+
|
|
40
|
+
client = PilotStatusClient(
|
|
41
|
+
api_key=os.environ["PILOT_STATUS_API_KEY"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
accepted = client.messages.send(
|
|
45
|
+
{
|
|
46
|
+
"templateId": "onboarding-test",
|
|
47
|
+
"destinationNumber": "+5511999999999",
|
|
48
|
+
"variables": {"name": "John"},
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
message = client.messages.get(accepted["id"])
|
|
53
|
+
print(message["status"])
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Management (projects, API keys, numbers)
|
|
57
|
+
|
|
58
|
+
These endpoints create resources within the scope (project + environment) of the current `api_key`.
|
|
59
|
+
|
|
60
|
+
### Projects
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
project = client.projects.create(
|
|
64
|
+
{
|
|
65
|
+
"name": "My Project",
|
|
66
|
+
"description": "Optional description",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
projects = client.projects.list()
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### API keys
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
key = client.api_keys.create(
|
|
77
|
+
{
|
|
78
|
+
"name": "Backend Key",
|
|
79
|
+
"retentionDays": 30,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
keys = client.api_keys.list()
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Numbers (WhatsApp)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
created = client.numbers.create(
|
|
90
|
+
{
|
|
91
|
+
"name": "My WhatsApp",
|
|
92
|
+
"number": "+5511999999999",
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
# created["qrcodeBase64"], created["pairingCode"] (letter code or None)
|
|
96
|
+
|
|
97
|
+
refreshed = client.numbers.connect(created["instance"]["id"])
|
|
98
|
+
# refreshed["qrcodeBase64"], refreshed["pairingCode"]
|
|
99
|
+
|
|
100
|
+
status = client.numbers.get_status(created["instance"]["id"])
|
|
101
|
+
print(status["state"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Opt-in check (destination authorization)
|
|
105
|
+
|
|
106
|
+
In LIVE, sending may require opt-in when using the Pilot Status WhatsApp number. You can check whether a destination is already authorized for your project:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
opt_in = client.messages.check_opt_in("+5511999999999")
|
|
110
|
+
if not opt_in["authorized"]:
|
|
111
|
+
raise Exception(f"Missing opt-in: {opt_in['reason']}")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Analytics
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
stats = client.analytics.get_dashboard_stats(tz="America/Sao_Paulo")
|
|
118
|
+
print(stats["totalSent"], stats["failureRate"])
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Webhooks (parse / validation)
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from pilot_status import parse_customer_webhook
|
|
125
|
+
|
|
126
|
+
def handler(payload: dict):
|
|
127
|
+
event = parse_customer_webhook(payload)
|
|
128
|
+
|
|
129
|
+
if event["event"] == "message.failed":
|
|
130
|
+
print(event["data"]["errorMessage"])
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Notes:
|
|
134
|
+
- Customer webhook payloads do not include: `projectSlug`, `lastMessageId`. Optional `correlationId` (same as HTTP 202 when present) may appear on outbound status events and on `message.reply` / `message.received` when correlated to a prior send.
|
|
135
|
+
- `message.received` includes `fromMe` (boolean).
|
|
136
|
+
- `message.group` is delivered for inbound group messages (includes `groupName`).
|
|
137
|
+
- Supported events in the parser: `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.reply`, `message.received`, `message.group`, `optin.created`.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# pilot-status (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the Pilot Status public API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pilot-status
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
Create an API key in the dashboard and use it only on the backend.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from pilot_status import PilotStatusClient
|
|
19
|
+
|
|
20
|
+
client = PilotStatusClient(
|
|
21
|
+
api_key=os.environ["PILOT_STATUS_API_KEY"],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
accepted = client.messages.send(
|
|
25
|
+
{
|
|
26
|
+
"templateId": "onboarding-test",
|
|
27
|
+
"destinationNumber": "+5511999999999",
|
|
28
|
+
"variables": {"name": "John"},
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
message = client.messages.get(accepted["id"])
|
|
33
|
+
print(message["status"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Management (projects, API keys, numbers)
|
|
37
|
+
|
|
38
|
+
These endpoints create resources within the scope (project + environment) of the current `api_key`.
|
|
39
|
+
|
|
40
|
+
### Projects
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
project = client.projects.create(
|
|
44
|
+
{
|
|
45
|
+
"name": "My Project",
|
|
46
|
+
"description": "Optional description",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
projects = client.projects.list()
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### API keys
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
key = client.api_keys.create(
|
|
57
|
+
{
|
|
58
|
+
"name": "Backend Key",
|
|
59
|
+
"retentionDays": 30,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
keys = client.api_keys.list()
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Numbers (WhatsApp)
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
created = client.numbers.create(
|
|
70
|
+
{
|
|
71
|
+
"name": "My WhatsApp",
|
|
72
|
+
"number": "+5511999999999",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
# created["qrcodeBase64"], created["pairingCode"] (letter code or None)
|
|
76
|
+
|
|
77
|
+
refreshed = client.numbers.connect(created["instance"]["id"])
|
|
78
|
+
# refreshed["qrcodeBase64"], refreshed["pairingCode"]
|
|
79
|
+
|
|
80
|
+
status = client.numbers.get_status(created["instance"]["id"])
|
|
81
|
+
print(status["state"])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Opt-in check (destination authorization)
|
|
85
|
+
|
|
86
|
+
In LIVE, sending may require opt-in when using the Pilot Status WhatsApp number. You can check whether a destination is already authorized for your project:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
opt_in = client.messages.check_opt_in("+5511999999999")
|
|
90
|
+
if not opt_in["authorized"]:
|
|
91
|
+
raise Exception(f"Missing opt-in: {opt_in['reason']}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Analytics
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
stats = client.analytics.get_dashboard_stats(tz="America/Sao_Paulo")
|
|
98
|
+
print(stats["totalSent"], stats["failureRate"])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Webhooks (parse / validation)
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from pilot_status import parse_customer_webhook
|
|
105
|
+
|
|
106
|
+
def handler(payload: dict):
|
|
107
|
+
event = parse_customer_webhook(payload)
|
|
108
|
+
|
|
109
|
+
if event["event"] == "message.failed":
|
|
110
|
+
print(event["data"]["errorMessage"])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Notes:
|
|
114
|
+
- Customer webhook payloads do not include: `projectSlug`, `lastMessageId`. Optional `correlationId` (same as HTTP 202 when present) may appear on outbound status events and on `message.reply` / `message.received` when correlated to a prior send.
|
|
115
|
+
- `message.received` includes `fromMe` (boolean).
|
|
116
|
+
- `message.group` is delivered for inbound group messages (includes `groupName`).
|
|
117
|
+
- Supported events in the parser: `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.reply`, `message.received`, `message.group`, `optin.created`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pilot-status"
|
|
7
|
+
version = "0.0.9"
|
|
8
|
+
description = "Official Python SDK for the Pilot Status public API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["typing-extensions>=4.8.0"]
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
authors = [{ name = "Pilot Status" }]
|
|
14
|
+
keywords = ["pilot-status", "whatsapp", "api", "sdk", "python"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://pilotstatus.online"
|
|
26
|
+
Repository = "https://github.com/pilot-status/pilot-status"
|
|
27
|
+
Issues = "https://github.com/pilot-status/pilot-status/issues"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = { "" = "src" }
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from .client import PilotStatusClient
|
|
2
|
+
from .errors import (
|
|
3
|
+
AuthenticationError,
|
|
4
|
+
ConflictError,
|
|
5
|
+
ForbiddenError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
PilotStatusError,
|
|
8
|
+
PilotStatusHttpError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServerError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .types import (
|
|
14
|
+
ApiKeyCreateInput,
|
|
15
|
+
ApiKeyCreated,
|
|
16
|
+
ApiKeyListItem,
|
|
17
|
+
DashboardStats,
|
|
18
|
+
CreateNumberInput,
|
|
19
|
+
CreateNumberResult,
|
|
20
|
+
CreateProjectInput,
|
|
21
|
+
Environment,
|
|
22
|
+
MessageDetails,
|
|
23
|
+
MessageStatus,
|
|
24
|
+
NumberConnectResult,
|
|
25
|
+
NumberStatusResult,
|
|
26
|
+
OptInCheckResult,
|
|
27
|
+
Project,
|
|
28
|
+
SendMessageAccepted,
|
|
29
|
+
SendMessageInput,
|
|
30
|
+
WhatsAppNumberInstance,
|
|
31
|
+
)
|
|
32
|
+
from .webhooks.parse import is_customer_webhook_event, parse_customer_webhook
|
|
33
|
+
from .webhooks.types import (
|
|
34
|
+
CustomerWebhookEvent,
|
|
35
|
+
MessageFailedEvent,
|
|
36
|
+
MessageReadEvent,
|
|
37
|
+
MessageReplyEvent,
|
|
38
|
+
MessageSentEvent,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"PilotStatusClient",
|
|
43
|
+
"PilotStatusError",
|
|
44
|
+
"PilotStatusHttpError",
|
|
45
|
+
"AuthenticationError",
|
|
46
|
+
"ValidationError",
|
|
47
|
+
"ForbiddenError",
|
|
48
|
+
"NotFoundError",
|
|
49
|
+
"ConflictError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"Environment",
|
|
53
|
+
"MessageStatus",
|
|
54
|
+
"SendMessageInput",
|
|
55
|
+
"SendMessageAccepted",
|
|
56
|
+
"MessageDetails",
|
|
57
|
+
"DashboardStats",
|
|
58
|
+
"OptInCheckResult",
|
|
59
|
+
"Project",
|
|
60
|
+
"CreateProjectInput",
|
|
61
|
+
"ApiKeyCreateInput",
|
|
62
|
+
"ApiKeyCreated",
|
|
63
|
+
"ApiKeyListItem",
|
|
64
|
+
"WhatsAppNumberInstance",
|
|
65
|
+
"CreateNumberInput",
|
|
66
|
+
"CreateNumberResult",
|
|
67
|
+
"NumberConnectResult",
|
|
68
|
+
"NumberStatusResult",
|
|
69
|
+
"CustomerWebhookEvent",
|
|
70
|
+
"MessageSentEvent",
|
|
71
|
+
"MessageReadEvent",
|
|
72
|
+
"MessageFailedEvent",
|
|
73
|
+
"MessageReplyEvent",
|
|
74
|
+
"parse_customer_webhook",
|
|
75
|
+
"is_customer_webhook_event",
|
|
76
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import urllib.request
|
|
6
|
+
|
|
7
|
+
from .http import HttpClient, HttpClientOptions
|
|
8
|
+
from .resources import AnalyticsResource, ApiKeysResource, MessagesResource, NumbersResource, ProjectsResource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PilotStatusClient:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
api_key: str,
|
|
16
|
+
timeout_s: float = 30.0,
|
|
17
|
+
user_agent: str | None = None,
|
|
18
|
+
get_retries: int = 2,
|
|
19
|
+
transport: Callable[[urllib.request.Request, float], tuple[int, dict[str, str], str]] | None = None,
|
|
20
|
+
):
|
|
21
|
+
base_url = "https://pilotstatus.online"
|
|
22
|
+
http = HttpClient(
|
|
23
|
+
HttpClientOptions(
|
|
24
|
+
base_url=base_url,
|
|
25
|
+
api_key=api_key,
|
|
26
|
+
timeout_s=timeout_s,
|
|
27
|
+
user_agent=user_agent,
|
|
28
|
+
get_retries=get_retries,
|
|
29
|
+
transport=transport,
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
self.messages = MessagesResource(http)
|
|
33
|
+
self.analytics = AnalyticsResource(http)
|
|
34
|
+
self.projects = ProjectsResource(http)
|
|
35
|
+
self.api_keys = ApiKeysResource(http)
|
|
36
|
+
self.numbers = NumbersResource(http)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
PilotStatusErrorBody = Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PilotStatusError(Exception):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PilotStatusHttpError(PilotStatusError):
|
|
16
|
+
message: str
|
|
17
|
+
status: int
|
|
18
|
+
method: str
|
|
19
|
+
url: str
|
|
20
|
+
headers: dict[str, str]
|
|
21
|
+
body: PilotStatusErrorBody | None = None
|
|
22
|
+
raw_body: str | None = None
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthenticationError(PilotStatusHttpError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationError(PilotStatusHttpError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ForbiddenError(PilotStatusHttpError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NotFoundError(PilotStatusHttpError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConflictError(PilotStatusHttpError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RateLimitError(PilotStatusHttpError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ServerError(PilotStatusHttpError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_http_error(
|
|
57
|
+
*,
|
|
58
|
+
message: str,
|
|
59
|
+
status: int,
|
|
60
|
+
method: str,
|
|
61
|
+
url: str,
|
|
62
|
+
headers: dict[str, str],
|
|
63
|
+
body: PilotStatusErrorBody | None = None,
|
|
64
|
+
raw_body: str | None = None,
|
|
65
|
+
) -> PilotStatusHttpError:
|
|
66
|
+
if status == 401:
|
|
67
|
+
return AuthenticationError(message, status, method, url, headers, body, raw_body)
|
|
68
|
+
if status == 400:
|
|
69
|
+
return ValidationError(message, status, method, url, headers, body, raw_body)
|
|
70
|
+
if status == 403:
|
|
71
|
+
return ForbiddenError(message, status, method, url, headers, body, raw_body)
|
|
72
|
+
if status == 404:
|
|
73
|
+
return NotFoundError(message, status, method, url, headers, body, raw_body)
|
|
74
|
+
if status == 409:
|
|
75
|
+
return ConflictError(message, status, method, url, headers, body, raw_body)
|
|
76
|
+
if status == 429:
|
|
77
|
+
return RateLimitError(message, status, method, url, headers, body, raw_body)
|
|
78
|
+
if status >= 500:
|
|
79
|
+
return ServerError(message, status, method, url, headers, body, raw_body)
|
|
80
|
+
return PilotStatusHttpError(message, status, method, url, headers, body, raw_body)
|
|
81
|
+
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, Mapping
|
|
10
|
+
|
|
11
|
+
from .errors import create_http_error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class HttpClientOptions:
|
|
16
|
+
base_url: str
|
|
17
|
+
api_key: str
|
|
18
|
+
timeout_s: float
|
|
19
|
+
user_agent: str | None
|
|
20
|
+
get_retries: int
|
|
21
|
+
transport: Callable[[urllib.request.Request, float], tuple[int, Mapping[str, str], str]] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
25
|
+
return base_url.rstrip("/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _join_url(base_url: str, path: str) -> str:
|
|
29
|
+
base = _normalize_base_url(base_url)
|
|
30
|
+
if not path.startswith("/"):
|
|
31
|
+
return f"{base}/{path}"
|
|
32
|
+
return f"{base}{path}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_url(
|
|
36
|
+
base_url: str,
|
|
37
|
+
path: str,
|
|
38
|
+
query: Mapping[str, str | int | float | bool | None] | None,
|
|
39
|
+
) -> str:
|
|
40
|
+
url = _join_url(base_url, path)
|
|
41
|
+
if not query:
|
|
42
|
+
return url
|
|
43
|
+
parts = list(urllib.parse.urlsplit(url))
|
|
44
|
+
current = urllib.parse.parse_qsl(parts[3], keep_blank_values=True)
|
|
45
|
+
for k, v in query.items():
|
|
46
|
+
if v is None:
|
|
47
|
+
continue
|
|
48
|
+
current.append((k, str(v)))
|
|
49
|
+
parts[3] = urllib.parse.urlencode(current)
|
|
50
|
+
return urllib.parse.urlunsplit(parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_json_if_possible(text: str) -> Any:
|
|
54
|
+
trimmed = text.strip()
|
|
55
|
+
if not trimmed:
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(trimmed)
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _default_transport(req: urllib.request.Request, timeout_s: float) -> tuple[int, Mapping[str, str], str]:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=timeout_s) as res:
|
|
65
|
+
status = int(getattr(res, "status", 200))
|
|
66
|
+
headers = {k.lower(): v for k, v in dict(res.headers).items()}
|
|
67
|
+
raw = res.read().decode("utf-8", errors="replace")
|
|
68
|
+
return status, headers, raw
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _should_retry(method: str, status: int | None) -> bool:
|
|
72
|
+
if method != "GET":
|
|
73
|
+
return False
|
|
74
|
+
if status is None:
|
|
75
|
+
return True
|
|
76
|
+
return status >= 500
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HttpClient:
|
|
80
|
+
def __init__(self, options: HttpClientOptions):
|
|
81
|
+
self._base_url = _normalize_base_url(options.base_url)
|
|
82
|
+
self._api_key = options.api_key
|
|
83
|
+
self._timeout_s = options.timeout_s
|
|
84
|
+
self._user_agent = options.user_agent
|
|
85
|
+
self._get_retries = options.get_retries
|
|
86
|
+
self._transport = options.transport or _default_transport
|
|
87
|
+
|
|
88
|
+
def request_json(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
query: Mapping[str, str | int | float | bool | None] | None = None,
|
|
94
|
+
body: Any | None = None,
|
|
95
|
+
) -> Any:
|
|
96
|
+
url = _build_url(self._base_url, path, query)
|
|
97
|
+
|
|
98
|
+
headers: dict[str, str] = {
|
|
99
|
+
"x-api-key": self._api_key,
|
|
100
|
+
"accept": "application/json",
|
|
101
|
+
}
|
|
102
|
+
if self._user_agent:
|
|
103
|
+
headers["user-agent"] = self._user_agent
|
|
104
|
+
|
|
105
|
+
data: bytes | None = None
|
|
106
|
+
if body is not None:
|
|
107
|
+
headers["content-type"] = "application/json"
|
|
108
|
+
data = json.dumps(body).encode("utf-8")
|
|
109
|
+
|
|
110
|
+
req = urllib.request.Request(url=url, data=data, method=method, headers=headers)
|
|
111
|
+
|
|
112
|
+
attempt = 0
|
|
113
|
+
while True:
|
|
114
|
+
status: int | None = None
|
|
115
|
+
response_headers: Mapping[str, str] = {}
|
|
116
|
+
raw: str = ""
|
|
117
|
+
try:
|
|
118
|
+
status, response_headers, raw = self._transport(req, self._timeout_s)
|
|
119
|
+
except urllib.error.HTTPError as e:
|
|
120
|
+
status = int(getattr(e, "code", 0) or 0) or None
|
|
121
|
+
response_headers = {k.lower(): v for k, v in dict(getattr(e, "headers", {}) or {}).items()}
|
|
122
|
+
raw = (e.read() or b"").decode("utf-8", errors="replace")
|
|
123
|
+
except urllib.error.URLError as e:
|
|
124
|
+
if attempt >= self._get_retries or not _should_retry(method, None):
|
|
125
|
+
raise e
|
|
126
|
+
backoff_ms = 250 * (2**attempt)
|
|
127
|
+
time.sleep(backoff_ms / 1000.0)
|
|
128
|
+
attempt += 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
parsed = _parse_json_if_possible(raw)
|
|
132
|
+
|
|
133
|
+
if status is None or not (200 <= status < 300):
|
|
134
|
+
message = None
|
|
135
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), str):
|
|
136
|
+
message = str(parsed["error"])
|
|
137
|
+
if not message:
|
|
138
|
+
message = f"HTTP {status}" if status is not None else "HTTP error"
|
|
139
|
+
err = create_http_error(
|
|
140
|
+
message=message,
|
|
141
|
+
status=int(status or 0),
|
|
142
|
+
method=method,
|
|
143
|
+
url=url,
|
|
144
|
+
headers=dict(response_headers),
|
|
145
|
+
body=parsed,
|
|
146
|
+
raw_body=raw or None,
|
|
147
|
+
)
|
|
148
|
+
if attempt < self._get_retries and _should_retry(method, err.status):
|
|
149
|
+
backoff_ms = 250 * (2**attempt)
|
|
150
|
+
time.sleep(backoff_ms / 1000.0)
|
|
151
|
+
attempt += 1
|
|
152
|
+
continue
|
|
153
|
+
raise err
|
|
154
|
+
|
|
155
|
+
return parsed
|
|
156
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .analytics import AnalyticsResource
|
|
2
|
+
from .api_keys import ApiKeysResource
|
|
3
|
+
from .messages import MessagesResource
|
|
4
|
+
from .numbers import NumbersResource
|
|
5
|
+
from .projects import ProjectsResource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MessagesResource",
|
|
9
|
+
"AnalyticsResource",
|
|
10
|
+
"ProjectsResource",
|
|
11
|
+
"ApiKeysResource",
|
|
12
|
+
"NumbersResource",
|
|
13
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..http import HttpClient
|
|
4
|
+
from ..types import DashboardStats
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AnalyticsResource:
|
|
8
|
+
def __init__(self, http: HttpClient):
|
|
9
|
+
self._http = http
|
|
10
|
+
|
|
11
|
+
def get_dashboard_stats(self, *, tz: str | None = None) -> DashboardStats:
|
|
12
|
+
query = None if tz is None else {"tz": tz}
|
|
13
|
+
return self._http.request_json(method="GET", path="/v1/analytics/dashboard", query=query)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..http import HttpClient
|
|
6
|
+
from ..types import ApiKeyCreateInput, ApiKeyCreated, ApiKeyListItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiKeysResource:
|
|
10
|
+
def __init__(self, http: HttpClient):
|
|
11
|
+
self._http = http
|
|
12
|
+
|
|
13
|
+
def list(self) -> list[ApiKeyListItem]:
|
|
14
|
+
return self._http.request_json(method="GET", path="/v1/api-keys")
|
|
15
|
+
|
|
16
|
+
def create(self, input: ApiKeyCreateInput) -> ApiKeyCreated:
|
|
17
|
+
body: dict[str, Any] = {"name": input["name"]}
|
|
18
|
+
if "webhookId" in input:
|
|
19
|
+
body["webhookId"] = input["webhookId"]
|
|
20
|
+
if "whatsappInstanceId" in input:
|
|
21
|
+
body["whatsappInstanceId"] = input["whatsappInstanceId"]
|
|
22
|
+
if "retentionDays" in input:
|
|
23
|
+
body["retentionDays"] = input["retentionDays"]
|
|
24
|
+
return self._http.request_json(method="POST", path="/v1/api-keys", body=body)
|