Hubspot toolkit (#363)
Reviewer:
1. DM me to get a client ID/secret for the Hubspot app and login
credentials for a sample Hubspot account
2. For now, you'll need to run the engine on the branch
`nate/token-introspection` for the auth flow to work with Hubspot
3. Add the following to `auth.providers` in your engine yaml:
```yaml
- id: arcade-hubspot
description: 'Hubspot provider'
enabled: true
type: oauth2
provider_id: hubspot
client_id: ${env:HUBSPOT_CLIENT_ID}
client_secret: ${env:HUBSPOT_CLIENT_SECRET}
```
This commit is contained in:
parent
52807bfd0e
commit
de227c5f51
21 changed files with 1800 additions and 0 deletions
|
|
@ -2,10 +2,12 @@ arcade-code-sandbox
|
||||||
arcade-dropbox
|
arcade-dropbox
|
||||||
arcade-github
|
arcade-github
|
||||||
arcade-google
|
arcade-google
|
||||||
|
arcade-hubspot
|
||||||
arcade-linkedin
|
arcade-linkedin
|
||||||
arcade-math
|
arcade-math
|
||||||
arcade-notion-toolkit
|
arcade-notion-toolkit
|
||||||
arcade-reddit
|
arcade-reddit
|
||||||
|
arcade-salesforce
|
||||||
arcade-search
|
arcade-search
|
||||||
arcade-slack
|
arcade-slack
|
||||||
arcade-spotify
|
arcade-spotify
|
||||||
|
|
|
||||||
18
toolkits/hubspot/.pre-commit-config.yaml
Normal file
18
toolkits/hubspot/.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/hubspot/.ruff.toml
Normal file
46
toolkits/hubspot/.ruff.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
target-version = "py39"
|
||||||
|
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/hubspot/LICENSE
Normal file
21
toolkits/hubspot/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.
|
||||||
53
toolkits/hubspot/Makefile
Normal file
53
toolkits/hubspot/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
.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; then \
|
||||||
|
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/hubspot/arcade_hubspot/__init__.py
Normal file
0
toolkits/hubspot/arcade_hubspot/__init__.py
Normal file
17
toolkits/hubspot/arcade_hubspot/constants.py
Normal file
17
toolkits/hubspot/arcade_hubspot/constants.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
HUBSPOT_BASE_URL = "https://api.hubapi.com"
|
||||||
|
HUBSPOT_CRM_BASE_URL = f"{HUBSPOT_BASE_URL}/crm"
|
||||||
|
HUBSPOT_DEFAULT_API_VERSION = "v3"
|
||||||
|
|
||||||
|
try:
|
||||||
|
HUBSPOT_MAX_CONCURRENT_REQUESTS = int(os.getenv("HUBSPOT_MAX_CONCURRENT_REQUESTS", 3))
|
||||||
|
except ValueError:
|
||||||
|
HUBSPOT_MAX_CONCURRENT_REQUESTS = 3
|
||||||
|
|
||||||
|
GLOBALLY_IGNORED_FIELDS = [
|
||||||
|
"createdate",
|
||||||
|
"hs_createdate",
|
||||||
|
"hs_lastmodifieddate",
|
||||||
|
"lastmodifieddate",
|
||||||
|
]
|
||||||
9
toolkits/hubspot/arcade_hubspot/custom_critics.py
Normal file
9
toolkits/hubspot/arcade_hubspot/custom_critics.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arcade.sdk.eval import BinaryCritic
|
||||||
|
|
||||||
|
|
||||||
|
class ValueInListCritic(BinaryCritic):
|
||||||
|
def evaluate(self, expected: list[Any], actual: Any) -> dict[str, float | bool]:
|
||||||
|
match = actual in expected
|
||||||
|
return {"match": match, "score": self.weight if match else 0.0}
|
||||||
20
toolkits/hubspot/arcade_hubspot/enums.py
Normal file
20
toolkits/hubspot/arcade_hubspot/enums.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class HubspotObject(Enum):
|
||||||
|
ACCOUNT = "account"
|
||||||
|
CALL = "call"
|
||||||
|
COMMUNICATION = "communication"
|
||||||
|
COMPANY = "company"
|
||||||
|
CONTACT = "contact"
|
||||||
|
DEAL = "deal"
|
||||||
|
EMAIL = "email"
|
||||||
|
MEETING = "meeting"
|
||||||
|
NOTE = "note"
|
||||||
|
TASK = "task"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plural(self) -> str:
|
||||||
|
if self.value == "company":
|
||||||
|
return "companies"
|
||||||
|
return f"{self.value}s"
|
||||||
9
toolkits/hubspot/arcade_hubspot/exceptions.py
Normal file
9
toolkits/hubspot/arcade_hubspot/exceptions.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from arcade.sdk.errors import ToolExecutionError
|
||||||
|
|
||||||
|
|
||||||
|
class HubspotToolExecutionError(ToolExecutionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(HubspotToolExecutionError):
|
||||||
|
pass
|
||||||
228
toolkits/hubspot/arcade_hubspot/models.py
Normal file
228
toolkits/hubspot/arcade_hubspot/models.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from arcade_hubspot.constants import (
|
||||||
|
HUBSPOT_CRM_BASE_URL,
|
||||||
|
HUBSPOT_DEFAULT_API_VERSION,
|
||||||
|
HUBSPOT_MAX_CONCURRENT_REQUESTS,
|
||||||
|
)
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
from arcade_hubspot.exceptions import HubspotToolExecutionError, NotFoundError
|
||||||
|
from arcade_hubspot.properties import get_object_properties
|
||||||
|
from arcade_hubspot.utils import clean_data, prepare_api_search_response, remove_none_values
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HubspotCrmClient:
|
||||||
|
auth_token: str
|
||||||
|
base_url: str = HUBSPOT_CRM_BASE_URL
|
||||||
|
max_concurrent_requests: int = HUBSPOT_MAX_CONCURRENT_REQUESTS
|
||||||
|
_semaphore: asyncio.Semaphore | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests)
|
||||||
|
|
||||||
|
def _raise_for_status(self, response: httpx.Response) -> None:
|
||||||
|
if response.status_code < 300:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
error_message = data["message"]
|
||||||
|
developer_message = json.dumps(data["errors"])
|
||||||
|
except Exception:
|
||||||
|
error_message = response.text
|
||||||
|
developer_message = None
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise NotFoundError(error_message, developer_message)
|
||||||
|
|
||||||
|
raise HubspotToolExecutionError(error_message, developer_message)
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
api_version: str = HUBSPOT_DEFAULT_API_VERSION,
|
||||||
|
) -> dict:
|
||||||
|
headers = headers or {}
|
||||||
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"url": f"{self.base_url}/{api_version}/{endpoint}",
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(params, dict):
|
||||||
|
kwargs["params"] = params
|
||||||
|
|
||||||
|
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
|
||||||
|
response = await client.get(**kwargs) # type: ignore[arg-type]
|
||||||
|
self._raise_for_status(response)
|
||||||
|
return cast(dict, response.json())
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
data: Optional[dict] = None,
|
||||||
|
json_data: Optional[dict] = None,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
api_version: str = HUBSPOT_DEFAULT_API_VERSION,
|
||||||
|
) -> dict:
|
||||||
|
headers = headers or {}
|
||||||
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"url": f"{self.base_url}/{api_version}/{endpoint}",
|
||||||
|
"headers": headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if data and json_data:
|
||||||
|
raise ValueError("Cannot provide both data and json_data")
|
||||||
|
|
||||||
|
if data:
|
||||||
|
kwargs["data"] = data
|
||||||
|
|
||||||
|
elif json_data:
|
||||||
|
kwargs["json"] = json_data
|
||||||
|
|
||||||
|
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
|
||||||
|
response = await client.post(**kwargs) # type: ignore[arg-type]
|
||||||
|
self._raise_for_status(response)
|
||||||
|
return cast(dict, response.json())
|
||||||
|
|
||||||
|
async def get_associated_objects(
|
||||||
|
self,
|
||||||
|
parent_object: HubspotObject,
|
||||||
|
parent_id: str,
|
||||||
|
associated_object: HubspotObject,
|
||||||
|
limit: int = 10,
|
||||||
|
after: Optional[str] = None,
|
||||||
|
properties: Optional[list[str]] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
endpoint = (
|
||||||
|
f"objects/{parent_object.value}/{parent_id}/associations/{associated_object.value}"
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
if after:
|
||||||
|
params["after"] = after # type: ignore[assignment]
|
||||||
|
|
||||||
|
response = await self.get(endpoint, params=params, api_version="v4")
|
||||||
|
|
||||||
|
if not response["results"]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return await self.batch_get_objects(
|
||||||
|
object_type=associated_object,
|
||||||
|
object_ids=[object_data["toObjectId"] for object_data in response["results"]],
|
||||||
|
properties=properties or get_object_properties(associated_object),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_object_by_id(
|
||||||
|
self,
|
||||||
|
object_type: HubspotObject,
|
||||||
|
object_id: str,
|
||||||
|
properties: Optional[list[str]] = None,
|
||||||
|
) -> dict:
|
||||||
|
endpoint = f"objects/{object_type.plural}/{object_id}"
|
||||||
|
params = {}
|
||||||
|
if properties:
|
||||||
|
params["properties"] = properties
|
||||||
|
return clean_data(await self.get(endpoint, params=params), object_type)
|
||||||
|
|
||||||
|
async def batch_get_objects(
|
||||||
|
self,
|
||||||
|
object_type: HubspotObject,
|
||||||
|
object_ids: list[str],
|
||||||
|
properties: Optional[list[str]] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
endpoint = f"objects/{object_type.plural}/batch/read"
|
||||||
|
data: dict[str, Any] = {"inputs": [{"id": object_id} for object_id in object_ids]}
|
||||||
|
if properties:
|
||||||
|
data["properties"] = properties
|
||||||
|
response = await self.post(endpoint, json_data=data)
|
||||||
|
return [clean_data(object_data, object_type) for object_data in response["results"]]
|
||||||
|
|
||||||
|
async def search_by_keywords(
|
||||||
|
self,
|
||||||
|
object_type: HubspotObject,
|
||||||
|
keywords: str,
|
||||||
|
limit: int = 10,
|
||||||
|
next_page_token: Optional[str] = None,
|
||||||
|
associations: Optional[list[HubspotObject]] = None,
|
||||||
|
) -> dict:
|
||||||
|
if not keywords:
|
||||||
|
raise HubspotToolExecutionError("`keywords` must be a non-empty string")
|
||||||
|
|
||||||
|
associations = associations or []
|
||||||
|
|
||||||
|
endpoint = f"objects/{object_type.plural}/search"
|
||||||
|
request_data = {
|
||||||
|
"query": keywords,
|
||||||
|
"limit": limit,
|
||||||
|
"sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "DESCENDING"}],
|
||||||
|
"properties": get_object_properties(object_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
if next_page_token:
|
||||||
|
request_data["after"] = next_page_token
|
||||||
|
|
||||||
|
data = prepare_api_search_response(
|
||||||
|
data=await self.post(endpoint, json_data=request_data),
|
||||||
|
object_type=object_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
for object_ in data[object_type.plural]:
|
||||||
|
for association in associations:
|
||||||
|
results = await self.get_associated_objects(
|
||||||
|
parent_object=object_type,
|
||||||
|
parent_id=object_["id"],
|
||||||
|
associated_object=association,
|
||||||
|
limit=10,
|
||||||
|
)
|
||||||
|
if results:
|
||||||
|
object_[association.plural] = results
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def create_contact(
|
||||||
|
self,
|
||||||
|
company_id: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: Optional[str] = None,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
phone: Optional[str] = None,
|
||||||
|
mobile_phone: Optional[str] = None,
|
||||||
|
job_title: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
request_data = {
|
||||||
|
"associations": [
|
||||||
|
{
|
||||||
|
"types": [
|
||||||
|
{
|
||||||
|
"associationCategory": "HUBSPOT_DEFINED",
|
||||||
|
"associationTypeId": "1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"to": {"id": company_id},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"properties": remove_none_values({
|
||||||
|
"firstname": first_name,
|
||||||
|
"lastname": last_name,
|
||||||
|
"email": email,
|
||||||
|
"phone": phone,
|
||||||
|
"mobilephone": mobile_phone,
|
||||||
|
"jobtitle": job_title,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
endpoint = "objects/contacts"
|
||||||
|
return await self.post(endpoint, json_data=request_data)
|
||||||
127
toolkits/hubspot/arcade_hubspot/properties.py
Normal file
127
toolkits/hubspot/arcade_hubspot/properties.py
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
|
||||||
|
HUBSPOT_OBJECT_PROPERTIES = {
|
||||||
|
HubspotObject.COMPANY: [
|
||||||
|
"type",
|
||||||
|
"name",
|
||||||
|
"about_us",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"zip",
|
||||||
|
"country",
|
||||||
|
"annualrevenue",
|
||||||
|
"hs_annual_revenue_currency_code",
|
||||||
|
"industry",
|
||||||
|
"phone",
|
||||||
|
"website",
|
||||||
|
"domain",
|
||||||
|
"numberofemployees",
|
||||||
|
"hs_lead_status",
|
||||||
|
"lifecyclestage",
|
||||||
|
"linkedin_company_page",
|
||||||
|
"twitterhandle",
|
||||||
|
],
|
||||||
|
HubspotObject.CONTACT: [
|
||||||
|
"salutation",
|
||||||
|
"email",
|
||||||
|
"work_email",
|
||||||
|
"hs_additional_emails",
|
||||||
|
"mobilephone",
|
||||||
|
"phone",
|
||||||
|
"firstname",
|
||||||
|
"lastname",
|
||||||
|
"lifecyclestage",
|
||||||
|
"annualrevenue",
|
||||||
|
"address",
|
||||||
|
"date_of_birth",
|
||||||
|
"degree",
|
||||||
|
"gender",
|
||||||
|
"buying_role",
|
||||||
|
"hs_language",
|
||||||
|
"hs_lead_status",
|
||||||
|
"hs_timezone",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"hubspot_owner_name",
|
||||||
|
"job_function",
|
||||||
|
"jobtitle",
|
||||||
|
"seniority",
|
||||||
|
"industry",
|
||||||
|
"twitterhandle",
|
||||||
|
],
|
||||||
|
HubspotObject.CALL: [
|
||||||
|
"hs_body_preview",
|
||||||
|
"hs_call_direction",
|
||||||
|
"hs_call_status",
|
||||||
|
"hs_call_summary",
|
||||||
|
"hs_call_title",
|
||||||
|
"hs_createdate",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"hs_call_disposition",
|
||||||
|
"hs_timestamp",
|
||||||
|
],
|
||||||
|
HubspotObject.DEAL: [
|
||||||
|
"dealname",
|
||||||
|
"dealstage",
|
||||||
|
"dealtype",
|
||||||
|
"closedate",
|
||||||
|
"closed_lost_reason",
|
||||||
|
"closed_won_reason",
|
||||||
|
"hs_is_closed_won",
|
||||||
|
"hs_is_closed_lost",
|
||||||
|
"hs_closed_amount",
|
||||||
|
"pipeline",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"description",
|
||||||
|
"hs_deal_score",
|
||||||
|
"amount",
|
||||||
|
"hs_forecast_probability",
|
||||||
|
"hs_forecast_amount",
|
||||||
|
],
|
||||||
|
HubspotObject.EMAIL: [
|
||||||
|
"hs_object_id",
|
||||||
|
"hs_body_preview",
|
||||||
|
"hs_email_sender_raw",
|
||||||
|
"hs_email_from_raw",
|
||||||
|
"hs_email_to_raw",
|
||||||
|
"hs_email_bcc_raw",
|
||||||
|
"hs_email_cc_raw",
|
||||||
|
"hs_email_subject",
|
||||||
|
"hs_email_status",
|
||||||
|
"hs_timestamp",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"hs_email_associated_contact_id",
|
||||||
|
],
|
||||||
|
HubspotObject.MEETING: [
|
||||||
|
"hs_object_id",
|
||||||
|
"hs_meeting_title",
|
||||||
|
"hs_body_preview",
|
||||||
|
"hs_meeting_location",
|
||||||
|
"hs_meeting_outcome",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"hs_meeting_start_time",
|
||||||
|
"hs_meeting_end_time",
|
||||||
|
],
|
||||||
|
HubspotObject.NOTE: [
|
||||||
|
"hs_object_id",
|
||||||
|
"hs_body_preview",
|
||||||
|
"hs_timestamp",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
],
|
||||||
|
HubspotObject.TASK: [
|
||||||
|
"hs_object_id",
|
||||||
|
"hs_body_preview",
|
||||||
|
"hs_timestamp",
|
||||||
|
"hubspot_owner_id",
|
||||||
|
"hs_associated_contact_labels",
|
||||||
|
"hs_task_is_overdue",
|
||||||
|
"hs_task_priority",
|
||||||
|
"hs_task_status",
|
||||||
|
"hs_task_subject",
|
||||||
|
"hs_task_type",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_object_properties(object_type: HubspotObject) -> list[str]:
|
||||||
|
return HUBSPOT_OBJECT_PROPERTIES[object_type]
|
||||||
8
toolkits/hubspot/arcade_hubspot/tools/__init__.py
Normal file
8
toolkits/hubspot/arcade_hubspot/tools/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from arcade_hubspot.tools.crm.companies import get_company_data_by_keywords
|
||||||
|
from arcade_hubspot.tools.crm.contacts import create_contact, get_contact_data_by_keywords
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_company_data_by_keywords",
|
||||||
|
"get_contact_data_by_keywords",
|
||||||
|
"create_contact",
|
||||||
|
]
|
||||||
63
toolkits/hubspot/arcade_hubspot/tools/crm/companies.py
Normal file
63
toolkits/hubspot/arcade_hubspot/tools/crm/companies.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from typing import Annotated, Any, Optional
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import OAuth2
|
||||||
|
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
from arcade_hubspot.models import HubspotCrmClient
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=OAuth2(
|
||||||
|
id="hubspot",
|
||||||
|
scopes=[
|
||||||
|
"oauth",
|
||||||
|
"crm.objects.companies.read",
|
||||||
|
"crm.objects.contacts.read",
|
||||||
|
"crm.objects.deals.read",
|
||||||
|
"sales-email-read",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_company_data_by_keywords(
|
||||||
|
context: ToolContext,
|
||||||
|
keywords: Annotated[
|
||||||
|
str,
|
||||||
|
"The keywords to search for companies. It will match against the company name, phone, "
|
||||||
|
"and website.",
|
||||||
|
],
|
||||||
|
limit: Annotated[
|
||||||
|
int, "The maximum number of companies to return. Defaults to 10. Max is 10."
|
||||||
|
] = 10,
|
||||||
|
next_page_token: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
"The token to get the next page of results. "
|
||||||
|
"Defaults to None (returns first page of results)",
|
||||||
|
] = None,
|
||||||
|
) -> Annotated[
|
||||||
|
dict[str, Any],
|
||||||
|
"Retrieve company data with associated contacts, deals, calls, emails, "
|
||||||
|
"meetings, notes, and tasks.",
|
||||||
|
]:
|
||||||
|
"""Retrieve company data with associated contacts, deals, calls, emails,
|
||||||
|
meetings, notes, and tasks.
|
||||||
|
|
||||||
|
This tool will return up to 10 items of each associated object (contacts, leads, etc).
|
||||||
|
"""
|
||||||
|
limit = min(limit, 10)
|
||||||
|
client = HubspotCrmClient(context.get_auth_token_or_empty())
|
||||||
|
return await client.search_by_keywords(
|
||||||
|
object_type=HubspotObject.COMPANY,
|
||||||
|
keywords=keywords,
|
||||||
|
limit=limit,
|
||||||
|
next_page_token=next_page_token,
|
||||||
|
associations=[
|
||||||
|
HubspotObject.CALL,
|
||||||
|
HubspotObject.CONTACT,
|
||||||
|
HubspotObject.DEAL,
|
||||||
|
HubspotObject.EMAIL,
|
||||||
|
HubspotObject.MEETING,
|
||||||
|
HubspotObject.NOTE,
|
||||||
|
HubspotObject.TASK,
|
||||||
|
],
|
||||||
|
)
|
||||||
91
toolkits/hubspot/arcade_hubspot/tools/crm/contacts.py
Normal file
91
toolkits/hubspot/arcade_hubspot/tools/crm/contacts.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
from typing import Annotated, Any, Optional
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import OAuth2
|
||||||
|
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
from arcade_hubspot.models import HubspotCrmClient
|
||||||
|
from arcade_hubspot.utils import clean_data
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=OAuth2(
|
||||||
|
id="hubspot",
|
||||||
|
scopes=[
|
||||||
|
"oauth",
|
||||||
|
"crm.objects.contacts.read",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_contact_data_by_keywords(
|
||||||
|
context: ToolContext,
|
||||||
|
keywords: Annotated[
|
||||||
|
str,
|
||||||
|
"The keywords to search for contacts. It will match against the contact's "
|
||||||
|
"first and last name, email addresses, phone numbers, and company name.",
|
||||||
|
],
|
||||||
|
limit: Annotated[
|
||||||
|
int, "The maximum number of contacts to return. Defaults to 10. Max is 100."
|
||||||
|
] = 10,
|
||||||
|
next_page_token: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
"The token to get the next page of results. "
|
||||||
|
"Defaults to None (returns first page of results)",
|
||||||
|
] = None,
|
||||||
|
) -> Annotated[
|
||||||
|
dict[str, Any],
|
||||||
|
"Retrieve contact data with associated companies, deals, calls, "
|
||||||
|
"emails, meetings, notes, and tasks.",
|
||||||
|
]:
|
||||||
|
"""
|
||||||
|
Retrieve contact data with associated companies, deals, calls, emails,
|
||||||
|
meetings, notes, and tasks.
|
||||||
|
"""
|
||||||
|
limit = min(limit, 100)
|
||||||
|
client = HubspotCrmClient(context.get_auth_token_or_empty())
|
||||||
|
return await client.search_by_keywords(
|
||||||
|
object_type=HubspotObject.CONTACT,
|
||||||
|
keywords=keywords,
|
||||||
|
limit=limit,
|
||||||
|
next_page_token=next_page_token,
|
||||||
|
associations=[
|
||||||
|
HubspotObject.CALL,
|
||||||
|
HubspotObject.COMPANY,
|
||||||
|
HubspotObject.DEAL,
|
||||||
|
HubspotObject.EMAIL,
|
||||||
|
HubspotObject.MEETING,
|
||||||
|
HubspotObject.NOTE,
|
||||||
|
HubspotObject.TASK,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
requires_auth=OAuth2(
|
||||||
|
id="hubspot",
|
||||||
|
scopes=["oauth", "crm.objects.contacts.write"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def create_contact(
|
||||||
|
context: ToolContext,
|
||||||
|
company_id: Annotated[str, "The ID of the company to create the contact for."],
|
||||||
|
first_name: Annotated[str, "The first name of the contact."],
|
||||||
|
last_name: Annotated[Optional[str], "The last name of the contact."] = None,
|
||||||
|
email: Annotated[Optional[str], "The email address of the contact."] = None,
|
||||||
|
phone: Annotated[Optional[str], "The phone number of the contact."] = None,
|
||||||
|
mobile_phone: Annotated[Optional[str], "The mobile phone number of the contact."] = None,
|
||||||
|
job_title: Annotated[Optional[str], "The job title of the contact."] = None,
|
||||||
|
) -> Annotated[dict, "Create a contact associated with a company."]:
|
||||||
|
"""Create a contact associated with a company."""
|
||||||
|
client = HubspotCrmClient(context.get_auth_token_or_empty())
|
||||||
|
response = await client.create_contact(
|
||||||
|
company_id=company_id,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
email=email,
|
||||||
|
phone=phone,
|
||||||
|
mobile_phone=mobile_phone,
|
||||||
|
job_title=job_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
return clean_data(response, HubspotObject.CONTACT)
|
||||||
313
toolkits/hubspot/arcade_hubspot/utils.py
Normal file
313
toolkits/hubspot/arcade_hubspot/utils.py
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from arcade_hubspot.constants import GLOBALLY_IGNORED_FIELDS
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
|
||||||
|
|
||||||
|
def remove_none_values(data: dict) -> dict:
|
||||||
|
cleaned: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None or key in GLOBALLY_IGNORED_FIELDS:
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
cleaned_dict = remove_none_values(value)
|
||||||
|
if cleaned_dict:
|
||||||
|
cleaned[key] = cleaned_dict
|
||||||
|
elif isinstance(value, (list, tuple, set)):
|
||||||
|
collection_type = type(value)
|
||||||
|
cleaned_list = [remove_none_values(item) for item in value]
|
||||||
|
if cleaned_list:
|
||||||
|
cleaned[key] = collection_type(cleaned_list)
|
||||||
|
else:
|
||||||
|
cleaned[key] = value
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_api_search_response(data: dict, object_type: HubspotObject) -> dict:
|
||||||
|
response: dict[str, Any] = {
|
||||||
|
object_type.plural: [clean_data(company, object_type) for company in data["results"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
after = data.get("paging", {}).get("next", {}).get("after")
|
||||||
|
|
||||||
|
if after:
|
||||||
|
response["pagination"] = {
|
||||||
|
"are_there_more_results?": True,
|
||||||
|
"next_page_token": after,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
response["pagination"] = {
|
||||||
|
"are_there_more_results?": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def rename_dict_keys(data: dict, rename: dict) -> dict:
|
||||||
|
for old_key, new_key in rename.items():
|
||||||
|
if old_key in data:
|
||||||
|
data[new_key] = data[old_key]
|
||||||
|
data.pop(old_key, None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def global_cleaner(clean_func: Callable[[dict], dict]) -> Callable[[dict], dict]:
|
||||||
|
def global_cleaner(data: dict) -> dict:
|
||||||
|
cleaned_data: dict[str, Any] = {}
|
||||||
|
if "hs_object_id" in data:
|
||||||
|
cleaned_data["id"] = data["hs_object_id"]
|
||||||
|
del data["hs_object_id"]
|
||||||
|
|
||||||
|
data = rename_dict_keys(
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
"hubspot_owner_id": "owner_id",
|
||||||
|
"hs_timestamp": "datetime",
|
||||||
|
"hs_body_preview": "body",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
cleaned_data[key] = value
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def wrapper(data: dict) -> dict:
|
||||||
|
return remove_none_values(global_cleaner(clean_func(data["properties"])))
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def clean_data(data: dict, object_type: HubspotObject) -> dict:
|
||||||
|
_mapping = {
|
||||||
|
HubspotObject.CALL: clean_call_data,
|
||||||
|
HubspotObject.COMPANY: clean_company_data,
|
||||||
|
HubspotObject.CONTACT: clean_contact_data,
|
||||||
|
HubspotObject.DEAL: clean_deal_data,
|
||||||
|
HubspotObject.EMAIL: clean_email_data,
|
||||||
|
HubspotObject.MEETING: clean_meeting_data,
|
||||||
|
HubspotObject.NOTE: clean_note_data,
|
||||||
|
HubspotObject.TASK: clean_task_data,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return _mapping[object_type](data)
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"Unsupported object type: {object_type}")
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_company_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.COMPANY.value
|
||||||
|
data["website"] = data.get("website", data.get("domain"))
|
||||||
|
data.pop("domain", None)
|
||||||
|
|
||||||
|
data["address"] = {
|
||||||
|
"street": data.get("address"),
|
||||||
|
"city": data.get("city"),
|
||||||
|
"state": data.get("state"),
|
||||||
|
"zip": data.get("zip"),
|
||||||
|
"country": data.get("country"),
|
||||||
|
}
|
||||||
|
data.pop("address", None)
|
||||||
|
data.pop("city", None)
|
||||||
|
data.pop("state", None)
|
||||||
|
data.pop("zip", None)
|
||||||
|
data.pop("country", None)
|
||||||
|
|
||||||
|
data["annual_revenue"] = {
|
||||||
|
"value": data.get("annualrevenue"),
|
||||||
|
"currency": data.get("hs_annual_revenue_currency_code"),
|
||||||
|
}
|
||||||
|
data.pop("annualrevenue", None)
|
||||||
|
data.pop("hs_annual_revenue_currency_code", None)
|
||||||
|
|
||||||
|
rename = {
|
||||||
|
"linkedin_company_page": "linkedin_url",
|
||||||
|
"numberofemployees": "employee_count",
|
||||||
|
"type": "company_type",
|
||||||
|
"annualrevenue": "annual_revenue",
|
||||||
|
"lifecyclestage": "lifecycle_stage",
|
||||||
|
"hs_lead_status": "lead_status",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_contact_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.CONTACT.value
|
||||||
|
rename = {
|
||||||
|
"lifecyclestage": "lifecycle_stage",
|
||||||
|
"hs_lead_status": "lead_status",
|
||||||
|
"email": "email_address",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_deal_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.DEAL.value
|
||||||
|
|
||||||
|
if data.get("closedate") or data.get("hs_closed_amount"):
|
||||||
|
data["close"] = {
|
||||||
|
"is_closed": data.get("hs_is_closed"),
|
||||||
|
"date": data.get("closedate"),
|
||||||
|
"amount": data.get("hs_closed_amount"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get("hs_is_closed_won") in ["true", True]:
|
||||||
|
data["close"]["status"] = "won"
|
||||||
|
data["close"]["status_reason"] = data.get("closed_won_reason")
|
||||||
|
elif data.get("hs_is_closed_lost") in ["true", True]:
|
||||||
|
data["close"]["status"] = "lost"
|
||||||
|
data["close"]["status_reason"] = data.get("closed_lost_reason")
|
||||||
|
|
||||||
|
if data.get("amount"):
|
||||||
|
data["amount"] = {
|
||||||
|
"value": data["amount"],
|
||||||
|
"currency": data.get("deal_currency_code"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get("hs_forecast_probability"):
|
||||||
|
data["amount"]["forecast"] = {
|
||||||
|
"probability": data["hs_forecast_probability"],
|
||||||
|
"expected_value": data.get("hs_forecast_amount"),
|
||||||
|
}
|
||||||
|
|
||||||
|
rename = {
|
||||||
|
"dealname": "name",
|
||||||
|
"dealstage": "stage",
|
||||||
|
"dealscore": "score",
|
||||||
|
"dealtype": "type",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
|
||||||
|
data.pop("hs_is_closed", None)
|
||||||
|
data.pop("closedate", None)
|
||||||
|
data.pop("hs_closed_amount", None)
|
||||||
|
data.pop("deal_currency_code", None)
|
||||||
|
data.pop("close_won_reason", None)
|
||||||
|
data.pop("close_lost_reason", None)
|
||||||
|
data.pop("hs_is_closed_won", None)
|
||||||
|
data.pop("hs_is_closed_lost", None)
|
||||||
|
data.pop("hs_forecast_probability", None)
|
||||||
|
data.pop("hs_forecast_amount", None)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_email_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.EMAIL.value
|
||||||
|
rename = {
|
||||||
|
"hs_body_preview": "body",
|
||||||
|
"hs_email_from_raw": "from",
|
||||||
|
"hs_email_to_raw": "to",
|
||||||
|
"hs_email_bcc_raw": "bcc",
|
||||||
|
"hs_email_cc_raw": "cc",
|
||||||
|
"hs_email_subject": "subject",
|
||||||
|
"hs_email_status": "status",
|
||||||
|
"hs_email_associated_contact_id": "contact_id",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_call_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.CALL.value
|
||||||
|
rename = {
|
||||||
|
"hs_call_direction": "direction",
|
||||||
|
"hs_call_status": "status",
|
||||||
|
"hs_call_summary": "summary",
|
||||||
|
"hs_call_title": "title",
|
||||||
|
"hs_call_disposition": "outcome",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_note_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.NOTE.value
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_meeting_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.MEETING.value
|
||||||
|
rename = {
|
||||||
|
"hs_meeting_outcome": "outcome",
|
||||||
|
"hs_meeting_location": "location",
|
||||||
|
"hs_meeting_start_time": "start_time",
|
||||||
|
"hs_meeting_end_time": "end_time",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
|
||||||
|
data["content"] = {
|
||||||
|
"title": data.get("hs_meeting_title"),
|
||||||
|
"body": data.get("hs_body_preview"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pop("hs_meeting_title", None)
|
||||||
|
data.pop("hs_body_preview", None)
|
||||||
|
|
||||||
|
data["schedule"] = {
|
||||||
|
"start_time": data.get("start_time"),
|
||||||
|
"end_time": data.get("end_time"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pop("start_time", None)
|
||||||
|
data.pop("end_time", None)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@global_cleaner
|
||||||
|
def clean_task_data(data: dict) -> dict:
|
||||||
|
data["object_type"] = HubspotObject.TASK.value
|
||||||
|
|
||||||
|
if data.get("hs_task_priority") == "NONE":
|
||||||
|
data["hs_task_priority"] = None
|
||||||
|
|
||||||
|
rename = {
|
||||||
|
"hs_task_status": "status",
|
||||||
|
"hs_task_priority": "priority",
|
||||||
|
"hs_task_missed_due_date": "missed_due_date",
|
||||||
|
"hs_task_type": "task_type",
|
||||||
|
"hs_associated_contact_labels": "associated_contact",
|
||||||
|
}
|
||||||
|
data = rename_dict_keys(data, rename)
|
||||||
|
|
||||||
|
data["content"] = {
|
||||||
|
"body": data.get("hs_body_preview"),
|
||||||
|
"subject": data.get("hs_task_subject"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pop("hs_body_preview", None)
|
||||||
|
data.pop("hs_task_subject", None)
|
||||||
|
|
||||||
|
data["schedule"] = {
|
||||||
|
"datetime": data.get("hs_timestamp"),
|
||||||
|
"is_overdue": data.get("hs_task_is_overdue"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data.pop("hs_timestamp", None)
|
||||||
|
data.pop("hs_task_is_overdue", None)
|
||||||
|
|
||||||
|
return data
|
||||||
16
toolkits/hubspot/conftest.py
Normal file
16
toolkits/hubspot/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_hubspot.models.httpx") as mock_httpx:
|
||||||
|
yield mock_httpx.AsyncClient().__aenter__.return_value
|
||||||
194
toolkits/hubspot/evals/eval_crm_companies.py
Normal file
194
toolkits/hubspot/evals/eval_crm_companies.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
import arcade_hubspot
|
||||||
|
from arcade_hubspot.tools import get_company_data_by_keywords
|
||||||
|
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.8,
|
||||||
|
warn_threshold=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_module(arcade_hubspot)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def get_company_data_by_keywords_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for the get_company_data_by_keywords tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Get Company Data by Keywords",
|
||||||
|
system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.",
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get company data by keywords",
|
||||||
|
user_message="Get information about the Acme company.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get company data by keywords with limit",
|
||||||
|
user_message="Get information of up to 3 companies with the word 'Acme' in their name.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 3,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.6),
|
||||||
|
BinaryCritic(critic_field="limit", weight=0.3),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get summary of company activity",
|
||||||
|
user_message="Get a summary of the latest activities in the Acme company.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Interactions with contacts of an account",
|
||||||
|
user_message="Get me the interactions with the contacts of the Acme company.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
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 company this week?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get company status",
|
||||||
|
user_message="What's the status of the Acme company?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get overdue tasks",
|
||||||
|
user_message="Are there any tasks overdue for the Acme company?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get company closing likelihood",
|
||||||
|
user_message="What's the likelihood of the Acme company closing a deal?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_company_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Acme",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
197
toolkits/hubspot/evals/eval_crm_contacts.py
Normal file
197
toolkits/hubspot/evals/eval_crm_contacts.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
BinaryCritic,
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
ExpectedToolCall,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
|
||||||
|
import arcade_hubspot
|
||||||
|
from arcade_hubspot.custom_critics import ValueInListCritic
|
||||||
|
from arcade_hubspot.tools import create_contact, get_contact_data_by_keywords
|
||||||
|
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.8,
|
||||||
|
warn_threshold=0.9,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_module(arcade_hubspot)
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def get_contact_data_by_keywords_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for the get_contact_data_by_keywords tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Get Contact Data by Keywords",
|
||||||
|
system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.",
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get contact data by keywords",
|
||||||
|
user_message="Get information about the Jason Bourne contact.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Jason Bourne",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get contact data by keywords with limit",
|
||||||
|
user_message="Get information of up to 3 contacts with last name 'Bourne'.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Bourne",
|
||||||
|
"limit": 3,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.7),
|
||||||
|
BinaryCritic(critic_field="limit", weight=0.15),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.15),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get summary of contact activity",
|
||||||
|
user_message="Get a summary of the latest activities with the Jason Bourne contact.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": "Jason Bourne",
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Interactions with contacts of an account",
|
||||||
|
user_message="Get me the interactions with the Jason Bourne contact from Acme.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": ["Jason Bourne", "Jason Bourne Acme"],
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
ValueInListCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Emails or calls with contacts of an account",
|
||||||
|
user_message="Were there any emails or calls with the Jason Bourne contact from Acme this week?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": ["Jason Bourne", "Jason Bourne Acme"],
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
ValueInListCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Get overdue tasks",
|
||||||
|
user_message="Are there any tasks overdue for the Jason Bourne contact from Acme?",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=get_contact_data_by_keywords,
|
||||||
|
args={
|
||||||
|
"keywords": ["Jason Bourne", "Jason Bourne Acme"],
|
||||||
|
"limit": 10,
|
||||||
|
"next_page_token": None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
ValueInListCritic(critic_field="keywords", weight=0.8),
|
||||||
|
BinaryCritic(critic_field="next_page_token", weight=0.2),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def create_contact_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for the create_contact tool."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Create Contact",
|
||||||
|
system_message="You are an AI assistant that can interact with Hubspot CRM using the provided tools.",
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Create contact",
|
||||||
|
user_message="Create a contact with the following information: first name 'Jason', "
|
||||||
|
"last name 'Bourne', email 'jason.bourne@acme.com', phone '+1234567890', "
|
||||||
|
"and job title 'Unbeatable', and associated with company id '1234567890'",
|
||||||
|
expected_tool_calls=[
|
||||||
|
ExpectedToolCall(
|
||||||
|
func=create_contact,
|
||||||
|
args={
|
||||||
|
"company_id": "1234567890",
|
||||||
|
"first_name": "Jason",
|
||||||
|
"last_name": "Bourne",
|
||||||
|
"email": "jason.bourne@acme.com",
|
||||||
|
"phone": "+1234567890",
|
||||||
|
"job_title": "Unbeatable",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rubric=rubric,
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="company_id", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="first_name", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="last_name", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="email", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="phone", weight=1 / 6),
|
||||||
|
BinaryCritic(critic_field="job_title", weight=1 / 6),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
42
toolkits/hubspot/pyproject.toml
Normal file
42
toolkits/hubspot/pyproject.toml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
[tool.poetry]
|
||||||
|
name = "arcade_hubspot"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Arcade tools designed for LLMs to interact with Hubspot"
|
||||||
|
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"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
files = ["arcade_hubspot/**/*.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
|
||||||
326
toolkits/hubspot/tests/test_hubspot_client.py
Normal file
326
toolkits/hubspot/tests/test_hubspot_client.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from arcade_hubspot.enums import HubspotObject
|
||||||
|
from arcade_hubspot.exceptions import HubspotToolExecutionError, NotFoundError
|
||||||
|
from arcade_hubspot.models import HubspotCrmClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_success(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"data": []}
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
response = await client.get("objects/contacts", params={"id": "123"})
|
||||||
|
|
||||||
|
assert response == {"data": []}
|
||||||
|
mock_httpx_client.get.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v3/objects/contacts",
|
||||||
|
params={"id": "123"},
|
||||||
|
headers={"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_not_found_error(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_response.json.return_value = {"message": "Not Found", "errors": [{"message": "Not Found"}]}
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(NotFoundError) as exc_info:
|
||||||
|
await client.get("objects/contacts", params={"id": "123"})
|
||||||
|
|
||||||
|
assert exc_info.value.message == "Not Found"
|
||||||
|
assert exc_info.value.developer_message == '[{"message": "Not Found"}]'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_internal_server_error(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"message": "Internal Server Error",
|
||||||
|
"errors": [{"message": "Internal Server Error"}],
|
||||||
|
}
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(HubspotToolExecutionError) as exc_info:
|
||||||
|
await client.get("objects/contacts", params={"id": "123"})
|
||||||
|
|
||||||
|
assert exc_info.value.message == "Internal Server Error"
|
||||||
|
assert exc_info.value.developer_message == '[{"message": "Internal Server Error"}]'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_with_invalid_json_error_response(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Text error message"
|
||||||
|
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(HubspotToolExecutionError) as exc_info:
|
||||||
|
await client.get("objects/contacts", params={"id": "123"})
|
||||||
|
|
||||||
|
assert exc_info.value.message == "Text error message"
|
||||||
|
assert exc_info.value.developer_message is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_success(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"data": []}
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
response = await client.post("objects/contacts", json_data={"id": "123"})
|
||||||
|
|
||||||
|
assert response == {"data": []}
|
||||||
|
mock_httpx_client.post.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v3/objects/contacts",
|
||||||
|
json={"id": "123"},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_not_found_error(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 404
|
||||||
|
mock_response.json.return_value = {"message": "Not Found", "errors": [{"message": "Not Found"}]}
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(NotFoundError) as exc_info:
|
||||||
|
await client.post("objects/contacts", json_data={"id": "123"})
|
||||||
|
|
||||||
|
assert exc_info.value.message == "Not Found"
|
||||||
|
assert exc_info.value.developer_message == '[{"message": "Not Found"}]'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_internal_server_error(mock_context, mock_httpx_client):
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"message": "Internal Server Error",
|
||||||
|
"errors": [{"message": "Internal Server Error"}],
|
||||||
|
}
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(HubspotToolExecutionError) as exc_info:
|
||||||
|
await client.post("objects/contacts", json_data={"id": "123"})
|
||||||
|
|
||||||
|
assert exc_info.value.message == "Internal Server Error"
|
||||||
|
assert exc_info.value.developer_message == '[{"message": "Internal Server Error"}]'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("arcade_hubspot.models.clean_data")
|
||||||
|
async def test_batch_get_objects(mock_clean_data, mock_context, mock_httpx_client):
|
||||||
|
mock_results = [MagicMock(spec=dict) for _ in range(3)]
|
||||||
|
|
||||||
|
clean_data_response = [MagicMock(spec=dict) for _ in range(3)]
|
||||||
|
mock_clean_data.side_effect = clean_data_response
|
||||||
|
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": mock_results,
|
||||||
|
}
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
response = await client.batch_get_objects(
|
||||||
|
HubspotObject.CONTACT, ["123", "456"], ["email", "firstname"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == clean_data_response
|
||||||
|
mock_clean_data.assert_has_calls([
|
||||||
|
call(result, HubspotObject.CONTACT) for result in mock_results
|
||||||
|
])
|
||||||
|
|
||||||
|
mock_httpx_client.post.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v3/objects/contacts/batch/read",
|
||||||
|
json={
|
||||||
|
"inputs": [{"id": "123"}, {"id": "456"}],
|
||||||
|
"properties": ["email", "firstname"],
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("arcade_hubspot.models.clean_data")
|
||||||
|
async def test_get_associated_objects(mock_clean_data, mock_context, mock_httpx_client):
|
||||||
|
# Mock stuff from the batch_get_objects function
|
||||||
|
mock_batch_results = [MagicMock(spec=dict) for _ in range(3)]
|
||||||
|
clean_data_response = [MagicMock(spec=dict) for _ in range(3)]
|
||||||
|
mock_clean_data.side_effect = clean_data_response
|
||||||
|
mock_post_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_post_response.status_code = 200
|
||||||
|
mock_post_response.json.return_value = {
|
||||||
|
"results": mock_batch_results,
|
||||||
|
}
|
||||||
|
mock_httpx_client.post.return_value = mock_post_response
|
||||||
|
|
||||||
|
# Mock stuff from the get_associated_objects function
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"results": [{"toObjectId": "123"}, {"toObjectId": "456"}],
|
||||||
|
}
|
||||||
|
mock_httpx_client.get.return_value = mock_response
|
||||||
|
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
response = await client.get_associated_objects(
|
||||||
|
parent_object=HubspotObject.COMPANY,
|
||||||
|
parent_id="parent_id",
|
||||||
|
associated_object=HubspotObject.CONTACT,
|
||||||
|
limit=5,
|
||||||
|
after=5,
|
||||||
|
properties=["email", "firstname"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == clean_data_response
|
||||||
|
mock_clean_data.assert_has_calls([
|
||||||
|
call(result, HubspotObject.CONTACT) for result in mock_batch_results
|
||||||
|
])
|
||||||
|
|
||||||
|
mock_httpx_client.get.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v4/objects/company/parent_id/associations/contact",
|
||||||
|
params={
|
||||||
|
"limit": 5,
|
||||||
|
"after": 5,
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_httpx_client.post.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v3/objects/contacts/batch/read",
|
||||||
|
json={
|
||||||
|
"inputs": [{"id": "123"}, {"id": "456"}],
|
||||||
|
"properties": ["email", "firstname"],
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("arcade_hubspot.models.prepare_api_search_response")
|
||||||
|
@patch("arcade_hubspot.models.get_object_properties")
|
||||||
|
async def test_search_by_keywords(
|
||||||
|
mock_get_object_properties,
|
||||||
|
mock_prepare_api_search_response,
|
||||||
|
mock_context,
|
||||||
|
mock_httpx_client,
|
||||||
|
):
|
||||||
|
mock_response = MagicMock(spec=httpx.Response)
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_httpx_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
mock_prepared_api_response = {
|
||||||
|
"contacts": [
|
||||||
|
{
|
||||||
|
"id": "123",
|
||||||
|
"first_name": "Jason",
|
||||||
|
"last_name": "Bourne",
|
||||||
|
"email": "jason.bourne@acme.com",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_prepare_api_search_response.return_value = mock_prepared_api_response
|
||||||
|
mock_get_object_properties.return_value = ["email", "firstname"]
|
||||||
|
|
||||||
|
mock_calls = [{"id": f"call_{i}"} for i in range(3)]
|
||||||
|
mock_emails = [{"id": f"email_{i}"} for i in range(3)]
|
||||||
|
mock_notes = [{"id": f"note_{i}"} for i in range(3)]
|
||||||
|
|
||||||
|
mock_get_associated_objects = AsyncMock()
|
||||||
|
mock_get_associated_objects.side_effect = [mock_calls, mock_emails, mock_notes]
|
||||||
|
|
||||||
|
client = HubspotCrmClient(mock_context.get_auth_token_or_empty())
|
||||||
|
client.get_associated_objects = mock_get_associated_objects
|
||||||
|
|
||||||
|
response = await client.search_by_keywords(
|
||||||
|
object_type=HubspotObject.CONTACT,
|
||||||
|
keywords="test",
|
||||||
|
limit=10,
|
||||||
|
associations=[
|
||||||
|
HubspotObject.CALL,
|
||||||
|
HubspotObject.EMAIL,
|
||||||
|
HubspotObject.NOTE,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_response = deepcopy(mock_prepared_api_response)
|
||||||
|
expected_response["contacts"][0]["calls"] = mock_calls
|
||||||
|
expected_response["contacts"][0]["emails"] = mock_emails
|
||||||
|
expected_response["contacts"][0]["notes"] = mock_notes
|
||||||
|
|
||||||
|
assert response == expected_response
|
||||||
|
|
||||||
|
mock_httpx_client.post.assert_called_once_with(
|
||||||
|
url="https://api.hubapi.com/crm/v3/objects/contacts/search",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {mock_context.get_auth_token_or_empty()}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"query": "test",
|
||||||
|
"limit": 10,
|
||||||
|
"sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "DESCENDING"}],
|
||||||
|
"properties": ["email", "firstname"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_associated_objects.assert_has_calls([
|
||||||
|
call(
|
||||||
|
parent_object=HubspotObject.CONTACT,
|
||||||
|
parent_id="123",
|
||||||
|
associated_object=HubspotObject.CALL,
|
||||||
|
limit=10,
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
parent_object=HubspotObject.CONTACT,
|
||||||
|
parent_id="123",
|
||||||
|
associated_object=HubspotObject.EMAIL,
|
||||||
|
limit=10,
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
parent_object=HubspotObject.CONTACT,
|
||||||
|
parent_id="123",
|
||||||
|
associated_object=HubspotObject.NOTE,
|
||||||
|
limit=10,
|
||||||
|
),
|
||||||
|
])
|
||||||
Loading…
Reference in a new issue