sendly 4.0.0__tar.gz → 4.2.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.0.0 → sendly-4.2.0}/CHANGELOG.md +22 -0
- {sendly-4.0.0 → sendly-4.2.0}/PKG-INFO +217 -6
- {sendly-4.0.0 → sendly-4.2.0}/README.md +216 -5
- {sendly-4.0.0 → sendly-4.2.0}/pyproject.toml +1 -1
- {sendly-4.0.0 → sendly-4.2.0}/sendly/__init__.py +69 -1
- {sendly-4.0.0 → sendly-4.2.0}/sendly/client.py +6 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/__init__.py +6 -0
- sendly-4.2.0/sendly/resources/calls.py +362 -0
- sendly-4.2.0/sendly/resources/voice.py +642 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/whatsapp.py +1 -1
- {sendly-4.0.0 → sendly-4.2.0}/sendly/types.py +439 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/http.py +1 -1
- sendly-4.2.0/tests/test_calls.py +1047 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages.py +23 -1
- sendly-4.2.0/tests/test_voice.py +1001 -0
- {sendly-4.0.0 → sendly-4.2.0}/.github/header.svg +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/.gitignore +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/examples/async_example.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/examples/error_handling.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/examples/send_sms.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/errors.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/py.typed +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/account.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/business_upgrade.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/campaigns.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/contacts.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/conversations.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/drafts.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/enterprise.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/labels.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/links.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/media.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/messages.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/numbers.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/rcs.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/rules.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/templates.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/tendlc.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/verify.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/resources/webhooks.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/__init__.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/utils/validation.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/sendly/webhooks.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/conftest.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_account.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_async_client.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_client.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_errors.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_http_body_kwarg.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_idempotency.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages.py.bak +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages_batch.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_messages_schedule.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_numbers.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_rcs.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_rcs_registration.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_tendlc.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_validation.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_webhooks.py +0 -0
- {sendly-4.0.0 → sendly-4.2.0}/tests/test_whatsapp.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# sendly (Python)
|
|
2
2
|
|
|
3
|
+
## 4.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **Voice configuration: `client.voice`.** Everything a phone call depends on is now configurable from code. `voice.numbers.list()`, `get(number)`, `update(number, voice_enabled=..., voice_mode=..., agent_id=...)` and `register_emergency_address(number, street=..., unit=..., city=..., state=..., zip=..., country=...)` switch voice on for a number, choose how it answers and register the emergency address it needs before it can place calls; `number` is the number's id or its E.164 phone number. `voice.agents.list()`, `create(name, ...)`, `get(id)`, `update(id, ...)` and `delete(id)` manage the AI agents that talk, and `voice.voices.list()` lists the voices they can use. Sync and async clients. Reads need the `calls:read` scope, writes `calls:write` and a live API key. Deleting an agent that still answers a number raises `SendlyError` with code `agent_in_use` (HTTP 409) and the numbers in `e.response.model_extra['numbers']`.
|
|
8
|
+
- New types: `VoiceNumber`, `VoiceNumberListResponse`, `VoiceNumberEmergencyAddress`, `EmergencyAddress`, `VoiceNumberRates`, `VoiceMode`, `VoiceAgent`, `VoiceAgentListResponse`, `VoiceAgentTools`, `Voice`, `VoiceListResponse`, `CreateVoiceAgentRequest`, `UpdateVoiceAgentRequest`, `UpdateVoiceNumberRequest` and `DeletedVoiceAgent`.
|
|
9
|
+
|
|
10
|
+
### Patch Changes
|
|
11
|
+
|
|
12
|
+
- The `calls.recording()` docstring had the channels the wrong way round. Agent calls are recorded with the agent on the left channel and the other party on the right.
|
|
13
|
+
|
|
14
|
+
## 4.1.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- **Voice calls: `client.calls`.** `create(to, agent_id, from_=..., context=..., metadata=...)` places a phone call that one of your workspace's AI agents handles; `list(...)`, `get(id)`, `hangup(id)` and `recording(id)` follow it, end it early and fetch the recording. Sync and async clients. Reads need the `calls:read` scope, writes `calls:write` and a live API key; until voice is enabled for your workspace the routes answer 404 `voice_not_enabled`.
|
|
19
|
+
- `voice_enabled` and `voice_mode` on owned numbers.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- `MessageStatus` gains `RECEIVED` and `UNDELIVERED`. `messages.list()` returns inbound rows by default and their status is `received`, which the strict enum rejected, so listing failed for any workspace with inbound traffic. Any status this version does not know now maps to `MessageStatus.UNKNOWN` instead of raising.
|
|
24
|
+
|
|
3
25
|
## 4.0.0
|
|
4
26
|
|
|
5
27
|
**Upgrading from 3.40.0:** that release already contained the breaking changes below, published by mistake as a minor version. 4.0.0 carries them under the correct major. Relative to 3.40.0, the only new changes are under **Security**.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sendly
|
|
3
|
-
Version: 4.
|
|
3
|
+
Version: 4.2.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
|
|
@@ -884,21 +884,28 @@ print(registration.stage) # 'in_review' -> 'brand_verification' -> 'agent_revie
|
|
|
884
884
|
# 5. Once in testing: invite devices, describe the campaign, then request launch
|
|
885
885
|
if registration.stage == RcsCustomerStage.TESTING:
|
|
886
886
|
client.rcs.agents.set_test_devices(agent.id, [
|
|
887
|
-
'+
|
|
888
|
-
{'phone_number': '+
|
|
887
|
+
'+15125550142',
|
|
888
|
+
{'phone_number': '+15125550177', 'label': 'Sam'},
|
|
889
889
|
])
|
|
890
890
|
client.rcs.agents.update(
|
|
891
891
|
agent.id,
|
|
892
892
|
campaign={
|
|
893
|
+
'company_overview': 'Acme Coffee runs 12 cafes and an online store.',
|
|
893
894
|
'agent_overview': 'Order updates and support replies',
|
|
894
895
|
'interactions': [{'interaction_type': 'TRANSACTIONAL_UPDATES',
|
|
895
896
|
'description': 'Shipping and delivery updates'}],
|
|
896
|
-
'message_examples': ['
|
|
897
|
-
'
|
|
898
|
-
'
|
|
897
|
+
'message_examples': ['Acme Coffee: order #123 has shipped. Reply STOP to opt out.',
|
|
898
|
+
'Acme Coffee: your table is ready!',
|
|
899
|
+
'Acme Coffee: reply HELP for help.'],
|
|
899
900
|
'consent_settings': {
|
|
900
901
|
'opt_in_methods': [{'method_type': 'WEBSITE',
|
|
901
902
|
'description': 'Checkout checkbox'}],
|
|
903
|
+
'call_to_action': 'Get order updates by message',
|
|
904
|
+
'call_to_action_url': 'https://acme.example/checkout',
|
|
905
|
+
'call_to_action_media_url': 'https://acme.example/rcs/opt-in.png',
|
|
906
|
+
'double_opt_in': False,
|
|
907
|
+
'opt_in_message': 'Acme Coffee: you are subscribed. Reply HELP for help, STOP to opt out.',
|
|
908
|
+
'help_response': 'Acme Coffee: email help@acme.example for help.',
|
|
902
909
|
'opt_out_response': 'You are unsubscribed.',
|
|
903
910
|
},
|
|
904
911
|
},
|
|
@@ -913,6 +920,146 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
|
|
|
913
920
|
`request_launch()` replays return the original response without notifying the
|
|
914
921
|
reviewers again.
|
|
915
922
|
|
|
923
|
+
## Voice Calls
|
|
924
|
+
|
|
925
|
+
Place phone calls that one of your AI agents handles, follow them while they
|
|
926
|
+
ring and after they end, end them early, and download recordings. Requires an
|
|
927
|
+
API key with the `calls:read` / `calls:write` scopes; placing and ending calls
|
|
928
|
+
needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
929
|
+
for yours, every call method raises `SendlyError` with code `voice_not_enabled`
|
|
930
|
+
(HTTP 404).
|
|
931
|
+
|
|
932
|
+
Calls are prepaid from your balance per started minute: 2 credits a minute
|
|
933
|
+
outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
|
|
934
|
+
for an agent-handled outbound call); unanswered calls cost nothing. The number
|
|
935
|
+
you call from must be voice-enabled, with an emergency address registered (see
|
|
936
|
+
[Configure voice](#configure-voice)); `client.voice.numbers.list()` shows each
|
|
937
|
+
number's `voice_enabled`, `voice_mode` and `emergency_address`. Destinations
|
|
938
|
+
are US and Canada.
|
|
939
|
+
|
|
940
|
+
```python
|
|
941
|
+
# Pick a voice-enabled number (optional when exactly one is voice-enabled)
|
|
942
|
+
voice_numbers = [n for n in client.voice.numbers.list().data if n.voice_enabled]
|
|
943
|
+
|
|
944
|
+
# Place a call; the agent speaks first, using the context you pass
|
|
945
|
+
call = client.calls.create(
|
|
946
|
+
to='+15555550123',
|
|
947
|
+
agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
|
|
948
|
+
from_=voice_numbers[0].phone_number,
|
|
949
|
+
context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
|
|
950
|
+
metadata={'crmId': 'lead_8812'},
|
|
951
|
+
)
|
|
952
|
+
print(call.id, call.status) # ... 'ringing'
|
|
953
|
+
|
|
954
|
+
# Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
|
|
955
|
+
call = client.calls.get(call.id)
|
|
956
|
+
print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
|
|
957
|
+
for line in call.transcript or []: # agent calls only; None on other calls
|
|
958
|
+
print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
|
|
959
|
+
|
|
960
|
+
# List calls, newest first, with filters and paging
|
|
961
|
+
page = client.calls.list(status='completed', direction='outbound', limit=20)
|
|
962
|
+
for c in page.data:
|
|
963
|
+
print(c.id, c.to, c.duration_secs, c.credits_charged)
|
|
964
|
+
if page.pagination.has_more:
|
|
965
|
+
page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
|
|
966
|
+
|
|
967
|
+
# End a call early (idempotent: an ended call is returned unchanged)
|
|
968
|
+
client.calls.hangup(call.id)
|
|
969
|
+
|
|
970
|
+
# Fetch the recording; the signed URL is valid for 5 minutes
|
|
971
|
+
recording = client.calls.recording(call.id)
|
|
972
|
+
if recording.status == 'ready':
|
|
973
|
+
print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
Refusals come back as `SendlyError` with a code that says what to do:
|
|
977
|
+
`insufficient_credits` (raised as `InsufficientCreditsError`; top up),
|
|
978
|
+
`e911_required` (register an emergency address for the number), `lines_busy`
|
|
979
|
+
(retry with backoff), `daily_call_limit` (try again tomorrow),
|
|
980
|
+
`agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
|
|
981
|
+
`destination_not_supported`, `live_key_required`. Webhooks `call.started`,
|
|
982
|
+
`call.completed` and `call.recording.ready` carry the same call object in
|
|
983
|
+
snake_case, including `billing` and your `metadata`.
|
|
984
|
+
|
|
985
|
+
### Configure voice
|
|
986
|
+
|
|
987
|
+
Everything a call depends on can be set up from code: switch voice on for a
|
|
988
|
+
number and choose how it answers, register its emergency address, and create
|
|
989
|
+
the AI agents that talk. Reads need `calls:read`; writes need `calls:write` and
|
|
990
|
+
a live key. In a team workspace, changing numbers and managing agents needs an
|
|
991
|
+
owner or admin role (`forbidden` otherwise). These settings change how real
|
|
992
|
+
phone calls to your numbers are answered.
|
|
993
|
+
|
|
994
|
+
```python
|
|
995
|
+
from sendly import SendlyError
|
|
996
|
+
|
|
997
|
+
# The voices an agent can speak with
|
|
998
|
+
for v in client.voice.voices.list().data:
|
|
999
|
+
print(v.id, v.label, v.language)
|
|
1000
|
+
|
|
1001
|
+
# Create an agent (up to 20 per workspace); it gets its own key for texting
|
|
1002
|
+
agent = client.voice.agents.create(
|
|
1003
|
+
name='Front desk',
|
|
1004
|
+
voice='ashley',
|
|
1005
|
+
greeting='Thanks for calling Acme, how can I help?',
|
|
1006
|
+
instructions='Answer questions about opening hours and take a message for anything else.',
|
|
1007
|
+
tools={'send_sms': True},
|
|
1008
|
+
)
|
|
1009
|
+
print(agent.id, agent.voice_label, agent.can_send_sms)
|
|
1010
|
+
|
|
1011
|
+
# Edit it later; only the fields you pass change
|
|
1012
|
+
client.voice.agents.update(agent.id, greeting='Hi, you have reached Acme.')
|
|
1013
|
+
|
|
1014
|
+
# Numbers with their voice settings; pass either the id or the E.164 number
|
|
1015
|
+
for n in client.voice.numbers.list().data:
|
|
1016
|
+
e911 = n.emergency_address.status if n.emergency_address else 'none'
|
|
1017
|
+
print(n.phone_number, n.voice_mode, n.agent_id, e911)
|
|
1018
|
+
|
|
1019
|
+
# Register the emergency address a number needs before it can place calls
|
|
1020
|
+
# (US and Canadian numbers; the first registration adds 1.50 USD a month)
|
|
1021
|
+
number = client.voice.numbers.register_emergency_address(
|
|
1022
|
+
'+15555550188',
|
|
1023
|
+
street='500 Example Ave',
|
|
1024
|
+
unit='Suite 2',
|
|
1025
|
+
city='Austin',
|
|
1026
|
+
state='TX',
|
|
1027
|
+
zip='78701',
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
# Let the agent answer the number (voice_mode='ring_dashboard' rings your team)
|
|
1031
|
+
number = client.voice.numbers.update(
|
|
1032
|
+
'+15555550188', voice_enabled=True, voice_mode='agent', agent_id=agent.id
|
|
1033
|
+
)
|
|
1034
|
+
print(number.voice_mode, number.rate_per_minute.agent) # credits a minute
|
|
1035
|
+
|
|
1036
|
+
# Switch voice off (the same as voice_mode='none')
|
|
1037
|
+
client.voice.numbers.update('+15555550188', voice_enabled=False)
|
|
1038
|
+
|
|
1039
|
+
# Deleting an agent revokes its key; refused while a number still points at it
|
|
1040
|
+
try:
|
|
1041
|
+
client.voice.agents.delete(agent.id)
|
|
1042
|
+
except SendlyError as e:
|
|
1043
|
+
if e.code == 'agent_in_use':
|
|
1044
|
+
print(e.response.model_extra['numbers'])
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
Without `voice_enabled`, `voice_mode='ring_dashboard'` or `'agent'` switches
|
|
1048
|
+
voice on and `voice_mode='none'` switches it off; `voice_enabled=False` always
|
|
1049
|
+
switches voice off.
|
|
1050
|
+
|
|
1051
|
+
Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
|
|
1052
|
+
`agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
|
|
1053
|
+
`number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
|
|
1054
|
+
retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
|
|
1055
|
+
says the number couldn't be found for emergency registration: contact
|
|
1056
|
+
support), `voice_unavailable` (HTTP 503),
|
|
1057
|
+
`e911_not_applicable` (only US and Canadian numbers take an emergency
|
|
1058
|
+
address) and `invalid_address`; on HTTP 422 the closest valid address is in
|
|
1059
|
+
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
|
|
1060
|
+
stored, but agents do not transfer calls yet: when a caller asks for a person,
|
|
1061
|
+
the agent offers to pass a message on and takes their name and number.
|
|
1062
|
+
|
|
916
1063
|
## Error Handling
|
|
917
1064
|
|
|
918
1065
|
The SDK provides typed exception classes:
|
|
@@ -1207,6 +1354,70 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
|
|
|
1207
1354
|
|
|
1208
1355
|
Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
|
|
1209
1356
|
|
|
1357
|
+
### `client.calls`
|
|
1358
|
+
|
|
1359
|
+
#### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
|
|
1360
|
+
|
|
1361
|
+
Place a phone call handled by an AI agent (live key, `calls:write`). Returns the call while it is `ringing`; `from_` is required when more than one number is voice-enabled.
|
|
1362
|
+
|
|
1363
|
+
#### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
|
|
1364
|
+
|
|
1365
|
+
List calls newest first; `pagination.has_more` says whether another page exists.
|
|
1366
|
+
|
|
1367
|
+
#### `get(id) -> Call`
|
|
1368
|
+
|
|
1369
|
+
Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
|
|
1370
|
+
|
|
1371
|
+
#### `hangup(id, idempotency_key=None) -> Call`
|
|
1372
|
+
|
|
1373
|
+
End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
|
|
1374
|
+
|
|
1375
|
+
#### `recording(id) -> CallRecording`
|
|
1376
|
+
|
|
1377
|
+
Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
|
|
1378
|
+
|
|
1379
|
+
### `client.voice`
|
|
1380
|
+
|
|
1381
|
+
#### `numbers.list() -> VoiceNumberListResponse`
|
|
1382
|
+
|
|
1383
|
+
Active numbers in the workspace with `voice_enabled`, `voice_mode`, `agent_id`, `emergency_address` and `rate_per_minute`.
|
|
1384
|
+
|
|
1385
|
+
#### `numbers.get(number) -> VoiceNumber`
|
|
1386
|
+
|
|
1387
|
+
One number's voice settings. `number` is the number's id or its E.164 phone number.
|
|
1388
|
+
|
|
1389
|
+
#### `numbers.update(number, voice_enabled=None, voice_mode=None, agent_id=None, idempotency_key=None) -> VoiceNumber`
|
|
1390
|
+
|
|
1391
|
+
Change how a number answers calls (live key, `calls:write`). Without `voice_enabled`, `ring_dashboard` or `agent` switches voice on and `none` switches it off; `voice_enabled=False` always switches it off. `agent` needs an enabled agent.
|
|
1392
|
+
|
|
1393
|
+
#### `numbers.register_emergency_address(number, street, city, state, zip, unit=None, country=None, idempotency_key=None) -> VoiceNumber`
|
|
1394
|
+
|
|
1395
|
+
Register the emergency address a number needs before it can place calls (live key, `calls:write`). US and Canadian numbers only.
|
|
1396
|
+
|
|
1397
|
+
#### `agents.list() -> VoiceAgentListResponse`
|
|
1398
|
+
|
|
1399
|
+
The workspace's AI agents.
|
|
1400
|
+
|
|
1401
|
+
#### `agents.create(name, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
|
|
1402
|
+
|
|
1403
|
+
Create an AI agent (live key, `calls:write`). Up to 20 per workspace.
|
|
1404
|
+
|
|
1405
|
+
#### `agents.get(id) -> VoiceAgent`
|
|
1406
|
+
|
|
1407
|
+
Fetch one agent.
|
|
1408
|
+
|
|
1409
|
+
#### `agents.update(id, name=None, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
|
|
1410
|
+
|
|
1411
|
+
Edit an agent; only the fields you pass change, and `tools` may be partial.
|
|
1412
|
+
|
|
1413
|
+
#### `agents.delete(id, idempotency_key=None) -> DeletedVoiceAgent`
|
|
1414
|
+
|
|
1415
|
+
Delete an agent and revoke its sending key. Raises `agent_in_use` (HTTP 409) while a number still points at it.
|
|
1416
|
+
|
|
1417
|
+
#### `voices.list() -> VoiceListResponse`
|
|
1418
|
+
|
|
1419
|
+
The voices an agent can speak with; pass a voice's `id` as `voice`.
|
|
1420
|
+
|
|
1210
1421
|
## Enterprise
|
|
1211
1422
|
|
|
1212
1423
|
The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
|
|
@@ -849,21 +849,28 @@ print(registration.stage) # 'in_review' -> 'brand_verification' -> 'agent_revie
|
|
|
849
849
|
# 5. Once in testing: invite devices, describe the campaign, then request launch
|
|
850
850
|
if registration.stage == RcsCustomerStage.TESTING:
|
|
851
851
|
client.rcs.agents.set_test_devices(agent.id, [
|
|
852
|
-
'+
|
|
853
|
-
{'phone_number': '+
|
|
852
|
+
'+15125550142',
|
|
853
|
+
{'phone_number': '+15125550177', 'label': 'Sam'},
|
|
854
854
|
])
|
|
855
855
|
client.rcs.agents.update(
|
|
856
856
|
agent.id,
|
|
857
857
|
campaign={
|
|
858
|
+
'company_overview': 'Acme Coffee runs 12 cafes and an online store.',
|
|
858
859
|
'agent_overview': 'Order updates and support replies',
|
|
859
860
|
'interactions': [{'interaction_type': 'TRANSACTIONAL_UPDATES',
|
|
860
861
|
'description': 'Shipping and delivery updates'}],
|
|
861
|
-
'message_examples': ['
|
|
862
|
-
'
|
|
863
|
-
'
|
|
862
|
+
'message_examples': ['Acme Coffee: order #123 has shipped. Reply STOP to opt out.',
|
|
863
|
+
'Acme Coffee: your table is ready!',
|
|
864
|
+
'Acme Coffee: reply HELP for help.'],
|
|
864
865
|
'consent_settings': {
|
|
865
866
|
'opt_in_methods': [{'method_type': 'WEBSITE',
|
|
866
867
|
'description': 'Checkout checkbox'}],
|
|
868
|
+
'call_to_action': 'Get order updates by message',
|
|
869
|
+
'call_to_action_url': 'https://acme.example/checkout',
|
|
870
|
+
'call_to_action_media_url': 'https://acme.example/rcs/opt-in.png',
|
|
871
|
+
'double_opt_in': False,
|
|
872
|
+
'opt_in_message': 'Acme Coffee: you are subscribed. Reply HELP for help, STOP to opt out.',
|
|
873
|
+
'help_response': 'Acme Coffee: email help@acme.example for help.',
|
|
867
874
|
'opt_out_response': 'You are unsubscribed.',
|
|
868
875
|
},
|
|
869
876
|
},
|
|
@@ -878,6 +885,146 @@ Writes accept an `idempotency_key` like the other write methods; `submit()` and
|
|
|
878
885
|
`request_launch()` replays return the original response without notifying the
|
|
879
886
|
reviewers again.
|
|
880
887
|
|
|
888
|
+
## Voice Calls
|
|
889
|
+
|
|
890
|
+
Place phone calls that one of your AI agents handles, follow them while they
|
|
891
|
+
ring and after they end, end them early, and download recordings. Requires an
|
|
892
|
+
API key with the `calls:read` / `calls:write` scopes; placing and ending calls
|
|
893
|
+
needs a live key. Voice is enabled workspace by workspace: until it is enabled
|
|
894
|
+
for yours, every call method raises `SendlyError` with code `voice_not_enabled`
|
|
895
|
+
(HTTP 404).
|
|
896
|
+
|
|
897
|
+
Calls are prepaid from your balance per started minute: 2 credits a minute
|
|
898
|
+
outbound plus 8 a minute while an AI agent is on the line (10 credits a minute
|
|
899
|
+
for an agent-handled outbound call); unanswered calls cost nothing. The number
|
|
900
|
+
you call from must be voice-enabled, with an emergency address registered (see
|
|
901
|
+
[Configure voice](#configure-voice)); `client.voice.numbers.list()` shows each
|
|
902
|
+
number's `voice_enabled`, `voice_mode` and `emergency_address`. Destinations
|
|
903
|
+
are US and Canada.
|
|
904
|
+
|
|
905
|
+
```python
|
|
906
|
+
# Pick a voice-enabled number (optional when exactly one is voice-enabled)
|
|
907
|
+
voice_numbers = [n for n in client.voice.numbers.list().data if n.voice_enabled]
|
|
908
|
+
|
|
909
|
+
# Place a call; the agent speaks first, using the context you pass
|
|
910
|
+
call = client.calls.create(
|
|
911
|
+
to='+15555550123',
|
|
912
|
+
agent_id='3c4d5e6f-7081-4293-a4b5-c6d7e8f90a1b',
|
|
913
|
+
from_=voice_numbers[0].phone_number,
|
|
914
|
+
context='You are calling Jordan to confirm the 3pm appointment on Tuesday.',
|
|
915
|
+
metadata={'crmId': 'lead_8812'},
|
|
916
|
+
)
|
|
917
|
+
print(call.id, call.status) # ... 'ringing'
|
|
918
|
+
|
|
919
|
+
# Follow it: status moves ringing -> active -> completed (or no_answer, busy, ...)
|
|
920
|
+
call = client.calls.get(call.id)
|
|
921
|
+
print(call.status, call.duration_secs, call.credits_charged, call.hangup_class)
|
|
922
|
+
for line in call.transcript or []: # agent calls only; None on other calls
|
|
923
|
+
print(f'[{line.at_ms} ms] {line.speaker}: {line.text}')
|
|
924
|
+
|
|
925
|
+
# List calls, newest first, with filters and paging
|
|
926
|
+
page = client.calls.list(status='completed', direction='outbound', limit=20)
|
|
927
|
+
for c in page.data:
|
|
928
|
+
print(c.id, c.to, c.duration_secs, c.credits_charged)
|
|
929
|
+
if page.pagination.has_more:
|
|
930
|
+
page = client.calls.list(status='completed', direction='outbound', limit=20, offset=20)
|
|
931
|
+
|
|
932
|
+
# End a call early (idempotent: an ended call is returned unchanged)
|
|
933
|
+
client.calls.hangup(call.id)
|
|
934
|
+
|
|
935
|
+
# Fetch the recording; the signed URL is valid for 5 minutes
|
|
936
|
+
recording = client.calls.recording(call.id)
|
|
937
|
+
if recording.status == 'ready':
|
|
938
|
+
print(recording.url, recording.expires_at, recording.content_type) # audio/ogg
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
Refusals come back as `SendlyError` with a code that says what to do:
|
|
942
|
+
`insufficient_credits` (raised as `InsufficientCreditsError`; top up),
|
|
943
|
+
`e911_required` (register an emergency address for the number), `lines_busy`
|
|
944
|
+
(retry with backoff), `daily_call_limit` (try again tomorrow),
|
|
945
|
+
`agent_not_found` / `agent_disabled`, `from_number_required` / `no_voice_number`,
|
|
946
|
+
`destination_not_supported`, `live_key_required`. Webhooks `call.started`,
|
|
947
|
+
`call.completed` and `call.recording.ready` carry the same call object in
|
|
948
|
+
snake_case, including `billing` and your `metadata`.
|
|
949
|
+
|
|
950
|
+
### Configure voice
|
|
951
|
+
|
|
952
|
+
Everything a call depends on can be set up from code: switch voice on for a
|
|
953
|
+
number and choose how it answers, register its emergency address, and create
|
|
954
|
+
the AI agents that talk. Reads need `calls:read`; writes need `calls:write` and
|
|
955
|
+
a live key. In a team workspace, changing numbers and managing agents needs an
|
|
956
|
+
owner or admin role (`forbidden` otherwise). These settings change how real
|
|
957
|
+
phone calls to your numbers are answered.
|
|
958
|
+
|
|
959
|
+
```python
|
|
960
|
+
from sendly import SendlyError
|
|
961
|
+
|
|
962
|
+
# The voices an agent can speak with
|
|
963
|
+
for v in client.voice.voices.list().data:
|
|
964
|
+
print(v.id, v.label, v.language)
|
|
965
|
+
|
|
966
|
+
# Create an agent (up to 20 per workspace); it gets its own key for texting
|
|
967
|
+
agent = client.voice.agents.create(
|
|
968
|
+
name='Front desk',
|
|
969
|
+
voice='ashley',
|
|
970
|
+
greeting='Thanks for calling Acme, how can I help?',
|
|
971
|
+
instructions='Answer questions about opening hours and take a message for anything else.',
|
|
972
|
+
tools={'send_sms': True},
|
|
973
|
+
)
|
|
974
|
+
print(agent.id, agent.voice_label, agent.can_send_sms)
|
|
975
|
+
|
|
976
|
+
# Edit it later; only the fields you pass change
|
|
977
|
+
client.voice.agents.update(agent.id, greeting='Hi, you have reached Acme.')
|
|
978
|
+
|
|
979
|
+
# Numbers with their voice settings; pass either the id or the E.164 number
|
|
980
|
+
for n in client.voice.numbers.list().data:
|
|
981
|
+
e911 = n.emergency_address.status if n.emergency_address else 'none'
|
|
982
|
+
print(n.phone_number, n.voice_mode, n.agent_id, e911)
|
|
983
|
+
|
|
984
|
+
# Register the emergency address a number needs before it can place calls
|
|
985
|
+
# (US and Canadian numbers; the first registration adds 1.50 USD a month)
|
|
986
|
+
number = client.voice.numbers.register_emergency_address(
|
|
987
|
+
'+15555550188',
|
|
988
|
+
street='500 Example Ave',
|
|
989
|
+
unit='Suite 2',
|
|
990
|
+
city='Austin',
|
|
991
|
+
state='TX',
|
|
992
|
+
zip='78701',
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
# Let the agent answer the number (voice_mode='ring_dashboard' rings your team)
|
|
996
|
+
number = client.voice.numbers.update(
|
|
997
|
+
'+15555550188', voice_enabled=True, voice_mode='agent', agent_id=agent.id
|
|
998
|
+
)
|
|
999
|
+
print(number.voice_mode, number.rate_per_minute.agent) # credits a minute
|
|
1000
|
+
|
|
1001
|
+
# Switch voice off (the same as voice_mode='none')
|
|
1002
|
+
client.voice.numbers.update('+15555550188', voice_enabled=False)
|
|
1003
|
+
|
|
1004
|
+
# Deleting an agent revokes its key; refused while a number still points at it
|
|
1005
|
+
try:
|
|
1006
|
+
client.voice.agents.delete(agent.id)
|
|
1007
|
+
except SendlyError as e:
|
|
1008
|
+
if e.code == 'agent_in_use':
|
|
1009
|
+
print(e.response.model_extra['numbers'])
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
Without `voice_enabled`, `voice_mode='ring_dashboard'` or `'agent'` switches
|
|
1013
|
+
voice on and `voice_mode='none'` switches it off; `voice_enabled=False` always
|
|
1014
|
+
switches voice off.
|
|
1015
|
+
|
|
1016
|
+
Refusals: `agent_required` (agent mode with no agent), `agent_not_found`,
|
|
1017
|
+
`agent_disabled` (turn the agent on first), `agent_limit`, `agent_in_use`,
|
|
1018
|
+
`number_not_found`, `invalid_voice_mode`, `voice_attach_failed` (HTTP 502,
|
|
1019
|
+
retry shortly), `carrier_refused` (HTTP 502, retry shortly unless the message
|
|
1020
|
+
says the number couldn't be found for emergency registration: contact
|
|
1021
|
+
support), `voice_unavailable` (HTTP 503),
|
|
1022
|
+
`e911_not_applicable` (only US and Canadian numbers take an emergency
|
|
1023
|
+
address) and `invalid_address`; on HTTP 422 the closest valid address is in
|
|
1024
|
+
`e.response.model_extra['suggested']`. An agent's `tools.transfer_to` is
|
|
1025
|
+
stored, but agents do not transfer calls yet: when a caller asks for a person,
|
|
1026
|
+
the agent offers to pass a message on and takes their name and number.
|
|
1027
|
+
|
|
881
1028
|
## Error Handling
|
|
882
1029
|
|
|
883
1030
|
The SDK provides typed exception classes:
|
|
@@ -1172,6 +1319,70 @@ Submit the agent and its brand for review by Sendly, then the carrier network. `
|
|
|
1172
1319
|
|
|
1173
1320
|
Ask for the launch review once the agent is in `testing` and has been tried on an invited device.
|
|
1174
1321
|
|
|
1322
|
+
### `client.calls`
|
|
1323
|
+
|
|
1324
|
+
#### `create(to, agent_id, from_=None, context=None, metadata=None, idempotency_key=None) -> Call`
|
|
1325
|
+
|
|
1326
|
+
Place a phone call handled by an AI agent (live key, `calls:write`). Returns the call while it is `ringing`; `from_` is required when more than one number is voice-enabled.
|
|
1327
|
+
|
|
1328
|
+
#### `list(limit=None, offset=None, status=None, direction=None, kind=None, agent_id=None, to=None, from_=None) -> CallListResponse`
|
|
1329
|
+
|
|
1330
|
+
List calls newest first; `pagination.has_more` says whether another page exists.
|
|
1331
|
+
|
|
1332
|
+
#### `get(id) -> Call`
|
|
1333
|
+
|
|
1334
|
+
Fetch one call. Agent-handled calls carry `transcript`; on other calls it is `None`.
|
|
1335
|
+
|
|
1336
|
+
#### `hangup(id, idempotency_key=None) -> Call`
|
|
1337
|
+
|
|
1338
|
+
End a call (live key, `calls:write`). `ringing` becomes `cancelled`, `active` becomes `completed`; an ended call is returned unchanged.
|
|
1339
|
+
|
|
1340
|
+
#### `recording(id) -> CallRecording`
|
|
1341
|
+
|
|
1342
|
+
Where to download the recording. `url` and `expires_at` are set only while `status` is `ready`; the link is valid for 5 minutes.
|
|
1343
|
+
|
|
1344
|
+
### `client.voice`
|
|
1345
|
+
|
|
1346
|
+
#### `numbers.list() -> VoiceNumberListResponse`
|
|
1347
|
+
|
|
1348
|
+
Active numbers in the workspace with `voice_enabled`, `voice_mode`, `agent_id`, `emergency_address` and `rate_per_minute`.
|
|
1349
|
+
|
|
1350
|
+
#### `numbers.get(number) -> VoiceNumber`
|
|
1351
|
+
|
|
1352
|
+
One number's voice settings. `number` is the number's id or its E.164 phone number.
|
|
1353
|
+
|
|
1354
|
+
#### `numbers.update(number, voice_enabled=None, voice_mode=None, agent_id=None, idempotency_key=None) -> VoiceNumber`
|
|
1355
|
+
|
|
1356
|
+
Change how a number answers calls (live key, `calls:write`). Without `voice_enabled`, `ring_dashboard` or `agent` switches voice on and `none` switches it off; `voice_enabled=False` always switches it off. `agent` needs an enabled agent.
|
|
1357
|
+
|
|
1358
|
+
#### `numbers.register_emergency_address(number, street, city, state, zip, unit=None, country=None, idempotency_key=None) -> VoiceNumber`
|
|
1359
|
+
|
|
1360
|
+
Register the emergency address a number needs before it can place calls (live key, `calls:write`). US and Canadian numbers only.
|
|
1361
|
+
|
|
1362
|
+
#### `agents.list() -> VoiceAgentListResponse`
|
|
1363
|
+
|
|
1364
|
+
The workspace's AI agents.
|
|
1365
|
+
|
|
1366
|
+
#### `agents.create(name, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
|
|
1367
|
+
|
|
1368
|
+
Create an AI agent (live key, `calls:write`). Up to 20 per workspace.
|
|
1369
|
+
|
|
1370
|
+
#### `agents.get(id) -> VoiceAgent`
|
|
1371
|
+
|
|
1372
|
+
Fetch one agent.
|
|
1373
|
+
|
|
1374
|
+
#### `agents.update(id, name=None, enabled=None, voice=None, language=None, greeting=None, instructions=None, tools=None, idempotency_key=None) -> VoiceAgent`
|
|
1375
|
+
|
|
1376
|
+
Edit an agent; only the fields you pass change, and `tools` may be partial.
|
|
1377
|
+
|
|
1378
|
+
#### `agents.delete(id, idempotency_key=None) -> DeletedVoiceAgent`
|
|
1379
|
+
|
|
1380
|
+
Delete an agent and revoke its sending key. Raises `agent_in_use` (HTTP 409) while a number still points at it.
|
|
1381
|
+
|
|
1382
|
+
#### `voices.list() -> VoiceListResponse`
|
|
1383
|
+
|
|
1384
|
+
The voices an agent can speak with; pass a voice's `id` as `voice`.
|
|
1385
|
+
|
|
1175
1386
|
## Enterprise
|
|
1176
1387
|
|
|
1177
1388
|
The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
|
|
@@ -29,7 +29,7 @@ Async Example:
|
|
|
29
29
|
>>> asyncio.run(main())
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
|
-
__version__ = "4.
|
|
32
|
+
__version__ = "4.2.0"
|
|
33
33
|
|
|
34
34
|
# Main clients
|
|
35
35
|
from .client import AsyncSendly, Sendly
|
|
@@ -51,11 +51,13 @@ from .resources.business_upgrade import (
|
|
|
51
51
|
AsyncBusinessUpgradeResource,
|
|
52
52
|
BusinessUpgradeResource,
|
|
53
53
|
)
|
|
54
|
+
from .resources.calls import AsyncCallsResource, CallsResource
|
|
54
55
|
from .resources.links import AsyncLinksResource, LinksResource
|
|
55
56
|
from .resources.media import AsyncMediaResource, MediaResource
|
|
56
57
|
from .resources.numbers import AsyncNumbersResource, NumbersResource
|
|
57
58
|
from .resources.rcs import AsyncRcsResource, RcsResource
|
|
58
59
|
from .resources.tendlc import AsyncTenDlcResource, TenDlcResource
|
|
60
|
+
from .resources.voice import AsyncVoiceResource, VoiceResource
|
|
59
61
|
from .resources.whatsapp import AsyncWhatsAppResource, WhatsAppResource
|
|
60
62
|
|
|
61
63
|
# Types
|
|
@@ -194,6 +196,36 @@ from .types import (
|
|
|
194
196
|
RcsTestDeviceInput,
|
|
195
197
|
RcsTestDeviceListResponse,
|
|
196
198
|
RcsTesting,
|
|
199
|
+
# Voice call types
|
|
200
|
+
Call,
|
|
201
|
+
CallBilling,
|
|
202
|
+
CallDirection,
|
|
203
|
+
CallHandledBy,
|
|
204
|
+
CallKind,
|
|
205
|
+
CallListResponse,
|
|
206
|
+
CallPagination,
|
|
207
|
+
CallRecording,
|
|
208
|
+
CallRecordingStatus,
|
|
209
|
+
CallStatus,
|
|
210
|
+
CallTranscriptLine,
|
|
211
|
+
CreateCallRequest,
|
|
212
|
+
ListCallsOptions,
|
|
213
|
+
# Voice configuration types
|
|
214
|
+
CreateVoiceAgentRequest,
|
|
215
|
+
DeletedVoiceAgent,
|
|
216
|
+
EmergencyAddress,
|
|
217
|
+
UpdateVoiceAgentRequest,
|
|
218
|
+
UpdateVoiceNumberRequest,
|
|
219
|
+
Voice,
|
|
220
|
+
VoiceAgent,
|
|
221
|
+
VoiceAgentListResponse,
|
|
222
|
+
VoiceAgentTools,
|
|
223
|
+
VoiceListResponse,
|
|
224
|
+
VoiceMode,
|
|
225
|
+
VoiceNumber,
|
|
226
|
+
VoiceNumberEmergencyAddress,
|
|
227
|
+
VoiceNumberListResponse,
|
|
228
|
+
VoiceNumberRates,
|
|
197
229
|
OptInPage,
|
|
198
230
|
PricingTier,
|
|
199
231
|
QuotaSettings,
|
|
@@ -406,6 +438,36 @@ __all__ = [
|
|
|
406
438
|
"RcsTestDeviceInput",
|
|
407
439
|
"RcsTestDeviceListResponse",
|
|
408
440
|
"RcsTesting",
|
|
441
|
+
# Voice call types
|
|
442
|
+
"Call",
|
|
443
|
+
"CallBilling",
|
|
444
|
+
"CallDirection",
|
|
445
|
+
"CallHandledBy",
|
|
446
|
+
"CallKind",
|
|
447
|
+
"CallListResponse",
|
|
448
|
+
"CallPagination",
|
|
449
|
+
"CallRecording",
|
|
450
|
+
"CallRecordingStatus",
|
|
451
|
+
"CallStatus",
|
|
452
|
+
"CallTranscriptLine",
|
|
453
|
+
"CreateCallRequest",
|
|
454
|
+
"ListCallsOptions",
|
|
455
|
+
# Voice configuration types
|
|
456
|
+
"CreateVoiceAgentRequest",
|
|
457
|
+
"DeletedVoiceAgent",
|
|
458
|
+
"EmergencyAddress",
|
|
459
|
+
"UpdateVoiceAgentRequest",
|
|
460
|
+
"UpdateVoiceNumberRequest",
|
|
461
|
+
"Voice",
|
|
462
|
+
"VoiceAgent",
|
|
463
|
+
"VoiceAgentListResponse",
|
|
464
|
+
"VoiceAgentTools",
|
|
465
|
+
"VoiceListResponse",
|
|
466
|
+
"VoiceMode",
|
|
467
|
+
"VoiceNumber",
|
|
468
|
+
"VoiceNumberEmergencyAddress",
|
|
469
|
+
"VoiceNumberListResponse",
|
|
470
|
+
"VoiceNumberRates",
|
|
409
471
|
# Links resources
|
|
410
472
|
"LinksResource",
|
|
411
473
|
"AsyncLinksResource",
|
|
@@ -427,6 +489,12 @@ __all__ = [
|
|
|
427
489
|
# RCS resources
|
|
428
490
|
"RcsResource",
|
|
429
491
|
"AsyncRcsResource",
|
|
492
|
+
# Voice call resources
|
|
493
|
+
"CallsResource",
|
|
494
|
+
"AsyncCallsResource",
|
|
495
|
+
# Voice configuration resources
|
|
496
|
+
"VoiceResource",
|
|
497
|
+
"AsyncVoiceResource",
|
|
430
498
|
# Errors
|
|
431
499
|
"SendlyError",
|
|
432
500
|
"AuthenticationError",
|