sendly 4.5.1__tar.gz → 4.6.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.
- {sendly-4.5.1 → sendly-4.6.0}/PKG-INFO +106 -10
- {sendly-4.5.1 → sendly-4.6.0}/README.md +105 -9
- {sendly-4.5.1 → sendly-4.6.0}/pyproject.toml +1 -1
- {sendly-4.5.1 → sendly-4.6.0}/sendly/__init__.py +37 -1
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/calls.py +104 -6
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/voice.py +244 -16
- {sendly-4.5.1 → sendly-4.6.0}/sendly/types.py +467 -4
- {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/http.py +1 -1
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_calls.py +305 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_voice.py +633 -1
- {sendly-4.5.1 → sendly-4.6.0}/.github/header.svg +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/.gitignore +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/CHANGELOG.md +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/examples/async_example.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/examples/error_handling.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/examples/send_sms.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/client.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/errors.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/py.typed +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/__init__.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/account.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/campaigns.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/contacts.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/conversations.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/drafts.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/enterprise.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/labels.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/links.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/media.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/messages.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/numbers.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/rcs.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/rules.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/templates.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/tendlc.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/verify.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/webhooks.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/resources/whatsapp.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/__init__.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/utils/validation.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/sendly/webhooks.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/conftest.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_account.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_async_client.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_campaigns.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_client.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_contacts.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_conversations.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_enterprise.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_errors.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_http_body_kwarg.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_http_retry.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_idempotency.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_media.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages.py.bak +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_batch.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_schedule.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_messages_wire.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_numbers.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_path_ids.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_rcs.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_rcs_registration.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_rules.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_templates.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_tendlc.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_validation.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_webhooks.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_webhooks_resource.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_whatsapp.py +0 -0
- {sendly-4.5.1 → sendly-4.6.0}/tests/test_whatsapp_extras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendly
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.6.0
|
|
4
4
|
Summary: Official Sendly Python SDK for SMS messaging
|
|
5
5
|
Project-URL: Homepage, https://sendly.live
|
|
6
6
|
Project-URL: Documentation, https://sendly.live/docs
|
|
@@ -552,8 +552,8 @@ contents.
|
|
|
552
552
|
|
|
553
553
|
Write methods that accept `idempotency_key` include `messages.send`,
|
|
554
554
|
`messages.schedule`, `messages.send_batch`, `messages.send_group`,
|
|
555
|
-
`calls.create`, `calls.hangup`, every `voice.*` write
|
|
556
|
-
`rcs.brands` / `rcs.agents` write.
|
|
555
|
+
`calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
|
|
556
|
+
`voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
|
|
557
557
|
|
|
558
558
|
Full details: https://sendly.live/docs/idempotency
|
|
559
559
|
|
|
@@ -1874,9 +1874,9 @@ if event.type.startswith('short_code.'):
|
|
|
1874
1874
|
## Voice Calls
|
|
1875
1875
|
|
|
1876
1876
|
Place phone calls that one of your AI agents handles, follow them while they
|
|
1877
|
-
ring and after they end, end
|
|
1878
|
-
API key with the `calls:read` / `calls:write` scopes;
|
|
1879
|
-
needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
1877
|
+
ring and after they end, transfer callers, end calls early, and download
|
|
1878
|
+
recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
|
|
1879
|
+
placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
1880
1880
|
for yours, every call method raises `SendlyError` with code `voice_not_enabled`
|
|
1881
1881
|
(HTTP 404).
|
|
1882
1882
|
|
|
@@ -1915,6 +1915,15 @@ for c in page.data:
|
|
|
1915
1915
|
if page.pagination.has_more:
|
|
1916
1916
|
page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
|
|
1917
1917
|
|
|
1918
|
+
# Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
|
|
1919
|
+
page = client.calls.list(agent_kind='websocket')
|
|
1920
|
+
|
|
1921
|
+
# Transfer the caller on an incoming phone call; without `to`, the agent's
|
|
1922
|
+
# transfer number (tools.transfer_to) is used
|
|
1923
|
+
incoming = client.calls.list(direction='inbound', status='active').data[0]
|
|
1924
|
+
incoming = client.calls.transfer(incoming.id, to='+15551234500')
|
|
1925
|
+
print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
|
|
1926
|
+
|
|
1918
1927
|
# End a call early (idempotent: an ended call is returned unchanged)
|
|
1919
1928
|
client.calls.hangup(call.id)
|
|
1920
1929
|
|
|
@@ -1938,10 +1947,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
|
|
|
1938
1947
|
`agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
|
|
1939
1948
|
`from_number_not_supported` (calls are placed from US and Canadian numbers only),
|
|
1940
1949
|
`number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
|
|
1941
|
-
`live_key_required
|
|
1950
|
+
`live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
|
|
1951
|
+
agents answer incoming calls only). Webhooks `call.started`, `call.completed`
|
|
1942
1952
|
and `call.recording.ready` carry the same call object in snake_case, including
|
|
1943
1953
|
`billing` and your `metadata`.
|
|
1944
1954
|
|
|
1955
|
+
Each call carries `agent_kind` (the kind of agent that answered, or None),
|
|
1956
|
+
`agent_fallback` (True when your own agent could not be reached, so Sendly
|
|
1957
|
+
said a short line and transferred the caller or took a message) and
|
|
1958
|
+
`transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
|
|
1959
|
+
at once with `transfer.status` `pending`; once the caller is handed over the
|
|
1960
|
+
call ends `completed` with `hangup_class` `transferred`, and a failed transfer
|
|
1961
|
+
leaves the caller on the call. Transfer refusals: `transfer_to_required`,
|
|
1962
|
+
`outbound_not_enabled` (a number other than the agent's needs calling out
|
|
1963
|
+
switched on), `insufficient_credits`, `call_not_active`,
|
|
1964
|
+
`call_not_transferable` (not an incoming phone call) and
|
|
1965
|
+
`transfer_in_progress`.
|
|
1966
|
+
|
|
1945
1967
|
### Configure voice
|
|
1946
1968
|
|
|
1947
1969
|
Everything a call depends on can be set up from code: switch voice on for a
|
|
@@ -2025,9 +2047,83 @@ says the number couldn't be found for emergency registration: contact
|
|
|
2025
2047
|
support), `voice_unavailable` (HTTP 503),
|
|
2026
2048
|
`e911_not_applicable` (only US and Canadian numbers take an emergency
|
|
2027
2049
|
address) and `invalid_address`; on HTTP 422 the closest valid address is in
|
|
2028
|
-
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
|
|
2029
|
-
|
|
2030
|
-
|
|
2050
|
+
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
|
|
2051
|
+
number it transfers a caller to when asked for a person.
|
|
2052
|
+
|
|
2053
|
+
### Bring your own agent
|
|
2054
|
+
|
|
2055
|
+
An agent's `kind` says who writes the replies: `sendly` (the default, from the
|
|
2056
|
+
agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
|
|
2057
|
+
completions endpoint writes each reply), `sip` (Sendly connects the caller to
|
|
2058
|
+
your SIP address) or `websocket` (Sendly streams the call audio to your
|
|
2059
|
+
`wss://` server). Your own agents need the workspace's bring-your-own-agent
|
|
2060
|
+
feature, which support switches on (`byo_agents_not_enabled` otherwise).
|
|
2061
|
+
|
|
2062
|
+
```python
|
|
2063
|
+
from sendly import VoiceAgentWebsocketConnectionInput
|
|
2064
|
+
|
|
2065
|
+
agent = client.voice.agents.create(
|
|
2066
|
+
'Support line',
|
|
2067
|
+
kind='websocket',
|
|
2068
|
+
connection=VoiceAgentWebsocketConnectionInput(
|
|
2069
|
+
url='wss://agent.example.com/calls', auth_token='tok_123'
|
|
2070
|
+
),
|
|
2071
|
+
)
|
|
2072
|
+
secret = agent.signing_secret # save it now; it is never shown again
|
|
2073
|
+
print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
|
|
2074
|
+
|
|
2075
|
+
# Same kind: the connection is merged, so only what you pass changes
|
|
2076
|
+
client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
|
|
2077
|
+
|
|
2078
|
+
# A new signing secret; the old one stops working at once
|
|
2079
|
+
secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
|
|
2080
|
+
```
|
|
2081
|
+
|
|
2082
|
+
`connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
|
|
2083
|
+
`api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
|
|
2084
|
+
`media_encryption`, `username`, `password`, `headers`),
|
|
2085
|
+
`VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
|
|
2086
|
+
`custom_parameters`) or a dict. A secret you leave out keeps its saved value
|
|
2087
|
+
and None removes it. Secrets are never returned: `agent.connection` reports
|
|
2088
|
+
`has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
|
|
2089
|
+
request to your agent with the signing secret
|
|
2090
|
+
(`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
|
|
2091
|
+
only on the create or update response that made it. A `connection` that breaks
|
|
2092
|
+
a rule raises `ValidationError` with a message like
|
|
2093
|
+
`connection.url: <reason>`.
|
|
2094
|
+
|
|
2095
|
+
#### Test an agent's connection
|
|
2096
|
+
|
|
2097
|
+
`voice.agents.test` reaches your own agent the way a call would, without
|
|
2098
|
+
placing one: `custom_llm` gets a signed, streamed chat completion with a test
|
|
2099
|
+
message, `websocket` gets a signed connection with a `start` carrying
|
|
2100
|
+
`test: true`, half a second of silence and a `stop`, and `sip` gets an
|
|
2101
|
+
`OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
|
|
2102
|
+
before saving them; nothing is saved.
|
|
2103
|
+
|
|
2104
|
+
```python
|
|
2105
|
+
result = client.voice.agents.test(agent.id)
|
|
2106
|
+
if not result.ok:
|
|
2107
|
+
print(result.outcome, result.message) # e.g. 'auth_failed'
|
|
2108
|
+
for step in result.steps:
|
|
2109
|
+
print(step.step, step.ok, step.detail)
|
|
2110
|
+
|
|
2111
|
+
# Try a new SIP address before saving it
|
|
2112
|
+
client.voice.agents.test(
|
|
2113
|
+
agent.id,
|
|
2114
|
+
kind='sip',
|
|
2115
|
+
connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
|
|
2116
|
+
)
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
A failed test is still a successful request, so check `ok`. `outcome` is
|
|
2120
|
+
`ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
|
|
2121
|
+
`auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
|
|
2122
|
+
`VoiceAgentTestOutcome`; a value added later comes through as a plain string),
|
|
2123
|
+
and each step is `address`, `connect`, `login` or `reply`. The result also
|
|
2124
|
+
carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
|
|
2125
|
+
It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
|
|
2126
|
+
and a workspace can run 10 tests a minute (`RateLimitError` after that).
|
|
2031
2127
|
|
|
2032
2128
|
## Error Handling
|
|
2033
2129
|
|
|
@@ -517,8 +517,8 @@ contents.
|
|
|
517
517
|
|
|
518
518
|
Write methods that accept `idempotency_key` include `messages.send`,
|
|
519
519
|
`messages.schedule`, `messages.send_batch`, `messages.send_group`,
|
|
520
|
-
`calls.create`, `calls.hangup`, every `voice.*` write
|
|
521
|
-
`rcs.brands` / `rcs.agents` write.
|
|
520
|
+
`calls.create`, `calls.hangup`, `calls.transfer`, every `voice.*` write but
|
|
521
|
+
`voice.agents.test`, and every `rcs.brands` / `rcs.agents` write.
|
|
522
522
|
|
|
523
523
|
Full details: https://sendly.live/docs/idempotency
|
|
524
524
|
|
|
@@ -1839,9 +1839,9 @@ if event.type.startswith('short_code.'):
|
|
|
1839
1839
|
## Voice Calls
|
|
1840
1840
|
|
|
1841
1841
|
Place phone calls that one of your AI agents handles, follow them while they
|
|
1842
|
-
ring and after they end, end
|
|
1843
|
-
API key with the `calls:read` / `calls:write` scopes;
|
|
1844
|
-
needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
1842
|
+
ring and after they end, transfer callers, end calls early, and download
|
|
1843
|
+
recordings. Requires an API key with the `calls:read` / `calls:write` scopes;
|
|
1844
|
+
placing, transferring and ending calls needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
1845
1845
|
for yours, every call method raises `SendlyError` with code `voice_not_enabled`
|
|
1846
1846
|
(HTTP 404).
|
|
1847
1847
|
|
|
@@ -1880,6 +1880,15 @@ for c in page.data:
|
|
|
1880
1880
|
if page.pagination.has_more:
|
|
1881
1881
|
page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
|
|
1882
1882
|
|
|
1883
|
+
# Only calls answered by one kind of agent (sendly, custom_llm, sip, websocket)
|
|
1884
|
+
page = client.calls.list(agent_kind='websocket')
|
|
1885
|
+
|
|
1886
|
+
# Transfer the caller on an incoming phone call; without `to`, the agent's
|
|
1887
|
+
# transfer number (tools.transfer_to) is used
|
|
1888
|
+
incoming = client.calls.list(direction='inbound', status='active').data[0]
|
|
1889
|
+
incoming = client.calls.transfer(incoming.id, to='+15551234500')
|
|
1890
|
+
print(incoming.transfer.status) # 'pending', then 'completed' or 'failed'
|
|
1891
|
+
|
|
1883
1892
|
# End a call early (idempotent: an ended call is returned unchanged)
|
|
1884
1893
|
client.calls.hangup(call.id)
|
|
1885
1894
|
|
|
@@ -1903,10 +1912,23 @@ Refusals come back as `SendlyError` with a code that says what to do:
|
|
|
1903
1912
|
`agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
|
|
1904
1913
|
`from_number_not_supported` (calls are placed from US and Canadian numbers only),
|
|
1905
1914
|
`number_not_found`, `destination_not_supported`, `outbound_calls_not_enabled`,
|
|
1906
|
-
`live_key_required
|
|
1915
|
+
`live_key_required`, and `agent_kind_unsupported` (`sip` and `websocket`
|
|
1916
|
+
agents answer incoming calls only). Webhooks `call.started`, `call.completed`
|
|
1907
1917
|
and `call.recording.ready` carry the same call object in snake_case, including
|
|
1908
1918
|
`billing` and your `metadata`.
|
|
1909
1919
|
|
|
1920
|
+
Each call carries `agent_kind` (the kind of agent that answered, or None),
|
|
1921
|
+
`agent_fallback` (True when your own agent could not be reached, so Sendly
|
|
1922
|
+
said a short line and transferred the caller or took a message) and
|
|
1923
|
+
`transfer` (None, or `to`, `status` and `failure_reason`). A transfer answers
|
|
1924
|
+
at once with `transfer.status` `pending`; once the caller is handed over the
|
|
1925
|
+
call ends `completed` with `hangup_class` `transferred`, and a failed transfer
|
|
1926
|
+
leaves the caller on the call. Transfer refusals: `transfer_to_required`,
|
|
1927
|
+
`outbound_not_enabled` (a number other than the agent's needs calling out
|
|
1928
|
+
switched on), `insufficient_credits`, `call_not_active`,
|
|
1929
|
+
`call_not_transferable` (not an incoming phone call) and
|
|
1930
|
+
`transfer_in_progress`.
|
|
1931
|
+
|
|
1910
1932
|
### Configure voice
|
|
1911
1933
|
|
|
1912
1934
|
Everything a call depends on can be set up from code: switch voice on for a
|
|
@@ -1990,9 +2012,83 @@ says the number couldn't be found for emergency registration: contact
|
|
|
1990
2012
|
support), `voice_unavailable` (HTTP 503),
|
|
1991
2013
|
`e911_not_applicable` (only US and Canadian numbers take an emergency
|
|
1992
2014
|
address) and `invalid_address`; on HTTP 422 the closest valid address is in
|
|
1993
|
-
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
|
|
1994
|
-
|
|
1995
|
-
|
|
2015
|
+
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is the
|
|
2016
|
+
number it transfers a caller to when asked for a person.
|
|
2017
|
+
|
|
2018
|
+
### Bring your own agent
|
|
2019
|
+
|
|
2020
|
+
An agent's `kind` says who writes the replies: `sendly` (the default, from the
|
|
2021
|
+
agent's `instructions`), `custom_llm` (Sendly listens and speaks, and your chat
|
|
2022
|
+
completions endpoint writes each reply), `sip` (Sendly connects the caller to
|
|
2023
|
+
your SIP address) or `websocket` (Sendly streams the call audio to your
|
|
2024
|
+
`wss://` server). Your own agents need the workspace's bring-your-own-agent
|
|
2025
|
+
feature, which support switches on (`byo_agents_not_enabled` otherwise).
|
|
2026
|
+
|
|
2027
|
+
```python
|
|
2028
|
+
from sendly import VoiceAgentWebsocketConnectionInput
|
|
2029
|
+
|
|
2030
|
+
agent = client.voice.agents.create(
|
|
2031
|
+
'Support line',
|
|
2032
|
+
kind='websocket',
|
|
2033
|
+
connection=VoiceAgentWebsocketConnectionInput(
|
|
2034
|
+
url='wss://agent.example.com/calls', auth_token='tok_123'
|
|
2035
|
+
),
|
|
2036
|
+
)
|
|
2037
|
+
secret = agent.signing_secret # save it now; it is never shown again
|
|
2038
|
+
print(agent.connection.url, agent.connection.has_auth_token, agent.has_signing_secret)
|
|
2039
|
+
|
|
2040
|
+
# Same kind: the connection is merged, so only what you pass changes
|
|
2041
|
+
client.voice.agents.update(agent.id, connection={'custom_parameters': {'tenant': 'acme'}})
|
|
2042
|
+
|
|
2043
|
+
# A new signing secret; the old one stops working at once
|
|
2044
|
+
secret = client.voice.agents.update(agent.id, rotate_signing_secret=True).signing_secret
|
|
2045
|
+
```
|
|
2046
|
+
|
|
2047
|
+
`connection` is a `VoiceAgentCustomLlmConnectionInput` (`base_url`, `model`,
|
|
2048
|
+
`api_key`, `headers`), `VoiceAgentSipConnectionInput` (`uri`, `transport`,
|
|
2049
|
+
`media_encryption`, `username`, `password`, `headers`),
|
|
2050
|
+
`VoiceAgentWebsocketConnectionInput` (`url`, `audio_format`, `auth_token`,
|
|
2051
|
+
`custom_parameters`) or a dict. A secret you leave out keeps its saved value
|
|
2052
|
+
and None removes it. Secrets are never returned: `agent.connection` reports
|
|
2053
|
+
`has_api_key`, `has_password` or `has_auth_token` instead. Sendly signs every
|
|
2054
|
+
request to your agent with the signing secret
|
|
2055
|
+
(`X-Sendly-Signature: t=<unix>,v1=<hex HMAC-SHA256>`); `signing_secret` is set
|
|
2056
|
+
only on the create or update response that made it. A `connection` that breaks
|
|
2057
|
+
a rule raises `ValidationError` with a message like
|
|
2058
|
+
`connection.url: <reason>`.
|
|
2059
|
+
|
|
2060
|
+
#### Test an agent's connection
|
|
2061
|
+
|
|
2062
|
+
`voice.agents.test` reaches your own agent the way a call would, without
|
|
2063
|
+
placing one: `custom_llm` gets a signed, streamed chat completion with a test
|
|
2064
|
+
message, `websocket` gets a signed connection with a `start` carrying
|
|
2065
|
+
`test: true`, half a second of silence and a `stop`, and `sip` gets an
|
|
2066
|
+
`OPTIONS` request. Pass `kind`, `connection` or `instructions` to test settings
|
|
2067
|
+
before saving them; nothing is saved.
|
|
2068
|
+
|
|
2069
|
+
```python
|
|
2070
|
+
result = client.voice.agents.test(agent.id)
|
|
2071
|
+
if not result.ok:
|
|
2072
|
+
print(result.outcome, result.message) # e.g. 'auth_failed'
|
|
2073
|
+
for step in result.steps:
|
|
2074
|
+
print(step.step, step.ok, step.detail)
|
|
2075
|
+
|
|
2076
|
+
# Try a new SIP address before saving it
|
|
2077
|
+
client.voice.agents.test(
|
|
2078
|
+
agent.id,
|
|
2079
|
+
kind='sip',
|
|
2080
|
+
connection={'uri': 'sip:+15125550100@sip.example.com', 'transport': 'tls'},
|
|
2081
|
+
)
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
A failed test is still a successful request, so check `ok`. `outcome` is
|
|
2085
|
+
`ok`, `blocked_destination`, `unreachable`, `timeout`, `tls_failed`,
|
|
2086
|
+
`auth_required`, `auth_failed`, `not_found`, `rejected` or `bad_reply` (see
|
|
2087
|
+
`VoiceAgentTestOutcome`; a value added later comes through as a plain string),
|
|
2088
|
+
and each step is `address`, `connect`, `login` or `reply`. The result also
|
|
2089
|
+
carries `status` (the HTTP or SIP status, or None), `latency_ms` and `signed`.
|
|
2090
|
+
It needs the `calls:write` scope and a live key, takes no `idempotency_key`,
|
|
2091
|
+
and a workspace can run 10 tests a minute (`RateLimitError` after that).
|
|
1996
2092
|
|
|
1997
2093
|
## Error Handling
|
|
1998
2094
|
|
|
@@ -29,7 +29,7 @@ Async Example:
|
|
|
29
29
|
>>> asyncio.run(main())
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "4.
|
|
32
|
+
__version__ = "4.6.0"
|
|
33
33
|
|
|
34
34
|
# Main clients
|
|
35
35
|
from .client import AsyncSendly, Sendly
|
|
@@ -216,24 +216,42 @@ from .types import (
|
|
|
216
216
|
CallRecordingStatus,
|
|
217
217
|
CallStatus,
|
|
218
218
|
CallTranscriptLine,
|
|
219
|
+
CallTransfer,
|
|
220
|
+
CallTransferFailureReason,
|
|
221
|
+
CallTransferStatus,
|
|
219
222
|
CreateCallRequest,
|
|
220
223
|
ListCallsOptions,
|
|
224
|
+
TransferCallRequest,
|
|
221
225
|
# Voice configuration types
|
|
222
226
|
CreateVoiceAgentRequest,
|
|
223
227
|
DeletedVoiceAgent,
|
|
224
228
|
EmergencyAddress,
|
|
229
|
+
SipMediaEncryption,
|
|
230
|
+
SipTransport,
|
|
231
|
+
TestVoiceAgentRequest,
|
|
225
232
|
UpdateVoiceAgentRequest,
|
|
226
233
|
UpdateVoiceNumberRequest,
|
|
227
234
|
Voice,
|
|
228
235
|
VoiceAgent,
|
|
236
|
+
VoiceAgentConnection,
|
|
237
|
+
VoiceAgentConnectionInput,
|
|
238
|
+
VoiceAgentCustomLlmConnectionInput,
|
|
239
|
+
VoiceAgentKind,
|
|
229
240
|
VoiceAgentListResponse,
|
|
241
|
+
VoiceAgentSipConnectionInput,
|
|
242
|
+
VoiceAgentTest,
|
|
243
|
+
VoiceAgentTestOutcome,
|
|
244
|
+
VoiceAgentTestStep,
|
|
245
|
+
VoiceAgentTestStepName,
|
|
230
246
|
VoiceAgentTools,
|
|
247
|
+
VoiceAgentWebsocketConnectionInput,
|
|
231
248
|
VoiceListResponse,
|
|
232
249
|
VoiceMode,
|
|
233
250
|
VoiceNumber,
|
|
234
251
|
VoiceNumberEmergencyAddress,
|
|
235
252
|
VoiceNumberListResponse,
|
|
236
253
|
VoiceNumberRates,
|
|
254
|
+
WebsocketAudioFormat,
|
|
237
255
|
OptInPage,
|
|
238
256
|
PricingTier,
|
|
239
257
|
QuotaSettings,
|
|
@@ -466,24 +484,42 @@ __all__ = [
|
|
|
466
484
|
"CallRecordingStatus",
|
|
467
485
|
"CallStatus",
|
|
468
486
|
"CallTranscriptLine",
|
|
487
|
+
"CallTransfer",
|
|
488
|
+
"CallTransferFailureReason",
|
|
489
|
+
"CallTransferStatus",
|
|
469
490
|
"CreateCallRequest",
|
|
470
491
|
"ListCallsOptions",
|
|
492
|
+
"TransferCallRequest",
|
|
471
493
|
# Voice configuration types
|
|
472
494
|
"CreateVoiceAgentRequest",
|
|
473
495
|
"DeletedVoiceAgent",
|
|
474
496
|
"EmergencyAddress",
|
|
497
|
+
"SipMediaEncryption",
|
|
498
|
+
"SipTransport",
|
|
499
|
+
"TestVoiceAgentRequest",
|
|
475
500
|
"UpdateVoiceAgentRequest",
|
|
476
501
|
"UpdateVoiceNumberRequest",
|
|
477
502
|
"Voice",
|
|
478
503
|
"VoiceAgent",
|
|
504
|
+
"VoiceAgentConnection",
|
|
505
|
+
"VoiceAgentConnectionInput",
|
|
506
|
+
"VoiceAgentCustomLlmConnectionInput",
|
|
507
|
+
"VoiceAgentKind",
|
|
479
508
|
"VoiceAgentListResponse",
|
|
509
|
+
"VoiceAgentSipConnectionInput",
|
|
510
|
+
"VoiceAgentTest",
|
|
511
|
+
"VoiceAgentTestOutcome",
|
|
512
|
+
"VoiceAgentTestStep",
|
|
513
|
+
"VoiceAgentTestStepName",
|
|
480
514
|
"VoiceAgentTools",
|
|
515
|
+
"VoiceAgentWebsocketConnectionInput",
|
|
481
516
|
"VoiceListResponse",
|
|
482
517
|
"VoiceMode",
|
|
483
518
|
"VoiceNumber",
|
|
484
519
|
"VoiceNumberEmergencyAddress",
|
|
485
520
|
"VoiceNumberListResponse",
|
|
486
521
|
"VoiceNumberRates",
|
|
522
|
+
"WebsocketAudioFormat",
|
|
487
523
|
# Links resources
|
|
488
524
|
"LinksResource",
|
|
489
525
|
"AsyncLinksResource",
|
|
@@ -3,13 +3,15 @@ Calls Resource - Phone calls handled by your AI agents
|
|
|
3
3
|
|
|
4
4
|
A workspace phone number can take and place phone calls. Over the API you
|
|
5
5
|
place an outbound call that one of your AI agents handles, list and inspect
|
|
6
|
-
calls,
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
calls, transfer a caller to another number, end a call, and fetch
|
|
7
|
+
recordings. Switching voice on for a number, choosing how it answers,
|
|
8
|
+
registering an emergency address and creating agents live in
|
|
9
|
+
``client.voice``; ``client.voice.numbers.list()`` reports each
|
|
9
10
|
number's ``voice_enabled`` and ``voice_mode`` so you can pick a ``from_``.
|
|
10
11
|
|
|
11
12
|
Reads need an API key with the ``calls:read`` scope, writes ``calls:write``.
|
|
12
|
-
Placing and ending calls needs a live key
|
|
13
|
+
Placing, transferring and ending calls needs a live key
|
|
14
|
+
(``live_key_required`` otherwise).
|
|
13
15
|
Voice is enabled workspace by workspace; until it is enabled for yours every
|
|
14
16
|
method raises ``SendlyError`` with code ``voice_not_enabled`` (HTTP 404).
|
|
15
17
|
|
|
@@ -103,6 +105,9 @@ class CallsResource:
|
|
|
103
105
|
(register an emergency address for the number first with
|
|
104
106
|
``client.voice.numbers.register_emergency_address``),
|
|
105
107
|
``lines_busy`` (retry shortly), ``daily_call_limit``,
|
|
108
|
+
``agent_kind_unsupported`` (HTTP 409: the agent's ``kind`` is
|
|
109
|
+
``sip`` or ``websocket``, which answer incoming calls only;
|
|
110
|
+
``custom_llm`` agents can place calls),
|
|
106
111
|
``outbound_calls_not_enabled`` or ``voice_not_enabled``.
|
|
107
112
|
|
|
108
113
|
Example:
|
|
@@ -129,6 +134,7 @@ class CallsResource:
|
|
|
129
134
|
direction: Optional[EnumLike] = None,
|
|
130
135
|
kind: Optional[EnumLike] = None,
|
|
131
136
|
agent_id: Optional[str] = None,
|
|
137
|
+
agent_kind: Optional[EnumLike] = None,
|
|
132
138
|
to: Optional[str] = None,
|
|
133
139
|
from_: Optional[str] = None,
|
|
134
140
|
) -> CallListResponse:
|
|
@@ -145,6 +151,10 @@ class CallsResource:
|
|
|
145
151
|
direction: ``inbound`` or ``outbound``.
|
|
146
152
|
kind: ``pstn`` or ``internal``.
|
|
147
153
|
agent_id: Only calls handled by this agent.
|
|
154
|
+
agent_kind: Only calls answered by this kind of agent:
|
|
155
|
+
``sendly``, ``custom_llm``, ``sip`` or ``websocket`` (see
|
|
156
|
+
:class:`~sendly.types.VoiceAgentKind`). Calls from before
|
|
157
|
+
agent kinds existed count as ``sendly``.
|
|
148
158
|
to: Exact E.164 match on the called number.
|
|
149
159
|
from_: Exact E.164 match on the calling number.
|
|
150
160
|
|
|
@@ -155,7 +165,9 @@ class CallsResource:
|
|
|
155
165
|
>>> if page.pagination.has_more:
|
|
156
166
|
... page = client.calls.list(status='completed', limit=20, offset=20)
|
|
157
167
|
"""
|
|
158
|
-
params = _list_params(
|
|
168
|
+
params = _list_params(
|
|
169
|
+
limit, offset, status, direction, kind, agent_id, agent_kind, to, from_
|
|
170
|
+
)
|
|
159
171
|
data = self._http.request(
|
|
160
172
|
method="GET", path="/calls", params=params if params else None
|
|
161
173
|
)
|
|
@@ -197,6 +209,62 @@ class CallsResource:
|
|
|
197
209
|
)
|
|
198
210
|
return _parse(Call, data)
|
|
199
211
|
|
|
212
|
+
def transfer(
|
|
213
|
+
self,
|
|
214
|
+
id: str,
|
|
215
|
+
to: Optional[str] = None,
|
|
216
|
+
*,
|
|
217
|
+
idempotency_key: Optional[str] = None,
|
|
218
|
+
) -> Call:
|
|
219
|
+
"""Transfer the caller on an incoming phone call to another number.
|
|
220
|
+
Requires the ``calls:write`` scope and a live API key.
|
|
221
|
+
|
|
222
|
+
Works whatever kind of agent is on the call. Returns at once with
|
|
223
|
+
``transfer.status`` ``pending``; the outcome shows on :meth:`get`
|
|
224
|
+
(``transfer.status``) and, once the caller is handed over, the call
|
|
225
|
+
ends ``completed`` with ``hangup_class`` ``transferred`` (also in the
|
|
226
|
+
``call.completed`` webhook). A failed transfer leaves the caller on
|
|
227
|
+
the call, with ``transfer.failure_reason`` saying why.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
id: Call identifier.
|
|
231
|
+
to: Number to transfer the caller to, in E.164 format: US or
|
|
232
|
+
Canadian, not premium-rate, not the number the caller dialled
|
|
233
|
+
and not a number the agent answers. Left out (not sent), the
|
|
234
|
+
agent's transfer number (``tools.transfer_to``) is used. Any
|
|
235
|
+
other number needs calling out switched on for the workspace.
|
|
236
|
+
idempotency_key: Optional key sent as the Idempotency-Key header;
|
|
237
|
+
generated automatically when omitted.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
ValidationError: ``id`` is empty, or the API answered
|
|
241
|
+
``invalid_request`` (``to`` is malformed, outside the US and
|
|
242
|
+
Canada, premium-rate, the dialled number or a number the agent
|
|
243
|
+
answers).
|
|
244
|
+
InsufficientCreditsError: The workspace has no credits left.
|
|
245
|
+
SendlyError: With ``code`` ``transfer_to_required`` (HTTP 400, no
|
|
246
|
+
``to`` and the agent has no transfer number),
|
|
247
|
+
``outbound_not_enabled`` (HTTP 403, ``to`` is not the agent's
|
|
248
|
+
transfer number and calling out is not switched on),
|
|
249
|
+
``call_not_found`` (HTTP 404), ``call_not_active`` (HTTP 409,
|
|
250
|
+
the call is not in progress), ``call_not_transferable``
|
|
251
|
+
(HTTP 409, not an incoming phone call), ``transfer_in_progress``
|
|
252
|
+
(HTTP 409, a transfer is already under way, or the call has had
|
|
253
|
+
3 transfer attempts) or ``voice_internal_error`` (HTTP 500).
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
>>> call = client.calls.transfer(call.id, to='+15555550142')
|
|
257
|
+
>>> print(call.transfer.status) # 'pending'
|
|
258
|
+
"""
|
|
259
|
+
_require(id, "id")
|
|
260
|
+
data = self._http.request(
|
|
261
|
+
method="POST",
|
|
262
|
+
path=f"/calls/{quote(id, safe='')}/transfer",
|
|
263
|
+
body=_transfer_body(to),
|
|
264
|
+
idempotency_key=idempotency_key,
|
|
265
|
+
)
|
|
266
|
+
return _parse(Call, data)
|
|
267
|
+
|
|
200
268
|
def recording(self, id: str) -> CallRecording:
|
|
201
269
|
"""Fetch where to download a call's recording. Requires the
|
|
202
270
|
``calls:read`` scope.
|
|
@@ -254,11 +322,14 @@ class AsyncCallsResource:
|
|
|
254
322
|
direction: Optional[EnumLike] = None,
|
|
255
323
|
kind: Optional[EnumLike] = None,
|
|
256
324
|
agent_id: Optional[str] = None,
|
|
325
|
+
agent_kind: Optional[EnumLike] = None,
|
|
257
326
|
to: Optional[str] = None,
|
|
258
327
|
from_: Optional[str] = None,
|
|
259
328
|
) -> CallListResponse:
|
|
260
329
|
"""List calls, newest first. See :meth:`CallsResource.list`."""
|
|
261
|
-
params = _list_params(
|
|
330
|
+
params = _list_params(
|
|
331
|
+
limit, offset, status, direction, kind, agent_id, agent_kind, to, from_
|
|
332
|
+
)
|
|
262
333
|
data = await self._http.request(
|
|
263
334
|
method="GET", path="/calls", params=params if params else None
|
|
264
335
|
)
|
|
@@ -281,6 +352,24 @@ class AsyncCallsResource:
|
|
|
281
352
|
)
|
|
282
353
|
return _parse(Call, data)
|
|
283
354
|
|
|
355
|
+
async def transfer(
|
|
356
|
+
self,
|
|
357
|
+
id: str,
|
|
358
|
+
to: Optional[str] = None,
|
|
359
|
+
*,
|
|
360
|
+
idempotency_key: Optional[str] = None,
|
|
361
|
+
) -> Call:
|
|
362
|
+
"""Transfer the caller on an incoming phone call to another number.
|
|
363
|
+
See :meth:`CallsResource.transfer`."""
|
|
364
|
+
_require(id, "id")
|
|
365
|
+
data = await self._http.request(
|
|
366
|
+
method="POST",
|
|
367
|
+
path=f"/calls/{quote(id, safe='')}/transfer",
|
|
368
|
+
body=_transfer_body(to),
|
|
369
|
+
idempotency_key=idempotency_key,
|
|
370
|
+
)
|
|
371
|
+
return _parse(Call, data)
|
|
372
|
+
|
|
284
373
|
async def recording(self, id: str) -> CallRecording:
|
|
285
374
|
"""Fetch where to download a call's recording.
|
|
286
375
|
See :meth:`CallsResource.recording`."""
|
|
@@ -323,6 +412,13 @@ def _create_body(
|
|
|
323
412
|
return body
|
|
324
413
|
|
|
325
414
|
|
|
415
|
+
def _transfer_body(to: Optional[str]) -> Dict[str, Any]:
|
|
416
|
+
body: Dict[str, Any] = {}
|
|
417
|
+
if to is not None:
|
|
418
|
+
body["to"] = to
|
|
419
|
+
return body
|
|
420
|
+
|
|
421
|
+
|
|
326
422
|
def _list_params(
|
|
327
423
|
limit: Optional[int],
|
|
328
424
|
offset: Optional[int],
|
|
@@ -330,6 +426,7 @@ def _list_params(
|
|
|
330
426
|
direction: Optional[EnumLike],
|
|
331
427
|
kind: Optional[EnumLike],
|
|
332
428
|
agent_id: Optional[str],
|
|
429
|
+
agent_kind: Optional[EnumLike],
|
|
333
430
|
to: Optional[str],
|
|
334
431
|
from_: Optional[str],
|
|
335
432
|
) -> Dict[str, Any]:
|
|
@@ -341,6 +438,7 @@ def _list_params(
|
|
|
341
438
|
"direction": _value(direction),
|
|
342
439
|
"kind": _value(kind),
|
|
343
440
|
"agentId": agent_id,
|
|
441
|
+
"agentKind": _value(agent_kind),
|
|
344
442
|
"to": to,
|
|
345
443
|
"from": from_,
|
|
346
444
|
}
|