diff --git a/toolkits/salesforce/.pre-commit-config.yaml b/toolkits/salesforce/.pre-commit-config.yaml new file mode 100644 index 00000000..3953e996 --- /dev/null +++ b/toolkits/salesforce/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +files: ^./ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: "v4.4.0" + hooks: + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.7 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/toolkits/salesforce/.ruff.toml b/toolkits/salesforce/.ruff.toml new file mode 100644 index 00000000..f1aed90f --- /dev/null +++ b/toolkits/salesforce/.ruff.toml @@ -0,0 +1,46 @@ +target-version = "py310" +line-length = 100 +fix = true + +[lint] +select = [ + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-comprehensions + "C4", + # flake8-debugger + "T10", + # flake8-simplify + "SIM", + # isort + "I", + # mccabe + "C90", + # pycodestyle + "E", "W", + # pyflakes + "F", + # pygrep-hooks + "PGH", + # pyupgrade + "UP", + # ruff + "RUF", + # tryceratops + "TRY", +] + +[lint.per-file-ignores] +"*" = ["TRY003", "B904"] +"**/tests/*" = ["S101", "E501"] +"**/evals/*" = ["S101", "E501"] + +[format] +preview = true +skip-magic-trailing-comma = false diff --git a/toolkits/salesforce/LICENSE b/toolkits/salesforce/LICENSE new file mode 100644 index 00000000..45f53e20 --- /dev/null +++ b/toolkits/salesforce/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Arcade + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/toolkits/salesforce/Makefile b/toolkits/salesforce/Makefile new file mode 100644 index 00000000..5abc0e90 --- /dev/null +++ b/toolkits/salesforce/Makefile @@ -0,0 +1,58 @@ +.PHONY: help + +help: + @echo "🛠️ dropbox Commands:\n" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.PHONY: install +install: ## Install the poetry environment and install the pre-commit hooks + @echo "📦 Checking if Poetry is installed" + @if ! command -v poetry >/dev/null 2>&1; then \ + echo "📦 Poetry not found. Checking if pip is available"; \ + if ! command -v pip >/dev/null 2>&1; then \ + echo "❌ pip is not installed. Please install pip first."; \ + exit 1; \ + fi; \ + echo "📦 Installing Poetry with pip"; \ + pip install poetry==1.8.5; \ + else \ + echo "📦 Poetry is already installed"; \ + fi + @echo "🚀 Installing package in development mode with all extras" + poetry install --all-extras + +.PHONY: build +build: clean-build ## Build wheel file using poetry + @echo "🚀 Creating wheel file" + poetry build + +.PHONY: clean-build +clean-build: ## clean build artifacts + @echo "🗑️ Cleaning dist directory" + rm -rf dist + +.PHONY: test +test: ## Test the code with pytest + @echo "🚀 Testing code: Running pytest" + @poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml + +.PHONY: coverage +coverage: ## Generate coverage report + @echo "coverage report" + coverage report + @echo "Generating coverage report" + coverage html + +.PHONY: bump-version +bump-version: ## Bump the version in the pyproject.toml file + @echo "🚀 Bumping version in pyproject.toml" + poetry version patch + +.PHONY: check +check: ## Run code quality tools. + @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check" + @poetry check + @echo "🚀 Linting code: Running pre-commit" + @poetry run pre-commit run -a + @echo "🚀 Static type checking: Running mypy" + @poetry run mypy --config-file=pyproject.toml diff --git a/toolkits/salesforce/arcade_salesforce/__init__.py b/toolkits/salesforce/arcade_salesforce/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/salesforce/arcade_salesforce/constants.py b/toolkits/salesforce/arcade_salesforce/constants.py new file mode 100644 index 00000000..416db3e7 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/constants.py @@ -0,0 +1,33 @@ +import os + +SALESFORCE_API_VERSION = "v63.0" + +DEFAULT_MAX_CONCURRENT_REQUESTS = 3 +try: + MAX_CONCURRENT_REQUESTS = int( + os.getenv("ARCADE_SALESFORCE_MAX_CONCURRENT_REQUESTS", DEFAULT_MAX_CONCURRENT_REQUESTS) + ) +except ValueError: + MAX_CONCURRENT_REQUESTS = DEFAULT_MAX_CONCURRENT_REQUESTS + +ASSOCIATION_REFERENCE_FIELDS = [ + "AccountId", + "OwnerId", + "AssociatedToWhom", + "ContactId", +] + +GLOBALLY_IGNORED_FIELDS = [ + "attributes", + "CleanStatus", + "CreatedById", + "CreatedDate", + "IsDeleted", + "LastModifiedById", + "LastModifiedDate", + "LastReferencedDate", + "LastViewedDate", + "PhotoUrl", + "SystemModstamp", + "WhatId", +] diff --git a/toolkits/salesforce/arcade_salesforce/enums.py b/toolkits/salesforce/arcade_salesforce/enums.py new file mode 100644 index 00000000..fca01f16 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/enums.py @@ -0,0 +1,21 @@ +from enum import Enum +from typing import cast + + +class SalesforceObject(Enum): + ACCOUNT = "Account" + CALL = "Call" + CONTACT = "Contact" + EMAIL = "Email" + EVENT = "Event" + LEAD = "Lead" + NOTE = "Note" + OPPORTUNITY = "Opportunity" + TASK = "Task" + USER = "User" + + @property + def plural(self) -> str: + if self == SalesforceObject.OPPORTUNITY: + return "Opportunities" + return cast(str, self.value) + "s" diff --git a/toolkits/salesforce/arcade_salesforce/exceptions.py b/toolkits/salesforce/arcade_salesforce/exceptions.py new file mode 100644 index 00000000..fb7a1f45 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/exceptions.py @@ -0,0 +1,22 @@ +from arcade.sdk.errors import ToolExecutionError + + +class SalesforceToolExecutionError(ToolExecutionError): + def __init__(self, errors: list[str], message: str = "Failed to execute Salesforce tool"): + self.message = message + self.errors = errors + exc_message = f"{message}. Errors: {errors}" + super().__init__( + message=exc_message, + developer_message=exc_message, + ) + + +class ResourceNotFoundError(SalesforceToolExecutionError): + def __init__(self, errors: list[str]): + super().__init__(message="Resource not found", errors=errors) + + +class BadRequestError(SalesforceToolExecutionError): + def __init__(self, errors: list[str]): + super().__init__(message="Bad request", errors=errors) diff --git a/toolkits/salesforce/arcade_salesforce/models.py b/toolkits/salesforce/arcade_salesforce/models.py new file mode 100644 index 00000000..a85cc8c2 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/models.py @@ -0,0 +1,495 @@ +import asyncio +import os +from dataclasses import dataclass +from typing import Any, cast + +import httpx + +from arcade_salesforce.constants import MAX_CONCURRENT_REQUESTS, SALESFORCE_API_VERSION +from arcade_salesforce.enums import SalesforceObject +from arcade_salesforce.exceptions import ( + BadRequestError, + ResourceNotFoundError, + SalesforceToolExecutionError, +) +from arcade_salesforce.utils import ( + build_soql_query, + clean_contact_data, + clean_lead_data, + clean_note_data, + clean_object_data, + clean_opportunity_data, + clean_task_data, + expand_associations, + get_ids_referenced, + get_object_type, + remove_none_values, +) + + +@dataclass +class SalesforceClient: + auth_token: str + org_domain: str | None = None + api_version: str = SALESFORCE_API_VERSION + max_concurrent_requests: int = MAX_CONCURRENT_REQUESTS + + # Internal state properties + _state_object_fields: dict[SalesforceObject, list[str]] | None = None + _state_is_person_account_enabled: bool | None = None + _semaphore: asyncio.Semaphore | None = None + + def __post_init__(self) -> None: + if self.org_domain is None: + self.org_domain = os.getenv("SALESFORCE_ORG_DOMAIN") + if self.org_domain is None: + raise ValueError( + "Either `org_domain` argument or `SALESFORCE_ORG_DOMAIN` env var must be set" + ) + + if self._state_object_fields is None: + self._state_object_fields = {} + + if self._semaphore is None: + self._semaphore = asyncio.Semaphore(self.max_concurrent_requests) + + @property + def _base_url(self) -> str: + return f"https://{self.org_domain}.my.salesforce.com/services/data/{self.api_version}" + + @property + def object_fields(self) -> dict[SalesforceObject, list[str]]: + return cast(dict, self._state_object_fields) + + def _endpoint_url(self, endpoint: str) -> str: + return f"{self._base_url}/{endpoint.lstrip('/')}" + + def _build_headers(self, headers: dict | None = None) -> dict: + headers = headers or {} + headers["Authorization"] = f"Bearer {self.auth_token}" + return headers + + def _raise_salesforce_error(self, response: httpx.Response) -> None: + """Raise a ToolExecutionError based on the Salesforce API response status code.""" + errors = [error.get("message") for error in response.json()] + if response.status_code == 404: + raise ResourceNotFoundError(errors) + elif response.status_code == 400: + raise BadRequestError(errors) + raise SalesforceToolExecutionError(errors) + + async def get( + self, + endpoint: str, + params: dict | None = None, + headers: dict | None = None, + ) -> dict: + """Make a GET request to the Salesforce API. + + Args: + endpoint: The Salesforce API endpoint to call. + params: The query parameters to include in the request. + headers: The headers to include in the request. + + Returns: + The JSON-loaded representation of the response from the Salesforce API. + """ + kwargs: dict[str, Any] = { + "url": self._endpoint_url(endpoint), + "headers": self._build_headers(headers), + } + if params: + kwargs["params"] = params + + async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] + response = await client.get(**kwargs) + + if response.status_code >= 300: + self._raise_salesforce_error(response) + + return cast(dict, response.json()) + + async def post( + self, + endpoint: str, + data: dict | None = None, + json_data: dict | None = None, + headers: dict | None = None, + ) -> dict: + """Make a POST request to the Salesforce API. + + Args: + endpoint: The Salesforce API endpoint to call. + data: The data for the request. (provide data or json_data, not both) + json_data: The JSON data for the request. (provide data or json_data, not both) + headers: The headers to include in the request. + + Returns: + The JSON-loaded representation of the response from the Salesforce API. + """ + kwargs: dict[str, Any] = { + "url": self._endpoint_url(endpoint), + "headers": self._build_headers(headers), + } + if data: + kwargs["data"] = data + if json_data: + kwargs["json"] = json_data + + async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] + response = await client.post(**kwargs) + + if response.status_code >= 300: + self._raise_salesforce_error(response) + + return cast(dict, response.json()) + + async def get_object_fields(self, object_type: SalesforceObject) -> list[str]: + """Get the fields available for a Salesforce object. + + Args: + object_type: The Salesforce object to get the fields for. + + Returns: + The list of fields available for a Salesforce object. + """ + if object_type not in self.object_fields: + response = await self._describe_object(object_type) + self.object_fields[object_type] = [field["name"] for field in response["fields"]] + + return self.object_fields[object_type] + + async def _describe_object(self, object_type: SalesforceObject) -> dict: + return await self.get(f"sobjects/{object_type.value}/describe/") + + async def _get_related_objects( + self, + child_object_type: SalesforceObject, + parent_object_type: SalesforceObject, + parent_object_id: str, + limit: int | None = 10, + ) -> list[dict]: + """Get the objects that are associated with another Salesforce object. + + Args: + child_object_type: The type of child object to retrieve. + parent_object_type: The type of parent object. + parent_object_id: The ID of the parent object. + limit: The maximum number of related objects to retrieve. + + Returns: + The list of related objects. + """ + try: + response = await self.get( + f"sobjects/{parent_object_type.value}/{parent_object_id}/{child_object_type.plural.lower()}", + params={"limit": limit}, + ) + return cast(list, response["records"]) + except ResourceNotFoundError: + return [] + + async def get_object_by_id(self, object_id: str) -> dict | None: + """Get a Salesforce object by its ID. + + Args: + object_id: The ID of the Salesforce object to retrieve. + + Returns: + The Salesforce object. + """ + try: + response = await self.get(f"sobjects/{object_id}") + return clean_object_data(response) + except ResourceNotFoundError: + if "User" not in object_id: + return await self.get_object_by_id(f"User/{object_id}") + return None + + async def get_account(self, account_id: str) -> dict[str, Any] | None: + """Get an account by its ID. + + Args: + account_id: The ID of the account to retrieve. + + Returns: + The account. + """ + try: + return cast(dict, await self.get(f"sobjects/Account/{account_id}")) + except ResourceNotFoundError: + return None + + async def get_account_contacts(self, account_id: str, limit: int | None = 10) -> list[dict]: + """Get the contacts associated with an account. + + Args: + account_id: The ID of the account to retrieve the contacts for. + limit: The maximum number of contacts to retrieve. + + Returns: + The list of contacts with cleaned and standardized dictionaries. + """ + contacts = await self._get_related_objects( + SalesforceObject.CONTACT, SalesforceObject.ACCOUNT, account_id, limit + ) + + return [ + clean_contact_data(contact) + for contact in await asyncio.gather(*[ + self.enrich_contact_with_notes(contact, limit) for contact in contacts + ]) + ] + + async def enrich_contact_with_notes(self, contact: dict, limit: int | None = 10) -> dict: + """Get the notes associated with a contact and add to the contact dictionary. + + Args: + contact: The contact to retrieve the notes for. + limit: The maximum number of notes to retrieve. + + Returns: + The contact with the notes added to the dictionary in the key `Notes`. + """ + notes = await self.get_notes(contact["Id"], limit) + if notes: + contact["Notes"] = notes + return contact + + async def get_account_leads(self, account_id: str, limit: int | None = 10) -> list[dict]: + """Get the leads associated with an account. + + Args: + account_id: The ID of the account to retrieve the leads for. + limit: The maximum number of leads to retrieve. + + Returns: + The list of leads with cleaned and standardized dictionaries. + """ + leads = await self._get_related_objects( + SalesforceObject.LEAD, SalesforceObject.ACCOUNT, account_id, limit + ) + return [clean_lead_data(lead) for lead in leads] + + async def get_notes(self, parent_id: str, limit: int | None = 10) -> list[dict]: + """Get the notes associated with a Salesforce object. + + Args: + parent_id: The ID of the Salesforce object to retrieve the notes for. + limit: The maximum number of notes to retrieve. + + Returns: + The list of notes with cleaned and standardized dictionaries. + """ + query = build_soql_query( + "SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate " + "FROM Note " + "WHERE ParentId = '{parent_id}' " + "LIMIT {limit}", + parent_id=parent_id, + limit=limit, + ) + response = await self.get("query", params={"q": query}) + notes = response["records"] + return [clean_note_data(note) for note in notes] + + # TODO: Add support for retrieving Currency, when enabled in the org account. + # If not enabled and we try to retrieve it, we get a 400 error. + # More inf: https://developer.salesforce.com/docs/atlas.en-us.254.0.object_reference.meta/object_reference/sforce_api_objects_opportunity.htm#i1455437 + async def get_account_opportunities( + self, + account_id: str, + limit: int | None = 10, + ) -> list[dict]: + """Get the opportunities associated with an account. + + Args: + account_id: The ID of the account to retrieve the opportunities for. + limit: The maximum number of opportunities to retrieve. + + Returns: + The list of opportunities with cleaned and standardized dictionaries. + """ + query = build_soql_query( + "SELECT Id, Name, Type, StageName, OwnerId, CreatedById, LastModifiedById, " + "Description, Amount, Probability, ExpectedRevenue, CloseDate, ContactId " + "FROM Opportunity " + "WHERE AccountId = '{account_id}' " + "LIMIT {limit}", + account_id=account_id, + limit=limit, + ) + response = await self.get("query", params={"q": query}) + opportunities = response["records"] + return [clean_opportunity_data(opportunity) for opportunity in opportunities] + + async def get_account_tasks( + self, + account_id: str, + limit: int | None = 10, + ) -> list[dict]: + """Get the tasks associated with an account. + + Args: + account_id: The ID of the account to retrieve the tasks for. + limit: The maximum number of tasks to retrieve. + + Returns: + The list of tasks with cleaned and standardized dictionaries. + """ + tasks = await self._get_related_objects( + SalesforceObject.TASK, SalesforceObject.ACCOUNT, account_id, limit + ) + return [clean_task_data(task) for task in tasks] + + async def enrich_account( + self, + account_id: str | None = None, + account_data: dict[str, Any] | None = None, + limit_per_association: int | None = 10, + ) -> dict: + """Enrich account dictionary with contacts, leads, opportunities, and tasks. + + Provide `account_id` or `account_data`, but not both. + + Args: + account_id: The ID of the account to retrieve the associations for. + account_data: The account data to enrich. + limit_per_association: The maximum number of associations to retrieve. + + Returns: + The account with the associations added to the dictionary. + """ + if (account_id and account_data) or (not account_id and not account_data): + raise ValueError("Must provide exactly one of `account_id` or `account_data`") + + if account_data is None: + account_data = await self.get_account(cast(str, account_id)) + + if not account_data: + raise ResourceNotFoundError([f"Account not found with ID: {account_id}"]) + + if not account_id: + account_id = cast(str, account_data["Id"]) + + associations = await asyncio.gather( + self.get_account_contacts(account_id, limit=limit_per_association), + self.get_account_leads(account_id, limit=limit_per_association), + self.get_account_opportunities(account_id, limit=limit_per_association), + self.get_account_tasks(account_id, limit=limit_per_association), + ) + + for association in associations: + for item in association: + try: + obj_type = SalesforceObject(get_object_type(item)).plural + except ValueError: + obj_type = get_object_type(item) + "s" + + if obj_type not in account_data: + account_data[obj_type] = [] + account_data[obj_type].append(item) + + return await self.expand_account_associations(account_data) + + async def expand_account_associations(self, account: dict) -> dict: + """Expand with metadata about objects referenced by ID in an account dictionary. + + This method will, for example, expand an `OwnerId` or `ContactId` referenced in an account + dictionary with the owner or contact name, for example. + + Args: + account: The account dictionary to expand. + + Returns: + The account with the associations expanded. + """ + objects_by_id = { + obj["Id"]: obj + for obj_type in SalesforceObject + for obj in account.get(obj_type.plural, []) + } + objects_by_id[account["Id"]] = account + + referenced_ids = get_ids_referenced( + account, + *[account.get(obj_type.plural, []) for obj_type in SalesforceObject], + ) + + missing_referenced_ids = [ref for ref in referenced_ids if ref not in objects_by_id] + + if missing_referenced_ids: + missing_objects = await asyncio.gather(*[ + self.get_object_by_id(missing_id) for missing_id in missing_referenced_ids + ]) + objects_by_id.update({obj["Id"]: obj for obj in missing_objects if obj is not None}) + + account = expand_associations(account, objects_by_id) + + for object_type in SalesforceObject: + if object_type.plural not in account: + continue + + expanded_items = [] + + for item in account[object_type.plural]: + if "AccountId" in item: + del item["AccountId"] + + expanded_items.append(expand_associations(item, objects_by_id)) + + if object_type == SalesforceObject.CONTACT: + for contact in expanded_items: + if "Notes" in contact: + contact["Notes"] = [ + expand_associations(note, objects_by_id) for note in contact["Notes"] + ] + + account[object_type.plural] = expanded_items + + return account + + async def create_contact( + self, + account_id: str, + last_name: str, + first_name: str | None = None, + email: str | None = None, + phone: str | None = None, + mobile_phone: str | None = None, + title: str | None = None, + department: str | None = None, + description: str | None = None, + ) -> dict: + """Create a contact in Salesforce. + + Args: + account_id: The ID of the account to associate the contact with. + last_name: The last name of the contact. + first_name: The first name of the contact. + email: The email of the contact. + phone: The phone number of the contact. + mobile_phone: The mobile phone number of the contact. + title: The title of the contact. + department: The department of the contact. + description: The description of the contact. + + Returns: + The created contact. + """ + data = { + "AccountId": account_id, + "FirstName": first_name, + "LastName": last_name, + "Email": email, + "Phone": phone, + "MobilePhone": mobile_phone, + "Title": title, + "Department": department, + "Description": description, + } + + return await self.post( + f"sobjects/{SalesforceObject.CONTACT.value}", + json_data=remove_none_values(data), + ) diff --git a/toolkits/salesforce/arcade_salesforce/tools/__init__.py b/toolkits/salesforce/arcade_salesforce/tools/__init__.py new file mode 100644 index 00000000..dff20713 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/tools/__init__.py @@ -0,0 +1,11 @@ +from arcade_salesforce.tools.crm.account import ( + get_account_data_by_id, + get_account_data_by_keywords, +) +from arcade_salesforce.tools.crm.contact import create_contact + +__all__ = [ + "create_contact", + "get_account_data_by_id", + "get_account_data_by_keywords", +] diff --git a/toolkits/salesforce/arcade_salesforce/tools/crm/account.py b/toolkits/salesforce/arcade_salesforce/tools/crm/account.py new file mode 100644 index 00000000..95416832 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/tools/crm/account.py @@ -0,0 +1,126 @@ +import asyncio +from typing import Annotated + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 +from arcade.sdk.errors import ToolExecutionError + +from arcade_salesforce.enums import SalesforceObject +from arcade_salesforce.models import SalesforceClient +from arcade_salesforce.utils import clean_account_data + + +# TODO: We only return up to 10 items of each related object (e.g. contacts). Need to implement +# separate tools for each related object, so that we can return more items, when needed. +@tool( + requires_auth=OAuth2( + id="arcade-salesforce", + scopes=[ + "read_account", + "read_contact", + "read_lead", + "read_note", + "read_opportunity", + "read_task", + ], + ) +) +async def get_account_data_by_keywords( + context: ToolContext, + query: Annotated[ + str, + "The query to search for accounts. MUST be longer than one character. It will match the " + "keywords against all account fields, such as name, website, phone, address, etc. " + "E.g. 'Acme'", + ], + # Note: Salesforce supports up to 200 results, but since we're enriching each account with + # related objects, we limit to 10, so that the response is not too lengthy for LLMs. + limit: Annotated[ + int, + "The maximum number of accounts to return. Defaults to 10. Maximum allowed is 10.", + ] = 10, + page: Annotated[int, "The page number to return. Defaults to 1 (first page of results)."] = 1, +) -> Annotated[ + dict, + "The accounts matching the query with related info: contacts, leads, notes, calls, " + "opportunities, tasks, emails, and events (up to 10 items of each type)", +]: + """Searches for accounts in Salesforce and returns them with related info: contacts, leads, + notes, calls, opportunities, tasks, emails, and events (up to 10 items of each type). + + An account is an organization (such as a customer, supplier, or partner, though more commonly + a customer). In some Salesforce account setups, an account can also represent a person. + """ + if len(query) < 2: + raise ToolExecutionError("The `query` argument must have two or more characters.") + + limit = min(limit, 10) + + client = SalesforceClient(context.get_auth_token_or_empty()) + + params = { + "q": query, + "sobjects": [ + { + "name": "Account", + "fields": await client.get_object_fields(SalesforceObject.ACCOUNT), + } + ], + "in": "ALL", + "overallLimit": limit, + "offset": (page - 1) * limit, + } + response = await client.post("parameterizedSearch", json_data=params) + search_results = response["searchRecords"] + + accounts = await asyncio.gather(*[ + client.enrich_account( + account_data=account, + limit_per_association=10, + ) + for account in search_results + ]) + return {"accounts": [clean_account_data(account) for account in accounts]} + + +@tool( + requires_auth=OAuth2( + id="arcade-salesforce", + scopes=[ + "read_account", + "read_contact", + "read_lead", + "read_note", + "read_opportunity", + "read_task", + ], + ) +) +async def get_account_data_by_id( + context: ToolContext, + account_id: Annotated[ + str, + "The ID of the account to get data for.", + ], +) -> Annotated[ + dict, + "The account with related info: contacts, leads, notes, calls, opportunities, tasks, emails, " + "and events (up to 10 items of each type)", +]: + """Gets the account with related info: contacts, leads, notes, calls, opportunities, tasks, + emails, and events (up to 10 items of each type). + + An account is an organization (such as a customer, supplier, or partner, though more commonly + a customer). In some Salesforce account setups, an account can also represent a person. + """ + client = SalesforceClient(context.get_auth_token_or_empty()) + + account = await client.get_account(account_id) + + if not account: + return {"account": None, "error": f"No account found with id '{account_id}'"} + + account = await client.enrich_account(account_data=account) + account = clean_account_data(account) + + return {"account": account} diff --git a/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py b/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py new file mode 100644 index 00000000..2095a62e --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/tools/crm/contact.py @@ -0,0 +1,64 @@ +from typing import Annotated + +from arcade.sdk import ToolContext, tool +from arcade.sdk.auth import OAuth2 +from arcade.sdk.errors import ToolExecutionError + +from arcade_salesforce.exceptions import SalesforceToolExecutionError +from arcade_salesforce.models import SalesforceClient + + +# TODO: Add support for referencing an account by keywords (name, website, etc). We can use the +# get_account_data_by_keywords tool to search for accounts. If more than one is returned, we can +# raise a RetryableToolError providing the matches for the user to choose. +@tool( + requires_auth=OAuth2( + id="arcade-salesforce", + scopes=["write_contact"], + ) +) +async def create_contact( + context: ToolContext, + account_id: Annotated[str, "The ID of the account to create the contact for."], + last_name: Annotated[str, "The last name of the contact."], + first_name: Annotated[str | None, "The first name of the contact."] = None, + email: Annotated[str | None, "The email of the contact."] = None, + phone: Annotated[str | None, "The phone number of the contact."] = None, + mobile_phone: Annotated[str | None, "The mobile phone number of the contact."] = None, + title: Annotated[ + str | None, + "The title of the contact. E.g. 'CEO', 'Sales Director', 'CTO', etc.", + ] = None, + department: Annotated[ + str | None, + "The department of the contact. E.g. 'Marketing', 'Sales', 'IT', etc.", + ] = None, + description: Annotated[str | None, "The description of the contact."] = None, +) -> Annotated[ + dict, + "The created contact.", +]: + """Creates a contact in Salesforce.""" + if not last_name: + raise ToolExecutionError("Last name is required by Salesforce to create a contact.") + + client = SalesforceClient(context.get_auth_token_or_empty()) + contact = await client.create_contact( + account_id=account_id, + first_name=first_name, + last_name=last_name, + email=email, + phone=phone, + mobile_phone=mobile_phone, + title=title, + department=department, + description=description, + ) + + if not contact.get("success"): + raise SalesforceToolExecutionError( + message="Failed to create contact", + errors=contact.get("errors", []), + ) + + return {"success": True, "contactId": contact.get("id")} diff --git a/toolkits/salesforce/arcade_salesforce/utils.py b/toolkits/salesforce/arcade_salesforce/utils.py new file mode 100644 index 00000000..3ae200c4 --- /dev/null +++ b/toolkits/salesforce/arcade_salesforce/utils.py @@ -0,0 +1,279 @@ +import string +from collections.abc import Callable +from typing import Any, cast + +from arcade_salesforce.constants import ASSOCIATION_REFERENCE_FIELDS, GLOBALLY_IGNORED_FIELDS +from arcade_salesforce.enums import SalesforceObject + + +def remove_fields_globally_ignored(clean_func: Callable[[dict], dict]) -> Callable[[dict], dict]: + """Remove fields that are irrelevant for our tools' responses. + + The main purpose of cleaning and removing unnecessary fields from data is to make our + responses more concise and save on LLM tokens/context window. + """ + + def global_cleaner(data: dict) -> dict: + cleaned_data = {} + for key, value in data.items(): + if key in GLOBALLY_IGNORED_FIELDS or value is None: + continue + + if isinstance(value, dict): + cleaned_data[key] = global_cleaner(value) + + elif isinstance(value, list | tuple | set): + cleaned_items = [] + for item in value: + if isinstance(item, dict): + cleaned_items.append(global_cleaner(item)) + else: + cleaned_items.append(item) + cleaned_data[key] = cleaned_items # type: ignore[assignment] + else: + cleaned_data[key] = value + return cleaned_data + + def wrapper(data: dict) -> dict: + return global_cleaner(clean_func(data)) + + return wrapper + + +def clean_object_data(data: dict) -> dict: + """Clean Salesforce object dictionary based on the object type.""" + + obj_type = data["attributes"]["type"] + if obj_type == SalesforceObject.ACCOUNT.value: + return clean_account_data(data) + elif obj_type == SalesforceObject.CONTACT.value: + return clean_contact_data(data) + elif obj_type == SalesforceObject.LEAD.value: + return clean_lead_data(data) + elif obj_type == SalesforceObject.NOTE.value: + return clean_note_data(data) + elif obj_type == SalesforceObject.TASK.value: + return clean_task_data(data) + elif obj_type == SalesforceObject.USER.value: + return clean_user_data(data) + raise ValueError(f"Unknown object type: '{obj_type}' in object: {data}") + + +@remove_fields_globally_ignored +def clean_account_data(data: dict) -> dict: + data["AccountType"] = data["Type"] + del data["Type"] + data["ObjectType"] = SalesforceObject.ACCOUNT.value + ignore_fields = [ + "BillingCity", + "BillingCountry", + "BillingCountryCode", + "BillingPostalCode", + "BillingState", + "BillingStateCode", + "BillingStreet", + "LastActivityDate", + "ShippingCity", + "ShippingCountry", + "ShippingCountryCode", + "ShippingPostalCode", + "ShippingState", + "ShippingStateCode", + "ShippingStreet", + ] + return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} + + +@remove_fields_globally_ignored +def clean_contact_data(data: dict) -> dict: + data["ObjectType"] = SalesforceObject.CONTACT.value + ignore_fields = ["IsEmailBounced", "IsPriorityRecord"] + return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} + + +@remove_fields_globally_ignored +def clean_lead_data(data: dict) -> dict: + data["ObjectType"] = SalesforceObject.LEAD.value + return data + + +@remove_fields_globally_ignored +def clean_opportunity_data(data: dict) -> dict: + data["ObjectType"] = SalesforceObject.OPPORTUNITY.value + data["Amount"] = { + "Value": data["Amount"], + "ClosingProbability": data["Probability"] + if not isinstance(data["Probability"], int | float) + else data["Probability"] / 100, + "ExpectedRevenue": data["ExpectedRevenue"], + } + del data["Probability"] + del data["ExpectedRevenue"] + return data + + +@remove_fields_globally_ignored +def clean_note_data(data: dict) -> dict: + data["ObjectType"] = SalesforceObject.NOTE.value + return data + + +@remove_fields_globally_ignored +def clean_task_data(data: dict) -> dict: + data["ObjectType"] = data["TaskSubtype"] + data["AssociatedToWhom"] = data["WhoId"] + ignore_fields = [ + "IsArchived", + "IsClosed", + "IsDeleted", + "IsHighPriority", + "IsRecurrence", + "IsReminderSet", + "TaskSubtype", + "WhoId", + ] + + if data["ObjectType"] == SalesforceObject.EMAIL.value: + data["Email"] = format_email(data["Description"]) + del data["ActivityDate"] + del data["CompletedDateTime"] + del data["Description"] + del data["Subject"] + del data["OwnerId"] + del data["Priority"] + del data["Status"] + + return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} + + +@remove_fields_globally_ignored +def clean_user_data(data: dict) -> dict: + data["ObjectType"] = SalesforceObject.USER.value + ignore_fields = [ + "attributes", + "CleanStatus", + "LastReferencedDate", + "LastViewedDate", + "SystemModstamp", + ] + return {k: v for k, v in data.items() if v is not None and k not in ignore_fields} + + +def get_ids_referenced(*data_objects: dict) -> set[str]: + """Build a set of IDs referenced in a list of dictionaries. + + Args: + data_objects: The list of dictionaries to search for referenced IDs. + + Returns: + The set of IDs referenced in the dictionaries. + """ + referenced_ids = set() + for data in data_objects: + for field in ASSOCIATION_REFERENCE_FIELDS: + if field in data: + referenced_ids.add(data[field]) + return referenced_ids + + +def expand_associations(data: dict, objects_by_id: dict) -> dict: + """Expand a Salesforce object referenced by ID with metadata about the object. + + Args: + data: The dictionary to expand. + objects_by_id: The dictionary of objects metadata by ID. + + Returns: + The expanded dictionary. + """ + for field in ASSOCIATION_REFERENCE_FIELDS: + if field not in data: + continue + + associated_object = objects_by_id.get(data[field]) + + if isinstance(associated_object, dict): + del data[field] + data[field.rstrip("Id")] = simplified_object_data(associated_object) + + return data + + +def simplified_object_data(data: dict) -> dict: + return { + "Id": data["Id"], + "Name": data["Name"], + "ObjectType": get_object_type(data), + } + + +def get_object_type(data: dict) -> str: + obj_type = data.get("ObjectType") or data["attributes"]["type"] + return cast(str, obj_type) + + +def build_soql_query(query: str, **kwargs: Any) -> str: + """Build a SOQL query with sanitized arguments. + + Args: + query: The SOQL query to build. + **kwargs: The arguments to sanitize and format into the query. + + Returns: + The SOQL query with the arguments formatted. + """ + return query.format(**{key: sanitize_soql_argument(value) for key, value in kwargs.items()}) + + +def sanitize_soql_argument(value: Any) -> str: + """Sanitize an argument for a SOQL query. + + The goal is to prevent SQL injection, since Salesforce does not support parameterized queries. + This function will only accept ASCII letters and digits in the value. + + Args: + value: The value to sanitize. + + Returns: + The sanitized value. + """ + allowed_chars = string.ascii_letters + string.digits + if not isinstance(value, str): + value = str(value) + return "".join([char for char in value if char in allowed_chars]) + + +def format_email(description: str) -> dict: + """Turns a Salesforce email description string into a more readable dictionary. + + Args: + description: The email description to format. + + Returns: + The formatted email as a dictionary. + """ + email = { + "To": description.split("To:")[1].split("\n")[0].strip(), + "CC": description.split("CC:")[1].split("\n")[0].strip(), + "BCC": description.split("BCC:")[1].split("\n")[0].strip(), + "Attachment": description.split("Attachment:")[1].split("\n")[0].strip(), + "Subject": description.split("Subject:")[1].split("\n")[0].strip(), + "Body": description.split("Body:\n")[1].strip(), + } + + if email["Attachment"] == "--none--": + del email["Attachment"] + + return email + + +def remove_none_values(data: dict) -> dict: + """Remove None values from a dictionary. + + Args: + data: The dictionary to clean. + + Returns: + The cleaned dictionary. + """ + return {k: v for k, v in data.items() if v is not None} diff --git a/toolkits/salesforce/conftest.py b/toolkits/salesforce/conftest.py new file mode 100644 index 00000000..d3dc1f8f --- /dev/null +++ b/toolkits/salesforce/conftest.py @@ -0,0 +1,16 @@ +from unittest.mock import patch + +import pytest +from arcade.sdk import ToolAuthorizationContext, ToolContext + + +@pytest.fixture +def mock_context(): + mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106 + return ToolContext(authorization=mock_auth) + + +@pytest.fixture +def mock_httpx_client(): + with patch("arcade_salesforce.models.httpx") as mock_httpx: + yield mock_httpx.AsyncClient().__aenter__.return_value diff --git a/toolkits/salesforce/evals/eval_create_contact.py b/toolkits/salesforce/evals/eval_create_contact.py new file mode 100644 index 00000000..93c72253 --- /dev/null +++ b/toolkits/salesforce/evals/eval_create_contact.py @@ -0,0 +1,102 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_salesforce +from arcade_salesforce.tools import create_contact + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_salesforce) + + +@tool_eval() +def create_contact_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="create contact eval suite", + system_message=( + "You are an AI assistant with access to Salesforce tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Create contact", + user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Jenifer Bear and email jenifer@acme.net.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_contact, + args={ + "account_id": "001gK000003DIn0QAG", + "last_name": "Bear", + "first_name": "Jenifer", + "email": "jenifer@acme.net", + "phone": None, + "mobile_phone": None, + "title": None, + "department": None, + "description": None, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="account_id", weight=0.2), + BinaryCritic(critic_field="last_name", weight=0.1), + BinaryCritic(critic_field="first_name", weight=0.1), + BinaryCritic(critic_field="email", weight=0.1), + BinaryCritic(critic_field="phone", weight=0.1), + BinaryCritic(critic_field="mobile_phone", weight=0.1), + BinaryCritic(critic_field="title", weight=0.1), + BinaryCritic(critic_field="department", weight=0.1), + BinaryCritic(critic_field="description", weight=0.1), + ], + ) + + suite.add_case( + name="Create contact with only last name", + user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Doe and email doe@acme.net.", + expected_tool_calls=[ + ExpectedToolCall( + func=create_contact, + args={ + "account_id": "001gK000003DIn0QAG", + "last_name": "Doe", + "first_name": None, + "email": "doe@acme.net", + "phone": None, + "mobile_phone": None, + "title": None, + "department": None, + "description": None, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="account_id", weight=0.2), + BinaryCritic(critic_field="last_name", weight=0.1), + BinaryCritic(critic_field="first_name", weight=0.1), + BinaryCritic(critic_field="email", weight=0.1), + BinaryCritic(critic_field="phone", weight=0.1), + BinaryCritic(critic_field="mobile_phone", weight=0.1), + BinaryCritic(critic_field="title", weight=0.1), + BinaryCritic(critic_field="department", weight=0.1), + BinaryCritic(critic_field="description", weight=0.1), + ], + ) + + return suite diff --git a/toolkits/salesforce/evals/eval_get_account_data.py b/toolkits/salesforce/evals/eval_get_account_data.py new file mode 100644 index 00000000..0fbe4b8c --- /dev/null +++ b/toolkits/salesforce/evals/eval_get_account_data.py @@ -0,0 +1,222 @@ +from arcade.sdk import ToolCatalog +from arcade.sdk.eval import ( + EvalRubric, + EvalSuite, + ExpectedToolCall, + tool_eval, +) +from arcade.sdk.eval.critic import BinaryCritic + +import arcade_salesforce +from arcade_salesforce.tools import get_account_data_by_id, get_account_data_by_keywords + +# Evaluation rubric +rubric = EvalRubric( + fail_threshold=0.85, + warn_threshold=0.95, +) + + +catalog = ToolCatalog() +catalog.add_module(arcade_salesforce) + + +@tool_eval() +def get_account_data_by_keywords_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get_account_data_by_keywords", + system_message=( + "You are an AI assistant with access to Salesforce tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get account data by keywords", + user_message="Get information about the Acme account.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Get account data by keywords with limit", + user_message="Get information of up to 3 accounts with the word 'Acme' in their name.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "limit": 3, + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=1 / 3), + BinaryCritic(critic_field="limit", weight=1 / 3), + BinaryCritic(critic_field="page", weight=1 / 3), + ], + ) + + suite.add_case( + name="Get summary of account activity", + user_message="Get a summary of the latest activities in the Acme account.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Interactions with contacts of an account", + user_message="Get me the interactions with the contacts of the Acme account.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Emails or calls with contacts of an account", + user_message="Were there any emails or calls with contacts of the Acme account this week?", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Get account status", + user_message="What's the status of the Acme account?", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Get overdue tasks", + user_message="Are there any tasks overdue for the Acme account?", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + suite.add_case( + name="Get account closing likelihood", + user_message="What's the likelihood of the Acme account closing a deal?", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_keywords, + args={ + "query": "Acme", + "page": 1, + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="query", weight=0.5), + BinaryCritic(critic_field="page", weight=0.5), + ], + ) + + return suite + + +@tool_eval() +def get_account_data_by_id_eval_suite() -> EvalSuite: + suite = EvalSuite( + name="get_account_data_by_id", + system_message=( + "You are an AI assistant with access to Salesforce tools. " + "Use them to help the user with their tasks." + ), + catalog=catalog, + rubric=rubric, + ) + + suite.add_case( + name="Get account data by ID", + user_message="Get information about the account with ID 001gK000003DIn0QAG.", + expected_tool_calls=[ + ExpectedToolCall( + func=get_account_data_by_id, + args={ + "account_id": "001gK000003DIn0QAG", + }, + ), + ], + rubric=rubric, + critics=[ + BinaryCritic(critic_field="account_id", weight=1.0), + ], + ) + + return suite diff --git a/toolkits/salesforce/pyproject.toml b/toolkits/salesforce/pyproject.toml new file mode 100644 index 00000000..27eb7175 --- /dev/null +++ b/toolkits/salesforce/pyproject.toml @@ -0,0 +1,42 @@ +[tool.poetry] +name = "arcade_salesforce" +version = "0.1.0" +description = "Arcade tools designed for LLMs to interact with Salesforce" +authors = ["Arcade "] + +[tool.poetry.dependencies] +python = "^3.10" +arcade-ai = ">=1.0.5,<2.0" +httpx = "^0.27.2" + +[tool.poetry.dev-dependencies] +pytest = "^8.3.0" +pytest-cov = "^4.0.0" +pytest-asyncio = "^0.24.0" +pytest-mock = "^3.11.1" +mypy = "^1.5.1" +pre-commit = "^3.4.0" +tox = "^4.11.1" +ruff = "^0.7.4" + +[build-system] +requires = ["poetry-core>=1.0.0,<2.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.mypy] +files = ["arcade_salesforce/**/*.py"] +python_version = "3.10" +disallow_untyped_defs = "True" +disallow_any_unimported = "True" +no_implicit_optional = "True" +check_untyped_defs = "True" +warn_return_any = "True" +warn_unused_ignores = "True" +show_error_codes = "True" +ignore_missing_imports = "True" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.report] +skip_empty = true diff --git a/toolkits/salesforce/tests/test_create_contact.py b/toolkits/salesforce/tests/test_create_contact.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/salesforce/tests/test_get_account_data.py b/toolkits/salesforce/tests/test_get_account_data.py new file mode 100644 index 00000000..e69de29b diff --git a/toolkits/salesforce/tests/test_salesforce_client.py b/toolkits/salesforce/tests/test_salesforce_client.py new file mode 100644 index 00000000..0ff0b7fe --- /dev/null +++ b/toolkits/salesforce/tests/test_salesforce_client.py @@ -0,0 +1,270 @@ +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from arcade_salesforce.constants import SALESFORCE_API_VERSION +from arcade_salesforce.enums import SalesforceObject +from arcade_salesforce.exceptions import ResourceNotFoundError, SalesforceToolExecutionError +from arcade_salesforce.models import SalesforceClient + + +@pytest.mark.asyncio +async def test_get_account_success(mock_context, mock_httpx_client): + account_id = "001gK000003DIn0QAG" + account = { + "attributes": { + "type": "Account", + }, + "Id": account_id, + "Name": "Acme, Inc.", + } + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = account + mock_httpx_client.get.return_value = mock_response + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + response = await client.get_account(account_id) + + assert response == account + + mock_httpx_client.get.assert_called_once_with( + url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Account/{account_id}", + headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, + ) + + +@pytest.mark.asyncio +async def test_get_account_not_found_error(mock_context, mock_httpx_client): + account_id = "001gK000003DIn0QAG" + response_data = [{"message": "Account not found"}] + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 404 + mock_response.json.return_value = response_data + mock_httpx_client.get.return_value = mock_response + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + response = await client.get_account(account_id) + assert response is None + + +@pytest.mark.asyncio +async def test_get_account_bad_request_error(mock_context, mock_httpx_client): + account_id = "001gK000003DIn0QAG" + response_data = [{"message": "Bad request"}] + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 400 + mock_response.json.return_value = response_data + mock_httpx_client.get.return_value = mock_response + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + with pytest.raises(SalesforceToolExecutionError) as exc_info: + await client.get_account(account_id) + assert exc_info.value.errors == ["Bad request"] + + +@pytest.mark.asyncio +async def test_get_account_internal_server_error(mock_context, mock_httpx_client): + account_id = "001gK000003DIn0QAG" + response_data = [{"message": "Internal server error"}] + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 500 + mock_response.json.return_value = response_data + mock_httpx_client.get.return_value = mock_response + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + with pytest.raises(SalesforceToolExecutionError) as exc_info: + await client.get_account(account_id) + assert exc_info.value.errors == ["Internal server error"] + + +@pytest.mark.asyncio +async def test_create_contact(mock_context, mock_httpx_client): + account_id = "001gK000003DIn0QAG" + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_httpx_client.post.return_value = mock_response + + response = await client.create_contact( + account_id=account_id, + last_name="Doe", + first_name="John", + email="john.doe@acme.net", + ) + + assert response == mock_response.json.return_value + + mock_httpx_client.post.assert_called_once_with( + url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Contact", + headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, + json={ + "AccountId": account_id, + "FirstName": "John", + "LastName": "Doe", + "Email": "john.doe@acme.net", + }, + ) + + +@pytest.mark.asyncio +async def test_get_related_objects_success(mock_context, mock_httpx_client): + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + + parent_object_id = "001gK000003DIn0QAG" + parent_object = SalesforceObject.ACCOUNT + child_object = SalesforceObject.CONTACT + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"records": []} + mock_httpx_client.get.return_value = mock_response + + response = await client._get_related_objects( + child_object_type=child_object, + parent_object_type=parent_object, + parent_object_id=parent_object_id, + limit=10, + ) + + assert response == [] + + mock_httpx_client.get.assert_called_once_with( + url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}", + headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, + params={"limit": 10}, + ) + + +@pytest.mark.asyncio +async def test_get_related_objects_not_found_error(mock_context, mock_httpx_client): + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 404 + mock_response.json.return_value = [{"message": "Not found"}] + mock_httpx_client.get.return_value = mock_response + + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + + parent_object_id = "001gK000003DIn0QAG" + parent_object = SalesforceObject.ACCOUNT + child_object = SalesforceObject.CONTACT + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"records": []} + mock_httpx_client.get.return_value = mock_response + + response = await client._get_related_objects( + child_object_type=child_object, + parent_object_type=parent_object, + parent_object_id=parent_object_id, + limit=10, + ) + + assert response == [] + + mock_httpx_client.get.assert_called_once_with( + url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}", + headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, + params={"limit": 10}, + ) + + +@pytest.mark.asyncio +@patch("arcade_salesforce.models.build_soql_query") +async def test_get_notes_success(mock_build_soql_query, mock_context, mock_httpx_client): + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + + note_data = { + "Id": "003gK000003DIn0QAG", + "Title": "Note 1", + "Body": "Note 1 body", + "OwnerId": "005gK000003DIn0QAG", + "CreatedById": "005gK000003DIn0QAG", + "CreatedDate": "2025-01-01T00:00:00Z", + "attributes": { + "type": "Note", + }, + } + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = {"records": [note_data]} + mock_httpx_client.get.return_value = mock_response + + parent_id = "003gK000003DIn0QAG" + response = await client.get_notes(parent_id=parent_id, limit=10) + assert response == [ + { + "Id": note_data["Id"], + "Title": note_data["Title"], + "Body": note_data["Body"], + "OwnerId": note_data["OwnerId"], + "ObjectType": SalesforceObject.NOTE.value, + } + ] + + mock_httpx_client.get.assert_called_once_with( + url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/query", + headers={"Authorization": f"Bearer {mock_context.authorization.token}"}, + params={"q": mock_build_soql_query.return_value}, + ) + + mock_build_soql_query.assert_called_once_with( + "SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate " + "FROM Note " + "WHERE ParentId = '{parent_id}' " + "LIMIT {limit}", + parent_id=parent_id, + limit=10, + ) + + +@pytest.mark.asyncio +@patch("arcade_salesforce.models.build_soql_query") +async def test_get_notes_not_found_error(mock_build_soql_query, mock_context, mock_httpx_client): + client = SalesforceClient( + auth_token=mock_context.authorization.token, + org_domain="org_domain", + ) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 404 + mock_response.json.return_value = [{"message": "Not found"}] + mock_httpx_client.get.return_value = mock_response + + parent_id = "003gK000003DIn0QAG" + with pytest.raises(ResourceNotFoundError) as exc_info: + await client.get_notes(parent_id=parent_id, limit=10) + assert exc_info.value.errors == ["Not found"]