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