Salesforce toolkit (#360)
Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
This commit is contained in:
parent
eb6b7f3987
commit
e1c293ee4f
20 changed files with 1846 additions and 0 deletions
18
toolkits/salesforce/.pre-commit-config.yaml
Normal file
18
toolkits/salesforce/.pre-commit-config.yaml
Normal file
|
|
@ -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
|
||||||
46
toolkits/salesforce/.ruff.toml
Normal file
46
toolkits/salesforce/.ruff.toml
Normal file
|
|
@ -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
|
||||||
21
toolkits/salesforce/LICENSE
Normal file
21
toolkits/salesforce/LICENSE
Normal file
|
|
@ -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.
|
||||||
58
toolkits/salesforce/Makefile
Normal file
58
toolkits/salesforce/Makefile
Normal file
|
|
@ -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
|
||||||
0
toolkits/salesforce/arcade_salesforce/__init__.py
Normal file
0
toolkits/salesforce/arcade_salesforce/__init__.py
Normal file
33
toolkits/salesforce/arcade_salesforce/constants.py
Normal file
33
toolkits/salesforce/arcade_salesforce/constants.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
21
toolkits/salesforce/arcade_salesforce/enums.py
Normal file
21
toolkits/salesforce/arcade_salesforce/enums.py
Normal file
|
|
@ -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"
|
||||||
22
toolkits/salesforce/arcade_salesforce/exceptions.py
Normal file
22
toolkits/salesforce/arcade_salesforce/exceptions.py
Normal file
|
|
@ -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)
|
||||||
495
toolkits/salesforce/arcade_salesforce/models.py
Normal file
495
toolkits/salesforce/arcade_salesforce/models.py
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
11
toolkits/salesforce/arcade_salesforce/tools/__init__.py
Normal file
11
toolkits/salesforce/arcade_salesforce/tools/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
126
toolkits/salesforce/arcade_salesforce/tools/crm/account.py
Normal file
126
toolkits/salesforce/arcade_salesforce/tools/crm/account.py
Normal file
|
|
@ -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}
|
||||||
64
toolkits/salesforce/arcade_salesforce/tools/crm/contact.py
Normal file
64
toolkits/salesforce/arcade_salesforce/tools/crm/contact.py
Normal file
|
|
@ -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")}
|
||||||
279
toolkits/salesforce/arcade_salesforce/utils.py
Normal file
279
toolkits/salesforce/arcade_salesforce/utils.py
Normal file
|
|
@ -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}
|
||||||
16
toolkits/salesforce/conftest.py
Normal file
16
toolkits/salesforce/conftest.py
Normal file
|
|
@ -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
|
||||||
102
toolkits/salesforce/evals/eval_create_contact.py
Normal file
102
toolkits/salesforce/evals/eval_create_contact.py
Normal file
|
|
@ -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
|
||||||
222
toolkits/salesforce/evals/eval_get_account_data.py
Normal file
222
toolkits/salesforce/evals/eval_get_account_data.py
Normal file
|
|
@ -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
|
||||||
42
toolkits/salesforce/pyproject.toml
Normal file
42
toolkits/salesforce/pyproject.toml
Normal file
|
|
@ -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 <dev@arcade.dev>"]
|
||||||
|
|
||||||
|
[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
|
||||||
0
toolkits/salesforce/tests/test_create_contact.py
Normal file
0
toolkits/salesforce/tests/test_create_contact.py
Normal file
0
toolkits/salesforce/tests/test_get_account_data.py
Normal file
0
toolkits/salesforce/tests/test_get_account_data.py
Normal file
270
toolkits/salesforce/tests/test_salesforce_client.py
Normal file
270
toolkits/salesforce/tests/test_salesforce_client.py
Normal file
|
|
@ -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"]
|
||||||
Loading…
Reference in a new issue