byourside 0.1.0__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.
- byourside-0.1.0/LICENSE +21 -0
- byourside-0.1.0/PKG-INFO +187 -0
- byourside-0.1.0/README.md +162 -0
- byourside-0.1.0/byourside/__init__.py +5 -0
- byourside-0.1.0/byourside/client.py +79 -0
- byourside-0.1.0/byourside/errors.py +34 -0
- byourside-0.1.0/byourside/webhooks.py +26 -0
- byourside-0.1.0/byourside.egg-info/PKG-INFO +187 -0
- byourside-0.1.0/byourside.egg-info/SOURCES.txt +15 -0
- byourside-0.1.0/byourside.egg-info/dependency_links.txt +1 -0
- byourside-0.1.0/byourside.egg-info/top_level.txt +1 -0
- byourside-0.1.0/pyproject.toml +35 -0
- byourside-0.1.0/setup.cfg +4 -0
- byourside-0.1.0/tests/test_client.py +48 -0
- byourside-0.1.0/tests/test_errors.py +17 -0
- byourside-0.1.0/tests/test_wait.py +26 -0
- byourside-0.1.0/tests/test_webhooks.py +22 -0
byourside-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 By Your Side
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
byourside-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: byourside
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: By Your Side SDK: give an AI a phone (outbound objective-driven calls).
|
|
5
|
+
Author: By Your Side
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://byourside.ai/docs/agent-api
|
|
8
|
+
Project-URL: Repository, https://github.com/allexp1/voip-agent
|
|
9
|
+
Keywords: voice,ai,phone,outbound,agent,sdk,voip,calls
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Telephony
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# By Your Side Python SDK
|
|
27
|
+
|
|
28
|
+
Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
|
|
29
|
+
Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
|
|
30
|
+
|
|
31
|
+
Requires Python 3.8+. No third-party packages needed.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
### Place a call and wait for the result
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from byourside import Client
|
|
41
|
+
|
|
42
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
43
|
+
|
|
44
|
+
# Place an outbound call with an objective
|
|
45
|
+
call = client.place_call(
|
|
46
|
+
to="+14155550123",
|
|
47
|
+
objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
|
|
48
|
+
fields=[
|
|
49
|
+
{"name": "confirmed", "type": "boolean"},
|
|
50
|
+
{"name": "new_time", "type": "string"},
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
print("Placed:", call["callId"], "status:", call["status"])
|
|
54
|
+
|
|
55
|
+
# Poll until the call reaches a terminal status (default timeout: 180s)
|
|
56
|
+
result = client.wait_for_call(call["callId"], timeout=180, interval=5)
|
|
57
|
+
print("Final status:", result["status"])
|
|
58
|
+
print("Extracted fields:", result.get("extracted"))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### List recent calls
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
calls = client.list_calls(limit=10)
|
|
65
|
+
for c in calls:
|
|
66
|
+
print(c["callId"], c["status"])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Get a specific call
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
call = client.get_call("call_abc123")
|
|
73
|
+
print(call)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Call statuses
|
|
79
|
+
|
|
80
|
+
| Status | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `queued` | Call accepted, waiting to be placed |
|
|
83
|
+
| `in_progress` | Call is active |
|
|
84
|
+
| `completed` | Call finished normally (terminal) |
|
|
85
|
+
| `no_answer` | Recipient did not pick up (terminal) |
|
|
86
|
+
| `voicemail` | Reached voicemail (terminal) |
|
|
87
|
+
| `declined` | Recipient declined the call (terminal) |
|
|
88
|
+
| `failed` | Call could not complete (terminal) |
|
|
89
|
+
|
|
90
|
+
`wait_for_call` returns once the status is one of the five terminal states.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Webhook verification
|
|
95
|
+
|
|
96
|
+
By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
|
|
97
|
+
Verify it before processing the payload.
|
|
98
|
+
|
|
99
|
+
### Flask example
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from flask import Flask, request, abort
|
|
103
|
+
from byourside import verify_webhook
|
|
104
|
+
|
|
105
|
+
app = Flask(__name__)
|
|
106
|
+
WEBHOOK_SECRET = "whsec_YOUR_SECRET"
|
|
107
|
+
|
|
108
|
+
@app.route("/webhook/bys", methods=["POST"])
|
|
109
|
+
def bys_webhook():
|
|
110
|
+
sig = request.headers.get("X-BYS-Signature", "")
|
|
111
|
+
raw_body = request.get_data(as_text=True)
|
|
112
|
+
if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
|
|
113
|
+
abort(400, "Invalid signature")
|
|
114
|
+
payload = request.get_json()
|
|
115
|
+
print("Call update:", payload["callId"], payload["status"])
|
|
116
|
+
return "", 200
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The signature format is `t=<unix_seconds>,v1=<hex>` where
|
|
120
|
+
`hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
|
|
121
|
+
|
|
122
|
+
`verify_webhook` never raises. It returns `False` for invalid or expired signatures
|
|
123
|
+
(default tolerance: 300 seconds).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Error handling
|
|
128
|
+
|
|
129
|
+
All API errors raise `ByoursideError`:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from byourside import Client, ByoursideError
|
|
133
|
+
|
|
134
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
135
|
+
try:
|
|
136
|
+
client.place_call(to="+19001234567", objective="Sell something")
|
|
137
|
+
except ByoursideError as e:
|
|
138
|
+
print(e.code) # e.g. "destination_blocked"
|
|
139
|
+
print(e.status) # HTTP status, or 0 for network/timeout errors
|
|
140
|
+
print(str(e)) # Human-readable message
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Common error codes:
|
|
144
|
+
|
|
145
|
+
| Code | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
|
|
148
|
+
| `invalid_number` | Number must be E.164, e.g. +14155550123 |
|
|
149
|
+
| `to_required` | Missing destination number |
|
|
150
|
+
| `objective_required` | Missing call objective |
|
|
151
|
+
| `caller_id_not_owned` | Caller ID not on your account |
|
|
152
|
+
| `rate_limited` | Rate limit reached, retry shortly |
|
|
153
|
+
| `over_minute_cap` | Outbound usage limit reached |
|
|
154
|
+
| `unauthorized` | Invalid or missing API key |
|
|
155
|
+
| `not_found` | Call ID not found |
|
|
156
|
+
| `placement_failed` | Carrier or trunk issue, retry shortly |
|
|
157
|
+
| `store_error` | Temporary service error, retry shortly |
|
|
158
|
+
| `network_error` | Could not reach the API |
|
|
159
|
+
| `timeout` | `wait_for_call` deadline exceeded |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
cd sdks/python
|
|
169
|
+
python3 -m unittest discover -s tests
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
|
|
173
|
+
|
|
174
|
+
If your environment does not add CWD to the path automatically, use:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd sdks/python
|
|
178
|
+
PYTHONPATH=. python3 -m unittest discover -s tests
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Syntax check all source files:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Publishing to PyPI is deferred. Do not publish without explicit owner approval.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# By Your Side Python SDK
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
|
|
4
|
+
Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
|
|
5
|
+
|
|
6
|
+
Requires Python 3.8+. No third-party packages needed.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
### Place a call and wait for the result
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from byourside import Client
|
|
16
|
+
|
|
17
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
18
|
+
|
|
19
|
+
# Place an outbound call with an objective
|
|
20
|
+
call = client.place_call(
|
|
21
|
+
to="+14155550123",
|
|
22
|
+
objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
|
|
23
|
+
fields=[
|
|
24
|
+
{"name": "confirmed", "type": "boolean"},
|
|
25
|
+
{"name": "new_time", "type": "string"},
|
|
26
|
+
],
|
|
27
|
+
)
|
|
28
|
+
print("Placed:", call["callId"], "status:", call["status"])
|
|
29
|
+
|
|
30
|
+
# Poll until the call reaches a terminal status (default timeout: 180s)
|
|
31
|
+
result = client.wait_for_call(call["callId"], timeout=180, interval=5)
|
|
32
|
+
print("Final status:", result["status"])
|
|
33
|
+
print("Extracted fields:", result.get("extracted"))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### List recent calls
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
calls = client.list_calls(limit=10)
|
|
40
|
+
for c in calls:
|
|
41
|
+
print(c["callId"], c["status"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Get a specific call
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
call = client.get_call("call_abc123")
|
|
48
|
+
print(call)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Call statuses
|
|
54
|
+
|
|
55
|
+
| Status | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `queued` | Call accepted, waiting to be placed |
|
|
58
|
+
| `in_progress` | Call is active |
|
|
59
|
+
| `completed` | Call finished normally (terminal) |
|
|
60
|
+
| `no_answer` | Recipient did not pick up (terminal) |
|
|
61
|
+
| `voicemail` | Reached voicemail (terminal) |
|
|
62
|
+
| `declined` | Recipient declined the call (terminal) |
|
|
63
|
+
| `failed` | Call could not complete (terminal) |
|
|
64
|
+
|
|
65
|
+
`wait_for_call` returns once the status is one of the five terminal states.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Webhook verification
|
|
70
|
+
|
|
71
|
+
By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
|
|
72
|
+
Verify it before processing the payload.
|
|
73
|
+
|
|
74
|
+
### Flask example
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from flask import Flask, request, abort
|
|
78
|
+
from byourside import verify_webhook
|
|
79
|
+
|
|
80
|
+
app = Flask(__name__)
|
|
81
|
+
WEBHOOK_SECRET = "whsec_YOUR_SECRET"
|
|
82
|
+
|
|
83
|
+
@app.route("/webhook/bys", methods=["POST"])
|
|
84
|
+
def bys_webhook():
|
|
85
|
+
sig = request.headers.get("X-BYS-Signature", "")
|
|
86
|
+
raw_body = request.get_data(as_text=True)
|
|
87
|
+
if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
|
|
88
|
+
abort(400, "Invalid signature")
|
|
89
|
+
payload = request.get_json()
|
|
90
|
+
print("Call update:", payload["callId"], payload["status"])
|
|
91
|
+
return "", 200
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The signature format is `t=<unix_seconds>,v1=<hex>` where
|
|
95
|
+
`hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
|
|
96
|
+
|
|
97
|
+
`verify_webhook` never raises. It returns `False` for invalid or expired signatures
|
|
98
|
+
(default tolerance: 300 seconds).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Error handling
|
|
103
|
+
|
|
104
|
+
All API errors raise `ByoursideError`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from byourside import Client, ByoursideError
|
|
108
|
+
|
|
109
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
110
|
+
try:
|
|
111
|
+
client.place_call(to="+19001234567", objective="Sell something")
|
|
112
|
+
except ByoursideError as e:
|
|
113
|
+
print(e.code) # e.g. "destination_blocked"
|
|
114
|
+
print(e.status) # HTTP status, or 0 for network/timeout errors
|
|
115
|
+
print(str(e)) # Human-readable message
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Common error codes:
|
|
119
|
+
|
|
120
|
+
| Code | Meaning |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
|
|
123
|
+
| `invalid_number` | Number must be E.164, e.g. +14155550123 |
|
|
124
|
+
| `to_required` | Missing destination number |
|
|
125
|
+
| `objective_required` | Missing call objective |
|
|
126
|
+
| `caller_id_not_owned` | Caller ID not on your account |
|
|
127
|
+
| `rate_limited` | Rate limit reached, retry shortly |
|
|
128
|
+
| `over_minute_cap` | Outbound usage limit reached |
|
|
129
|
+
| `unauthorized` | Invalid or missing API key |
|
|
130
|
+
| `not_found` | Call ID not found |
|
|
131
|
+
| `placement_failed` | Carrier or trunk issue, retry shortly |
|
|
132
|
+
| `store_error` | Temporary service error, retry shortly |
|
|
133
|
+
| `network_error` | Could not reach the API |
|
|
134
|
+
| `timeout` | `wait_for_call` deadline exceeded |
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Development
|
|
139
|
+
|
|
140
|
+
Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
cd sdks/python
|
|
144
|
+
python3 -m unittest discover -s tests
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
|
|
148
|
+
|
|
149
|
+
If your environment does not add CWD to the path automatically, use:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
cd sdks/python
|
|
153
|
+
PYTHONPATH=. python3 -m unittest discover -s tests
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Syntax check all source files:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Publishing to PyPI is deferred. Do not publish without explicit owner approval.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""By Your Side SDK client. Zero third-party deps (urllib). Raises ByoursideError on any
|
|
2
|
+
non-2xx or network failure. The HTTP transport is injectable for tests."""
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from .errors import map_error, ByoursideError
|
|
10
|
+
|
|
11
|
+
TERMINAL = {"completed", "no_answer", "voicemail", "declined", "failed"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_transport(method, url, headers, body):
|
|
15
|
+
data = body.encode() if body is not None else None
|
|
16
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
17
|
+
try:
|
|
18
|
+
with urllib.request.urlopen(req) as resp:
|
|
19
|
+
return (resp.status, json.loads(resp.read().decode() or "null"))
|
|
20
|
+
except urllib.error.HTTPError as e:
|
|
21
|
+
try:
|
|
22
|
+
obj = json.loads(e.read().decode() or "null")
|
|
23
|
+
except Exception:
|
|
24
|
+
obj = None
|
|
25
|
+
return (e.code, obj)
|
|
26
|
+
except urllib.error.URLError as e:
|
|
27
|
+
raise ByoursideError(map_error(0, None), 0, "network_error")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Client:
|
|
31
|
+
def __init__(self, api_key, base_url="https://api.byourside.ai", transport=None, sleep=None):
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise ByoursideError("api_key is required", 0, "no_api_key")
|
|
34
|
+
self._api_key = api_key
|
|
35
|
+
self._base = base_url.rstrip("/")
|
|
36
|
+
self._transport = transport or _default_transport
|
|
37
|
+
self._sleep = sleep or time.sleep
|
|
38
|
+
|
|
39
|
+
def _request(self, method, path, body=None):
|
|
40
|
+
headers = {"Authorization": "Bearer %s" % self._api_key}
|
|
41
|
+
body_str = None
|
|
42
|
+
if body is not None and method != "GET":
|
|
43
|
+
headers["content-type"] = "application/json"
|
|
44
|
+
body_str = json.dumps(body)
|
|
45
|
+
status, data = self._transport(method, self._base + path, headers, body_str)
|
|
46
|
+
if not (200 <= status < 300):
|
|
47
|
+
code = data.get("error") if isinstance(data, dict) else "error"
|
|
48
|
+
raise ByoursideError(map_error(status, data), status, code or "error")
|
|
49
|
+
return data
|
|
50
|
+
|
|
51
|
+
def place_call(self, to, objective, context=None, fields=None, webhook_url=None, caller_id=None):
|
|
52
|
+
body = {"to": to, "objective": objective}
|
|
53
|
+
if context is not None:
|
|
54
|
+
body["context"] = context
|
|
55
|
+
if fields is not None:
|
|
56
|
+
body["fields"] = fields
|
|
57
|
+
if webhook_url is not None:
|
|
58
|
+
body["webhookUrl"] = webhook_url
|
|
59
|
+
if caller_id is not None:
|
|
60
|
+
body["callerId"] = caller_id
|
|
61
|
+
return self._request("POST", "/v1/agent/calls", body)
|
|
62
|
+
|
|
63
|
+
def get_call(self, call_id):
|
|
64
|
+
return self._request("GET", "/v1/agent/calls/" + urllib.parse.quote(str(call_id), safe=""))
|
|
65
|
+
|
|
66
|
+
def list_calls(self, limit=20):
|
|
67
|
+
n = min(max(1, int(limit) if str(limit).isdigit() else 20), 100)
|
|
68
|
+
data = self._request("GET", "/v1/agent/calls?limit=%d" % n)
|
|
69
|
+
return data.get("calls", []) if isinstance(data, dict) else []
|
|
70
|
+
|
|
71
|
+
def wait_for_call(self, call_id, timeout=180, interval=3):
|
|
72
|
+
deadline = time.monotonic() + timeout
|
|
73
|
+
while True:
|
|
74
|
+
call = self.get_call(call_id)
|
|
75
|
+
if isinstance(call, dict) and call.get("status") in TERMINAL:
|
|
76
|
+
return call
|
|
77
|
+
if time.monotonic() + interval > deadline:
|
|
78
|
+
raise ByoursideError("Call %s did not finish within %ss" % (call_id, timeout), 0, "timeout")
|
|
79
|
+
self._sleep(interval)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Typed error + token->message mapping for the By Your Side SDK. Never includes the API key."""
|
|
2
|
+
|
|
3
|
+
_TOKEN_MESSAGES = {
|
|
4
|
+
"destination_blocked": "That destination is not allowed (premium, IRSF, or unsupported country).",
|
|
5
|
+
"invalid_number": "The destination number is invalid (use full E.164, e.g. +14155550123).",
|
|
6
|
+
"to_required": "A destination number (to) is required.",
|
|
7
|
+
"objective_required": "An objective for the call is required.",
|
|
8
|
+
"caller_id_not_owned": "That caller ID is not a number on your account.",
|
|
9
|
+
"rate_limited": "Rate limit reached. Try again shortly.",
|
|
10
|
+
"over_minute_cap": "Outbound usage limit reached for now.",
|
|
11
|
+
"unauthorized": "Invalid or missing API key.",
|
|
12
|
+
"not_found": "No call found with that id (or it does not belong to your account).",
|
|
13
|
+
"placement_failed": "The call could not be placed (carrier/trunk issue). Try again shortly.",
|
|
14
|
+
"store_error": "Temporary service error. Please retry shortly.",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def map_error(status, body):
|
|
19
|
+
token = body.get("error", "") if isinstance(body, dict) else ""
|
|
20
|
+
if token and token in _TOKEN_MESSAGES:
|
|
21
|
+
return _TOKEN_MESSAGES[token]
|
|
22
|
+
if not status or status >= 500:
|
|
23
|
+
return "Temporary service error. Please retry shortly."
|
|
24
|
+
if token:
|
|
25
|
+
return "Request failed (%s)." % token
|
|
26
|
+
return "Request failed with status %s." % status
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ByoursideError(Exception):
|
|
30
|
+
def __init__(self, message, status=0, code=None):
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.message = message
|
|
33
|
+
self.status = status
|
|
34
|
+
self.code = code
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Verify a By Your Side webhook signature header. Never raises; returns bool.
|
|
2
|
+
Header: "t=<unix_seconds>,v1=<hex>"; sig = HMAC-SHA256(secret, f"{t}.{raw_body}")."""
|
|
3
|
+
import hmac
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def verify_webhook(signature_header, raw_body, secret, tolerance=300):
|
|
9
|
+
try:
|
|
10
|
+
if not signature_header or not secret:
|
|
11
|
+
return False
|
|
12
|
+
parts = {}
|
|
13
|
+
for kv in str(signature_header).split(","):
|
|
14
|
+
if "=" in kv:
|
|
15
|
+
k, v = kv.split("=", 1)
|
|
16
|
+
parts[k.strip()] = v.strip()
|
|
17
|
+
t = parts.get("t"); v1 = parts.get("v1")
|
|
18
|
+
if t is None or v1 is None:
|
|
19
|
+
return False
|
|
20
|
+
t = int(t)
|
|
21
|
+
if abs(int(time.time()) - t) > tolerance:
|
|
22
|
+
return False
|
|
23
|
+
expected = hmac.new(secret.encode(), ("%d.%s" % (t, raw_body)).encode(), hashlib.sha256).hexdigest()
|
|
24
|
+
return hmac.compare_digest(expected, str(v1))
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: byourside
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: By Your Side SDK: give an AI a phone (outbound objective-driven calls).
|
|
5
|
+
Author: By Your Side
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://byourside.ai/docs/agent-api
|
|
8
|
+
Project-URL: Repository, https://github.com/allexp1/voip-agent
|
|
9
|
+
Keywords: voice,ai,phone,outbound,agent,sdk,voip,calls
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Communications :: Telephony
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# By Your Side Python SDK
|
|
27
|
+
|
|
28
|
+
Zero-dependency Python client for the By Your Side Agent Outbound-Call API.
|
|
29
|
+
Place objective-driven AI calls, poll for results, and verify webhooks using only the Python standard library.
|
|
30
|
+
|
|
31
|
+
Requires Python 3.8+. No third-party packages needed.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
### Place a call and wait for the result
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from byourside import Client
|
|
41
|
+
|
|
42
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
43
|
+
|
|
44
|
+
# Place an outbound call with an objective
|
|
45
|
+
call = client.place_call(
|
|
46
|
+
to="+14155550123",
|
|
47
|
+
objective="Confirm the appointment for tomorrow at 2pm and ask if they need to reschedule.",
|
|
48
|
+
fields=[
|
|
49
|
+
{"name": "confirmed", "type": "boolean"},
|
|
50
|
+
{"name": "new_time", "type": "string"},
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
print("Placed:", call["callId"], "status:", call["status"])
|
|
54
|
+
|
|
55
|
+
# Poll until the call reaches a terminal status (default timeout: 180s)
|
|
56
|
+
result = client.wait_for_call(call["callId"], timeout=180, interval=5)
|
|
57
|
+
print("Final status:", result["status"])
|
|
58
|
+
print("Extracted fields:", result.get("extracted"))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### List recent calls
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
calls = client.list_calls(limit=10)
|
|
65
|
+
for c in calls:
|
|
66
|
+
print(c["callId"], c["status"])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Get a specific call
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
call = client.get_call("call_abc123")
|
|
73
|
+
print(call)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Call statuses
|
|
79
|
+
|
|
80
|
+
| Status | Meaning |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `queued` | Call accepted, waiting to be placed |
|
|
83
|
+
| `in_progress` | Call is active |
|
|
84
|
+
| `completed` | Call finished normally (terminal) |
|
|
85
|
+
| `no_answer` | Recipient did not pick up (terminal) |
|
|
86
|
+
| `voicemail` | Reached voicemail (terminal) |
|
|
87
|
+
| `declined` | Recipient declined the call (terminal) |
|
|
88
|
+
| `failed` | Call could not complete (terminal) |
|
|
89
|
+
|
|
90
|
+
`wait_for_call` returns once the status is one of the five terminal states.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Webhook verification
|
|
95
|
+
|
|
96
|
+
By Your Side sends a `X-BYS-Signature` header with each webhook delivery.
|
|
97
|
+
Verify it before processing the payload.
|
|
98
|
+
|
|
99
|
+
### Flask example
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from flask import Flask, request, abort
|
|
103
|
+
from byourside import verify_webhook
|
|
104
|
+
|
|
105
|
+
app = Flask(__name__)
|
|
106
|
+
WEBHOOK_SECRET = "whsec_YOUR_SECRET"
|
|
107
|
+
|
|
108
|
+
@app.route("/webhook/bys", methods=["POST"])
|
|
109
|
+
def bys_webhook():
|
|
110
|
+
sig = request.headers.get("X-BYS-Signature", "")
|
|
111
|
+
raw_body = request.get_data(as_text=True)
|
|
112
|
+
if not verify_webhook(sig, raw_body, WEBHOOK_SECRET):
|
|
113
|
+
abort(400, "Invalid signature")
|
|
114
|
+
payload = request.get_json()
|
|
115
|
+
print("Call update:", payload["callId"], payload["status"])
|
|
116
|
+
return "", 200
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The signature format is `t=<unix_seconds>,v1=<hex>` where
|
|
120
|
+
`hex = HMAC-SHA256(secret, f"{t}.{raw_body}")`.
|
|
121
|
+
|
|
122
|
+
`verify_webhook` never raises. It returns `False` for invalid or expired signatures
|
|
123
|
+
(default tolerance: 300 seconds).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Error handling
|
|
128
|
+
|
|
129
|
+
All API errors raise `ByoursideError`:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from byourside import Client, ByoursideError
|
|
133
|
+
|
|
134
|
+
client = Client(api_key="bys_ak_YOUR_KEY")
|
|
135
|
+
try:
|
|
136
|
+
client.place_call(to="+19001234567", objective="Sell something")
|
|
137
|
+
except ByoursideError as e:
|
|
138
|
+
print(e.code) # e.g. "destination_blocked"
|
|
139
|
+
print(e.status) # HTTP status, or 0 for network/timeout errors
|
|
140
|
+
print(str(e)) # Human-readable message
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Common error codes:
|
|
144
|
+
|
|
145
|
+
| Code | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `destination_blocked` | Destination not allowed (premium, IRSF, or unsupported country) |
|
|
148
|
+
| `invalid_number` | Number must be E.164, e.g. +14155550123 |
|
|
149
|
+
| `to_required` | Missing destination number |
|
|
150
|
+
| `objective_required` | Missing call objective |
|
|
151
|
+
| `caller_id_not_owned` | Caller ID not on your account |
|
|
152
|
+
| `rate_limited` | Rate limit reached, retry shortly |
|
|
153
|
+
| `over_minute_cap` | Outbound usage limit reached |
|
|
154
|
+
| `unauthorized` | Invalid or missing API key |
|
|
155
|
+
| `not_found` | Call ID not found |
|
|
156
|
+
| `placement_failed` | Carrier or trunk issue, retry shortly |
|
|
157
|
+
| `store_error` | Temporary service error, retry shortly |
|
|
158
|
+
| `network_error` | Could not reach the API |
|
|
159
|
+
| `timeout` | `wait_for_call` deadline exceeded |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
Clone the repo. No install step needed for local use: run tests from the `sdks/python/` directory so that `byourside/` is on the path automatically.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
cd sdks/python
|
|
169
|
+
python3 -m unittest discover -s tests
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Expected output: all tests pass (11 tests across errors, client, wait, and webhooks).
|
|
173
|
+
|
|
174
|
+
If your environment does not add CWD to the path automatically, use:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
cd sdks/python
|
|
178
|
+
PYTHONPATH=. python3 -m unittest discover -s tests
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Syntax check all source files:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
python3 -m py_compile byourside/errors.py byourside/client.py byourside/webhooks.py byourside/__init__.py
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Publishing to PyPI is deferred. Do not publish without explicit owner approval.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
byourside/__init__.py
|
|
5
|
+
byourside/client.py
|
|
6
|
+
byourside/errors.py
|
|
7
|
+
byourside/webhooks.py
|
|
8
|
+
byourside.egg-info/PKG-INFO
|
|
9
|
+
byourside.egg-info/SOURCES.txt
|
|
10
|
+
byourside.egg-info/dependency_links.txt
|
|
11
|
+
byourside.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
13
|
+
tests/test_errors.py
|
|
14
|
+
tests/test_wait.py
|
|
15
|
+
tests/test_webhooks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
byourside
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "byourside"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "By Your Side SDK: give an AI a phone (outbound objective-driven calls)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.8"
|
|
7
|
+
dependencies = []
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
authors = [{ name = "By Your Side" }]
|
|
10
|
+
keywords = ["voice", "ai", "phone", "outbound", "agent", "sdk", "voip", "calls"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.8",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Communications :: Telephony",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://byourside.ai/docs/agent-api"
|
|
27
|
+
Repository = "https://github.com/allexp1/voip-agent"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=61"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["byourside*"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import json, unittest
|
|
2
|
+
from byourside.client import Client
|
|
3
|
+
from byourside.errors import ByoursideError
|
|
4
|
+
|
|
5
|
+
def make_client(captured, resp=None, raise_status=None):
|
|
6
|
+
def transport(method, url, headers, body):
|
|
7
|
+
captured["method"] = method; captured["url"] = url
|
|
8
|
+
captured["headers"] = headers; captured["body"] = body
|
|
9
|
+
if raise_status is not None:
|
|
10
|
+
return raise_status # (status, obj)
|
|
11
|
+
return resp
|
|
12
|
+
return Client(api_key="bys_ak_x", base_url="https://b", transport=transport)
|
|
13
|
+
|
|
14
|
+
class TestClient(unittest.TestCase):
|
|
15
|
+
def test_place_call(self):
|
|
16
|
+
cap = {}
|
|
17
|
+
c = make_client(cap, resp=(200, {"callId": "c1", "status": "queued"}))
|
|
18
|
+
r = c.place_call(to="+14155550123", objective="Book a table", fields=[{"name": "booked"}])
|
|
19
|
+
self.assertEqual(cap["method"], "POST")
|
|
20
|
+
self.assertEqual(cap["url"], "https://b/v1/agent/calls")
|
|
21
|
+
self.assertEqual(cap["headers"]["Authorization"], "Bearer bys_ak_x")
|
|
22
|
+
self.assertEqual(json.loads(cap["body"])["to"], "+14155550123")
|
|
23
|
+
self.assertEqual(r["callId"], "c1")
|
|
24
|
+
|
|
25
|
+
def test_get_call_encoded(self):
|
|
26
|
+
cap = {}
|
|
27
|
+
c = make_client(cap, resp=(200, {"id": "c1", "status": "completed"}))
|
|
28
|
+
c.get_call("c 1")
|
|
29
|
+
self.assertEqual(cap["method"], "GET")
|
|
30
|
+
self.assertEqual(cap["url"], "https://b/v1/agent/calls/c%201")
|
|
31
|
+
self.assertIsNone(cap["body"])
|
|
32
|
+
|
|
33
|
+
def test_list_calls_unwrap_clamp(self):
|
|
34
|
+
cap = {}
|
|
35
|
+
c = make_client(cap, resp=(200, {"calls": [{"id": "c1"}]}))
|
|
36
|
+
r = c.list_calls(limit=9999)
|
|
37
|
+
self.assertTrue(cap["url"].endswith("/v1/agent/calls?limit=100"))
|
|
38
|
+
self.assertEqual(r, [{"id": "c1"}])
|
|
39
|
+
|
|
40
|
+
def test_error_raises(self):
|
|
41
|
+
c = make_client({}, raise_status=(400, {"error": "destination_blocked"}))
|
|
42
|
+
with self.assertRaises(ByoursideError) as ctx:
|
|
43
|
+
c.place_call(to="+1900", objective="x")
|
|
44
|
+
self.assertEqual(ctx.exception.status, 400)
|
|
45
|
+
self.assertEqual(ctx.exception.code, "destination_blocked")
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
unittest.main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from byourside.errors import map_error, ByoursideError
|
|
3
|
+
|
|
4
|
+
class TestErrors(unittest.TestCase):
|
|
5
|
+
def test_known_tokens(self):
|
|
6
|
+
self.assertIn("not allowed", map_error(400, {"error": "destination_blocked"}).lower())
|
|
7
|
+
self.assertIn("api key", map_error(401, {"error": "unauthorized"}).lower())
|
|
8
|
+
def test_fallbacks(self):
|
|
9
|
+
self.assertIn("temporary", map_error(503, {"error": "store_error"}).lower())
|
|
10
|
+
self.assertIn("temporary", map_error(0, None).lower())
|
|
11
|
+
self.assertIn("weird", map_error(400, {"error": "weird"}))
|
|
12
|
+
def test_error_fields(self):
|
|
13
|
+
e = ByoursideError("msg", 400, "destination_blocked")
|
|
14
|
+
self.assertEqual(e.status, 400); self.assertEqual(e.code, "destination_blocked"); self.assertEqual(e.message, "msg")
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
unittest.main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from byourside.client import Client
|
|
3
|
+
from byourside.errors import ByoursideError
|
|
4
|
+
|
|
5
|
+
def seq_client(statuses):
|
|
6
|
+
state = {"i": 0, "sleeps": 0}
|
|
7
|
+
def transport(method, url, headers, body):
|
|
8
|
+
s = statuses[min(state["i"], len(statuses) - 1)]; state["i"] += 1
|
|
9
|
+
return (200, {"id": "c1", "status": s})
|
|
10
|
+
c = Client(api_key="k", base_url="https://b", transport=transport, sleep=lambda *_: state.__setitem__("sleeps", state["sleeps"] + 1))
|
|
11
|
+
return c, state
|
|
12
|
+
|
|
13
|
+
class TestWait(unittest.TestCase):
|
|
14
|
+
def test_returns_when_terminal(self):
|
|
15
|
+
c, st = seq_client(["queued", "in_progress", "completed"])
|
|
16
|
+
r = c.wait_for_call("c1", timeout=100, interval=0)
|
|
17
|
+
self.assertEqual(r["status"], "completed")
|
|
18
|
+
self.assertEqual(st["sleeps"], 2)
|
|
19
|
+
def test_timeout(self):
|
|
20
|
+
c, _ = seq_client(["queued"])
|
|
21
|
+
with self.assertRaises(ByoursideError) as ctx:
|
|
22
|
+
c.wait_for_call("c1", timeout=0, interval=0)
|
|
23
|
+
self.assertEqual(ctx.exception.code, "timeout")
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
unittest.main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import hmac, hashlib, time, unittest
|
|
2
|
+
from byourside.webhooks import verify_webhook
|
|
3
|
+
|
|
4
|
+
SECRET = "whsec"
|
|
5
|
+
def sign(ts, body):
|
|
6
|
+
v1 = hmac.new(SECRET.encode(), ("%d.%s" % (ts, body)).encode(), hashlib.sha256).hexdigest()
|
|
7
|
+
return "t=%d,v1=%s" % (ts, v1)
|
|
8
|
+
|
|
9
|
+
class TestWebhooks(unittest.TestCase):
|
|
10
|
+
def test_accepts_valid(self):
|
|
11
|
+
body = '{"callId":"c1"}'; ts = int(time.time())
|
|
12
|
+
self.assertTrue(verify_webhook(sign(ts, body), body, SECRET))
|
|
13
|
+
def test_rejects(self):
|
|
14
|
+
body = '{"callId":"c1"}'; ts = int(time.time())
|
|
15
|
+
self.assertFalse(verify_webhook(sign(ts, body), '{"callId":"c2"}', SECRET))
|
|
16
|
+
self.assertFalse(verify_webhook(sign(ts, body), body, "other"))
|
|
17
|
+
self.assertFalse(verify_webhook(sign(ts - 9999, body), body, SECRET, tolerance=300))
|
|
18
|
+
self.assertFalse(verify_webhook("garbage", body, SECRET))
|
|
19
|
+
self.assertFalse(verify_webhook("", body, SECRET))
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
unittest.main()
|