diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ccd69b..73f8c50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,16 +43,20 @@ jobs: - name: Install dependencies run: uv sync + # uv run, not uvx: uvx resolves ruff at run time, so a new release silently + # changes what CI enforces. This uses the version pinned in the lockfile. - name: Check formatting - run: uvx ruff format --check . + run: uv run ruff format --check . - name: Lint - run: uvx ruff check . + run: uv run ruff check . + # No test declares a "unit" marker, so `-m unit` deselected every test and the + # exit-5 guard reported that as success — the suite never actually ran. - name: Run unit tests run: | if [ -d "tests" ]; then - uv run pytest tests/ -m unit || [ $? -eq 5 ] # exit 5 means no tests selected; treat as success + uv run pytest tests/ else echo "No tests directory, skipping" fi diff --git a/pyproject.toml b/pyproject.toml index 3a468d1..6936039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gradient-labs" -version = "0.12.1" +version = "0.12.2" description = "Python bindings for the Gradient Labs API" readme = "README.md" requires-python = ">=3.9,<4.0" diff --git a/src/gradient_labs/_http_client.py b/src/gradient_labs/_http_client.py index f565a75..cddbe46 100644 --- a/src/gradient_labs/_http_client.py +++ b/src/gradient_labs/_http_client.py @@ -1,5 +1,6 @@ from typing import Any, Callable from datetime import datetime +from importlib import metadata from pytz import UTC import requests @@ -7,7 +8,14 @@ from .errors import ResponseError API_BASE_URL = "https://api.gradient-labs.ai" -USER_AGENT = "Gradient Labs Python" + +try: + _version = metadata.version("gradient-labs") +except metadata.PackageNotFoundError: + # Running from a source tree with no installed distribution metadata. + _version = "unknown" + +USER_AGENT = f"Gradient Labs Python/{_version}" class HttpClient: @@ -36,7 +44,10 @@ def delete(self, path: str, body: Any): @classmethod def localize(cls, timestamp: datetime) -> str: - return UTC.localize(timestamp).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + # Naive datetimes are taken to already be UTC, as they always have been. + if timestamp.tzinfo is None: + return timestamp.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + return timestamp.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ") def _api_call(self, request_func: Callable, path: str, body: Any): url = f"{self.base_url}/{path}" diff --git a/src/gradient_labs/_outbound_conversation_start.py b/src/gradient_labs/_outbound_conversation_start.py index a29bb7f..11859d8 100644 --- a/src/gradient_labs/_outbound_conversation_start.py +++ b/src/gradient_labs/_outbound_conversation_start.py @@ -1,95 +1,145 @@ -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from enum import Enum from dataclasses import dataclass from dataclasses_json import dataclass_json from ._http_client import HttpClient +from ._conversation_start import CustomerSupportPlatformIdentifier -class CustomerSource(str, Enum): - """Identifies where customer data originates from.""" +class OutboundSupportPlatform(str, Enum): + """Identifies the support platform an outbound chat or email is delivered on.""" - INTERCOM = "intercom" - FRESHCHAT = "freshchat" - FRESHDESK = "freshdesk" - PUBLIC_API = "public-api" - SALESFORCE = "salesforce" - ZENDESK = "zendesk" - VOICE = "livekit" - VOICE_TWILIO = "twilio" - VOICE_TALKDESK = "talkdesk" - VOICE_INTERCOM = "intercom-voice" - WEB_APP = "web-app" - FILE = "file" + INTERCOM: str = "intercom" + ZENDESK: str = "zendesk" + SALESFORCE: str = "salesforce" + # PUBLIC_API delivers the conversation to your own webhook endpoint. + PUBLIC_API: str = "public-api" -class SupportPlatform(str, Enum): - """Identifies the support platform where the conversation will be created.""" - FRESHCHAT = "freshchat" - FRESHDESK = "freshdesk" - INTERCOM = "intercom" - PUBLIC_API = "public-api" - SALESFORCE = "salesforce" - ZENDESK = "zendesk" - VOICE = "livekit" - VOICE_TWILIO = "twilio" - VOICE_TALKDESK = "talkdesk" - VOICE_INTERCOM = "intercom-voice" - WEB_APP = "web-app" +@dataclass_json +@dataclass(frozen=True) +class StartOutboundChatConversationParams: + """Parameters for starting an outbound live chat conversation.""" + + # customer_id is your own identifier for the customer, as used in your systems. + # It is stored as the customer's company customer ID, and is the identifier echoed + # back to you in tool and webhook payloads. + customer_id: str + + # procedure_id is the ID of the outbound procedure that defines what the AI agent + # should accomplish in this conversation. The procedure must be of type "outbound", + # must be live (deployed), and must be enabled for the chat channel. + procedure_id: str + + # support_platform is the support platform the chat is delivered on. + # Valid values: "intercom", "public-api". + support_platform: OutboundSupportPlatform + + # customer_support_platform_identifiers optionally links the customer to their + # record(s) in third-party support platforms (e.g. Intercom), alongside customer_id. + # + # The platform named in support_platform needs an identifier here, unless the + # customer already carries one from an earlier conversation. + customer_support_platform_identifiers: Optional[ + List[CustomerSupportPlatformIdentifier] + ] = None + + # body is the content of the initial message to send to the customer. + # If omitted, the AI agent will generate an appropriate opening message based on + # the procedure. + body: Optional[str] = None + + # resources is a JSON object containing structured data that the AI agent + # can use during the conversation. This should be organized as a dict + # where keys are resource type names and values are the corresponding data. + # Example: {"customer_profile": {"tier": "premium", "lifetime_value": 5000}} + resources: Optional[Dict[str, Any]] = None @dataclass_json @dataclass(frozen=True) -class StartOutboundConversationParams: - """Parameters for starting a new outbound conversation. +class StartOutboundEmailConversationParams: + """Parameters for starting an outbound email conversation.""" - This kicks off a proactive conversation where your AI agent initiates - contact with a customer. - """ - - # customer_id is the external identifier for the customer in your support platform. - # For Intercom, this is the external ID you've defined for the user (e.g., "user-123456"). - # For other platforms, this is the customer identifier used by that platform. + # customer_id is your own identifier for the customer, as used in your systems. + # It is stored as the customer's company customer ID, and is the identifier echoed + # back to you in tool and webhook payloads. customer_id: str - # customer_source is the source of the customer data. - # For example, a customer ID and phone number might be from Intercom, but the outbound - # conversation is initiated via Twilio. - customer_source: CustomerSource - # procedure_id is the ID of the outbound procedure that defines what the AI agent - # should accomplish in this conversation. The procedure must be of type "outbound" - # and must be live (deployed). + # should accomplish in this conversation. The procedure must be of type "outbound", + # must be live (deployed), and must be enabled for the email channel. procedure_id: str - # support_platform is the support platform where the conversation should be created. - # Valid values include "intercom", "zendesk", "freshdesk", "freshchat". - # If not provided, the system will automatically select the first connected platform - # in priority order: intercom, zendesk, freshchat, freshdesk, public-api. - support_platform: Optional[SupportPlatform] = None + # support_platform is the support platform the email is sent from. + # Valid values: "intercom", "zendesk", "salesforce", "public-api". + support_platform: OutboundSupportPlatform - # channel specifies the communication channel for this conversation. - # If not provided, defaults to "email". - # Valid values: "email", "web", "sms", "voice", etc. - channel: Optional[str] = None + # customer_support_platform_identifiers optionally links the customer to their + # record(s) in third-party support platforms (e.g. Intercom, Zendesk, Salesforce), + # alongside customer_id. + # + # The platform named in support_platform needs an identifier here, unless the + # customer already carries one from an earlier conversation. Zendesk requires type + # "zendesk_support_user"; Salesforce requires type "salesforce_contact_id". + customer_support_platform_identifiers: Optional[ + List[CustomerSupportPlatformIdentifier] + ] = None - # subject is the subject line for the initial message (primarily used for email channels). - # Only used if body is also provided. If both subject and body are omitted, the AI agent - # will generate the initial message. + # subject is the subject line for the initial email. Required if body is provided, + # and forbidden otherwise. If both are omitted, the AI agent will write the opening + # email. subject: Optional[str] = None - # body is the content of the initial message to send to the customer. - # If provided, this message will be sent instead of having the AI agent generate one. - # If omitted, the AI agent will generate an appropriate initial message based on the procedure. + # body is the content of the initial email to send to the customer. Required if + # subject is provided, and forbidden otherwise. body: Optional[str] = None # resources is a JSON object containing structured data that the AI agent # can use during the conversation. This should be organized as a dict # where keys are resource type names and values are the corresponding data. # Example: {"customer_profile": {"tier": "premium", "lifetime_value": 5000}} - # The data will be made available to the AI agent for context during conversation processing. + resources: Optional[Dict[str, Any]] = None + + +@dataclass_json +@dataclass(frozen=True) +class StartOutboundPhoneConversationParams: + """Parameters for placing an outbound phone call.""" + + # customer_id is your own identifier for the customer, as used in your systems. + # It is stored as the customer's company customer ID, and is the identifier echoed + # back to you in tool and webhook payloads. + customer_id: str + + # procedure_id is the ID of the outbound procedure that defines what the AI agent + # should accomplish on the call. The procedure must be of type "outbound", must be + # live (deployed), and must be enabled for the "voice" channel. + procedure_id: str + + # to_phone_number is the customer's phone number to dial (E.164 format, + # e.g. "+14155551234"). + to_phone_number: str + + # from_phone_number is the caller ID to place the call from (E.164 format). + # It must be a phone number already provisioned for your company. + from_phone_number: str + + # customer_support_platform_identifiers optionally links the customer to their + # record(s) in third-party support platforms (e.g. Intercom, Zendesk, Salesforce), + # alongside customer_id. They are also used to pull that platform's customer data + # into the call as context. + customer_support_platform_identifiers: Optional[ + List[CustomerSupportPlatformIdentifier] + ] = None + + # resources is a JSON object containing structured data that the AI agent + # can use during the conversation. This should be organized as a dict + # where keys are resource type names and values are the corresponding data. + # Example: {"customer_profile": {"tier": "premium", "lifetime_value": 5000}} resources: Optional[Dict[str, Any]] = None @@ -103,36 +153,77 @@ class StartOutboundConversationResponse: conversation_id: str -def start_outbound_conversation( - *, client: HttpClient, params: StartOutboundConversationParams +def _support_platform_value(platform: OutboundSupportPlatform) -> str: + return platform.value if isinstance(platform, OutboundSupportPlatform) else platform + + +def _identifiers( + identifiers: List[CustomerSupportPlatformIdentifier], +) -> List[Dict[str, Any]]: + return [i.to_dict() for i in identifiers] + + +def start_outbound_chat_conversation( + *, client: HttpClient, params: StartOutboundChatConversationParams ) -> StartOutboundConversationResponse: - """Creates and starts a new outbound conversation where the AI agent - proactively initiates contact with a customer. + """Creates and starts a new outbound live chat conversation in which the AI agent + proactively initiates contact with a customer, following the instructions defined + in the specified outbound procedure. - The conversation follows the instructions defined in the specified outbound procedure. + If body is provided, that message will be sent as the opening message. Otherwise, + the AI agent will generate one based on the procedure. - If support_platform is not provided, the system will automatically select the highest - priority platform that has integration settings configured for your company. + The customer is created, or matched to an existing record, from customer_id and any + customer_support_platform_identifiers you supply. The platform the chat is delivered + on needs an identifier for that customer. + """ + body = { + "customer_id": params.customer_id, + "procedure_id": params.procedure_id, + "support_platform": _support_platform_value(params.support_platform), + } + + if params.customer_support_platform_identifiers is not None: + body["customer_support_platform_identifiers"] = _identifiers( + params.customer_support_platform_identifiers + ) + if params.body is not None: + body["body"] = params.body + if params.resources is not None: + body["resources"] = params.resources + + rsp = client.post( + path="outbound/conversations/chat", + body=body, + ) + return StartOutboundConversationResponse.from_dict(rsp) + + +def start_outbound_email_conversation( + *, client: HttpClient, params: StartOutboundEmailConversationParams +) -> StartOutboundConversationResponse: + """Creates and starts a new outbound email conversation in which the AI agent + proactively initiates contact with a customer, following the instructions defined + in the specified outbound procedure. - If body and subject are provided, that message will be sent as the initial message. - Otherwise, the AI agent will generate an appropriate initial message based on the procedure. + If body and subject are provided, that email will be sent as the opening message. + Otherwise, the AI agent will write one based on the procedure. + + The customer is created, or matched to an existing record, from customer_id and any + customer_support_platform_identifiers you supply. The platform the email is sent + from needs an identifier for that customer, so sending from Zendesk needs a Zendesk + identifier, and so on. """ body = { "customer_id": params.customer_id, - "customer_source": params.customer_source.value - if isinstance(params.customer_source, CustomerSource) - else params.customer_source, "procedure_id": params.procedure_id, + "support_platform": _support_platform_value(params.support_platform), } - if params.support_platform is not None: - body["support_platform"] = ( - params.support_platform.value - if isinstance(params.support_platform, SupportPlatform) - else params.support_platform + if params.customer_support_platform_identifiers is not None: + body["customer_support_platform_identifiers"] = _identifiers( + params.customer_support_platform_identifiers ) - if params.channel is not None: - body["channel"] = params.channel if params.subject is not None: body["subject"] = params.subject if params.body is not None: @@ -141,7 +232,40 @@ def start_outbound_conversation( body["resources"] = params.resources rsp = client.post( - path="outbound/conversations", + path="outbound/conversations/email", + body=body, + ) + return StartOutboundConversationResponse.from_dict(rsp) + + +def start_outbound_phone_conversation( + *, client: HttpClient, params: StartOutboundPhoneConversationParams +) -> StartOutboundConversationResponse: + """Places an outbound phone call in which the AI agent proactively contacts a + customer, following the instructions defined in the specified outbound procedure. + + from_phone_number must be a phone number already provisioned for your company. + + The customer is created, or matched to an existing record, from customer_id and any + customer_support_platform_identifiers you supply. The dialled number is recorded + against that same customer. + """ + body = { + "customer_id": params.customer_id, + "procedure_id": params.procedure_id, + "to_phone_number": params.to_phone_number, + "from_phone_number": params.from_phone_number, + } + + if params.customer_support_platform_identifiers is not None: + body["customer_support_platform_identifiers"] = _identifiers( + params.customer_support_platform_identifiers + ) + if params.resources is not None: + body["resources"] = params.resources + + rsp = client.post( + path="outbound/conversations/phone", body=body, ) return StartOutboundConversationResponse.from_dict(rsp) diff --git a/src/gradient_labs/client.py b/src/gradient_labs/client.py index eb8a1d6..a9c7e6b 100644 --- a/src/gradient_labs/client.py +++ b/src/gradient_labs/client.py @@ -32,8 +32,13 @@ ) from ._outbound_conversation_start import ( - start_outbound_conversation, - StartOutboundConversationParams, + start_outbound_chat_conversation, + start_outbound_email_conversation, + start_outbound_phone_conversation, + OutboundSupportPlatform as OutboundSupportPlatform, + StartOutboundChatConversationParams, + StartOutboundEmailConversationParams, + StartOutboundPhoneConversationParams, StartOutboundConversationResponse, ) @@ -341,23 +346,65 @@ def start_conversation( params=params, ) - def start_outbound_conversation( + def start_outbound_chat_conversation( self, *, - params: StartOutboundConversationParams, + params: StartOutboundChatConversationParams, ) -> StartOutboundConversationResponse: - """Starts an outbound conversation. + """Starts an outbound live chat conversation. - Creates and starts a new outbound conversation where the AI agent proactively - initiates contact with a customer. The conversation follows the instructions + The AI agent proactively initiates contact with a customer, following the + instructions defined in the specified outbound procedure. + + If body is provided, that message will be sent as the opening message. + Otherwise, the AI agent will generate one based on the procedure. + + The customer is created, or matched to an existing record, from customer_id and + any customer_support_platform_identifiers you supply. The platform the chat is + delivered on needs an identifier for that customer.""" + return start_outbound_chat_conversation( + client=self.http_client, + params=params, + ) + + def start_outbound_email_conversation( + self, + *, + params: StartOutboundEmailConversationParams, + ) -> StartOutboundConversationResponse: + """Starts an outbound email conversation. + + The AI agent proactively initiates contact with a customer, following the + instructions defined in the specified outbound procedure. + + If body and subject are provided, that email will be sent as the opening + message. Otherwise, the AI agent will write one based on the procedure. + + The customer is created, or matched to an existing record, from customer_id and + any customer_support_platform_identifiers you supply. The platform the email is + sent from needs an identifier for that customer, so sending from Zendesk needs a + Zendesk identifier, and so on.""" + return start_outbound_email_conversation( + client=self.http_client, + params=params, + ) + + def start_outbound_phone_conversation( + self, + *, + params: StartOutboundPhoneConversationParams, + ) -> StartOutboundConversationResponse: + """Places an outbound phone call. + + The AI agent proactively contacts a customer, following the instructions defined in the specified outbound procedure. - If support_platform is not provided, the system will automatically select the - highest priority platform that has integration settings configured for your company. + from_phone_number must be a phone number already provisioned for your company. - If body and subject are provided, that message will be sent as the initial message. - Otherwise, the AI agent will generate an appropriate initial message based on the procedure.""" - return start_outbound_conversation( + The customer is created, or matched to an existing record, from customer_id and + any customer_support_platform_identifiers you supply. The dialled number is + recorded against that same customer.""" + return start_outbound_phone_conversation( client=self.http_client, params=params, ) diff --git a/src/gradient_labs/webhook.py b/src/gradient_labs/webhook.py index 325685c..dfe42ca 100644 --- a/src/gradient_labs/webhook.py +++ b/src/gradient_labs/webhook.py @@ -51,7 +51,7 @@ def parse_event( ) if not sig.valid: raise SignatureVerificationError("invalid signature") - if abs(UTC.localize(datetime.now()) - sig.timestamp) > cls.LEEWAY: + if abs(datetime.now(UTC) - sig.timestamp) > cls.LEEWAY: raise SignatureVerificationError("expired signature") data = json.loads(payload) @@ -85,7 +85,7 @@ def parse_signature_header( valid = any(hmac.compare_digest(expected_sig, s) for s in signatures) return WebhookSignature( - timestamp=UTC.localize(datetime.fromtimestamp(timestamp)), + timestamp=datetime.fromtimestamp(timestamp, UTC), valid=valid, ) @@ -114,7 +114,7 @@ def generate_signature_header( ts: Optional[datetime] = None, ) -> str: if ts is None: - ts = UTC.localize(datetime.now()) + ts = datetime.now(UTC) ts_unix = ts.timestamp() data = "%d.%s" % (ts_unix, payload) sig = cls._compute_signature(data, signing_key) diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..5a4d3c7 --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,20 @@ +from datetime import datetime, timedelta, timezone + +from gradient_labs._http_client import HttpClient + +INSTANT = "2026-08-04T15:00:00.000000Z" + + +class TestLocalize: + def test_naive_is_treated_as_utc(self): + assert HttpClient.localize(datetime(2026, 8, 4, 15, 0, 0)) == INSTANT + + def test_aware_utc(self): + timestamp = datetime(2026, 8, 4, 15, 0, 0, tzinfo=timezone.utc) + assert HttpClient.localize(timestamp) == INSTANT + + def test_aware_offsets_are_converted_to_utc(self): + ahead = datetime(2026, 8, 4, 16, 0, 0, tzinfo=timezone(timedelta(hours=1))) + behind = datetime(2026, 8, 4, 10, 0, 0, tzinfo=timezone(timedelta(hours=-5))) + assert HttpClient.localize(ahead) == INSTANT + assert HttpClient.localize(behind) == INSTANT diff --git a/tests/test_outbound_conversation_start.py b/tests/test_outbound_conversation_start.py new file mode 100644 index 0000000..510da2e --- /dev/null +++ b/tests/test_outbound_conversation_start.py @@ -0,0 +1,185 @@ +from unittest.mock import MagicMock + +from gradient_labs import ( + Client, + CustomerSupportPlatformIdentifier, + CustomerSupportPlatformIdentifierType, + OutboundSupportPlatform, + StartOutboundChatConversationParams, + StartOutboundConversationResponse, + StartOutboundEmailConversationParams, + StartOutboundPhoneConversationParams, + SupportPlatform, +) + + +def _client_returning(response: dict): + client = Client(api_key="test-key") + post = MagicMock(return_value=response) + client.http_client.post = post + return client, post + + +def test_start_outbound_chat_conversation(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + rsp = client.start_outbound_chat_conversation( + params=StartOutboundChatConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + support_platform=OutboundSupportPlatform.INTERCOM, + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert kwargs["path"] == "outbound/conversations/chat" + assert body == { + "customer_id": "cust-456", + "procedure_id": "procedure-789", + "support_platform": "intercom", + } + + assert isinstance(rsp, StartOutboundConversationResponse) + assert rsp.conversation_id == "conv-123" + + +def test_start_outbound_chat_conversation_with_optional_fields(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + client.start_outbound_chat_conversation( + params=StartOutboundChatConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + support_platform=OutboundSupportPlatform.PUBLIC_API, + customer_support_platform_identifiers=[ + CustomerSupportPlatformIdentifier( + support_platform=SupportPlatform.INTERCOM, + type=CustomerSupportPlatformIdentifierType.INTERCOM_USER, + value="6953e162a988d9ef0f73ef9b", + ), + ], + body="Hi there!", + resources={"customer_profile": {"tier": "premium"}}, + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert body["support_platform"] == "public-api" + assert body["body"] == "Hi there!" + assert body["resources"] == {"customer_profile": {"tier": "premium"}} + identifiers = body["customer_support_platform_identifiers"] + assert identifiers[0]["support_platform"] == "intercom" + assert identifiers[0]["type"] == "intercom_user" + assert identifiers[0]["value"] == "6953e162a988d9ef0f73ef9b" + + +def test_start_outbound_email_conversation(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + rsp = client.start_outbound_email_conversation( + params=StartOutboundEmailConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + support_platform=OutboundSupportPlatform.ZENDESK, + customer_support_platform_identifiers=[ + CustomerSupportPlatformIdentifier( + support_platform=SupportPlatform.ZENDESK, + type=CustomerSupportPlatformIdentifierType.ZENDESK_SUPPORT_USER, + value="zd-42", + ), + ], + subject="Your order", + body="It has shipped.", + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert kwargs["path"] == "outbound/conversations/email" + assert body["customer_id"] == "cust-456" + assert body["procedure_id"] == "procedure-789" + assert body["support_platform"] == "zendesk" + assert body["subject"] == "Your order" + assert body["body"] == "It has shipped." + identifiers = body["customer_support_platform_identifiers"] + assert identifiers[0]["support_platform"] == "zendesk" + assert identifiers[0]["type"] == "zendesk_support_user" + assert identifiers[0]["value"] == "zd-42" + + assert rsp.conversation_id == "conv-123" + + +def test_start_outbound_email_conversation_omits_unset_subject_and_body(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + client.start_outbound_email_conversation( + params=StartOutboundEmailConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + support_platform=OutboundSupportPlatform.SALESFORCE, + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert "subject" not in body + assert "body" not in body + assert "customer_support_platform_identifiers" not in body + assert "resources" not in body + + +def test_start_outbound_phone_conversation(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + rsp = client.start_outbound_phone_conversation( + params=StartOutboundPhoneConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + to_phone_number="+14155551234", + from_phone_number="+14155559876", + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert kwargs["path"] == "outbound/conversations/phone" + assert body == { + "customer_id": "cust-456", + "procedure_id": "procedure-789", + "to_phone_number": "+14155551234", + "from_phone_number": "+14155559876", + } + assert "support_platform" not in body + + assert rsp.conversation_id == "conv-123" + + +def test_start_outbound_phone_conversation_with_optional_fields(): + client, post = _client_returning({"conversation_id": "conv-123"}) + + client.start_outbound_phone_conversation( + params=StartOutboundPhoneConversationParams( + customer_id="cust-456", + procedure_id="procedure-789", + to_phone_number="+14155551234", + from_phone_number="+14155559876", + customer_support_platform_identifiers=[ + CustomerSupportPlatformIdentifier( + support_platform=SupportPlatform.SALESFORCE, + type=CustomerSupportPlatformIdentifierType.SALESFORCE_CONTACT_ID, + value="003xx000004TmiQAAS", + ), + ], + resources={"order": {"id": "ord-1"}}, + ) + ) + + _, kwargs = post.call_args + body = kwargs["body"] + assert body["resources"] == {"order": {"id": "ord-1"}} + identifiers = body["customer_support_platform_identifiers"] + assert identifiers[0]["support_platform"] == "salesforce" + assert identifiers[0]["type"] == "salesforce_contact_id" + assert identifiers[0]["value"] == "003xx000004TmiQAAS" diff --git a/uv.lock b/uv.lock index 28d64b1..e36e574 100644 --- a/uv.lock +++ b/uv.lock @@ -128,7 +128,7 @@ wheels = [ [[package]] name = "gradient-labs" -version = "0.13.0" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "dataclasses-json" },